简介:SignalRWPF 是一份面向 C# 与 WPF 开发者的 SignalR 客户端工程资料,适合需要了解实时通信在桌面端如何落地、或希望参考现成项目结构进行练习与二次开发的中级学习者。压缩包共 320 个文件,约 3.41MB,包含 54 个 cs 源码、45 个 dll 依赖、2 个 xaml 界面文件、2 个 csproj 项目文件及 sln 解决方案,同时带有 editorconfig、json、props、targets 等构建与配置项,并混有 cache、pdb、exe 等编译运行产物。已有 264 人学习下载。读者可从中观察 WPF 客户端与 SignalR 服务的连接组织方式、消息处理代码、项目依赖和解决方案结构,也可借助现有工程文件复现实验环境、对照排查编译与运行问题;其中的缓存和中间产物需结合源码甄别使用。
1. 桌面端要“秒级刷新”,我把轮询换成了 SignalR
上一套 WPF 上位机用DispatcherTimer每 2 秒拉一次设备状态,200 台设备、8 条产线,跑起来局域网里全是重复请求,界面还时不时卡半秒。后来换成 SignalR:服务端 ASP.NET Core 挂一个 Hub,客户端用 C# 写 WPF,连上之后由服务端主动推,刷新延迟从秒级掉到几十毫秒。
这份资源就是那套骨架的可运行版本——一个 Hub 服务端、一个 WPF 客户端、一套共享 DTO,外加断线重连、跨线程刷新、批量合并这几块最容易翻车的地方。
适合正在做工业上位机、监控看板、告警推送的 C# 开发者。只想找 Hello World 的话它偏重;被 Timer 轮询和Invoke搞烦了,正好。
2. 拆开这套 C# 实时方案:Hub、传输协商与客户端生命周期
在动手改代码之前,得先把三件事想明白:服务端到底暴露了什么、连接是怎么建起来的、客户端那个HubConnection对象活多久。这三个问题没搞清楚,后面遇到“连上了但收不到消息”就只能靠猜。
2.1 Hub 是契约不是接口,这是很多人的第一个误解
SignalR 的服务端 Hub 看起来像一个普通的 C# 类,客户端调用connection.InvokeAsync("PushStatus", payload)也像是调用方法,但两者之间没有编译期约束。方法名是字符串,参数是序列化后的字节,写错一个字母,服务端不会报“方法不存在”,它只会在运行期悄悄丢掉这次调用,或者给你一个含糊的InvalidOperationException。
这套工程里用了两个办法把风险压下去。一是把方法名抽成常量类放在共享项目里,客户端和服务端都引用同一份;二是 DTO 单独一个Contracts类库,服务端 Hub 方法签名和客户端On<T>的泛型参数都指向同一个类型。共享项目只放 DTO 和常量,不引任何 SignalR 包,这样服务端和 WPF 客户端可以各自升级传输层依赖而互不影响。
Hub 的另一个特点是它无状态且每次调用可能在不同实例上。默认情况下每次客户端调用 Hub 方法,框架都会从 DI 容器里取一个新的 Hub 实例,所以你没法在 Hub 的字段里存“当前用户订阅了哪条产线”。要存状态只有三个去处:Context.Items(单次连接生命周期)、分组(Groups)、外部缓存。这套工程选的是分组,因为分组天然支持服务端主动推送。
2.2 传输协商:WebSocket 优先,但降级不是白送的
SignalR 客户端第一次连接时不是直接开 WebSocket,而是先发一个/negotiate的 HTTP POST,拿到connectionToken和服务端支持的传输列表,然后才决定用哪种方式。这个协商步骤是很多“连不上”问题的根源——你的反向代理可能放行了 WebSocket,但没放行/negotiate这个 POST。
| 传输方式 | 建立条件 | 典型耗时 | 适用场景 | 注意点 |
|---|---|---|---|---|
| WebSockets | 服务端与中间代理都放行Upgrade头 | 最低,一次握手 | 双向高频推送,内网首选 | 代理需透传Connection: Upgrade |
| ServerSentEvents | WebSocket 不可用 | 中等 | 服务端单向推送为主 | 浏览器下有并发连接数限制 |
| LongPolling | 上面两种都不通 | 最高,每次请求一轮 | 兜底,例如老旧网关后面 | 延迟高,连接开销大 |
工程里客户端默认写了HttpTransportType.WebSockets加SkipNegotiation = true,这是内网调试期的故意选择:跳过协商直接开 WebSocket,一旦连不上就立刻报错,不会悄悄降级到长轮询让你误以为“性能还行”。上线前如果你的部署环境有网关,把这个改回默认(允许协商、允许降级)更稳妥,然后靠日志确认实际用的是哪种传输。
提示:判断当前走的是不是 WebSocket,别靠猜。客户端的
HubConnection没有直接暴露传输类型,但服务端在OnConnectedAsync里可以通过Context.GetHttpContext().WebSockets.IsWebSocketRequest判断,把这个结果打进日志最省事。
2.3 客户端生命周期:HubConnection是长生命周期对象,别乱 new
HubConnection内部维护着传输连接、心跳定时器、重连状态机和消息分发队列。它的状态只有四种:Connecting、Connected、Reconnecting、Disconnected(Closed)。很多人踩的坑是在每个窗口、每个 ViewModel 里各new一个,结果一个应用开了七八条连接,服务端连接数暴涨,重连时互相打架。
这套工程的做法是整个应用只注册一个单例RealtimeClient,由它持有唯一的HubConnection,对外暴露事件。ViewModel 订阅事件、在Dispose时退订,但不去销毁连接。连接的生命周期跟应用走,跟界面无关。
另外HubConnection实现了IAsyncDisposable,DisposeAsync会先发Close消息再关传输层。直接让进程退出而不 Dispose,服务端要等ClientTimeoutInterval到期才会清理这条连接,调试期间会出现“明明关了客户端,服务端还显示在线”的错觉。
2.4 为什么不直接用裸 WebSocket 或者 gRPC
裸 WebSocket 更轻,但重连、心跳、分组、序列化、调用应答全得自己写,一套下来几百行,还容易在“服务端主动推 + 客户端调用”混合场景下写乱。gRPC 的双向流很强,适合服务间通信,但 WPF 客户端要处理 HTTP/2、证书、流式重连,学习成本比 SignalR 高一截,而且分组广播这类“按频道推”的语义要自己造。
SignalR 的价值在于它把这四件事做完了:传输降级、心跳保活、分组广播、调用/推送双向语义,而且在 .NET 客户端和服务端上是同一套 API。代价是它的消息名是字符串、序列化格式默认是 JSON(体积偏大),以及对代理配置有要求。对桌面端上位机这种“内网、连接数几十到几百、消息频率几十到几百条每秒”的场景,这个取舍是划算的。
3. 从零跑通:Hub 服务端到 WPF 客户端的最小骨架
这一章按“服务端 → 共享契约 → 客户端 → ViewModel”的顺序落地,每一段都能直接放进工程里跑。默认环境是 .NET 8,服务端Microsoft.AspNetCore.App自带 SignalR,客户端需要装Microsoft.AspNetCore.SignalR.Client。
3.1 服务端:路由、超时参数与 Hub 实现
先看宿主。SignalR 服务端的可调参数不多,但每一个都跟“断线”直接相关,我一般会在启动时显式写出来,避免用默认值出事之后还要去翻文档。
// Program.cs —— 服务端最小宿主 var builder = WebApplication.CreateBuilder(args); builder.Services.AddSignalR(o => { // 客户端多久没发消息就算掉线,默认 30s o.ClientTimeoutInterval = TimeSpan.FromSeconds(30); // 服务端心跳间隔,默认 15s,必须明显小于 ClientTimeoutInterval o.KeepAliveInterval = TimeSpan.FromSeconds(15); // 单条消息上限,默认 32KB,推送大对象时要调 o.MaximumReceiveMessageSize = 64 * 1024; // 内网调试期打开,异常细节会原样回传客户端;上线务必关掉 o.EnableDetailedErrors = true; }); builder.Services.AddSingleton<IDeviceService, DeviceService>(); var app = builder.Build(); app.MapHub<DeviceHub>("/hubs/device"); // 客户端 URL 必须和这里完全一致 app.Run();参数含义值得多说两句。KeepAliveInterval是服务端主动发 ping 的间隔,ClientTimeoutInterval是服务端多久收不到客户端任何消息就判定掉线,两者必须满足KeepAliveInterval小于ClientTimeoutInterval,否则会出现“客户端还活着,服务端已经判定它死了”的鬼故事。MaximumReceiveMessageSize只限制客户端发给服务端的单条消息,服务端推给客户端的大小不受它约束,这点很容易记反。
Hub 本身保持薄,只做三件事:连接时入组、按产线订阅、接收上报后转发。
// DeviceHub.cs public sealed class DeviceHub : Hub { private readonly IDeviceService _devices; public DeviceHub(IDeviceService devices) => _devices = devices; public override async Task OnConnectedAsync() { // 全局监控频道,所有看板都收 await Groups.AddToGroupAsync(Context.ConnectionId, "monitor"); await base.OnConnectedAsync(); } public Task SubscribeLineAsync(string line) { if (string.IsNullOrWhiteSpace(line)) throw new HubException("line 不能为空"); // HubException 的消息会回传给客户端 return Groups.AddToGroupAsync(Context.ConnectionId, $"line:{line}"); } public async Task ReportAsync(DeviceStatus status) { // 客户端上报的数据不可信,服务端必须校验后再广播 if (string.IsNullOrWhiteSpace(status.DeviceId)) throw new HubException("DeviceId 不能为空"); var saved = _devices.Save(status); await Clients.Group($"line:{saved.Line}").SendAsync("StatusChanged", saved); await Clients.Group("monitor").SendAsync("StatusChanged", saved); } }这里有个设计决定:上报和广播用同一个 Hub。好处是连接复用、鉴权统一;坏处是所有的上报流量都走同一条连接,客户端一旦网络抖动,上报和接收一起断。如果上报频率很高(每秒上百条),更稳的做法是上报走独立的 HTTP 接口,Hub 只负责下发,两者解耦。
3.2 共享契约:DTO 和方法名常量单独一个类库
前面说过 Hub 调用没有编译期约束,那就人为造一个。建一个Contracts类库,只放 DTO 和常量,服务端和 WPF 客户端都引用它。
// Contracts/HubMethods.cs public static class HubMethods { public const string StatusChanged = nameof(StatusChanged); public const string SubscribeLine = nameof(SubscribeLine); public const string Report = nameof(Report); } // Contracts/DeviceStatus.cs public sealed record DeviceStatus( string DeviceId, string Line, int State, // 0 停机 1 运行 2 报警 double Temperature, DateTimeOffset Timestamp);用过nameof之后,改方法名只要改常量,客户端和服务端一起重编译,写错立刻编译不过。这个类库不要引Microsoft.AspNetCore.SignalR.Client,否则 WPF 客户端会被迫带上服务端依赖。
3.3 客户端:连接、订阅、收消息
客户端封装成一个单例服务,对外只暴露事件和两个方法。
public sealed class RealtimeClient : IAsyncDisposable { private HubConnection? _conn; public event Action<DeviceStatus>? StatusChanged; public async Task StartAsync(string baseUrl, CancellationToken ct = default) { _conn = new HubConnectionBuilder() .WithUrl($"{baseUrl}/hubs/device", o => { // 内网直连:跳过协商,强制 WebSocket,出问题立刻暴露 o.Transports = HttpTransportType.WebSockets; o.SkipNegotiation = true; }) .AddMessagePackProtocol() // 两端必须一致,否则握手阶段就失败 .WithAutomaticReconnect(new RetryPolicy()) .Build(); _conn.On<DeviceStatus>(HubMethods.StatusChanged, s => StatusChanged?.Invoke(s)); _conn.Reconnecting += _ => { /* 更新状态栏为“重连中” */ return Task.CompletedTask; }; _conn.Reconnected += async _ => { // 重连后 ConnectionId 变了,分组订阅全部丢失,必须重订 await _conn.InvokeAsync(HubMethods.SubscribeLine, "L1"); }; _conn.Closed += _ => { /* 触发兜底重启或提示用户 */ return Task.CompletedTask; }; await _conn.StartAsync(ct); } public ValueTask DisposeAsync() => _conn?.DisposeAsync() ?? ValueTask.CompletedTask; }WithUrl里的 URL 必须和MapHub的路由一一对应,多一层少一层都会在/negotiate阶段返回 404。AddMessagePackProtocol是可选项,作用是换掉默认的 JSON 序列化,消息体积大约能压到三分之一,代价是两端必须同时装包、同时开启,只开一边会在握手时直接失败。如果服务端要对接浏览器端,就别用 MessagePack,老老实实 JSON。
3.4 在 ViewModel 里落地:订阅、刷新、释放
ViewModel 不碰HubConnection,只订阅事件。但要注意,事件回调是在后台线程触发的,直接改ObservableCollection会抛InvalidOperationException,所以这里先接一个批处理器(下一章展开)。
public sealed partial class MonitorViewModel : ObservableObject, IAsyncDisposable { private readonly RealtimeClient _client; private readonly UiBatcher _batcher; public ObservableCollection<DeviceRow> Rows { get; } = new(); public MonitorViewModel(RealtimeClient client) { _client = client; _batcher = new UiBatcher(Application.Current.Dispatcher, Apply); _client.StatusChanged += _batcher.Enqueue; // 这里拿到的是后台线程,不能直接改集合 } public Task LoadAsync() => _client.StartAsync("http://10.0.0.20:5080"); private void Apply(IReadOnlyList<DeviceStatus> batch) { // 已经回到 UI 线程,可以安全操作集合 foreach (var s in batch) { var row = Rows.FirstOrDefault(r => r.DeviceId == s.DeviceId); if (row is null) Rows.Add(new DeviceRow(s)); else row.Update(s); } } public ValueTask DisposeAsync() { _client.StatusChanged -= _batcher.Enqueue; // 退订,否则窗口关了还在刷 return _client.DisposeAsync(); } }Apply里用FirstOrDefault线性查找,行数在两百以内没问题;上千行就要在 ViewModel 里挂一个Dictionary<string, DeviceRow>做索引,否则每次批量刷新都是 O(n·m),卡顿会非常明显。这一点在后面的压测章节还会提到。
4. 断线重连与跨线程刷新:把消息稳稳送上界面
实时推送的难点从来不在“连上”,而在“断了之后怎么办”和“消息来得太快怎么办”。这一章是两个问题的具体解法。
4.1 自动重连的三个空档,以及分组为什么会丢
WithAutomaticReconnect()不带参数时用的是内置策略:立即重试一次,然后 2 秒、10 秒、30 秒各一次,之后放弃。对桌面端来说这个策略太短——用户可能把笔记本合盖两小时再打开,回来发现连接早就Closed了。工程里换成自定义策略:
sealed class RetryPolicy : IRetryPolicy { private static readonly TimeSpan[] Delays = { TimeSpan.Zero, // 立刻重试,应对瞬时抖动 TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30) }; public TimeSpan? NextRetryDelay(RetryContext ctx) => ctx.PreviousRetryCount < Delays.Length ? Delays[ctx.PreviousRetryCount] : TimeSpan.FromSeconds(30); // 之后固定 30s 一次,永不放弃 }RetryContext里有两个有用的字段:PreviousRetryCount(第几次重试)和ElapsedTime(从断开到现在过了多久)。生产环境常见的做法是“前 5 次按固定退避,之后固定 30 秒”,既不会把服务端打爆,也不会永久放弃。
重连过程中有三个事件必须处理,缺一个就会出现“界面状态和实际连接不一致”:
Reconnecting:连接已断,正在重试。此时InvokeAsync会抛异常,界面上的所有写操作要禁用。Reconnected:重连成功,但ConnectionId已经变了,之前通过Groups.AddToGroupAsync加的组全部丢失。必须在这里重新订阅。Closed:重连彻底失败(或主动关闭)。要弹提示、启动兜底轮询、或者让用户点“重新连接”。
第二个最容易被忽略。有人在StartAsync之后订阅了分组,测试时拔网线再插上,发现只有全局monitor组还在收消息——因为OnConnectedAsync每次连接都会重新执行,而手动订阅的line:L1没有。血泪经验是:所有订阅逻辑要么写进服务端的OnConnectedAsync(按连接携带的参数自动入组),要么在Reconnected里重放一遍,没有第三种做法。
4.2 Dispatcher:为什么直接改集合会抛异常
WPF 的ObservableCollection不是线程安全的,它内部会触发CollectionChanged事件,而这个事件会直接驱动ItemsControl生成容器。从非 UI 线程触发,轻则抛InvalidOperationException(“该类型的 CollectionView 不支持从调度程序线程以外的线程对其 SourceCollection 进行的更改”),重则界面元素状态错乱但不报错,属于典型的黑匣子问题。
最朴素的解法是每条消息都Dispatcher.Invoke一次:
// 能跑,但高频下会卡:每来一条消息就排一次 UI 队列 _client.StatusChanged += s => Application.Current.Dispatcher.Invoke(() => Apply(s));这条写法在每秒 5 条时毫无问题,每秒 200 条时会把 UI 线程排满,用户拖动窗口都会掉帧。改进方向是批量合并:消息先进无锁队列,再用一个“是否已排队”的开关保证同一时刻只有一个 UI 回调,回调执行时一次性把队列抽干。
public sealed class UiBatcher { private readonly Dispatcher _dispatcher; private readonly Action<IReadOnlyList<DeviceStatus>> _apply; private readonly ConcurrentQueue<DeviceStatus> _queue = new(); private int _scheduled; // 0 = 没排队, 1 = 已排队 public UiBatcher(Dispatcher dispatcher, Action<IReadOnlyList<DeviceStatus>> apply) { _dispatcher = dispatcher; _apply = apply; } public void Enqueue(DeviceStatus s) { _queue.Enqueue(s); // 只有第一个进入的线程能把 0 换成 1,其余的直接把消息丢进队列就返回 if (Interlocked.Exchange(ref _scheduled, 1) == 0) _dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(Drain)); } private void Drain() { Interlocked.Exchange(ref _scheduled, 0); // 先复位,允许下一轮排队 var batch = new List<DeviceStatus>(_queue.Count); while (_queue.TryDequeue(out var s)) batch.Add(s); if (batch.Count > 0) _apply(batch); // 此时一定在 UI 线程 } }几个细节。Interlocked.Exchange返回旧值,只有把 0 换成 1 的那个线程才负责调度,避免了重复排队。DispatcherPriority.Background让刷新让位给输入事件,用户拖动窗口时不会感觉卡。Drain里先复位再抽队列,看起来反直觉,但这样在_apply执行期间新到的消息会触发下一轮调度,不会丢。_queue.Count只用来预估容量,ConcurrentQueue的Count在高并发下不精确,不影响正确性。
4.3 帧预算:一次刷新多少条才不卡
批量合并之后,真正的瓶颈变成“单次Apply处理多少条”。经验数据是:DataGrid(开启虚拟化)里更新 50~100 行的单个字段,UI 线程耗时大约 3~8 毫秒;超过 300 行就容易掉到 30 fps 以下。所以批处理不能无脑抽干,得设个上限。
| 单批条数 | 单次耗时(实测区间) | 界面观感 | 建议 |
|---|---|---|---|
| 10 以内 | 1~2 ms | 完全无感 | 小规模看板直接用 |
| 50~100 | 3~8 ms | 流畅 | 大多数场景的甜点区 |
| 200~300 | 10~25 ms | 轻微掉帧 | 需要配合虚拟化 |
| 500 以上 | 30 ms+ | 明显卡顿、拖动窗口迟滞 | 必须做按 Key 合并 |
超过上限的部分不要丢,留在队列里等下一轮。Drain改成while (batch.Count < 100 && _queue.TryDequeue(out var s))即可,配合“先复位再抽”的写法,剩下的消息会自动进入下一轮调度,不会饿死。
4.4 把连接状态暴露出来,别让它成为黑匣子
桌面端程序最怕的是“界面看着正常,其实数据早就停了”。HubConnection.State有四种取值,直接绑到状态栏上,用户能自己看出问题,你排查也少一半沟通成本。
_conn.Reconnecting += _ => { _dispatcher.BeginInvoke(() => ConnectionText = "重连中…"); return Task.CompletedTask; };再配一条规则:连接不在Connected状态时,界面上的数据加一层灰底或者打上“数据已冻结”的时间戳。这个小细节能省掉大量“你们系统数据不准”的扯皮。
5. 避坑与排查:握手失败、重连丢组、内存上涨这几个坑
下面五条都是我在实际部署里撞过的,按“现象 → 原因 → 解决”写,遇到同类问题可以直接对照。
5.1 连接与握手:连不上、连上就断、悄悄降级
坑一:本地跑得好好的,部署到服务器后StartAsync报 404。
现象是客户端抛HttpRequestException,日志里能看到/hubs/device/negotiate?negotiateVersion=1返回 404。原因通常不是 SignalR 配置错,而是反向代理把路径前缀吃掉了——比如外部访问是https://host/app/hubs/device,转发到内网却只给了/hubs/device,而服务端MapHub注册的是/hubs/device,看起来对得上,实际上代理没配X-Forwarded-Prefix。解决方式是让代理做路径重写后直连,或者服务端显式app.UsePathBase("/app")并在客户端WithUrl里带上同样的前缀。排查时先用浏览器或curl手工 POST 一次/negotiate,能拿到 JSON 就说明服务端没问题,锅在代理。
坑二:连接建立后大约 30~60 秒必断一次,日志里没有异常。
现象是Closed事件规律触发,间隔接近ClientTimeoutInterval。原因是客户端所在机器的网络有 NAT 会话老化,或者服务端线程池饥饿导致心跳 ping 没能按时发出。前者靠把KeepAliveInterval调到 10 秒以内、让心跳频率高于 NAT 老化时间来缓解;后者要看服务端有没有同步阻塞调用占满线程池——async方法里混了.Result或.Wait(),在并发上来之后会把线程池抽干,心跳自然发不出去。这条属于玄学问题,但只要日志里能看到心跳时间戳,就不是玄学。
坑三:内网一切正常,接到客户网关后吞吐骤降,延迟从几十毫秒变成一两秒。
现象是功能都对,就是慢。原因是协商阶段 WebSocket 握手失败,客户端静默降级到了 LongPolling。解决分两步:先在客户端日志里确认实际传输方式(服务端OnConnectedAsync里判断IsWebSocketRequest并打日志),再去网关放行Upgrade和Connection两个头。如果网关确实不支持,就把SkipNegotiation = true打开让它快速失败,而不是让业务在降级模式下慢慢跑——慢比断更难排查。
5.2 消息与状态:重连丢组、状态栏永远停在“连接中”
坑四:拔网线再插上,全局广播能收到,按产线订阅的消息全没了。
现象很典型:重连成功,monitor组的推送正常到达,但line:L1的一条都不来,而且服务端不报错。原因前面提过——重连后ConnectionId变了,Groups是按ConnectionId索引的,旧订阅随旧连接一起消失。解决是把订阅逻辑放进Reconnected回调重放一遍,或者更彻底一点,在OnConnectedAsync里根据Context.GetHttpContext().Request.Query["line"]自动入组,客户端把产线号放在连接 URL 的查询串里,重连时自然带上。后者更省心,但要接受“连接参数变了必须重建连接”这个约束。
还有一种变体:Reconnected里await InvokeAsync抛超时。原因是重连刚完成、服务端还没完全就绪,或者同一条连接上并发了太多调用。稳妥的写法是在Reconnected里加个短延迟再订阅,并对失败做一次重试,别把异常吞掉。
5.3 线程与内存:跨线程异常、内存缓慢上涨
坑五:连续开关窗口十几次之后,内存涨了两三百兆,关闭窗口也不释放。
现象是任务管理器里内存曲线一路上台阶,最终可能 OOM。原因几乎总是事件订阅没退订:_client.StatusChanged += ...加了上去,窗口关掉时没有-=,RealtimeClient是单例,它持有的委托链把整个 ViewModel 连同里面的ObservableCollection、图片、绑定对象全部钉在内存里。解决是在Dispose/OnClosed里成对退订,并且养成“凡是在构造函数里+=的,必须在同一处写下对应的-=”的习惯。如果 ViewModel 数量多,可以统一用一个CompositeDisposable管理订阅,窗口关闭时一次性清空。
跨线程那个坑单独说一句:InvalidOperationException至少还会抛,最怕的是不抛但界面状态错乱——比如某一行的颜色停在报警色不恢复,重启又好了。这种基本都是后台线程直接改了绑定属性但没走Dispatcher。定位办法是在Apply开头断言_dispatcher.CheckAccess(),调试期一旦为 false 就直接抛,比线上慢慢查便宜得多。
6. 上线前的压测与自检:把每秒 200 条推送压成 20 帧
骨架跑通之后,最后一步是把推送压到界面能承受的节奏。批量合并解决的是“UI 线程被排队打满”,但还有一种浪费:同一台设备在 100 毫秒内报了 5 次状态,前 4 次对用户毫无意义,只有最后一次有效。这类中间态应该在Drain里就地合并。
// 按 DeviceId 做 last-write-wins,中间态直接丢弃 private readonly Dictionary<string, DeviceStatus> _latest = new(); private void Drain() { Interlocked.Exchange(ref _scheduled, 0); while (_queue.TryDequeue(out var s)) _latest[s.DeviceId] = s; // 后到的覆盖先到的 if (_latest.Count == 0) return; _apply(_latest.Values.ToList()); _latest.Clear(); }改了之后,200 条每秒、同一批设备反复上报的场景,实际进 UI 的批次数会从每秒上百次降到每秒十几次,而且每批的条数受设备总数约束,不再随上报频率线性增长。代价是丢掉了中间态——如果业务需要画温度曲线,那就不能这么合并,得改成按时间窗口采样(比如每 200 毫秒取一个点),保留趋势而不是保留全部原始值。
压测时我会用一个简单的计时器卡住Drain的耗时,超过 16 毫秒就写日志,带上批次数和队列长度:
注意:压测要在真实网络下做,别用本机回环。回环的 RTT 接近 0,永远压不出心跳超时和重连丢组这两类问题。
最后是一张上线前我会逐条过的参数表,抄下来按顺序核一遍,能省掉大部分“上线才出现”的问题:
| 参数 | 位置 | 默认值 | 上线建议 |
|---|---|---|---|
KeepAliveInterval | 服务端 | 15s | 10s 以内,抵消 NAT 老化 |
ClientTimeoutInterval | 服务端 | 30s | 保持为心跳间隔的 2~3 倍 |
MaximumReceiveMessageSize | 服务端 | 32KB | 按上报报文实测值 ×2 |
EnableDetailedErrors | 服务端 | false | 调试开、上线关 |
SkipNegotiation | 客户端 | false | 内网可开,有网关必须关 |
| 单批上限 | 客户端 | — | 50~100 条,配合虚拟化 |
| 订阅重放 | 客户端 | — | Reconnected里必须重放分组订阅 |
这套东西我从轮询时代一路改过来,最大的教训是:实时推送的 bug 基本不在“推”的那一步,而在重连、订阅、线程和退订这四个边角上。后来每次接新项目,我都会先把参数表和这三条订阅规则写进设计文档,再动第一行业务代码——不然等到客户现场拔网线才发现组丢了,代价就不是改几行代码的事了。希望帮到你。
本文还有配套的精品资源,点击获取