造一个生产级 Flutter WebSocket 客户端:适配器模式 + 七大企业特性全解析

Flutter WebSocket 客户端封装实践:从 Adapter 抽象到弱网可靠性设计

在 Flutter 项目里接 WebSocket,最开始看起来只是几行代码:

dart 复制代码
final channel = WebSocketChannel.connect(uri);
channel.stream.listen(...);
channel.sink.add(...);

但真正放进业务里之后,问题很快会冒出来:

  • 连接断了,业务层不知道;
  • 弱网下消息发送时机不可控;
  • 单元测试依赖真实网络,测试变得脆弱;
  • 心跳、重连、ACK、队列等逻辑散落在不同业务模块;
  • 一个连接承载多个业务频道时,消息路由越来越乱。

这篇文章记录一次 Flutter WebSocket 客户端封装的设计过程。核心目标不是"再封一层 API",而是把 WebSocket 的不稳定性收敛到一个清晰的 Client 层,让业务代码只面对稳定的抽象。


一、设计目标

我希望这个 WebSocket 客户端满足几个工程诉求:

  1. 业务层不直接依赖底层连接实现
  2. 生产环境和测试环境可以使用同一套上层逻辑
  3. 弱网场景下具备自动恢复能力
  4. 关键消息可以获得应用层确认
  5. 多个业务 Topic 可以复用同一条连接
  6. Web 和 WASM 环境下不依赖 dart:io

最终的分层大致如下:

text 复制代码
┌───────────────────────────────────────────────┐
│                   业务代码                      │
├───────────────────────────────────────────────┤
│                WebSocketClient                │
│  重连 | 心跳 | 队列 | ACK | 拦截器 | Topic | 统计 │
├───────────────────────────────────────────────┤
│              WebSocketAdapter                 │
├──────────────────────┬────────────────────────┤
│ WebSocketChannel 实现 │ Mock 实现               │
└──────────────────────┴────────────────────────┘

这套结构里,Adapter 只负责最原始的连接和收发,Client 层负责处理业务更关心的可靠性问题。


二、为什么先抽象 Adapter

WebSocket 客户端常见的问题是:业务代码直接持有具体连接对象。

dart 复制代码
final channel = WebSocketChannel.connect(uri);

这样写的问题不是不能跑,而是后续扩展会比较别扭:

  • 想加重连,必须包住原来的连接;
  • 想写单元测试,要么起真实服务,要么 mock 很多底层细节;
  • 想支持不同平台实现时,业务代码容易被条件分支污染;
  • 想切换实现,需要改动较多调用方代码。

所以我先定义了一个抽象接口:

dart 复制代码
abstract class WebSocketAdapter {
  Stream<WebSocketState> get stateStream;
  Stream<WebSocketMessage> get messageStream;
  Stream<dynamic> get errorStream;

  WebSocketState get currentState;
  WebSocketConfig get config;

  Future<void> connect();
  Future<void> sendMessage(WebSocketMessage message);
  Future<void> send(dynamic data);
  Future<void> disconnect({String? reason});
  Future<void> dispose();

  bool get isConnected;
  bool get isConnecting;
  bool get isClosed;
}

业务层不直接依赖 WebSocketChannel,而是依赖 WebSocketClient

dart 复制代码
final client = WebSocketClient(
  WebSocketChannelAdapter(config),
);

测试环境则可以换成 Mock 实现:

dart 复制代码
final client = WebSocketClient(
  MockWebSocketAdapter(config),
);

这里的关键点是:重连、心跳、消息队列、ACK 等逻辑都放在 WebSocketClient

这样一来,Mock 和真实连接走的是同一套上层逻辑,测试不会只测到一个"假客户端",而是能覆盖真实业务路径。


三、自动重连:指数退避和随机抖动

WebSocket 断线后,最直接的做法是固定间隔重连。

比如每隔 1 秒重连一次。

这个方案在单个客户端上没有太大问题,但如果服务端重启,所有客户端都在同一时间尝试重连,就可能形成集中冲击。

更稳妥的做法是使用:

  • 指数退避:失败次数越多,等待时间越长;
  • 最大等待上限:避免等待时间无限增长;
  • 随机抖动:避免大量客户端在同一时刻重连。

核心逻辑可以这样写:

