核心看点:三层架构分离 × 实时双向绑定 × 命令模式演绎 = 从零到一掌握现代桌面开发的精妙之道=手写MVVM
去年夏天,我接手一个老项目。打开代码——我的天啦。
前辈们把所有逻辑堆在UI层。点击按钮直接操作数据库。修改个界面样式,得用肉眼debug整个业务流程。更奇葩的是,测试人员没法单独验证业务逻辑,因为根本分不清哪些行为属于UI、哪些是核心业务。这就是传说中的意大利面条代码(Spaghetti Code)。
当时花了三个月才把这摊子理顺。期间我深刻体会到一件事——架构设计不是锦上添花,是避坑减灾的必需品。
今天分享的这个点胶机实时监控系统?它用MVVM模式展现了企业级应用的标准做法。咱们一起把它拆开看看。
说个现实情况:大量时间花在维护已有代码上。更现实的是,这里大半时间在喊"这特么什么鬼代码"。
MVVM要解决的核心问题是啥呢?
View和业务逻辑紧耦合。改个需求,UI、数据处理、事件响应,全得动。牵一发而动全身。
MVVM的思路很直白——把东西分清楚:
| 层级 | 职责 | 典型问题 |
|---|---|---|
| View | 只负责展示和用户输入 | UI线程安全?数据格式转换? |
| ViewModel | 数据处理、命令执行、事件通知 | 属性更新如何通知UI? |
| Model | 纯数据对象、业务规则 | 能否独立测试验证? |
| Service | 业务操作、外部调用、数据获取 | 如何实现真实与模拟切换? |
这分层一旦做好,新增功能只影响特定层,测试覆盖率能翻倍提升,甚至换个UI框架都不怕。

任何MVVM系统的基座都是两样东西——INotifyPropertyChanged(属性变化通知)和ICommand(命令执行)。
咱们先看基础设施代码。这是ViewModelBase:
csharppublic 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:
csharppublic 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:
csharppublic 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}";
}
}
为啥用record或init而不是普通类?这涉及到防御式编程的思想。一旦Model定义好了,就不应该被随意篡改。只读特性能帮你避免很多诡异的bug。
Service层定义了与设备交互的协议。注意这里用了接口而不是具体实现:
csharppublic 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,模拟点胶机的运行状态:
csharppublic 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线程同时访问这些字段,不加锁就会出现脏读(读到正在被修改的数据)。
这是整个系统的大脑。让我分块讲。
csharppublic 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更新按钮状态。
csharppublic 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代码里。这样做有什么好处?
csharpprivate 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", "生产停止");
}
这里注意一点——异步操作。SetSpeedAsync、StartAsync这些不会阻塞UI线程。这是WinForms应用保持响应的关键。
最有意思的部分来了。
csharpprivate 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)
{
// 正常取消,什么都不做
}
}
这个循环做了三件事:
这就是后台监控的典型实现。整个UI一直保持响应,因为轮询在后台线程里跑。
csharppublic 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层的职责非常清晰:
关键点:View永远不直接访问Service或数据库。所有的业务逻辑都通过ViewModel间接完成。
我在这个项目里学到了什么?
第一点:分层的真正意义不是为了好看。
如果没有ViewModel这一层,轮询逻辑、命令执行、属性转换……这些全得堆在Form代码里。两周后你就会看到一个3000行的代码文件。然后每次改需求都是噩梦。
第二点:接口驱动设计让测试变得现实。
有了IService接口,我可以写一个MockService:
csharppublic 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只需要绑定:
csharpbtnStart.Enabled = _viewModel.StartCommand.CanExecute(null);
简洁多了。
后台轮询线程和UI线程同时访问_alarmMessage这样的字段——必须加锁或用线程安全的数据结构。
我见过项目因为这个读到了一半修改的字符串,然后就crash了。
csharppublic void Dispose()
{
_pollingCts.Cancel(); // 停止轮询线程
_pollingCts.Dispose(); // 释放资源
}
如果忘了这个,后台线程会一直跑。应用关闭都关不掉。
如果轮询间隔设成1ms而不是600ms,UI会被PropertyChanged事件轰炸。直接卡死。所以轮询间隔要平衡——既要及时反映状态变化,也要保证UI流畅。
csharp// 不要这样:
_ => !IsRunning && DispensingSpeed > 0 && _systemReady && _deviceConnected && ...
// 应该这样:保持简单清晰
_ => !IsRunning && DispensingSpeed > 0
太复杂的判断条件难以维护,也难以测试。
如果要加新功能,比如"设备离线自动重连"或"数据本地缓存",咱们的架构怎么应对?
很简单——添加新的Service接口和Model对象,ViewModel引入新的属性和命令,View绑定新的UI控件。彼此独立,互不影响。
这就是为什么企业级应用都倾向于用MVVM的原因——可维护性和可扩展性成倍增长。
看完这个架构,你会想到:如果需要支持多设备同时监控,这个ViewModel怎么设计? 是一个ViewModel管理列表,还是每个设备一个ViewModel?各有什么权衡?
欢迎在评论区分享你的想法。或者说说你项目里遇到过的MVVM应用场景——特别是踩过的坑。
相关标签:#C#开发 #MVVM架构 #桌面应用 #设计模式 #代码重构
相关信息
通过网盘分享的文件:AppMvvm2026.zip 链接: https://pan.baidu.com/s/1tTpPSupfW2fz2j2leRsSaA?pwd=dm4i 提取码: dm4i --来自百度网盘超级会员v9的分享
本文作者:技术老小子
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!