场景:桌面端 / 移动端 App 需要发现同一局域网里的智能设备,并维持连接状态。 技术栈:Flutter(macOS / Windows / iOS / Android)、
dart:io的RawDatagramSocket、nsd插件、dio。
"设备列表"看起来是最简单的功能:扫一扫,列出来,点一下连上。真正做下来会发现一堆问题:
- 设备明明在线,列表里就是不出来;
- 设备关机半天了,列表里还挂着;
- 设备重启后,App 连不回来;
- 用户点了 A 设备,结果被后台的自动重连"抢"去连了 B。
这篇文章讲讲我是怎么一步步解决这些问题的。
一、为什么要两路发现
我们的设备同时提供两种被发现的方式:
- UDP 广播 :设备周期性地往局域网广播一条消息,内容类似
DEVICE:<设备名>; - 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 是这样的:
- App 启动,后台开始静默重连上次的设备 A(探测需要 3 秒);
- 这 3 秒内,用户手动点了设备 B;
- 早期用一个
isConnecting布尔值防重入,用户的点击被拦掉了; - 或者反过来: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 加载数据、图片切换,都适用。
五、总结
- 发现:UDP 广播 + mDNS 双路并行,合并去重,保持稳定排序;
- 在线判定:最近广播过 或 端口此刻可达;超时只触发复核,定时器兜底清扫;
- 连接保持:心跳探真实接口,连续失败才断开;重连无上限 + 退避,设备可见时用快档;
- 竞态:用递增代次作废过期的异步结果,用户操作优先于自动操作。