Flutter 局域网设备发现:UDP 广播 + mDNS 双路合并,以及靠谱的在线判定与断线重连

场景:桌面端 / 移动端 App 需要发现同一局域网里的智能设备,并维持连接状态。 技术栈:Flutter(macOS / Windows / iOS / Android)、dart:io 的 RawDatagramSocket、nsd 插件、dio。

"设备列表"看起来是最简单的功能:扫一扫,列出来,点一下连上。真正做下来会发现一堆问题:

  • 设备明明在线,列表里就是不出来;
  • 设备关机半天了,列表里还挂着;
  • 设备重启后,App 连不回来;
  • 用户点了 A 设备,结果被后台的自动重连"抢"去连了 B。

这篇文章讲讲我是怎么一步步解决这些问题的。

一、为什么要两路发现

我们的设备同时提供两种被发现的方式:

  1. UDP 广播 :设备周期性地往局域网广播一条消息,内容类似 DEVICE:<设备名>;
  2. mDNS(Bonjour) :设备注册一个服务,类型类似 _mydevice._tcp。

只用一种都不够:

方式 优点 缺点
UDP 广播 简单,设备在就一直有包 部分路由器/企业网络会屏蔽广播;同一端口只能有一个进程监听
mDNS 系统级支持,iOS 上最稳 设备掉电不会发"goodbye",系统缓存会长时间认为它在线;不周期广播

所以两路并行,结果合并去重。

UDP 监听

dart 复制代码
Future<void> startUdp() async {
  // reusePort:同一台电脑上另一个 App 也监听这个端口时不冲突;Windows 不支持该选项
  _socket = await RawDatagramSocket.bind(
    InternetAddress.anyIPv4, kUdpPort,
    reusePort: !Platform.isWindows,
  );
  _socket!.listen((event) {
    if (event != RawSocketEvent.read) return;
    final dg = _socket!.receive();
    if (dg == null) return;
    final msg = String.fromCharCodes(dg.data);
    if (!msg.toLowerCase().startsWith('$kPrefix:')) return; // 过滤无关广播
    final host = dg.address.address;
    if (dg.address.type != InternetAddressType.IPv4 || host == myIp) return;
    _upsert(_udpResults, DeviceItem(name: msg.substring(kPrefix.length + 1), host: host));
  });
}

踩坑:端口占用 EADDRINUSE。App 切到后台再回来、页面销毁又重建时,旧 socket 还没释放,新的就去 bind 了。解决办法:

  • bind 之前先彻底清理旧 socket,并 await 一小段时间等系统释放端口;
  • 加"正在初始化"的标志位,防止多条重试链同时去 bind;
  • 失败有限次重试(比如 5 次、间隔 500ms),超过就放弃,避免无限刷日志;
  • App 在后台时不 bind。

mDNS 发现

dart 复制代码
_discovery = await startDiscovery('_mydevice._tcp', ipLookupType: IpLookupType.v4);
_discovery!.addServiceListener((service, status) {
  if (status == ServiceStatus.lost) {
    // lost 事件通常只带 name,不带地址,只能按 name 删
    _mdnsResults.removeWhere((e) => e.name == service.name);
  } else if (service.addresses?.isNotEmpty == true) {
    final ip = service.addresses!.first.address;
    // 同一设备会被重复上报,先删后加,否则列表只增不减
    _mdnsResults.removeWhere((e) => e.host == ip && e.port == '${service.port}');
    _mdnsResults.add(DeviceItem(name: service.name, host: ip, port: '${service.port}'));
  }
  _merge();
});

💡 一个和固件相关的坑:设备自己开热点(AP)时,如果 DHCP 下发了网关和 DNS,iOS 发现不了 mDNS 服务,Android 正常。设备端关掉 AP 的网关和 DNS 下发后,两端都正常了。这类问题光看 App 代码是查不出来的,需要和固件同事一起抓包。

合并与排序

