1. 为什么 dart_service_announcement 会成为鸿蒙化路上的第一个拦路虎在 Flutter 应用从 Android/iOS 向鸿蒙 NEXT 迁移的实践中最先撞墙的往往不是 UI 组件也不是状态管理而是那些躲在角落里的网络小工具。我遇到的第一个典型就是dart_service_announcement——一个负责局域网服务通告与发现的轻量插件。它在智能家居联动、投屏设备查找、配件周边发现这些场景里几乎是标配尤其适合做类似App 自动发现局域网里的摄像头 / 音箱 / 打印机这类功能。先说结论这个库本身不大核心逻辑也不复杂但它的痛点在于强依赖宿主平台的多播 DNSmDNS能力和系统服务发现框架。在 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 记录。这里有几个关键机制决定了它能不能稳定工作缓存与 TTLmDNS 响应不是每次都发。其他设备收到响应后会把服务信息缓存起来缓存时长由响应报文里的 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 侧为例它做的事情本质上是用NsdManager.registerService()注册一个NsdServiceInfo包含服务名、服务类型、端口和 TXT 属性用NsdManager.discoverServices()发起对某个服务类型的发现通过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-1234port服务监听端口serviceType服务类型例如_camera._tcptxtTXT 记录字典例如{ 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: Recordstring, 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最后置空引用否则会有线程泄漏和重复回调。我把状态机做了一个简单映射表事件阶段AndroidNsdManager鸿蒙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 回调里的数据当成服务发现结果回传给 DartDart 层会认为发现立即成功却拿不到后续设备上线事件。正确的桥接方式是then里只回一条discoveryStarted的事件设备上下线事件必须通过serviceFound/serviceLost监听器回传每个事件回传前先序列化成 Dart 层能识别的 Map。这个经验说起来简单但实际工程里有相当一部分适配完找不到服务的问题根源就是 Async 时序搞错了。5. 实测排障为什么看起来通了但就是看不到设备是最难查的坑适配完成不代表能跑通业务。我在真机联调阶段遇到的几个问题一度让人怀疑是协议栈的问题最后都证明是桥接层的细节。下面按我的排查顺序完整还原一遍。5.1 服务通告成功但另一台手机死活发现不了现象设备 A鸿蒙原生注册服务成功了success回调正常设备 BFlutter 应用执行startDiscovery没有任何 serviceFound 事件。排查链路一层层往深处走先确认两台设备在同一局域网。这个看似废话但在双频路由器、AP 隔离开启的办公网里不同 VLAN 段的设备是互不可见的。把两部手机都连到同一个热点就能排除。再确认服务类型字符串完全一致。dart_service_announcement发现方传入的类型带不带下划线、后缀是不是_tcp必须在发现方和设备端完全一致。用抓包工具看 5353 端口有没有组播流量。在 PC 上装一个抓包工具过滤port 5353看设备 B 有没有发出 PTR 查询设备 A 有没有回 PTR 响应。最后检查 WiFi 是否开启了客户端隔离。热点的高级设置里如果有 AP 隔离组播包会被网关拦截这是最坑的一环。实测下来很多场景的问题根源是第 4 条而不是代码逻辑。5.2 明明能发现服务但拿不到 TXT 记录第二次踩坑是设备 B 能看到设备 A 在线但点击进去发现没有 TXT 信息无法做后续握手校验。原因是鸿蒙侧serviceFound回调里返回的txt字段类型和 Android 的NsdServiceInfo.getAttributes()不一致。鸿蒙把 TXT 记录解析成了一个数组我原以为直接转成 Map 就行结果发现数组里每一项都是keyvalue的字符串。修复方式是在桥接层做一次解析function parseTxtArray(arr: string[]): Recordstring, string { const result: Recordstring, 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时把服务信息压入本地 MapserviceLost时从 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 是否解析正确keyvalue 正常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 测试用例设计从单机自通到多设备冲突适配完不能只做两台手机手动点一圈就算完。我建议至少覆盖这几个用例单机自发现一部手机既做通告方又做发现方验证本机回环场景。双机互通两台手机分别做通告和发现验证消息能跨设备传递。服务冲突两台设备用同一个 serviceName 注册验证系统如何处理自动改名或拒绝注册。服务丢失感知通告方主动退出验证发现方是否能在合理时间内收到 serviceLost。后台恢复App 切后台再切回验证发现能力是否自动恢复。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 的事件模型鸿蒙有自己的一套生命周期和权限要求这三者的差异是文档不会直接告诉你、但实测一定撞上的地方。趁早把协议栈的底子摸清楚把状态机映射表建好再把测试用例铺到真实网络环境里鸿蒙化适配这件事就能从玄学变成工程。