平台能力与原生互通篇(5/6):WebSocket 长连接与断线重连机制(Flutter 实战)

6 阅读6分钟

#WebSocket 长连接与断线重连机制(Flutter 实战)

系列:平台能力与原生互通篇
适用场景:IM、语音房、游戏房间、实时榜单、在线状态
技术栈:Flutter + web_socket_channel


1. 问题背景:业务场景 + 现象

在实时业务里,WebSocket 经常“看起来连上了,但实际不可用”:

  • 前后台切换后,连接假活着,消息收不到。
  • 弱网/地铁切网时,触发频繁断开,重连风暴把服务端打爆。
  • 用户连点发送,断线期间消息丢失,或重连后重复发送。
  • 页面多处各自建连接,导致重复订阅、状态错乱、内存上涨。
  • iOS 上锁屏/挂后台后恢复,心跳和重连时序混乱。

一句话:连上不难,稳定难;重连不难,控节奏难。


2. 原因分析:核心原理 + 排查过程

核心原理(你必须统一认知)

WebSocket 长连接稳定性 = 连接状态机 + 心跳保活 + 有策略的重连 + 生命周期协同

如果只写“onDone 里 reconnect()”,基本必踩坑。

常见根因

  1. 无状态机connecting/open/closing/reconnecting/dead 混在一起,重复 connect。
  2. 无退避策略:每次立即重连,弱网下秒变 DoS。
  3. 心跳只发不收:没 pong 超时判定,假连接长期存在。
  4. 无发送队列:断线时直接 send 抛异常,业务消息丢失。
  5. 无单连接约束:多个页面重复 new client。
  6. 与 App 生命周期脱节paused/resumed 不做处理。

排查建议(线上有效)

  • 每次连接尝试打印 connId + attempt + delay + reason
  • 统计指标:重连次数/分钟平均在线时长心跳 RTT消息丢失率
  • 区分 close code:服务端主动踢下线 vs 网络中断 vs 鉴权失败。

3. 解决方案:方案对比 + 最终选择

方案对比

  • 方案 A:页面级连接(不推荐)
    快,但一定走向多连接混乱。

  • 方案 B:全局单连接 + 简单重连(勉强)
    能用,但弱网场景抖动大,体验不稳定。

  • 方案 C:连接管理器 + 状态机 + 指数退避 + 心跳 + 队列(推荐)
    工程化成本高一点,但可观测、可维护、可演进。

最终选择(落地架构)

WsManager(单例) 负责:

  1. 生命周期:connect / disconnect / dispose
  2. 状态广播:ValueNotifierStream<ConnectionState>
  3. 重连:指数退避 + 抖动 + 最大次数
  4. 心跳:ping/pong + 超时主动断开
  5. 消息:发送队列 + ACK 去重(可选)
  6. 生命周期联动: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. 效果验证:数据/截图/日志

建议把“稳定性”作为可量化指标,而不是主观体验。

验证清单(可直接用于测试用例)

  1. 切网测试:Wi-Fi -> 4G -> 飞行模式 -> 恢复
    • 预期:进入 reconnecting,指数退避,恢复后自动 open
  2. 后台恢复测试:Home 键后台 3 分钟再回前台
    • 预期:若断开可自动恢复,不重复连接。
  3. 弱网压测:抓包工具模拟 30% 丢包
    • 预期:无重连风暴,失败重试间隔逐渐增加。
  4. 消息一致性:断线期间发送 20 条消息
    • 预期:重连后按策略补发(或明确丢弃规则),服务端无重复落库(靠 cid 去重)。

建议埋点

  • ws_connect_success_total
  • ws_reconnect_attempt_total
  • ws_reconnect_dead_total
  • ws_ping_rtt_ms_p95
  • ws_message_send_fail_total

6. 可复用结论:通用经验 + 避坑清单

通用经验

  • 长连接不是“一个 API”,而是“一个子系统”,必须有状态机。
  • 重连一定要退避 + 抖动;否则你和服务端都会被自己打挂。
  • 心跳要有超时判定,不然“假在线”最难查。
  • 业务消息要有 cid,服务端做幂等,才能抗重发。
  • 连接管理器保持单例,页面只订阅,不直接创建连接。

避坑清单

  • onDone => connect() 直接无限重连(无上限、无退避)
  • 多页面各自 new WebSocket,导致重复消息
  • 只依赖 TCP keepalive,不做业务层 ping/pong
  • 没有生命周期联动,resumed 后仍显示在线但收不到消息
  • 发送失败直接丢弃,无队列/无幂等保障
  • 鉴权失败也一直重连,造成无效请求风暴

结语

WebSocket 的核心不是“连上”,而是在复杂网络和系统生命周期下保持可恢复、可观测、可兜底
你把这套“状态机 + 心跳 + 退避重连 + 队列幂等”落地后,实时业务稳定性会有明显提升。