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)

在 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.startmdns.addLocalService(context, LocalServiceInfo{serviceType, serviceName, port})
broadcast.stopmdns.removeLocalService(context, info)
discovery.startmdns.createDiscoveryService(context, type) + ds.startSearchingMDNS()
discovery.stopds.stopSearchingMDNS()
serviceFound 事件ds.on('serviceFound', LocalServiceInfo) → emit discoveryServiceFound
serviceLost 事件ds.on('serviceLost', ...) → emit discoveryServiceLost
resolveServiceOH 自动解析(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 直接工作)。

四、运行效果(DevEco 模拟器实测)

首屏
启动即见 mDNS 演示说明卡(发现类型 _ssh._tcp 宿主真实服务 · 广播类型 _myapp._tcp)+ 两按钮 + 事件流

discovery 启动
点"启动发现"后事件流新增 [22:53:02] 发现已启动(_ssh._tcp),按钮变"停止发现"——createDiscoveryService + startSearchingMDNS + EventChannel 绑定全部成功

broadcast 生命周期
广播尝试与事件流累计——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 已经提供了 addLocalServicecreateDiscoveryServicestartSearchingMDNS 等完备能力,与 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 清单,能让下一位鸿蒙插件迁移者少走几个小时的弯路——这大概也是技术分享最朴素的价值所在。

参考链接

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