Flutter 即时通讯 Demo:从痛点到实现(HTTP 封装、SockJS/STOMP、跨平台适配)

摘要

本文以一个可运行的 Flutter 即时通讯 Demo 为例,讲述即时通讯常见痛点、实现难点与工程化做法。重点解读两块核心代码:HTTP 请求封装与 WebSocket(SockJS + STOMP)封装——包括错误处理、泛型响应解析、一行注册监听的设计,以及移动端忽略自签名证书的方案与浏览器端受限问题。文章附带演示与源码获取方式(商业/学习授权说明见文末)。

即时通讯的痛点

高并发与连接管理:IM 要求长连接稳定且能同时支撑大量在线用户,连接的建立、心跳、断线重连、并发推送都是工程难点。

消息一致性与幂等:消息可能重复传输或丢失,需要设计消息 id、ACK、持久化策略。

跨平台差异:Web / Android / iOS / 桌面在 socket、证书、跨域/同源策略上的行为不同。

成本与运维:自己搭建 WebSocket/STOMP + 消息队列 + 持久化 + 监控成本高,很多公司直接买第三方即服(如第三方推送/IM 平台)以减少运维成本。

隐私/合规:企业做商用 IM 时还要考虑数据合规、审计、备份等。

因此很多公司选择付费第三方服务以降低运维与研发风险;而对于学习、内部工具或小团队,能拿到一套开箱可跑的 Demo 就很有价值。

即时通讯的难点(实现层面)

结合本项目实现,这些是实战中经常遇到的难点——也是我在 Demo 中重点解决/演示的点:

浏览器与原生(Android/iOS)证书校验差异:移动端/桌面可以通过底层 client 做自签名证书放行;但浏览器端无法忽略证书(必须使用可信证书或通过代理/反向代理解决)

SockJS 与原生 WebSocket 的兼容:很多后端使用 SockJS(为浏览器降级),客户端要兼容多种传输方式

STOMP 协议层:统一消息订阅/发送语义,管理订阅 id、目标路径、用户私聊路径等

HTTP 接口统一封装与错误处理:泛型解析、统一 header、token 注入、统一错误码处理与全局登出逻辑

我们的功能(项目概览)

用户注册 / 登录(含 token 管理)

搜索 / 添加好友(好友请求、同意/拒绝流)

目前仅支持单聊(发送文本、表情)

本地缓存

WebSocket(SockJS + STOMP)订阅/推送框架与一行注册监听 API

HTTP:统一的请求封装、泛型 ApiResponse 解析、统一错误处理与 loading 提示集成

对初学者与进阶者的学习参考意义

初学者:能通过 Demo 看到一个从 UI 到后端交互(HTTP + 长连接)完整的实现链路,理解消息的发送/接收流程。

进阶者:可以学习到工程化的封装技巧(统一请求入口、泛型响应解析、可扩展的订阅/监听体系),并据此做二次开发或扩展到真实生产环境(加持认证、TLS、分布式消息队列等)。

关键代码解读

HTTP 封装思路(Dio + 平台适配 + 泛型响应)

设计要点:

使用 Dio 做底层请求;为移动/桌面平台提供 IOHttpClientAdapter,在非 Web 平台上可以短时放行自签名证书(用于测试环境)。

所有接口都使用 postWithResponse(url, data, fromJsonT) 的泛型方法,调用方传入如何把 data 解析成 T,实现类型安全的响应解析。

请求失败、token 失效等由统一代码处理(例如 code==1001 强制清除本地token并跳转登录)。

代码片段:

final dio = Dio(BaseOptions(...));

// 泛型请求接口(核心思想)
Future<ApiResponse<T>> postWithResponse<T>(String url, data, T Function(Object?) parser) async {
  // 1. 发请求(带统一 headers)
  // 2. 检查 HTTP 状态码
  // 3. 将 body 转为 Map,再用 parser 解析 data 字段
  // 4. 统一错误码处理(例如 token 失效)
  // 5. 返回 ApiResponse<T>
}

WebSocket / STOMP 封装(SockJS 兼容 + 一行注册监听)

目标:对外提供简单统一的 connectWithAuth(url, token, userId) 与 addListener(callback) / removeListener() 接口,内部负责 STOMP 订阅、消息转发与 reconnect 策略。

关键点:

使用 stomp_dart_client 的 StompConfig.sockJS 对接后端的 SockJS endpoint(浏览器优雅降级)。

在 stompConnectHeaders 里把 token / userId 发送,服务端可在握手时读取(注意:SockJS 在浏览器环境里有些 header 无法设置,通常使用 query 参数或 STOMP CONNECT headers)。

订阅用户私聊路径(例如 /user/queue/messages),并把所有收到的消息分发给内部 listener 列表(实现“一行注册监听”)。

