一件一屏:用 AIUI 把「快递上门」做成一条会自己往下走的任务线

0 阅读35分钟

本文参与 Rokid AIUI 十月征文。项目名「一件一屏」,代码 8 个文件、1200 余行。文中截图与录屏均来自 AIUI Studio「真机模拟」页面,验证方法与边界见第 8 节。

摘要

快递员的手从来不是空的。这篇记录我把「上门签收」做成 AIUI 智能体的全过程:意图怎么拆成三层、八个状态怎么砍出来、镜腿四个手势怎么分配、透明叠加层上怎么做可读性,以及没有眼镜时我怎么验证它。含七个 bug 的定位过程。样例数据虚构。

一、项目简介

冬天送件最难受的不是冷,是手套。

一只手套着,另一只手拎着几件快递,站在单元门口要确认「这户是 501 还是 502、要不要本人签收、上一件放柜的取件码发没发出去」。手机在兜里,掏出来要摘手套,解锁,进 App,找单号,退出来,再戴上。五件快递,这个动作重复五次。

我做过很多年后端系统,习惯把这类问题归因到「系统不好用」。但站在门口那两分钟我想明白了一件事:问题不在系统,在入口。手被占着的时候,任何需要「伸手去点」的方案都不成立。

眼镜是唯一不需要手的形态。所以我想试试,能不能把「签收」这件事,做成一条不用手也能走完的线。

待命屏:今天 3 件

S0 待命。整屏只有一句「今天 3 件」和一个「开始」。没写操作说明,也没写「点击镜腿」——戴上去的人第一秒只需要知道「它知道我手里有几件」。背景是模拟器自带街景,透明叠加层就叠在这种画面上,这也解释了后面第 5 节为什么深色字不能写。

它解决的是「腾不出手」。目标用户是所有「一手拿东西、一手要操作」的现场岗位:快递员、上门装维、巡检、送水送药。产品定义可以压成一句话:先说一批件,眼镜再逐件告诉你「这一件现在该做什么」。

先划清边界:不接真实业务系统,不替代签收系统,不采集任何客户生物信息,演示数据全部虚构,签收结果只落本地。它是一个形态验证。

二、场景与用户意图

2.1 意图不是一次识别,是三层

大部分 AI 眼镜上的智能体,把「一句话」当成终点:用户问,它答,交互结束。查询类场景这样没问题,但签收不是查询,签收是一串必须按顺序完成的动作,中间任何一步都可能变卦——客户忽然开门了,箱子其实破了,人不在但邻居能代收。

所以我没把「意图」当成一次识别,而是拆成三层:

  • L0 会话意图——「今天这 3 件送完」。它决定要不要开一条任务线,以及这条线的边界在哪。
  • L1 单件意图——「这一件怎么处置」。它决定现在这张屏长什么样。
  • L2 异常意图——「为什么送不掉,怎么留证」。它决定走哪条分支,哪些字段变成必填。

拆开之后,界面的问题就变成一个可计算的问题:当前处在哪一层、哪一个节点,这一屏只应该出现哪个动作。

2.2 为什么最后没做成聊天框

我试过最省事的路:一个聊天框,用户说什么就回什么。两天就放弃了。

原因很具体。聊天框把「当前进度」藏在了对话历史里。做到第 3 件想问「还剩几件」,得往上翻;网络卡一下,他甚至不知道刚才那句「放柜吧」到底生效没有。而签收这件事最不能容忍的就是「不知道生效没有」。

界面必须时刻回答三个问题:这是第几件、这件现在该做什么、我刚才那个动作成了没有。 聊天框答不了,屏幕可以。

意图三层:L0 会话 / L1 单件 / L2 异常

三层不是三个聊天框,是三次判断。同一句「这户没人」,在 L0 是「今天送不完」、在 L1 是「这屏该出现哪个处置」、在 L2 是「走哪条分支、照片变必填」。右边那列是判断的落点,每一层最后都要落到具体某一屏上,落不下去的层就是空谈。

2.3 意图落到界面上

层用户表达系统判断界面响应
L0「今天这 3 件」建会话、装载清单S1 备件清单(3 条,可滑)
L1单击清单某件定位到具体件S2 识别屏(运单号、楼栋、要求)
L1单击「拍照识别」进入处置S3 处置屏(4 个选项)
L1选「本人签收」进取证S4 取证屏(必拍 1 张)
L2选「异常」走异常分支S3E 原因四选一
L1拍下记账、递增S5 完成屏(可撤销)
L0全部记账完会话收尾S6 小结 + 台账导出

这张表是这篇的骨架,后面每一屏都能从它一行一行读出来。

三、方案设计

3.1 它是什么,不是什么

三句话说完:

  • 它是一个有状态的现场助手,不是问答机器人。
  • 它不做业务决策:不判责、不改地址、不承诺时效。这些留给站点和系统。
  • 它只做一件事:把这一件,推到下一个确定状态。

「确定状态」是关键词。每按一次键,状态必须往前走一格,或者明确告诉你为什么不能走。没有第三种情况。