dart 复制代码
void _merge() {
  final result = [..._udpResults];
  for (final m in _mdnsResults) {
    if (!result.any((e) => e.host == m.host && e.port == m.port)) result.add(m);
  }
  // 按"首次被发现的顺序"排序,避免列表跳来跳去
  result.sort((a, b) => _order.indexOf(a.host).compareTo(_order.indexOf(b.host)));
  devices.value = result;
}

排序这个细节很重要:如果每次合并都按名字或信号重新排,用户正要点的时候列表一跳,就点错了。

二、在线判定:"最近广播过"或"端口此刻可达"

最初的做法是:超过 10 秒没收到广播就从列表里删掉。结果 mDNS 发现的设备会被误删------mDNS 不周期广播,正常在线也会"超时"。

反过来只信 mDNS 的状态也不行:设备直接拔电不会发 goodbye,系统缓存会把它当成在线服务继续返回很久,表现为"设备关机半天了还在列表里"。

最终的判据是:

在线 = 最近广播过 或 端口此刻可达

超时只表示"该复核一下了",不代表离线。用一个定时器定期清扫:

dart 复制代码
Future<void> _sweep() async {
  if (_sweeping) return; // 探测是异步的,别让多轮清扫叠在一起
  final now = DateTime.now();
  final stale = [..._udpResults, ..._mdnsResults]
      .where((e) => now.difference(e.lastSeen) > const Duration(seconds: 10))
      .toList();
  if (stale.isEmpty) return;

  _sweeping = true;
  try {
    // 同一台设备可能在两份列表里,按 host 去重后只探一次
    final hosts = stale.map((e) => e.host).toSet();
    final alive = {for (final h in hosts) h: await _isAlive(h)};
    for (final e in stale) {
      if (alive[e.host]!) {
        e.lastSeen = DateTime.now(); // 还活着,续期
      } else {
        _udpResults.remove(e);
        _mdnsResults.remove(e);
      }
    }
    _merge();
  } finally {
    _sweeping = false;
  }
}

Future<bool> _isAlive(String host) async {
  try {
    final s = await Socket.connect(host, kHttpPort, timeout: const Duration(seconds: 2));
    s.destroy();
    return true;
  } catch (_) {
    return false;
  }
}

为什么一定要有定时器? 因为"删除"如果只在收到广播时触发,设备全部关机后就再也没有回调了,列表永远不会更新。

三、连接保持:HTTP 心跳 + 无上限退避重连

桌面端看图软件和设备之间不需要实时推送,所以没有用 WebSocket,而是选中设备 + HTTP 轮询:

  • 每 5 秒请求一次设备的一个轻量接口;
  • 连续 2 次失败才判定断开(偶发的一次超时不算);
  • 断开后进入重连循环。

心跳一定要探"接口",不能只探端口

一开始心跳只探 TCP 端口,结果遇到过:端口开着但接口不可用,于是出现"心跳失败 → 断开 → 端口可达 → 立即重连 → 心跳失败......"的死循环。判断"能不能用",要以真实接口能不能返回为准。

重连不设次数上限,只做退避

最早写的是"失败 5 次就放弃"。问题是设备开机瞬间先发广播、HTTP 服务后就绪,几次探测失败就把次数用完了,设备开机完成后反而再也连不回来。

改成无限重试 + 退避:

dart 复制代码
static Duration backoff(int fails, {required bool deviceVisible}) {
  // 设备正出现在扫描列表里,多半只是服务还没起来,用快档
  if (deviceVisible) return const Duration(seconds: 2);
  if (fails <= 1) return const Duration(seconds: 5);
  if (fails == 2) return const Duration(seconds: 10);
  return const Duration(seconds: 15);
}

"设备在扫描列表里"就用 2 秒快档,这样用户看着设备出现在列表里,几秒内就能自动连上,而不是傻等 15 秒。

四、并发连接的竞态:用"代次"代替布尔锁