void connectWithAuth({required String url, required String token, required String userId}) {
  final cfg = StompConfig.sockJS(
    url: url,
    stompConnectHeaders: {
      'token': token,
      'userId': userId,
    },
    onConnect: (frame) { /* 订阅并分发到 listeners */ },
    onWebSocketError: (err) { /* 错误回调 */ },
  );
  _stompClient = StompClient(config: cfg)..activate();
}

服务端握手处理(思路):如果你需要在握手时把 userId 绑定到 session,可以在后端 HandshakeHandler 中读取:

如果是直接 WebSocket:可在 ServerHttpRequest.getHeaders() 中读取 header。

如果是 SockJS(浏览器端通常使用 query 参数或 STOMP CONNECT headers):解析 URI query 或 CONNECT headers 并返回一个 Principal。

监听器设计:一行注册/注销

// ListenerWrapper:包装泛型解析与回调逻辑
class ListenerWrapper<T> {
  final T Function(Object? json) parser;                 // 把 data 字段解析成 T 的函数
  final void Function(ApiResponse<T> api) onMessage;    // 业务回调(泛型安全)
  late final void Function(String) _internal;           // 实际注册到 WebSocketManager 的函数

  ListenerWrapper({required this.parser, required this.onMessage}) {
    _internal = (raw) {
      try {
        final Map<String, dynamic> decoded = jsonDecode(raw);
        // 只做示意:ApiResponse.fromJson 内部会调用 parser 解析 data
        final ApiResponse<T> api = ApiResponse.fromJson(decoded, parser);
        onMessage(api);
      } catch (e, st) {
        print('ListenerWrapper parse error: $e\n$st');
      }
    };
  }

  // 注册:一行调用即可
  void register() => WebSocketManager().addListener(_internal);

  // 注销:一行调用即可
  void unregister() => WebSocketManager().removeListener(_internal);
}

为什么不直接让 WebSocketManager 使用泛型 ListenerWrapper?

简单结论:责任分离 + 运行时类型和回调签名管理使得把解析工作放在 ListenerWrapper 更稳健、更灵活。

详细原因:

单一职责 — 传输 vs 解析

WebSocketManager 的职责是尽可能 简单:连接、订阅、获取底层原始消息并广播(字符串或 frame)。

解析业务数据(JSON → ApiResponse → T)属于业务逻辑层,应该由具体的 listener 负责。把解析放在 manager 会把传输层和业务层耦合,难以维护。

泛型在运行时的使用限制与类型安全

如果把 WebSocketManager 设计成 addListener(void Function(ApiResponse) listener),它需要在内部保存不同泛型签名的回调并在运行时区分解析策略,导致 manager 必须知道各种 T 的 parser(违背扩展性)。

ListenerWrapper 把 parser(如何把 JSON 解析为 T)与 onMessage 绑定,保证了调用时的类型安全。

避免回调签名不匹配与异常传播

底层广播原始字符串并由包装器做 try/catch,任何单个解析或回调异常不会影响其它 listener。若 manager 直接调用泛型回调,异常与类型转换错误更难局部化处理。

方便组件生命周期管理(add/remove 对称)

ListenerWrapper.register() / unregister() 语义简单明了,UI 层只要在 initState/dispose 调用一对方法即可。若 manager 支持泛型并暴露更复杂的订阅接口,开发者更容易忘记注销或传错 id。

支持不同解析策略、来源区分

有时候来自不同 destination 的消息格式不同(例如 /topic/global vs /user/queue),使用 wrapper 可以为每路消息单独指定 parser,而 manager 只需要把原始消息分发出去。

便于做中间处理(去重、幂等、过滤)

在 wrapper 层可以实现消息去重(按 messageId)、幂等保护、或只处理特定 code 的通知,而不用修改 manager。

设计要点:

提供 addListener(void Function(String)) 和 removeListener(...),内部维护 List<Function>。

上层业务可通过封装的 ListenerWrapper(或直接 lambda)完成类型解析并回调具体业务逻辑。

这样 UI 层只关心“收到消息如何展示”,而不需要处理底层 STOMP 的订阅 id、frame 解析等。

总结

本项目是一个工程化的 Flutter 即时通讯 Demo,展示了从 HTTP 层到长连接(SockJS + STOMP)的一套实现思路。

关键工程实践包括:统一 HTTP 封装 + 泛型解析、按平台做证书适配(移动端可临时放行自签证书,浏览器端必须用可信证书/反向代理)、以及一行注册监听的 WebSocket 订阅分发机制。

如果你想购买完整源码与演示:请访问: link.(或私信询问我)。提醒:源码仅限学习/评估,若需要商用授权请在购买前沟通并签署授权协议。

Android运行截图:
联系人页面消息列表
设置页面

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