三个月的崩溃日报铺开在我面前的时候,最刺眼的不是页面列表渲染卡顿,而是WebSocket。这个用Flutter写的实时消息类App,灰度期里将近一半的崩溃都跟连接有关——不是业务代码写错,而是WebSocket断开后没处理好:重连风暴、未捕获异常、SocketException堆栈一屏又一屏。Flutter加WebSocket本身不算新组合,但稳定连接真的不是把WebSocketChannel.connect()调用一遍就完事。
这篇文章记录的是我从“频繁崩溃”到“稳定连接”的重连机制优化全过程。核心就三件事:把连接生命周期从一坨回调改造成状态机,把盲目重连改造成指数退避加抖动,再加一套能感知前后台和网络变化的保活策略。适合正在做Flutter实时通信、IM、行情推送、工单通知这类项目的同学参考,也适合被WebSocket断线重连折磨过的朋友对照自己的代码找问题。
1. 问题复盘:频繁崩溃不是偶发,是设计缺位
1.1 崩溃现场:这些堆栈你都见过吗
先把灰度期收集到的崩溃日志按出现频率排个序,前几位非常固定:
SocketException: Connection failed (OS Error: Connection refused, errno = 111) WebSocketException: Connection closed before full header was received Bad state: Stream has already been listened第一类Connection refused最常见,服务端重启、防火墙切换、内网环境变化都会触发。第二类Connection closed before full header多半是网络代理或网关把握手给拦了,客户端只发出HTTP请求还没收到101 Switching Protocols就断掉。第三类Stream has already been listened则是同一流被监听两次,多半是重连时没取消旧订阅,new一个channel又listen一遍。
真正致命的不是这些异常本身,而是它们四处乱飞:有的从stream.listen事件回调里抛出来,有的从sink.add的Future里冒出来,Dart是单线程事件循环,只要有一个异步异常没有捕获入口,整个isolate都可能挂掉,然后表现在用户体验上就是——“App闪退了”。
1.2 WebSocket生命周期比HTTP长得多,也脆弱得多
HTTP是一次性请求-响应,发完就完事,网络断了顶多多等几秒失败一次。WebSocket是一条长期通道,从建立到关闭可能跨越几小时甚至几天,期间要经历NAT超时、Wi-Fi切4G、App进后台被冻结、服务端发布重启、运营商代理闲置断连。
我以前做过一个推送类需求,最初用HTTP轮询,5秒一次,服务端压力大,消息还得等下一个轮询周期才能到。后来切到WebSocket,实时性解决了,但新问题冒出来:连接一旦断了,业务方完全不知道,UI还显示“已连接”,实际上已经收不到任何推送。等我们手动刷新页面时才触发重连,此时服务端的消息早堆积成山了。
所以WebSocket项目里,重连机制不是“锦上添花”,而是“生存底线”。你不可能要求用户的网络环境永远稳定,移动端尤其如此。下面这套设计是我在多次踩坑后总结出来的,状态机加指数退避加心跳三层叠加,基本能覆盖90%以上的断线场景。
1.3 先排查环境噪音:别被版本告警分散注意力
在这个项目的日志系统里还看到过一条很搞笑的告警——The current configured Flutter SDK is not known to be fully supported,配合着Xcode版本、Gradle插件版本一起出现的。最开始我还以为是这些环境问题导致的崩溃,结果排查了半天发现,环境告警只是“吵”,真正崩溃还是WebSocket连接管理缺位。
所以建议你干这件事之前,先清理日志噪音:把Flutter SDK、Xcode、Gradle的版本对齐升级,让那些无关告警闭嘴,再集中精力看WebSocket的连接日志。否则一堆红色告警里混着真正的致命异常,很容易被带偏。
2. 重连机制设计:从“碰运气”到“状态机”
2.1 为什么不能用“断线就重连”
最初版本我写得很直白:onError里调用connect(),失败了再调,死循环。看起来“代码简单”,实际一上线就出事——服务端发布重启的30秒里,客户端每几百毫秒就发起一次连接,把所有请求全堵在网关层,形成重连风暴。有的机型直接卡死,有的机型电池狂掉,线上问题雪上加霜。
后来我意识到,重连机制的本质不是“让代码一直尝试连接”,而是“在合适的时机用合适的频率把连接拉起来”。这就需要一个状态机,把连接的各阶段变成可枚举、可控制的状态,而不是靠深浅不一的回调嵌套。
2.2 五个状态,把连接生命周期管起来
我最终设计的状态机包含五个状态:
- idle:初始状态,什么都没干
- connecting:正在发起连接,包括DNS解析、TCP握手、WebSocket握手
- connected:连接建立成功,可以收发消息
- reconnecting:连接意外断开,正在等待下一次重连
- closed:连接被主动关闭,或重连次数达到上限后放弃
状态迁移规则如下:
- idle收到
connect(),进入connecting - connecting成功后进入connected
- connecting失败,若是首次失败且未到达重连上限,进入reconnecting
- connected期间收到close或异常,进入reconnecting
- reconnecting等待退避时间结束,重新进入connecting
- 重连次数达到上限,进入closed,不再自动重试
- 主动
dispose()或close(),无论当前什么状态,直接进入closed
这套状态机的价值在于:所有状态跳转都有明确入口和出口,不会出现“连接失败后又触发重连,重连过程中又收到旧回调”的混乱局面。配合Dart的enum加ChangeNotifier,UI层也能实时感知连接变化,用户至少能看到“连接中”“已断开”“重连中”之类的提示,而不是一脸懵。
2.3 指数退避加抖动:重连风暴的解药
光有状态机还不够,重连的频率策略必须科学。最常用的方案是指数退避加抖动(Exponential Backoff with Jitter),腾讯、谷歌的很多SDK内部也是这个思路。
核心公式:
delay = min(maxDelay, baseDelay * 2^attempt) + random(0, jitter)解释一下各参数:
baseDelay:首次重连的基准间隔,我取1秒maxDelay:最大间隔,我取30秒,防止无限退避到几分钟引发体验问题attempt:当前已连续失败的次数,从0开始jitter:随机抖动值,我取0到1秒
具体来说:
- 第1次失败后:delay = min(30, 1 * 2^0) + random(0,1) = 1~2秒
- 第2次失败后:delay = min(30, 1 * 2^1) + random(0,1) = 2~3秒
- 第3次失败后:delay = min(30, 1 * 2^2) + random(0,1) = 4~5秒
- 第5次失败后:delay = min(30, 1 * 2^4) + random(0,1) = 16~17秒
- 之后基本就趋近30秒左右一次
抖动这0到1秒的随机量非常关键。如果不加抖动,所有客户端在同一时间断线,重连请求会呈周期性地同时撞上来,服务端还是会周期性过载。加上抖动能把请求分散到时间轴上,像“错峰出行”一样,大大降低服务端压力。
2.4 什么时候该停:重连不是无限循环
有人问,既然要稳定,为什么还要设置重连上限,一直重连不是更好吗?实际不是。
如果服务端持续不可达,无限重连只会让客户端像“僵尸”一样每隔30秒打一次,用户感受到的是“一直在转圈”,电池和流量都在消耗。所以我的策略是:
- 连续重连达到5次后,停止自动重连,进入closed状态
- UI弹出提示“连接已断开,请检查网络”
- 提供一个手动“重新连接”按钮,用户点击后重置attempt计数,重新开始
- 如果App从后台回到前台,强制重置重连次数,重新发起连接
这样既保证了网络抖动时能自动恢复,又避免了无脑重连对用户和服务的双重伤害。
3. 实战代码:一个健壮的WebSocket连接管理器
3.1 依赖与初始化
我用的是web_socket_channel,跨平台,Android、iOS、Web都能跑。另一个是connectivity_plus,用来监听网络状态变化,判断是否值得重连。
dependencies: web_socket_channel: ^2.4.0 connectivity_plus: ^6.0.0顺带提一句,如果你的项目用了BLoC或Cubit,后面我会给一个把连接状态接入Cubit的小例子。要是你习惯用Provider或GetX,同理可以替换。
3.2 核心类骨架
直接上代码,我把关键逻辑都写在WebSocketManager里,技术上叫“管理器”,本质就是把连接相关的状态、心跳、重连统一塞进一个类,避免到处散落WebSocketChannel实例。
import 'dart:async'; import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; enum WSStatus { idle, connecting, connected, reconnecting, closed } class WebSocketManager extends ChangeNotifier { WebSocketManager({ required this.url, this.pingInterval = const Duration(seconds: 20), this.pingTimeout = const Duration(seconds: 8), this.baseDelay = const Duration(seconds: 1), this.maxDelay = const Duration(seconds: 30), this.maxAttempts = 5, }); final String url; final Duration pingInterval; final Duration pingTimeout; final Duration baseDelay; final Duration maxDelay; final int maxAttempts; WSStatus _status = WSStatus.idle; WSStatus get status => _status; WebSocketChannel? _channel; StreamSubscription? _sub; Timer? _pingTimer; Timer? _reconnectTimer; Timer? _pongTimeoutTimer; int _attempt = 0; bool _manualClose = false; bool _appInBackground = false; StreamSubscription? _connectivitySub; // 对外暴露消息流,业务层监听这个即可 final _controller = StreamController<dynamic>.broadcast(); Stream<dynamic> get messages => _controller.stream; }ChangeNotifier是为了Flutter UI能addListener监听连接状态变化,状态一变就rebuild。broadcast流则是为了多个业务模块能同时订阅收到的消息,比如未读角标模块和消息列表模块可以各听各的,互不干扰。
3.3 连接与监听:把异常一口吃掉
核心连接方法如下,注意三个点:一是每次连接前先把旧订阅取消干净,避免Stream has already been listened;二是给onError和onDone都挂上处理函数;三是连接过程中主动检查网络,避免在无网环境下白白握手。
Future<void> connect() async { if (_status == WSStatus.connecting || _status == WSStatus.connected) return; _manualClose = false; _attempt = 0; _setStatus(WSStatus.connecting); try { final connectivity = await Connectivity().checkConnectivity(); if (connectivity == ConnectivityResult.none) { _setStatus(WSStatus.reconnecting); _scheduleReconnect(); return; } _channel = WebSocketChannel.connect(Uri.parse(url)); _sub?.cancel(); _sub = _channel!.stream.listen( _onMessage, onError: _onError, onDone: _onDone, cancelOnError: true, ); // 连接成功:重置重连计数,开启心跳 _attempt = 0; _setStatus(WSStatus.connected); _startHeartbeat(); } catch (e) { _onError(e); } }cancelOnError: true这个参数容易被忽略,但作用很大。它表示当监听器收到error事件时,自动取消订阅,防止后续又冒出更多异常事件污染环境。搭配onError里统一走_onError,相当于给整个连接期异常建了一道防火墙。
3.4 重连调度:退避计时器
_onError和_onDone最后都指向_handleDisconnect,统一处理断开后的流程。这个统一收敛很重要——不管是被动断开还是异常断开,逻辑完全一致,不会出现两个分支行为不一致的bug。
void _onError(Object e) { debugPrint('WebSocket error: $e'); _handleDisconnect(); } void _onDone() { debugPrint('WebSocket closed by server'); _handleDisconnect(); } void _handleDisconnect() { _stopHeartbeat(); try { _sub?.cancel(); } catch (_) {} if (_manualClose) { _setStatus(WSStatus.closed); return; } if (_status == WSStatus.closed) return; if (_attempt >= maxAttempts) { _setStatus(WSStatus.closed); return; } _setStatus(WSStatus.reconnecting); _scheduleReconnect(); } void _scheduleReconnect() { _reconnectTimer?.cancel(); final attempt = _attempt++; final baseMs = baseDelay.inMilliseconds * pow(2, attempt).toInt(); final capped = min(baseMs, maxDelay.inMilliseconds); final jitter = Random().nextInt(1000); final delay = capped + jitter; debugPrint('Reconnect attempt $_attempt in ${delay}ms'); _reconnectTimer = Timer(Duration(milliseconds: delay), () { if (_appInBackground) return; connect(); }); }注意_appInBackground的判断:如果App在后台,重连虽然也会被执行,但为了避免后台长时间占用资源,我选择等回到前台再真正拉起连接。后台的重连计时器也会一直跑,但真正发起连接的动作被推迟了,省电省流量。
3.5 心跳机制:别等死了才发现断了
这是整个优化里最值钱的一块。前面说过,WebSocket连接可能处于“半开”状态——客户端不知道服务端已经挂了。这时候你收不到任何消息,代码上看起来连接还在,但其实就是一座断桥。
心跳方案如下:
- 每20秒向服务端发送一个ping帧或自定义的
{"type":"ping"}消息 - 服务端收到后回一个pong帧或
{"type":"pong"} - 客户端设置一个8秒的pong超时计时器,如果在8秒内没收到任何消息,判定连接已死,主动关闭并触发重连
void _startHeartbeat() { _stopHeartbeat(); _pingTimer = Timer.periodic(pingInterval, (_) => _sendPing()); } void _sendPing() { if (_status != WSStatus.connected) return; try { _channel?.sink.add('{"type":"ping"}'); _pongTimeoutTimer?.cancel(); _pongTimeoutTimer = Timer(pingTimeout, () { debugPrint('Pong timeout, force reconnect'); _channel?.sink.close(4000, 'ping timeout'); _handleDisconnect(); }); } catch (_) { _handleDisconnect(); } } void _onMessage(dynamic data) { // 收到任何消息都说明连接活着,重置pong超时 _pongTimeoutTimer?.cancel(); if (data is String && data.contains('"type":"pong"')) { return; } _controller.add(data); } void _stopHeartbeat() { _pingTimer?.cancel(); _pongTimeoutTimer?.cancel(); }有个细节:如果在WebSocket协议层直接发ping帧,用web_socket_channel的sink.add其实是走数据帧的,因为Dart标准库对WebSocket的控制帧封装得不够直白。所以我选择在业务层模拟ping/pong,发一条JSON文本消息。服务端只要判断消息类型等于ping,就原路回一个pong即可。这个方案兼容性好,排查也方便。
3.6 监听前后台与网络变化
移动端的网络切换和前后台切换,对WebSocket连接来说是“致命打击”。我接入了WidgetsBindingObserver和connectivity_plus,专门处理这类场景:
void startMonitor() { _connectivitySub = Connectivity().onConnectivityChanged.listen((result) { if (result == ConnectivityResult.none) { _appInBackground = true; _stopHeartbeat(); } else { _appInBackground = false; if (_status != WSStatus.connected) { _reconnectTimer?.cancel(); _attempt = 0; connect(); } } }); } void onAppLifecycleResumed() { _appInBackground = false; if (_status != WSStatus.connected) { _reconnectTimer?.cancel(); _attempt = 0; connect(); } } void onAppLifecyclePaused() { _appInBackground = true; } @override void dispose() { _manualClose = true; _reconnectTimer?.cancel(); _pingTimer?.cancel(); _pongTimeoutTimer?.cancel(); _connectivitySub?.cancel(); _sub?.cancel(); _channel?.sink.close(); _controller.close(); super.dispose(); }这里有个很容易踩的坑:dispose()里如果直接_channel?.sink.close(),可能把状态机搞乱。因为close()会触发onDone,然后_handleDisconnect()又看到_manualClose为true才走到closed。所以一定记得在dispose()里先把_manualClose置为true,防止回收对象时又触发自动重连。
4. 常见问题与排查技巧实录
4.1 为什么还是偶发SocketException
有段时间线上依然有零星的SocketException: Connection reset by peer,排查后发现是服务端Nginx的proxy_read_timeout设置成了60秒,而我的心跳是20秒,理论上不会触发超时。但实际中有些代理会静默断开不通知对端,客户端层面只能靠心跳超时兜底。这个没有银弹,唯一的建议是:心跳频率要小于服务端和所有中间代理的任意超时时间,留足余量。
4.2 重连后消息丢失怎么办
重连成功的一瞬间,服务端可能有消息正在推送,但客户端还没就绪,消息就丢了。我的处理是在服务端维护一个lastMsgId或lastOffset,客户端重连成功后带上这个游标,服务端从游标之后的消息开始补推。
如果你不想改造服务端,还有一个偷懒方案:重连成功后,主动拉一次全量快照或增量快照,比如拉最近100条未读消息,由客户端做去重合并。虽然比游标方案笨一点,但落地快。
4.3 内存泄漏:一个反复出现的隐患
WebSocket连接管理器如果被反复创建,监听器不及时取消,内存会以肉眼可见的速度涨。我遇到过最典型的情况:用户登录后创建管理器,登出后没有dispose(),再换账号登录又new一个,旧连接还活着,新连接也建起来了,结果出现“双连接”。
排查技巧很简单:在connect()里打印当前实例的hashCode,同时打印Disposed日志。出现多个不同hashCode的实例在跑,就是泄漏了。修正方法也简单,确保登出、页面销毁、账号切换三个时机都调用manager.dispose()。
4.4 重连风暴的再次出现
虽然没有最开始那么夸张,但有一次服务端发布新版本时,还是有几百台设备同一时刻涌入连接。后来我给maxAttempts加了一层封顶,同时把服务端的发布流程改成了“先摘流量,再发布”,客户端这边又加了一个冷启动随机抖动的延迟:App启动后不是立刻连接,而是等待2到5秒随机延迟再连,这样能够摊开峰值。
下面这个速查表是我在项目文档里沉淀下来的,每次遇到问题先对着看一遍:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| SocketException: Connection refused | 服务端未启动或端口不对 | 先telnet测试端口连通性 |
| SocketException: Connection reset | 中间代理断连,服务端强制关闭 | 调整心跳频率,抓包看RST |
| Stream has already been listened | 同一channel被listen两次 | 检查是否取消旧订阅再connect |
| 连接看似正常但不收消息 | 半开连接,NAT超时 | 开启心跳超时兜底 |
| 后台回前台立即掉线 | 系统冻结了socket | 前台恢复后主动重连并重置状态 |
| 重连CPU拉满 | 重连间隔过短 | 落实指数退避加抖动 |
4.5 一些额外的小建议
- 调试阶段给
WebSocketManager增加一个debugLog开关,把所有连接事件、状态切换、退避延迟、错误信息都打出来。生产环境关掉,只保留关键错误上报。 - 不要用
print打日志,统一走debugPrint或你现有的日志框架,否则release模式性能有损失。 - 如果你的App对实时性要求极高,可以用
StreamBuilder直接监听manager.messages,无需自己再做一层状态管理。但要注意在dispose流程里关闭订阅。
5. 状态如何接进UI层:Cubit实战一小段
之前热词里有人提到Flutter Cubit,这里顺手补一个最简示例。因为我的UI希望实时显示连接状态,所以把Cubit便利性和ChangeNotifier结合了一下:
class WSStatusCubit extends Cubit<WSStatus> { WSStatusCubit(this.manager) : super(manager.status) { _listener = () => emit(manager.status); manager.addListener(_listener); } late final VoidCallback _listener; final WebSocketManager manager; @override Future<void> close() { manager.removeListener(_listener); return super.close(); } }使用方只需:
BlocBuilder<WSStatusCubit, WSStatus>( builder: (context, status) { return Text(status == WSStatus.connected ? '已连接' : '连接中...'); }, )当然,这只是演示。真正的大型项目通常会在WebSocketManager内部注入回调或事件总线,再转发到BLoC。核心思想是:WebSocket层不依赖具体UI框架,UI框架只消费状态和消息。
6. 最后的经验补充
从崩溃率峰值到稳定运行,差不多花了两周时间。如果让我总结最关键的一点,那就是:重连机制不是一个“重试循环”,而是一套包含状态机、退避调度、心跳保活、网络感知、前后台感知的完整体系。只做其中一环,线上还是会出问题。
另外,代码里设计状态机的枚举值命名时,我特意区分了reconnecting和closed。很多同学觉得二者差不多,其实含义完全不同:reconnecting代表“有救”状态,后续会主动恢复;closed代表“没救”状态,需要用户介入或重新初始化。把这两件事分开,UI层不会把“正在恢复”和“已完蛋”混在一起提示,用户体验会好很多。
如果你在Flutter项目里也遇到了WebSocket频繁崩溃的问题,不妨按这个思路慢慢改造,从状态机开始,再加心跳,最后补网络感知。每一步都是独立的收益点,就算只改一个环节,稳定性也会比之前好一截。