最隐蔽的 bug 是这样的:

  1. App 启动,后台开始静默重连上次的设备 A(探测需要 3 秒);
  2. 这 3 秒内,用户手动点了设备 B;
  3. 早期用一个 isConnecting 布尔值防重入,用户的点击被拦掉了;
  4. 或者反过来:B 连上了,A 的探测结果晚回来,又把选中设备改回了 A。

解决办法是引入一个递增的代次(sequence),每发起一次连接就 +1,异步操作回来时比对代次,不是最新的就丢弃:

dart 复制代码
int _connectSeq = 0;

int _beginConnect(String host) {
  connectingHost.value = host;
  return ++_connectSeq;
}

Future<void> userTapDevice(String host) async {
  final seq = _beginConnect(host); // 用户操作从不被拦,直接作废在途的自动连接
  final ok = await _probe(host);
  if (seq != _connectSeq) return; // 已经被更新的一次连接顶替了
  if (ok) _select(host);
  _endConnect(seq);
}

Future<void> silentReconnect() async {
  if (connectingHost.value.isNotEmpty) return; // 自动连接见到在途连接就让路
  final seq = _beginConnect(savedHost);
  ...
}

/// 网络切换时:作废所有在途连接,旧网络上的结果回来也不会生效
void onNetworkChanged() => _connectSeq++;

同样的思路也用在心跳上:每次启停轮询都让 _healthGeneration++,上一轮还在路上的心跳结果回来时代次对不上,直接丢弃,不会污染新一轮的失败计数。

这其实是一个通用模式:凡是"异步操作回来时,世界可能已经变了"的场景,都可以用代次来判断结果是否过期。 搜索框联想、快速切换 Tab 加载数据、图片切换,都适用。

五、总结

  1. 发现:UDP 广播 + mDNS 双路并行,合并去重,保持稳定排序;
  2. 在线判定:最近广播过 或 端口此刻可达;超时只触发复核,定时器兜底清扫;
  3. 连接保持:心跳探真实接口,连续失败才断开;重连无上限 + 退避,设备可见时用快档;
  4. 竞态:用递增代次作废过期的异步结果,用户操作优先于自动操作。
相关推荐
大龄秃头程序员2 小时前
【Flutter Bloc 从 0 到 1】用最少代码跑通 Bloc
flutter
武汉唯众智创2 小时前
物联网安装调试员实训基地建设方案(2026版)设备清单 + 预算拆解 + 技能等级认定避坑指南
物联网·物联网安装调试员·物联网实训基地·实训室建设方案·职业技能等级认定·智慧物联网 / aiot
LiuYanG3 小时前
物联网平台怎么给设备下发指令-SpringBoot下行命令与回执状态机实战
物联网
LiuYanG3 小时前
Spring Boot 单元测试怎么写?物联网平台 MockMvc 接口测试与 Mockito 消息链路测试实战(附 JaCoCo 覆盖率报告)
物联网
zoyation3 小时前
jrp 1.2.0 重磅发布|P2P打洞直连 · 端口自动分配 · 一端口多服务,这次全齐了!
网络·网络协议·p2p
m0_738185823 小时前
Flutter 鸿蒙化实战:sqflite 适配 OpenHarmony,本地 SQLite 数据库
flutter·华为·harmonyos·鸿蒙
盛世宏博智慧档案4 小时前
IP 温湿度设备频繁断连?TCP 心跳保活机制调试与参数设置指南
网络·网络协议·tcp/ip·php·传感器·温湿度
权球物联分享物联网连接服务4 小时前
AGV 机器人商业化落地验证,物联网卡保障产线交付稳定性!
物联网·机器人
码流子4 小时前
事件告警延迟拆解:从摄像头到手机通知,延迟卡在哪六个环节
数据库·人工智能·物联网
梦曦i4 小时前
uni-app、uni-app x 与 Flutter 深度对比:2026年跨平台开发框架选型指南
flutter·uni-app·uni-app x