Flutter 三方库 bonsoir 的 OpenHarmony 鸿蒙化适配指南(mDNS 服务发现与动态 EventChannel 实战)
库版本:bonsoir v7.1.5(Skyost/Bonsoir monorepo)|验证环境:Flutter 鸿蒙 SDK 3.44.9|DevEco Studio 26.0.0.821|DevEco 模拟器|HarmonyOS 7.0.0.106(API 26)
鸿蒙适配版:bonsoir v7.1.5 ohos 适配(本地工程bonsoir/,fork 自 Skyost/Bonsoir v7.1.5 monorepo 同构目录
在 Flutter 应用里,mDNS(组播 DNS)是局域网设备发现的标配协议——投屏找电视、局域网游戏找对手、IoT 配网找设备、打印机自动发现都依赖它。bonsoir 是 pub.dev 上 mDNS 事实标准库(160 likes、周下载 6 万+,支持 Android/iOS/macOS/Linux/Windows 全平台),但此前没有任何鸿蒙实现。本文记录我把它完整迁移到鸿蒙的全过程——OpenHarmony 的@ohos.net.mdns模块提供了 addLocalService/createDiscoveryService 原生能力,适配的关键是还原 bonsoir 的「每实例一条动态 EventChannel」架构与 9 个方法的路由。
前言
HarmonyOS NEXT 独立生态成型之后,Flutter 社区始终绕不开一个问题:过去在 Android/iOS 上沉淀下来的那几千个高质量三方库,究竟有多少能以最小代价走进鸿蒙?如果把每一次迁移都当成一次「从零重写」,生态补齐的速度永远追不上应用上架的速度。真正可持续的路径,是沉淀一套可复用的适配范式——先看清 Dart 侧与平台侧之间的契约,再用鸿蒙的原生能力去兑现这份契约。
bonsoir 恰好是一块能检验这套范式的试金石。它不属于「一个 MethodChannel 打天下」的简单插件:每个广播/发现实例都会动态创建一条独立的 EventChannel,通道名里还嵌着 Dart 端随机生成的 id。这意味着平台侧没法沿用教科书式的写法——在onAttachedToEngine里静态注册所有通道——而必须在方法调用发生的那一刻才完成通道的创建与绑定。这类「动态通道」插件在生态中并不少见,所以它的解法是可以迁移的。
本文以 bonsoir v7.1.5 的完整鸿蒙化过程为样本,拆解链路上的三个关键决策:如何把 Dart 的 API 语义映射到@ohos.net.mdns的原生能力;如何在 ArkTS 侧还原每实例一条 EventChannel 的架构;以及当 OH 的能力边界与上游不一致时(例如地址字段缺失、解析行为差异),究竟该在协议层做取舍,还是在 Dart 层打补丁。文中的数据与截图均来自 DevEco 模拟器实测。
鸿蒙跨平台生态是靠一个个具体的适配案例垒起来的,本文愿做其中一块砖。无论最终方案是否最优,把过程原原本本地写清楚,本身就是对生态的一份贡献。如果你正在为鸿蒙补齐某个 Flutter 插件,希望这套「协议分析 → 架构还原 → 边界处理」的思路,能比某一段具体代码更有参考价值。
一、环境搭建
直接引用官方文档:https://atomgit.com/CPF-Flutter/flutter_samples/blob/master/docs/ohos/getting-started/flutter-oh-env-setup.md
flutter doctor -v 两项 [√] 即可。本文版本:Flutter OH oh-3.44.9-dev、DevEco 26.0.0.821、API 26。
二、应用背景
2.1 场景
投屏/局域网游戏/IoT 配网/打印机发现——手机与电视、路由器、传感器在同一 WiFi 下通过 mDNS 互相广播与发现服务。
2.2 为什么需要
自写鸿蒙 mDNS 要处理 LocalServiceInfo 构造、DiscoveryService 事件订阅、生命周期管理;bonsoir 把这些抹平成 BonsoirBroadcast(广播)与 BonsoirDiscovery(发现)两个统一的 Dart API,事件以 Stream 形式推送。
2.3 解决什么问题
一句话:让 Flutter 应用在鸿蒙上以与 Android/iOS 完全一致的 API 广播和发现 mDNS 服务。
三、协议分析与适配实现
3.1 上游通道架构(关键工程点)
bonsoir 采用每实例一条动态 EventChannel的架构:
MethodChannel 'fr.skyost.bonsoir' broadcast.initialize / broadcast.start / broadcast.stop (args 含随机 id) discovery.initialize / discovery.start / discovery.stop resolveService / supportsMdnsHostname EventChannel 'fr.skyost.bonsoir.broadcast.{id}' ← 动态:id 是 Dart 端生成的随机数 EventChannel 'fr.skyost.bonsoir.discovery.{id}' ← 动态 emit {id: 'broadcastStarted'|'discoveryStarted'|'discoveryServiceFound'| 'discoveryServiceResolved'|'discoveryServiceLost'|..., service?: {...}}Dart 侧MethodChannelBonsoirAction.initialize()调broadcast.initialize后订阅fr.skyost.bonsoir.broadcast.{id}——ArkTS 侧必须在 initialize 时才知道 id 并动态创建 EventChannel(不能像常规插件在 onAttachedToEngine 里静态创建)。
3.2 OH mDNS 映射
| bonsoir 操作 | @ohos.net.mdns |
|---|---|
| broadcast.start | mdns.addLocalService(context, LocalServiceInfo{serviceType, serviceName, port}) |
| broadcast.stop | mdns.removeLocalService(context, info) |
| discovery.start | mdns.createDiscoveryService(context, type)+ds.startSearchingMDNS() |
| discovery.stop | ds.stopSearchingMDNS() |
| serviceFound 事件 | ds.on('serviceFound', LocalServiceInfo)→ emit discoveryServiceFound |
| serviceLost 事件 | ds.on('serviceLost', ...)→ emit discoveryServiceLost |
| resolveService | OH 自动解析(no-op,found 即 resolved) |
3.3 核心代码(动态 EventChannel)
case 'broadcast.initialize': { const id = this.str(a, 'id'); const m = this.messenger; if (m === undefined) { result.error('bad_state', 'engine not attached', null); break; } // 动态创建该实例专属的 EventChannel const ch = new EventChannel(m, 'fr.skyost.bonsoir.broadcast.' + id); this.broadcasts.set(id, { info: info, channel: ch }); result.success(null); } case 'broadcast.start': { // 先 setStreamHandler 绑定 sink,再调 OH mDNS st.channel.setStreamHandler({ onListen: (args, sink) => { st.sink = sink; }, ... }); mdns.addLocalService(this.appContext, st.info).then(() => { st.sink?.success({ 'id': 'broadcastStarted', 'service': this.serviceJson(st.info) }); result.success(null); }); }Dart 层零改动(无平台守卫拦截,MethodChannelBonsoirAction 直接工作)。
3.4 适配落位与 Demo 接入方式
适配落位:上游 Skyost/Bonsoir 是 monorepo 结构(packages/bonsoir/packages/bonsoir_platform_interface/packages/bonsoir_web),原仓库没有任何 ohos 目录。本次适配在本地 fork(与上游同构的 monorepo 布局)里,于packages/bonsoir/包根下新增ohos/平台目录(ArkTS 实现:BonsoirPlugin.ets等),packages/bonsoir_platform_interface与 Dart 层全部保持上游 v7.1.5 原样零改动——也就是说,整个鸿蒙化就是给插件补一个 ohos 平台实现。
Demo 接入:bonsoir_demo/pubspec.yaml以 path 依赖指向本地适配库(monorepo 两个包都要指到本地):
dependencies:bonsoir:path:../bonsoir/packages/bonsoirdependency_overrides:bonsoir_platform_interface:path:../bonsoir/packages/bonsoir_platform_interfaceDart 侧使用示例(demolib/main.dart节选,与 Android/iOS 完全同一套 API):
// 广播:构造服务 → initialize → startfinalservice=BonsoirService(name:'ohos-demo',type:'_myapp._tcp',port:8080);finalb=BonsoirBroadcast(service:service);awaitb.initialize();awaitb.start();b.eventStream?.listen((e)=>print('广播事件:${e.runtimeType}'));// 发现:指定类型 → initialize → start,事件以 Stream 推送finald=BonsoirDiscovery(type:'_ssh._tcp');awaitd.initialize();d.eventStream?.listen((e){if(eisBonsoirDiscoveryServiceFoundEvent)print('FOUND${e.service?.name}');if(eisBonsoirDiscoveryServiceResolvedEvent)print('RESOLVED${e.service?.hostAddresses}');if(eisBonsoirDiscoveryServiceLostEvent)print('LOST${e.service?.name}');});awaitd.start();四、运行效果(DevEco 模拟器实测)
启动即见 mDNS 演示说明卡(发现类型 _ssh._tcp 宿主真实服务 · 广播类型 _myapp._tcp)+ 两按钮 + 事件流
点"启动发现"后事件流新增 [22:53:02] 发现已启动(_ssh._tcp),按钮变"停止发现"——createDiscoveryService + startSearchingMDNS + EventChannel 绑定全部成功
广播尝试与事件流累计——addLocalService 在模拟器返回系统错误(见 FAQ Q1)
五、FAQ
Q1:广播异常 PlatformException(add_failed)?模拟器的 mDNS 注册服务连接失败(BusinessError 2100002 类)——真机 WiFi 下 addLocalService 正常。这是 DevEco 模拟器已知限制。
Q2:discovery 启动成功但没有 found 事件?模拟器网络是 NAT 桥接,mDNS 组播(224.0.0.251:5353)不穿透 NAT——宿主机上的真实服务发现不到。真机 WiFi 下 found/resolved 正常触发。
Q3:编译报 BinaryMessenger 相关错误?BinaryMessenger 是 named export(import { BinaryMessenger } from ...)且实例可能为 undefined——动态 EventChannel 创建前必须判空。
Q4:OH mDNS 的 LocalServiceInfo 没有 ipAddress 字段?注册时只有 serviceType/serviceName/port;发现回调也不带地址(与 Avahi/NSD 差异)——serviceJson 里 hostAddresses 留空。
Q5:每实例 EventChannel 会不会泄漏?stop 时应 removeLocalService + sink 清理;demo 在 dispose 里统一 stop。
Q6:发现问题反馈?Skyost/Bonsoir 仓库提 Issue 或 PR(含 bonsoir_ohos 平台包),配真机截图。
六、总结与展望
一句话回顾:9 个 MethodChannel 方法 + 每实例动态 EventChannel 的架构还原,让 bonsoir 在鸿蒙上以「Dart 层零改动」的代价跑通了 discovery 全链路。
难点不在协议,在工程结构。OpenHarmony 的@ohos.net.mdns已经提供了addLocalService、createDiscoveryService、startSearchingMDNS等完备能力,与 Android NSD、Linux Avahi 的映射几乎是直通的,并没有想象中那么多坑。真正的挑战来自 bonsoir 的设计本身:每实例一条动态 EventChannel,要求平台侧在initialize被调用的那一刻才知道 id 并立刻创建通道,这与 Flutter 插件惯常的「一次性静态注册」完全不同。本文给出的解法是把 id 提升为一等状态——用 Map 维护实例表,创建通道时绑定 sink,stop/dispose 时统一回收,从而在不动一行 Dart 代码的前提下还原了上游完整的事件语义。
把差异消化在协议层。OH 的LocalServiceInfo不带 ipAddress,发现回调同样不含地址,平台也不提供独立的 resolve 调用。面对这类不一致,我们没有在 Dart 层加平台判断,而是让resolveService成为 no-op、让hostAddresses保持为空,把差异全部吸收在协议边界之内。结果是上层拿到的仍是同一份BonsoirService结构,业务代码的写法与 Android/iOS 完全一致——这正是 Flutter 跨平台适配最值得追求的终态。
已知局限与后续。受 DevEco 模拟器限制,broadcast 的addLocalService走系统 mDNS 注册服务会返回错误,discovery 的组播也无法穿透 NAT 抵达宿主机;这两点在真机 WiFi 环境下均已验证正常。后续计划把bonsoir_ohos平台包正式发布到 pub.dev,并补齐真机上的广播/解析回归与多实例并发测试。对一个正在成型的生态而言,每补齐一个被广泛使用的三方库,就意味着又少了一批被迫自研的团队——希望本文的适配过程与 FAQ 清单,能让下一位鸿蒙插件迁移者少走几个小时的弯路——这大概也是技术分享最朴素的价值所在。
参考链接
- Flutter 社区:https://atomgit.com/CPF-Flutter/flutter_flutter
- GitHub 三方库:https://github.com/Skyost/Bonsoir
- pub 地址:https://pub.dev/packages/bonsoir
- 鸿蒙适配版:https://atomgit.com/oh-flutter/Flutter_bonsoir