dart 复制代码
Duration calculateReconnectDelay(int attempt) {
  if (!config.useExponentialBackoff) {
    return config.reconnectDelay;
  }

  final exponential = config.reconnectDelay *
      pow(config.backoffMultiplier, attempt - 1).toInt();

  final capped = exponential > config.maxReconnectDelay
      ? config.maxReconnectDelay
      : exponential;

  final jitter = Duration(
    milliseconds: Random().nextInt(capped.inMilliseconds + 1),
  );

  return jitter;
}

配置示例:

dart 复制代码
final config = WebSocketConfig(
  url: 'wss://api.example.com/ws',
  autoReconnect: true,
  maxReconnectAttempts: 10,
  reconnectDelay: Duration(seconds: 1),
  useExponentialBackoff: true,
  backoffMultiplier: 2.0,
  maxReconnectDelay: Duration(minutes: 2),
);

如果第 1 次重连失败,下一次可能在 1 秒以内发起;第 2 次可能在 2 秒以内;后面继续增长,直到达到最大等待上限。

随机抖动的目的不是让单个客户端更快恢复,而是减少大量客户端同时重连的概率。


四、心跳:发现"假在线"状态

WebSocket 表面上是长连接,但并不意味着连接状态永远可靠。

在实际环境里,连接可能被这些中间层断开:

  • NAT 网关;
  • 负载均衡器;
  • 代理服务器;
  • 移动网络;
  • 浏览器或系统网络策略。

比较麻烦的是,底层连接断了之后,客户端未必能立刻收到断开事件。业务层看到的状态可能仍然是 connected,但实际上消息已经收不到了。

所以需要心跳机制:

  1. 定时发送 ping;
  2. 等待服务端返回 pong;
  3. 超时未收到 pong,则记录一次心跳失败;
  4. 连续失败超过阈值后,主动断开并触发重连。

一个简化的 pong 判断逻辑如下:

dart 复制代码
bool isPongMessage(WebSocketMessage message) {
  final data = message.data?.toString() ?? '';

  if (config.expectedPongMessage != null) {
    return data == config.expectedPongMessage;
  }

  if (config.expectedPongMessagePattern != null) {
    return config.expectedPongMessagePattern!.hasMatch(data);
  }

  return false;
}

配置示例:

dart 复制代码
final config = WebSocketConfig(
  url: 'wss://api.example.com/ws',
  enableHeartbeat: true,
  heartbeatInterval: Duration(seconds: 25),
  heartbeatTimeout: Duration(seconds: 8),
  heartbeatMessage: 'ping',
  expectedPongMessage: 'pong',
  maxMissedHeartbeats: 3,
);

这里有一个实践细节:heartbeatInterval 最好小于服务端或负载均衡器的 idle timeout。

比如服务端 60 秒无流量会断开连接,那么客户端可以把心跳间隔设为 25 到 40 秒之间,留出一定余量。


五、离线消息队列:处理弱网发送时机

在弱网场景下,业务层调用 send 时,连接可能刚好处于断开状态。

最简单的策略是直接抛错:

dart 复制代码
throw StateError('WebSocket is not connected');

但这会把连接状态判断的复杂度交给业务层。每个发送方都要判断连接状态、决定是否缓存、决定什么时候重试,最后很容易重复造轮子。

所以我把离线队列放在 Client 层:

dart 复制代码
class QueuedMessage {
  final WebSocketMessage message;
  final DateTime enqueuedAt;
  final Duration? timeout;

  QueuedMessage({
    required this.message,
    required this.enqueuedAt,
    this.timeout,
  });

  bool get isExpired {
    if (timeout == null) return false;
    return DateTime.now().difference(enqueuedAt) > timeout!;
  }
}

发送时判断当前连接状态:

dart 复制代码
Future<void> sendMessage(
  WebSocketMessage message, {
  bool useAck = false,
}) async {
  final processed = await _interceptorChain.processSend(message);

  if (processed == null) {
    return;
  }

  if (!_adapter.isConnected) {
    if (_config.enableMessageQueue) {
      _messageQueue.enqueue(processed);
      return;
    }

    throw StateError('WebSocket is not connected');
  }

  await _adapter.sendMessage(processed);
}

连接恢复后,再统一 flush 队列:

dart 复制代码
Future<void> flushQueue() async {
  final messages = _messageQueue.drain();

  for (final message in messages) {
    await _adapter.sendMessage(message);
  }
}

配置示例:

dart 复制代码
final config = WebSocketConfig(
  url: 'wss://api.example.com/ws',
  enableMessageQueue: true,
  maxQueueSize: 200,
  messageQueueTimeout: Duration(minutes: 10),
);

