从零搭建半导体设备上位机(二):Prism + DryIoc,五个工程与区域导航怎么串起来

Prism + DryIoc:五个工程与区域导航怎么串起来

这是「从零搭建半导体设备上位机」系列第2篇。上一篇定了三层架构,这一篇把骨架落地:五个工程怎么划边界、App 启动时到底按什么顺序跑、区域导航怎么组织,以及一个 526 行的 App.xaml.cs 里藏着的三个坑。


一个方法写了两百行

项目第一次交给我 review 的时候,我打开 App.xaml.cs,滚了十几屏,全是 RegisterForNavigation。

数了一下:六十多行注册,中间夹着二十多行被注释掉的注册,还有几行是同一个页面注册了两遍。

当时的直觉是"这文件该拆"。但真去拆的时候发现拆不动------不是代码写坏了,是注册这件事天然就该集中在一个地方 。Prism 的 RegisterTypes 是容器唯一的配置入口,把它拆到各个模块里,你就再也说不清"某个 View 到底注册没注册"。

所以后来没拆,而是给它定了三条规矩:注册必须分组、必须有注释说明、注释掉的必须删。这一篇讲的就是这三条规矩背后的东西------五个工程怎么分、导航怎么走、什么该单例什么该瞬时。


一、五个工程怎么划边界

先看引用关系,这是硬约束,写代码时绕不过去:

工程 引用谁 职责
RoboCell.Core 无 MVVM 基类、通用命令、可复用的 ViewModel 基类
RoboCell.Shared Core 领域模型、枚举、DTO、扩展方法
RoboCell.DbManage Shared DbContext、数据访问
RoboCell.UI Core、Shared、DbManage App、View、ViewModel、用户控件
RoboCell.UnitTests Core、Shared、UI 测试

引用是一条单向链:Core ← Shared ← DbManage ← UI。没有循环,也没有跨层。

为什么 Shared 引用 Core,而不是反过来

因为 Core 里放的是 MVVM 基类,它必须对领域模型一无所知。

一开始我把 BaseContainer(带 INotifyPropertyChanged 的基类)放在了 Shared 里,结果 Core 里想写一个通用的 ViewModelBase 就得反过来引用 Shared,立刻成环。

挪到 Core 之后干净了:Core 只依赖 CommunityToolkit.Mvvm 和 Prism.Wpf,不知道什么叫"腔体"、什么叫"配方"。Shared 才认识这些词。

这条规则的判据很简单:如果这个类型出现在"业务名词表"里,它就不该在 Core。

DbManage 为什么只依赖 Shared

数据访问层只认识模型,不认识界面。这条守住了,后面做单元测试才有可能------测试可以直接 new 一个 DbContext,不用拉起整个 WPF 应用。

UnitTests 为什么敢引用 UI

这是唯一"看起来不合规"的引用,但它是故意的。

工控上位机里大量逻辑长在 ViewModel 上(上一篇提过,业务层塌陷了)。要测这些逻辑,测试工程必须能拿到 ViewModel 类型。引用 UI 工程是最省事的办法。

代价是测试会连带编译整个 UI------219 个 XAML 一起编,慢。但比起"为了可测性重构一遍",这个代价当时是划算的。

如果重来一次,我会先划出 Service 层再写测试。这一点在第 21-23 篇讲业务核心时会正面处理。


二、启动顺序:App 里的五个重写点

Prism 应用的启动不是"new 一个 Window 就完事",它有一套固定顺序。搞不清这个顺序,会出现"容器里没注册""Region 还没创建就去导航"这类玄学问题。

顺序是这样的:

1. OnStartup ------ 单实例锁

csharp 复制代码
IMutex = new Mutex(true, "RoboCell_Run_App");
if (IMutex.WaitOne(0, false))
{
    base.OnStartup(e);
}

工控上位机必须有这个。双击两次图标开出两个实例,两个实例同时连 PLC,写入会打架------一个在写"开阀",另一个在写"关阀",PLC 那边收到的顺序完全不可控。

Mutex 用命名互斥量,名字带应用标识,跨会话也能拦住。第二个实例直接退出,不给任何提示------现场操作员不需要知道"为什么点不开",他只需要知道点一次能开。

2. CreateShell ------ 决定哪个窗口当壳

csharp 复制代码
protected override Window CreateShell()
{
    return Container.Resolve<MainWindow>();
}

