灌装监控系统设计:从业务场景到MVVM骨架
这是「从零搭建灌装监控系统」系列第1篇。从一个灌装车间的真实需求出发,搞清楚上位机到底要干什么,再手把手搭建一个能跑的MVVM骨架。
一场灾难性的重构
去年有个朋友找我帮忙,他在给一家做饮料灌装的小厂写上位机软件。软件功能不复杂——跟PLC通信读数据、界面上显示产量温度液位、超限了弹报警、生产记录存数据库。
他给我看代码的时候,我差点没绷住。MainWindow.xaml.cs 里塞了 1800多行:串口初始化、Modbus轮询、温度判断、报警弹窗、数据库插入、CSV导出、图表刷新,全在一个文件里。没有分层,没有 ViewModel,所有逻辑都是按钮点击事件里一把梭。
我说你这跑着没问题吧?他说能用是能用,但上周老板要加一个"温度历史曲线"功能,他在那个 1800 行的文件里改了两天,改完一跑发现报警不弹了——因为他动报警逻辑的时候不小心把一个 if 条件改错了。修好报警,产量计数又不对了。
改一个功能,崩三个地方。 这就是没有架构的代价。
问题出在哪?界面代码、业务逻辑、数据访问全揉在一起,互相耦合。你动任何一处,都不知道会影响哪些地方。他需要的不是更多的注释或者更仔细的修改,而是一个让各部分各司其职的架构。
这就是 MVVM 要解决的事。
灌装监控系统到底要干什么?
在写代码之前,先搞清楚我们要做什么。这个系列要构建的系统叫 FillTrack——一个灌装生产线监控上位机。
业务场景
一条典型的饮料灌装线,从原料桶出来,经过灌装工位、封盖、贴标、检重,最后成品出料。整条线的节奏由 PLC 控制——阀门什么时候开、温度维持在多少、流量多大,都是 PLC 在管。
那上位机干什么?上位机不直接控制硬件,而是通过 Modbus 协议跟 PLC 通信,做三件事:
系统架构

