一、先看清一个现实:鸿蒙上的“传统做法”大多走不通

从 Android 迁移到 HarmonyOS NEXT 的快递 App 团队,最容易低估三件事。

第一,蓝牙打印不是“调个 API 就行” 。Android 时代可以用经典蓝牙 SPP 的 createRfcommSocketToServiceRecord 直接往打印机写字节流,鸿蒙的蓝牙能力栈完全不同——经典蓝牙和 BLE 是两套模型,权限模型也从“安装时授予”变成了“按需申请 + 精确管控”。很多团队第一版卡在“能搜到打印机但连不上”或者“连上了但写不进特征值”。

第二,连续扫码在鸿蒙上是一个需要认真设计的功能,不是一个 API 调用。Scan Kit 的默认扫码界面一次只返回一个结果,适合登录、绑定这类场景;快递员在流水线上需要的是“镜头对着包裹,扫一个录一个,不停顿”。这必须用 customScan 自己做,而且同一张码在视口内停留期间,相机以约 30fps 持续识别,回调会反复触发,不去重就会一秒录入同一票货十几次。

第三,录单的“快”在鸿蒙上要靠本地优先的写入模型来保证。网点网络不稳定是常态,如果录单表单提交依赖网络往返,快递员在现场会被卡住。鸿蒙的 preferences 和关系型数据库能力足够做本地暂存,建单和打印应当解耦,录单先落本地,同步和打印各自异步推进。

这篇文章围绕这三件事展开:录单的本地优先模型、连续扫码的去重与 rescan 机制、蓝牙面单打印的指令层与分包写入,全部基于 ArkTS 和 HarmonyOS NEXT 的可用能力,给出可落地的代码和踩坑记录。

二、录单:本地优先,网络和打印都不能挡住录单

2.1 为什么录单不能等网络

快递员在客户现场录单,最理想的操作是“填完最后一项,界面立刻反馈已保存”。如果录单提交同步等待服务端返回,弱网环境下一次往返可能几秒,连续录十单就会被拖到无法作业。

正确的模型是:录单先写本地持久化,后台异步同步,打印独立触发。鸿蒙的 @ohos.data.preferences 适合存表单草稿和同步状态,关系型数据库(@ohos.data.relationalStore)适合存待同步的运单队列。

typescript

// 录单本地暂存与同步状态标记(ArkTS)
import { preferences } from '@kit.ArkData';

const PREF_STORE = 'express_order_draft';
const PENDING_KEY = 'pending_orders';

export interface PendingOrder {
  clientRequestId: string;   // 客户端幂等键
  waybillDraft: Record<string, string>;
  createdAt: number;
  synced: boolean;
  printed: boolean;
}

export class LocalOrderStore {
  private pref: preferences.Preferences | null = null;

  async init(context: Context): Promise<void> {
    this.pref = await preferences.getPreferences(context, PREF_STORE);
  }

  async saveDraft(order: PendingOrder): Promise<void> {
    if (!this.pref) return;
    const raw = await this.pref.get(PENDING_KEY, '[]') as string;
    const list: PendingOrder[] = JSON.parse(raw);
    list.push(order);
    await this.pref.put(PENDING_KEY, JSON.stringify(list));
    await this.pref.flush();  // 必须 flush,否则进程被杀可能丢失
  }

  async markSynced(clientRequestId: string): Promise<void> {
    if (!this.pref) return;
    const raw = await this.pref.get(PENDING_KEY, '[]') as string;
    const list: PendingOrder[] = JSON.parse(raw);
    const item = list.find(o => o.clientRequestId === clientRequestId);
    if (item) item.synced = true;
    await this.pref.put(PENDING_KEY, JSON.stringify(list));
    await this.pref.flush();
  }
}

关键约束:flush() 不能省略。鸿蒙 preferences 的 put 是写内存,只有 flush 才落盘。快递 App 的录单数据一旦丢失,补录成本极高。

2.2 录单表单的字段推进,不要在 ArkTS 里做“自动跳转焦点”

Android 时代习惯在录单表单里用 requestFocus() 自动跳到下一个输入框。鸿蒙 ArkUI 的焦点体系不同,在 TextInput 之间用代码强制跳转焦点容易与软键盘的弹出、收起时序冲突,表现为键盘闪一下又消失,或者焦点跳到了错误的位置。

更稳的做法是用 @State 驱动一个“当前活跃字段”的视觉高亮,配合 onSubmit 或 onChange 做业务校验,但不强行移动系统焦点:

