编辑
2026-04-03
C#
00

目录

🎯 你是不是也这样过?
💡 为啥非要用MVVM?
先看样式
🏗️ 架构那些事儿:从基础设施开始
⚙️ 第一步:搭基座
🔌 第二步:定义数据模型
📡 第三步:业务服务接口
🎬 核心大戏:ViewModel的那些讲究
先看属性定义
再看命令定义
命令具体执行
🔄 实时更新的秘诀:后台轮询与双向绑定
🎨 View层:简而不陋
🎯 从架构设计看工程能力
💎 那些容易踩的坑
坑一:线程安全疏忽
坑二:内存泄漏
坑三:频繁更新UI导致卡顿
坑四:命令的CanExecute判断过于复杂
🚀 扩展这个架构
📚 一句话总结
🤔 留个问题给你

核心看点:三层架构分离 × 实时双向绑定 × 命令模式演绎 = 从零到一掌握现代桌面开发的精妙之道=手写MVVM


🎯 你是不是也这样过?

去年夏天,我接手一个老项目。打开代码——我的天啦。

前辈们把所有逻辑堆在UI层。点击按钮直接操作数据库。修改个界面样式,得用肉眼debug整个业务流程。更奇葩的是,测试人员没法单独验证业务逻辑,因为根本分不清哪些行为属于UI、哪些是核心业务。这就是传说中的意大利面条代码(Spaghetti Code)。

当时花了三个月才把这摊子理顺。期间我深刻体会到一件事——架构设计不是锦上添花,是避坑减灾的必需品

今天分享的这个点胶机实时监控系统?它用MVVM模式展现了企业级应用的标准做法。咱们一起把它拆开看看。


💡 为啥非要用MVVM?

说个现实情况:大量时间花在维护已有代码上。更现实的是,这里大半时间在喊"这特么什么鬼代码"。

MVVM要解决的核心问题是啥呢?

View和业务逻辑紧耦合。改个需求,UI、数据处理、事件响应,全得动。牵一发而动全身。

MVVM的思路很直白——把东西分清楚:

层级职责典型问题
View只负责展示和用户输入UI线程安全?数据格式转换?
ViewModel数据处理、命令执行、事件通知属性更新如何通知UI?
Model纯数据对象、业务规则能否独立测试验证?
Service业务操作、外部调用、数据获取如何实现真实与模拟切换?

这分层一旦做好,新增功能只影响特定层,测试覆盖率能翻倍提升,甚至换个UI框架都不怕


先看样式

image.png

🏗️ 架构那些事儿:从基础设施开始

⚙️ 第一步:搭基座

任何MVVM系统的基座都是两样东西——INotifyPropertyChanged(属性变化通知)和ICommand(命令执行)。

咱们先看基础设施代码。这是ViewModelBase:

