做鸿蒙适配这一年,我最深的感触是:UI 和业务逻辑迁移其实没那么难,真正卡住进度的,往往是一个不起眼的原生插件。项目里用的是 Flutter 的蓝牙插件 flutter_blue_plus,平时在 Android/iOS 上跑得好好的,一搬到 OpenHarmony 上就哑火了——扫描没结果、连接没回调、服务发现直接超时。查了一圈发现插件压根没有鸿蒙端的原生实现,社区里也找不到现成的轮子。没办法,只能自己动手在 OpenHarmony 侧补一套原生逻辑,把 flutter_blue_plus 的 Dart 调用接住。最后做出来的效果是:上层业务代码一刀没改,蓝牙扫描、连接、服务发现、特征读写、通知订阅全部跑通。这篇就是我整个适配过程的完整复盘,包括底层原理、代码实现、踩坑记录,希望能给同样在搞 Flutter 鸿蒙化的人省点时间。
1. 为什么 Flutter 上鸿蒙,蓝牙这件小事成了大麻烦
1.1 项目背景:Flutter 应用要跑在 OpenHarmony 上
先说下背景。我手上的项目是一个设备控制类 App,核心功能是通过蓝牙 BLE 连接硬件设备,做参数配置和数据采集。App 本身是用 Flutter 写的,主要跑 Android,后来要适配鸿蒙生态,目标系统是 OpenHarmony。
刚开始我以为工作量不大。Flutter 的 OpenHarmony 适配社区已经做了一段时间,基础 UI、网络、本地存储这些场景基本能跑。但等我把功能清单过一遍,发现蓝牙这块完全没有着落。
项目里的蓝牙能力全部依赖 flutter_blue_plus 这个插件。这个插件是 Flutter 社区目前最常用的 BLE 库,底层在 Android 用 Kotlin 封装了系统蓝牙 API,在 iOS 用 Swift 封装了 CoreBluetooth,逻辑成熟、接口稳定。但它的官方实现里没有 OpenHarmony 这一端,也就是说,你在鸿蒙设备上跑这个插件,会在原生层直接报“找不到实现”。
当时的可选方案有三个:等官方支持、换别的插件、自己适配。前两个都不可行——官方路线遥遥无期,换插件意味着上层所有蓝牙业务代码要重写,代价更大。唯一可行的路就是,在 OpenHarmony 侧补一套原生实现,让 flutter_blue_plus 的 Dart 层调用能找到“接盘侠”。
1.2 插件生态是 Flutter 鸿蒙化的最大短板
这个项目做完之后,我更加确认了一个判断:Flutter 迁移鸿蒙,真正的难点从来不是 Flutter 框架本身,而是插件生态。
Flutter 框架的鸿蒙适配属于“社区推动型”,已经有人在做,核心渲染、Dart 运行时、PlatformView 这些基础能力陆续在补齐。但第三方插件就参差不齐了。像 flutter_blue_plus 这种涉及系统级能力的插件,官方不会主动支持 OpenHarmony,社区也没几个人去适配,最后只能项目方自己干。
而且蓝牙不是个“能跑就行”的功能。它牵扯系统权限、GATT 协议、多状态回调、线程切换,任何一个环节断了,表现就是扫描不到设备、连接不上、数据读不出来。这种功能一旦适配不彻底,后面调试的成本比重新写一遍还高。
所以我在动手之前就定了一个原则:不能简单“能用”,要尽量做到上层 Dart 代码零改动,把 flutter_blue_plus 现有的调用语义完整地映射到鸿蒙侧。这样后续插件升级、业务扩展,我们不需要再回头改适配层。
1.3 适配方案怎么选:fork、wrapper、还是补全端实现
动手之前还有一个路线选择的问题。基于 flutter_blue_plus 做鸿蒙适配,业内常见的做法无非三种。
第一种是直接 fork 插件,在源码里加一个 OpenHarmony 平台判断,然后跳转到自己写的原生实现。好处是简单直接,坏处是以后想同步插件上游更新会非常痛苦,每次都得手动合并。
第二种是封装一层“假插件”,不真正实现蓝牙逻辑,而是通过 MethodChannel 转发给另一个自定义插件处理。这种方案的好处是不动上游代码,坏处是多了一层跳转,调用链路变长,调试起来会绕。
第三种是采用 Flutter 官方的 federated plugin 机制,把 flutter_blue_plus 改造成一个多端架构的插件,为 OpenHarmony 单独注册一个平台实现包。这种做法是最规范的,和 flutter_blue_plus 本身的架构也契合,但它要求你先把插件源码内部结构吃透,改造量最大。
我最终选择了第三种思路的简化版:不把工程改造成完整的 federated plugin,而是把 flutter_blue_plus 的代码拉下来,在它的 Android 实现旁边增加一个 OpenHarmony 平台的入口,复用它的 MethodChannel 协议,自己写鸿蒙端原生逻辑。这样上层业务代码不用动,插件升级时只要重点看通道协议有没有变化就好。
2. 先拆插件:flutter_blue_plus 的通道路由设计
2.1 Flutter 与原生通信的三个通道
要适配 flutter_blue_plus,先得搞明白 Flutter 和原生之间到底怎么通信。
Flutter 定义了一套 Platform Channel 机制,开发者常用的主要是三种通道。MethodChannel 是“你问我答”式的方法调用,Flutter 侧发起一个方法名和参数,原生侧处理后返回结果,适合扫描、连接、读写这种一次性操作。EventChannel 是“你监听我推送”式的事件流,原生侧主动往 Flutter 侧推送数据,适合蓝牙状态变化、特征值通知这类持续回调。BasicMessageChannel 是双向消息传递,用的场景相对少。
flutter_blue_plus 的通信设计就是 MethodChannel 和 EventChannel 的组合拳:Dart 层调用像 startScan、connect、writeCharacteristic 这样的方法,全部走 MethodChannel;而设备发现、连接状态变化、特征值通知这些事件,则通过 EventChannel 或者 MethodChannel 的反向 invokeMethod 推给 Dart 层。
这意味着,如果鸿蒙侧想“冒充”Android,关键不在于 UI 怎么写,而在于你能不能精确复现这套通道协议,让 Dart 层感知不到对面换了操作系统。
2.2 flutter_blue_plus 能力清单与通道方法映射
适配工程里最琐碎但也最重要的一步,是把 flutter_blue_plus 全部的能力列出来,逐一确认鸿蒙侧用什么 API 对应。
我从源码里梳理出一份核心能力清单,大致包括这些:蓝牙状态获取与监听、设备扫描与停止扫描、连接与断开连接、服务发现、特征值读写、特征值通知开关与监听、MTU 设置,以及一些设备基本信息获取。
以扫描为例,Dart 层调用 startScan,会拼一个 Map 作为参数通过 MethodChannel 发到原生侧,里面包括扫描的服务 UUID 过滤条件、超时时间、是否允许重复上报。原生侧扫到设备后,需要把设备 ID、设备名、RSSI、广播数据、服务 UUID 列表等信息包装成固定结构往回传。
这里要特别提醒一下,flutter_blue_plus 的通道方法是动态拼接的,也就是说方法名不是简单写死在 switch 里的,而是带设备 ID、服务 UUID 这类参数组成一个唯一的 channel。最典型的例子是:读取特征值这个方法,传入的参数是特征值实例 ID,你不能靠已知的设备地址去定位操作对象,必须维护好“Dart 侧实例 ID 到鸿蒙侧 GATT 对象”的映射关系。这个映射关系很容易被忽略,但恰恰是适配最容易出错的地方。
2.3 为什么“序列化格式”是适配成败的关键
很多人在适配时有个误区,觉得只要把方法名对上,能返回数据就行。但实际上,flutter_blue_plus 的 Dart 层对原生侧返回的数据结构有严格的解析逻辑,字段名错了、类型不对,都会导致序列化失败或者运行时异常。
举几个例子。设备扫描结果里,deviceId 在 Android 端返回的是 MAC 地址字符串,鸿蒙端返回的也必须是字符串,而且最好保持同一种格式,否则 Dart 层拿来当 map key 会出现奇奇怪怪的问题。蓝牙值数据的传输用的是字节数组,Flutter 侧的 typed_data 在 MethodChannel 里会被序列化成标准类型,鸿蒙侧必须做 Uint8Array 和 ArrayBuffer 之间的转换,不能直接当普通数组处理。还有枚举状态值,连接状态这个字段 Android 返回 0/1/2,鸿蒙侧就得把系统状态码转换成 Dart 层认识的语义,一字不差。
这一步没有捷径,只能老老实实对照 Android 端的实现代码,把每个字段的类型和含义确认好。我建议在正式开始写鸿蒙代码之前,先把 Android 端 FlutterBluePlusPlugin 里的 methodCallHandler 完整读一遍,在纸上把方法名、参数 key、返回值结构画成一张表,这张表就是后续所有工作的设计文档。
3. OpenHarmony 蓝牙 API 摸底与能力对齐
3.1 鸿蒙侧蓝牙模块:bluetoothManager 能干什么
OpenHarmony 系统本身提供了蓝牙能力,API 封装在 bluetoothManager 这个模块里。整体能力上和 Android 原生蓝牙 API 高度相似,毕竟 BLE 协议栈的底层逻辑是相通的。
扫描能力方面,bluetoothManager 支持 startScan 和 stopScan,同时可以监听设备发现事件。连接能力方面,它通过 createGattClientDevice 创建一个 GATT 客户端,然后调用 connect 方法发起连接,并监听连接状态变化。连接成功之后,可以调用 getServices 获取服务列表,然后进一步读取特征值、写入特征值、开启通知。
有一点和 Android 不太一样。Android 的 BLE 扫描有比较灵活的 ScanFilter 和 ScanSettings,可以按厂商数据、服务 UUID、信号强度做过滤;OpenHarmony 的扫描 API 相对简洁,全局扫描为主,服务 UUID 过滤能力也有,但参数没有 Android 那么细,需要自己在回调里做二次过滤。
另外,从 API 版本演进来看,老版本用的是@ohos.bluetooth这种导入方式,新版本推荐统一从@kit.BluetoothKit里拿,不同系统版本之间会有差异。开发前最好先确认目标设备的系统 API 版本,避免出现模块找不到的问题。
3.2 能力比对表:Android/iOS/OpenHarmony 蓝牙能力差异
我把 flutter_blue_plus 用到的核心能力在三个平台上的实现方式做了一张比对表,这样列出来比较直观。
| 能力项 | Android 实现思路 | iOS 实现思路 | OpenHarmony 实现思路 |
|---|---|---|---|
| 权限声明 | 蓝牙扫描/连接权限 + 定位权限 | Info.plist 描述文案 | module.json5 声明蓝牙权限 |
| 设备扫描 | BluetoothLeScanner + ScanCallback | CBCentralManager scanForPeripherals | bluetoothManager.startScan + 设备发现监听 |
| 连接管理 | BluetoothGatt.connect + 回调 | CBCentralManager connect | createGattClientDevice + connect |
| 服务发现 | BluetoothGatt.discoverServices | discoverServices 回调 | gattClient.getServices |
| 特征读写 | writeCharacteristic/readCharacteristic | writeValue/readValueForCharacteristic | gattClient.writeCharacteristicValue / readCharacteristicValue |
| 通知订阅 | setCharacteristicNotification + 描述符写入 | setNotifyValue | gattClient.setCharacteristicChangeNotification |
| 状态监听 | BroadcastReceiver + 系统广播 | centralManagerDidUpdateState | bluetoothManager.on('stateChange') |
从表里能看出来,OpenHarmony 的蓝牙能力覆盖得还是比较全的,关键路径上一个没缺。这对适配工作是很大的利好,说明不是“没得做”,而是“怎么对齐”的问题。
3.3 适配范围与取舍:先跑通哪些,后补哪些
能力全覆盖是一回事,实际项目要不要全部实现是另一回事。第一次做适配时,我的建议是分阶段来,不要一上来就追求 100% 功能一致。
第一阶段只做最核心的链路:初始化、扫描、连接、获取服务、读写特征值、通知开关与监听。这一套跑通,App 的主业务流程基本就能用了。
第二阶段再补齐增强能力:MTU 协商、多连接管理、广播数据解析、重连机制、后台权限兼容等。这些功能虽然重要,但不影响第一版 Demo 的验证,没必要在第一周就全扑上去。
我实际项目中,第一阶段花了两周,第二阶段陆续又花了一个多月,边用边补。有些边角功能比如厂商私有扩展指令,其实是后面硬件那边提出新需求才加的,前期做了大概率也是白做。
不要试图一次做完。适配的本质是“协议对齐”,而协议本身是活的,你只有先把主干跑起来,才能在真机联调中发现哪些字段被漏了、哪些语义理解错了。
4. 核心实操:手写一个 flutter_blue_plus 的 OpenHarmony 原生实现
4.1 工程准备:Flutter、DevEco Studio、SDK 版本
开始写代码之前,先把环境搭好。适配 flutter_blue_plus 到 OpenHarmony,本质上要做的是一个 Flutter 插件工程里的 OpenHarmony 端原生模块,所以环境要具备两个能力:能编译 Flutter,能编译 OpenHarmony 应用。
开发机上建议安装 Flutter SDK 和 DevEco Studio,OpenHarmony 的 SDK 通过 DevEco Studio 的 SDK Manager 单独安装。另外需要注意,Flutter 跑 OpenHarmony 需要用带鸿蒙适配的 Flutter SDK 分支,不是官方主线,社区有几个维护中的 fork,选一个活跃度高的就好。
这里还有个小坑。DevEco Studio 的版本和 OpenHarmony SDK 版本是有对应关系的,版本不匹配会导致工程创建失败或者编译报错。建议直接用 DevEco Studio 默认配套的 SDK 版本,不要手贱去升级到最新的 SDK,因为 Flutter 鸿蒙适配分支不一定跟得上系统 API 的变化。
工程结构上,我是在 Flutter 插件工程里执行了flutter create --template=plugin --platforms=ohos这种思路,先让工程具备 OpenHarmony 侧的平台目录,然后把 flutter_blue_plus 的 Dart 源码作为普通依赖引进来调试。插件工程的 ohos 目录下通常包含一个 Index.ets 和对应的原生模块,等等要做的核心工作都在这个模块里。
4.2 第一步:权限声明,这是大多数人踩的第一个坑
鸿蒙应用访问蓝牙,必须在 module.json5 里声明权限,这点和 Android 的 AndroidManifest 声明类似。如果漏了权限,代码本身不会报错,但系统会在 API 层静默拒绝,表现为扫描不到任何设备。
我用的权限声明配置如下:
{ "module": { "requestPermissions": [ { "name": "ohos.permission.USE_BLUETOOTH", "reason": "$string:app_name", "usedScene": { "abilities": ["EntryAbility"] } }, { "name": "ohos.permission.DISCOVER_BLUETOOTH", "reason": "$string:app_name", "usedScene": { "abilities": ["EntryAbility"] } }, { "name": "ohos.permission.ACCESS_BLUETOOTH", "reason": "$string:app_name", "usedScene": { "abilities": ["EntryAbility"] } } ] } }这里需要解释下三个权限的区别:USE_BLUETOOTH 是使用蓝牙的基础权限,DISCOVER_BLUETOOTH 是扫描发现设备需要的权限,ACCESS_BLUETOOTH 则是进行蓝牙通信时需要的权限。简单理解就是:发现用第二个,通信用第三个,基础开关用第一个,场景不同缺一个都可能出问题。
权限声明还有一个关联问题:如果目标应用是首装后动态弹权限框,还需要在代码里处理权限申请逻辑。比如在扫描前调用 requestPermissionsFromUser,系统会弹出授权框,用户同意后再执行 startScan。这个逻辑在真机上调试时非常关键,我第一次就是权限弹窗没处理,导致开发板一直扫不到设备。
4.3 第二步:写 MethodChannel 入口与路由
权限搞定了,接下来就是核心代码。我先在鸿蒙端建一个类,专门负责和 Flutter 侧的 MethodChannel 通信。
整体的通信架构可以这样设计:Flutter 侧创建了一个 MethodChannel,channel name 是 flutter_blue_plus 约定的那个;鸿蒙侧在初始化时给这个 channel 绑定 MethodCallHandler,Flutter 侧每次调用方法,都会走到这里。
核心的入口代码如下:
import { MethodChannel } from '@ohos/hypium'; // 实际按工程里引入 export class FlutterBluePlusOhos { private channel: MethodChannel; private gattDevices: Map<string, bluetoothManager.GattClientDevice> = new Map(); private connectedStateMap: Map<string, number> = new Map(); constructor(channel: MethodChannel) { this.channel = channel; channel.setMethodCallHandler((call) => { return this.handleMethodCall(call); }); } private async handleMethodCall(call): Promise<any> { const method = call.method; const args = call.arguments; switch (true) { case method === 'startScan': return this.startScan(args); case method === 'stopScan': return this.stopScan(); case method === 'connect': return this.connect(args); case method === 'disconnect': return this.disconnect(args); case method === 'getServices': return this.getServices(args); case method === 'readCharacteristic': return this.readCharacteristic(args); case method === 'writeCharacteristic': return this.writeCharacteristic(args); case method === 'setNotify': return this.setNotify(args); case method === 'getPlatformState': return this.getPlatformState(); default: return Promise.reject({ code: 'UNIMPLEMENTED', message: `method ${method} not implemented` }); } } }这段代码的思路很简单:维护一个方法名到处理函数的映射,每来一个调用,把它分发到对应的处理逻辑里。
但这里有个非常关键的细节,容易在第一步就被忽略:flutter_blue_plus 的方法名是用“请求 ID + 操作名”动态生成的,不是一个完全固定的字符串。比如某个特征的读取,方法名可能是read_characteristic#1234这种带后缀的格式。如果不做兼容,光在源码里搜方法名是搜不到的,必须看它 Dart 层是怎么拼出这个字符串的。
我的做法是在 Dart 层临时打日志,把所有到达原生侧的方法名和参数完整打印出来,然后根据实际请求去对齐。这个方法虽然土,但最有效。
4.4 第三步:扫描与设备订阅
扫描功能是最先要跑通的,也是很多问题的集中爆发点。鸿蒙侧扫描的第一步是调用系统蓝牙能力,在扫描过程中要监听设备发现事件,把发现的结果整理成 flutter_blue_plus 需要的结构,通过 MethodChannel 反向推送回 Flutter 层。
来看这段代码:
private startScan(args: any): Promise<void> { return new Promise((resolve, reject) => { try { if (this.isScanning) { resolve(); return; } const serviceUuids = args.serviceUuids || []; const onDeviceFind = (device: bluetoothManager.BLEDevice) => { // 过滤:如果需要按服务 UUID 过滤,在这里判断 if (serviceUuids.length > 0 && !this.deviceHasService(device, serviceUuids)) { return; } const scanResult = { deviceId: device.deviceId, name: device.deviceName || '', rssi: device.rssi || 0, manufacturerData: this.parseManufacturerData(device), serviceUuids: device.serviceUuids || [], rawAdvertisementData: [] }; // 回调给 Flutter 层 this.channel.invokeMethod('ScanResult', scanResult); }; bluetoothManager.on('BLUETOOTH_DEVICE_FIND', onDeviceFind); bluetoothManager.startScan(); this.isScanning = true; this.scanCallback = onDeviceFind; resolve(); } catch (e) { reject({ code: 'ScanFailed', message: e.message }); } }); } private stopScan(): Promise<void> { bluetoothManager.stopScan(); if (this.scanCallback) { bluetoothManager.off('BLUETOOTH_DEVICE_FIND', this.scanCallback); } this.isScanning = false; return Promise.resolve(); }这里有几个细节要注意。
deviceId 的稳定性问题。鸿蒙扫描返回的 deviceId 在部分系统版本上可能是随机地址或者动态变化的,如果你用 deviceId 做缓存 key,可能会出现设备列表越扫越多的诡异现象。建议把它和 deviceName 一起处理,至少在日志里能看到每次扫描结果的变化轨迹。
广播数据的解析。flutter_blue_plus 的 Dart 层对 manufacturerData 有特定解析方式,如果你希望上层业务的厂商识别逻辑继续生效,鸿蒙侧必须把广播数据按 BLE 广播包的格式解析出来,否则这个字段永远是空的。这块可以从系统 API 的 scanResult 里拿到原始广播数据,然后自己解析。
扫描是高频回调场景。不建议每收到一个设备事件就无脑往 Flutter 侧推,可以先在原生侧用 Map 做去重,同一设备只推一次或者只在信号强度明显变化时更新,减少 Dart 层的渲染压力。
4.5 第四步:连接、服务发现、特征读写与通知
扫描通了之后,连接链路是下一个大头。鸿蒙侧的 GATT 连接逻辑通过 createGattClientDevice 创建设备实例,然后调用 connect。
这里需要建立一个设备映射表,关键点在于:Dart 层的 deviceId 和鸿蒙侧的 GattClientDevice 实例必须一一对应。我用了一个 Map 来维护这个关系。
核心实现如下:
private connect(args: any): Promise<void> { const deviceId = args.deviceId; const gattClient = bluetoothManager.createGattClientDevice(deviceId); // 监听连接状态变化 gattClient.on('BLEConnectionStateChange', (state) => { const isConnected = state.state === 2; // 2 = CONNECTED this.connectedStateMap.set(deviceId, state.state); // 回传连接状态给 Flutter 层 this.channel.invokeMethod('ConnectionStateChanged', { deviceId: deviceId, connected: isConnected, state: state.state }); }); gattClient.connect(); this.gattDevices.set(deviceId, gattClient); return Promise.resolve(); }连接状态监听这里,最需要注意的是状态码的语义。不同平台的连接状态码不一定一致,Android 的 STATE_CONNECTED 是 2,OpenHarmony 这边如果返回的枚举不一样,需要在适配层做一次转换,绝对不能直接透传给 Dart 层,否则上层基于状态码做的判断会全部失效。
服务发现和特征值获取代码如下:
private getServices(args: any): Promise<any> { const deviceId = args.deviceId; const gattClient = this.gattDevices.get(deviceId); if (!gattClient) { return Promise.reject({ code: 'DeviceNotConnected', message: 'device not found' }); } return gattClient.getServices().then((services) => { const result = services.map((service) => ({ uuid: service.uuid, characteristics: service.characteristics.map((char) => ({ uuid: char.uuid, properties: char.properties, descriptors: (char.descriptors || []).map((desc) => ({ uuid: desc.uuid })) })) })); return result; }); }读特征值的时候,鸿蒙 API 里 readCharacteristicValue 返回的通常是 ArrayBuffer,需要转成标准数字数组再回传。写特征值时则反过来,要把 Dart 层传来的数组转成 ArrayBuffer。
private writeCharacteristic(args: any): Promise<void> { const { deviceId, serviceUuid, characteristicUuid, value, type } = args; const gattClient = this.gattDevices.get(deviceId); const arrayBuffer = new ArrayBuffer(value.length); const view = new Uint8Array(arrayBuffer); value.forEach((byte, index) => { view[index] = byte; }); return gattClient.writeCharacteristicValue( serviceUuid, characteristicUuid, arrayBuffer, 'WRITE_DEFAULT' ).then(() => { // 部分设备需要等待写入完成的回调 return Promise.resolve(); }); }特征值通知的订阅,是 IoT 场景下用的最多的一个功能。设备有数据变化时主动通过 GATT 通知推给手机,App 不需要主动轮询。鸿蒙侧的做法是给 GattClientDevice 注册 BLECharacteristicChange 监听,同时开启指定特征的通知开关。
private setNotify(args: any): Promise<void> { const { deviceId, serviceUuid, characteristicUuid, enable } = args; const gattClient = this.gattDevices.get(deviceId); // 注册数据变化监听 gattClient.on('BLECharacteristicChange', (charChange) => { if (charChange.characteristicUuid !== characteristicUuid) { return; } const value = Array.from(new Uint8Array(charChange.value)); this.channel.invokeMethod('CharacteristicChanged', { deviceId: deviceId, characteristicUuid: characteristicUuid, value: value }); }); return gattClient.setCharacteristicChangeNotification( serviceUuid, characteristicUuid, enable ); }这里要特别注意,通知开关在某些低功耗设备上有依赖顺序问题。严格来说,先开启服务端特征值通知,再写客户端特征描述符(CCCD),顺序反了可能收不到任何通知。flutter_blue_plus 在 Android 端是自动处理这个顺序的,鸿蒙端的 setCharacteristicChangeNotification 如果你发现某些设备收不到通知,可以检查一下是不是 CCCD 没有成功写入。
4.6 让上层 Dart 代码“零改动”的收尾配置
核心逻辑实现完之后,还有一个收尾工程:确保 Flutter 工程在构建时能正确引用到我们写的鸿蒙端实现,而不是在找不到原生实现时报错。
flutter_blue_plus 的依赖可以分为 Dart 层和原生层。Dart 层是纯逻辑,可以直接复用;原生层则需要替换成我们写的 OpenHarmony 实现。最简单的做法是,把适配后的插件工程放到本地目录,然后在 pubspec.yaml 里通过 path 依赖指向本地插件。
dependencies: flutter_blue_plus: path: ./packages/flutter_blue_plus_ohos这样做的好处是,编译的时候 Flutter 会自动把鸿蒙端模块的源码打包进去,不需要额外配置。坏处是切回老版本的 flutter_blue_plus 时要改依赖路径,稍微麻烦一点,但换来的是上层代码零改动,这个取舍完全值得。
如果团队后续有多个 App 都要用这套适配,建议做一次正式封装,将 OpenHarmony 实现单独抽成一个插件包,按 flutter_blue_plus 的 platform interface 规范注册。这个改造更规范,但要花时间理解 flutter_blue_plus 的 inner lib 结构和接口定义,属于二期工程。
5. 常见问题排查与避坑记录
5.1 扫描不到设备的经典原因
在我适配和后续联调的过程中,“扫描不到设备”是出现频率最高的问题。这里我整理了四个最可能的原因。
权限没声明或没授权,这是最常见的原因。你需要在 module.json5 里声明三个蓝牙权限,同时还要确保应用运行时拿到了用户授权。尤其是第一次安装后的授权弹窗,如果测试时点掉了没同意,后续扫描永远会失败。建议每个新设备第一次调试时,先手动进入系统设置确认应用权限状态。
权限有了但扫描时机太早。系统蓝牙服务还没就绪的时候立刻调用 startScan,部分鸿蒙版本会静默失败,不抛异常也不产生回调。可以先用 API 查询蓝牙开关状态,或者做一次重试机制。
过滤条件写太死。如果传了 serviceUuids 过滤条件,而设备广播里没有包含完全一致的 UUID,往往会漏掉设备。前期验证时建议先不过滤,扫到之后再逐步收紧。
系统的安全限制。部分鸿蒙设备上,如果目标手机与 App 之间没有完成某种配对关系,应用是扫不到某些“受限广播”设备的。这个问题在国产设备上比较特殊,遇到时可以从系统设置里的“允许被发现/可被连接”选项排查。
5.2 连接状态一直对不上
连接功能最常见的现象是:Flutter 侧显示连接失败,但设备实际上已经连上了,或者反过来,界面显示已连接但设备根本没在线。
这种问题的根源基本都出在状态码语义不一致。flutter_blue_plus 的 Dart 层会根据自己的枚举值判断当前连接状态,而鸿蒙系统返回的状态码如果不做转换,就会造成误判。解决办法是,在鸿蒙侧用一张映射表把系统状态码转成 flutter_blue_plus 约定好的语义码,不要图省事直接透传。
还有一个经验是,connect 调用之后不要立即设置超时。BLE 连接过程涉及链路层连接、GATT 服务发现等多个阶段,整体可能耗时几百毫秒到几秒不等。第一次连接还会触发系统的配对流程,如果用户没及时点同意,连接时间会拖得更长。超时时间给到 10 秒以上比较稳妥。
5.3 特征值读写返回 0 或失败
特征值读写失败,往往不是适配层的问题,而是对 GATT 协议栈的理解问题。
一个很典型的场景是读取数据,返回结果是 0 或者 null。大概率是这个特征值本身不支持 Read 操作,只支持 Notify 或者 Write。flutter_blue_plus 在描述特征值时会带 properties 字段,你可以先读一下这个字段,确认该特征值支持哪些操作,再做对应的读写调用。
写入失败还有一种可能,就是写入的数据长度超过了 MTU。默认 MTU 是 23 字节,扣掉 3 字节的协议头,用户数据实际只有 20 字节。如果App 一次性写入超过 20 字节的数据,有些设备会直接返回失败,有些设备会截断。解决方案是先做 MTU 协商,或者业务层自己分包。分包逻辑一定要在 Flutter 侧做,原生侧只负责透传,不然每包之间的时序很难控制。
5.4 通知收不到、线程卡顿
通知收不到,八成是 CCCD 描述符的问题。很多低功耗设备需要同时写入 0x0001 到 0x2902 这个客户端特征描述符,才能开启通知。鸿蒙侧的 setCharacteristicChangeNotification 在不同的系统版本上行为不一致,有的版本会自动处理,有的版本不会。如果你发现只有部分设备能收到通知,强烈建议手动检查 CCCD 描述符的写入状态。
线程问题则出在原生回调的线程模型上。鸿蒙侧的回调事件有些不在主线程,直接往 MethodChannel 里 invokeMethod 可能偶发时序问题。我的处理方式是把真正要回传的数据先推到主线程,再统一走通道发送。否则偶尔会碰到,回调先到,数据还没准备好,导致 Dart 层拿到的字段不完整。
5.5 排查工具推荐
最后分享几个我在调试时用的工具。
第一是 hdc shell 的蓝牙日志。OpenHarmony 的系统日志里会有大量 Bluetooth 相关输出,出现诡异问题时,第一反应应该是去拉 hdc 日志看协议栈日志,而不是盯着 Flutter 层打断点。
第二是 Flutter 层的 MethodChannel 日志。我给鸿蒙侧的门禁函数加了统一的日志打印,每个方法的请求参数和返回结果都会落日志。这个习惯帮我省了很多事,尤其是联调第三方设备时,对方一问“你发的是什么指令”,我能直接翻日志回答。
第三是一个小技巧:在写适配代码时,先在 Channel 入口打一行“收到方法 X,参数 Y”的日志,跑通之后再一条条打开具体业务日志。这样既能控制日志量,又能快速定位到是调用没到原生侧,还是结果没回来。
综合这次适配经验,我的体会有三条。第一,适配工作开始前,花一天时间把插件源码读透,比动手后盲目试错省力得多,尤其是 MethodChannel 的方法路由和序列化规则,这是整个适配的地基。第二,一定要用真机验证,模拟器上扫描、连接的行为和真机完全不同,很多坑只有真机才能暴露出来。第三,优先保证主链路的完整闭环,再逐步补充边缘能力,不要试图一次性做全。如果你也在做 Flutter 鸿蒙化,希望这份记录能帮你少走几个弯路。