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 里有二十多行被注释的注册,横跨两年。
危险不在于它们占地方,在于它们会骗人。有一次我以为某个页面没注册,加了一行;结果那行注释其实是"暂时不用",加回去之后导航到了旧界面,功能对不上,白查半天。
后来定规矩:注释掉的注册必须带原因和日期,超过一个迭代没恢复就删。删掉之后文件短了一百多行,可读性立刻不一样。
本篇小结
- 五个工程的引用是一条单向链,Core 不认识业务名词,DbManage 不认识界面。UnitTests 引用 UI 是妥协,代价是编译慢。
- 启动顺序是硬约束 :
OnStartup→CreateShell→RegisterTypes→OnInitialized。首次导航只能写在最后一步之后。 - Region 要么全用常量要么别用,一半一半比全硬编码更容易出事。
- 单例 ViewModel 只给持有设备状态的那个,并且必须自己区分"设备态"和"界面态"。
下一篇解决一个上一篇留下的悬念:Prism 和 CommunityToolkit.Mvvm 两套 MVVM 框架在同一个项目里混用,到底哪些用源生成器、哪些手写,边界在哪。
下期预告
第3篇:CommunityToolkit.Mvvm 与 Prism 混用:哪些用源生成器,哪些手写