1. 为什么第二篇才谈架构:单连接 Demo 藏着的三个坑
上一篇我们把ServerSocket.bind跑通,用Socket.connect连上去,互相发几行字符串,屏幕上能打印出来,那一刻确实爽。但凡是把这种 Demo 直接搬进真实项目的人,大概都在同一个地方栽过跟头:单机测试一切正常,换到真机、换到多台设备同时连、换到用户切后台再切回来,整个服务就像断了线的风筝,连接还在,数据没了,日志也不报错。
这一篇要解决的就是这个落差。Flutter用Socket实现主机服务(也就是让一台设备当服务端,其他设备当客户端连进来),难点从来不在“怎么连上”,而在于连上之后的一整套秩序:谁管连接、谁管数据边界、谁管超时、谁管进程别被系统悄悄干掉。我把这套秩序拆成架构、协议、实现、发现、平台差异、排查六块来讲,面向的是已经能跑通 Hello World、准备做真实功能的人,比如局域网文件互传、多设备协同白板、投屏遥控、线下多人小游戏这类场景。
先把这个系列里最容易忽略的前提说清楚:移动端的“主机服务”和服务器机房里那套东西不是一回事。机房里的服务端假设进程一直活着、网络一直通、内存管够;手机上的主机服务假设的是——用户随时会锁屏、系统随时会回收、WiFi 随时会切、另一台设备随时会掉线。你在架构上做的每一个选择,本质上都是在回答“这些意外发生时,我怎么办”。
1.1 从“一对一连得上”到“多对一扛得住”
单连接 Demo 的代码通常长这样:server.listen((socket) { socket.listen(print); })。这行代码在只有一个客户端时看不出毛病,但一旦第二个客户端进来,你会立刻发现两件事:第一,你没有地方存这两个 socket,想主动给某个客户端推消息时找不到引用;第二,两边的数据混在同一个回调里,你分不清哪条消息来自谁。
所以第一层架构改造是引入**会话(Session)**这个概念。每 accept 到一个 socket,就生成一个会话对象,里面至少装四样东西:唯一 ID、远端地址、写入口、解码器实例。会话对象是主机服务的基本调度单位,后面所有的广播、踢人、统计在线数,都是围绕这个 Map 在操作。
这里有个很容易被忽略的点:会话 ID 不要直接用 IP 加端口。原因很实在,NAT 环境下多台设备可能共享出口地址,端口也会因为重连而变化;更稳妥的做法是连接建立后由客户端先上报一个设备标识(可以是持久化的 UUID),服务端用它做逻辑身份,物理连接只作为传输通道。这样断线重连之后,你还能把新连接映射回原来的业务上下文,用户不会看到“好友掉线又重新加了一遍”这种莫名其妙的体验。
第二层是广播的边界。新手常犯的错误是把广播写成一个 for 循环遍历所有会话直接add。这在十个连接以内没问题,到几十个连接、每个连接都在高频推数据时,就会撞上背压问题——某个客户端网络差,它的 socket 缓冲区写满,add会阻塞或者丢数据,进而拖慢整个循环。正确的做法是给每个会话配一个发送队列,写操作异步化,队列长度设上限,超限就判定该客户端“跟不上”,主动断开而不是让它拖累别人。
1.2 主机的生命周期比连接本身更难管
连接的生死是显性的,onDone一触发你就知道它没了。主机的生死是隐性的,进程被系统挂起时不会有任何回调,等你回来时 socket 已经是个僵尸对象,add不报错,对端却收不到。
我在实际项目里吃过这个亏:应用切到后台十分钟,回来之后界面显示“3 台设备在线”,实际上那三台早就断了,只是本地的会话 Map 没被清理。用户点了发送,转圈转到超时。后来我们加了一条铁律——在线状态必须以心跳为准,不能以 Map 里有没有键为准。每个会话记录最后一次收到有效数据的时间戳,定时器每十秒扫一遍,超过三十秒没动静的直接销毁并广播离线事件。
另一个生命周期问题是启动幂等。用户手滑连点两次“开启服务”,如果代码里没有对_server != null做判断,第二次bind会抛SocketException: Address already in use,而这个异常如果在异步回调里没被捕获,会直接变成未处理异常打到控制台,用户那边只看到按钮没反应。我的做法是把服务封装成一个带状态机的对象,状态只有idle / starting / running / stopping,所有启动调用先查状态,非idle一律直接返回当前 Future,这样并发调用天然安全。
1.3 线程模型:Dart 的“单线程”其实是假象
很多人第一次听说 Dart 是单线程模型,就担心“我当服务端会不会一连接多就卡”。这个担心方向对,但结论错。Dart 的单线程指的是单个 Isolate 内只有一个事件循环,而所有 socket IO 都是操作系统层面完成的,事件循环只负责在数据就绪时回调你的代码。IO 密集型场景——也就是主机服务最典型的场景——单 Isolate 完全够用,一个中端手机跑几十个并发连接、每秒几百条小消息,CPU 占用是很低的。
真正需要警惕的是CPU 密集型任务混进事件循环。比如你在收到文件分片后顺手做了一次 AES 解密加 Base64 解码,这一坨同步计算会卡住整个事件循环,所有连接的收发全部延迟。表现就是:传小文件没事,传大文件时所有客户端一起卡顿。
判断标准很简单:一段处理逻辑如果耗时超过 16 毫秒,就该挪走。挪的方式有两种,Isolate.run适合一次性任务(比如整块数据解密),常驻的Isolate.spawn加双向SendPort适合持续性的重活(比如视频帧转码)。要注意 Isolate 之间传对象是有成本的,能传TransferableTypedData就别传大Uint8List,前者是转移所有权,后者是完整拷贝。
顺带说一句,服务端的ServerSocket我一般就放在主 Isolate 里,理由是它只负责 accept,真正的数据处理按需分发。把手写的主循环和 UI 事件循环强行拆开,反而会引入一堆跨 Isolate 的状态同步问题,得不偿失。
2. 协议先行:让 Socket 上的字节流不再靠猜
Socket 给你的是字节流,不是消息流。这句话是所有网络编程事故的源头,值得单独拎出来讲一整节。TCP 保证的是字节按序到达,不保证你一次write对应对方一次read。你发了两条消息,对方可能一次收到两条拼在一起,也可能先收到第一条的前半截。这就是粘包和半包。
2.1 粘包与半包到底怎么来的
用一个生活化的比喻:TCP 像一根水管,你往里倒了一杯水、又倒了一杯水,对面接水的人看到的是一股连续的水流,他没法凭水流本身判断“这是两杯”。想让他分清,你得在每杯水里放一张写着容量的纸条。
具体到代码里,假设客户端连续调了三次socket.add(utf8.encode('hello'))。由于 Nagle 算法和内核缓冲的存在,服务端很可能只收到一次onData,内容是hellohellohello。如果你的处理逻辑是“收到一次就当一条消息”,那你就把三条消息当成了一条超长消息,解析必然失败。
半包更阴险。发一条 8KB 的 JSON,服务端第一次onData可能只给你 1448 字节(一个以太网 MTU 的量级),剩下的分好几次到。如果你的代码里写着jsonDecode(utf8.decode(data)),那必然抛FormatException,而且这个异常出现与否取决于网络状况,本地测试永远复现不了,上了真实 WiFi 才开始炸。
有人会说,那我用换行符分隔不就行了?LineSplitter确实能解决大部分场景,很多文本协议(比如简单的聊天)用它完全够。但它有两个硬伤:一是消息体里不能出现分隔符,二进制数据直接出局;二是超长消息没有长度上限,一个恶意或者出 bug 的客户端发一条 500MB 的“行”,你的内存就没了。
2.2 长度前缀 + 类型字段:一套能长期用的帧格式
我的默认选择是定长头部 + 变长载荷。头部 8 个字节:前 4 字节是载荷长度(大端序uint32),后 2 字节是指令类型(uint16),再留 2 字节给标志位或者序号。载荷就是业务数据,二进制或者 UTF-8 编码的 JSON 都行。
const int kHeaderSize = 8; const int kMaxFrameSize = 4 * 1024 * 1024; // 单帧上限 4MB Uint8List encodeFrame(int type, List<int> payload) { final body = payload is Uint8List ? payload : Uint8List.fromList(payload); final frame = Uint8List(kHeaderSize + body.length); final header = ByteData.sublistView(frame, 0, kHeaderSize); header.setUint32(0, body.length, Endian.big); header.setUint16(4, type, Endian.big); frame.setRange(kHeaderSize, frame.length, body); return frame; }解码端必须做成有状态的解析器,每次喂进去一段新数据,吐出来零到多条完整帧,剩下的留在内部缓冲里等着下次拼。这段逻辑是整个主机服务里最值得写单元测试的地方,我一般在项目里给它配至少六组用例:完整一帧、两帧粘一起、一帧拆两半、头部拆两半、空包、超长包。
class FrameDecoder { Uint8List _pending = Uint8List(0); List<({int type, Uint8List payload})> feed(Uint8List chunk) { final merged = Uint8List(_pending.length + chunk.length) ..setRange(0, _pending.length, _pending) ..setRange(_pending.length, _pending.length + chunk.length, chunk); _pending = merged; final out = <({int type, Uint8List payload})>[]; var offset = 0; while (_pending.length - offset >= kHeaderSize) { final header = ByteData.sublistView( _pending, offset, offset + kHeaderSize); final len = header.getUint32(0, Endian.big); final type = header.getUint16(4, Endian.big); if (len > kMaxFrameSize) { throw const FormatException('frame too large'); } if (_pending.length - offset - kHeaderSize < len) break; final start = offset + kHeaderSize; out.add((type: type, payload: _pending.sublist(start, start + len))); offset = start + len; } if (offset > 0) _pending = _pending.sublist(offset); return out; } }这段的实现思路是“可读优先”,每次拼接都新建数组,在几百字节的消息上完全没压力。如果单条消息动辄几百 KB、每秒几十条,就得换成环形缓冲区加读写指针,避免反复分配。这个优化我建议先别做,等 profile 数据真的指到这儿了再动手,过早优化会让代码可读性掉一个档次。
注意:
kMaxFrameSize一定要设,而且要在读到头部之后立刻校验。没有上限的解析器等于把内存交给对端控制,公网上跑一天就能把你服务器打爆。局域网内也别省这一步,手机 OOM 崩掉比报错难查得多。
2.3 心跳、超时与半开连接的判定
TCP 有个很尴尬的状态叫半开连接:一端已经拔网线或者进程被杀,另一端的内核毫不知情,还认为连接是好的。这种情况下socket.done永远不会触发,你的会话就变成了永久僵尸。
标准的解决思路是应用层心跳。客户端每隔一段时间发一个类型为ping的空帧,服务端收到立刻回pong。服务端这边维护一个lastActiveAt时间戳,任何入站数据(包括pong)都刷新它,定时器发现某个会话超过阈值没刷新就直接关掉。
参数的取值是有讲究的,不是随便拍。心跳间隔要小于移动网络的 NAT 超时时间,常见做法是 15 到 30 秒;超时判定一般是心跳间隔的 2 到 3 倍,我习惯用 30 秒间隔配 90 秒超时。间隔太短会明显增加耗电,手机上每 5 秒唤醒一次网络栈,用户看电量统计会找你麻烦;间隔太长又会出现“用户已经走了半分钟,界面还显示在线”的尴尬。
还有一个不那么明显的问题:重连风暴。服务端重启,几十个客户端同时掉线,同时开始重连,如果重连策略是固定 1 秒重试,那所有重连请求会在同一秒砸进来。正确做法是指数退避加随机抖动,比如从 1 秒开始翻倍,最大 30 秒,每次附加 0 到 500 毫秒的随机量。这个改动只有几行代码,但能显著提升服务的恢复体验。
3. 动手实现:把主机服务拆成四个可复用模块
架构讲完,落到代码。我把主机服务拆成四块:监听器负责端口绑定和 accept,会话管理负责连接集合和生命周期,编解码与分发负责把字节变成业务事件,发现服务负责让客户端找到我。四块之间只通过明确定义的接口通信,这样以后换传输层(比如从裸 TCP 换成 WebSocket 以兼容浏览器端)时,改动被限制在监听器和编解码两个模块里。
3.1 工程结构与依赖约束
目录我一般是这么分的:
lib/host/host_server.dart—— 对外门面,暴露 start/stop/broadcast/onMessagelib/host/session.dart—— 会话对象与发送队列lib/host/frame_codec.dart—— 帧编码与有状态解码lib/host/discovery.dart—— UDP 广播发现lib/host/protocol.dart—— 指令类型常量与业务消息定义
依赖上尽量克制,核心服务只依赖dart:io和dart:async,这两是 SDK 自带的。心跳和定时器用Timer就够,不用引第三方调度库。真正需要外部插件的地方只有两个:保持后台存活和获取当前 WiFi 信息,这两个稍后单开一节讲。
3.2 监听器:端口绑定的参数取舍
端口选择有三个约束要同时满足:不能落在 1 到 1023 的特权区间(移动端虽然限制没桌面端严,但为了跨端一致还是避开);不能和常见服务撞车(80、443、8080、3306 这些);最好固定,方便用户手动输入时少一位数字。
我一般选 40000 到 50000 之间的一个固定值,比如 45231。bind的时候把shared打开,这样热重启时不会因为上个实例没释放而报错。
Future<void> start() async { if (_state != HostState.idle) return; _state = HostState.starting; try { _server = await ServerSocket.bind( InternetAddress.anyIPv4, port, shared: true, backlog: 16, ); _state = HostState.running; _server!.listen(_onAccept, onError: _onServerError, onDone: _onServerDone); _bindTimer(); } catch (e) { _state = HostState.idle; rethrow; } }InternetAddress.anyIPv4和loopbackIPv4的区别值得说一句。用anyIPv4监听所有网卡,同一 WiFi 下的其他设备才能连进来;用loopbackIPv4只有本机能连,适合开发调试时不想让外部设备干扰。很多人排查“为什么另一台手机连不上”排查半天,最后发现绑的是 loopback,这个坑踩一次就记住了。
backlog: 16是等待 accept 的队列长度,默认值在部分平台上偏小,短时间大量连接涌入时可能被拒。设成 16 到 32 之间比较稳妥,再大意义不大,因为 accept 本身非常快。
3.3 会话管理:连接对象、缓冲与背压
会话对象我按这个形状写:
class HostSession { HostSession(this.socket) : id = _genId(); static int _counter = 0; static String _genId() => 'S${_counter++}'; final Socket socket; final String id; String? deviceId; DateTime lastActiveAt = DateTime.now(); final _decoder = FrameDecoder(); final List<Uint8List> _outQueue = []; bool _writing = false; bool _closed = false; void enqueue(Uint8List frame) { if (_closed) return; if (_outQueue.length > 200) { close(); // 跟不上就断开,不拖累别人 return; } _outQueue.add(frame); _pump(); } Future<void> _pump() async { if (_writing || _closed) return; _writing = true; try { while (_outQueue.isNotEmpty) { socket.add(_outQueue.removeAt(0)); await socket.flush(); } } catch (_) { close(); } finally { _writing = false; } } void close() { if (_closed) return; _closed = true; _outQueue.clear(); socket.destroy(); } }队列上限 200 这个数字不是拍的,按单帧平均 1KB 算大约是 200KB 的积压,超过这个量说明对端要么网络极差要么根本不读,继续积压只会让你的内存曲线失控。断开它,让客户端自己去重连,是更健康的选择。
await socket.flush()这步在有些教程里会被省略,但在移动端很关键:add只是把数据塞进 Dart 层的缓冲,flush才是推给内核。不 flush 的话,在小流量场景下数据可能趴在你自己的缓冲里十几毫秒才出去,体感上就是“消息发了但对方隔一会才收到”。
3.4 编解码器与指令分发
分发这块我用一个switch加常量的朴素写法,不搞什么反射注册表。指令类型定义成int常量,集中在一个文件里,前后端共用同一份定义,改协议时改一处。
class Cmd { static const ping = 1; static const pong = 2; static const text = 10; static const fileMeta = 20; static const fileChunk = 21; } void _onPayload(HostSession s, int type, Uint8List payload) { switch (type) { case Cmd.ping: s.enqueue(encodeFrame(Cmd.pong, Uint8List(0))); case Cmd.text: onText?.call(s, utf8.decode(payload)); case Cmd.fileChunk: onChunk?.call(s, payload); default: // 未知指令不要断连,记日志即可 } }关于未知指令的处理,我特意留了个default分支只记日志不断连。原因很实际:客户端和服务端的版本可能不同步,老客户端连上新服务端是很常见的情况。因为一个不认识的指令就断连,用户体验会非常差。向前兼容的代价就是这里几行代码。
3.5 客户端侧的最小对接实现
服务端写完了,客户端得能连。最小实现不长,但有几个参数不能省:
Future<Socket> connect(String ip, int port) async { final socket = await Socket.connect( ip, port, timeout: const Duration(seconds: 3), ); socket.setOption(SocketOption.tcpNoDelay, true); return socket; }timeout必须给。不给的话,目标 IP 不存在时系统默认超时可能是几十秒,用户点了连接之后按钮一直转圈,会以为应用卡死。三秒是个合理值,局域网内正常握手在几十毫秒量级。
tcpNoDelay打开是为了关掉 Nagle 算法。Nagle 的设计目的是把小包攒起来一起发以提升带宽利用率,但在交互式场景下它会引入几十毫秒的额外延迟,感受上就是“操作延迟”。我们发的帧本来就经过了自己的缓冲控制,不需要系统再帮忙攒包,所以直接关掉。
客户端的接收端同样要用FrameDecoder,和服务端共用一份代码。这一点在工程上很划算:协议格式只有一个定义,不会出现两端理解不一致的问题。
4. 让客户端找得到你:局域网发现的三条路
服务跑起来了,接下来的问题是:另一台设备怎么知道主机的 IP 是多少?让用户手输192.168.1.37显然不现实。这块有三条路,各有各的适用边界。
4.1 UDP 广播:最土但最通用
思路很直白:客户端往局域网广播地址发一个探测包,主机收到后回一个包含自己 IP、端口、设备名的应答,客户端就拿到了地址。
// 主机侧 Future<void> startDiscovery(int port) async { final sock = await RawDatagramSocket.bind( InternetAddress.anyIPv4, 45232, reuseAddress: true, ); sock.broadcastEnabled = true; sock.listen((event) { if (event != RawSocketEvent.read) return; final dg = sock.receive(); if (dg == null) return; final text = utf8.decode(dg.data, allowMalformed: true); if (text == 'DISCOVER:MYAPP') { final reply = jsonEncode({ 'name': await _deviceName(), 'port': port, 'token': _sessionToken, }); sock.send(utf8.encode(reply), dg.address, dg.port); } }); }客户端这边绑定一个随机端口发送广播,然后监听一小段时间收集应答:
Future<List<Map<String, dynamic>>> discover() async { final sock = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0); sock.broadcastEnabled = true; final results = <Map<String, dynamic>>[]; final sub = sock.listen((event) { if (event != RawSocketEvent.read) return; final dg = sock.receive(); if (dg == null) return; results.add({ ...jsonDecode(utf8.decode(dg.data)), 'ip': dg.address.address, }); }); sock.send(utf8.encode('DISCOVER:MYAPP'), InternetAddress('255.255.255.255'), 45232); await Future.delayed(const Duration(seconds: 2)); await sub.cancel(); sock.close(); return results; }实测下来这套方案在绝大多数家用路由器下能工作,但有几个已知的失效场景必须提前想好兜底:路由器开了AP 隔离(很多公共 WiFi 默认开),设备之间根本不互通;设备连在不同的频段(2.4G 和 5G 分开 SSID 时其实还是同一个网段,但有些路由器会隔离);防火墙拦截广播包。这些都排查不了,也不用排查,直接引导用户走手动输入。
4.2 组播与 mDNS 的适用边界
组播(224.0.0.0/4)比广播更“礼貌”,交换机只会把包转发给加入了该组的端口,理论上更适合发现协议。Dart 里用joinMulticast就能加入组:
final sock = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 45233); sock.joinMulticast(InternetAddress('224.0.0.167'));但它需要额外的权限,Android 上要CHANGE_WIFI_MULTICAST_STATE,而且部分家用路由器对组播的支持并不好。我在实际项目里的经验是:广播的成功率明显高于组播,所以主路径用广播,组播作为补充。
mDNS(也就是苹果那套 Bonjour)的好处是能提供_myapp._tcp.local这种人类可读的服务名,还能和系统级的服务发现打通。代价是要引插件、要在 iOS 的Info.plist里声明NSBonjourServices,而且 Android 各厂商 ROM 对它的支持参差不齐。如果你的产品只在 iOS 或者只在桌面端跑,mDNS 很值得;要覆盖安卓全家桶,我建议还是广播打底。
4.3 兜底方案:二维码与手动输入
不管用哪种自动发现,兜底方案都得有,而且要好用。我的做法是:主机启动服务后立刻在界面上展示一个二维码,内容是myapp://connect?ip=192.168.1.37&port=45231&token=abc123。客户端扫码直接拿到全部信息,一步到位。
二维码相比手动输入的优势不只是省事,更重要的是它顺带解决了安全问题。URL 里带的token是每次启动随机生成的,主机只接受携带正确 token 的首次握手,其他设备即使扫到了 IP 也连不上。这个机制在公共 WiFi 场景下很有必要,否则同网段任何人都能往你的服务里塞数据。
手动输入这条最后的退路也别砍。二维码在对方设备没有摄像头、或者你的应用不方便调扫码时就是废的。输入框里预填上网关段的常见前缀(比如192.168.),用户只需要补后面几位,体验会好很多。
5. 平台差异与服务保活:那些文档里不写的细节
同一份 Dart 代码,在三个平台上跑出来的行为可能完全不同。这一节讲的全是我在实际适配中撞出来的东西。
5.1 Android 侧的端口、权限与前台服务
权限上,INTERNET是必须的,Android 6 之后虽然它属于普通权限不需要动态申请,但 manifest 里漏写会直接抛异常。如果你要用组播,还得加CHANGE_WIFI_MULTICAST_STATE。
更麻烦的是后台存活。Android 8 之后对后台进程的限制越来越紧,应用切到后台几分钟,进程就可能被冻结甚至杀掉,你的 ServerSocket 随之消失。官方给出的正解是前台服务:起一个带常驻通知的服务,系统就不会随便动你。Flutter 侧可以通过插件实现,核心是声明FOREGROUND_SERVICE权限,并在启动时把服务类型标成合适的类别。
我的实际做法比较保守:只在用户显式点击“保持后台运行”时才启动前台服务,默认不开。原因是常驻通知对普通用户是个明显的打扰,而大部分使用场景(比如当面传输)根本不需要后台存活。把选择权交给用户,同时在界面上写清楚“开启后通知栏会出现一条常驻通知”,比默认偷偷开着体验好得多。
还有一个细节:Android 10 之后对anyIPv4监听的限制其实没有传言中那么严,但如果你的应用处于省电模式的深度限制状态,socket 会被静默掐断。所以服务端的日志里一定要记录“服务启动时间”和“最后一次收到数据时间”,用户反馈连不上时,这两个时间戳能帮你快速判断是启动失败还是中途被掐。
5.2 iOS 本地网络权限与后台限制
iOS 14 引入了一个新的权限:本地网络访问。应用第一次尝试往局域网发数据时会弹窗询问,用户点了“不允许”,你的所有广播和连接都会静默失败——注意是静默,不会报错,connect就是一直 pending 到超时。
对应的Info.plist必须声明NSLocalNetworkUsageDescription,描述文案要写清楚用途,比如“用于发现同一局域网内的其他设备并建立直连传输”。如果你用 mDNS,还要在NSBonjourServices里列出服务类型,漏了这个会直接被系统拒绝。
后台限制方面,iOS 比 Android 严格得多。应用进后台后大约只有几十秒的宽限期,之后 socket 会被系统挂起。能在后台持续通信的合法路径只有几种特定类别,而且审核对着墨很严格。我的建议很直接:不要把“后台持续当服务端”当成需求。正确的产品设计是——用户切走时服务暂停,切回来时自动重启并让客户端重连。这个设计反而更省电,也不会遇到审核问题。
5.3 桌面端的特权端口与防火墙
桌面端(Windows、macOS、Linux)跑同样的代码,主要差异在防火墙。Windows 上第一次监听端口会弹出防火墙提示,用户点了“取消”,之后这个端口就彻底不通,而且不会再提示。这是排查桌面端“为什么另一台电脑连不上”的头号原因,比代码问题的概率高得多。
另一个边界是特权端口:Linux 和 macOS 上绑定 1024 以下需要 root 权限,用普通用户跑会直接抛SocketException: Permission denied。所以千万别把默认端口设成 80 或者 8080 这种看起来顺眼的数字,用 45231 这种高位端口能省掉一堆权限问题。
6. 常见问题与排查实录
主机服务这东西,出问题时现象往往高度相似——连不上、或者连上了没数据——但根因可能天差地别。这份速查表是我在几个项目里攒下来的,按出现频率排序。
6.1 排查速查表
| 现象 | 高频原因 | 定位方法 |
|---|---|---|
| 客户端连接立即被拒 | 服务端绑的是 loopback 而非 anyIPv4 | 打印_server.address.address,应为0.0.0.0 |
| 连接超时无响应 | 两端不在同一网段,或路由器 AP 隔离 | 用系统 ping 交叉验证连通性 |
| 连上后完全没有数据 | 一端忘了flush,或帧头长度写错 | 抓包看是否有字节流出,检查setUint32的字节序 |
| 大消息必崩 | 解码器没有长度上限,或半包处理缺失 | 单测跑“一帧拆两半”用例 |
| 传几个文件后全部卡死 | 事件循环被同步计算阻塞 | 用Stopwatch打点,超 16ms 的段落挪进 Isolate |
| 切后台回来后所有设备显示离线 | 缺心跳超时清理 + 缺重连退避 | 查看会话 Map 是否残留僵尸项 |
| 只有 iOS 连不上 | 缺本地网络权限描述或用户拒绝了 | 检查Info.plist,并让用户去设置里重开 |
排查的第一步永远是缩小范围,别一上来就改代码。我的顺序是:先用系统自带的 ping 确认网络可达,再用一个极简的nc或者 telnet 确认端口开着,最后才怀疑协议层。很多“代码 bug”在第一步就被排除了。
6.2 性能与内存的几个实测数据
我在一台中端安卓机上做过一轮粗测,数据不精确但有参考价值。八个客户端同时在线,每客户端每秒发 20 条平均 200 字节的消息,也就是总共约 32KB/s 的入站流量,CPU 占用基本在个位数百分比,主力 Isolate 的事件循环延迟在 2 毫秒以内。
把单帧放大到 256KB、每秒 10 帧(约 2.5MB/s),CPU 上升到 20% 左右,内存因为反复sublist分配会看到明显的 GC 波动。这时候把解码器换成环形缓冲、payload用Uint8List.sublistView做零拷贝切片,GC 次数能降下来一大截。这个优化的门槛很明确:小消息场景别做,大流量场景必须做。
还有一个容易被忽略的内存点:会话的发送队列如果用的是List并且频繁removeAt(0),元素多时是 O(n) 的。队列长度一旦超过几十,就该换成Queue(dart:collection里的ListQueue)。这个改动只有一行,但能省掉不必要的搬移开销。
6.3 我踩过的五个坑
第一个坑是忘记处理onError。socket.listen不给onError的话,连接异常会变成未捕获异常。表现是应用在某些网络抖动下直接白屏,日志里只有一句看不懂的堆栈。给每个listen都补上onError并把会话清理掉,这个习惯能省掉大量线上问题。
第二个坑是用 IP 当设备唯一标识。换网络之后 IP 变了,历史记录全对不上。改成客户端上报持久化 UUID 之后,重连映射就很自然了。
第三个坑是在onData里做异步等待。有人写成socket.listen((data) async { await saveToDisk(data); }),这会导致数据乱序,因为下一个onData不会等你。正确做法是先把数据放进队列,由一个串行的消费者处理。
第四个坑是广播时遍历中修改 Map。某个会话在enqueue里因为队列超限把自己close了,如果close里顺手从会话 Map 里删掉自己,而外面正for循环遍历这个 Map,就会抛ConcurrentModificationError。改成先List.of(_sessions.values)拷贝一份再遍历。
第五个坑是调试时用 debug 模式测性能。Flutter 的 debug 模式为了热重载,性能比 release 低一个数量级,尤其在大量小对象分配的场景。有人拿 debug 模式的数据说“Socket 性能不行”,换成 release 之后问题直接消失。测性能一律用--profile或 release 包。
最后一个心得是关于日志的。主机服务的日志我一般按三个级别打:连接建立和断开打 info,帧收发打 debug(默认关,出问题时按需开),解码失败和异常打 warn 并带上远端地址和当前缓冲长度。第三条特别有用——FormatException的堆栈里根本看不出是哪台设备发来的坏数据,加上远端地址之后,五分钟就能定位到是哪个客户端版本有问题。这类日志不会影响性能,但在排障时的价值远超它占的那几行代码。