资讯详情

资讯详情

Kotlin Multiplatform for OpenHarmony 实战:为 kable 实现 OpenHarmony 蓝牙低功耗(BLE)引擎

大家好我是熊猫钓鱼欢迎大家和我一起探讨技术。希望您能点赞关注谢谢摘要本文是「Kotlin Multiplatform 三方库鸿蒙化适配」系列的第四篇围绕kableJuul Labs 出品的 Kotlin Multiplatform 低功耗蓝牙库Apache-2.0在 OpenHarmony 上的适配展开。kable 把 BLE 塑造成了一套响应式流模型扫描是一个流、连接状态是一个流、特征变更是一个流。作者选择路线 A——用 ArkTS 实现BleScanner/BlePeripheral接口桥接系统kit.ConnectivityKit的ble模块而不是等待上游支持 ohosArm64 或自绘一套蓝牙协议。适配沿用系列一致的语义层 / 引擎层 / 验收页三层架构其中最大的挑战是鸿蒙 BLE 是回调式的扫描结果推事件、连接状态推事件、特征变更推事件和 kable「一切皆 Flow」的模型正好错位——作者用「语义层管理器统一派发事件」把回调翻译成了响应式。文中逐一记录 6 个基于 SDK 真实.d.ts签名的 ArkTS 踩坑并最终在HarmonyOS SDK 6.0.0(20)API 20下零错误编译出包HAP 约 621 KB。文末把 Decompose、Ktor、Notifier、kable 四篇并置把这套系列收口成一个判断适配的本质是替平台之间对不齐的模型写一层翻译。目录一个和前三篇都不太一样的起点路线取舍第一层语义层先把 kable 的契约画对第二层引擎层真正把 BLE 接到系统那道绕不过的弯回调式 BLE 怎么变成响应式第三层验收页能扫能连能读写那些只有踩过才知道的坑6 个 ArkTS 真签名坑怎么知道它真的活了编译 验证表和四篇连起来看是四种不同的「适配」三条我现在认准的判断工程信息工具链 / 版本 / 仓库 / 社区写完 Decompose、Ktor、Notifier 三篇我本来以为「适配」这件事的方法论已经收得差不多了——状态管理复刻、真接口真实现、系统能力桥接三板斧轮着用。直到动手做 kable我才发现蓝牙这一篇把前三篇里那个「平台模型错位」的命题推到了最极端。kableio.github.juul.kableApache-2.0做的是 Kotlin Multiplatform 的 BLE。它的核心体验是响应式的你拿到一个Scannerscanner.peripherals是个Flow设备进来就进流你拿到一个Peripheralconnect()、discoverServices()、读特征、写特征、订阅特征变更背后都是协程Flow在驱动。它在 Kotlin 侧把「蓝牙这种天生异步、事件驱动的东西」包装得很顺滑。我这次的目标就是让这套响应式模型在鸿蒙上长出真东西。我觉得还是很有意义的。一个和前三篇都不太一样的起点动手前我还是先想清楚路线。摆在面前的选择其实就两条路线做法为什么没选 / 选了路线 B等上游给 ohosArm64 的 expect/actualkable 上游自己支持鸿蒙我坐等上游目前没有 ohos 目标而且 BLE 本质就是要调系统蓝牙栈绕不开鸿蒙这层路线 B’自己实现一份 BLE 协议栈不碰系统 API纯 ArkTS 写 GAP/GATT那是重写一个蓝牙协议栈工作量不可控而且系统已经做得够好路线 AArkTS 实现 Ble 引擎桥接 kit.ConnectivityKit选真接系统 BLE 能力回调翻译成响应式语义同构能立刻跑验证直观选 A 我心里是踏实的理由和 Ktor、Notifier 一样系统已经把蓝牙做得足够好我没必要在适配阶段自己造轮子。但 kable 比前两篇更考验的一点是——它的「模型」和鸿蒙的「模型」长得最不像。Ktor 的「请求-响应」和鸿蒙ohos.net.http的「请求-响应」是同构的Notifier 的「发一条通知」和kit.NotificationKit的「发一条通知」是同构的只是点击回灌那一下错位。可 kable 的「一切皆 Flow」碰到鸿蒙 BLE 的「一切皆回调」中间隔着的不是一层薄翻译而是一整个事件模型的重写。kable 作用与代码描述kable 是 Juul Labs 开源的 Kotlin Multiplatform 低功耗蓝牙BLE库它以响应式的编程模型统一封装了蓝牙外设的扫描、连接、服务发现、特征读写与变更订阅等能力让一套 KMP 代码就能在 Android、iOS、桌面与鸿蒙等平台复用同一套蓝牙交互逻辑。在 OpenHarmony 上由于系统蓝牙能力集中在kit.ConnectivityKit的ble模块且必须以回调方式驱动本适配沿用「路线 A真接口真实现」的思路用 ArkTS 在鸿蒙侧等价实现 kable 的BleScanner/BlePeripheral契约把系统的回调式 BLE 事件翻译成语义层的响应式派发从而让上层 KMP 业务无需感知平台差异即可跑通完整蓝牙链路。代码采用三层架构组织ble/Ble.ets为语义层定义BleScanResult、BleGattService、BleCharacteristic、BleConnectionState、BleScanner、BlePeripheral与全局管理器KableBle纯逻辑、不引入任何kit.*bridge/OhosBle.ets为引擎层通过OhosBleScanner与OhosBlePeripheral桥接ble.startBLEScan、createGattClientDevice、connect、getServices、readCharacteristicValue、writeCharacteristicValue、setCharacteristicChangeNotification等系统 API完成回调到响应式模型的转换pages/KableDemo.ets为验收页按「扫描列表→连接→发现服务→读/写/订阅特征」的流程展示能力并输出事件日志另含「模拟演示无需蓝牙」按钮可在无蓝牙芯片的模拟器上直接走通扫描→连接→发现→读取全流程验证适配链路的正确性。开发代码界面如下不得不说Dev Eco 26最新版比之前功能更加丰富好用了看我写的demo都智能化标识了代码。第一层语义层先把 kable 的契约画对和前三篇一样我第一步没写引擎而是先写了个不碰任何kit.*的语义层Ble.ets。它把 kable 的契约原样画一遍/** 一次扫描发现的设备 */exportclassBleScanResult{readonlyid:string;name:string;rssi:number;connectable:boolean;rawData:ArrayBuffer;constructor(id:string,name:string,rssi:number,connectable:boolean,rawData:ArrayBuffer){/* ... */}}/** 一个 GATT 特征value 用 ArrayBuffer与系统侧一致 */exportclassBleCharacteristic{serviceUuid:string;characteristicUuid:string;value:ArrayBuffer;constructor(serviceUuid:string,characteristicUuid:string,value:ArrayBuffer){/* ... */}}/** 一个 GATT 服务 */exportclassBleGattService{serviceUuid:string;isPrimary:boolean;characteristics:BleCharacteristic[];constructor(serviceUuid:string,isPrimary:boolean,characteristics:BleCharacteristic[]){/* ... */}}/** 连接状态对应系统 ProfileConnectionState */exportenumBleConnectionState{DISCONNECTED0,CONNECTING1,CONNECTED2,DISCONNECTING3}/** 扫描器 / 外设外部只面对这两个接口 */exportinterfaceBleScanner{start(filterName?:string):void;stop():void;}exportinterfaceBlePeripheral{readonlyid:string;connect():Promisevoid;disconnect():void;discoverServices():PromiseBleGattService[];read(characteristic:BleCharacteristic):PromiseBleCharacteristic;write(characteristic:BleCharacteristic,value:ArrayBuffer,withResponse:boolean):Promisevoid;setNotify(characteristic:BleCharacteristic,enable:boolean):Promisevoid;}这一层一共 162 行它不import任何平台 API。kable 最值钱的不是它的平台实现是这套「扫描 / 连接 / 服务 / 特征 / 变更」的与平台无关的模型。只要模型画对了底层换成kit.ConnectivityKit就是顺理成章的事。第二层引擎层真正把 BLE 接到系统骨架画好引擎层OhosBle.ets把语义层的接口接到系统ble模块上。核心逻辑长这样已按 SDK 真实签名校正import{ble,constant}fromkit.ConnectivityKit;/** 扫描器桥接 ble.startBLEScan / ble.on(BLEDeviceFind) */exportclassOhosBleScannerimplementsBleScanner{privatereadonlyonFound:(results:Arrayble.ScanResult)void;constructor(){this.onFound(results:Arrayble.ScanResult):void{for(constrofresults){constmapped:BleScanResultnewBleScanResult(r.deviceId,r.deviceName,r.rssi,r.connectable,r.data);KableBle.dispatchScanResult(mapped);// 翻译回调 → 语义层派发}};}start(filterName?:string):void{if(this.scanning){return;}constfilters:Arrayble.ScanFilter[];if(filterName!undefinedfilterName.length0){filters.push({name:filterName});}ble.on(BLEDeviceFind,this.onFound);ble.startBLEScan(filters);this.scanningtrue;}stop():void{/* ble.off(...) ble.stopBLEScan() */}}/** 外设桥接 GattClientDevice 的 connect / discoverServices / 读写 / 订阅 */exportclassOhosBlePeripheralimplementsBlePeripheral{readonlyid:string;privatereadonlydevice:ble.GattClientDevice;constructor(id:string){this.idid;this.deviceble.createGattClientDevice(id);// 特征变更是设备级事件构造时挂一次按 uuid 过滤后派发this.device.on(BLECharacteristicChange,(changed:ble.BLECharacteristic):void{KableBle.dispatchCharacteristicChanged(newBleCharacteristic(changed.serviceUuid,changed.characteristicUuid,changed.characteristicValue));});}connect():Promisevoid{returnnewPromisevoid((resolve,reject):void{constonState:(change:ble.BLEConnectionChangeState)void(change):void{if(change.deviceId!this.id){return;}if(change.stateconstant.ProfileConnectionState.STATE_CONNECTED){this.device.off(BLEConnectionStateChange,onState);KableBle.dispatchConnectionState(BleConnectionState.CONNECTED,this.id);resolve();}elseif(change.stateconstant.ProfileConnectionState.STATE_DISCONNECTED){this.device.off(BLEConnectionStateChange,onState);KableBle.dispatchConnectionState(BleConnectionState.DISCONNECTED,this.id);reject(newBleException(BLE 连接失败或已断开,change.state));}};this.device.on(BLEConnectionStateChange,onState);this.device.connect();});}asyncdiscoverServices():PromiseBleGattService[]{constlist:Arrayble.GattServiceawaitthis.device.getServices();constmapped:BleGattService[][];for(constsoflist){constchars:BleCharacteristic[][];for(constcofs.characteristics){chars.push(newBleCharacteristic(c.serviceUuid,c.characteristicUuid,c.characteristicValue));}mapped.push(newBleGattService(s.serviceUuid,s.isPrimary,chars));}returnmapped;}asyncwrite(characteristic:BleCharacteristic,value:ArrayBuffer,withResponse:boolean):Promisevoid{consttarget:ble.BLECharacteristicthis.findSystemChar(characteristic);target.characteristicValuevalue;constwriteType:ble.GattWriteTypewithResponse?ble.GattWriteType.WRITE:ble.GattWriteType.WRITE_NO_RESPONSE;awaitthis.device.writeCharacteristicValue(target,writeType);}// read / setNotify 同理都是「系统调用 语义层派发」的薄翻译}你看createGattClientDevice/connect/getServices/readCharacteristicValue/writeCharacteristicValue/setCharacteristicChangeNotification这几个调用名字和 Android 的BluetoothGatt几乎是镜像的。这就是我开头说的「最舒服的适配」——契约对得上系统能力也对得上中间只隔一层很薄的翻译。那道绕不过的弯回调式 BLE 怎么变成响应式但「最舒服」是错觉因为 kable 和鸿蒙之间最大的不同正好戳在 kable 最金贵的能力上。kable 把扫描、连接、特征变更都做成了Flow——你订阅流事件自己来。鸿蒙 BLE 不是。ble.startBLEScan之后设备是通过ble.on(BLEDeviceFind, callback)一个一个推给你的连接状态是通过GattClientDevice.on(BLEConnectionStateChange, callback)推给你的特征变更是通过on(BLECharacteristicChange, callback)推给你的。三个独立的回调入口没有任何「流」的概念。换句话说kable 的「响应式体验」在鸿蒙上得靠我这一层KableBle管理器来重建。这就是我说的「绕一道弯」也是这一篇比前两篇更考验平台判断的地方——你得先想清楚kable 的Flow在鸿蒙的世界里本质上是一堆散落的回调你得把它们收口到一个派发中心再让页面去订阅。我是这么接的扫描器订阅ble.on(BLEDeviceFind)每来一批结果逐条KableBle.dispatchScanResult(...)。外设构造时订阅on(BLECharacteristicChange)变更来了KableBle.dispatchCharacteristicChanged(...)。连接时订阅on(BLEConnectionStateChange)状态变了KableBle.dispatchConnectionState(...)并在 CONNECTED 时 resolve 那个Promisevoid。页面只跟KableBle打交道注册一个BleListener扫描结果、连接状态、特征变更就从三个回调入口统一回流到页面的日志卡。写这一段的时候我有点感慨kable 把「蓝牙这种异步事件」抽象成两条干净的流到鸿蒙上这两条流被拆成了一圈on/off事件订阅 一个派发中心 一堆 uuid 过滤。抽象没变但「抽象落到平台」的那一步辛苦程度完全不一样。适配的含金量往往就藏在这种「抽象和平台对不齐」的缝隙里。顺带说一个已知的小遗憾连接我用了Promisevoid 内部超时靠系统状态事件没有设硬超时如果设备不在范围内connect()可能迟迟不 resolve。对于 demo 主路径——设备在线、走 CONNECTED——这件事是稳的。要彻底解决可以给connect()加一个setTimeout兜底 reject。这篇先不展开留个口子。第三层验收页能扫能连能读写为了让这个引擎「看得见摸得着」我写了KableDemo.ets验收页和 Ktor / Notifier 页平级首页点按钮切换。这一页要证明四件事自研BleScanner真能调kit.ConnectivityKit扫到设备点列表项能connectdiscoverServices把 GATT 树拉出来read/write/setNotify真能打到系统 BLE连接状态变化、特征变更能回灌到页面日志。「换引擎」同样只要一行aboutToAppear():void{KableBle.setScanner(newOhosBleScanner());// 这一行就是「换引擎」this.listenernewDemoListener(/* 三个回调扫描/连接/变更 → 写进日志卡 */);KableBle.addListener(this.listener);}DemoListener是用一个具体 class 实现BleListener的——原因下面「ArkTS 坑」里会讲直接把对象字面量赋给这个接口在 ArkTS 下会踩红线。通过编译部署执行效果如下搜索匹配运行测试那些只有踩过才知道的坑这一节留给想照着做的朋友。下面每一个都是我在hvigor的红字里一个个认出来的没有一个是我提前知道的。BLECharacteristic的对象字面量缺descriptors字段会编译失败。我在findSystemChar的兜底分支写了{ serviceUuid, characteristicUuid, characteristicValue }编译器当场不认Property descriptors is missing。去翻 SDK 的ble.d.ts才发现BLECharacteristic里descriptors: ArrayBLEDescriptor是必填字段不像properties/permissions那样带?。兜底对象补上descriptors: []就过了。一句话系统类型里看似可选的东西未必真的可选以.d.ts为准。模块级函数不能用this.调。我把bufToHexArrayBuffer 转十六进制纯工具函数写在文件顶部模块作用域页面里却写成this.bufToHex(c.value)编译器报Property bufToHex does not exist on type KableDemo。ArkTS 里模块函数不走this直接bufToHex(...)调即可。这个坑小但第一次见挺懵。List组件没有maxHeight。我想给扫描列表限个高写了.layoutWeight(1).maxHeight(180)编译器说Property maxHeight does not exist on type ListAttribute。低版本 ArkUI 的List确实没暴露maxHeight用height(180)固定高度即可List 放在Scroll里固定高度比maxHeight更稳妥。Row组件没有wrapContent。我给按钮行写了.width(100%).wrapContent()编译器照样不认。ArkUI 的Row没有wrapContent这个属性——要么用Flex容器开wrap要么直接.width(100%)让按钮按比例排。我选了后者。对象字面量不能直接赋给含方法签名的接口。这是最让我意外的一个和 Notifier 那篇一模一样。我本来写const listener: BleListener { onScanResult: (r) {...}, ... }编译器报Object literal must correspond to some explicitly declared class or interface。原因大概是BleListener的方法参数用到了具体类型ArkTS 的arkts-no-untyped-obj-literals规则对这个组合特别敏感。解决办法是用一个具体 class 实现接口像上面的DemoListener通过构造函数把闭包注进去——class 实现接口不受这条字面量规则约束。BLE 扫描取设备名运行时往往还要位置权限。编译期ble.startBLEScan只声明ohos.permission.ACCESS_BLUETOOTH我在module.json5里也只加了这一个。但真机上Android/iOS 那套「扫 BLE 拿设备名需要位置权限」的规则在鸿蒙上同样存在——不授予位置权限deviceName常常是空的。这件事我如实记在这里没藏着demo 已声明ACCESS_BLUETOOTH真机验证时若设备名为空先去查位置权限。怎么知道它真的活了验证分两层和前三篇一致。第一层是编译。我在HarmonyOS SDK 6.0.0(20)API 20下对工程做了编译验证BUILD SUCCESSFUL零 ArkTS error仅余若干router.back废弃告警与「函数可能抛异常」提示属已知噪音。产出的entry-default-unsigned.hap约 621 KB未签名模拟器直接能装。这一版 HAP 比 Notifier 那篇的 529 KB 又大了一些因为多了 BLE 这层适配代码符合预期。第二层才是真刀真枪DevEco 里起真机或开发板模拟器没有蓝牙芯片跑不了 BLE装好 HAP首页点「KMP kable 蓝牙适配 DemoBLE 扫描/连接/GATT→」进去点按钮。各按钮的预期我列一下方便你对着查按钮你该看到什么开始扫描日志卡出现scan 开始扫描附近 BLE 设备出现在列表需真机开蓝牙位置权限点列表里的设备状态变「连接中」连上后日志conn 已连接下方出现该设备的服务树点一个特征该特征标「✔已选」读特征日志read带 uuid 和十六进制特征值写特征(KMP)日志write带 uuid往特征写 ASCII “KMP”订阅通知日志notify 已订阅外设主动发数时日志changed带新值断开日志conn 已断开说句心里话当我在真机上点开扫描、看着列表一条条冒出来、点连接再看到 GATT 树展开的时候那种「它真的和系统蓝牙栈通了」的踏实感和当初 Ktor 点出第一个 200、Notifier 点开通知栏是同一种。只不过这次我还得多确认一件事扫描的回调、连接的回调、特征的回调能原路回到我的派发中心、再回到页面监听者——那才是 kable 的魂。和四篇连起来看是四种不同的「适配」写到这里我想把 Decompose、Ktor、Notifier、kable 四篇连起来因为我觉得这才是整个系列最值得讲清楚的一点。维度DecomposeKtorKMPNotifierkable本质纯状态管理真实网络能力真实系统能力通知真实系统能力BLE鸿蒙上怎么做的ArkTS等价复刻ArkTS真实现 HttpClientEngineArkTS真实现 LocalNotifierArkTS真实现 Ble 引擎最难的弯组件树根必须在宿主创建一次DNS/TLS 交给系统别硬刚点击/动作不能直接回调WantAgent 回灌回调式 BLE 没有 Flow自己翻译成派发.so就绪后替换桥接层替换引擎层替换引擎层替换引擎层换 Kotlin 侧实现验证难度相对容易无外部依赖难真联网、权限、DNS/TLS 要通中不用联网权限回灌要通中需真机/开发板无芯片模拟器跑不了Decompose 考验耐心把状态机重写一遍Ktor 考验你对网络边界的判断别去造 TLSNotifier 考验你对「事件模型」落地的判断Android/iOS 的回调在鸿蒙上要翻译成 Ability 启动kable 考验你对「响应式模型」落地的判断kable 的 Flow在鸿蒙上得翻译成一个派发中心。四篇下来我越来越确信一句话适配的难从来不在「翻译 API」而在「翻译那些平台之间对不齐的模型」。三条我现在认准的判断折腾完这一圈有四件事我想得很清楚先看抽象层再决定写什么。kable 把「BLE」抽成了Scanner/Peripheral这两个接口加一套事件模型所以我的适配说到底是「实现接口 重建事件派发」。抽象画在哪儿工作量就在哪儿。能复用平台能力就别硬刚平台短板。蓝牙直接交给kit.ConnectivityKitTLS 那种「Ktor 式」的坑这里根本没有唯一要自己补的是把回调翻译成响应式——而这恰恰是无法交给系统的必须自己接。事件模型对不齐的时候别试图强行 1:1 映射。kable 的「Flow」在鸿蒙上硬要找一个等价物比如某个内置流是找不到的。与其拧巴不如承认「BLE 就是回调式」这个事实把派发做成一个清晰的KableBle单例。承认平台差异反而写得最顺。四篇收口适配 替对不齐的模型写翻译。Decompose 翻译「组件树」Ktor 翻译「网络边界」Notifier 翻译「点击回灌」kable 翻译「回调→响应式」。它们表面是四个库底层是同一件事——在 Kotlin 的抽象和鸿蒙的系统能力之间补一层你自己的翻译层。工程信息Demo 工程E:\huawei\hongmengdev\demo在 Decompose / Ktor / Notifier demo 基础上新增 kable 三文件 首页入口复用同一工程不破坏原有功能新增代码约 634 行 ArkTSBle.ets162 OhosBle.ets157 KableDemo.ets315另在module.json5增加ohos.permission.ACCESS_BLUETOOTH权限声明与字符串资源工具链DevEco Studio 26.0.0 Release · HarmonyOS SDK6.0.0(20)API 20· KMPCMP 鸿蒙社区工具链 v1.1.0Kotlin 2.2.21 / CMP 1.9.2目标设备HarmonyOS 手机 ROM 6.1BLE 需真机/开发板模拟器无蓝牙芯片DevEco 模拟器可装包但跑不了扫描适配路线路线 A自研Ble引擎桥接kit.ConnectivityKit的ble模块回调式翻译成语义层派发关键权限ohos.permission.ACCESS_BLUETOOTH扫描取设备名真机常还需位置权限适配后仓库https://atomgit.com/wdracky/kmp-ohos-adapters/tree/main/kableKMP/CMP 鸿蒙化社区https://atomgit.com/CPF-KMP-CMP欢迎加入KMPCMP 鸿蒙社区 https://atomgit.com/CPF-KMP-CMP推荐 AtomCode https://developer.huaweicloud.com/codeartsco.html?sourcedmzntgwatomgit1sourceaddmzntgwatomgiths顺手说一句到这篇KMP 三方库鸿蒙化适配系列的第一阶段Decompose / Ktor / Notifier / kable 四篇就算收口了。每一篇走的都是「语义层不碰平台、引擎层桥接系统、验收页真机验证」的三层架构区别只在平台模型错位的那道弯长什么样。后续若要往下走最值得补的反而是前面几篇都提到的「冷启/超时兜底」——把没接住的事件先缓存等页面就绪再补派。那是把 demo 级适配推向生产级适配的最后一公里。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →