2026-05-19
C#
0

目录

🤔 先说一个让人头疼的老问题
🏗️ 整体架构:四层分明,各司其职
🖼️先看效果
💉 DI 注册:生命周期选错,整个系统白搭
⚙️ 服务层:模型设计里藏着哲学
为什么 DeviceStatus 用 record?
AlarmService 的接口设计:为未来留门
🧠 ViewModel 层:真正的核心战场
阈值过滤:一个被低估的性能技巧
AlarmBrushKey:ViewModel 和 View 的优雅约定
图表刷新的"脏标记"模式
线程安全:SynchronizationContext 的正确打开方式
🖼️ View 层:薄到极致
🛎️Service 服务层
📊 ScottPlot 图表集成:细节决定体验
🚨 三个需要注意的潜在问题
💡 三句话带走的技术洞察
🔚 写在最后

🤔 先说一个让人头疼的老问题

做过 WinForms 项目的人,大概都经历过这种绝望——

打开一个三年前的老窗体,Form1.cs 里密密麻麻两千行,业务逻辑、UI 更新、数据库调用全搅在一起。你想改一个报警弹窗的颜色,结果顺藤摸瓜,发现它跟设备连接状态、历史记录查询耦合得死死的。改一行,崩三处。

这不是个例。这是 WinForms 项目的"传统艺能"。

但问题来了:WinForms 真的没救了吗?

不。我最近在一个工业设备监控项目里,把 MVVM 模式、微软官方 DI 容器、CommunityToolkit.Mvvm 以及 ScottPlot 实时图表全部揉进了 WinForms——跑通了,而且跑得挺漂亮。今天把这套架构完整拆给你看。


🏗️ 整体架构:四层分明,各司其职

先上全局视角。这套架构分四层,层与层之间单向依赖,没有回头路:

Infrastructure(DI 注册) ↓ Services(业务逻辑 + 设备模拟) ↓ ViewModels(状态管理 + 命令) ↓ Views(纯绑定,不碰业务)

这个结构有个核心原则:ViewModel 绝对不引用任何 UI 命名空间。你在整个 ViewModel 层找不到一个 System.Drawing、一个 Control.Invoke,连颜色都不出现——颜色是 View 的事。

这不是洁癖,是为了让 ViewModel 可以脱离界面单独跑单元测试。


🖼️先看效果

image.png

image.png

image.png

💉 DI 注册:生命周期选错,整个系统白搭

