☰
dart_service_announcement鸿蒙化适配:mDNS服务发现从NsdManager到鸿蒙的桥接实践
2026/10/11 14:46:53 网站建设 项目流程

1. 为什么 dart_service_announcement 会成为鸿蒙化路上的第一个拦路虎

在 Flutter 应用从 Android/iOS 向鸿蒙 NEXT 迁移的实践中,最先撞墙的往往不是 UI 组件,也不是状态管理,而是那些躲在角落里的"网络小工具"。我遇到的第一个典型就是dart_service_announcement——一个负责局域网服务通告与发现的轻量插件。它在智能家居联动、投屏设备查找、配件周边发现这些场景里几乎是标配,尤其适合做类似"App 自动发现局域网里的摄像头 / 音箱 / 打印机"这类功能。

先说结论:这个库本身不大,核心逻辑也不复杂,但它的痛点在于强依赖宿主平台的多播 DNS(mDNS)能力和系统服务发现框架。在 Android 上它调用的是NsdManager,在 iOS 上走的是Bonjour / NetService,而鸿蒙 NEXT 在这块既没有直接用 Bonjour,也没有照搬 NsdManager 的 API,而是提供了自己的一套网络服务发现能力。适配的本质,就是把库内部对平台通道的调用,从"Android/iOS 方言"翻译成"鸿蒙方言"。

更准确地说,dart_service_announcement的适配不是纯 Dart 层的修修补补,它涉及协议栈选型、平台通道桥接、生命周期管理、权限模型对齐四个层面。任何一个环节处理不到位,都会出现"服务通告出去了但别人发现不了"或者"能发现但信息不完整"这类诡异问题。

这篇内容适合谁?如果你正在做 Flutter 库的鸿蒙迁移,或者你手上有依赖局域网设备发现功能的 App 需要支持鸿蒙 NEXT,又或者你只是想搞明白 mDNS 在真实工程项目里到底怎么落地,这篇文章都能给你一套可以直接抄的作业。我会把适配过程中踩过的坑、排障链路、以及一些常规文档里不写的细节全部拿出来。

2. 动手前的"奢疗法":先把 mDNS 和库的内部机制拆干

2.1 mDNS 为什么不需要服务器也能互相找到

mDNS 全称 Multicast DNS,它的思路非常简单粗暴:把 DNS 查询从"去问服务器"改成"对着局域网喊一嗓子"。设备加入局域网后,会向224.0.0.251:5353这个组播地址发送查询报文,询问"局域网里有没有_my-service._tcp的服务?",同一网段里所有监听这个组播地址的设备都能收到,拥有匹配服务的设备就直接回复自己的 IP、端口和 TXT 记录。

这里有几个关键机制决定了它能不能稳定工作:

  • 缓存与 TTL:mDNS 响应不是每次都发。其他设备收到响应后会把服务信息缓存起来,缓存时长由响应报文里的 TTL 决定。dart_service_announcement里默认 TTL 是 4500 秒(约 75 分钟)。也就是说,如果服务已经下线了,但缓存还没过期,其他设备依然会在缓存里找到它的"尸体"。
  • QU 位(Qu Question):常规查询是普通查询,但如果发起方希望对方必须直接响应而不是用缓存应答,会在查询报文的标志位里带上 QU。平台 API 在底层通常已经自动处理好,但如果自己从 socket 层写协议栈,很容易漏掉这个位,导致查询结果不可靠。
  • 单播响应与多播响应:当设备数量很多,为了避免组播风暴,mDNS 规范允许在特定条件下使用单播回复。不同平台的网络栈对此的偏好不一样,这也是"同一套代码在不同系统上表现不同"的主要原因之一。

理解这三点,后面排查"服务丢失"问题时才有方向。

2.2 dart_service_announcement 的内部结构与适配红区

这个库的源码结构很清晰,分为两层:

  • Dart 层:DartServiceAnnouncement负责启动通告、停止通告,DartServiceDiscovery负责启动发现、停止发现、监听服务在线/离线回调。这两层只做状态管理和结果转发,本身不含任何网络协议实现。
  • 平台层:通过 MethodChannel 调用原生能力。Android 侧封装了NsdManager的注册服务与发现服务接口,iOS 侧封装了NSNetService与NSNetServiceBrowser。

