一、ArkTS不是“换了个框架的TypeScript”

从Android或前端迁移到鸿蒙ArkTS的团队,第一周往往觉得“语法差不多”,第二周开始遇到“数据变了但UI不动”,第三周发现连续扫码一秒录入同一票货十几次。问题的根源不在API不熟,而在ArkTS的运行时模型和前端/Android有结构性差异:声明式UI的更新粒度由状态装饰器决定,硬件能力的生命周期由平台状态机严格约束。

这篇文章聚焦物流终端App的两个核心场景——状态管理与连续扫码——说清楚哪些“老习惯”在ArkTS里是错的,以及为什么错。

二、状态管理:V1和V2不是版本号,是两套观察机制

2.1 V1的观测边界:一层属性,代理观察

状态管理V1通过数据代理来观察变化。创建状态变量时同时创建一个代理观察者,但这个观察者只能感知代理层的变化。V1的观测能力有明确的边界:只能观察第一层对象属性的变化、第一层数据项的变化,无法深度观测嵌套对象内部的属性变化。要让深层数据具备观测能力,开发者必须用@ObjectLink逐层拆解嵌套类。

这意味着在物流App中,一个包含嵌套地址对象(省、市、区、街道)的运单模型,如果放在V1的@State里,修改address.city不会触发UI刷新——除非把address拆成独立的@ObjectLink子组件。

2.2 V2的升级:数据本身可观察,属性级精准更新

V2把观察能力建在数据本身上,而不是代理层。@ObservedV2 + @Trace装饰的类,其被标记的属性天然可观察,支持深度观测和属性级精准更新——修改嵌套对象的某个字段,只有依赖该字段的UI节点会刷新,而不是整个对象相关的视图全部重算。

2.3 硬约束:V1和V2不能混用

V1和V2的装饰器不允许交叉使用。@Component(V1)的组件里不能用V2的组件内装饰器,@ComponentV2里也不能用V1的装饰器,编译会报错。更隐蔽的场景是跨版本传参:V1的@State传给V2子组件,只能传简单类型,且V2子组件必须用@Param接收;如果子组件忘了改,父子之间走的是跨版本通道,数据变了但子组件不刷新。

迁移的正确粒度是“一条完整的数据链” :父子组件一起从V1切到V2,不要在同一个数据链路上半迁半留。

三、连续扫码:rescan是硬约束,去重必须“内容+位置+TTL”

3.1 默认扫码界面不适合流水线

Scan Kit的默认扫码界面一次只返回一个结果然后关闭,适合登录和绑定。快递员在流水线上需要的是镜头一直开着,扫一个录一个。这必须用customScan自建预览界面,并且在start的回调中调用customScan.rescan()触发下一帧。反复stop再start会触发错误码7400201(相机服务异常),甚至页面卡死;rescan也只在start的回调中生效,Promise方式调用无效。

3.2 去重:纯“内容+时间窗”会误杀真实业务

连续扫码模式下,相机以约30fps持续识别,同一张码在视口内停留时每一帧都会触发回调。只按“内容相同+1秒内丢弃”去重,会误杀一个真实场景:同一SKU的两件货,条码内容相同,用户真的扫了两次。在仓储场景中,同货架标签内容完全一样,纯内容去重无法区分“相机对着同一张码没动”和“用户扫了两张同内容码”。

华为社区推荐的方案是 “内容+位置+TTL”三维联合去重:用ScanResult.scanCodeRect获取码在预览画面中的外接矩形,将中心点按15–30px的网格量化,去重键为“内容+量化位置”,TTL取2000–3000ms。同一张码、相机不动→内容和位置都相同→去重;同内容、不同位置→位置不同→放行;scanCodeRect为空时降级为纯内容+TTL。

typescript

class ScanDedup {
  private lastValue: string = '';
  private lastPosKey: string = '';
  private lastTime: number = 0;
  private readonly TTL_MS: number = 2500;
  private readonly GRID: number = 20;

  accept(result: scanBarcode.ScanResult): boolean {
    const now = Date.now();
    const value = result.originalValue ?? '';
    const rect = result.scanCodeRect;
    if (now - this.lastTime > this.TTL_MS) { this.update(value, rect, now); return true; }
    if (value !== this.lastValue) { this.update(value, rect, now); return true; }
    if (rect) {
      const posKey = this.quantize(rect);
      if (posKey === this.lastPosKey) return false;
    }
    this.update(value, rect, now);
    return true;
  }
}

四、蓝牙面单打印:指令层和分包写入是两个独立问题

蓝牙打印的失败通常不在“连不上”,而在“连上了但打不出来”。指令层有三个跨平台约束:CPCL指令必须以\r\n结尾,否则打印机不走纸;中文必须用GBK编码,UTF-8会乱码;写入必须是字节数组,字符串直传会静默失败。分包写入是另一个问题:BLE的MTU决定了单次可写负载上限,必须等上一次写入的完成回调后再发下一包,不能连续快速写入冲垮打印机缓冲区。

鸿蒙生态中已有厂商SDK提供enableChunkWrite和chunkSize配置来处理分包,并内置GBK转换。打印机型号在支持范围内时,优先用SDK比从零封装GATT更稳。

五、避坑指南

坑1:V1的@State修改嵌套对象属性,UI不刷新。 V1只能观测第一层变化。深层数据要用@ObjectLink拆解,或整条链路迁移到V2的@ObservedV2+@Trace。

坑2:V1和V2在同一条父子链上混用。 父组件用@Local,子组件还是@State,数据变了但子组件不刷新。迁移必须以完整数据链为单位,父子一起切。

坑3:连续扫码用stop/start代替rescan。 触发7400201相机服务异常。回调里只做业务处理,然后rescan()。

坑4:纯内容去重误杀“同内容不同件”。 同一SKU的两件货条码相同,内容去重会把第二件吃掉。必须引入位置维度,scanCodeRect为空时降级,但不能默认只靠内容。

六、总结

鸿蒙ArkTS物流App的核心经验可以概括为两句话:状态管理的观测粒度决定更新代价,V1和V2不可混用——V1是一层代理观察,V2是属性级深度观察,迁移以数据链为单位。连续扫码的生命周期由rescan约束,去重必须引入位置维度——纯内容去重会误杀同内容不同件的真实场景,scanCodeRect为空时降级但不应放弃位置判断。

下一篇预告:扫码解决的是“包裹在哪”,下一个问题是“包裹的重量怎么稳定采集”。明天聊蓝牙电子秤的Notify解析与稳定判定设计。

> 本文作者:深圳+Eyf•半字节科技▪技术产品团队
> 物流、快递、快运作业管理系统,让每一票,都清晰可控。从任务分配到签收回传,一站式管理每个作业环节。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