首页 / 资讯中心 / 文章详情

鸿蒙下Flutter局域网发现:mdns_dart适配全解析

鸿蒙下Flutter局域网发现:mdns_dart适配全解析 ★ FEATURED ARTICLE
做 Flutter 鸿蒙适配这段时间我绕不开的一个需求就是局域网服务发现。鸿蒙应用想知道同一 Wi-Fi 下有哪些音箱、投屏器、传感器最标准的做法就是用 mDNS/DNS-SD而 Flutter 生态里最成熟的纯 Dart 实现就是 mdns_dart。但我很快发现一个现实问题mdns_dart 在 Android、iOS 上跑得顺不代表在鸿蒙设备上能直接跑。它依赖 dart:io 的组播 socket而鸿蒙的 Flutter 引擎在组播行为、网络接口枚举、权限模型上都有差异。这篇文章就把 mdns_dart 鸿蒙化适配的完整过程、底层原理、关键代码和实测数据一起讲清楚给那些跟我一样要在鸿蒙上做多设备互联的开发者一条可以直接参考的路径。1. 鸿蒙局域网设备发现的现状为什么绕不开 mdns_dart1.1 每个团队都在重复造轮子的现实我所在团队做的是一个多设备协同工具功能类似发现身边的设备并快速连接。立项之初我们评估过三条路线。第一条是直接用鸿蒙原生网络接口自己写 mDNS。这条路能通但代价不小mDNS 报文是二进制 DNS 协议PTR、SRV、TXT、A、AAAA 这些记录类型要自己解析TTL 和缓存要自己维护组播组要自己管理还要处理网络切换后的重连。团队里如果没人啃过 DNS 协议细节光是报文解析就能蹉跎两周。第二条路线是写 Flutter 插件用原生通道桥接实现。问题是这套方案要求同时维护原生侧和 Dart 侧两套代码鸿蒙、Android、iOS 三端各来一遍维护成本直接翻倍。而且每次鸿蒙 SDK 升级插件的编译问题都要重新折腾一轮。所以第三条路线——纯 Dart 的 mdns_dart——就成了最值得押注的方向。它不依赖任何原生代码理论上只要鸿蒙 Flutter 引擎把 dart:io 实现得足够完整一套代码就能跑通三端。这个判断后来被验证是对的但足够完整这个前提恰恰是整个适配工作的核心。1.2 mdns_dart 能提供什么适配的边界又在哪里mdns_dart 不是那种需要你从零拼装的底层库。它内部已经实现了完整的 mDNS 客户端链路创建 UDP socket、加入组播组、构造 DNS 查询报文、解析响应、按 TTL 管理缓存。对外暴露的核心 API 也很简洁一个 MDnsClient 对象负责启动和查询ResourceType 用来指定你要找的是服务类型、主机地址还是其他记录。用起来大致是这样final client MDnsClient(); await client.start(); // 查找局域网内所有 HTTP 服务实例 final result await client.lookupResourceRecords( ResourceType.service(_http._tcp.local), ); for (final record in result) { // record.name 就是设备实例名可以继续查 SRV/TXT 拿连接信息 }看到这个 API 设计我当时心里就有底了——协议细节都被封装好了使用方不需要碰报文。而鸿蒙化适配的真正边界也在这里暴露出来mdns_dart 的底层跑在 dart:io 上dart:io 由 Flutter 引擎提供鸿蒙 Flutter 引擎对 UDP 组播的支持完整度、网络接口的枚举规则、权限模型跟 Android 不完全一样。适配要做的事情不是去改协议层的解析逻辑而是把引擎差异这层补齐让 mdns_dart 在鸿蒙上也能稳定地收发组播报文。这一点想清楚后续所有工作就有了明确方向。2. mdns_dart 的底层工作机制以及鸿蒙化最大的变数在哪2.1 mDNS / DNS-SD 一次完整发现要经历什么mDNS 的基本套路是设备向局域网组播地址 224.0.0.251IPv4或 FF02::FBIPv6的 5353 端口发 DNS 格式的查询包网内所有支持 mDNS 的设备都能收到然后用自己的服务信息回包。一次完整的 DNS-SD 服务发现通常分两跳。第一跳是问这个网里有哪些服务类型客户端向_services._dns-sd._udp.local发 PTR 查询得到的是一串类似_http._tcp.local的服务类型列表。第二跳是针对具体服务类型再查一次 PTR拿到具体的服务实例名比如客厅音箱._http._tcp.local。拿到实例名之后客户端还要继续查 SRV 记录确定端口和主机名查 TXT 记录拿设备元数据查 A/AAAA 记录拿 IP。整个过程的记录类型可以整理成一张表记录类型作用典型内容PTR服务类型到实例名的映射客厅音箱._http._tcp.localSRV实例对应主机名和端口hostname.local:8080TXT实例元数据modelxxx, pdl...A / AAAA主机名到 IPv4/IPv6 地址192.168.1.23 / fe80::xxx每条记录都带 TTL客户端要在 TTL 过期前主动刷新否则设备信息会被缓存机制遗忘。这也是 mDNS 比静态配置高明的地方设备上线自动广播设备离线 TTL 一到自然消失。mDNS 规范里还有个 cache-flush 机制设备重启后会发带 cache-flush 标记的响应主动作废旧缓存但现实中不少设备实现并不标准这个后面踩坑部分会详细讲。2.2 mdns_dart 包内部的数据流mdns_dart 的工作流程可以用一条链路概括启动时在 5353 端口绑定 UDP socket加入 IPv4/IPv6 组播组发出查询后持续监听网上的 mDNS 响应收到报文后按 DNS 协议格式解析成记录对象按域名缓存起来等到下一次查询或 TTL 到期再处理。这条链路里最容易出问题的地方恰恰不是协议解析而是最底层的两步绑定 socket 和加入组播组。dart:io 提供的 RawDatagramSocket 可以做到这两件事但能做到和在所有平台都做得对是两回事。在我实测过的设备上绑定地址的选择、加入组播组时是否要显式指定网络接口、multicastHops 和 multicastLoopback 的默认值都会直接影响能不能收到响应。2.3 为什么纯 Dart不等于零适配纯 Dart 这个标签很容易给人一个错觉既然不碰原生代码换到鸿蒙上就能直接跑。真实情况是dart:io 本身就是由 Flutter 引擎实现的平台能力层它封装了操作系统差异但封装程度在每个平台上有差别。鸿蒙 Flutter 引擎在实现 dart:io 时网络部分基本复用标准能力但有几个细节跟 Android 不一样。第一权限模型不同Android 网络权限是运行时权限鸿蒙是安装时声明制漏了声明就是静默失败。第二NetworkInterface.list() 返回的接口名称与索引规则不同盲按网卡名过滤很容易拿错接口。第三鸿蒙上系统对后台网络访问、弱网下的 socket 生命周期管理有自己的策略mDNS 这种长驻组播监听进程显然要按鸿蒙的规矩来设计自愈机制。想明白这三条适配方案就不是玄学调参而是有明确目标的工作。3. 适配实操从权限声明到组播收发的完整改造3.1 工程侧权限声明与网络状态获取第一步永远是权限。鸿蒙工程里网络权限在模块的 module.json5 中声明{ module: { requestPermissions: [ { name: ohos.permission.INTERNET }, { name: ohos.permission.GET_NETWORK_INFO } ] } }这里有个常见的误解以为做局域网发现要申请定位权限。mDNS 本身就是为局域网设计的不需要定位权限组播报文不涉及位置信息。如果你用了某种按服务名推断物理位置的逻辑那是业务层的事跟协议权限无关。所以我在适配文档里特意强调INTERNET 和 GET_NETWORK_INFO 两个权限就够别多申请避免应用审核时被追问用途。GET_NETWORK_INFO 权限的作用是拿到当前活跃网络的连接属性比如 Wi-Fi 的接口信息。有了它才能在多接口设备上正确选择要加入组播组的网卡。3.2 组播 socket 的创建与接口绑定mdns_dart 原版在创建 socket 时没有暴露接口选择的能力这正是鸿蒙适配要补的第一块。我采用的做法是 fork 一个内部版本把 socket 创建逻辑抽成一个可替换的工厂方法然后在适配层显式绑定接口。核心代码形如下面这样RawDatagramSocket createMDnsSocket({ required InternetAddress multicastAddress, required NetworkInterface interface, }) { final socket RawDatagramSocket.bind( InternetAddress.anyIPv4, 5353, reuseAddress: true, ); socket.multicastHops 16; socket.multicastLoopback false; socket.joinMulticast(multicastAddress, interface); socket.listen((event) { if (event RawSocketEvent.read) { final datagram socket.receive(); if (datagram ! null) { _onMDnsPacket(datagram); } } }); return socket; }代码里两个细节值得展开。第一bind 时用InternetAddress.anyIPv4这在大部分场景下没问题但实测发现鸿蒙部分设备上绑定任意地址会导致组播包发出去了却收不到原因在于系统没有正确设置 IP_MULTICAST_IF。遇到这种情况要改成 bind 到指定接口的第一个 IPv4 地址而不是 any。第二joinMulticast的第二个参数 interface 一定不能省。多网卡设备Wi-Fi 加蜂窝甚至双 Wi-Fi上不指定接口很可能加入到了错误网卡的组播组。提示multicastLoopback 默认设为 false 是合理的本机 mDNS 响应走 loopback 会造成重复事件。但如果你的测试场景里客户端和服务端在同一台设备上需要临时改成 true 才能测通。这是排查时最容易困惑的一个点。3.3 网络切换后的自愈监听网络变化并重建会话鸿蒙设备上 mDNS 失效最高发的场景是网络切换。设备从 Wi-Fi A 漫游到 Wi-Fi B或者从蜂窝切回 Wi-Fi原来加入的组播组会随旧接口销毁而 mdns_dart 原版不会自动感知。适配层必须补上监听网络变化、重建 socket、重新查询这条自愈链路。鸿蒙侧监听网络变化用的原生接口是 ohos.net.connectionimport connection from ohos.net.connection; connection.on(netCapabilitiesChange, () { // 把事件透传给 Dart 侧 this.eventHub.emit(networkChanged); });Dart 侧拿到事件后的处理很简单就是停掉旧 client、重新 startvoid _onNetworkChanged() async { await _client.stop(); await _client.start(); _refreshAllServices(); }这里有个经验不要一收到网络变化事件就立刻重启最好加一个 1 到 2 秒的延迟等新的网络连接稳定、地址分配完成否则重启后的组播加入很可能又绑定到旧接口上。我在实测中把延迟设为 1.5 秒效果稳定。3.4 跑通一个最小链路验证改造完成后先不要直接上业务用最小链路验证一次。我在局域网一台开发机上跑了个简单的 mDNS 广告服务广播_demo._tcp.local类型的服务实例然后在鸿蒙设备上用适配后的 mdns_dart 去查询final client MDnsClient(); await client.start(); final records await client.lookupResourceRecords( ResourceType.service(_demo._tcp.local), ); for (final record in records) { print(发现实例: ${record.name}); }第一次跑通时从发出查询到 console 打印实例名大约耗时 300 到 500 毫秒。这个数字很重要后续所有性能调优都拿它当基线。如果这一步能稳定复现说明适配层的 socket 创建、组播加入、报文解析已经走通可以进入业务封装阶段了。4. 从发现到治理分布式设备连接的关键设计4.1 服务注册表的 TTL 缓存与定时刷新mDNS 发现解决的是知道设备在哪但真实业务里设备的 IP 会变、端口会变、能力会变。如果不做治理层会发现一个设备却连不上或者连上了但用的是过期信息。我的做法是在 mdns_dart 之上再包一层服务注册表统一管理当前网内有哪些可用设备这个状态。注册表的核心逻辑是收到一条记录就更新内存缓存同时记录它剩余的 TTLTTL 快过期时主动重新查询而不是被动等待。实现上我维护一张 key 为实例名的 Map配合一个定时器做 TTL 扫描class ServiceRegistry { final MapString, ServiceEntry _entries {}; void upsert(ServiceRecord record) { final entry _entries.putIfAbsent( record.name, () ServiceEntry(ttl: record.ttl), ); entry.update(record); } ListServiceEntry get activeServices _entries.values.where((e) !e.isExpired).toList(); }这一步的好处是业务层不用频繁调用 mdns_dart 的查询接口直接从注册表拿快照即可。实测下来把查询频率从每次进页面都查一次降到只在注册表 TTL 到期或页面需要实时刷新时查CPU 占用和网络包量都明显下降。4.2 上线/下线的去抖与状态机mDNS 的响应在无线网络里并不是完全稳定的。设备开机广播、网络抖动导致的重传、多个 AP 下的延迟到达都会让上层收到同一设备先消失又出现的假象。如果业务层一收到下线事件就断开连接一收到上线事件就重连用户体验会非常差。我的做法是给每条服务记录加一个 500 毫秒的去抖窗口收到 PTR 记录时不立即发上线事件而是等窗口结束再统一发布收到记录消失TTL 到期时也延迟 1 秒再发下线事件给可能的响应重传留出时间。下线的去抖窗口要比上线稍长因为在无线环境里偶尔丢一两个响应包是很正常的。去抖之后再配合一个简单的状态机新设备进入 pending去抖窗口内再次确认才进入 onlineonline 状态下连续若干个 TTL 周期没有刷新才进入 offline。这套逻辑看起来简单但它直接决定了上层 UI 会不会出现设备列表来回抖动的糟糕体验。4.3 IP 漂移、端口变化与重连策略局域网设备最常见的问题是 IP 漂移。设备重启后 DHCP 可能重新分配地址老 IP 已经失效但 mDNS 缓存里还留着老地址。这时候如果业务层拿着老 IP 去连 TCP必然失败。正确的思路是连接失败不要立刻放弃而是触发一次针对该实例的强制重查。mdns_dart 的查询结果本身带有 TTL强制重查可以拿到最新的 SRV 和 A 记录从而拿到新 IP。我在注册表里加了forceResolve(name)方法业务层在 TCP 握手失败、超时或收到 RST 时主动调用它。另一个容易被忽略的细节是端口变化。很多设备的业务端口是动态分配的SRV 记录里的端口才是准的。所以连接设备时不要硬编码端口一定要从 SRV 记录的 target 和 port 字段去构造连接地址。为这个细节我们早期在开发环境踩过一次设备的固定端口 8080 恰好和它动态分配的一致换了一台设备就怎么也连不上最后定位到是端口硬编码的问题。5. 性能实测与三个绕不开的坑5.1 实测环境与数据适配完成后我在办公网环境里做了一轮系统性测试。网络规模是一个 AP 下面挂了大概 20 多台设备其中 mDNS 服务端有 8 台测试端是 3 台不同的鸿蒙设备加上 2 台 Android 设备做对照。每台测试端轮流执行 100 次完整发现流程统计从发出查询到 PTR、SRV、TXT、A 全部拿齐的耗时。指标鸿蒙设备 A鸿蒙设备 BAndroid 对照平均发现耗时420 ms510 ms380 ms成功率100 次100%99%100%首次包到达时间180 ms240 ms150 ms这个数据说明适配后的 mdns_dart 在鸿蒙上的表现和 Android 基本持平多出来的几十毫秒主要来自鸿蒙引擎 socket 事件调度的开销。对于设备发现这类低频操作体感上没有任何差别。不过数据好看的前提是绕开了下面这三个坑。5.2 坑 1组播包有去无回查了两个晚上第一次在鸿蒙真机上测试时查询包能发出去抓包能看到但就是收不到任何响应。一开始我怀疑是防火墙或者 AP 的 IGMP snooping 设置折腾了 AP 配置也没用。后来把怀疑对象放回代码对比 Android 和鸿蒙上的 socket 行为发现差异在 bind 地址上。Android 上 bind 到InternetAddress.anyIPv4后系统默认正确选择了带 Wi-Fi 的接口鸿蒙上 bind any 之后IP_MULTICAST_IF 没有落到活跃 Wi-Fi 接口上导致组播包从错误的接口发出或者根本收不到入站组播。解法前面已经提到改成 bind 到指定接口的 IPv4 地址同时在 joinMulticast 时显式传入 NetworkInterface。这里补充一个排查技巧先在代码里打印所有 NetworkInterface.list() 的结果确认接口名称和地址是否符合预期再决定 bind 策略。很多看起来玄学的问题打印一下接口列表立刻就有答案。注意同一款鸿蒙设备的不同系统版本接口名称可能不同。代码里不要只匹配 wlan0 这种固定名称建议优先筛选包含非回环 IPv4 地址的接口再按优先级选择。5.3 坑 2Wi-Fi 切换后 mDNS 静默失效测试中有一次我把测试设备从办公 Wi-Fi 切到手机热点mDNS 发现就再也不出结果而且没有任何报错。抓包显示查询包还在发但设备没有响应。原因是组播组成员关系是绑定在旧接口上的切换网络后 socket 还活着但已经不在新的组播组里。mdns_dart 原版既不会感知网络变化也不会重建 socket所以只能是静默失效。适配层的自愈机制就是我前面写的网络变化监听加延迟重启。这里再强调一次延迟重启的必要性我一开始收到事件立刻重启发现还是失败后来加了 1.5 秒延迟才稳定。原因很简单网络切换后地址重新分配需要时间太早重启等于在旧网络状态上重试。5.4 坑 3设备重启后一直连不上老 IP 缓存作祟还有一次是设备更新固件重启后App 怎么也连不上。重查一次能发现新 IP但 App 内部保存的上一次连接信息还是旧 IP。这个问题不完全出在 mdns_dart而是整个连接治理链路mDNS 的 TTL 默认值是 4500 秒也就是 75 分钟设备信息在这段时间内会被客户端缓存视为有效。设备重启后 IP 变了但只要 TTL 没到注册表就还留着旧记录。同时mDNS 规范的 cache-flush 机制本可以主动作废旧缓存但很多设备实现并不标准甚至完全不发带 cache-flush 标记的响应。我的最终方案是双管齐下客户端这边连接失败立即触发 forceResolve不走 TTL 等待服务端这边在广告服务时把 TTL 主动调低到 120 秒缩短失效窗口。两边配合之后设备重启后的重连时间从最坏十几分钟降到了 10 秒以内。最后说一个做这类适配时的个人心得不要为了适配鸿蒙去大改 mdns_dart 的协议解析部分尽量把改动收敛在 socket 创建和生命周期管理这一层。这样上游升级时合并成本很低而且 bug 排查范围也小。我现在的内部版本对原版代码的改动只有 socket 工厂这一个点剩下的能力全部通过外层包装实现。如果你的团队也在做类似的事建议用同样的边界思路去控制改动范围长期维护会轻松很多。
阅读完成 · 觉得有帮助?
咨询建站