以 Android 侧为例,它做的事情本质上是:

  1. 用NsdManager.registerService()注册一个NsdServiceInfo,包含服务名、服务类型、端口和 TXT 属性;
  2. 用NsdManager.discoverServices()发起对某个服务类型的发现;
  3. 通过NsdManager.DiscoveryListener回传发现结果和丢失事件。

鸿蒙 NEXT 的 Network Kit 里同样提供了服务发布与服务发现能力,接口形式、回调模型和 NsdManager 颇有些神似,但不兼容:方法名不同、回调签名不同、生命周期绑定对象不同,甚至服务类型字符串的格式规则也有差异。最直接的做法,就是仿照 Android 实现,在鸿蒙侧写一套等价的桥接代码,把 MethodChannel 原有的语义一一对应过去。

2.3 动手前必须先划清的"三条边界"

第一条边界:哪些逻辑留在 Dart 层,哪些必须下沉到鸿蒙原生层。dart_service_announcement的 Dart 层其实只做了一件事:把平台层的回调转成 Dart Stream。真正的网络行为全部在原生平台。因此鸿蒙化的最小改动方案就是:保留 Dart 层不动,只新写鸿蒙侧平台实现。

第二条边界:"通告方"和"发现方"不是对称的。通告方需要注册服务、处理注册成功/失败回调,发现方需要开启浏览器、处理 onServiceFound / onServiceLost。这两套流程在 NsdManager 里是两套 Listener,在鸿蒙的 API 里也是两套独立的回调接口,适配时一定要分开实现,别图省事塞到一个回调类里。

第三条边界:服务实例名的唯一性。mDNS 允许同一服务类型下有多台设备,区分它们的不是服务类型,而是服务实例名(通常由设备名 + UUID 组成)。dart_service_announcement的内部逻辑里,发现回调拿到的deviceUuid和deviceName也是靠这条区分。鸿蒙侧注册服务时可以指定实例名,但如果实例名重复,系统会静默地在末尾加上数字后缀,这对依赖"通过名字查找固定设备"的业务是个隐患。适配中要保证实例名在局域网内唯一,比较稳妥的做法是"设备名 + 随机短 ID"。

3. 三条鸿蒙化路线,我为什么最终选了 MethodChannel 桥接方案

在正式写代码之前,我在适配路线上摇摆过一阵。这不是小题大做,因为鸿蒙化 Flutter 插件的方案不止一种,选错了后面维护成本会直线上升。

3.1 路线一:砍掉原生层,改用纯 Dart 自研 mDNS 协议栈

这是最"激进"的思路:不用鸿蒙的网络服务发现 API,而是在 Dart 层自己构造 mDNS 报文,通过 socket 直接发出查询和响应。

听上去很优雅,但落地时问题太多。鸿蒙 NEXT 对 Dart 的dart:io中 multicast 能力的支持边界尚不明确,自行构造组播 socket 需要依赖系统授予网络权限和组播权限,而权限模型在不同版本上还在演进;另外 mDNS 报文的解析、冲突处理、缓存刷新、多接口管理都要自己实现,相当于把 Bonjour/JmDNS 重写一遍。为一个小库引入这么大复杂度,不划算,风险也大。

这条路线适合什么场景?适合那些"单个库本身就有自定义协议需求、且后续计划深度定制服务发现逻辑"的长期主义者。对dart_service_announcement这种轻量插件来说,收益撑不起成本。

3.2 路线二:保留 Dart 层,MethodChannel 桥接鸿蒙原生网络套件

这是"最小侵入"方案:Dart 层完全不动,鸿蒙侧新建一个工程,用 HarmonyOS NEXT 的 ArkTS 实现registerService与discoverServices能力,把原有MethodChannel的方法名和参数映射到鸿蒙 API 上,然后把结果和回调事件通过Result和EventSink送回 Dart 层。

