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—— 单实例锁
IMutex=newMutex(true,"RoboCell_Run_App");if(IMutex.WaitOne(0,false)){base.OnStartup(e);}工控上位机必须有这个。双击两次图标开出两个实例,两个实例同时连 PLC,写入会打架——一个在写"开阀",另一个在写"关阀",PLC 那边收到的顺序完全不可控。
Mutex用命名互斥量,名字带应用标识,跨会话也能拦住。第二个实例直接退出,不给任何提示——现场操作员不需要知道"为什么点不开",他只需要知道点一次能开。
2.CreateShell—— 决定哪个窗口当壳
protectedoverrideWindowCreateShell(){returnContainer.Resolve<MainWindow>();}这个方法当时被当成"调试开关"用了。上面压着五行注释:
//return Container.Resolve<DeviceTestView>(); // 用户控件测试//return Container.Resolve<ChamberTest>(); // 单腔体测试//return Container.Resolve<WaferInfoDisplay>(); // 晶圆信息展示//return Container.Resolve<MainTestWindow>(); // 开发测试容器returnContainer.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 里引用:
<ContentControlprism:RegionManager.RegionName="{x:Static Extensions:PrismManager.MainViewRegionName}"/>好处是改名只改一处、编译期就能发现拼错。
问题是八个区域里有七个用了常量,剩下那个硬编码:
<ContentControlprism:RegionManager.RegionName="DeviceContentRegion"/>后果很实在:后来想统一给区域名加前缀,全局搜索RegionName=的时候漏了这一个,导致那个页面导航进去一片空白,查了半小时。
规则要么全用,要么别用。一半一半比全硬编码更危险,因为你以为有保护。
四、注册策略:什么时候单例,什么时候瞬时
Prism 默认的RegisterForNavigation是每次导航新建实例。这对大部分页面是对的——每次进参数页都想要一份干净的初始状态。
但主流程那个 ViewModel 是例外,它被显式注册成了单例:
containerRegistry.RegisterSingleton<ProcessViewModel>();containerRegistry.RegisterForNavigation<ProcessView,ProcessViewModel>();两行一起写,效果是:导航到ProcessView时不会新建 ViewModel,而是复用同一个。
为什么?因为这个 ViewModel 持有设备当前状态——当前在哪个腔、走到第几步、报警列表是什么。如果每次导航都新建,切走再切回来,状态全丢,界面显示"空闲",而设备实际正在跑工艺。这在现场是要出事的。
代价是它的生命周期和进程一样长,里面任何一个订阅没退订,就会一直挂着。这就是第 27 篇要讲的"资源释放"问题的来源之一。
工厂注册:让容器认识已经存在的对象
ADS 连接和配方名集合是典型的"进程级单例,但必须在启动时才创建":
containerRegistry.RegisterSingleton<AdsHelper>(()=>gAdsHelper);containerRegistry.RegisterSingleton<RecipeNames>(()=>gRecipeNames);gAdsHelper在OnStartup里根据配置文件创建,创建完再让容器登记。这样 ViewModel 构造函数的参数里可以直接写AdsHelper ads,容器能注入,而对象本身的生命周期仍由启动流程控制。
别在 ViewModel 里new AdsHelper()。一旦这么写,容器就管不住它了,测试也没法替换。
踩坑记录
坑一:重复注册不报错,最后一次生效
RegisterTypes里有两个页面各注册了两遍:
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 混用:哪些用源生成器,哪些手写