typescript

@Entry
@Component
struct QuickEntryForm {
  @State activeField: number = 0;
  @State receiverName: string = '';
  @State receiverPhone: string = '';
  @State receiverAddress: string = '';
  @State weight: string = '';

  private next(): void {
    if (this.activeField < 3) this.activeField += 1;
  }

  build() {
    Column({ space: 12 }) {
      this.fieldInput('收件人', this.receiverName, 0)
      this.fieldInput('电话', this.receiverPhone, 1)
      this.fieldInput('地址', this.receiverAddress, 2)
      this.fieldInput('重量', this.weight, 3)
    }.padding(16)
  }

  @Builder
  fieldInput(label: string, value: string, index: number) {
    Column({ space: 4 }) {
      Text(label).fontSize(12).fontColor(
        this.activeField === index ? '#1677FF' : '#999')
      TextInput({ text: value })
        .onChange((v: string) => {
          if (index === 0) this.receiverName = v;
          else if (index === 1) this.receiverPhone = v;
          else if (index === 2) this.receiverAddress = v;
          else this.weight = v;
        })
        .onSubmit(() => this.next())
        .enterKeyType(EnterKeyType.Next)
    }
  }
}

设计要点:用 EnterKeyType.Next 让软键盘的“下一项”按钮承担推进职责,onSubmit 里只更新 activeField 做视觉反馈,不强行 requestFocus。快递员按键盘上的“下一项”比用手指点输入框更符合连续录单的手感。

三、连续扫码:customScan 的生命周期与“内容 + 位置 + TTL”去重

3.1 三条扫码链路,快递场景必须选对

Scan Kit 提供了三种扫码方式,适用场景完全不同:

链路入口结果行为快递场景
默认扫码界面scanBarcode.startScanForResult扫到一条返回一次,然后关闭登录、绑定设备
自定义界面(单次)customScan.start + stop一次一条,需要手动重启不适合流水线
自定义界面 + 连续customScan.start + 回调内 rescan引擎持续输出,不回停流水线、一物一码

快递员在分拣线或装车口需要的是第三种:镜头一直开着,扫到一个录一个,不需要每扫一次点一下屏幕。

3.2 生命周期:init → start → callback → rescan → stop → release

customScan 的状态机是严格的:init 之后才能 start,start 进入 Running 状态;Running 状态下可以做闪光灯、变焦、rescan 和 stop;退出时必须 release。

typescript

import { customScan, scanBarcode, scanCore } from '@kit.ScanKit';

@Entry
@Component
struct ContinuousScanPage {
  private surfaceId: string = '';
  private xComponentController: XComponentController = new XComponentController();
  private hasInit: boolean = false;

  aboutToAppear(): void {
    this.initScan();
  }

  private initScan(): void {
    const options: scanBarcode.ScanOptions = {
      scanTypes: [scanCore.ScanType.ONE_D_CODE],  // 快递面单以一维码为主
      enableMultiMode: false,
      enableAlbum: false
    };
    customScan.init(options);
    this.hasInit = true;
  }

  private startScan(): void {
    this.surfaceId = this.xComponentController.getXComponentSurfaceId();
    const viewControl: customScan.ViewControl = {
      width: 360,
      height: 640,
      surfaceId: this.surfaceId
    };

    customScan.start(viewControl, (err: BusinessError,
        data: Array<scanBarcode.ScanResult>) => {
      if (err || !data || data.length === 0) return;

      const result = data[0];
      // 1. 去重判断(见 3.3)
      if (!this.dedup.accept(result)) return;

      // 2. 业务处理:录单、标记已扫
      this.onBarcodeAccepted(result.originalValue);

      // 3. 关键:用 rescan 触发下一帧,不要 stop/start
      customScan.rescan();
    });
  }

  aboutToDisappear(): void {
    customScan.stop();
    customScan.release();
  }
}

最容易踩的坑:连续扫码的下一帧必须用 customScan.rescan() 触发。反复 stop 再 start 会触发错误码 7400201(相机服务异常),甚至页面卡死,而且 rescan 只在 start 的回调里生效,Promise 方式调用无效。

3.3 去重:只靠“内容 + 1 秒”会误杀真实业务

同一张码在视口内停留,30fps 的识别频率会让回调反复触发。但快递场景有一个特殊性:同一批货的运单号可能前缀高度相似,甚至用户真的会连续扫两个内容相同的码(比如同一订单的多件货,外包装条码相同)。纯内容去重会把第二件误判为重复,导致少录。

