Flutter鸿蒙化实战:flutter_blue_plus蓝牙插件适配OpenHarmony全记录
2026/9/24 12:49:26 网站建设 项目流程

2023 年底开始,我手里的几个 Flutter 项目陆续被要求支持鸿蒙系统。最先适配的页面展示类 App 倒还好,社区方案很快能跑通,但真正让人头疼的是蓝牙相关功能。我们的智能硬件类 App 重度依赖 flutter_blue_plus 这套插件,设备扫描、BLE 连接、特征值读写、通知接收,整个链路在 Android 和 iOS 上都跑得非常稳,可一旦切到 OpenHarmony,flutter_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_BLUETOOTH,BLE 扫描在部分设备上还会连带要求定位权限,原因是扫描结果中的广播数据携带的信号强度可以用于位置推断。如果权限声明不全,扫描接口可能直接返回失败,或者扫描窗口期极短,几乎扫不到设备。

真机调试前还要完成签名配置。鸿蒙真机的签名和本地开发证书绑定,这一步不做,应用安装阶段就会失败。签名配置直接在 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 扫描不到设备的排查顺序

扫描不到设备是蓝牙适配里最高频的问题。我的固定排查顺序如下:

  1. 确认系统蓝牙已打开,getState() 返回 ACTIVE 状态
  2. 确认权限声明完整:USE_BLUETOOTH、ACCESS_BLUETOOTH 必须存在且已授予
  3. 确认设备广播类型支持被动扫描,部分低功耗设备只在主动扫描时响应
  4. 确认应用处于前台,鸿蒙对后台扫描有频率限制

在鸿蒙真机上遇到过一种特殊状况:设备代码没变,重启系统后扫描就正常了。后来定位到是系统蓝牙缓存与热启动冲突。这种问题没有捷径,只能让用户先开关一次蓝牙再试,或者在应用层加入“重新初始化适配器”的功能按钮。

扫描不到的另一个高频原因是过滤条件错误。比如设备广播的服务 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 重连策略和设备兼容性这两个方向,值得深入打磨。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询