csharp
using AppMvvm14.Services; using AppMvvm14.Services.Interfaces; using AppMvvm14.ViewModels; using AppMvvm14.Views; using Microsoft.Extensions.DependencyInjection; namespace AppMvvm14.Infrastructure; public static class ServiceRegistration { public static IServiceCollection AddAppServices(this IServiceCollection services) { // 服务层(单例) services.AddSingleton<IDeviceService, MockDeviceService>(); services.AddSingleton<IAlarmService, AlarmService>(); // ViewModel(瞬态) services.AddTransient<DeviceMonitorViewModel>(); services.AddTransient<AlarmListViewModel>(); // View(瞬态) services.AddTransient<FrmDeviceMonitor>(); services.AddTransient<FrmAlarmList>(); services.AddSingleton<FrmMain>(); return services; } }

这里有个容易踩的坑,说清楚:

MockDeviceService 构造函数里直接启动了后台轮询循环,内部维护着设备连接状态字典。如果注册成 Transient,每次解析都会 new 一个新的,每个新实例都会开一个新的轮询线程——内存泄漏,还没人管 Dispose。所以必须是 Singleton

AlarmService 同理,它的报警记录列表 _records 要在整个应用里共享,AlarmListViewModelDeviceMonitorViewModel 都要看到同一份数据。单例,没得商量。

反过来,ViewModel 和 View 注册成 Transient 是因为你可能同时打开多个监控窗口,每个窗口有自己独立的状态——这是正确的设计。


⚙️ 服务层:模型设计里藏着哲学

为什么 DeviceStatusrecord

csharp
namespace AppMvvm14.Models; /// <summary> /// 设备实时状态快照,只有数据,没有行为 /// </summary> public record DeviceStatus { public string DeviceId { get; init; } = string.Empty; public double Temperature { get; init; } public double Pressure { get; init; } public double FlowRate { get; init; } public bool IsRunning { get; init; } public bool HasAlarm { get; init; } public string AlarmMessage { get; init; } = string.Empty; public DateTime Timestamp { get; init; } = DateTime.Now; }

设备状态天然是"快照",不是"对象"。每次读到的数据代表某一时刻的状态,不应该被修改。用 record + init-only 属性,从语言层面强制了不可变性。

这还有个隐藏好处:record 自带结构相等,两个状态快照可以直接用 == 比较,在写测试的时候省老事了。

AlarmService 的接口设计:为未来留门

csharp
public Task AcknowledgeAsync(string deviceId) { ... } public Task<List<AlarmRecord>> GetActiveAlarmsAsync() { ... }

这两个方法现在内部是同步操作,但包了 Task 外壳。有人会说:多此一举。

不是的。今天是内存列表,明天可能换成 SQLite,后天可能是远程 API。接口定义成异步,调用方从第一天就写 await,将来换实现不需要改任何上层代码。这叫面向未来的接口设计,和过度设计不是一回事。


🧠 ViewModel 层:真正的核心战场

阈值过滤:一个被低估的性能技巧

DeviceMonitorViewModel 里有段代码,第一次看可能觉得多余:

csharp
private void ApplyStatus(DeviceStatus status) { if (Math.Abs(status.Temperature - Temperature) > 0.05) Temperature = status.Temperature; if (Math.Abs(status.Pressure - Pressure) > 0.005) Pressure = status.Pressure; // ... }

设备每 500ms 推一次数据,温度值可能每次都有 0.001 度的随机抖动。如果每次都触发 PropertyChanged,UI 线程就得每秒处理两次标签重绘、两次图表刷新。

加了这个 delta 过滤之后,只有变化幅度超过阈值才触发通知。UI 安静了,CPU 也轻松了。这个技巧在工业 SCADA 系统里几乎是标配,但很少有人在 WinForms 教程里提到它。

AlarmBrushKey:ViewModel 和 View 的优雅约定

csharp
// ViewModel 里 public string AlarmBrushKey => HasAlarm ? "AlarmActive" : "Normal"; // View 里 private static readonly Dictionary<string, Color> ColorMap = new() { ["Normal"] = Color.FromArgb(40, 167, 69), ["AlarmActive"] = Color.FromArgb(220, 53, 69), };

ViewModel 不知道颜色是什么,它只知道当前语义状态是"报警激活"还是"正常"。颜色的映射完全在 View 层。

这个设计的价值在于:如果哪天产品说"报警颜色改成橙色",你只改 View 里的 ColorMap,ViewModel 一行不动。如果要支持深色主题,也只需要换一套 ColorMap

图表刷新的"脏标记"模式

List<double> 不实现 INotifyCollectionChanged,View 没法直接监听历史数据的变化。这里用了一个很巧的方案:

csharp
[ObservableProperty] private int _chartRefreshTick; // 每次追加历史数据后 ChartRefreshTick++;

View 监听这个整数属性,每次它变化就重绘图表。本质上是个"脏标记",通知 View"数据变了,你去取最新的"。简单,有效,没有多余的封装。

线程安全:SynchronizationContext 的正确打开方式

csharp
private readonly SynchronizationContext? _uiContext = SynchronizationContext.Current; private void OnDeviceStatusChanged(object? sender, DeviceStatus status) { if (_uiContext is not null) _uiContext.Post(_ => ApplyStatus(status), null); else ApplyStatus(status); }

这里有个隐含前提:ViewModel 必须在 UI 线程上构造。因为 SynchronizationContext.Current 只在 UI 线程上有值,如果在后台线程 new 出来,_uiContext 就是 null,后面那个 else 分支就会直接在后台线程操作 UI——悄无声息地挂。

在这个项目里,ViewModel 是通过 DI 在按钮点击事件里解析的,天然在 UI 线程,所以安全。但这个假设值得在代码注释里写清楚,不然下一个接手的人可能踩进去。


🖼️ View 层:薄到极致

FrmDeviceMonitorFrmAlarmList 的 View 层做的事情归纳起来就三件:

第一,绑定数据。DataBindings.Add 把 ViewModel 的属性直接接到控件上,数据流是单向的,View 不存状态。

第二,同步按钮状态。 WinForms 没有 WPF 那种 Command 绑定,所以手动订阅 CanExecuteChanged

csharp
private static void SyncEnabled(Button btn, IAsyncRelayCommand cmd) { btn.Enabled = cmd.CanExecute(null); cmd.CanExecuteChanged += (_, _) => btn.Enabled = cmd.CanExecute(null); }

这个小工具方法值得提炼成通用扩展,项目里有多少按钮就能复用多少次。

第三,转发命令。 按钮点击事件只做一件事——调用 ViewModel 上的命令:

csharp
btnConnect.Click += async (_, _) => await _vm.ConnectCommand.ExecuteAsync(null);

没有任何业务判断,没有任何状态修改。View 就是个"传声筒"。


🛎️Service 服务层

c#
using AppMvvm14.Models; using AppMvvm14.Services.Interfaces; namespace AppMvvm14.Services; public sealed class AlarmService : IAlarmService { public event EventHandler<AlarmRecord>? AlarmOccurred; private readonly List<AlarmRecord> _records = new(); private int _nextId = 1; public void RaiseAlarm(AlarmRecord record) { record.Id = _nextId++; _records.Add(record); AlarmOccurred?.Invoke(this, record); } public Task AcknowledgeAsync(string deviceId) { _records .Where(r => r.DeviceId == deviceId && !r.IsAcknowledged) .ToList() .ForEach(r => r.IsAcknowledged = true); return Task.CompletedTask; } public Task<List<AlarmRecord>> GetActiveAlarmsAsync() => Task.FromResult(_records.Where(r => !r.IsAcknowledged).ToList()); }

模拟测试Service

c#
using AppMvvm14.Models; using AppMvvm14.Services.Interfaces; namespace AppMvvm14.Services; public sealed class MockDeviceService : IDeviceService, IDisposable { public event EventHandler<DeviceStatus>? StatusChanged; private readonly Dictionary<string, bool> _connected = new(); private readonly Random _rng = new(); private readonly PeriodicTimer _timer = new(TimeSpan.FromMilliseconds(500)); private CancellationTokenSource _cts = new(); public MockDeviceService() => StartPollingLoop(); public Task<bool> ConnectAsync(string deviceId) { _connected[deviceId] = true; return Task.FromResult(true); } public Task DisconnectAsync(string deviceId) { _connected[deviceId] = false; return Task.CompletedTask; } public Task<DeviceStatus> ReadStatusAsync(string deviceId) { var status = BuildStatus(deviceId); return Task.FromResult(status); } public Task<bool> WriteCommandAsync(string deviceId, string command, object value) => Task.FromResult(true); private void StartPollingLoop() { _ = Task.Run(async () => { while (await _timer.WaitForNextTickAsync(_cts.Token)) { foreach (var kv in _connected.Where(x => x.Value)) StatusChanged?.Invoke(this, BuildStatus(kv.Key)); } }); } private static readonly string[] AlarmMessages = { "温度超限!", "压力超限!", "流量异常!", "传感器离线", "通信超时", }; private DeviceStatus BuildStatus(string deviceId) { bool hasAlarm = _rng.NextDouble() > 0.92; return new DeviceStatus { DeviceId = deviceId, Temperature = 60.0 + _rng.NextDouble() * 30.0, Pressure = 1.0 + _rng.NextDouble() * 2.0, FlowRate = 10.0 + _rng.NextDouble() * 5.0, IsRunning = _connected.GetValueOrDefault(deviceId), HasAlarm = hasAlarm, AlarmMessage = hasAlarm ? AlarmMessages[_rng.Next(AlarmMessages.Length)] : string.Empty, Timestamp = DateTime.Now }; } public void Dispose() { _cts.Cancel(); _timer.Dispose(); } }

📊 ScottPlot 图表集成:细节决定体验

图表部分有几个处理得比较讲究的地方。

初始化和刷新分离。SetupPlot 只跑一次,建好坐标轴、报警阈值线、字体配置。RedrawPlot 每次只移除数据系列、重建,报警线纹丝不动:

csharp
plot.Plot.PlottableList .Where(p => p is ScottPlot.Plottables.Signal) .ToList() .ForEach(p => plot.Plot.Remove(p));

X 轴滚动实现了"滑动窗口"效果,始终显示最新 120 个采样点:

csharp
double xMin = Math.Max(0, count - ChartWindow); double xMax = xMin + ChartWindow; plot.Plot.Axes.SetLimitsX(xMin, xMax);

Y 轴在每次刷新后强制重设范围。这是因为 ScottPlot 5 在添加新数据后会触发自动缩放,把你手动设的范围覆盖掉。防御性重设是必要的,不是冗余。


🚨 三个需要注意的潜在问题

问题一:AlarmServiceMockDeviceService 的线程安全。

AlarmService._records 是普通 List<T>MockDeviceService._connected 是普通 Dictionary,两者都可能被后台线程和 UI 线程同时访问。目前因为 Post 的存在,实际运行大概率没问题,但这是运气,不是设计。建议换成 ConcurrentBagConcurrentDictionary,或者加 lock

问题二:按设备确认报警的语义。

AcknowledgeAsync(SelectedAlarm.DeviceId) 会一次性确认该设备的所有未确认报警,而不是只确认选中的那一条。这可能是有意为之(设备级确认),但如果业务需要精确到单条,应该改成按 record.Id 确认。

问题三:FrmMain 直接用 ServiceProvider 解析。

csharp
var frm = Program.ServiceProvider.GetRequiredService<FrmDeviceMonitor>();

这是服务定位器模式,理论上是 DI 的反模式。但在这个规模的项目里,只有主窗体这一个地方用,可以接受。如果项目规模扩大,建议抽象出 IWindowFactoryINavigationService,把这个依赖隐藏进去。


💡 三句话带走的技术洞察

"ViewModel 不知道颜色,只知道语义" ——这是 MVVM 边界最精准的一句注脚。

"接口设计成异步,不是因为现在需要,而是因为未来可能需要" ——这是工程师和码农的分水岭。

"后台线程的事件,必须 Post 回 UI 线程再碰控件" ——这条规矩在 WinForms 里从未过时。


🔚 写在最后

WinForms 没有死。在工业控制、医疗设备、老系统改造这些场景里,它还活得好好的。

真正的问题从来不是框架,是架构。把 MVVM、DI、事件驱动这些思想带进 WinForms,你会发现它其实能撑起相当复杂的业务——代码可测试、可维护、可扩展。

完整工程代码结构已在文中完整展示。如果你正好在维护一个"意大利面条式"的老 WinForms 项目,希望这套架构能给你一个重构的起点。

#C#开发 #WinForms #MVVM架构 #CommunityToolkit #工业软件开发

相关信息

我用夸克网盘给你分享了「AppMvvm14.zip」,点击链接或复制整段内容,打开「夸克APP」即可获取。 /248d3YeHDd:/ 链接:https://pan.quark.cn/s/c16edc64bc53 提取码:zCDG

本文作者:技术老小子

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!