做过 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 可以脱离界面单独跑单元测试。



csharpusing 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 要在整个应用里共享,AlarmListViewModel 和 DeviceMonitorViewModel 都要看到同一份数据。单例,没得商量。
反过来,ViewModel 和 View 注册成 Transient 是因为你可能同时打开多个监控窗口,每个窗口有自己独立的状态——这是正确的设计。
DeviceStatus 用 record?csharpnamespace 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 的接口设计:为未来留门csharppublic Task AcknowledgeAsync(string deviceId) { ... }
public Task<List<AlarmRecord>> GetActiveAlarmsAsync() { ... }
这两个方法现在内部是同步操作,但包了 Task 外壳。有人会说:多此一举。
不是的。今天是内存列表,明天可能换成 SQLite,后天可能是远程 API。接口定义成异步,调用方从第一天就写 await,将来换实现不需要改任何上层代码。这叫面向未来的接口设计,和过度设计不是一回事。
DeviceMonitorViewModel 里有段代码,第一次看可能觉得多余:
csharpprivate 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 的正确打开方式csharpprivate 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 线程,所以安全。但这个假设值得在代码注释里写清楚,不然下一个接手的人可能踩进去。
FrmDeviceMonitor 和 FrmAlarmList 的 View 层做的事情归纳起来就三件:
第一,绑定数据。 用 DataBindings.Add 把 ViewModel 的属性直接接到控件上,数据流是单向的,View 不存状态。
第二,同步按钮状态。 WinForms 没有 WPF 那种 Command 绑定,所以手动订阅 CanExecuteChanged:
csharpprivate static void SyncEnabled(Button btn, IAsyncRelayCommand cmd)
{
btn.Enabled = cmd.CanExecute(null);
cmd.CanExecuteChanged += (_, _) => btn.Enabled = cmd.CanExecute(null);
}
这个小工具方法值得提炼成通用扩展,项目里有多少按钮就能复用多少次。
第三,转发命令。 按钮点击事件只做一件事——调用 ViewModel 上的命令:
csharpbtnConnect.Click += async (_, _) =>
await _vm.ConnectCommand.ExecuteAsync(null);
没有任何业务判断,没有任何状态修改。View 就是个"传声筒"。
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();
}
}
图表部分有几个处理得比较讲究的地方。
初始化和刷新分离。SetupPlot 只跑一次,建好坐标轴、报警阈值线、字体配置。RedrawPlot 每次只移除数据系列、重建,报警线纹丝不动:
csharpplot.Plot.PlottableList
.Where(p => p is ScottPlot.Plottables.Signal)
.ToList()
.ForEach(p => plot.Plot.Remove(p));
X 轴滚动实现了"滑动窗口"效果,始终显示最新 120 个采样点:
csharpdouble xMin = Math.Max(0, count - ChartWindow);
double xMax = xMin + ChartWindow;
plot.Plot.Axes.SetLimitsX(xMin, xMax);
Y 轴在每次刷新后强制重设范围。这是因为 ScottPlot 5 在添加新数据后会触发自动缩放,把你手动设的范围覆盖掉。防御性重设是必要的,不是冗余。
问题一:AlarmService 和 MockDeviceService 的线程安全。
AlarmService._records 是普通 List<T>,MockDeviceService._connected 是普通 Dictionary,两者都可能被后台线程和 UI 线程同时访问。目前因为 Post 的存在,实际运行大概率没问题,但这是运气,不是设计。建议换成 ConcurrentBag 和 ConcurrentDictionary,或者加 lock。
问题二:按设备确认报警的语义。
AcknowledgeAsync(SelectedAlarm.DeviceId) 会一次性确认该设备的所有未确认报警,而不是只确认选中的那一条。这可能是有意为之(设备级确认),但如果业务需要精确到单条,应该改成按 record.Id 确认。
问题三:FrmMain 直接用 ServiceProvider 解析。
csharpvar frm = Program.ServiceProvider.GetRequiredService<FrmDeviceMonitor>();
这是服务定位器模式,理论上是 DI 的反模式。但在这个规模的项目里,只有主窗体这一个地方用,可以接受。如果项目规模扩大,建议抽象出 IWindowFactory 或 INavigationService,把这个依赖隐藏进去。
"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 许可协议。转载请注明出处!