〖实战〗快递 App 纯血鸿蒙开发指南:录单、连续扫码与蓝牙面单打印的 ArkTS 落地
一、先看清一个现实:鸿蒙上的“传统做法”大多走不通
从 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 的录单、扫码和蓝牙打印,核心经验可以概括为三句话:
-
录单的“快”来自本地优先,不是来自接口快:
preferences先落盘、flush不省略、同步和打印各自异步。网络和打印机都不能挡住录单本身。 -
连续扫码的关键不是“扫得到”,是“不重复扫” :
customScan+rescan负责持续输出,“内容 + 位置 + TTL”负责把同一张码的重复回调挡掉。位置信息缺失时必须降级,不能让去重逻辑崩掉。 -
蓝牙面单打印优先用厂商 SDK,自研要把指令层做对:CPCL 的
\r\n结尾、GBK 编码、BLE 分包写入,这三个细节决定了“能连上”之后能不能真正打出面单。佳博 SDK 和 PSDK 已经把连接、指令、编码、分包封装好了,如果打印机型号在支持范围内,直接接入是性价比最高的选择。
> 本文作者:深圳+Eyf•半字节科技▪技术产品团队
> 物流、快递、快运作业管理系统,让每一票,都清晰可控。从任务分配到签收回传,一站式管理每个作业环节。
你在鸿蒙上做快递扫码或蓝牙打印时,卡在了哪一步?是相机回调去重,还是蓝牙特征值写入?欢迎在评论区分享,一起避坑。
更多推荐




所有评论(0)