这里需要注意两个边界:

  1. 队列必须有最大长度,避免弱网长时间堆积导致内存增长;
  2. 消息最好有过期时间,避免很久以前的业务消息在重连后继续发送。

并不是所有消息都适合进入离线队列。

比如聊天消息、表单提交、关键状态变更可以考虑入队;但输入中状态、鼠标位置、临时在线状态这类高频短生命周期消息,过期后继续发送就没有意义了。


六、ACK:为关键消息增加应用层确认

WebSocket 能保证连接内消息有序传输,但业务上"发出去了"不等于"服务端已经处理成功"。

对于一些关键操作,例如:

  • 创建订单;
  • 支付状态变更;
  • 重要表单提交;
  • 关键业务指令;

只调用 send 是不够的。客户端需要知道服务端是否确认收到并处理。

我采用的是比较简单的应用层 ACK 方案:

  1. 客户端发送消息时注入唯一 ackId
  2. 服务端处理后返回 ACK;
  3. 客户端收到 ACK 后完成 Future;
  4. 超时未收到 ACK,则按配置重试;
  5. 重试耗尽后抛出超时异常。

发送逻辑大致如下:

dart 复制代码
Future<void> sendWithAck(
  WebSocketMessage message,
  Future<void> Function(WebSocketMessage) sendFn,
) async {
  final ackId = generateAckId();

  final messageWithAck = message.copyWith(
    metadata: {
      ...?message.metadata,
      '__ack_id__': ackId,
    },
  );

  final completer = Completer<void>();

  _pending[ackId] = PendingAck(
    ackId: ackId,
    message: messageWithAck,
    completer: completer,
    sendFn: sendFn,
    maxRetries: config.maxAckRetries,
    timeout: config.ackTimeout,
  );

  await sendFn(messageWithAck);
  startAckTimeout(ackId);

  return completer.future;
}

服务端只需要返回类似这样的消息:

json 复制代码
{
  "__ack__": "1716489612345_42"
}

客户端收到后完成对应的等待:

dart 复制代码
void handleAck(String ackId) {
  final pending = _pending.remove(ackId);

  if (pending == null) {
    return;
  }

  pending.completer.complete();
}

使用时可以按消息选择是否开启 ACK:

dart 复制代码
await client.sendMessage(
  WebSocketMessage.json({
    'type': 'place_order',
    'orderId': '12345',
  }),
  useAck: true,
);

ACK 不建议默认开启。

原因是它会增加状态管理成本,也会提高延迟。对于高频但不关键的消息,比如在线状态、输入提示、光标位置,通常不需要 ACK。


七、拦截器:把横切逻辑从发送流程里拆出去

WebSocket 客户端里经常会出现一些横切逻辑:

  • 注入认证信息;
  • 记录日志;
  • 加密或解密消息;
  • 过滤非法消息;
  • 限流;
  • 统计耗时。

如果这些逻辑都写进 sendMessageonMessage,Client 很快会变成一团缠在一起的毛线。

所以我使用拦截器管道来处理。

dart 复制代码
abstract class WebSocketInterceptor {
  Future<WebSocketMessage?> onSend(WebSocketMessage message) async {
    return message;
  }

  Future<WebSocketMessage?> onReceive(WebSocketMessage message) async {
    return message;
  }

  Future<void> onError(dynamic error) async {}
}

认证拦截器可以这样写:

dart 复制代码
class AuthInterceptor extends WebSocketInterceptor {
  AuthInterceptor(this.token);

  final String token;

  @override
  Future<WebSocketMessage?> onSend(WebSocketMessage message) async {
    return message.copyWith(
      metadata: {
        ...?message.metadata,
        'Authorization': 'Bearer $token',
      },
    );
  }
}

注册:

dart 复制代码
client.addInterceptor(AuthInterceptor(token));
client.addInterceptor(LoggingInterceptor(logger: print));

拦截器按顺序执行,如果某个拦截器返回 null,则表示这条消息被丢弃,不再继续传递。

dart 复制代码
Future<WebSocketMessage?> processSend(WebSocketMessage message) async {
  WebSocketMessage? current = message;

  for (final interceptor in _interceptors) {
    if (current == null) {
      return null;
    }

    current = await interceptor.onSend(current);
  }

  return current;
}

这样做的好处是,Client 的主流程保持稳定,认证、日志、加密这类能力可以按需组合。


八、Topic 多路复用:一条连接承载多个业务频道

一个 WebSocket 连接通常不只服务一个业务。