这个方法当时被当成"调试开关"用了。上面压着五行注释:

csharp 复制代码
//return Container.Resolve<DeviceTestView>();   // 用户控件测试
//return Container.Resolve<ChamberTest>();      // 单腔体测试
//return Container.Resolve<WaferInfoDisplay>(); // 晶圆信息展示
//return Container.Resolve<MainTestWindow>();   // 开发测试容器
return Container.Resolve<MainWindow>();          // 正式入口

改一行、F5,就能单独调某个界面,不用每次都走完整登录和初始化流程。这个习惯一直保留到最后------它是这套代码里性价比最高的调试手段。

代价是这五行注释谁也不敢删。后来约定:正式发布前统一检查,只留最后一行。

3. RegisterTypes ------ 容器配置

这是那个两百行的方法。分三段:

  • 导航注册 :RegisterForNavigation<View, ViewModel>(),决定"哪些页面可以被导航到"。
  • 单例注册 :RegisterSingleton<T>(),决定"哪些对象全局只有一份"。
  • 工厂注册 :RegisterSingleton<T>(() => instance),用于"已经在外面 new 好了,只是让容器认识它"。

4. OnInitialized ------ 壳创建之后

这时候 Shell 已经显示,Region 已经就位,可以安全地做首次导航。把首次导航写在 RegisterTypes 里是错的------那时 Region 还不存在。

5. OnExit ------ 释放

关 Mutex、关 ADS 连接、flush 日志。顺序不能反:先断连接再关日志,否则最后一批日志写不进去。


三、区域导航:8 个 Region 怎么组织

Prism 的 Region 是"可以往里塞视图的占位符"。这个项目用了 8 个:

Region 常量 用途
MainViewRegionName 主内容区,几乎所有页面都导航到这里
CalibrationViewRegionName 标定页内的子区域
LogRecordViewRegionName 日志页内的子区域
ParameterSettingViewRegionName 参数设置页内的子区域
RecipeOrSequenceViewRegionName 配方/流程页内的子区域
RecipeViewRegionName 配方管理页内的子区域
DeviceContentRegion 设备测试页的内容区(唯一一个硬编码的)

用法是嵌套的:主窗口里放一个 MainViewRegionName,导航到"参数设置页";参数设置页自己内部又有一个 ParameterSettingViewRegionName,再往里导航"腔体A参数""腔体B参数"。这样每个页面都能独立演进,主窗口不用知道下面有几层。

常量类是个好东西,但只做了一半

Region 名放在 PrismManager 静态类里,用 {x:Static} 在 XAML 里引用:

xml 复制代码
<ContentControl prism:RegionManager.RegionName="{x:Static Extensions:PrismManager.MainViewRegionName}" />

好处是改名只改一处、编译期就能发现拼错。

问题是八个区域里有七个用了常量,剩下那个硬编码:

xml 复制代码
<ContentControl prism:RegionManager.RegionName="DeviceContentRegion" />

后果很实在:后来想统一给区域名加前缀,全局搜索 RegionName= 的时候漏了这一个,导致那个页面导航进去一片空白,查了半小时。

规则要么全用,要么别用。 一半一半比全硬编码更危险,因为你以为有保护。


四、注册策略:什么时候单例,什么时候瞬时

Prism 默认的 RegisterForNavigation 是每次导航新建实例。这对大部分页面是对的------每次进参数页都想要一份干净的初始状态。

但主流程那个 ViewModel 是例外,它被显式注册成了单例:

csharp 复制代码
containerRegistry.RegisterSingleton<ProcessViewModel>();
containerRegistry.RegisterForNavigation<ProcessView, ProcessViewModel>();

两行一起写,效果是:导航到 ProcessView 时不会新建 ViewModel,而是复用同一个。

为什么?因为这个 ViewModel 持有设备当前状态------当前在哪个腔、走到第几步、报警列表是什么。如果每次导航都新建,切走再切回来,状态全丢,界面显示"空闲",而设备实际正在跑工艺。这在现场是要出事的。

代价是它的生命周期和进程一样长,里面任何一个订阅没退订,就会一直挂着。这就是第 27 篇要讲的"资源释放"问题的来源之一。

工厂注册:让容器认识已经存在的对象

ADS 连接和配方名集合是典型的"进程级单例,但必须在启动时才创建":