华为开发者社区的推荐方案是 “内容 + 位置 + TTL”三维联合去重:

  • 内容:originalValue

  • 位置:ScanResult.scanCodeRect(码在预览画面中的外接矩形),API 12 起还提供 cornerPoints

  • TTL:去重窗口,建议 2000–3000ms

typescript

class ScanDedup {
  private lastValue: string = '';
  private lastRect: scanBarcode.Rect | undefined = undefined;
  private lastTime: number = 0;
  private readonly TTL_MS: number = 2500;
  private readonly GRID: number = 20;  // 位置量化网格,15~30px

  accept(result: scanBarcode.ScanResult): boolean {
    const now = Date.now();
    const value = result.originalValue ?? '';
    const rect = result.scanCodeRect;

    // 超过 TTL,一定放行
    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 && this.lastRect) {
      const samePosition = this.quantize(rect) === this.quantize(this.lastRect);
      if (samePosition) {
        return false;  // 同一位置同一内容 → 同一张码仍在画面
      }
    }

    // 位置缺失或位置不同 → 视为新扫描
    this.update(value, rect, now);
    return true;
  }

  private quantize(rect: scanBarcode.Rect): string {
    const cx = Math.round((rect.left + rect.width / 2) / this.GRID);
    const cy = Math.round((rect.top + rect.height / 2) / this.GRID);
    return `${cx},${cy}`;
  }

  private update(value: string, rect: scanBarcode.Rect | undefined, t: number): void {
    this.lastValue = value;
    this.lastRect = rect;
    this.lastTime = t;
  }
}

降级策略:scanCodeRect 可能为空,这时必须降级为纯内容 + TTL 去重,不能让逻辑崩掉。TTL 参数建议取 2000–3000ms;如果业务允许用户快速来回扫同一张码,可以缩短到 1500ms。

四、蓝牙面单打印:优先用厂商 SDK,而不是从零封装 GATT

4.1 先做选型判断:自研 GATT 还是用 SDK

鸿蒙的 @ohos.bluetooth.ble 提供了完整的 BLE 能力,包括 GATT 客户端、特征值读写、通知订阅。理论上可以从零封装:扫描 → 连接 → 发现服务 → 找到可写特征值 → 分包写入 → 监听通知。

但快递面单打印的特殊性在于:打印机型号多、指令集多(CPCL / ESC/POS / TSPL / ZPL)、BLE MTU 有限制。从零封装意味着要为每一款打印机适配指令和分包策略,维护成本极高。

更务实的选择是优先使用已经适配 HarmonyOS NEXT 的厂商 SDK。佳博打印机 SDK 支持 SPP、BLE、USB、网络四种连接,指令集覆盖 ESC 小票、CPC 面单、TSPL 标签、ZPL 斑马,完全基于 ArkTS 开发,适配 API 14 及以上。PSDK OpenHarmony SDK 同样支持 CPCL、TSPL、ESC,明确提供了分包写入能力(enableChunkWrite / chunkSize)来应对 BLE MTU 限制,并且内置了 GBK 编码转换,省去了手动处理中文编码的麻烦。

如果你的打印机是佳博或爱印系列,直接接入对应 SDK 是效率最高的路径。如果必须支持自有型号,再基于 @ohos.bluetooth.ble 做最小封装。

4.2 面单打印的指令层:CPCL 是首选

电子面单本质是标签,不是小票。在热敏标签场景下,CPCL 的排版控制比 ESC/POS 更精细,适合绘制条码、文本块和多区域布局。

无论用 SDK 还是自研,面单指令的构建逻辑是一致的:先定义标签尺寸,再按区域绘制条码和文本,最后 PRINT。用 SDK 的链式 API 可以这样构建一张简化的面单:

typescript

// 基于 PSDK 风格的链式 API(示意)
import { CpclPrinter } from '@psdk/cpcl';

function buildWaybillLabel(
  printer: CpclPrinter,
  waybillNo: string,
  receiverName: string,
  receiverPhone: string,
  receiverAddress: string
): void {
  printer.label(400, 300)             // 标签宽高
    .barcode(waybillNo, '128', 50)    // 条码:内容、码制、高度
    .text(receiverName, { x: 20, y: 80 })
    .text(receiverPhone, { x: 20, y: 110 })
    .text(receiverAddress, { x: 20, y: 140, maxWidth: 360 })
    .print();
}