优点很明显:

  • Dart 层代码零改动,上层业务也不受影响;
  • 鸿蒙原生实现只涉及一个插件模块,边界清晰,出问题排查范围小;
  • 可以与原插件共享 serviceType、TXT record 的数据结构定义,减少转换层出错的概率。

缺点也真实:如果鸿蒙系统后续 API 调整,桥接层需要同步维护;但这个问题在所有桥接方案里都存在。

3.3 路线三:把插件升级为联邦插件,接入 Flutter 插件联邦机制

Flutter 生态里管理多平台插件有一套标准做法:把平台实现按 endor/ 目录拆开,让 Dart 层通过interface调用各个平台实现,各平台可以独立发布插件包。这样 Android、iOS、鸿蒙各维护各的分包,Dart 层只依赖接口约束。

这套机制的好处是工程结构更规范,适合 SDK 级、长期维护的项目。但对dart_service_announcement这种单体仓储来说,动刀动得有点大,而且原仓库的 owner 不一定愿意合入这样一个大 PR。如果我是自己 fork 出来做内部适配,联邦插件的多余工程结构只会拖慢迭代速度。

3.4 我的划折判据:先按路线二跑通,再按路线三预留演进

最终我选的是路线二,但实现时保留了两个"联邦化"习惯:一是把鸿蒙侧桥接逻辑集中在一个 feature 包内,与其他代码隔离;二是所有方法名和参数名都做成常量集中管理,为将来拆联邦插件留了口子。

工程上有个原则我一直在用:适配的第一目标不是完美,而是跑通边界场景。先让普通局域网互通验证通过,再回头处理缓存、冲突、生命周期这些软性问题。路线二天然支持这种渐进式推进。

4. 核心适配实现:从 NsdManager 到鸿蒙网络套件的 API 逐项映射

这一节直接上干货。适配dart_service_announcement的关键,就是把原有 Android/iOS 实现里的平台调用,逐一翻译成鸿蒙 ArkTS 侧的等价调用。

4.1 工程结构:把鸿蒙实现放进独立模块

我采用的是 OHOS 插件工程标准结构,鸿蒙实现独立成模块,最后通过module.json5声明依赖:

dart_service_announcement/ lib/ # Dart 层(原样保留) harmony_project/ # 鸿蒙侧工程 src/main/ets/ ...

在入口类里初始化 MethodChannel:

// EntryAbility.ets / 插件的 Ability 初始化位置 import { MethodChannel } from '@ohos/flutter_ohos'; const channel = new MethodChannel('com.example.dart_service_announcement'); channel.setMethodCallHandler((call, result) => { // 分发调用 });

启动通告与启动发现分别对应两个方法名,比如registerService和discoverServices。鸿蒙侧注册完服务后,通过success(parameters)返回结果;发现过程中收到设备上线事件,则通过EventSink回传。

4.2 服务通告流程:startAdvertising 的鸿蒙映射

Dart 层startAdvertising的核心入参是:

  • serviceName:服务实例名,例如livingroom-camera-1234
  • port:服务监听端口
  • serviceType:服务类型,例如_camera._tcp
  • txt:TXT 记录字典,例如{ "model": "A100", "vendor": "xx" }

鸿蒙侧注册服务的关键代码可以按这个框架写:

