ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

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

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


一个方法写了两百行

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

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

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

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


一、五个工程怎么划边界

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

工程引用谁职责
RoboCell.Core无MVVM 基类、通用命令、可复用的 ViewModel 基类
RoboCell.SharedCore领域模型、枚举、DTO、扩展方法
RoboCell.DbManageSharedDbContext、数据访问
RoboCell.UICore、Shared、DbManageApp、View、ViewModel、用户控件
RoboCell.UnitTestsCore、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里有二十多行被注释的注册,横跨两年。

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

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


本篇小结

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

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


下期预告

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进