一、三端原生的真正难点,不在“写三遍”,而在“三套硬件抽象层互不兼容”
Eyf 半字节 3.0 是一个面向快递末端与网点作业的 APP,核心能力集中在三件事:蓝牙电子面单打印、蓝牙电子秤称重、摄像头连续扫码。这三个能力有一个共同特征——它们都直接操作硬件,而 Kotlin、Swift、ArkTS 对同一硬件的抽象模型完全不同。
Android 的 BLE 允许应用主动协商 MTU(requestMtu),开发者可以精细控制分包大小和写入节奏;iOS 的 CoreBluetooth 不开放 MTU 协商 API,由系统与外设自动协商,应用层能做的只有“按顺序写入、等回调再写下一条”;HarmonyOS 的蓝牙能力栈又是另一套模型,经典蓝牙和 BLE 分开管理,权限从安装时授予变为按需申请。扫码同理:Android 可以用 CameraX + ML Kit 自建分析管线,iOS 有 VisionKit 的DataScannerViewController把 AVCapture 和 Vision 封装成开箱即用的 Swift API,鸿蒙的customScan则要求开发者自己管理rescan的生命周期,否则会触发相机服务异常。
如果用一套跨平台框架硬扛,最终会退化成“每个平台写一遍原生代码,再包一层 JS 桥”。三端原生架构的正确做法不是追求代码复用率,而是在平台层各自用最合适的方式操作硬件,在领域层收敛出统一的能力契约。
二、整体分层:平台适配层薄,领域契约层稳
Eyf 半字节 3.0 的分层原则是:硬件差异留在最薄的一层,业务逻辑不感知平台。
契约层只定义能力语义,不定义传输方式。例如LabelPrinter的契约是“给定一份面单数据,输出打印成功/失败”,至于底层走 BLE 还是经典 SPP、分包大小是多少、指令结尾是\r\n还是\n,全部由平台适配层决定。
三、蓝牙电子面单:三端的“分包写入”没有统一答案
3.1 面单指令层的共同约束
电子面单本质是热敏标签,CPCL 是跨平台最通用的指令集。三个平台无论用哪套蓝牙 API,指令层有三个不可协商的约束:
第一,指令结尾必须是\r\n。只写\n或\r,大量便携打印机会“写入成功但不走纸”。
第二,中文内容必须使用 GBK 编码。UTF-8 编码的中文姓名和地址在热敏打印机上会输出乱码。Android 侧成熟的 UTS 插件默认就是 GBK 编码,鸿蒙的厂商 SDK 和 PSDK 也在内部处理了 GBK 转换。
第三,写入必须是字节流,不是字符串。无论 Kotlin 的ByteArray、Swift 的Data还是 ArkTS 的Uint8Array,BLE 特征值写入的入参必须是字节序列,字符串直传在部分平台会静默失败。
这三个约束在三端完全一致,是契约层可以直接固化的部分。真正的差异在分包与节奏控制。
3.2 Android(Kotlin):主动协商 MTU,显式控制分包
Android 5.1+ 支持BluetoothGatt.requestMtu(512)主动协商 MTU,协商成功后实际可用负载为MTU - 3(ATT 头开销)。这意味着 Android 端有能力把面单指令切成更大的块,减少写入次数。但 MTU 协商是异步的,必须在onMtuChanged回调之后才开始写入。
一个务实的 Kotlin 实现用协程把回调地狱收敛为挂起函数,并按协商后的 MTU 串行分包:
kotlin
suspend fun writeCpcl(gatt: BluetoothGatt, char: BluetoothGattCharacteristic, payload: ByteArray) { val mtu = negotiateMtu(gatt, 512) // suspend,等待 onMtuChanged val chunkSize = (mtu - 3).coerceAtLeast(20) payload.toList().chunked(chunkSize).forEach { chunk -> val bytes = chunk.toByteArray() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { gatt.writeCharacteristic(char, bytes, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) } else { @Suppress("DEPRECATION") char.value = bytes @Suppress("DEPRECATION") gatt.writeCharacteristic(char) } // 等待 onCharacteristicWrite 回调后再发下一包 awaitWriteComplete() } }Android 端的核心风险不是“切多大”,而是“写多快”。WRITE_TYPE_NO_RESPONSE模式下连续快速写入会塞满打印机的蓝牙接收缓冲区,导致丢包或覆盖。工程上必须在每包之间保留 15–20ms 的间隔,或者严格依赖onCharacteristicWrite回调驱动下一包。
如果打印机同时支持经典 SPP,Android 端还有一条更简单的路径:SPP 走 RFCOMM socket,写字节流的语义更接近串口,不需要处理 BLE 的分包和 MTU。但 SPP 的socket.connect()必须在 IO 线程真正阻塞到连接建立,否则会出现“isConnected()为 true 但实际写不出去”的幽灵连接。
3.3 iOS(Swift):MTU 不可协商,写操作必须串行化
iOS 的 CoreBluetooth 不提供requestMtu,MTU 由系统与外设协商,应用层拿不到也改不了。开发者能控制的是写入策略:选withResponse还是withoutResponse,以及两次写入之间的节奏。
withResponse模式下,系统会在外设确认后回调didWriteValueFor,串行化天然有保障,但吞吐较低。withoutResponse更快,但必须自己维护队列,等上一包写完(或加延迟)再发下一包,否则同样会冲垮打印机缓冲区。
swift
func writeCpcl(_ payload: Data, to char: CBCharacteristic, on peripheral: CBPeripheral) { let chunkSize = 182 // 经验值,不超过协商MTU-3 var offset = 0 func writeNext() { guard offset < payload.count else { return } let end = min(offset + chunkSize, payload.count) let chunk = payload.subdata(in: offset..<end) peripheral.writeValue(chunk, for: char, type: .withResponse) // 回调驱动,天然串行 offset = end } writeNext() } func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { if error == nil { writeNext() } // 上一包确认后才发下一包 }iOS 端还有一个隐蔽的坑:特征值发现不能硬编码服务 UUID。不同打印机的可写特征值不同,正确做法是连接后遍历services和characteristics,优先匹配已知的打印机服务(如49535343-...、FF00),找不到时选第一个具备properties.contains(.write)或.writeWithoutResponse的特征值。
3.4 HarmonyOS(ArkTS):优先用厂商 SDK,自研走@ohos.bluetooth.ble
ArkTS 的蓝牙能力与 Android/iOS 是两套模型。经典蓝牙和 BLE 分开管理,权限需要按需申请并在module.json5中声明usedScene和reason,否则审核不通过。
对电子面单打印,优先选择已适配 HarmonyOS 的厂商 SDK。佳博的鸿蒙 SDK 支持 SPP、BLE、USB、网络四种连接,覆盖 CPCL、TSPL、ESC、ZPL 四种指令集,完全基于 ArkTS 开发,适配 API 14 及以上。PSDK 的 OpenHarmony SDK 同样支持 CPCL,并明确提供了enableChunkWrite和chunkSize参数来处理 BLE 分包,内置 GBK 编码转换。如果打印机型号在 SDK 支持范围内,直接接入是效率最高的路径。
自研 GATT 封装时,ArkTS 侧的核心约束与 iOS 类似:写操作必须串行,分包大小不能硬编码,要从连接参数中读取协商后的 MTU 再减去 3 字节的 ATT 头。区别在于 ArkTS 的异步模型是 Promise/async-await,分包写入需要await每一次writeCharacteristicValue的完成回调,而不是依赖事件监听。
四、蓝牙称重:协议解析是共性,数据稳定判定是平台差异
4.1 电子秤的 BLE 模型:Notify 是唯一入口
快递场景的蓝牙电子秤,绝大多数走 BLE GATT 的Notify(通知)模式:手机连接后使能对应特征值的通知,秤在重量变化或稳定时主动推送字节数组,APP 解析出重量值。
Android 端用setCharacteristicNotification+ 写ENABLE_NOTIFICATION_VALUE描述符来开启通知,然后在onCharacteristicChanged中解析字节。iOS 端用setNotifyValue(true, for:),在didUpdateValueFor中拿到Data并解析。鸿蒙端使用@ohos.bluetooth.ble的on('BLECharacteristicChange')监听特征值变化。
三端的共性:电子秤通常以 10–50Hz 的高频推送数据,必须在非主线程做字节解析,并设计防抖逻辑——连续 N 次读数相同且持续一定时间,才视为稳定重量。
4.2 字节解析:协议是设备相关的,不是平台相关的
电子秤的字节布局由厂商协议决定,与 Kotlin/Swift/ArkTS 无关。一个典型的解析逻辑(以小端 16 位整数 + 比例因子为例)在三端的写法几乎是一一对应的:
kotlin
// Kotlin fun parseWeight(data: ByteArray): Double { val raw = (data[1].toInt() and 0xFF) or ((data[2].toInt() and 0xFF) shl 8) return raw * 0.005 // 比例因子来自厂商协议 }swift
// Swift func parseWeight(_ data: Data) -> Double { let raw = UInt16(data[1]) | (UInt16(data[2]) << 8) return Double(raw) * 0.005 }typescript
// ArkTS function parseWeight(data: Uint8Array): number { const raw = (data[1] & 0xFF) | ((data[2] & 0xFF) << 8); return raw * 0.005; }真正需要平台适配的,是“何时认为重量已稳定”。Android 端可以利用协程的debounce操作符对通知流做防抖;iOS 端可以用 Combine 的debounce;ArkTS 没有内置的流式防抖,需要手写时间窗口判断。这部分逻辑放在领域层,用统一的WeightStabilizer契约封装,平台层只负责把原始字节流喂进来。
五、扫码摄像头:连续扫码的“去重”是三端最大的行为差异
5.1 默认扫码 vs 连续扫码
三端的系统扫码能力都有一个“默认行为”:扫到一条、返回、关闭。这适合登录或绑定,不适合快递流水线上“镜头对着包裹,扫一个录一个”的作业模式。
连续扫码的关键不是“能不能识别”,而是同一张码在视口内停留时,识别引擎会不会反复回调。Android 的 CameraXImageAnalysis以稳定的帧率持续分析,同一个条码在画面中不动,每一帧都会触发识别结果;iOS 的 VisionKitDataScannerViewController同样持续输出;鸿蒙的customScan在相机约 30fps 的驱动下,同一张码一秒可以回调多次。
不做去重,一秒内同一票货会被录入十几次。
5.2 Android(Kotlin):帧率控制 + 时间窗去重
CameraX + ML Kit 的连续扫码,去重策略有两个层面。第一层是降低分析帧率:不需要 30fps 都做识别,可以通过ImageAnalysis的setBackpressureStrategy或手动在分析器中加节流,把有效识别频率降到 2–5fps。第二层是业务去重:记录上一次识别的内容和时间戳,在时间窗内相同内容直接丢弃,同时用“跳过 N 帧 + 冷却门控”避免抖动。
kotlin
class ContinuousBarcodeAnalyzer( private val onResult: (String) -> Unit ) : ImageAnalysis.Analyzer { private val recent = mutableMapOf<String, Long>() private val ttlMs = 2000L override fun analyze(image: ImageProxy) { val barcode = detect(image) // ML Kit 识别 if (barcode != null) { val now = SystemClock.elapsedRealtime() val last = recent[barcode] if (last == null || now - last > ttlMs) { recent[barcode] = now onResult(barcode) } } image.close() } }Android 端的优势是可以完全控制分析管线,帧率、分辨率、识别区域都可以按场景调优;代价是需要自己处理 CameraX 的生命周期和图像格式转换。
5.3 iOS(Swift):VisionKit 开箱即用,但连续去重要自己兜
iOS 有两条路径。优先路径是 VisionKit 的DataScannerViewController:它把 AVCapture 和 Vision 封装成 Swift API,支持指定条码符号类型,持续识别视频流中的条码和文本。它的局限是 UI 和交互由系统控制,深度定制受限。
定制路径是 AVFoundation + Vision:自己搭建AVCaptureSession,在captureOutput中用VNDetectBarcodesRequest做识别。这条路径灵活,但需要自己处理会话配置、方向校正和性能调优。
无论走哪条路,去重逻辑都必须自己写。VisionKit 不会告诉你“这是同一张码的第几次回调”。一个实用的策略是“内容 + 时间窗 + 位置 IoU”:记录上一次识别的条码值和在画面中的归一化矩形,如果内容相同且矩形 IoU 较高,判定为同一张码仍在画面;连续若干帧未出现或位置明显跳变,再放行同内容的新扫描。
5.4 HarmonyOS(ArkTS):customScan的rescan是硬约束
鸿蒙的Scan Kit默认扫码界面只返回一次结果。连续扫码必须用customScan自建预览界面,并且在start的回调中调用customScan.rescan()触发下一帧。反复stop再start会导致错误码 7400201(相机服务异常),甚至页面卡死;rescan也只在start的回调中生效,Promise 方式调用无效。
去重方面,华为开发者社区给出的推荐方案是“内容 + 位置 + TTL”三维联合去重:ScanResult.scanCodeRect提供码在预览画面中的外接矩形,API 12 起还有cornerPoints;去重键为“内容 + 量化后的位置中心点”,TTL 建议 2000–3000ms;scanCodeRect为空时降级为纯内容去重。位置网格建议取 15–30px,过小则相机抖动产生新键,过大则不同位置的同内容码被误判为同一张。
六、契约层设计:三端收敛的最小接口集
三端适配层各自实现,契约层只暴露语义。以面单打印和扫码为例:
kotlin
// 契约层(平台无关语义) interface LabelPrinter { suspend fun print(label: LabelData): PrintResult } interface ContinuousScanner { fun start(onScan: (Barcode) -> Unit) fun stop() } interface ScaleReader { fun start(onWeight: (WeightReading) -> Unit) fun stop() }Kotlin、Swift、ArkTS 各自实现这三个接口。领域层的面单组装、幂等提交、称重稳定判定、扫码后的业务处理,全部写在契约之上,一份逻辑,三端共享(通过 KMP 或各自维护,取决于团队的技术栈选择)。
这样做的直接收益是:新增一款打印机时,只需要改平台适配层的一个实现类;扫码去重策略调整时,只改领域层的WeightStabilizer或ScanDeduplicator,三端行为一致。
七、避坑指南
坑1:把 Android 的 MTU 协商经验直接套到 iOS。iOS 不开放 MTU 协商,硬编码 Android 侧协商出的分包大小,在 iOS 上可能超出实际 MTU 导致写入失败。iOS 端的分包策略必须基于“写回调驱动 + 保守的 chunkSize”,而不是“协商后精确切分”。
坑2:鸿蒙连续扫码用stop/start代替rescan。这是鸿蒙 Scan Kit 最容易踩的坑。回调里只做业务处理,然后customScan.rescan();stop只在页面退出时调用。
坑3:电子秤通知开了,但解析在主线程。10–50Hz 的推送在主线程解析会阻塞 UI,严重时 ANR。三端都必须把字节解析和稳定判定放到后台线程/协程/异步任务。
坑4:面单打印“写入成功”就认为打出来了。BLE 写入成功只代表数据交给了蓝牙栈,不代表打印机已经执行。真正的成功信号要么来自打印机的状态通知(如果支持),要么来自人工确认。系统层面应当把“已发送”和“已打印”作为两个独立状态。
八、总结
Eyf 半字节 3.0 的三端原生架构,核心经验可以概括为三句话:
硬件差异留在最薄的一层:Kotlin 的 MTU 协商、Swift 的写回调串行化、ArkTS 的
rescan生命周期,各自解决各自的问题。领域层只面对LabelPrinter、ScaleReader、ContinuousScanner三个契约。面单的共性是指令层,差异是传输层:CPCL 的
\r\n结尾、GBK 编码、字节流写入三端一致;分包大小和写入节奏由平台适配层各自决定。连续扫码的“去重”是三端行为差异最大的地方:Android 靠帧率控制 + 时间窗,iOS 靠“内容 + 位置 IoU”,鸿蒙靠
rescan+ 三维联合去重。把去重策略放在领域层收敛,平台层只负责把原始识别结果喂进来。
> 本文作者:深圳+Eyf•半字节科技▪技术产品团队
> 物流、快递、快运作业管理系统,让每一票,都清晰可控。从任务分配到签收回传,一站式管理每个作业环节。
你在三端原生开发中遇到的最棘手问题是蓝牙分包的不一致,还是连续扫码去重的行为差异?欢迎在评论区分享具体场景。