3.2 八个状态,怎么砍出来的

状态机我改了三版。第一版 11 个状态,画完自己都记不住;第二版砍到 6 个,可有一屏塞了两个主按钮,用户会犹豫;最终定稿 8 个——主路径 6 个,加一个异常分支、一个过渡态。规则只有一条:

一屏只允许一个主操作,外加一条退路。

IDLE 待命 → LIST 清单 → IDENTIFY 识别 → DECIDE 处置
                                        ├─ 异常 → EXCEPTION 原因
                                        └──────→ EVIDENCE 取证 → DONE 完成 → SUMMARY 小结

主操作永远映射到镜腿单击,退路永远是前滑(往回)。多出来的功能不是删掉,是挪到「这一屏不该出现」的地方去。比如「改约时间」第一版和「放柜」并排,测试时发现有人会误选,最后它被挪进了处置屏第四位,只有滑到底才会遇到。

这条规则看着像 UI 规范,其实是注意力预算。戴眼镜的人此刻正在看门牌号、听门里有没有人、算下一栋楼怎么走。他给你的注意力,大概只有一秒。

八个状态与流转:主路径六屏 + 异常分支 + 小结

实线是单击,虚线是会话的起止(重新开始那一圈)。橙色那个 S3 是唯一的分叉点,也是唯一一屏允许「有两种走法」的地方,所以它的边框我特意画重,让它在视觉上跟别的屏不一样。第一版 11 个状态画完自己都记不住,删到 8 个才敢往下写代码。

3.3 八张屏

屏一句话主操作退路关键字段
S0 待命今天 3 件单击开始—件数
S1 清单3 条(演示用虚构样例),当前高亮单击进入前后滑浏览序号、地址、运单号、要求
S2 识别运单号 + 收件人 + 备注单击拍照识别前滑回清单运单号、楼栋、是否本人、备注
S3 处置四选一单击确认前后滑换选项处置方式
S3E 异常原因四选一单击确认前滑回处置原因
S4 取证取景框 + 拍下单击拍下—照片、时间戳
S5 完成✓ 已记录 / 还剩几件单击下一件前滑撤销(不限时)结果、剩余件数
S6 小结3/3 件 + 分布单击重新开始—汇总、台账

3.4 能力只碰三样,而且刻意碰得浅

  • 摄像头:wx.media.createCameraContext().takePhoto(),一张就够,不做连续流;
  • 条码识别:BarcodeDetector,只吃图像数据,不吃 canvas 元素;
  • 本地存储:当日台账落本地,导出 JSON,不联外网。

每多接一个能力,就多一个在现场会失败的点。这条线要能在电梯里、地下车库、雨天跑完,所以能力越少越好。

识别这件事,我没做成「必须成功」的环节,而是做成一条三级降级链:

  1. 拿到照片 → BarcodeDetector 命中 → 用识别结果;
  2. 识别不到 → 回空字符串,界面写「未识别出条码 · 已按清单记录本件」,流程照走;
  3. 连摄像头都拿不到(无设备 / 权限被拒 / 被别的进程占用)→ 直接按清单当前件记录。

三级里任意一级成立,流程都能往下走。现场工具能不能用,看的不是最聪明的那条路径,是最笨的那条还灵不灵。

这条降级链后来救了我一次真的:在公司台式机上测试,机器没有摄像头,点「拍照识别」时宿主弹出授权框,我点了取消——日志里留下 [capture] fallback: OperationError: 用户取消拍照。,界面照常进了处置屏。这个分支后来真被触发过一次。

四、AIUI 实现

4.1 八个文件

deploy-yijian-yiping/
├─ app.json                 # 只有一页:pages/index/index
├─ app.js                   # 启动日志
├─ package.json
├─ pages/index/index.ink    # 832 行:模板 + 脚本 + 样式,全在一个文件
└─ lib/
   ├─ flow.js               # 状态机(纯函数,288 行)
   ├─ keys.js               # 镜腿按键别名表
   ├─ actions.js            # 每屏单击发什么事件(29 行)
   └─ data.js               # 3 件虚构样例

整个智能体只有一个页面。八张屏不是八个路由,是同一页里的八个 ink:if 分支。这个选择省了很多事:眼镜上没有「返回上一页」,也没有「页面栈」,一旦拆成多页,退路就得靠系统导航,而系统导航是别人的地盘。