如果自研 CPCL 指令生成,最容易出错的两个点:

第一,指令结尾必须是 \r\n。只写 \n 或只写 \r,很多便携打印机不会走纸。这不是打印机的 bug,是 CPCL 协议的要求。

第二,中文字符必须用 GBK 编码,不能用 UTF-8。快递面单的收件人姓名和地址是中文,如果按 UTF-8 编码发送,打印机输出的会是乱码。PSDK 在 SDK 内部处理了 GBK 转换,这是它比自研省事的关键原因之一。

4.3 BLE 分包写入:大数据量面单的必选项

BLE 的 MTU 通常只有 20–512 字节(取决于协商结果),而一张面单的 CPCL 指令可能达到几 KB。一次性 writeValue 发送超出 MTU 的数据,轻则写入失败,重则导致连接断开。

PSDK 的处理方式是开启 enableChunkWrite 并设置 chunkSize,SDK 内部按 MTU 自动分包写入。如果自研,必须在每次写入前根据协商后的 MTU 计算分包大小,并等待上一次写入的完成回调后再发下一包:

typescript

// 自研分包写入的核心约束(示意)
async function writeInChunks(
  client: ble.GattClientDevice,
  characteristic: ble.BLECharacteristic,
  data: Uint8Array,
  chunkSize: number
): Promise<void> {
  for (let offset = 0; offset < data.length; offset += chunkSize) {
    const chunk = data.slice(offset, offset + chunkSize);
    characteristic.characteristicValue = chunk.buffer;
    await client.writeCharacteristicValue(characteristic,
      ble.GattWriteType.WRITE);
    // 某些打印机需要短暂间隔,否则会丢包
    await sleep(20);
  }
}

分包大小不能写死。BLE 连接建立后应读取协商后的 MTU,减去 3 字节的 ATT 头,才是实际可用的负载大小。

五、避坑指南

坑1:把默认扫码界面用在流水线上

现象:快递员每扫一个包裹,扫码页面就关闭一次,需要重新点击“扫码”按钮才能扫下一个。

原因:scanBarcode.startScanForResult 的行为就是“扫到一条、返回、关闭”。它适合一次性扫码,不适合连续作业。

解决:流水线场景必须用 customScan + rescan。不要试图在默认扫码界面的返回回调里重新拉起扫码,那样每次都有相机启动延迟,体验极差。

坑2:连续扫码用 stop/start 代替 rescan,页面卡死

现象:第一版连续扫码在回调里调用 customScan.stop() 然后重新 start(),扫了十几个码之后页面卡死,日志出现 7400201。

原因:反复 stop/start 会触发相机服务异常,这是 customScan 状态机不允许的操作。

解决:回调里只做业务处理,然后调 customScan.rescan()。rescan 在 Running 状态下是幂等的,不会重建相机流。

坑3:蓝牙打印机“写入成功”但不走纸

现象:特征值写入返回成功,但打印机毫无反应。

原因:CPCL 指令没有以 \r\n 结尾,或者数据没有转成 Uint8Array / ArrayBuffer 直接写了字符串。

解决:在指令生成层强制 \r\n 结尾;写入前统一编码为字节数组,中文内容走 GBK。如果使用厂商 SDK,检查 SDK 是否已内置编码转换,不要重复编码。

六、总结

纯血鸿蒙上做快递 App 的录单、扫码和蓝牙打印,核心经验可以概括为三句话:

  1. 录单的“快”来自本地优先,不是来自接口快:preferences 先落盘、flush 不省略、同步和打印各自异步。网络和打印机都不能挡住录单本身。

  2. 连续扫码的关键不是“扫得到”,是“不重复扫” :customScan + rescan 负责持续输出,“内容 + 位置 + TTL”负责把同一张码的重复回调挡掉。位置信息缺失时必须降级,不能让去重逻辑崩掉。

  3. 蓝牙面单打印优先用厂商 SDK,自研要把指令层做对:CPCL 的 \r\n 结尾、GBK 编码、BLE 分包写入,这三个细节决定了“能连上”之后能不能真正打出面单。佳博 SDK 和 PSDK 已经把连接、指令、编码、分包封装好了,如果打印机型号在支持范围内,直接接入是性价比最高的选择。

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

你在鸿蒙上做快递扫码或蓝牙打印时,卡在了哪一步?是相机回调去重,还是蓝牙特征值写入?欢迎在评论区分享,一起避坑。

Logo

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

更多推荐