Flutter+SignalR实时聊天:从WebSocket协议到手写客户端实战
2026/9/16 7:31:03 网站建设 项目流程

简介:基于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.csStartup.csProgram.cs这些 C# 文件,也有main.dartchat_screen.dartsignalr_connection.darthub_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 不直接暴露 WebSocket

SignalR 是一个包含协议协商、序列化和传输多路复用的框架,WebSocket 只是底层的一种传输选项。客户端调用HubConnectionBuilder后,第一件事是向服务器发一个 HTTP POST 到/chathub/negotiate。服务器返回的 JSON 里带有connectionIdconnectionTokenavailableTransportsnegotiateVersion等字段;客户端再根据返回结果选择 WebSocket 或其他传输方式。真正建立长连接之后,才有一个叫invocationId的会话级标识,用来匹配“谁发的调用”和“谁给的返回”。

如果用最朴素的 Flutter 客户端去连这个服务端,不能直接WebSocket.connect('ws://host/chathub'),否则会收到一个 400 或 404。因为 SignalR 要求先完成协商,再带着?id=<connectionToken>去握手。这就是把它叫“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)和连接 ID(connectionId)是两套概念。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 与 KeepAlive

ASP.NET Core SignalR 服务端默认每 15 秒发一次 Ping(KeepAliveInterval),同时在 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广播消息,OnConnectedAsyncOnDisconnectedAsync记录生命周期。参考这个压缩包里的文件结构,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这个方法名、usermessage两个参数广播给所有连接者。OnConnectedAsyncOnDisconnectedAsync覆盖了连接生命周期,这里把连接加入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.MapHub<ChatHub>("/chathub"); app.Run();

AddSignalR里的三个参数对 Flutter 端影响最大。ClientTimeoutInterval设得太大,服务端会晚发现死连接,客户端重连就更慢;设得太小又容易误杀正常空闲用户。常见搭配是KeepAliveInterval = 15sClientTimeoutInterval = 60s,留足两到三倍的冗余。MaximumReceiveMessageSize默认 32 KB 对纯文本聊天足够;如果消息里带 base64 图片,必须调到 1 MB 以上,否则 Dart 端发大包时会被服务端直接断开。

SetIsOriginAllowed(_ => true)是开发阶段放开跨域用的,生产环境要改成白名单校验,否则任意网页都能连到你的 Hub。MapHub<ChatHub>("/chathub")中的路径也很关键:协商发生在/chathub/negotiate,真正的 WebSocket 地址是/chathub?id=xxx。如果后面要走 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的 Invocation,browserhello hub会被拼到arguments数组里。如果Index.cshtml的控制台能看到输出,说明 Hub 注册、协商、长连接、端点映射全部通了,下一步才轮到 Flutter 侧。

这里要强调:@microsoft/signalr浏览器库只是验证工具,不是 Flutter 示例的依赖。移动端真正跑起来时,Dart 客户端用自己的 WebSocket 实现,不携带任何 JS 依赖。压缩包里那些Error.cshtml.csPrivacy.cshtml.cs_ValidationScriptsPartial.cshtml都是默认 MVC 模板的遗留文件,对 SignalR 链路没有影响,不想看可以直接忽略。

4. Flutter/Dart 客户端:自己写 SignalR 协商与 WebSocket 通道

4.1 为什么不用现成包,而是手写信号层

pub.dev 上有signalr_netcore这类封装好的包,但这份示例在摘要里明确提到使用dart:io的 WebSocket 实现。打开signalr_connection.darthub_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, Completer<dynamic>>{}; final _handlers = <String, void Function(List<dynamic>)>{}; Future<void> connect() async { final negotiateUrl = Uri.parse('$hubUrl/negotiate?negotiateVersion=1'); 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 Map<String, 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); } }

negotiateVersion=1必须带上,否则服务器可能按旧版协议协商,拿不到connectionTokenconnectionTokenconnectionId??回退是为了兼容旧版本返回。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,同一连接上可以同时挂多个未完成的调用,服务器靠它来辨别返回属于哪一次请求。发送和接收的对应关系如下:

Future<dynamic> invoke(String method, List<Object?> arguments) async { final id = '${_invocationId++}'; final frame = { 'type': 1, 'invocationId': id, 'target': method, 'arguments': arguments, }; _channel!.sink.add(jsonEncode(frame)); final completer = Completer<dynamic>(); _pending[id] = completer; return completer.future; } void _handleFrame(dynamic raw) { final frame = jsonDecode(raw as String) as Map<String, dynamic>; final type = frame['type'] as int; if (type == 1 && frame['invocationId'] == null) { final method = frame['target'] as String; final args = frame['arguments'] as List<dynamic>; _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 帧里可能只有invocationIdtype:3,没有result字段。因此completer.complete(frame['result'])在结果为 null 时也是正常的,调用方不要试图把 null 当异常处理。

4.4 hub_proxy.dart 与 UI 层的边界

hub_proxy.dart的作用是把SignalRConnection的帧级回调,封装成业务友好的强类型方法。这样chat_screen.dart就不用关心 JSON 帧和invocationId

class 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); } }); } Future<void> 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,在StatefulWidgetinitState里调用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收到doneerror时,先关闭旧通道,清空所有未完成的_pending,再执行connect()。重连前必须把每个挂起的CompletercompleteError,否则调用方会永远停在那里等待结果。这个清空动作容易漏掉,也是“重连后界面假死”的常见原因。

5.2 聊天列表刷新前先合并消息,必要时放到 Isolate

当消息以每秒几十条的节奏进入 Dart 的 stream 时,每条消息都触发一次setState会明显掉帧。一个低成本优化是引入 20-30 毫秒的缓冲窗口,把连续到达的帧合并后再批量追加到ListView。如果消息里带大字段,jsonDecode本身也有开销,可以交给compute在后台 isolate 执行:

final result = await compute(_parseFrames, rawBatch); List<dynamic> _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。剩下要做的就是把sendMessageReceiveMessage换成你的业务方法名,并处理好 token 鉴权。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询