顺便记一笔踩过的坑:deploy 和开发目录必须分开。浏览器侧的构建器不认 node: 协议,开发目录里那几个 test/*.mjs 一被扫进产物,整包构建就失败,表现是「选择页面」下拉为空、点运行毫无反应,没有任何报错指向真正的原因。前两个小时我就是这么浪费掉的。

4.2 状态机写成纯函数

// lib/flow.js —— 界面不判断状态,只投影状态
export function reduce(state, event) {
  const s = { ...state };
  switch (event.type) {
    case 'MOVE': {
      // delta 兜底成整数:外部传 NaN 时,后面所有钳位都会跟着变 NaN
      const delta = Number.isInteger(event.delta) ? event.delta : 0;
      if (s.stage === STAGE.LIST) {          // 移动的是"第几件"
        s.index = clamp(s.index + delta, 0, s.parcels.length - 1);
        s.cursor = s.index;
        return s;
      }
      // 移动的是"第几个选项"
      s.cursor = clamp(s.cursor + delta, 0, optionList(s.stage).length - 1);
      return s;
    }
    case 'SHOT': {
      if (s.stage !== STAGE.EVIDENCE) return s;   // 状态守卫
      s.logs = [...s.logs, { index: s.index, action: s.action, reason: s.reason, ts: now }];
      s.stage = STAGE.DONE;
      return s;
    }
    // …共 10 个事件
  }
}

写这段的初衷很朴素:八个状态如果散在各处各写一遍 if,改一次流程要改八处,而且必然漏。抽成一个纯函数之后,页面只剩「把 state 映射成几个控件」,加状态就是加一个 case。

副作用是意外收获:因为 reduce 是纯的,我可以在 Node 里把一串事件喂进去做回归,根本不用点屏幕。这个副产品后来救了整个项目——第 6 章的七次定位里,有三次是靠它抓住的。

4.3 拍照与识别

async tryCapture() {
  if (DEMO_NO_CAMERA) return '';               // 演示开关,见下
  if (!this.cameraCtx) this.cameraCtx = wx.media.createCameraContext();
  if (!this.cameraCtx) throw new Error('camera context unavailable');

  let photo;
  try {
    photo = await this.cameraCtx.takePhoto({ quality: 'high', mode: 'wide' });
  } catch (err) {
    photo = await this.cameraCtx.takePhoto({ quality: 'high' });   // 退一档重试
  }
  if (!photo || !photo.data) throw new Error('empty photo');

  const input = photo.width && photo.height
    ? { data: photo.data, width: photo.width, height: photo.height }
    : photo.data;

  const detector = new BarcodeDetector();
  const results = await detector.detect(input);
  if (!results || !results.length) return '';
  return String(results[0].rawValue || '').trim();
}

有两个细节。

第一,mode: 'wide' 会抛错,所以套了一层重试。 广角不是所有宿主都给,抛了就退一档用默认模式。这种「参数不支持」的失败在现场很常见,不能让它冒到用户面前。

第二,DEMO_NO_CAMERA 这个开关是真的。 录演示视频时,每点一次「拍照识别」,宿主就弹一次摄像头授权,白卡整个盖住画面。63 秒的片子里出现两次,画面全毁。所以演示时把它设成 true,直接走降级;真机演示时改回 false。不是给视频作弊:权限弹窗是宿主的行为,不是我的界面,入镜就是噪点。

识别屏:运单号、楼栋、要求

S2 识别。运单号放大到一眼能扫,楼栋和「是否本人」是这一户的判断依据,最下面那行备注是唯一可变长的地方。这一屏只做一件事:让人确认「没认错件」。注意底部那行小字:识别不到条码时它就换成「未识别出条码 · 已按清单记录本件」,流程照走。

4.4 镜腿:四次改稿,四个实测值

撤销这件事我改了四次。

第一版放长按。 跑起来才发现镜腿面板只给四个动作:单击、双击、前滑、后滑。这不是模拟器偷懒,是照着真实输入面做的:长按在窄镜腿上本来就分不清「按住不动」还是「按了两下」。

第二版改双击。 单击往前走,双击往回退,同一根手指,不用计时。听起来很顺,直到我点了那一下——界面直接退出了,顶上一行小字「检测到退出」,会话结束、回到系统视频。双击是系统级手势,智能体抢不到。

第三版不加了,把撤销挂前滑。 前滑在每一屏都只有一个方向:往回。往回走过清单是上一件,往回走过完成页就是撤销。撤销从来不需要一个新动作,它只是「往回」走到了头。

第四次是文案。 实测发现平台把「往前滑」上报成 ArrowUp、把「往后滑」上报成 ArrowDown——它把「前后」当成了「上下」。如果照直觉把提示写成「前滑下一件」,用户第一下就会觉得反了。

第五次是商店审核逼出来的,也是我最绕的一次。 11-05 首轮提审被拒,三条意见:缺新手引导;双击不能返回上级、会直接退出;用户要能自己换一件。第一条我认。第二条我起初不服——双击是系统级手势,10-30 就实测过,点一下直接「检测到退出」。但复查文档发现按键事件会先分发给当前页面、页面可以拦截(Backspace 的示例就叫 "back action intercepted"),于是我做了单击挂起 350 毫秒的双击判定:窗口内来了第二下就返回上一级。传上云端一测,日志给了答案:双击在宿主层就被截走了——「检测到手势: 双击 → 已返回系统模式」,页面根本收不到第二次信号,挂起的窗口等来的永远是超时。文档说的「先分发页面」不含双击这个例外:Backspace 能拦,双击不能拦,审核员自己也是这么撞上的。最终修法是让双击维持系统行为,把「返回上级」整个挂到前滑上——识别屏前滑回清单,取证、小结前滑逐级退回,任何一屏退到清单、单击另一件就是换件,第三条意见跟它也是同一件事。顺手把 350 毫秒的单击延迟也拆了:等一个永远等不来的第二击,纯属白等。

四个键的实测值,后来者可以直接抄:

手势上报 code语义备注
单击GlobalHook + Enter(间隔 1~3ms)确认只认 Enter
前滑GlobalHook + ArrowUp往回(上一项 / 完成页撤销 / 逐级退回清单)方向和名字相反
后滑GlobalHook + ArrowDown往前(下一件 / 下一选项)
双击宿主层截走,页面收不到退出会话(系统保留)Backspace 能拦,双击不能拦

按键码我一个都没写死:

export const KEY_ALIASES = {
  OK:    ['Enter', 'NumpadEnter', 'Space'],
  TOUCH: ['GlobalHook'],          // 手势开始,不是确认
  BACK:  ['Backspace', 'Escape'],
  NEXT:  ['ArrowDown', 'PageDown', 'ArrowRight', /* 未实测兼容项 */],
  PREV:  ['ArrowUp',   'PageUp',   'ArrowLeft',  /* 未实测兼容项 */],
};

