Flutter+SignalR实时聊天:从WebSocket协议到手写客户端实战

📅 发布时间:2026/9/16 7:31:05
Flutter+SignalR实时聊天:从WebSocket协议到手写客户端实战
简介基于Flutter与SignalR的实时聊天示例项目面向移动端及.NET开发者重点演示Dart客户端与C#服务端之间如何建立双向实时通信。压缩包共128个文件体积约1.07MB以dart、cs、cshtml、css、json等类型为主涵盖前端界面、后端Hub、模板页面及配置文件目录划分清晰便于快速定位。该示例已有154人学习浏览。资源中完整展现了Flutter通过Widget构建聊天界面、SignalR Hub定义消息推送、async/await处理异步操作等关键环节。研读客户端入口、聊天界面、连接管理及服务端Hub等核心代码可以掌握从连接建立、消息收发到状态刷新的完整链路。适合希望将实时通信能力集成到跨平台应用的中级开发者也是理解Flutter与.NET互操作的实用参考。1. 一个能跑的 Flutter SignalR 聊天示例先拆开看再动手聊天实时上屏很多新项目会直接裸写 WebSocket然后自己补心跳、断线重连和消息帧。而这个 signalR-flutter-chat-example-master 压缩包给了另一条能跑的路服务端用 ASP.NET Core SignalR Hub客户端用 Flutter/Dart 在 WebSocket 层手写协议对接。里面同时有ChatHub.cs、Startup.cs、Program.cs这些 C# 文件也有main.dart、chat_screen.dart、signalr_connection.dart、hub_proxy.dart等 Dart 文件。这个示例真正值得拆的不是聊天窗口而是 SignalR 如何在 WebSocket、Server-Sent Events、Long Polling 之间自动选传输方式以及 Dart 侧从哪里拿connectionToken、怎么拼消息帧。适合三类人要把实时能力暴露给移动端的 C# 后端工程师需要在跨端客户端里移植 SignalR 协议的开发者以及正在搭即时通讯原型的团队。读这份代码时建议倒着看先看服务端ChatHub.cs暴露了哪些方法再看signalr_connection.dart怎么解析帧最后才看chat_screen.dart里的 UI 状态。2. SignalR 的传输协商与 Hub 协议先搞懂帧再碰代码2.1 为什么 SignalR 不直接暴露 WebSocketSignalR 是一个包含协议协商、序列化和传输多路复用的框架WebSocket 只是底层的一种传输选项。客户端调用HubConnectionBuilder后第一件事是向服务器发一个 HTTP POST 到/chathub/negotiate。服务器返回的 JSON 里带有connectionId、connectionToken、availableTransports、negotiateVersion等字段客户端再根据返回结果选择 WebSocket 或其他传输方式。真正建立长连接之后才有一个叫invocationId的会话级标识用来匹配“谁发的调用”和“谁给的返回”。如果用最朴素的 Flutter 客户端去连这个服务端不能直接WebSocket.connect(ws://host/chathub)否则会收到一个 400 或 404。因为 SignalR 要求先完成协商再带着?idconnectionToken去握手。这就是把它叫“Hub 协议”而不是“WebSocket 协议”的原因。三种传输方式的取舍可以先放在这里传输方式底层方向移动端/Flutter 成本断线恢复成本WebSocket全双工低dart:io 原生支持需要重连 恢复调用状态Server-Sent Events服务端单向推送客户端经 HTTP 上行需要额外处理上行通道较低Long Polling半双工轮询请求频繁电量敏感低在 .NET SignalR 默认实现里服务端会优先选择 WebSocket只有在 WebSocket 不可用时才回退。移动端 WebSocket 基本可用所以这个示例里才用dart:io的 WebSocket 来对接。这张表也能解释一种线上现象当某台设备“连不上”很可能不是 Flutter 的问题而是协商阶段返回的availableTransports里根本没有 WebSocket比如中间网关把 Upgrade 头拦了。注意协商结果被缓存后连接令牌connectionToken和连接 IDconnectionId是两套概念。Dart 客户端请求 WebSocket 地址时URL 上的id参数应填connectionToken不是connectionId。很多“能协商但连不上”的故障都出在这里。2.2 消息帧长什么样type / target / invocationId 三者的分工SignalR 的 Hub 协议在网络上不是裸的 JSON而是带类型标记的 JSON。协议里最常用的三种帧类型type: 1是 Invocation表示“调用对方方法”。客户端调用服务端方法、服务端反向调用客户端方法用的都是 type 1。type: 3是 Completion表示“这次调用结束这是结果”通常由被调用方发回。type: 6是 Ping连接空闲时双方互相发送用于维持连接。例如一个客户端调用服务器SendMessage的请求帧{ type: 1, invocationId: 1, target: sendMessage, arguments: [userA, hello] }服务器收到后如果成功且没有返回值会向同一个invocationId回一个 Completion 帧如果 Hub 里执行了Clients.All.SendAsync(ReceiveMessage, user, message)则所有客户端都会收到另一个 Invocation 帧{ type: 1, target: ReceiveMessage, arguments: [userA, hello] }注意后面这个帧没有invocationId。它相当于一个由服务端主动发起的广播事件而不是对某次调用的应答。Dart 客户端解析时必须区分带invocationId的帧要么是服务端对调用的完成通知要么是需要后续处理的Completer不带invocationId的 type 1 帧才是服务器发过来的客户端回调事件。2.3 手写客户端时最容易漏掉的 Ping 与 KeepAliveASP.NET Core SignalR 服务端默认每 15 秒发一次 PingKeepAliveInterval同时在 30 秒内没收到客户端任何帧就判定连接无效ClientTimeoutInterval。如果 Flutter 侧只管接收不回复连接空闲超过 30 秒就可能被服务端静默关闭。在弱网环境里问题表现为“网络明明还在聊天却断了”。手写客户端可以在收到 type 6 后回一个 Ping 帧if (frame[type] 6) { _channel.sink.add({type:6}); return; }这里的_channel来自IOWebSocketChannel就是 Flutter 和服务器之间的长连接通道。收到 Ping 后原样回一个 JSON Ping 帧服务端的 KeepAlive 计数器就会被重置。如果不回空闲超过阈值后服务端会主动断开.NET端日志里会出现类似Client 123abc 已超时的记录。问题在于SignalR 的 Ping 是协议层数据不能用 WebSocket 原生ping帧替代所以对接代码里必须保留这一行。3. 服务端怎么搭ChatHub.cs、Startup.cs 与 Program.cs 的参数逐项拆3.1 ChatHub.cs最小聊天 Hub 的职责边界一个能跑通全链路的最小 Hub 只需要四个方法SendMessage广播消息OnConnectedAsync和OnDisconnectedAsync记录生命周期。参考这个压缩包里的文件结构ChatHub.cs的常见写法是using Microsoft.AspNetCore.SignalR; namespace SignalRChat; public class ChatHub : Hub { public async Task SendMessage(string user, string message) { await Clients.All.SendAsync(ReceiveMessage, user, message); } public override async Task OnConnectedAsync() { await Groups.AddToGroupAsync(Context.ConnectionId, default); await base.OnConnectedAsync(); } public override async Task OnDisconnectedAsync(Exception? exception) { await Groups.RemoveFromGroupAsync(Context.ConnectionId, default); await base.OnDisconnectedAsync(exception); } }SendMessage是客户端可以调用的服务端方法Clients.All.SendAsync会把ReceiveMessage这个方法名、user、message两个参数广播给所有连接者。OnConnectedAsync和OnDisconnectedAsync覆盖了连接生命周期这里把连接加入default组后续要做群聊或定向推送时Clients.Group(default)就能直接复用。Clients属性有几种常见形态Clients.All发所有人Clients.Caller回给调用者Clients.Group(name)发给一个组。如果要发给特定客户端Clients.Client(Context.ConnectionId)也能做到。真正常踩的坑是不要直接把Context.ConnectionId当业务用户 ID因为重连后这个值会变。正确做法是把它与会话或 token 映射后再用于业务否则消息历史会串人。SignalR 的方法名序列化有一个容易错的地方C# 方法SendMessage在网络帧上默认变成sendMessage而SendAsync(ReceiveMessage, ...)里的ReceiveMessage是一个字符串参数不会变。因此第 4 章的 Dart 代码里调用目标写sendMessage回调监听写ReceiveMessage。如果你在 Flutter 端写反了最常见的报错是Failed to invoke method或连接正常但收不到任何回包。3.2 Program.cs 与 Startup.cs 的注册路径在 .NET 6 之后的模板里Program.cs是入口Startup.cs是可选的老式组织方式。压缩包里同时出现这两个文件说明它是“兼容两种写法的教材型项目”。真正的核心配置在Program.cs中长这样var builder WebApplication.CreateBuilder(args); builder.Services.AddRazorPages(); builder.Services.AddSignalR(options { options.ClientTimeoutInterval TimeSpan.FromSeconds(60); options.KeepAliveInterval TimeSpan.FromSeconds(15); options.MaximumReceiveMessageSize 1024 * 1024; }); builder.Services.AddCors(options { options.AddPolicy(flutter, policy policy.AllowAnyHeader().AllowAnyMethod() .SetIsOriginAllowed(_ true) .AllowCredentials()); }); var app builder.Build(); app.UseDefaultFiles(); app.UseStaticFiles(); app.UseRouting(); app.UseCors(flutter); app.MapRazorPages(); app.MapHubChatHub(/chathub); app.Run();AddSignalR里的三个参数对 Flutter 端影响最大。ClientTimeoutInterval设得太大服务端会晚发现死连接客户端重连就更慢设得太小又容易误杀正常空闲用户。常见搭配是KeepAliveInterval 15s、ClientTimeoutInterval 60s留足两到三倍的冗余。MaximumReceiveMessageSize默认 32 KB 对纯文本聊天足够如果消息里带 base64 图片必须调到 1 MB 以上否则 Dart 端发大包时会被服务端直接断开。SetIsOriginAllowed(_ true)是开发阶段放开跨域用的生产环境要改成白名单校验否则任意网页都能连到你的 Hub。MapHubChatHub(/chathub)中的路径也很关键协商发生在/chathub/negotiate真正的 WebSocket 地址是/chathub?idxxx。如果后面要走 Nginx记得额外配置 WebSocket 的 Upgrade 头这部分经常让联调卡上一下午。这里有一个参数速查表可以贴在服务端代码旁边SignalR 参数默认值推荐范围Flutter 侧影响KeepAliveInterval15 秒10-20 秒设太短会频繁收到 Ping手写客户端必须处理ClientTimeoutInterval30 秒30-90 秒设太小弱网下客户端稍晚发帧就被断线MaximumReceiveMessageSize32 KB32 KB-4 MB超过后服务端直接拒绝Dart 端会得到非预期关闭3.3 用 Index.cshtml 快速验证 Hub 是否活着这个示例的服务端文件里有Index.cshtml和_Layout.cshtml按照 ASP.NET Core MVC 默认结构可以直接在主页里放一个 SignalR 浏览器客户端做冒烟测试。常见做法是script src~/lib/microsoft/signalr/dist/browser/signalr.min.js/script script const conn new signalR.HubConnectionBuilder() .withUrl(/chathub) .build(); conn.on(ReceiveMessage, (user, message) { console.log(${user}: ${message}); }); conn.start() .then(() conn.invoke(sendMessage, browser, hello hub)) .catch(console.error); /script这段脚本先创建连接再注册客户端回调ReceiveMessage连接成功后调用invoke(sendMessage)。由于invoke发送的是type:1的 Invocationbrowser和hello hub会被拼到arguments数组里。如果Index.cshtml的控制台能看到输出说明 Hub 注册、协商、长连接、端点映射全部通了下一步才轮到 Flutter 侧。这里要强调microsoft/signalr浏览器库只是验证工具不是 Flutter 示例的依赖。移动端真正跑起来时Dart 客户端用自己的 WebSocket 实现不携带任何 JS 依赖。压缩包里那些Error.cshtml.cs、Privacy.cshtml.cs、_ValidationScriptsPartial.cshtml都是默认 MVC 模板的遗留文件对 SignalR 链路没有影响不想看可以直接忽略。4. Flutter/Dart 客户端自己写 SignalR 协商与 WebSocket 通道4.1 为什么不用现成包而是手写信号层pub.dev 上有signalr_netcore这类封装好的包但这份示例在摘要里明确提到使用dart:io的 WebSocket 实现。打开signalr_connection.dart和hub_proxy.dart之后你会发现它对协议层的控制是完整暴露出来的。手写不是纯粹为了教学而是减少第三方包的中间层服务端升级 .NET 小版本时不需要等待某个 Dart 包跟进。代价是你自己要维护invocationId、Ping 帧和重连状态。从维护角度看两种方式的区别如下方案依赖数量协议控制力.NET 版本适配速度signalr_netcore 现成包多低依赖包发布节奏手写 WebSocket 通道少高只要协议不变基本通用如果你只是快速验证功能用现成包会快很多如果你要在这个示例上做长线产品建议沿用项目里的手写结构后续加 token、二进制消息、多设备踢人都会更顺手。4.2 协商向 /chathub/negotiate 发起 POST手写客户端的第一步是拿到connectionToken。为了不阻塞 UI这个过程要放进异步方法并且要处理好返回值import dart:async; import dart:convert; import dart:io; import package:http/http.dart as http; import package:web_socket_channel/io.dart; class SignalRConnection { SignalRConnection({required this.hubUrl}); final String hubUrl; WebSocketChannel? _channel; String? _connectionToken; int _invocationId 0; final _pending String, Completerdynamic{}; final _handlers String, void Function(Listdynamic){}; Futurevoid connect() async { final negotiateUrl Uri.parse($hubUrl/negotiate?negotiateVersion1); final res await http.post( negotiateUrl, headers: {Accept: application/json}, ); if (res.statusCode ! 200) { throw StateError(negotiate failed: ${res.statusCode} ${res.body}); } final body jsonDecode(res.body) as MapString, dynamic; _connectionToken (body[connectionToken] ?? body[connectionId]) as String; final wsUrl Uri.parse(hubUrl.replaceFirst(http, ws) ?id$_connectionToken); _channel IOWebSocketChannel.connect(wsUrl); _channel!.stream.listen(_handleFrame); } }negotiateVersion1必须带上否则服务器可能按旧版协议协商拿不到connectionToken。connectionToken与connectionId的??回退是为了兼容旧版本返回。hubUrl.replaceFirst(http, ws)会把http://变成ws://https://变成wss://这个替换顺序是安全的。如果跑在 Android 模拟器上hubUrl不能写localhost要写http://10.0.2.2:5000因为 Android 模拟器访问宿主机固定走这个地址iOS 模拟器则可以直接用localhost。4.3 发送消息构造 SignalR 的 type:1 帧invoke需要自增invocationId同一连接上可以同时挂多个未完成的调用服务器靠它来辨别返回属于哪一次请求。发送和接收的对应关系如下Futuredynamic invoke(String method, ListObject? arguments) async { final id ${_invocationId}; final frame { type: 1, invocationId: id, target: method, arguments: arguments, }; _channel!.sink.add(jsonEncode(frame)); final completer Completerdynamic(); _pending[id] completer; return completer.future; } void _handleFrame(dynamic raw) { final frame jsonDecode(raw as String) as MapString, dynamic; final type frame[type] as int; if (type 1 frame[invocationId] null) { final method frame[target] as String; final args frame[arguments] as Listdynamic; _handlers[method]?.call(args); } else if (type 3) { final invocationId frame[invocationId] as String; final completer _pending.remove(invocationId); if (completer ! null) { if (frame.containsKey(error)) { completer.completeError(frame[error]); } else { completer.complete(frame[result]); } } } else if (type 6) { _channel!.sink.add({type:6}); } }这段代码的逻辑是invoke发送帧后立即返回Future真正的结果在后面某个时刻通过_pending里的Completer完成。_handleFrame收到type:3时会根据invocationId找到对应的Completer有error字段就抛出异常否则返回result。收到type:1且没有invocationId说明是服务器主动回调客户端方法交给_handlers里注册的函数。收到type:6时回一个 Ping维持心跳。这里有个容易忽略的细节如果服务器方法没有返回值Completion 帧里可能只有invocationId和type:3没有result字段。因此completer.complete(frame[result])在结果为 null 时也是正常的调用方不要试图把 null 当异常处理。4.4 hub_proxy.dart 与 UI 层的边界hub_proxy.dart的作用是把SignalRConnection的帧级回调封装成业务友好的强类型方法。这样chat_screen.dart就不用关心 JSON 帧和invocationIdclass HubProxy { HubProxy(this._connection); final SignalRConnection _connection; void onReceiveMessage(void Function(String user, String message) handler) { _connection.on(ReceiveMessage, (args) { if (args.length 2) { handler(args[0] as String, args[1] as String); } }); } Futurevoid sendMessage(String user, String message) { return _connection.invoke(sendMessage, [user, message]); } }onReceiveMessage注册监听时ReceiveMessage必须与 C# 端SendAsync传入的方法名完全一致sendMessage则对应 C# 的SendMessage在默认 JSON 协议里的驼峰形式。如果你在 Flutter 里把两个名字的大小写搞反index.cshtml页面没问题但移动端会必现“发不出去”或“收不到事件”。由于这一层把异步调用和回调都收拢了chat_screen.dart只需要持有HubProxy在StatefulWidget的initState里调用onReceiveMessage发送按钮直接调sendMessage。等到要加“输入中”状态、已读回执或者撤回功能时也是先改 Hub 协议再改这个 proxy 方法最后才动 UI。5. 最后的技巧用重连和 Isolate 把这套实时链路做得不卡5.1 重连使用带抖动的指数退避而不是固定间隔固定两秒重连在服务器重启时会让所有客户端像打地鼠一样同时涌进来服务端容易被打挂。常见做法是使用指数退避加随机抖动import dart:math; Duration _backoff(int attempt) { final baseMs 1 min(attempt, 6); final jitter Random().nextInt(500); return Duration(milliseconds: baseMs * 200 jitter); }当_channel.stream收到done或error时先关闭旧通道清空所有未完成的_pending再执行connect()。重连前必须把每个挂起的Completer都completeError否则调用方会永远停在那里等待结果。这个清空动作容易漏掉也是“重连后界面假死”的常见原因。5.2 聊天列表刷新前先合并消息必要时放到 Isolate当消息以每秒几十条的节奏进入 Dart 的 stream 时每条消息都触发一次setState会明显掉帧。一个低成本优化是引入 20-30 毫秒的缓冲窗口把连续到达的帧合并后再批量追加到ListView。如果消息里带大字段jsonDecode本身也有开销可以交给compute在后台 isolate 执行final result await compute(_parseFrames, rawBatch); Listdynamic _parseFrames(String data) { return data .split(\n) .where((e) e.isNotEmpty) .map(jsonDecode) .toList(); }compute适合处理一次性大任务不适合高频小消息因为频繁跨 isolate 反而会增加开销。实际调优时先统计_handleFrame里每条消息的平均耗时超过 16 毫秒再考虑 isolate否则优先做批量追加。聊天示例原本消息量不大但这些边界决定了一个 demo 能否平滑过渡到真实使用场景。拿到这个压缩包之后建议先把第 3 章的Index.cshtml跑通再给_handleFrame临时加上print(frame)观察帧类型最后才改 UI。剩下要做的就是把sendMessage和ReceiveMessage换成你的业务方法名并处理好 token 鉴权。本文还有配套的精品资源点击获取