首页 / 资讯中心 / 文章详情

〖实战〗Eyf 半字节 3.0 APP 三端原生架构:Kotlin、Swift、ArkTS 下的蓝牙面单、蓝牙称重与扫码摄像头

〖实战〗Eyf 半字节 3.0 APP 三端原生架构:Kotlin、Swift、ArkTS 下的蓝牙面单、蓝牙称重与扫码摄像头 ★ FEATURED ARTICLE
一、三端原生的真正难点不在“写三遍”而在“三套硬件抽象层互不兼容”Eyf 半字节 3.0 是一个面向快递末端与网点作业的 APP核心能力集中在三件事蓝牙电子面单打印、蓝牙电子秤称重、摄像头连续扫码。这三个能力有一个共同特征——它们都直接操作硬件而 Kotlin、Swift、ArkTS 对同一硬件的抽象模型完全不同。Android 的 BLE 允许应用主动协商 MTUrequestMtu开发者可以精细控制分包大小和写入节奏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 的Uint8ArrayBLE 特征值写入的入参必须是字节序列字符串直传在部分平台会静默失败。这三个约束在三端完全一致是契约层可以直接固化的部分。真正的差异在分包与节奏控制。3.2 AndroidKotlin主动协商 MTU显式控制分包Android 5.1 支持BluetoothGatt.requestMtu(512)主动协商 MTU协商成功后实际可用负载为MTU - 3ATT 头开销。这意味着 Android 端有能力把面单指令切成更大的块减少写入次数。但 MTU 协商是异步的必须在onMtuChanged回调之后才开始写入。一个务实的 Kotlin 实现用协程把回调地狱收敛为挂起函数并按协商后的 MTU 串行分包kotlinsuspend 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回调驱动下一包。如果打印机同时支持经典 SPPAndroid 端还有一条更简单的路径SPP 走 RFCOMM socket写字节流的语义更接近串口不需要处理 BLE 的分包和 MTU。但 SPP 的socket.connect()必须在 IO 线程真正阻塞到连接建立否则会出现“isConnected()为 true 但实际写不出去”的幽灵连接。3.3 iOSSwiftMTU 不可协商写操作必须串行化iOS 的 CoreBluetooth 不提供requestMtuMTU 由系统与外设协商应用层拿不到也改不了。开发者能控制的是写入策略选withResponse还是withoutResponse以及两次写入之间的节奏。withResponse模式下系统会在外设确认后回调didWriteValueFor串行化天然有保障但吞吐较低。withoutResponse更快但必须自己维护队列等上一包写完或加延迟再发下一包否则同样会冲垮打印机缓冲区。swiftfunc 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 HarmonyOSArkTS优先用厂商 SDK自研走ohos.bluetooth.bleArkTS 的蓝牙能力与 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 的debounceArkTS 没有内置的流式防抖需要手写时间窗口判断。这部分逻辑放在领域层用统一的WeightStabilizer契约封装平台层只负责把原始字节流喂进来。五、扫码摄像头连续扫码的“去重”是三端最大的行为差异5.1 默认扫码 vs 连续扫码三端的系统扫码能力都有一个“默认行为”扫到一条、返回、关闭。这适合登录或绑定不适合快递流水线上“镜头对着包裹扫一个录一个”的作业模式。连续扫码的关键不是“能不能识别”而是同一张码在视口内停留时识别引擎会不会反复回调。Android 的 CameraXImageAnalysis以稳定的帧率持续分析同一个条码在画面中不动每一帧都会触发识别结果iOS 的 VisionKitDataScannerViewController同样持续输出鸿蒙的customScan在相机约 30fps 的驱动下同一张码一秒可以回调多次。不做去重一秒内同一票货会被录入十几次。5.2 AndroidKotlin帧率控制 时间窗去重CameraX ML Kit 的连续扫码去重策略有两个层面。第一层是降低分析帧率不需要 30fps 都做识别可以通过ImageAnalysis的setBackpressureStrategy或手动在分析器中加节流把有效识别频率降到 2–5fps。第二层是业务去重记录上一次识别的内容和时间戳在时间窗内相同内容直接丢弃同时用“跳过 N 帧 冷却门控”避免抖动。kotlinclass ContinuousBarcodeAnalyzer( private val onResult: (String) - Unit ) : ImageAnalysis.Analyzer { private val recent mutableMapOfString, 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 iOSSwiftVisionKit 开箱即用但连续去重要自己兜iOS 有两条路径。优先路径是 VisionKit 的DataScannerViewController它把 AVCapture 和 Vision 封装成 Swift API支持指定条码符号类型持续识别视频流中的条码和文本。它的局限是 UI 和交互由系统控制深度定制受限。定制路径是 AVFoundation Vision自己搭建AVCaptureSession在captureOutput中用VNDetectBarcodesRequest做识别。这条路径灵活但需要自己处理会话配置、方向校正和性能调优。无论走哪条路去重逻辑都必须自己写。VisionKit 不会告诉你“这是同一张码的第几次回调”。一个实用的策略是“内容 时间窗 位置 IoU”记录上一次识别的条码值和在画面中的归一化矩形如果内容相同且矩形 IoU 较高判定为同一张码仍在画面连续若干帧未出现或位置明显跳变再放行同内容的新扫描。5.4 HarmonyOSArkTScustomScan的rescan是硬约束鸿蒙的Scan Kit默认扫码界面只返回一次结果。连续扫码必须用customScan自建预览界面并且在start的回调中调用customScan.rescan()触发下一帧。反复stop再start会导致错误码 7400201相机服务异常甚至页面卡死rescan也只在start的回调中生效Promise 方式调用无效。去重方面华为开发者社区给出的推荐方案是“内容 位置 TTL”三维联合去重ScanResult.scanCodeRect提供码在预览画面中的外接矩形API 12 起还有cornerPoints去重键为“内容 量化后的位置中心点”TTL 建议 2000–3000msscanCodeRect为空时降级为纯内容去重。位置网格建议取 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•半字节科技▪技术产品团队 物流、快递、快运作业管理系统让每一票都清晰可控。从任务分配到签收回传一站式管理每个作业环节。你在三端原生开发中遇到的最棘手问题是蓝牙分包的不一致还是连续扫码去重的行为差异欢迎在评论区分享具体场景。
阅读完成 · 觉得有帮助?
咨询建站