第一次点下去,控制台只回一句 [key] unknown code: GlobalHook。当时我以为单击就是它。错了——这个认错付出的代价写在 6.3 里。

4.5 一处生成性组件都没接

AIUI 提供了 a2ui,能把结构化指令变成界面。我到最后也没接。三条理由都很具体:

  1. 这个流程的变量在人身上,不在界面结构上。 变的只是「这一户为什么送不掉」,那是一个字段,不是一套布局。
  2. 接入等于引入一个不可预测的失败点。 现场是地下车库、电梯井、雨天,网络先崩。骨架由模型生成,模型抖动一次就是现场卡死。
  3. 生成失败得有兜底,而兜底本质就是静态模板。 那不如一开始就静态,然后把省下来的复杂度放在真正需要的地方。

如果将来要接,我会放两处:异常原因解释(把「客户拒收」扩成一句人话给站点看)、当日台账摘要(把 3 条记录写成一段话)。这两处都是「内容」可变、「结构」不变,正好是生成性组件该管的部分。

AI 负责解释,代码负责流程。边界不清的时候,宁可先不接。

4.6 台账落本地

每件完成时往 logs 里追加一条:件号、运单号、处置、原因、时间戳。全部走完自动汇总:

{ "total": 3, "done": 3, "received": 3, "locker": 0,
  "reschedule": 0, "exception": 0, "reasons": [], "elapsedMs": 138603 }

存 wx.setStorageSync,同时 console.log 一份。没做云端同步,两个理由:现场网络不可靠;客户信息不该在演示阶段出设备。

五、交互与体验

5.1 我只是叠在别人画面上的客人

第二版界面做完,我先看到的不是「好不好看」,是清单第一行不见了:边框在、间距对、位置也对,就是没字。

查了半小时才明白:我把选中行做成了「白底黑字」的反白,可这一页的背景根本没渲染——AIUI 的页面是叠在摄像头画面上的透明层,根 view 的 background 画不出来。 白底没出现,黑字就沉进了实景的树影里。

这件事改掉了我做界面的习惯。在手机上,页面是我的画布,我可以先用底色把世界挡掉,再在上面写字。在眼镜上,实景永远在最底下,我能用的只有白字、描边和粗细。所以这一版的视觉规则只剩三条:

  1. 字只写白色,绝不用深色字——深色字没有可信的底可以依靠;
  2. 层次靠描边、加粗、透明度,不靠色块;
  3. 辅助信息不许比 #c0c0c0 更暗。在户外的树影里,#7a7a7a 等于不存在。

5.2 布局的三条红线

.screen 必须 height: 100%,.body 必须 min-height: 0,.foot 必须 flex: none。

这三条是用一次误判换来的。处置屏有 4 个选项,比别的屏高一行;.screen 原本写的是 min-height: 100%,容器就跟着长高,最下面的提示区被顶出了可视区。而提示区里装的正好是「已撤销这一件」。

所以那一次,撤销其实成功了——屏幕确实退回了处置屏——但「已撤销」三个字在框外。看起来像没生效,其实是没显示。

处置屏:四个选项

S3 处置。四个选项是这一版里最高的一屏,也正是它把底栏顶出了框(就是上面那一节)。「改约时间」被排到第四位是改过的——第一版它和「放柜」并排,测试时我自己都滑错过一次。

5.3 后悔不该有倒计时

完成页上我写了三行字:一个 ✓、一句「已记录」、一行「还剩 2 件」。下面挂一个「下一件」按钮,同时开一个 3 秒后自动跳走的定时器——派件员手上拎着货,少按一下是一下。撤销也定在这 3 秒里。