四层结构,数据流单向流动:
- PlcService — 跟 PLC 通信,200ms 轮询一次,读到数据后通过事件抛出去
- ViewModels — 订阅 PlcService 的事件,处理业务逻辑(报警判断、数据入库),暴露属性给界面绑定
- Views — 纯 XAML,只管显示,不写逻辑
- SQLite — FreeSql ORM 管理数据库,存生产记录、报警记录、日志
技术选型
| 框架 | WPF .NET 8.0 | 工业上位机标配,Windows 桌面生态成熟 |
| MVVM | CommunityToolkit.Mvvm 8.4 | 微软官方,源生成器省掉样板代码 |
| 通信 | NModbus4 + SerialPort | Modbus RTU 串口通信,工业标准协议 |
| 数据库 | SQLite + FreeSql | 轻量免安装,ORM 自动建表 |
| 图表 | LiveCharts 0.9.7 | 实时曲线,WPF 原生支持 |
| 日志 | Serilog 4.3 | 结构化日志,多输出端 |
| DI | Microsoft.Extensions.DependencyInjection | 微软官方 DI 容器 |
这套技术栈的特点是轻量——不需要安装数据库服务器(SQLite 是文件数据库),不需要重型框架(CommunityToolkit.Mvvm 只有源生成器没有运行时反射),适合中小型上位机项目。
MVVM:让代码各司其职
回到我朋友那个 1800 行的灾难。MVVM 把代码分成三层:
- View(界面) — 只管长什么样,XAML 里写布局和绑定
- ViewModel(逻辑) — 只管数据和命令,不知道界面长啥样
- Model(数据) — 只管业务模型,PLC 数据、数据库实体
三者通过数据绑定连接。ViewModel 里的属性变了,界面自动更新;用户点按钮,自动调用 ViewModel 里的命令。不需要手动刷新 UI。
CommunityToolkit.Mvvm:一行特性搞定样板代码
传统 MVVM 写法,每个属性都要手写 INotifyPropertyChanged,每个命令都要实现 ICommand。一个有 20 个属性的 ViewModel,光样板代码就 300 行。
CommunityToolkit.Mvvm 用 C# 的源代码生成器技术,编译时自动生成这些样板。你只需要加特性:
| 属性通知 | 15行重复代码 | 1行特性搞定 |
| 命令绑定 | 实现 ICommand 接口 | 1行特性搞定 |
| 学习曲线 | 低但代码繁琐 | 最低 |
| 运行时反射 | 有 | 无,编译时生成 |
第一步:创建项目 + 安装包
1.1 创建 WPF 项目
打开 Visual Studio 2022 → 新建项目 → WPF 应用程序 → 命名 FillTrack → .NET 8.0。
1.2 安装 NuGet 包
打开程序包管理器控制台,装核心包:
Install-Package CommunityToolkit.Mvvm
后续篇目会陆续装 NModbus4、FreeSql、Serilog、LiveCharts 等,这篇先只装 MVVM 包。
1.3 建立项目目录结构
FillTrack/
├── Models/ # 数据模型(PLC状态、报警、生产记录)
├── Services/ # 业务服务(PLC通信、报警、数据库)
├── ViewModels/ # 视图模型
├── Views/ # 界面
├── UserControls/ # 自定义控件
├── Converters/ # 值转换器
├── Assests/Styles/ # 全局样式
└── App.xaml # 应用入口
这个结构不是随便分的。Models 放纯数据,Services 放业务逻辑,ViewModels 做中间层,Views 只管显示。后面每加一个功能,都知道该往哪个目录放。
第二步:写你的第一个 ViewModel
我们要做的 Dashboard 需要显示这些数据:实际产量、目标产量、当前温度、设定温度、液位。先写一个简化版 ViewModel。
在 ViewModels/ 下新建 DashboardViewModel.cs:
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace FillTrack.ViewModels
{
// 关键1:partial 类,源代码生成器才能工作
// 关键2:继承 ObservableObject,自带属性变更通知
public partial class DashboardViewModel : ObservableObject
{
// 关键3:[ObservableProperty] 自动生成公共属性 + 通知
[ObservableProperty]
private int actualCount;
[ObservableProperty]
private int targetCount;
[ObservableProperty]
private double currentTemp;
[ObservableProperty]
private double settingTemp;
[ObservableProperty]
private double liquidLevel;
// 关键4:[RelayCommand] 自动生成命令属性
[RelayCommand]
private async Task StartProductionAsync()
{
// 后续篇目会在这里调用 PlcService 写 PLC 寄存器
// 现在先空着,跑通骨架
}
[RelayCommand]
private async Task StopProductionAsync()
{
// 停止生产
}
}
}
4 个关键点解读:
① partial class — 必须加。源代码生成器在编译时生成另一半代码(属性定义、命令包装),没有 partial 它无处生成。
② ObservableObject — 基类,内置 INotifyPropertyChanged 实现。继承它,你的属性变更通知就有了基础设施。
③ [ObservableProperty] — 标记在私有字段上。编译器自动生成同名的公共属性(actualCount → ActualCount),赋值时自动触发界面刷新。你不用写一行 OnPropertyChanged。
④ [RelayCommand] — 标记在方法上。编译器自动生成同名命令属性(StartProductionAsync → StartProductionCommand),可直接绑定到按钮的 Command。
注意看属性命名——actualCount、currentTemp、liquidLevel,这些不是随便起的玩具名字,是灌装线上的真实数据语义。后面接 PLC 的时候,这些属性直接对应 Modbus 寄存器。
第三步:写界面 + 绑定
在 Views/ 下新建 DashboardView.xaml,先写一个简化版:
<Window x:Class="FillTrack.Views.DashboardView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="灌装监控" Height="500" Width="700">
<Grid Margin="20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!– 产量卡片 –>
<Border Grid.Row="0" BorderBrush="#3E3E42" BorderThickness="1" CornerRadius="4" Padding="15" Margin="0,0,0,10">
<StackPanel>
<TextBlock Text="实际产量 / 目标产量" FontSize="14" Foreground="#AAAAAA"/>
<StackPanel Orientation="Horizontal" Margin="0,5">
<TextBlock Text="{Binding ActualCount}" FontSize="36" FontWeight="Bold"/>
<TextBlock Text=" / " FontSize="36" Foreground="#AAAAAA"/>
<TextBlock Text="{Binding TargetCount}" FontSize="36" Foreground="#AAAAAA"/>
</StackPanel>
</StackPanel>
</Border>
<!– 温度卡片 –>
<Border Grid.Row="1" BorderBrush="#3E3E42" BorderThickness="1" CornerRadius="4" Padding="15" Margin="0,0,0,10">
<StackPanel>
<TextBlock Text="当前温度 / 设定温度" FontSize="14" Foreground="#AAAAAA"/>
<StackPanel Orientation="Horizontal" Margin="0,5">
<TextBlock Text="{Binding CurrentTemp}" FontSize="36" FontWeight="Bold"/>
<TextBlock Text=" °C / " FontSize="36" Foreground="#AAAAAA"/>
<TextBlock Text="{Binding SettingTemp}" FontSize="36" Foreground="#AAAAAA"/>
<TextBlock Text=" °C" FontSize="36" Foreground="#AAAAAA"/>
</StackPanel>
</StackPanel>
</Border>
<!– 液位卡片 –>
<Border Grid.Row="2" BorderBrush="#3E3E42" BorderThickness="1" CornerRadius="4" Padding="15" Margin="0,0,0,10">
<StackPanel>
<TextBlock Text="原料桶液位" FontSize="14" Foreground="#AAAAAA"/>
<TextBlock Text="{Binding LiquidLevel, StringFormat={}{0:F1}%}"
FontSize="36" FontWeight="Bold" Margin="0,5"/>
</StackPanel>
</Border>
<!– 控制按钮 –>
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center">
<Button Content="启动生产" Command="{Binding StartProductionCommand}" Padding="20,10" Margin="5"/>
<Button Content="停止生产" Command="{Binding StopProductionCommand}" Padding="20,10" Margin="5"/>
</StackPanel>
</Grid>
</Window>
绑定语法解读:
- {Binding ActualCount} — TextBlock 的 Text 绑定到 ViewModel 的 ActualCount 属性。属性变了,界面数字自动更新
- {Binding StartProductionCommand} — Button 的 Command 绑定到 ViewModel 的 StartProductionCommand。点击按钮自动调用 StartProductionAsync 方法
- StringFormat={}{0:F1}% — 液位显示保留一位小数加百分号
注意,XAML 里没有任何逻辑代码。没有 if 判断、没有循环、没有数据库调用。View 只管显示什么,不管数据从哪来。
第四步:连接 View 和 ViewModel
MVVM 最关键的一步——把 View 和 ViewModel 绑在一起。
打开 DashboardView.xaml.cs:
using FillTrack.ViewModels;
using System.Windows;
namespace FillTrack.Views
{
public partial class DashboardView : Window
{
public DashboardView()
{
InitializeComponent();
// 这一行就是 MVVM 的灵魂
DataContext = new DashboardViewModel();
}
}
}
DataContext = new DashboardViewModel() — 设置数据上下文后,XAML 里所有 {Binding xxx} 都会去 ViewModel 里找对应属性和命令。
这是最简单的连接方式。后续篇目会讲用依赖注入容器自动注入 ViewModel,不用手动 new。但骨架阶段先这样,跑通再说。
第五步:运行起来
按 F5 运行,你会看到:
- 三个卡片显示 0 / 0、0 °C / 0 °C、0.0%
- 两个按钮可以点击(命令绑定了,但方法体是空的,点了没反应)
现在数据都是默认值 0。后面接了 PLC 通信,数据会每 200ms 自动刷新——你不需要写一行手动更新 UI 的代码。
这就是 MVVM 的核心价值:数据变了,界面自动变。
进阶:带条件判断的命令
实际项目中,按钮不是永远能点的。比如"设备运行中时禁止再点启动"。
CommunityToolkit.Mvvm 原生支持:
public partial class DashboardViewModel : ObservableObject
{
[ObservableProperty]
private bool isRunning = false;
// CanExecute 方法名 = 命令方法名 + "CanExecute"
[RelayCommand(CanExecute = nameof(CanStart))]
private async Task StartProductionAsync()
{
IsRunning = true;
// 启动生产…
}
private bool CanStart() => !IsRunning;
// 当 IsRunning 变化时,通知命令刷新按钮状态
partial void OnIsRunningChanged(bool value)
{
StartProductionCommand.NotifyCanExecuteChanged();
}
}
XAML 中按钮绑定不变。当 IsRunning = true 时,启动按钮自动变灰禁用。不需要在 XAML 里写任何判断逻辑。
partial void OnIsRunningChanged 是源代码生成器提供的回调——每当 IsRunning 属性被赋值,这个方法会被自动调用。你在里面通知命令刷新状态,按钮就跟着变了。
真实项目长什么样?
上面是骨架版。来看看完整项目的 DashboardViewModel 有多少属性:
// 完整项目:灌装监控 DashboardViewModel
public partial class DashboardViewModel : ObservableObject
{
// === 产量数据 ===
[ObservableProperty] private int actualCount;
[ObservableProperty] private int targetCount;
// === 温度数据 ===
[ObservableProperty] private double currentTemp;
[ObservableProperty] private double settingTemp;
// === 运行数据 ===
[ObservableProperty] private double runningTime;
[ObservableProperty] private double currentCycleTime;
[ObservableProperty] private double standardCycleTime;
// === 液位与阀门 ===
[ObservableProperty] private double liquidLevel;
[ObservableProperty] private bool valueOpen;
// === 状态指示 ===
[ObservableProperty] private LightState indicatorState = LightState.Off;
[ObservableProperty] private string deviceStatus = "自动运行";
// === 图表数据 ===
[ObservableProperty] private SeriesCollection tempLiveCharts;
// === 命令 ===
[RelayCommand] private async Task StartProductionAsync() { /* 写 PLC 启动寄存器 */ }
[RelayCommand] private async Task StopProductionAsync() { /* 写 PLC 停止寄存器 */ }
[RelayCommand] private async Task ResetProductionAsync() { /* 写复位脉冲 */ }
}
十几个属性、三个命令,代码依然清晰可读。每个属性一行特性,不用写任何 OnPropertyChanged。如果用传统手写方式,光这些属性的样板代码就 200 多行。
这就是 CommunityToolkit.Mvvm 的威力——让你把时间花在业务逻辑上,不是样板代码上。
踩坑记录
| 忘了加 partial | 编译报错"找不到 StartProductionCommand" | 源代码生成器需要 partial 类才能注入生成代码 | 类声明必须加 partial |
| 属性名大小写搞错 | XAML 绑定不生效,输出窗口报 BindingExpression 错误 | [ObservableProperty] private int actualCount 生成的是 ActualCount(首字母大写),XAML 里要写 {Binding ActualCount} | 记住:字段驼峰,生成属性帕斯卡 |
| 命令方法返回 void | 异步操作界面卡死 | [RelayCommand] 标记的方法如果是 async Task,会生成异步命令;如果是 void,调用方不会 await | 需要异步操作时用 async Task,不要用 void |
| 在非 UI 线程赋值属性 | 抛 InvalidOperationException"调用线程无法访问此对象" | PLC 轮询在后台线程,直接给 [ObservableProperty] 赋值会触发 UI 线程检查 | 用 Application.Current.Dispatcher.Invoke 切回 UI 线程 |
第四个坑是工业上位机最常见的——PLC 数据在后台线程读上来,直接赋值给绑定属性就炸。后面讲 PlcService 那篇会详细讲怎么处理跨线程问题。
本篇小结
| 属性自动通知 | [ObservableProperty] private int actualCount; |
| 命令自动生成 | [RelayCommand] private async Task StartAsync() |
| 命令条件判断 | [RelayCommand(CanExecute = nameof(CanStart))] |
| 属性变更回调 | partial void OnIsRunningChanged(bool value) |
| 连接 View 和 VM | DataContext = new DashboardViewModel(); |
| XAML 绑定 | {Binding ActualCount} / {Binding StartCommand} |
从一个 1800 行的灾难,到一个清晰的 MVVM 骨架——View 只管显示,ViewModel 只管逻辑,Model 只管数据。数据变了界面自动更新,不用手动刷新。
但这个骨架还缺一个关键部分:ViewModel 是手动 new 的,服务之间互相依赖时怎么办? 比如 DashboardViewModel 需要 PlcService,PlcService 需要配置,配置需要从文件加载。手动 new 会变成依赖地狱。
下期预告
第2篇:依赖注入与服务架构
我们将引入 Microsoft.Extensions.DependencyInjection 容器,把 ViewModel 和 Service 的创建交给 DI 管理。告别手动 new,让依赖关系自动注入,为后续接入 PLC 通信、数据库、报警服务打好架构基础。
网硕互联帮助中心



评论前必须登录!
注册