csharp 复制代码
containerRegistry.RegisterSingleton<AdsHelper>(() => gAdsHelper);
containerRegistry.RegisterSingleton<RecipeNames>(() => gRecipeNames);

gAdsHelper 在 OnStartup 里根据配置文件创建,创建完再让容器登记。这样 ViewModel 构造函数的参数里可以直接写 AdsHelper ads,容器能注入,而对象本身的生命周期仍由启动流程控制。

别在 ViewModel 里 new AdsHelper()。一旦这么写,容器就管不住它了,测试也没法替换。


踩坑记录

坑一:重复注册不报错,最后一次生效

RegisterTypes 里有两个页面各注册了两遍:

csharp 复制代码
containerRegistry.RegisterForNavigation<PageCalibration, PageCalibrationViewModel>();
// ... 十几行之后
containerRegistry.RegisterForNavigation<PageCalibration, PageCalibrationViewModel>();

Prism + DryIoc 对重复注册不抛异常,后一次覆盖前一次。表面看没影响,直到有一次我在中间那段改了 ViewModel 的构造参数,改的是一处,生效的是另一处,调试器里断点死活不进。

排查方式很土:把 RegisterTypes 里所有注册行提取出来排序,肉眼找重复。后来写了个小脚本做这件事------注册超过五十行之后,去重必须自动化。

坑二:单例 ViewModel + 导航,状态会跨页面残留

主 ViewModel 是单例,于是出现过一个诡异现象:从"参数设置页"返回主页面,参数页上勾选的某个调试开关还在生效。

原因不是参数页没清,是主 ViewModel 单例里缓存了一份参数页的状态。导航回来时它没重新初始化。

修法不是改成瞬时(那样会丢设备状态),而是在 OnNavigatedTo 里显式重置界面相关字段。单例对象必须自己管好"哪些字段是设备状态(保留)、哪些是界面状态(重置)"。

这条经验后来写进了团队的 Code Review 清单:看到 RegisterSingleton<XxxViewModel> 就问一句,它的字段分不分设备态和界面态。

坑三:注释掉的注册堆成山

RegisterTypes 里有二十多行被注释的注册,横跨两年。

危险不在于它们占地方,在于它们会骗人。有一次我以为某个页面没注册,加了一行;结果那行注释其实是"暂时不用",加回去之后导航到了旧界面,功能对不上,白查半天。

后来定规矩:注释掉的注册必须带原因和日期,超过一个迭代没恢复就删。删掉之后文件短了一百多行,可读性立刻不一样。


本篇小结

  1. 五个工程的引用是一条单向链,Core 不认识业务名词,DbManage 不认识界面。UnitTests 引用 UI 是妥协,代价是编译慢。
  2. 启动顺序是硬约束 :OnStartup → CreateShell → RegisterTypes → OnInitialized。首次导航只能写在最后一步之后。
  3. Region 要么全用常量要么别用,一半一半比全硬编码更容易出事。
  4. 单例 ViewModel 只给持有设备状态的那个,并且必须自己区分"设备态"和"界面态"。

下一篇解决一个上一篇留下的悬念:Prism 和 CommunityToolkit.Mvvm 两套 MVVM 框架在同一个项目里混用,到底哪些用源生成器、哪些手写,边界在哪。


下期预告

第3篇:CommunityToolkit.Mvvm 与 Prism 混用:哪些用源生成器,哪些手写

相关推荐
一条闲鱼_mytube3 小时前
分布式 ID 生成方案:雪花算法与号段模式
分布式·算法·wpf
她说彩礼65万4 小时前
WPF Prism代码走读
wpf
光泽雨3 天前
动画生命周期
c#·wpf
nap1174 天前
WPF 中常用的 LINQ 语句
wpf
做个文艺程序员7 天前
MQ第12篇:AI原生消息架构演进:从MQ到事件驱动的全链路设计
架构·消息队列·wpf·事件驱动架构·ai-native·cqrs
donk13149 天前
React Native for OpenHarmony 实战:三方库 react-native-circular-action-menu 的鸿蒙化适配指南
javascript·分布式·react native·react.js·wpf·harmonyos
donk131410 天前
React Native for OpenHarmony 实战:三方库 react-native-step-indicator 的鸿蒙化适配指南
分布式·react native·react.js·wpf·harmonyos
He BianGu10 天前
【笔记】WPF中RenderOptions类的详细功能
笔记·wpf