#WebSocket 长连接与断线重连机制(Flutter 实战)
系列:平台能力与原生互通篇
适用场景:IM、语音房、游戏房间、实时榜单、在线状态
技术栈:Flutter +web_socket_channel
1. 问题背景:业务场景 + 现象
在实时业务里,WebSocket 经常“看起来连上了,但实际不可用”:
- 前后台切换后,连接假活着,消息收不到。
- 弱网/地铁切网时,触发频繁断开,重连风暴把服务端打爆。
- 用户连点发送,断线期间消息丢失,或重连后重复发送。
- 页面多处各自建连接,导致重复订阅、状态错乱、内存上涨。
- iOS 上锁屏/挂后台后恢复,心跳和重连时序混乱。
一句话:连上不难,稳定难;重连不难,控节奏难。
2. 原因分析:核心原理 + 排查过程
核心原理(你必须统一认知)
WebSocket 长连接稳定性 = 连接状态机 + 心跳保活 + 有策略的重连 + 生命周期协同。
如果只写“onDone 里 reconnect()”,基本必踩坑。
常见根因
- 无状态机:
connecting/open/closing/reconnecting/dead混在一起,重复 connect。 - 无退避策略:每次立即重连,弱网下秒变 DoS。
- 心跳只发不收:没 pong 超时判定,假连接长期存在。
- 无发送队列:断线时直接 send 抛异常,业务消息丢失。
- 无单连接约束:多个页面重复 new client。
- 与 App 生命周期脱节:
paused/resumed不做处理。
排查建议(线上有效)
- 每次连接尝试打印
connId + attempt + delay + reason。 - 统计指标:
重连次数/分钟、平均在线时长、心跳 RTT、消息丢失率。 - 区分 close code:服务端主动踢下线 vs 网络中断 vs 鉴权失败。
3. 解决方案:方案对比 + 最终选择
方案对比
-
方案 A:页面级连接(不推荐)
快,但一定走向多连接混乱。 -
方案 B:全局单连接 + 简单重连(勉强)
能用,但弱网场景抖动大,体验不稳定。 -
方案 C:连接管理器 + 状态机 + 指数退避 + 心跳 + 队列(推荐)
工程化成本高一点,但可观测、可维护、可演进。
最终选择(落地架构)
WsManager(单例) 负责:
- 生命周期:
connect / disconnect / dispose - 状态广播:
ValueNotifier或Stream<ConnectionState> - 重连:指数退避 + 抖动 + 最大次数
- 心跳:
ping/pong+ 超时主动断开 - 消息:发送队列 + ACK 去重(可选)
- 生命周期联动:
WidgetsBindingObserver
4. 关键代码:最小必要但可直接改造的实现
示例偏详细,可直接复制做骨架。
依赖:web_socket_channel: ^2.x
4.1 连接状态定义
enum WsConnState {
idle,
connecting,
open,
reconnecting,
closed,
authFailed,
dead,
}
4.2 WebSocket 管理器(核心)
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/widgets.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
class WsManager with WidgetsBindingObserver {
WsManager({
required this.urlBuilder,
required this.tokenProvider,
this.maxReconnectAttempts = 12,
this.baseReconnectDelay = const Duration(seconds: 1),
this.maxReconnectDelay = const Duration(seconds: 30),
this.heartbeatInterval = const Duration(seconds: 15),
this.heartbeatTimeout = const Duration(seconds: 8),
});
final Uri Function(String token) urlBuilder;
final Future<String?> Function() tokenProvider;
final int maxReconnectAttempts;
final Duration baseReconnectDelay;
final Duration maxReconnectDelay;
final Duration heartbeatInterval;
final Duration heartbeatTimeout;
final ValueNotifier<WsConnState> state = ValueNotifier(WsConnState.idle);
final StreamController<Map<String, dynamic>> _messageController =
StreamController.broadcast();
Stream<Map<String, dynamic>> get messages => _messageController.stream;
WebSocketChannel? _channel;
StreamSubscription? _sub;
Timer? _heartbeatTimer;
Timer? _heartbeatTimeoutTimer;
Timer? _reconnectTimer;
bool _manualClose = false;
int _reconnectAttempt = 0;
int _connSeq = 0; // 用于过滤旧连接回调
final List<Map<String, dynamic>> _sendQueue = [];
static const int _maxQueueSize = 200;
bool get _isOpen => state.value == WsConnState.open;
Future<void> connect() async {
if (state.value == WsConnState.connecting || _isOpen) return;
_manualClose = false;
state.value = _reconnectAttempt == 0
? WsConnState.connecting
: WsConnState.reconnecting;
final token = await tokenProvider();
if (token == null || token.isEmpty) {
state.value = WsConnState.authFailed;
return;
}
final int currentConnId = ++_connSeq;
try {
final uri = urlBuilder(token);
final channel = WebSocketChannel.connect(uri);
_channel = channel;
_sub?.cancel();
_sub = channel.stream.listen(
(event) => _onMessage(event, currentConnId),
onError: (e, st) => _onSocketBroken('onError:$e', currentConnId),
onDone: () => _onSocketBroken('onDone', currentConnId),
cancelOnError: true,
);
// 连接成功
_reconnectAttempt = 0;
state.value = WsConnState.open;
_startHeartbeat();
_flushSendQueue();
} catch (e) {
_onSocketBroken('connectException:$e', currentConnId);
}
}
Future<void> disconnect({bool manual = true}) async {
_manualClose = manual;
_reconnectTimer?.cancel();
_stopHeartbeat();
await _sub?.cancel();
_sub = null;
await _channel?.sink.close();
_channel = null;
state.value = WsConnState.closed;
}
void dispose() {
WidgetsBinding.instance.removeObserver(this);
disconnect(manual: true);
_messageController.close();
state.dispose();
}
void startObserveLifecycle() {
WidgetsBinding.instance.addObserver(this);
}
@override
void didChangeAppLifecycleState(AppLifecycleState appState) {
// 根据业务选择策略:
// paused: 可以不断开(依赖系统),也可主动断开降低资源
// resumed: 主动校验连接,不在 open 则重连
if (appState == AppLifecycleState.resumed) {
if (!_isOpen) {
connect();
} else {
_sendJson({'type': 'ping', 'ts': DateTime.now().millisecondsSinceEpoch});
}
}
}
void sendBusinessMessage(Map<String, dynamic> payload) {
// 增加客户端消息 ID,服务端回 ACK 做去重
payload['cid'] ??= _genCid();
payload['clientTs'] = DateTime.now().millisecondsSinceEpoch;
_safeSend(payload);
}
void _safeSend(Map<String, dynamic> jsonMap) {
if (_isOpen && _channel != null) {
_sendJson(jsonMap);
return;
}
// 断线入队,防止消息直接丢失
if (_sendQueue.length >= _maxQueueSize) {
_sendQueue.removeAt(0); // 丢弃最旧
}
_sendQueue.add(jsonMap);
}
void _flushSendQueue() {
if (!_isOpen || _sendQueue.isEmpty) return;
for (final msg in List<Map<String, dynamic>>.from(_sendQueue)) {
_sendJson(msg);
}
_sendQueue.clear();
}
void _sendJson(Map<String, dynamic> data) {
_channel?.sink.add(jsonEncode(data));
}
void _onMessage(dynamic event, int connId) {
// 防止旧连接回调污染
if (connId != _connSeq) return;
Map<String, dynamic> map;
try {
map = jsonDecode(event as String) as Map<String, dynamic>;
} catch (_) {
return;
}
final type = map['type'];
if (type == 'pong') {
_heartbeatTimeoutTimer?.cancel();
return;
}
if (type == 'auth_expired') {
state.value = WsConnState.authFailed;
disconnect(manual: true);
return;
}
// 业务消息抛给上层
_messageController.add(map);
}
void _onSocketBroken(String reason, int connId) {
if (connId != _connSeq) return;
_stopHeartbeat();
_sub?.cancel();
_sub = null;
_channel = null;
if (_manualClose) {
state.value = WsConnState.closed;
return;
}
_scheduleReconnect(reason);
}
void _scheduleReconnect(String reason) {
if (_reconnectAttempt >= maxReconnectAttempts) {
state.value = WsConnState.dead;
return;
}
_reconnectAttempt++;
state.value = WsConnState.reconnecting;
final delay = _calcBackoffWithJitter(_reconnectAttempt);
_reconnectTimer?.cancel();
_reconnectTimer = Timer(delay, () {
connect();
});
// 建议接埋点:attempt/delay/reason
// print('[ws] reconnect attempt=$_reconnectAttempt delay=$delay reason=$reason');
}
Duration _calcBackoffWithJitter(int attempt) {
final baseMs = baseReconnectDelay.inMilliseconds;
final maxMs = maxReconnectDelay.inMilliseconds;
final exp = min(maxMs, baseMs * (1 << min(attempt, 10)));
final jitter = Random().nextInt(500); // 0~500ms 抖动
return Duration(milliseconds: min(maxMs, exp + jitter));
}
void _startHeartbeat() {
_stopHeartbeat();
_heartbeatTimer = Timer.periodic(heartbeatInterval, (_) {
if (!_isOpen) return;
_sendJson({'type': 'ping', 'ts': DateTime.now().millisecondsSinceEpoch});
_heartbeatTimeoutTimer?.cancel();
_heartbeatTimeoutTimer = Timer(heartbeatTimeout, () {
// 心跳超时,主动打断让重连接管
_onSocketBroken('heartbeatTimeout', _connSeq);
});
});
}
void _stopHeartbeat() {
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
_heartbeatTimeoutTimer?.cancel();
_heartbeatTimeoutTimer = null;
}
String _genCid() =>
'${DateTime.now().microsecondsSinceEpoch}_${Random().nextInt(999999)}';
}
4.3 在业务层接入(Riverpod / 普通单例都可)
final wsManagerProvider = Provider<WsManager>((ref) {
final manager = WsManager(
urlBuilder: (token) => Uri.parse('wss://api.xxx.com/ws?token=$token'),
tokenProvider: () async {
// 从本地或内存拿 token
return 'your_token';
},
)..startObserveLifecycle();
ref.onDispose(manager.dispose);
return manager;
});
4.4 页面消费连接状态 + 消息流
class RoomPage extends ConsumerStatefulWidget {
const RoomPage({super.key});
@override
ConsumerState<RoomPage> createState() => _RoomPageState();
}
class _RoomPageState extends ConsumerState<RoomPage> {
StreamSubscription? _sub;
@override
void initState() {
super.initState();
final ws = ref.read(wsManagerProvider);
ws.connect();
_sub = ws.messages.listen((msg) {
// 按 type 分发业务事件
// e.g. chat_message / user_join / rtc_signal
});
}
@override
void dispose() {
_sub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final ws = ref.watch(wsManagerProvider);
return ValueListenableBuilder<WsConnState>(
valueListenable: ws.state,
builder: (_, connState, __) {
return Column(
children: [
Text('连接状态: $connState'),
ElevatedButton(
onPressed: () {
ws.sendBusinessMessage({
'type': 'chat_message',
'roomId': '1001',
'content': 'hello',
});
},
child: const Text('发送消息'),
),
],
);
},
);
}
}
5. 效果验证:数据/截图/日志
建议把“稳定性”作为可量化指标,而不是主观体验。
验证清单(可直接用于测试用例)
- 切网测试:Wi-Fi -> 4G -> 飞行模式 -> 恢复
- 预期:进入
reconnecting,指数退避,恢复后自动open。
- 预期:进入
- 后台恢复测试:Home 键后台 3 分钟再回前台
- 预期:若断开可自动恢复,不重复连接。
- 弱网压测:抓包工具模拟 30% 丢包
- 预期:无重连风暴,失败重试间隔逐渐增加。
- 消息一致性:断线期间发送 20 条消息
- 预期:重连后按策略补发(或明确丢弃规则),服务端无重复落库(靠
cid去重)。
- 预期:重连后按策略补发(或明确丢弃规则),服务端无重复落库(靠
建议埋点
ws_connect_success_totalws_reconnect_attempt_totalws_reconnect_dead_totalws_ping_rtt_ms_p95ws_message_send_fail_total
6. 可复用结论:通用经验 + 避坑清单
通用经验
- 长连接不是“一个 API”,而是“一个子系统”,必须有状态机。
- 重连一定要退避 + 抖动;否则你和服务端都会被自己打挂。
- 心跳要有超时判定,不然“假在线”最难查。
- 业务消息要有
cid,服务端做幂等,才能抗重发。 - 连接管理器保持单例,页面只订阅,不直接创建连接。
避坑清单
-
onDone => connect()直接无限重连(无上限、无退避) - 多页面各自 new WebSocket,导致重复消息
- 只依赖 TCP keepalive,不做业务层 ping/pong
- 没有生命周期联动,
resumed后仍显示在线但收不到消息 - 发送失败直接丢弃,无队列/无幂等保障
- 鉴权失败也一直重连,造成无效请求风暴
结语
WebSocket 的核心不是“连上”,而是在复杂网络和系统生命周期下保持可恢复、可观测、可兜底。
你把这套“状态机 + 心跳 + 退避重连 + 队列幂等”落地后,实时业务稳定性会有明显提升。