import { serviceDiscoveryManager } from '@ohos.net.serviceDiscovery'; let serviceInfo = { serviceName: serviceName, // 实例名 serviceType: serviceType, // 类型字符串 port: port, // 端口 txt: txt // TXT 记录 }; serviceDiscoveryManager.registerService( serviceInfo ).then(() => { // 注册成功,回传成功事件给 Dart result.success({ code: 0 }); }).catch((err) => { // 注册失败,回传错误码 result.error(err.code.toString(), err.message, null); });

这里有个小坑:鸿蒙的registerService注册成功后,如果没有显式启动"服务发布状态监听",那么后续这个服务是否真正可被发现、是否与其他服务冲突,都不会有回调。必须在注册的同时注册监听器,把服务状态变化主动推给 Dart 层。

另外txt记录的类型。Dart 层传过来的 TXT 是多组 key-value,鸿蒙侧要求 value 是字符串数组。我在这里踩过一次坑:Android 上可以直接传byte[],到了鸿蒙如果不先做 UTF-8 转字符串数组,registerService会直接抛参数非法错误。适配代码里需要加一层类型归一化:

let txtMap: Record<string, string[]> = {}; Object.keys(txt).forEach(key => { txtMap[key] = [String(txt[key])]; });

4.3 服务发现流程:startDiscovery 的鸿蒙映射

发现流程的入参只有一个serviceType。鸿蒙侧调用:

serviceDiscoveryManager.startDiscoveryServices( { serviceType: serviceType } ).then((discovery) => { discovery.on('discoveryStateChange', (state) => { // 状态变化事件 }); discovery.on('serviceFound', (service) => { // 收到服务上线事件,把关键信息回传给 Dart eventSink.success({ event: 'onServiceFound', serviceName: service.serviceName, deviceUuid: service.serviceName, // 以实例名辅助识别 ip: service.host, port: service.port, txt: service.txt }); }); discovery.on('serviceLost', (service) => { eventSink.success({ event: 'onServiceLost', serviceName: service.serviceName }); }); });

值得注意的是:dart_service_announcement的 Dart 层要求发现结果里必须带deviceUuid。在 Android 上,这个字段往往由 NsdManager 返回的服务信息再加工而成,但如果设备的 mDNS 通告里没有 UUID,就用服务名替代。鸿蒙的serviceFound回调里不一定直接暴露设备的 MAC 或 UUID,最可靠的兼容做法就是用 serviceName 作为主键。这也是为什么我在 2.3 里专门强调实例名唯一性——它直接影响到上层业务能否正确标识设备。

4.4 生命周期与状态机映射:stop 与销毁

网络服务发现的典型问题是生命周期管理。Android 上NsdManager需要 registerReceiver 管理广播,iOS 上NSNetService需要手动 stop。鸿蒙侧的机制也类似:startDiscovery 返回的 discovery 对象,终止时必须要off掉所有监听,再调用stopDiscoveryServices,最后置空引用,否则会有线程泄漏和重复回调。

我把状态机做了一个简单映射表:

事件阶段Android(NsdManager)鸿蒙(serviceDiscoveryManager)
发起发现discoverServices()startDiscoveryServices()
发现进行中onDiscoveryStarteddiscoveryStateChange
发现到服务onServiceFoundserviceFound
服务丢失onServiceLostserviceLost
停止发现stopDiscovery()stopDiscoveryServices()
服务注册registerService()registerService()
注册失败onRegistrationFailedregisterService().catch()

这套映射表是适配的核心资产。以后如果有人让你适配别的 Flutter 网络插件,只要表建清楚,代码基本是照着翻译。

4.5 异步模型桥接:鸿蒙 Promise 与 Dart Future 的配合

鸿蒙侧 API 大量使用 Promise 或回调,Dart 侧则是Future与Stream。桥接的要点在于:用 Result 回传一次性结果,用 EventSink 回传持续性事件。

我在实现时碰到一个比较隐蔽的问题:发现服务时,startDiscoveryServices().then()只是说明"发现流程启动成功",并不是"发现了某个服务"。如果误把 then 回调里的数据当成服务发现结果回传给 Dart,Dart 层会认为发现立即成功,却拿不到后续设备上线事件。

正确的桥接方式是:

  • then里只回一条discoveryStarted的事件;
  • 设备上下线事件必须通过serviceFound/serviceLost监听器回传;
  • 每个事件回传前,先序列化成 Dart 层能识别的 Map。

这个经验说起来简单,但实际工程里,有相当一部分"适配完找不到服务"的问题,根源就是 Async 时序搞错了。

5. 实测排障:为什么"看起来通了但就是看不到设备"是最难查的坑

适配完成不代表能跑通业务。我在真机联调阶段遇到的几个问题,一度让人怀疑是协议栈的问题,最后都证明是桥接层的细节。下面按我的排查顺序完整还原一遍。

5.1 服务通告成功,但另一台手机死活发现不了

现象:设备 A(鸿蒙原生)注册服务成功了,success回调正常;设备 B(Flutter 应用)执行startDiscovery,没有任何 serviceFound 事件。

排查链路一层层往深处走:

  1. 先确认两台设备在同一局域网。这个看似废话,但在双频路由器、AP 隔离开启的办公网里,不同 VLAN 段的设备是互不可见的。把两部手机都连到同一个热点就能排除。
  2. 再确认服务类型字符串完全一致。dart_service_announcement发现方传入的类型带不带下划线、后缀是不是_tcp,必须在发现方和设备端完全一致。
  3. 用抓包工具看 5353 端口有没有组播流量。在 PC 上装一个抓包工具,过滤port 5353,看设备 B 有没有发出 PTR 查询,设备 A 有没有回 PTR 响应。
  4. 最后检查 WiFi 是否开启了"客户端隔离"。热点的高级设置里如果有 AP 隔离,组播包会被网关拦截,这是最坑的一环。

实测下来,很多场景的问题根源是第 4 条,而不是代码逻辑。

5.2 明明能发现服务,但拿不到 TXT 记录

第二次踩坑是:设备 B 能看到设备 A 在线,但点击进去发现没有 TXT 信息,无法做后续握手校验。

原因是鸿蒙侧serviceFound回调里返回的txt字段类型,和 Android 的NsdServiceInfo.getAttributes()不一致。鸿蒙把 TXT 记录解析成了一个数组,我原以为直接转成 Map 就行,结果发现数组里每一项都是"key=value"的字符串。

修复方式是在桥接层做一次解析:

function parseTxtArray(arr: string[]): Record<string, string> { const result: Record<string, string> = {}; for (const item of arr) { const index = item.indexOf('='); if (index > 0) { result[item.substring(0, index)] = item.substring(index + 1); } } return result; }

5.3 服务下线的"幽灵缓存"问题

现象:设备 A 退出应用并主动stopAdvertising,但设备 B 在很长一段时间内仍能看到设备 A 在线。点进去当然连不上。

mDNS 的 TTL 是主要因素,但设备主动发 goodbye 报文时,其他设备应当立刻丢弃缓存。实测发现,鸿蒙侧stopDiscoveryServices之后,如果不主动清除缓存数据,Dart 层维护的设备列表还会停留在"在线"状态。

所以适配时必须在上层维护一份服务状态缓存:

  • serviceFound时,把服务信息压入本地 Map;
  • serviceLost时,从 Map 删除;
  • 业务层拿到的设备列表,一律从这份 Map 生成,不直接依赖系统缓存。

这样即使系统的 mDNS 缓存还在,业务层的状态也是干净的。

5.4 从后台切回前台,发现流程"假死"

另一个高频问题:App 退到后台再回来,discovery 事件再也不回调了。

原因也简单:鸿蒙侧的 discovery 对象是"一次启动,一次监听"。如果你没有在onPause/onStop时清理探测对象,回到前台后再调startDiscoveryServices(),系统会认为重复启动而拒绝,或者事件不再外发。

解决办法是:把 discovery 对象的生命周期与页面生命周期绑定。页面 onDisappear 时显式 off 监听并停止发现,页面重新展示时重新 start。绝对不能依赖"start 一次就一直收事件"的惯性思维。

这里整理一个自检清单,适配完照着这条链路过一遍,比瞎猜好得多:

步骤检查项预期结果
1两台设备是否同网段ping 通
2服务类型是否完全一致字符串完全匹配
3系统是否允许组播WiFi 未开 AP 隔离
4通告方是否还在前台服务未停止
5发现方是否重复 start无重复启动
6TXT 是否解析正确key=value 正常
7生命周期是否正确清理无泄漏无假死

6. 把服务发现"资产化":适配完成后的工程治理经验

适配只是第一步,后续的维护治理决定这个库能不能长期稳定地在鸿蒙生态里跑下去。

6.1 服务类型命名规范:为整个组织建立"服务发现注册表"

服务类型字符串是 mDNS 世界的"门牌号"。如果两个团队各自起名,一个叫_camera._tcp,另一个叫_mycamera._tcp,那么这两个团队的应用永远找不到彼此。所以适配完成后,第一件事是内部建立一个服务类型注册表,明确某个服务类型由哪个团队负责、对应的 TXT 字段有哪些、版本是什么。

我在实际项目中维护的表类似这样:

服务类型业务含义负责人端口约定TXT 必带字段
_camera._tcp摄像头直播A 团队8554model, vendor, uid
_speaker._tcp音箱发现B 团队8090room, zone
_printer._tcp打印机服务C 团队631pdls

这张表平时看着没啥用,一旦出现设备互联故障,第一个排查动作就是查这张表——百分之八九十的问题都是类型字符串不统一导致的。

6.2 测试用例设计:从"单机自通"到"多设备冲突"

适配完不能只做"两台手机手动点一圈"就算完。我建议至少覆盖这几个用例:

  1. 单机自发现:一部手机既做通告方又做发现方,验证本机回环场景。
  2. 双机互通:两台手机分别做通告和发现,验证消息能跨设备传递。
  3. 服务冲突:两台设备用同一个 serviceName 注册,验证系统如何处理(自动改名或拒绝注册)。
  4. 服务丢失感知:通告方主动退出,验证发现方是否能在合理时间内收到 serviceLost。
  5. 后台恢复:App 切后台再切回,验证发现能力是否自动恢复。
  6. TXT 修改生效:动态修改 TXT 内容后,验证发现方拿到的记录同步更新。

其中第 3 条最容易被忽略。服务名冲突是 mDNS 协议中的一个经典场景,规范要求新加入的服务应当检测到冲突并改名。实测鸿蒙的注册服务 API 在冲突时表现偏保守,有时直接注册失败,需要在桥接层做一次"换名重试"兜底。

6.3 与 CI/CD 联动:把鸿蒙真机纳入回归机器

鸿蒙模拟器的网络行为与真机差异不小,尤其在组播、Wi-Fi 直连这些场景。我建议在设备测试矩阵里固定放一两台鸿蒙真机,专门跑发现/通告用例。如果团队资源紧张,至少在上线前手动跑一遍完整链路。

6.4 版本兼容矩阵:系统升级不背锅

鸿蒙网络套件还在演进期,不同版本间 API 变化并不罕见。我建了一张兼容矩阵表,记下"服务发现 API 从哪个版本起可用、哪个版本改了回调签名、哪个版本新增了权限声明"。

遇到线上问题先查矩阵,可以省去大量"是不是系统死了"的无效排查时间。

7. 收尾前再给一个实用技巧:善用抓包,让 mDNS 问题半小时内定位

最后分享一个我在整个适配过程中觉得性价比最高的环节:抓包。mDNS 的调试,靠日志打点是低效的,因为日志只能看到自己应用的行为,看不到网络上的真实报文。抓包能直接在协议层面看到一个设备有没有发查询、另一个设备有没有回响应、回的内容对不对。

具体操作不复杂:

# 在 PC 上开启 WiFi 抓包,过滤 mDNS 流量 tcpdump -i en0 port 5353 -A -vv

或者用图形化抓包工具,直接过滤mdns。

通过抓包对比dart_service_announcement在 Android 上的报文和鸿蒙桥接层发出的报文,我能立刻判断:是系统 API 没把 mDNS 报文发出去,还是发出去的报文结构不符合预期。这种"从协议层面找根因"的思路,比在代码里加一堆日志效率高得多。

我在完成dart_service_announcement的鸿蒙化之后,最大的体会是:适配一个跨平台库,最难的从来不是代码,而是搞懂对方平台底层的"隐性契约"。Android 有 NsdManager 的整套状态机,iOS 有 Bonjour 的事件模型,鸿蒙有自己的一套生命周期和权限要求,这三者的差异是文档不会直接告诉你、但实测一定撞上的地方。趁早把协议栈的底子摸清楚,把状态机映射表建好,再把测试用例铺到真实网络环境里,鸿蒙化适配这件事就能从"玄学"变成"工程"。

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

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

立即咨询