csharp
public abstract class ViewModelBase : INotifyPropertyChanged { public event PropertyChangedEventHandler? PropertyChanged; // SetProperty的核心逻辑:只有值真的变了,才通知UI protected bool SetProperty<T>( ref T field, T value, [CallerMemberName] string? propertyName = null) { // 如果新值和旧值一样,直接return false,别折腾 if (EqualityComparer<T>.Default.Equals(field, value)) { return false; } field = value; OnPropertyChanged(propertyName); // 通知UI更新 return true; } protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } }

这里有个细节很重要——CallerMemberName属性。C#编译器会自动把属性名填进来,咱们就不用手写字符串了。省得拼写错误。

再看RelayCommand:

csharp
public sealed class RelayCommand : ICommand { private readonly Action<object?> _execute; private readonly Func<object?, bool>? _canExecute; public bool CanExecute(object? parameter) { // 如果没提供判断逻辑,就默认能执行 return _canExecute?.Invoke(parameter) ?? true; } public void Execute(object? parameter) { _execute(parameter); } // 这个方法很关键——UI绑定监听这个事件 public void RaiseCanExecuteChanged() { CanExecuteChanged?.Invoke(this, EventArgs.Empty); } }

这个RelayCommand其实就是命令模式的实现。把"做什么"和"能不能做"分开定义。稍后你就会看到它的妙用。


🔌 第二步:定义数据模型

Model层很纯粹,就是数据容器。注意这里用了init而不是set

csharp
public sealed class DeviceTelemetry { // init代表只能在初始化时赋值,之后只读 // 这样设计是为了保证数据的不可变性 public bool IsRunning { get; init; } public string DeviceStatus { get; init; } = "待机"; public double Temperature { get; init; } public double Pressure { get; init; } public string AlarmMessage { get; init; } = string.Empty; } public sealed class OperationLog { public DateTime Time { get; init; } public string Action { get; init; } = string.Empty; public string Detail { get; init; } = string.Empty; // ToString重写便于日志显示 public override string ToString() { return $"[{Time:HH:mm:ss}] {Action} - {Detail}"; } }

为啥用recordinit而不是普通类?这涉及到防御式编程的思想。一旦Model定义好了,就不应该被随意篡改。只读特性能帮你避免很多诡异的bug。


📡 第三步:业务服务接口

Service层定义了与设备交互的协议。注意这里用了接口而不是具体实现:

csharp
public interface IService { Task SetSpeedAsync(double speed, CancellationToken cancellationToken = default); Task StartAsync(CancellationToken cancellationToken = default); Task StopAsync(CancellationToken cancellationToken = default); Task ClearAlarmAsync(CancellationToken cancellationToken = default); Task<DeviceTelemetry> GetTelemetryAsync(CancellationToken cancellationToken = default); }

为啥一定要用接口? 因为咱们既需要真实的硬件实现,也需要模拟实现用于测试。接口让这两者可以无缝切换,而上层代码毫不知情。这就是依赖注入的核心价值。

接下来是SimulatedService,模拟点胶机的运行状态:

csharp
public sealed class SimulatedService : IService { private readonly Random _random = new(); private readonly object _syncRoot = new(); private bool _isRunning; private double _speed; private string _status = "待机"; private string _alarmMessage = string.Empty; public Task SetSpeedAsync(double speed, CancellationToken cancellationToken = default) { lock (_syncRoot) { _speed = speed; if (_isRunning) { _status = "运行中"; } } return Task.CompletedTask; } // ... 其他方法 public Task<DeviceTelemetry> GetTelemetryAsync(CancellationToken cancellationToken = default) { lock (_syncRoot) { // 这里模拟设备的温度变化逻辑 // 设备运行时温度更高,停止时恢复常温 var baseTemperature = _isRunning ? 35 + (_speed / 80d) : 28; var basePressure = _isRunning ? 0.6 + (_speed / 2200d) : 0.15; // 加入随机波动,模拟真实设备的不稳定性 var temperature = Math.Round(baseTemperature + _random.NextDouble() * 2.5, 1); var pressure = Math.Round(basePressure + _random.NextDouble() * 0.15, 2); // 低概率触发告警(0.015%的概率) if (_isRunning && _random.NextDouble() > 0.985) { _alarmMessage = "喷头压力波动,请检查供胶系统"; _status = "告警"; } else if (string.IsNullOrEmpty(_alarmMessage) && _isRunning) { _status = "运行中"; } return Task.FromResult(new DeviceTelemetry { IsRunning = _isRunning, DeviceStatus = _status, Temperature = temperature, Pressure = pressure, AlarmMessage = _alarmMessage }); } } }

注意这里用了lock (_syncRoot)。为啥?多线程访问。轮询线程和UI线程同时访问这些字段,不加锁就会出现脏读(读到正在被修改的数据)。


🎬 核心大戏:ViewModel的那些讲究

这是整个系统的大脑。让我分块讲。

先看属性定义

csharp
public sealed class ViewModel : ViewModelBase, IDisposable { private readonly IService _deviceService; private readonly ILogRepository _logRepository; private readonly CancellationTokenSource _pollingCts = new(); // 私有字段存储实际数据 private double _dispensingSpeed = 320; private string _deviceStatus = "待机"; private bool _isRunning; private string _alarmMessage = string.Empty; private double _temperature; private double _pressure; private List<string> _logItems = []; // 公开属性,通过SetProperty通知UI变化 public double DispensingSpeed { get => _dispensingSpeed; set { var validValue = Math.Clamp(value, 0, 1000); if (SetProperty(ref _dispensingSpeed, validValue)) { // 速度变了,StartCommand的"能不能执行"判断条件也变了 StartCommand.RaiseCanExecuteChanged(); } } } public string DeviceStatus { get => _deviceStatus; set => SetProperty(ref _deviceStatus, value); } // ... 其他属性类似 }

这里有个关键细节——属性setter里调用RaiseCanExecuteChanged()

为什么?因为命令的**"能不能执行"取决于当前的业务状态**。比如"开始运行"这个命令,只有在速度>0且设备已停止时才能执行。一旦速度变了,这个判断条件就变了,所以要通知UI更新按钮状态。

再看命令定义

csharp
public ViewModel(IService deviceService, ILogRepository logRepository) { _deviceService = deviceService; _logRepository = logRepository; // StartCommand的执行逻辑:设置速度→启动设备 StartCommand = new RelayCommand( async _ => await StartDispensingAsync(), // 只有速度>0且设备未运行时,才能执行 _ => !IsRunning && DispensingSpeed > 0 ); StopCommand = new RelayCommand( async _ => await StopDispensingAsync(), // 只有设备正在运行时,才能执行 _ => IsRunning ); ClearAlarmCommand = new RelayCommand( async _ => await ClearAlarmAsync(), // 只有有告警信息时,才能执行 _ => !string.IsNullOrWhiteSpace(AlarmMessage) ); // 启动后台轮询任务 _ = PollTelemetryAsync(_pollingCts.Token); }

看到没有?命令的"能不能执行"逻辑直接写在这里,而不是散落在UI代码里。这样做有什么好处?

  1. 逻辑集中管理 —— 一眼能看清楚什么时候才能执行
  2. 易于测试 —— 直接测试ViewModel,不用操纵UI
  3. 跨平台复用 —— 换个UI框架,这些命令逻辑完全不用改

命令具体执行

csharp
private async Task StartDispensingAsync() { if (DispensingSpeed <= 0) { AlarmMessage = "速度必须大于 0"; return; } await _deviceService.SetSpeedAsync(DispensingSpeed); await _deviceService.StartAsync(); IsRunning = true; DeviceStatus = "运行中"; AlarmMessage = string.Empty; await AddLogAsync("Start", $"Speed={DispensingSpeed:F1} mm/s"); } private async Task StopDispensingAsync() { await _deviceService.StopAsync(); _lastLoggedAlarmMessage = string.Empty; AlarmMessage = string.Empty; IsRunning = false; DeviceStatus = "已停止"; await AddLogAsync("Stop", "生产停止"); }

这里注意一点——异步操作SetSpeedAsyncStartAsync这些不会阻塞UI线程。这是WinForms应用保持响应的关键。


🔄 实时更新的秘诀:后台轮询与双向绑定

最有意思的部分来了。

csharp
private async Task PollTelemetryAsync(CancellationToken cancellationToken) { try { while (!cancellationToken.IsCancellationRequested) { // 每600ms从设备获取一次数据 var telemetry = await _deviceService.GetTelemetryAsync(cancellationToken); // 直接赋值,SetProperty会自动判断是否需要通知UI IsRunning = telemetry.IsRunning; DeviceStatus = telemetry.DeviceStatus; Temperature = telemetry.Temperature; Pressure = telemetry.Pressure; // 告警逻辑有点讲究 if (string.IsNullOrWhiteSpace(telemetry.AlarmMessage)) { AlarmMessage = string.Empty; _lastLoggedAlarmMessage = string.Empty; } else { AlarmMessage = telemetry.AlarmMessage; // 关键:只有告警信息第一次出现时才记日志 // 如果告警持续存在,就不重复记了 if (!string.Equals(_lastLoggedAlarmMessage, telemetry.AlarmMessage, StringComparison.Ordinal)) { _lastLoggedAlarmMessage = telemetry.AlarmMessage; await AddLogAsync("Alarm", telemetry.AlarmMessage); } } await Task.Delay(600, cancellationToken); } } catch (OperationCanceledException) { // 正常取消,什么都不做 } }

这个循环做了三件事:

  1. 周期获取设备状态 —— 每600ms一次
  2. 自动更新属性 —— SetProperty会判断值是否真的变了
  3. 智能记录告警 —— 不重复记同一条告警

这就是后台监控的典型实现。整个UI一直保持响应,因为轮询在后台线程里跑。


🎨 View层:简而不陋

csharp
public partial class FrmMain : Form { private readonly ViewModel _viewModel; public FrmMain() { InitializeComponent(); _viewModel = new ViewModel( new SimulatedService(), new InMemoryLogRepository()); BindViewModel(); UpdateAllUi(); } private void BindViewModel() { // 订阅ViewModel的属性变化事件 _viewModel.PropertyChanged += ViewModel_PropertyChanged; // 按钮点击 → 执行命令 btnStart.Click += (_, _) => { if (_viewModel.StartCommand.CanExecute(null)) { _viewModel.StartCommand.Execute(null); } }; // 速度输入框变化 → 更新ViewModel属性 txtSpeed.TextChanged += (_, _) => { if (double.TryParse(txtSpeed.Text, out var speed)) { _viewModel.DispensingSpeed = speed; } }; // 监听命令的"能不能执行"变化 _viewModel.StartCommand.CanExecuteChanged += (_, _) => ExecuteOnUiThread(UpdateCommandState); } // 当ViewModel属性变化时,这个方法会被调用 private void ViewModel_PropertyChanged(object? sender, PropertyChangedEventArgs e) { ExecuteOnUiThread(() => UpdateUiByPropertyName(e.PropertyName)); } // 确保UI更新始终在UI线程执行 private void ExecuteOnUiThread(Action action) { if (IsDisposed) return; if (InvokeRequired) { BeginInvoke(action); } else { action(); } } // 根据不同属性更新不同的UI控件 private void UpdateUiByPropertyName(string? propertyName) { switch (propertyName) { case nameof(ViewModel.DeviceStatus): case nameof(ViewModel.IsRunning): UpdateDeviceStatus(); break; case nameof(ViewModel.Temperature): case nameof(ViewModel.Pressure): UpdateTelemetry(); break; case nameof(ViewModel.AlarmMessage): UpdateAlarm(); break; case nameof(ViewModel.LogItems): UpdateLogs(); break; } } private void UpdateDeviceStatus() { lblStatusValue.Text = _viewModel.DeviceStatus; lblModeValue.Text = _viewModel.IsRunning ? "自动运行" : "待机"; pnlStatus.BackColor = _viewModel.IsRunning ? Color.FromArgb(42, 128, 74) : Color.FromArgb(90, 90, 90); } // ... 其他UI更新方法 }

View层的职责非常清晰:

  1. 接收用户输入 —— 文本框、按钮点击
  2. 更新ViewModel —— 把用户输入反映到ViewModel属性
  3. 监听ViewModel变化 —— PropertyChanged事件
  4. 刷新UI显示 —— 根据ViewModel属性值重绘

关键点:View永远不直接访问Service或数据库。所有的业务逻辑都通过ViewModel间接完成。


🎯 从架构设计看工程能力

我在这个项目里学到了什么?

第一点:分层的真正意义不是为了好看。

如果没有ViewModel这一层,轮询逻辑、命令执行、属性转换……这些全得堆在Form代码里。两周后你就会看到一个3000行的代码文件。然后每次改需求都是噩梦。

第二点:接口驱动设计让测试变得现实。

有了IService接口,我可以写一个MockService:

csharp
public class MockService : IService { public async Task StartAsync(CancellationToken cancellationToken = default) { // 直接模拟特定场景,比如模拟告警频繁触发的情况 // 用于测试UI对异常的反应 } }

然后用这个MockService测试ViewModel。不用真的接硬件。测试速度快得不是一个量级。

第三点:命令模式干掉了按钮的"能不能点"判断散落四处的问题。

以前的代码:

csharp
// 到处都是这样的判断 if (_isRunning && !string.IsNullOrEmpty(_alarmMessage)) { btnStop.Enabled = false; btnClear.Enabled = true; }

现在?命令对象自己负责"能不能执行"。UI只需要绑定:

csharp
btnStart.Enabled = _viewModel.StartCommand.CanExecute(null);

简洁多了。


💎 那些容易踩的坑

坑一:线程安全疏忽

后台轮询线程和UI线程同时访问_alarmMessage这样的字段——必须加锁或用线程安全的数据结构

我见过项目因为这个读到了一半修改的字符串,然后就crash了。

坑二:内存泄漏

csharp
public void Dispose() { _pollingCts.Cancel(); // 停止轮询线程 _pollingCts.Dispose(); // 释放资源 }

如果忘了这个,后台线程会一直跑。应用关闭都关不掉。

坑三:频繁更新UI导致卡顿

如果轮询间隔设成1ms而不是600ms,UI会被PropertyChanged事件轰炸。直接卡死。所以轮询间隔要平衡——既要及时反映状态变化,也要保证UI流畅。

坑四:命令的CanExecute判断过于复杂

csharp
// 不要这样: _ => !IsRunning && DispensingSpeed > 0 && _systemReady && _deviceConnected && ... // 应该这样:保持简单清晰 _ => !IsRunning && DispensingSpeed > 0

太复杂的判断条件难以维护,也难以测试。


🚀 扩展这个架构

如果要加新功能,比如"设备离线自动重连"或"数据本地缓存",咱们的架构怎么应对?

很简单——添加新的Service接口和Model对象,ViewModel引入新的属性和命令,View绑定新的UI控件。彼此独立,互不影响。

这就是为什么企业级应用都倾向于用MVVM的原因——可维护性和可扩展性成倍增长


📚 一句话总结

  • MVVM不是复杂的架构设计,而是把混乱的逻辑梳理清楚的方法论
  • Command模式让"做什么"和"能不能做"分开定义,简化了业务判断
  • PropertyChanged事件让UI和ViewModel实现了真正的解耦,改一个不影响另一个

🤔 留个问题给你

看完这个架构,你会想到:如果需要支持多设备同时监控,这个ViewModel怎么设计? 是一个ViewModel管理列表,还是每个设备一个ViewModel?各有什么权衡?

欢迎在评论区分享你的想法。或者说说你项目里遇到过的MVVM应用场景——特别是踩过的坑。


相关标签:#C#开发 #MVVM架构 #桌面应用 #设计模式 #代码重构

相关信息

通过网盘分享的文件:AppMvvm2026.zip 链接: https://pan.baidu.com/s/1tTpPSupfW2fz2j2leRsSaA?pwd=dm4i 提取码: dm4i --来自百度网盘超级会员v9的分享

本文作者:技术老小子

本文链接:

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