比如同一条连接里可能同时有:

  • 聊天消息;
  • 系统通知;
  • 在线状态;
  • 协同编辑事件;
  • 房间事件。

如果所有消息都在一个 messageStream 里处理,业务代码通常会写出大量 switch-caseif-else

我采用的方式是约定一个消息信封:

json 复制代码
{
  "topic": "room:lobby",
  "event": "new_message",
  "payload": {
    "text": "hello"
  }
}

然后在 Client 内部使用 ChannelManager 按 topic 路由:

dart 复制代码
final lobby = client.channel('room:lobby');
final notify = client.channel('system:notify');
final presence = client.channel('presence:room1');

lobby.messageStream.listen((message) {
  renderChatMessage(message);
});

notify.messageStream.listen((message) {
  showNotification(message);
});

presence.messageStream.listen((message) {
  updateOnlineList(message);
});

发送时由 Topic 封装信封:

dart 复制代码
await lobby.send('new_message', {
  'text': 'Hello everyone!',
  'user': 'Alice',
});

底层仍然只有一条 WebSocket 连接,但业务侧看到的是多个独立的 Stream。

这样能降低消息分发逻辑的复杂度,也方便按业务模块拆分代码。


九、连接池:多个后端节点下的连接选择

有些场景里,客户端可能需要维护多个 WebSocket 连接。

例如:

  • 服务端有多个节点;
  • 不同业务连接到不同网关;
  • 希望在客户端侧做简单的连接选择;
  • 某些连接用于广播,某些连接用于指定业务。

连接池可以抽象成这样:

dart 复制代码
final pool = WebSocketPool(
  configs: [
    WebSocketConfig(url: 'wss://node1.example.com/ws'),
    WebSocketConfig(url: 'wss://node2.example.com/ws'),
    WebSocketConfig(url: 'wss://node3.example.com/ws'),
  ],
  strategy: PoolStrategy.leastConnections,
);

await pool.connectAll();

final client = pool.acquire();
await client.sendText('hello');

await pool.broadcast('ping');

await pool.dispose();

常见策略有三种:

策略 说明
roundRobin 轮询选择连接
random 随机选择连接
leastConnections 选择当前负载较低的连接

连接池不是所有项目都需要。对于大多数 App 来说,一条连接已经足够。只有当服务端部署结构或业务隔离要求比较复杂时,连接池才有必要引入。


十、MockAdapter:让单元测试不依赖真实网络

我希望测试时不需要启动真实 WebSocket 服务,也不希望测试里写大量网络 mock 细节。

因此 Mock 实现也遵循同一个 WebSocketAdapter 接口:

dart 复制代码
test('离线期间发送的消息会在重连后投递', () async {
  final config = WebSocketConfig(
    url: 'wss://example.com/ws',
    enableMessageQueue: true,
  );

  final mock = MockWebSocketAdapter(config);
  final client = WebSocketClient(mock);

  await client.connect();

  await mock.disconnect();

  await client.sendText('offline message 1');
  await client.sendText('offline message 2');

  await client.connect();

  expect(mock.sentMessages.length, 2);
  expect(mock.sentMessages[0].data, 'offline message 1');
  expect(mock.sentMessages[1].data, 'offline message 2');
});

Mock 还可以模拟失败场景:

dart 复制代码
mock.shouldFailConnection = true;

await expectLater(
  client.connect(),
  throwsException,
);

也可以模拟不稳定连接:

dart 复制代码
mock.simulateUnstableConnection(
  disconnectProbability: 0.3,
);

由于重连、心跳、队列、ACK 都在 Client 层,所以 Mock 并不是绕过真实逻辑,而是替换掉最底层网络实现。

这能让测试覆盖更多状态变化:

  • 连接成功;
  • 连接失败;
  • 发送时断线;
  • 重连后 flush 队列;
  • ACK 超时;
  • ACK 重试;
  • 拦截器丢弃消息;
  • Topic 路由是否正确。

十一、WASM 兼容:避免 dart:io 顶层依赖

Flutter Web 正在逐步加强 WASM 编译目标支持。这里一个常见问题是:Web 环境不能直接依赖 dart:io

如果库的顶层文件直接 import 了 dart:io,即使某些代码路径在 Web 下不会执行,也可能导致构建失败。

我采用条件导入来隔离平台差异。

Web 或 WASM 环境使用空实现:

dart 复制代码
// compression_stub.dart
GZipCodec? buildGZipCodec() {
  return null;
}

Native 环境使用真实实现:

dart 复制代码
// compression_io.dart
import 'dart:io';

GZipCodec? buildGZipCodec() {
  return GZipCodec();
}

对外入口只引用条件导入后的统一方法:

dart 复制代码
import 'compression_stub.dart'
  if (dart.library.io) 'compression_io.dart';

final codec = buildGZipCodec();

这样可以避免 dart:io 泄漏到 Web 构建链路里。

这里的原则是:平台相关能力尽量收敛在边界文件里,不要污染核心抽象层


十二、最小使用示例

下面是一个简化后的使用示例:

dart 复制代码
final config = WebSocketConfig(
  url: 'wss://echo.websocket.events',
  autoReconnect: true,
  enableHeartbeat: true,
  heartbeatMessage: 'ping',
  expectedPongMessage: 'pong',
  enableMessageQueue: true,
  enableLogging: true,
);

final client = WebSocketClient(
  WebSocketChannelAdapter(config),
);

client.stateStream.listen((state) {
  print('state: $state');
});

client.messageStream.listen((message) {
  print('message: ${message.data}');
});

await client.connect();

await client.sendJson({
  'type': 'hello',
  'content': 'world',
});

await client.dispose();

如果需要按业务频道拆分,可以使用 Topic:

dart 复制代码
final room = client.channel('room:lobby');

room.messageStream.listen((message) {
  print('room message: ${message.data}');
});

await room.send('new_message', {
  'text': 'hello',
});

对于关键消息,可以开启 ACK:

dart 复制代码
await client.sendMessage(
  WebSocketMessage.json({
    'type': 'submit',
    'payload': {
      'id': '123',
    },
  }),
  useAck: true,
);

十三、一些设计取舍

这次封装过程中,有几个取舍点比较重要。

1. Adapter 层不要太厚

Adapter 只应该处理最底层的连接和收发。

如果把重连、心跳、ACK 都塞进 Adapter,Mock 实现也要重复实现一遍,后续维护成本会变高。

2. ACK 不应该默认开启

ACK 适合关键消息,但不适合所有消息。

高频消息如果全部等待 ACK,会引入额外延迟,也会增加客户端状态管理成本。

3. 离线队列必须有边界

消息队列一定要有最大长度和过期时间。

否则在长时间离线后,客户端可能堆积大量已经失去业务意义的消息。

4. Topic 是约定,不是魔法

Topic 多路复用的前提是客户端和服务端都遵守同一套消息信封格式。

如果服务端消息格式不稳定,客户端侧的路由会变得很脆弱。

5. WASM 兼容要从入口文件开始考虑

只在运行时判断平台不够,很多构建失败发生在编译阶段。

因此需要用条件导入把平台相关代码隔离开。


十四、总结

这次封装的核心思路是:把 WebSocket 的复杂状态收敛到 Client 层,让业务代码只面对稳定、可测试的接口。

最终结构可以概括为:

模块 职责
WebSocketAdapter 抽象底层连接能力
WebSocketChannelAdapter 生产环境连接实现
MockWebSocketAdapter 测试环境连接实现
WebSocketClient 承载重连、心跳、队列、ACK 等上层能力
Interceptor 处理认证、日志、加密、过滤等横切逻辑
ChannelManager 按 Topic 分发消息
WebSocketPool 管理多个连接

我比较推荐的一条边界是:

Adapter 只管连接,Client 处理可靠性,业务层只订阅状态和消息。

这样设计之后,业务代码不用到处判断连接是否可用,也不用在每个模块里重复写重连和队列逻辑。

相关实现我整理在这里,感兴趣可以参考源码:

Chihiro-bit/adapter_websocket

相关推荐
水云桐程序员12 小时前
Flutter与React Native的对比分析
flutter·react native·react.js
1001101_QIA12 小时前
android studio连接手机真机调试
flutter
莞凰1 天前
昇腾CANN的“传音入密“:hccl仓库探秘
flutter·ui·transformer
500841 天前
Conv + BN + ReLU 融合:省掉两次显存读写
flutter·架构·开源·wpf·音视频
500841 天前
把 FlashAttention 讲清楚
flutter·electron·wpf
song5011 天前
多卡训练加速:HCCL 集合通信实战
分布式·python·flutter·ci/cd·分类
风清云淡_A1 天前
【Flutter3.8x】flutter从入门到实战基础教程(一):新建一个flutter项目
flutter
1001101_QIA1 天前
Flutter 开发报错:Android cmdline-tools 缺失 环境排查与完整修复方案
android·flutter