云计算百科
云计算领域专业知识百科平台

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

一、三端原生的真正难点,不在“写三遍”,而在“三套硬件抽象层互不兼容”

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 的 CameraX ImageAnalysis 以稳定的帧率持续分析,同一个条码在画面中不动,每一帧都会触发识别结果;iOS 的 VisionKit DataScannerViewController 同样持续输出;鸿蒙的 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•半字节科技▪技术产品团队
    > 物流、快递、快运作业管理系统,让每一票,都清晰可控。从任务分配到签收回传,一站式管理每个作业环节。

    你在三端原生开发中遇到的最棘手问题是蓝牙分包的不一致,还是连续扫码去重的行为差异?欢迎在评论区分享具体场景。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 〖实战〗Eyf 半字节 3.0 APP 三端原生架构:Kotlin、Swift、ArkTS 下的蓝牙面单、蓝牙称重与扫码摄像头
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!