项目背景
工业 HMI 往往从少量按钮和状态灯开始,随后不断增加配方、权限、报警、趋势、设备插件和生产追踪。如果所有逻辑都堆在主窗口 ViewModel 中,项目会在功能增长后迅速失去边界。
这份匿名化示例展示一种面向长期维护的 WPF 结构:界面只表达操作意图,应用层编排用例,设备宿主负责通信生命周期,基础设施层保存数据和日志。
系统约束
- 应用需要连续运行,页面切换不能影响设备连接;
- 设备型号可能不同,但应共享启动、停止、复位和状态能力;
- UI 必须在通信超时后仍可响应;
- 配方修改要校验、留痕,并在下发前形成不可变快照;
- 报警确认不等同于故障已经消失。
我的职责
示例职责覆盖解决方案分层、ViewModel 约定、设备插件接口、统一异常处理、数据库迁移和关键页面性能。目标不是追求抽象数量,而是让设备、业务和展示层能够独立测试与替换。
技术架构
Presentation
Views ── ViewModels ── UI State
│
▼
Application
Commands ── Use Cases ── Validation
│
▼
Domain / Devices
DeviceHost ── Capabilities ── State Snapshot
│
▼
Infrastructure
Protocols ── EF Core / SQLite ── Logging
ViewModel 不持有串口、Socket 或数据库上下文。它依赖用例接口,接收为 UI 设计的状态模型。设备层也不知道按钮和页面是否存在,只发布状态与事件。
核心模块
DeviceHost 与 Capability
DeviceHost 管理连接、心跳、状态聚合和停止顺序。具体能力用小接口表达,例如 IMotionCapability、IIoCapability、IRecipeCapability。不支持某项能力的设备不会返回空对象,而是在注册阶段明确其能力集合。
配方工作流
编辑中的配方和已批准配方分开。下发操作使用版本化快照,并记录设备确认值。这样可以区分“数据库保存成功”和“设备已经应用”两个不同事实。
报警模型
报警至少包含代码、来源、严重级别、发生时间、活动状态、确认状态和复位条件。UI 可以过滤和确认,但只有设备状态恢复后,活动报警才真正结束。
技术难点
问题一:后台线程更新 UI
现象
设备事件频繁到达时,界面偶发跨线程异常;直接把每条采样都切回 Dispatcher,又会让消息队列积压。
原因
通信频率与界面刷新频率被当作同一件事。ViewModel 对每个原始事件逐一通知,造成大量属性变更和 UI 布局。
方案
设备层保留最新状态快照,展示层按固定节奏读取或合并变更。只有报警、状态转换等离散事件立即投递到 UI;高频采样采用限频更新。所有 UI 调度集中在一个适配器中,而不是散落在业务代码里。
问题二:异步命令无法正确取消
现象
操作员切换产品后,上一条配方下发任务仍可能继续执行,最终覆盖新状态。
原因
异步命令只防止重复点击,却没有把取消令牌传到协议层,也没有在完成时校验任务版本。
方案
每次业务操作生成操作 ID 和取消源。新操作开始前取消旧操作,协议调用全链路接受令牌;返回结果只有在操作 ID 仍匹配当前上下文时才允许提交到 UI 状态。
数据与迁移
SQLite 保存配方、用户操作记录和追踪索引,EF Core 负责映射与迁移。数据库上下文使用短生命周期,每个用例完成一次明确事务,避免把长生命周期 DbContext 绑定到 ViewModel。
最终结果
最终结构让页面、用例和设备驱动之间只通过明确接口交互。新设备优先实现能力接口,新页面复用应用用例;模拟设备可以覆盖连接失败、超时和异常状态,而不需要启动真实硬件。
项目复盘
MVVM 的价值不在于目录名称,而在于界面生命周期不再决定业务对象的生命周期。真实项目发布时,应补充性能数据、测试范围和本人实际负责的模块,并删除客户与设备敏感信息。