ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Flutter鸿蒙化实战:flutter_blue_plus蓝牙插件适配OpenHarmony全记录

Flutter鸿蒙化实战:flutter_blue_plus蓝牙插件适配OpenHarmony全记录 2023 年底开始我手里的几个 Flutter 项目陆续被要求支持鸿蒙系统。最先适配的页面展示类 App 倒还好社区方案很快能跑通但真正让人头疼的是蓝牙相关功能。我们的智能硬件类 App 重度依赖 flutter_blue_plus 这套插件设备扫描、BLE 连接、特征值读写、通知接收整个链路在 Android 和 iOS 上都跑得非常稳可一旦切到 OpenHarmonyflutter_blue_plus 直接就没有原生实现等于整个蓝牙能力在鸿蒙上瘫了。这篇文章想记录我们团队把 flutter_blue_plus 适配到 OpenHarmony 的完整过程重点讲讲蓝牙扫描连接链路从零到“开箱即用”的实操经验包括 SDK 选型、桥接层设计、常见坑位和排查思路。如果你也在做 Flutter 鸿蒙化并且项目里涉及 BLE 设备交互这篇文章应该能帮你省下不少调研时间。1. 鸿蒙化现状Flutter 在 OpenHarmony 上是如何跑起来的1.1 Flutter 的 OpenHarmony 分支与运行机制先明确一个大前提Flutter 官方主线并不直接支持 OpenHarmony但目前 flutter_flutter 仓库维护了一个名为 ohos 的分支专门用来承载鸿蒙底座的适配逻辑。这个分支本质上是给 Flutter 引擎增加了一个新的嵌入层embedder用来对接鸿蒙的窗口管理、输入事件、渲染表面、字体加载和事件循环。没有这套嵌入层Flutter 的 Dart 代码就算能编译也跑不到鸿蒙屏幕上。把 ohos 分支拉下来之后Flutter 在鸿蒙上的渲染默认走 Impeller 引擎。这一点让我比较意外因为印象里 Impeller 在 Android 上还属于逐步开放状态没想到鸿蒙分支直接就用上了。从实际体验看普通业务页面的渲染表现已经不输 Android 端复杂纹理和部分自定义 shader 的兼容性也超出预期。对于绝大多数业务团队来说只要页面本身不是重度依赖 Flutter 底层绘制细节迁移成本基本为零。这里要注意一个概念所谓 Flutter 鸿蒙化并不是把 APK 丢到鸿蒙设备上碰运气而是通过 Flutter 的 ohos 分支构建出 HAP 产物以原生应用的身份安装在 HarmonyOS NEXT 或 OpenHarmony 设备上。这样的好处是应用可以正常申请鸿蒙系统权限、调用系统服务、上架应用市场整个生命周期和原生应用完全一致。1.2 为什么 flutter_blue_plus 卡住了鸿蒙化进度flutter_blue_plus 是 Flutter 生态里目前维护比较活跃的跨平台蓝牙插件API 设计非常贴近业务。所谓贴近业务指的是你不需要自己处理 Android 的 ScanFilter 构造也不需要管 iOS 的 CBCentralManager 状态机直接调用 BluetoothScanner.startScan() 就能拿到扫描结果调用 BluetoothDevice.connect() 就能发起连接。这种封装让业务开发效率很高但反过来也对平台适配提出了更高要求。问题就在这flutter_blue_plus 官方并没有提供 ohos 平台的实现。插件在鸿蒙设备上调用时MethodChannel 发出去的消息没有对应平台侧处理Dart 层拿不到任何返回结果蓝牙功能完全不可用。当时团队里也有同事提出要不直接用鸿蒙官方的 ohos.bluetoothManager 写一套 ArkTS 业务代码算了。这个方案本身可行但代价是蓝牙业务逻辑被拆成两套一套 Dart、一套 ArkTS后续维护要同时改两个工程违背了 Flutter 项目“一次编写处处运行”的核心初衷。我们最终的决定是保留 Dart 层业务代码不动只为 flutter_blue_plus 补一份 ohos 平台的原生适配。这样既解决了当前项目的鸿蒙化问题将来 flutter_blue_plus 官方若推出鸿蒙支持也能平滑切换。2. 环境准备从 SDK 分支到工程跑通的最小闭环2.1 获取 OpenHarmony 分支的 Flutter SDK这一步如果做错了后面全是坑。我们一开始随手拉了一个 Flutter stable 版本然后用 DevEco Studio 创建工程编译到一半各种 C 符号缺失排查了大半天才发现是 SDK 分支没选对。Flutter 的 ohos 分支和主线版本号并不是严格对应的你要是图省事直接拿主线 SDK 去编 ohos 工程engine 层的接口对不上编译失败几乎是必然的。正确的做法是直接获取 flutter_flutter 仓库的 ohos 分支git clone -b ohos https://gitee.com/openharmony-sig/flutter_flutter.git export PATH$PATH:/path/to/flutter_flutter/bin拉完分支后执行 flutter doctor确认 SDK 被识别。这一步经常会遇到一条提示the current configured Flutter SDK is not known to be fully supported。我第一次看到这个提示心里咯噔一下以为是分支拉错了。后来对照仓库的 commit 信息和版本记录才发现这通常是工程模板与 SDK 版本之间的预期差异只要没有阻塞编译可以先继续。判断标准很简单后续 flutter pub get 和 flutter build 能正常走到配置阶段说明问题不大如果编译再报出版本相关错误再回来更新分支即可。另外提醒一句flutter_blue_plus 的版本选择也要注意。尽量选较新版本旧版本在 Dart 层 API 上做了不少调整适配层对不上接口的话改起来更费劲。2.2 创建工程并跑通第一个鸿蒙应用工程创建方式和普通 Flutter 工程一样flutter create flutter_blue_demo区别在于鸿蒙平台的 ohos 目录不会自动生成。社区里目前比较通用的做法是分两步走先用 flutter build 把 Dart 代码整体编译一遍确认 Dart 侧没有语法和依赖问题再用 DevEco Studio 打开工程根目录让 IDE 自动识别并生成 ohos 模块。我推荐这个顺序的原因很简单Dart 编译错误和原生编译错误混在一起时排查成本会成倍增加。先把 Dart 侧的问题排除后面 DevEco Studio 报错时就能把注意力集中在 ArkTS 和 C 侧。DevEco Studio 第一次打开 Flutter 工程时会要求配置 HarmonyOS SDK 路径。这一步注意选择与 ohos 分支匹配的 SDK 版本版本差异过大会导致编译配置失败。生成完 ohos 模块后先不要急着加业务代码直接跑一个空模板应用确认 Flutter 页面能在模拟器或真机上正常渲染再进入蓝牙适配环节。2.3 工程配置权限声明与真机签名工程跑通后要在 ohos 模块的 module.json5 里声明蓝牙相关权限{ requestPermissions: [ { name: ohos.permission.USE_BLUETOOTH }, { name: ohos.permission.ACCESS_BLUETOOTH }, { name: ohos.permission.ACCESS_FINE_LOCATION } ] }鸿蒙的蓝牙权限体系比 Android 更接近 iOS 的思路普通蓝牙连接需要 USE_BLUETOOTHBLE 扫描在部分设备上还会连带要求定位权限原因是扫描结果中的广播数据携带的信号强度可以用于位置推断。如果权限声明不全扫描接口可能直接返回失败或者扫描窗口期极短几乎扫不到设备。真机调试前还要完成签名配置。鸿蒙真机的签名和本地开发证书绑定这一步不做应用安装阶段就会失败。签名配置直接在 DevEco Studio 的 File Project Structure 中操作按界面指引生成证书即可流程本身不复杂但容易被人遗漏。3. 插件适配的整体思路MethodChannel 与鸿蒙蓝牙 API 的桥接3.1 先看 flutter_blue_plus 的双端通信模型在做任何代码改造之前必须先理解 flutter_blue_plus 的插件架构。Dart 层对外暴露 BluetoothScanner、BluetoothDevice、BluetoothCharacteristic 等对象内部通过 MethodChannel 发起一次性调用通过 EventChannel 接收持续不断的事件流。举几个具体的例子调用 BluetoothScanner.startScan()Dart 层会通过 MethodChannel 发起 startScan 调用同时注册一个事件监听通道用于接收扫描结果调用 BluetoothDevice.connect()Dart 层发起 connect 调用原生侧通过状态事件通知连接结果调用 BluetoothCharacteristic.write()Dart 层发起 write 调用原生侧返回布尔值表示写入是否成功搞清楚这个模型后鸿蒙适配的本质就清晰了在 ohos 平台实现一个同名插件把 flutter_blue_plus 的 MethodCall 映射到鸿蒙蓝牙 API把鸿蒙蓝牙的回调映射回 EventChannel。这里面的角色相当于一个翻译官把一套接口翻译成另一套接口但业务语义保持完全一致。可能有朋友会问能不能直接把 flutter_blue_plus 里 Android 的原生代码复制过来改我劝你三思。Android 的 BluetoothGattCallback 和鸿蒙的 on(BLEConnectionStateChange) 事件模型差异很大对象生命周期也完全不同。硬改的代码往往在 Android 上看起来是通的到鸿蒙上就是各种状态丢失、回调冲突与其在破地基上缝缝补补不如按鸿蒙的线程模型和回调机制重新实现一版。3.2 鸿蒙侧蓝牙能力盘点鸿蒙通过 ohos.bluetoothManager 模块提供蓝牙能力适配 flutter_blue_plus 时主要用到下面这些接口功能鸿蒙 API说明蓝牙状态getState()返回适配器状态码启动扫描startBluetoothDiscovery()BLE 扫描入口停止扫描stopBluetoothDiscovery()结束扫描扫描回调on(bluetoothDeviceFind)订阅设备发现事件创建 GATT 客户端createGattClientDevice(deviceId)返回 GATT 客户端对象发起连接connect()建立 BLE 链路连接状态on(BLEConnectionStateChange)连接/断开事件服务发现getServices()获取服务与特征值列表写特征值writeCharacteristicValue()写入数据特征值通知on(BLECharacteristicChange)接收设备上行数据逐项对照 flutter_blue_plus 的接口清单核心方法基本都能对应上。最明显的差异在于事件机制Android 使用回调对象ScanCallback、GattCallback鸿蒙则是字符串事件加订阅者模式。这意味着适配层里需要维护一张事件订阅注册表把 Dart 侧的事件订阅请求映射成鸿蒙侧的事件订阅并在生命周期结束时释放资源避免内存泄漏。3.3 适配层代码结构怎么规划适配层的代码结构直接影响后续维护难度。我建议按功能维度拆成四个文件扫描管理器负责 startScan、stopScan 以及扫描结果的格式化与事件推送连接管理器负责连接、连接状态监听、MTU 协商和重连逻辑服务管理器负责 discoverServices、特征值读写与通知订阅事件分发器统一封装 EventChannel 的事件投递避免各管理器直接持有 channel 引用把四个模块分开是为了降低连接管理器和事件分发器之间的耦合。蓝牙插件在真实业务中经常要处理断线重连、多设备切换、状态同步这些复杂场景如果一开始就把所有事件订阅集中在一个文件里后期扩展会非常痛苦。举例来说我们的硬件设备支持一键回连功能。第一次连接成功后会把设备 MAC 地址存下来下次进入页面时自动发起重连。这个逻辑在业务层写起来很轻松但底层如果事件分发不清晰就会出现多个页面同时监听连接状态、重复回调的副作用。把事件订阅统一收口到事件分发器中心化管理再配合 Flutter 侧的 Stream 广播就能很好避免这类问题。4. 核心实现扫描、连接、读写、通知的完整链路4.1 扫描权限申请、通道注册与设备过滤先看正常情况下 Dart 侧的调用方式await BluetoothScanner.startScan( withServices: [Guid(0000ffe0-0000-1000-8000-00805f9b34fb)], timeout: const Duration(seconds: 5), ); BluetoothScanner.scanResults.listen((results) { // 处理扫描结果 });这段代码在 Android 上可以直接跑在鸿蒙上则依赖适配层实现。鸿蒙侧 startScan 的流程分四步检查并申请权限、判断蓝牙适配器状态、调用 startBluetoothDiscovery()、把扫描数据映射为 ScanResult 对象并通过事件通道推送。权限申请这一步需要注意鸿蒙的权限弹窗与 UIAbility 生命周期绑定不适合在插件内部直接发起。我在实际项目中是在 Flutter 侧先调用一个 permission helper 方法在页面上下文环境中触发系统弹窗用户授权后再真正执行扫描。这个流程虽然多了一步但规避了权限弹窗在插件上下文里不弹出的问题。扫描回调里的数据量比你想象的大。如果不过滤手机、耳机、手表、鼠标都会出现在结果列表里logcat 里每秒钟能刷出几十条设备信息。建议在鸿蒙侧先把设备名和广播业务数据解析出来交给 Dart 层做业务级过滤。特别注意 RSSI 信号的解析信号强度为 0 或负值范围异常的设备通常是已经离开扫描范围或广播格式不对可以在 Dart 层直接丢弃。4.2 连接GATT 客户端、状态回调与超时保护扫描到设备后进入连接环节。鸿蒙侧创建 GATT 客户端并发起连接的核心代码大致如下let device bluetoothManager.createGattClientDevice(deviceId); device.connect(); device.on(BLEConnectionStateChange, (data) { if (data.state 2) { // 连接成功 } });这里有一个关键陷阱connect() 方法返回并不代表 BLE 连接已经建立成功。你必须在 BLEConnectionStateChange 事件里等待 state 变为连接态再继续执行后续的服务发现操作。如果直接同步调用 discoverServices()不少固件版本的设备会直接拒绝服务甚至断开连接。我踩过这个坑后在适配层里加了一个连接超时保护发起 connect 后启动一个 5 秒的定时器如果 5 秒内没有收到连接态回调就主动断开连接并向上抛出超时异常。这个机制上线后测试人员反馈的偶发卡死问题明显减少。原因也很简单BLE 连接受距离、信号干扰、设备固件状态影响很大没有超时保护的连接流程等于把线程让给了一个不确定事件。连接成功后的第一件事我建议先发起 MTU 协商。很多蓝牙外设的默认 MTU 只有 23 字节一个完整数据包都传不完通知数据一长就会被系统拆包最终表现为数据残缺、乱序。鸿蒙的 requestMTU() 接口在部分设备上返回较慢需要放到异步流程里等待完成并及时把结果返回给 Dart 层。这样上层可以从容决定是直接解析数据还是等待 MTU 完成后重新订阅。4.3 服务发现与特征值读写数据结构映射连接成功后flutter_blue_plus 会调用 discoverServices() 来获取外设的服务列表。鸿蒙的 getServices() 一次返回所有服务、特征值和描述符但返回的数据结构与 flutter_blue_plus 的期望不同需要在适配层做一次转换。转换过程中最容易踩坑的是 UUID 的格式问题。Android 端习惯传短 UUID比如 ffe0但鸿蒙 API 返回的是完整 UUID例如 0000ffe0-0000-1000-8000-00805f9b34fb。如果你在 Dart 层写了 withServices: [Guid(ffe0)]然后那去匹配鸿蒙侧返回的完整 UUID匹配永远失败。解决办法是在适配层统一把小写完整 UUID 作为内部标准格式。Dart 层传入的 Guid 先转成完整格式再比较鸿蒙侧返回的完整 UUID 也统一转成小写。这个约定看起来土但能避免大量由于大小写和长短格式导致的问题。如果项目里既有 Android 又有鸿蒙建议在业务层面也约定一种统一格式比如在配置中心里存完整 UUID。特征值写入同样要关注写入类型的匹配。鸿蒙的 writeCharacteristicValue 带 writeType 参数需要与 flutter_blue_plus 传入的写入类型保持一致。如果类型不匹配部分外设会出现“数据写进去了但设备不执行”的现象。这类问题排查起来非常头疼因为从日志看写入是成功的只有实测设备行为才会发现异常。4.4 特征值通知EventChannel 与性能防洪特征值通知是 BLE 设备最常用的上行通道硬件数据心率、温湿度、运动状态等基本都走这条链路。鸿蒙侧的 on(BLECharacteristicChange) 回调频率在部分设备上相当高我们接的一款心率设备每秒推送 20 条数据每条还带着时间戳和原始波形。如果每一条都直接通过 EventChannel 推到 Dart 层UI isolate 会被淹没页面明显掉帧。这里我采用的折中方案是鸿蒙侧先把事件原样透传Dart 侧在订阅回调里做采样和合并处理。具体来说心率这类连续数据以 1 秒为窗口合并成一条数组再交给界面单值类型的数据比如温度则保留最新值。这样既不影响上层业务读取原始数据又能保证 UI 刷新率稳定在 60fps 附近。还遇到过一种情况设备断连后 EventChannel 通道没有及时关闭Dart 侧仍能收到事件流导致页面显示的数据还是旧值。我的做法是在断连事件发放时同步关闭事件订阅并在下次连接成功后重新注册。这套逻辑放到连接管理器里统一维护不要在业务页面里分散处理。5. 常见问题与排查技巧实录5.1 扫描不到设备的排查顺序扫描不到设备是蓝牙适配里最高频的问题。我的固定排查顺序如下确认系统蓝牙已打开getState() 返回 ACTIVE 状态确认权限声明完整USE_BLUETOOTH、ACCESS_BLUETOOTH 必须存在且已授予确认设备广播类型支持被动扫描部分低功耗设备只在主动扫描时响应确认应用处于前台鸿蒙对后台扫描有频率限制在鸿蒙真机上遇到过一种特殊状况设备代码没变重启系统后扫描就正常了。后来定位到是系统蓝牙缓存与热启动冲突。这种问题没有捷径只能让用户先开关一次蓝牙再试或者在应用层加入“重新初始化适配器”的功能按钮。扫描不到的另一个高频原因是过滤条件错误。比如设备广播的服务 UUID 是 16 位短 UUID代码里却写成了 128 位完整 UUID结果自然为空。这也是我前面反复强调统一 UUID 格式的原因。真要排查时建议先去掉 withServices 过滤条件扫一把看设备是否出现在全量结果里再逐步加过滤条件缩小范围。5.2 连接成功但立刻断开连接成功后立刻断开通常会让你怀疑设备固件有问题但大多数情况下问题出在适配层。常见原因有三种设备要求配对信息未配对导致连接被拒绝MTU 协商失败设备侧主动断开上层服务发现调用过早与连接状态回调产生竞态竞态问题是我在适配中遇到最多的。前面提过 connect() 返回不代表连接成功如果紧接着同步执行 discoverServices()部分固件的设备会直接断开。解决方式是在连接管理器里维护一个状态队列连接成功事件到位后再触发后续服务发现动作而不是依赖调用顺序。MTU 协商失败导致的断开在低功耗蓝牙设备上比较常见。建议在适配层把 MTU 协商结果也作为连接成功条件之一如果 MTU 协商失败向上层返回一个可以区分的错误码而不是直接静默失败。5.3 状态同步混乱与错误映射flutter_blue_plus 的蓝牙状态枚举包含 on、off、turningOn、turningOff 等状态而鸿蒙侧 getState() 返回的状态码与 Android 并不完全一致。状态映射漏一项Dart 层收到 unknow 状态就会走异常分支表现为连接按钮失效、页面状态卡死。我的经验是直接画一张状态映射表下面这张是精简版完整参考可以查阅鸿蒙官方文档但核心逻辑是一样的Flutter 状态鸿蒙状态码含义off0蓝牙关闭turningOn1正在开启on2蓝牙开启turningOff3正在关闭这张表不仅是给自己看的建议也写进适配层的注释里方便后续维护者快速对照。除此之外Flutter 侧的蓝牙状态管理建议使用 bloc 或 provider 这类状态管理库统一收口不要在多个页面里各自维护一份状态。多次踩坑后的体验是蓝牙状态机天生适合集中管理分散处理迟早会出现一个页面改了另一个页面不知道的情况。5.4 插件构建报错与调试技巧flutter_blue_plus 在做鸿蒙适配时需要把插件注册到 ohos 模块的插件注册表里。这个流程和 Android 的 MainActivity 里注册插件不太一样导致很多人卡在插件没有注册成功这一步运行时调用直接报“MissingPluginException”。排查这类问题我强烈建议优先使用 DevEco Studio 的日志窗口直接查看 ArkTS 编译输出。Flutter 侧日志在很多时候是 web/Android 逻辑比较多不太容易直接对应到鸿蒙底层而 DevEco Studio 能把 ArkTS 的异常堆栈看得非常清楚。一旦你在 ArkTS 层看到“method not found”或者“channel not registered”基本就是插件注册表或方法映射没配好。还有一个容易忽略的点部分 flutter_blue_plus 新版本在编译时会校验 Flutter 主工程的 Gradle 插件鸿蒙适配过程中不要把这些校验依赖到 Android 构建流程里否则很容易出现配置冲突。我的做法是给 ohos 模块单独建一套构建配置和 Android 的 Gradle 配置彻底隔离这样两边互不干扰出问题时也好定位。6. 适配之外一些经验与后续方向这次 flutter_blue_plus 的鸿蒙适配前后花了大概三周时间核心链路扫描、连接、读写、通知全部跑通后公司内部另一款运动健康类 App 也直接复用了这套适配层算是把成本摊平了。如果让我重新做一遍我可能会在项目第一天就把扫描、连接、通知三条链路的时序图画出来贴在工位上因为这轮所有 bug 最后都回归到了事件时序上。还想分享一个小技巧适配层里加一个“协议抓包”开关平时关闭出问题时动态打开把蓝牙扫描结果、连接状态变化、特征值读写内容全部打到本地日志。这个开关在真机联调阶段价值极高尤其当你和硬件团队合作时双方拿着同一份日志去对齐问题比互相猜对方的数据格式高效得多。目前这套方案已经稳定运行在几个 OpenHarmony 真机设备上。后续我们计划把 iBeacon 扫描和基于广播数据的前后台切换完善一下进一步覆盖运动场景下的低功耗需求。如果你也在做相关适配欢迎交流尤其是 BLE 重连策略和设备兼容性这两个方向值得深入打磨。
返回列表