云计算百科
云计算领域专业知识百科平台

从零搭建灌装监控系统(一):灌装监控系统设计,从业务场景到MVVM骨架

灌装监控系统设计:从业务场景到MVVM骨架

这是「从零搭建灌装监控系统」系列第1篇。从一个灌装车间的真实需求出发,搞清楚上位机到底要干什么,再手把手搭建一个能跑的MVVM骨架。


一场灾难性的重构

去年有个朋友找我帮忙,他在给一家做饮料灌装的小厂写上位机软件。软件功能不复杂——跟PLC通信读数据、界面上显示产量温度液位、超限了弹报警、生产记录存数据库。

他给我看代码的时候,我差点没绷住。MainWindow.xaml.cs 里塞了 1800多行:串口初始化、Modbus轮询、温度判断、报警弹窗、数据库插入、CSV导出、图表刷新,全在一个文件里。没有分层,没有 ViewModel,所有逻辑都是按钮点击事件里一把梭。

我说你这跑着没问题吧?他说能用是能用,但上周老板要加一个"温度历史曲线"功能,他在那个 1800 行的文件里改了两天,改完一跑发现报警不弹了——因为他动报警逻辑的时候不小心把一个 if 条件改错了。修好报警,产量计数又不对了。

改一个功能,崩三个地方。 这就是没有架构的代价。

问题出在哪?界面代码、业务逻辑、数据访问全揉在一起,互相耦合。你动任何一处,都不知道会影响哪些地方。他需要的不是更多的注释或者更仔细的修改,而是一个让各部分各司其职的架构。

这就是 MVVM 要解决的事。


灌装监控系统到底要干什么?

在写代码之前,先搞清楚我们要做什么。这个系列要构建的系统叫 FillTrack——一个灌装生产线监控上位机。

业务场景

一条典型的饮料灌装线,从原料桶出来,经过灌装工位、封盖、贴标、检重,最后成品出料。整条线的节奏由 PLC 控制——阀门什么时候开、温度维持在多少、流量多大,都是 PLC 在管。

那上位机干什么?上位机不直接控制硬件,而是通过 Modbus 协议跟 PLC 通信,做三件事:

  • 监控 — 实时读取 PLC 的产量、温度、液位、节拍等数据,在界面上显示
  • 报警 — 温度超限、液位过低、通信断开时触发报警,通知操作员
  • 追溯 — 每生产一瓶(条码变化),自动记录生产数据到数据库,支持历史查询和导出
  • 系统架构

    FillTrack 系统架构

    四层结构,数据流单向流动:

    • 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# 的源代码生成器技术,编译时自动生成这些样板。你只需要加特性:

    对比项传统手写CommunityToolkit.Mvvm
    属性通知 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 通信、数据库、报警服务打好架构基础。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 从零搭建灌装监控系统(一):灌装监控系统设计,从业务场景到MVVM骨架
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!