从零搭建半导体设备上位机(三):CommunityToolkit.Mvvm 与 Prism 混用,哪些用源生成器,哪些手写
CommunityToolkit.Mvvm 与 Prism 混用:哪些用源生成器,哪些手写
这是「从零搭建半导体设备上位机」系列第3篇。上一篇说主 ViewModel 是单例、注册集中在一个方法里。这一篇处理一个更麻烦的问题:项目里同时存在 Prism 的 MVVM 体系和 CommunityToolkit.Mvvm,到底该怎么分。
同一个项目里,有三套基类
把整个解决方案扫一遍,按"属性通知怎么来的"分类,结果是这样的:
| 做法 | 文件数 |
|---|---|
继承 Prism 的BindableBase | 100 |
用 Prism 的DelegateCommand | 37 |
用[ObservableProperty]源生成器 | 23 |
继承自写基类ViewModel/ViewModelNew | 23 |
用[RelayCommand]源生成器 | 9 |
继承ObservableObject | 3 |
一百个文件用 Prism,九个文件用源生成器。这不是"混用",这是"主干是 Prism,CommunityToolkit 是后来局部接进来的"。
而且中间还夹着两套自写基类:Core/Mvvm/ViewModel.cs(继承ObservableObject)和Shared/Common/ViewModelNew.cs。加上 Prism 的ViewModelBase,项目里一共三套基类。
这一篇不打算说"这样很乱,应该统一"——统一是正确但昂贵的建议,现实里没人有时间重写一百个文件。我想说清楚的是:在必须混用的前提下,哪些地方该用哪一套,以及混用会踩什么坑。
一、先搞清楚两套东西的边界
Prism 提供什么
BindableBase:SetProperty/RaisePropertyChanged,手写属性通知。DelegateCommand:命令,支持ObservesProperty(属性变化时自动重新求值CanExecute)和ObservesCanExecute。IConfirmNavigationRequest:导航前拦截,可以弹"确定离开吗"。INavigationAware:OnNavigatedTo/OnNavigatedFrom。
CommunityToolkit.Mvvm 提供什么
[ObservableProperty]:字段上加特性,编译期生成属性 + 通知。[RelayCommand]:方法上加特性,编译期生成ICommand。ObservableObject/ObservableRecipient:基类。WeakReferenceMessenger:消息总线。
边界在哪里
一句话:Prism 提供的是"框架能力"(导航、区域、对话框),CommunityToolkit 提供的是"写代码的省力工具"(源生成器)。
所以正确的用法不是二选一,而是分层:
- 需要和 Prism 导航/区域协作的 ViewModel→ 继承
BindableBase,命令用DelegateCommand。 - 纯数据模型、DTO、叶子 ViewModel(不参与导航、不弹对话框)→ 用
[ObservableProperty]/[RelayCommand]。
实测下来,这个划分和项目现状基本吻合:23 个用源生成器的文件,绝大多数在Models/和DTO/下,而不是ViewModels/下。
二、源生成器的三个真实限制
用[ObservableProperty]之前,得知道它不能干什么。这三条都是踩过的。
限制一:类必须是partial
publicpartialclassAxisInfoBase:ObservableObject{[ObservableProperty]privatedouble_speed;}忘了写partial,编译器报的是"找不到Speed属性",而不是"你的类不是 partial"。这个错误信息指向 XAML 绑定,很容易往错的方向查。
项目约定:Models/下凡是带[ObservableProperty]的类,类名一律partial,不用想。
限制二:生成属性名和字段名的对应规则容易记错
[ObservableProperty] private double _speed;生成Speed。[ObservableProperty] private double speed;也生成Speed。
但[ObservableProperty] private double _axisSpeed;生成的是AxisSpeed。
规则是"去掉前导下划线,首字母大写"。听上去简单,实际项目里出现过_chAMaxSpeed这种字段——生成出来是ChAMaxSpeed,而 XAML 里写的是CHAMaxSpeed,绑定静默失效,界面数字不动,也不报错。
WPF 的绑定失败是静默的,这是它最坑的地方之一。后来在调试配置里打开了绑定错误输出到 Output 窗口,这类问题才变得可见。
限制三:[RelayCommand]生成的命令名是方法名 + Command
[RelayCommand]privateasyncTaskSaveAsync(){...}生成SaveCommand,不是SaveAsyncCommand。方法名里的Async后缀会被吃掉。
这条踩过一次:XAML 里写Command="{Binding SaveAsyncCommand}",编译通过、运行不报错、按钮点了没反应。查了半小时才发现命令名少了个Async。
三、混用最容易出的事:通知链断裂
比上面三条更隐蔽的问题,是基类混用导致属性通知链断掉。
场景是这样的:某个 ViewModel 继承BindableBase,里面有一个集合属性,集合元素用的是[ObservableProperty]的模型类。
// ViewModel:Prism 体系publicclassPageChamberViewModel:BindableBase{publicObservableCollection<ChamberInfo>Chambers{get;}=new();}// 元素:CommunityToolkit 体系publicpartialclassChamberInfo:ObservableObject{[ObservableProperty]privatedouble_pressure;}这个组合是能工作的——元素自己通知自己,界面上的{Binding Pressure}会更新。
但一旦你想在 ViewModel 里监听元素变化:
chamber.PropertyChanged+=(s,e)=>{/* 汇总处理 */};ObservableObject和BindableBase都实现了INotifyPropertyChanged,事件签名一致,所以这段代码能编译能跑。
问题出在SetProperty的相等性判断上。两套基类的SetProperty实现细节不同:对double的NaN、对-0.0与0.0的比较,行为不完全一致。项目里出现过一次"压力值从 0 变成 -0.0,界面不刷新"的怪事,就是这里。
真正稳妥的做法不是去研究两套实现的差异,而是在边界处统一:跨体系传值的地方,用显式的RaisePropertyChanged,不依赖SetProperty的短路逻辑。
四、那两套自写基类是怎么回事
Core/Mvvm/ViewModel.cs和Shared/Common/ViewModelNew.cs,都是继承ObservableObject的薄封装。
为什么会有两个?因为它们是不同时期加的:
ViewModel:最早的,加了IsBusy和一个统一的Title属性。ViewModelNew:后来发现IsBusy不够用(需要"加载中/保存中/测试中"三种状态),又加了一个。
结果就是现在:有的页面继承BindableBase,有的继承ViewModel,有的继承ViewModelNew,还有的直接继承ObservableObject。
这是典型的技术债,但它的代价比想象中低——因为三套基类都只做属性通知,没有复杂的生命周期逻辑。真正的代价是新人接手时要问三次"我该继承哪个"。
如果要收敛,正确顺序是:先合并两个自写基类(改动小、收益明确),再考虑要不要统一到ObservableObject(改动大、收益一般)。不要一次性推翻 Prism 体系——IConfirmNavigationRequest和DelegateCommand.ObservesProperty这两个能力,CommunityToolkit 没有对等物,换掉的成本远大于收益。
踩坑记录
坑一:绑定失败不报错
前面提过,CHAMaxSpeed写成ChAMaxSpeed之后,界面数字永远不动。查的时候先怀疑了 PLC 没给数据、又怀疑了转换器,最后才发现是属性名。
现在所有 WPF 项目的第一个配置项就是打开绑定诊断:在App.xaml.cs里设置PresentationTraceSources.DataBindingSource的级别,让绑定错误打到 Output。这条配置救过的时间比任何重构都多。
坑二:[RelayCommand]的 CanExecute 不会自动重算
Prism 的DelegateCommand有ObservesProperty,属性变了自动重新求值。[RelayCommand]的CanExecute需要显式调用NotifyCanExecuteChanged()。
有一次按钮该灰的时候没灰,操作员连点三下,把同一个动作下发给了 PLC 三次。工控软件里"按钮该灰没灰"不是体验问题,是安全问题。
后来定规矩:凡是能下发硬件动作的命令,CanExecute必须显式通知,且在方法入口再做一次幂等判断。
坑三:两套 Messenger 都在用
Prism 有IEventAggregator,CommunityToolkit 有WeakReferenceMessenger。项目里两个都在用。
后果是排查"谁收到了这个消息"时要看两个地方。有一次报警消息用WeakReferenceMessenger发,订阅方用的是IEventAggregator,消息发出去没人接,界面上没有报警——但日志里明明记了。
约定:跨模块通信用 Prism 的IEventAggregator(因为它和 Region 生命周期一致),模块内部用直接事件或WeakReferenceMessenger。一个消息只走一条通道。
本篇小结
- 混用的正确姿势是分层,不是二选一:参与导航的 ViewModel 走 Prism,纯数据模型走源生成器。
- 源生成器有三个硬限制:类必须
partial、属性名规则容易记错、[RelayCommand]会吃掉Async后缀。 - 跨体系边界要用显式
RaisePropertyChanged,不要依赖SetProperty的短路判断。 - 两套 Messenger 不能混着发消息,一个消息只走一条通道。
下一篇换到最"重"的那块:那个 526 行的App.xaml.cs,单实例锁、启动流程、两套配置文件,以及开发模式和生产模式行为不一样带来的坑。
下期预告
第4篇:单实例、启动流程与配置文件:App 里那 526 行到底在干什么