两个 3 秒,是同一个 3 秒。

完成屏:已记录 / 还剩 2 件

S5 完成,就是那两个 3 秒撞在一起的地方。第一版这一屏底下还挂着倒计时,单击之后 3 秒自动跳到下一件;同时撤销也定在那 3 秒里。图上看不出倒计时,因为删掉之后才截的图,现在它停在这儿等你按。

测试那天我看清了:单击拍照 → 屏幕跳成「✓ 已记录」→ 我抬手去滑,屏幕已经自己走了。不是撤销失败,是根本来不及。

写代码时这两个 3000 毫秒在两个文件里,谁都没违反谁的约定;摆在一起看,它们互相抵消——自动推进的意思是「我替你决定了下一件」,撤销的意思是「等我一下,我可能错了」。同一秒里既替用户决定,又请用户否决。这不是两个功能,是一个矛盾。

改法是把自动推进去掉。完成页停在那儿,等两个动作之一:单击「下一件」,或前滑「撤销」。撤销也不再三秒——它只跟「有没有进下一件」有关,跟过了几秒无关。

代价是每件多按一次。我接受:一次单击大约 300 毫秒,记错一件的回执要回站点改单。在眼镜上,宁可让人多按一下,也别替他做决定。

副产品是这条规则变得可测了。原来「3 秒内」没法自动验证(要么 sleep、要么假时钟),现在只需问一句:进没进下一件?

5.4 撤销退到哪一屏

拿掉自动推进之后,我面对一个更具体的选择:从完成页往回退,退到证据屏,还是退到处置屏?

退到证据屏,意思是「这张拍糊了,重拍一张」。退到处置屏,意思是「这件事我判错了,重新处置」。

我选了后者。派件员会后悔的很少是照片,多半是判断——站在门口发现客户其实在家、或者箱子其实没破,要改的是「异常 / 外包装破损」这个结论,照片只是附属品。

代价要说清楚:撤销会把已选的理由一起清掉,想改证据就得重新滑五次回到「外包装破损」。这是真实的取舍,不是免费的。

为什么愿意付?因为撤销只该有一个入口、一个含义。要是让它既改证据、又留判断,它就变成了分支——而分支在眼镜上是灾难:用户看不见自己站在哪一条上。

六、调试与验证

我的验证手段只有几样:模拟器、事件流回归测试,以及把录屏停下来逐帧看。这一章记录七个 bug,每一个都有日志、截图或录屏作证。一共修了十个,这里挑七个最有代表性的。

6.1 一进页面,首屏就没了

第一版跑起来,我盯着看了十秒:一进来就是清单,1/3、三行件等着我选。可首屏去哪了?

日志里只有一行 [一件一屏] launch,一条按键记录都没有。也就是说,我没点,它自己往前走了。

试了三轮才确认:宿主在页面加载时会补送一次「激活」,那一次被我的 onKeyDown 当成了一次单击确认,于是 S0 直接跳到 S1。

修法三行:

this.readyAt = Date.now() + 800;          // onLoad
if (Date.now() < this.readyAt) return;    // onKeyDown 第一行

补丁三行就写完了,麻烦的是它说明的事:窗口里的输入,不全是用户给的。 手机应用里「谁的点击」基本不用怀疑;在眼镜上,宿主、语音助手、系统手势都在同一个键盘上按。分不清来源,状态机就会自己走。

6.2 清单第一行是个空格子

原因上一节写过(5.1):反白废掉,选中态只留描边加粗和白字。

.row.on { border-color: #fff; border-width: 2px; background: rgba(255,255,255,.18); }
.row.on .row-title { font-weight: 700; }

这次之后我给整个样式表定了一条硬规矩:任何可读内容,都不许依赖底色。

6.3 点一下跳两屏,滑一下变拍照

修完首屏,我点了一下「单击」,页面直接跳到了识别页:SF0000 0000 0001、地址、收件人,一个「拍照识别」按钮等着我。中间那屏清单,一眼都没看见。

探针行留着最近四个 code:

key GlobalHook Enter → OK

一次点击,两个事件。 触摸层先报 GlobalHook,按键层紧接着报 Enter。我前一轮刚把 GlobalHook 补进 OK 的别名表(因为它在日志里一直报「未知」),于是两个 code 都解成了 OK,各走了一遍主操作:S0 → S1 是一次,S1 → S2 是第二次。

到这里我判断是「重复触发」,加了个合并窗口:同一语义 300ms 内只认第一次。写完自测,单击确实只走一步了。我以为修完了。

下一轮实测,向前滑动,屏幕上弹出了摄像头授权框。

探针这次给了四个 code:

key GlobalHook ArrowUp GlobalHook ArrowDown → NEXT

那一刻我才明白 GlobalHook 到底是什么:它不是「单击」,它是「摸到了镜腿」。 滑动必然从触摸开始,所以每一次滑动,第一下都被我当成了一次确认。而在识别页,「确认」就是「拍照识别」,于是滑动变成了拍照。

前面那个合并窗口治不了它:两次触摸之间隔了一百多毫秒,语义也不是「重复」,是两个不同的意思被我塞进了同一个名字。

最后的改法不是加窗口,是拆语义:GlobalHook 单列成 TOUCH,页面里对它显式 return。它没被删掉——将来要用它做「按下反馈」,只是它不再拥有「确认」的权力。

两条教训:补丁不是修复,合并窗口是对症,拆语义才是对根因;「同一次手势」只能靠毫秒间隔判断,所以我给每个按键事件都带了 (+Nms) 的间隔日志。没有这个数字,我永远分不清「点一下发了两个事件」和「用户点了两下」。

6.4 我写的是行,它渲染成了列

四个键都通了,清单也出来了,我以为可以开始截图。然后我盯着截图看了半分钟:每一行里,序号「1」在地址上方居中,处置方式「本人签收」在运单号下方居中——一行被我写成了三行。顶部 1/3 和 备件 一上一下叠着,而我的 CSS 明明写着 display:flex; justify-content: space-between。

少了两个字:flex-direction: row。

这个渲染器的 flex 默认方向是 column,不是 web 的 row。 所以 justify-content 在主轴上生效——主轴是竖的,于是「两端对齐」变成了「上下分布」。

我没在任何 Web 项目里遇到过这个,因为浏览器里 flex 默认就是 row。我没记错,是环境不一样。

清单屏:3 条,当前件描边加粗

S1 清单,这是 6.4 那个 bug 修好之后的样子:序号在左、地址在中间、处置方式在右,一行三列。修之前它们是竖着堆的,因为渲染器的 flex 默认方向是 column。选中行靠描边加粗和白字,不靠白底反白——白底在这块画面上根本画不出来。

6.5 滑动没反应,日志却全对

四个键都通了,清单也归位了,我开始测滑动。屏幕下方那行提示是我自己写的:前后滑动选件 · 单击进入。

后滑一下。HUD 还是 1/3。再后滑,还是 1/3。前滑,还是 1/3。

日志是这样的:

[key] GlobalHook → TOUCH (+33771ms)
[key] ArrowDown → NEXT (+1ms)
镜腿向后滑动

事件到了,语义解对了,不是 unknown,没被守卫吞掉,时间戳也正常。每一层都说自己没问题,界面就是不动。

这种 bug 比报错的难十倍。报错至少告诉你「我在这」;这个连声音都没有。根因只有一行,在页面层:

const next = Math.min(this.state.total - 1, Math.max(0, this.state.index + delta));

状态机里存件数的字段叫 parcels,从来没有 total。于是 undefined - 1 得到 NaN,Math.min(NaN, 1) 还是 NaN,index 变成了 NaN。而 NaN 不抛错、不告警、不进任何分支——就那么安静地等于「没动」。

修的时候发现第二层:MOVE 只处理有选项表的两屏,清单屏没有选项表,直接 return s。就算页面层算对了,这次移动也会被状态机丢掉。

第三层是三天后复测才看见的,也是最难看见的。那次我没留意云端的包还是旧的,屏幕上看到的是:清单怎么滑都停在 1/3;然后单击进了识别页,运单号、地址、收件人、备注四行全空,只剩一行小字「这一件」和一个拍照按钮。

我第一反应是「数据没传进去」。其实 index 早就变成 NaN 了:parcels[NaN] 是 undefined,于是 {{parcel.waybill}} 渲染成空字符串。界面没有任何异常,只是少了几行字。

真正该记住的是:我的越界守卫没拦住它。我写的是 `if (i < 0i >= s.parcels.length) return s;,但 NaN < 0 是 false,NaN >= 3 也是 false——**NaN` 跟任何数比较都返回 false,所以它能穿过所有范围检查。**

补上的是 Number.isInteger(i)。这也是我后来给所有下标加的第一道检查:范围检查防不住 NaN,得先问它是不是个数。

所以这个 bug 该换个名字:它不是「滑动没反应」,是一个 NaN 从清单页漏到下一页,把一屏数据变空白,而两屏看起来都像正常界面。

6.6 单击之后什么都没发生

清单能滑了,我理所当然地以为后面是直线:单击进识别,再单击进处置。

单击。屏幕没动。

日志:GlobalHook → TOUCH (+0ms)、Enter → OK (+1ms)、镜腿单击。事件到了,语义对了。屏幕就是不动。

这次我学乖了,直接去状态机里找:SHOT 只在证据屏有效,其它屏一律 return s。而识别屏发的正是 SHOT。

为什么?因为「识别拍照」和「证据拍照」我写成了同一个函数——两处都是「拍一张」,就共用了 onCapture。

可这两张照片根本不是一回事:识别那张只用来回答「这是哪一件」,拍完该去处置;证据那张要留在台账里给客户看,拍完该去完成。一个是路由,一个是记账。

最难受的是:这种错误 Node 测试永远抓不到。 状态机的 8 条用例全绿,因为它测的是「收到 IDENTIFIED 之后对不对」,没有一条测「页面到底发没发 IDENTIFIED」。测试盯着逻辑,bug 长在接线上。

修法不是补一个 if,是把「每屏单击发什么事件」抽成一张表:

export const PRIMARY_EVENT = {
  IDLE: 'START', LIST: 'PICK', IDENTIFY: 'IDENTIFIED', DECIDE: 'DECIDE',
  EXCEPTION: 'REASON', EVIDENCE: 'SHOT', DONE: 'NEXT', SUMMARY: 'RESET',
};

页面按表发事件,测试按表回放:从待命出发,一路「单击」15 次,断言每一步状态都必须变化,最后走完 3 件回到待命。

这条测试问的是:如果我只会单击,能不能从头走到尾?以前的测试问「状态机对不对」,现在问「这个智能体能不能用」。前者全绿的时候,我照样卡在识别屏。

还有一条:日志说对了、界面说错了的时候,信界面。因为用户只看界面,而日志是我写给自己的辩护词。

6.7 同一个坑,三个门

这个 bug 不是测试找出来的,是录屏回放找出来的。

片子剪到结尾,我停在小结屏上:屏幕中间写「2/3 件」,右上角写「3/3」,下面一行「签收 2 · 放柜 0 · 改约 0」。

同一屏,三个数字,两个在说「做完了三件」,一个在说「只做了两件」。而它们全都是对的——右上角的 3/3 是「你走到第 3 件了」(我在清单里先滑到第 2 件开始做,第 1 件被跳过了),中间的 2/3 件 是「账上有两条记录」。

结论是:我把「走到最后一件」当成了「今天做完了」。

if (s.index + 1 >= s.parcels.length) s.stage = STAGE.SUMMARY;

这行读起来像「如果这是最后一件,就出小结」。作为单件判断它没错;作为「一天结束」的判断,它漏掉了整整一类情况——用户不一定按顺序做。

修法是把「下一件」的定义改掉:不是下标加一,是还没记账的那件。先往后找,找不到再回头补。跳过的件会被找回来,三件都记过账才允许出小结。往回补的时候屏幕上写一句「补做前面漏掉的那件」,不然 HUD 从 3/3 跳回 1/3,用户会以为进度丢了。

改完重录,结尾又出同样的毛病——正文写「3/3 件」,左上角写「1/3」。因为它补做漏掉那件时下标回到了第一件,而左上角显示的正是下标。同一个坑,换了个门进来。

这次的修法不是再调数字,而是问一句:这个位置上到底该不该有数字?待命屏没有件、小结屏是全天结果,这两处显示「第几件」本身就是错的。所以这两屏左上角直接不画。

我以为这就完了。第三遍录,完成屏上出现「还剩 2 件」——而那已经是最后一件。

<text>还剩 {{total - index - 1}} 件</text>

total - index - 1 是位置差,意思是「我后面还排着几件」。按顺序走的时候它和「还剩几件没记」恰好相等,所以前两次录屏都没露馅;一旦跳着做,它就开始说反话。

export function remainingCount(s) {
  const done = new Set(s.logs.map((l) => l.index));
  let n = 0;
  for (let i = 0; i < s.parcels.length; i += 1) if (!done.has(i)) n += 1;
  return n;
}

同一个错误在三个地方出现,每次换一行,每次都很像对的。第一次是「走到末尾就算做完」,第二次是「左上角写着第几件」,第三次是「还剩几件」——三次都源于同一个偷懒:用位置去代替状态。

位置是过程,状态是结果;过程可以乱序,结果不会。前两次我都把它当成「这里算错了」,改那个数字;第三次才改成「这里不该算位置」。

有一点得诚实交代:这三个都不是崩溃、不是报错,是剪辑时盯着画面才发现的。人眼对「2/3 和 3/3 同时出现」的迟钝程度,比想象中高得多——我导出日志看了三次都没注意。

七、实测数据与素材

指标实测值来源
一镜到底走完 3 件(含一次撤销重走、一次补做漏件)75.2 秒演示录屏
另一次完整走完(含撤销重走)138.6 秒台账导出 elapsedMs: 138603
一次单击GlobalHook +0ms → Enter +1~3ms[key] … (+Nms) 日志
前滑GlobalHook + ArrowUp,+1~2ms同上
后滑GlobalHook + ArrowDown,+1ms同上
双击「检测到手势: 双击 → 已返回系统模式」宿主层截走,页面收不到
摄像头失败降级用户取消拍照 → 仍进处置屏[capture] fallback: OperationError
单元测试23 例(流程 13 + 按键 10),全绿node --test

那个 75.2 秒要说清它不是什么:不是真实派件速度,没有爬楼、没有等电梯、没有现场的人。它的意义只有一个:证明这条任务线能在两分钟内从头走到尾,中途撤销重走了一件、补做了一件。

素材清单:模拟器全流程截图 6 张(待命 / 清单 / 识别 / 处置 / 完成 / 小结)、演示录屏 1 段 75 秒一镜到底、带探针的调试截图 20 余张(画面与日志同框)。

本文定稿时,这个智能体已经走完 AIUI Studio 的云端打包并提交了商店审核(AIUI ID:6c72c5ef9d8e460e86506d29604decf7)。首轮审核没有过:三条意见,缺新手引导、双击直接退出会话、不能自己换一件。三条都以 v1.1.0 重新提审——引导有了专门的屏,后两条合并成一条完整的前滑回退链。有意思的是第二条:我先按文档做了双击拦截,是模拟器日志告诉我宿主根本不给页面这个机会。文档写了对开发者友好的那一半,剩下的那一半,只有点过那一下的人才知道。

小结屏:3/3 件与分布

S6 小结。中间是件数,下面一行是分布,最底下一行是「当日台账已存本地」。这张图里唯一需要停下来看的数字是「异常 0 件」,原因写在下一段。

录屏里有一个细节要专门说明,否则会被误读:中段演示了「异常 → 客户拒收」,随后前滑撤销,所以结尾小结是「异常 0 件」。不是异常流程没跑通:撤销退掉的是账,不只是画面。 台账里没有那条记录,是因为它真的被撤掉了。

八、没有眼镜,我怎么验证它

先说清楚:我没有 Rokid Glasses。

一开始我觉得这是硬伤,后来发现它逼出了一套更严谨的验证方式。没有设备,我只能把「看得见的验证」换成「可重复的验证」,一共四件事:

一、把模拟器当第一现场。 每加一个状态,先在模拟器里从头点到尾一遍,过不了就不写下一屏。八屏定稿前,这个动作重复了四十多次。

二、把事件流当回归测试。 因为状态机是纯函数,我可以把一串事件喂进去断言结果:

const s = events.reduce(reduce, init);
assert.equal(s.stage, STAGE.SUMMARY);
assert.equal(s.logs.length, 3);

这行代码抓住了只在「连点两下」时才出现的错位,手点很难复现,事件流一秒能跑一百遍。

三、把录屏停下来看。 6.7 里那三个数字,测试没抓住、日志没抓住,是逐帧看画面抓住的。

四、认清模拟器的边界。 面板给了「绝对黑暗 / 室内明亮 / 夜晚偏光」三档光照,没有一档叫正午的太阳;镜腿是点按钮,真机是摸一条窄边;网页端没有冷启动。

能验证手段不能验证原因
八态流转、异常分支、补做漏件事件流回归镜腿滑动手感输入设备形态不同
界面布局、层级、底栏是否出框模拟器截图强光下可读性环境光无法还原
权限拒绝、无摄像头的降级手动构造异常冷启动耗时无真实系统进程
按键事件时序(毫秒级间隔)探针日志嘈杂环境语音识别无声学环境,且本版未接语音

这三条测不了的,正文里我也不给结论。等拿到设备,第一件事就是补这三项。

倒是有一件事不需要设备就完成了:打包和提审,全程在网页里。AIX 包 14.23 KB,云端构建,从头到尾没有插过一根线——「没有实体眼镜也能完成从创意到上线」这句话,我不是引用来的,是走完一遍之后写下的。

九、复盘

先说清一件事:我没有做用户测试,也没有真机。 下面这些是我自己在模拟器里走了一整天之后写下的判断,不是数据。

  • 动作数确实少了。 同一件事,手机上要掏、解锁、进 App、找单号;眼镜上是抬头、单击。但动作数少不等于更快——第一次戴的人得先摸到那条镜腿,那几秒比掏手机还长。速度不是这个方案现在的卖点。
  • 省下来的主要是中断。 不用把件放下、不用摘手套,这个收益不随熟练度衰减。
  • 最脆的地方不是识别,是接续。 我修的十个 bug 里有三个是同一类:上一步做完了,下一步没接上。做出八张屏不难,难的是让这八张屏之间不出现「屏幕不动」的空白格。

局限

  • 不接业务系统,签收结果只落本地,真实场景需要对接工单;
  • 只覆盖线性流程,「一户多件」需要额外的合并态;
  • 没有多人协作(同一片区两人分送);
  • 待命屏保留了「说开始」这句文案,但这一版只实现了按键路径,语音入口是下一步;
  • 未做真机验证,强光可读性、滑动手感、冷启动三项无数据;
  • 演示用的 3 件样例全部虚构,不含任何真实客户信息。

十、写在最后

这篇写的其实不是快递。

眼镜这个形态,稀缺的从来不是显示,是它长在人身上。凡是「手腾不出来、眼睛还空着」的现场,都有同一条任务线可以往里放:装维师傅在梯子上、巡检员在配电房、护工在给老人翻身。

我做的第一版只跑通了一条线,中间修了十个 bug,其中三个是同源的——都出在「用位置表示状态」这一个偷懒上。但这条线证明了一件事:当界面开始跟着任务走、而不是跟着页面走,眼镜就不再是个「小屏幕」,它变成了一个能接活的东西。