资讯动态

WPF Prism全特性实战:从MVVM到模块化架构设计

发布时间:2026/10/14 4:36:58 来源:尧图企业网站定制
搞了几年 WPF我陆陆续续用 Prism 搭过好几套内部项目。最近把一套老系统的壳重写了一遍顺手把所有常用特性都过了一遍做了一个相对完整的全特性 Demo这里把整个设计和踩坑记录整理一下给准备上手 Prism 或想系统补一遍特性的朋友做个参考。无论你是刚接触 MVVM 框架、想搞清楚依赖注入到底怎么用还是已经在用 Prism 但某些模块比如模块化、导航、事件聚合器用得不太顺手这篇文章都适合你。我会把整个项目的架构思路、核心代码、关键配置逐步展开重点解释“为什么这么做”而不是贴一堆代码让你自己琢磨。1. 项目全景这套“全特性”到底全在哪1.1 为什么是 Prism而不是手写 MVVM很多 WPF 项目从简单的数据展示开始挺多人会自己写 ViewModelBase 和命令封装。刚开始还行等项目膨胀到十几个窗口、几百个依赖关系问题就来了ViewModel 的构造函数里到处是 new界面跳转要自己维护窗口实例模块之间一改就牵一发动全身。手写框架的痛点是“能跑”和“好维护”之间差着一整层架构设计。Prism 这套框架强在它不是一个单纯的 MVVM 工具包它把 WPF 开发里最容易被写乱的几个环节都规范化了依赖注入对象不是你 new 出来的是容器给的接口和实现彻底解耦。模块化一个大型应用可以拆成多个独立模块各自负责自己的界面和逻辑外壳只负责加载。导航基于 URI 的视图切换机制把页面跳转做成了类似 Web 路由的方式支持传参和返回。区域页面上的“插槽”不同模块往同一块区域注册视图视图互不感知。事件聚合器模块之间的通信不用互相引用发消息和收消息完全解耦。所以选择 Prism 的理由很简单它把架构约束前置了。你按它的规则写代码风格自然统一新人接手成本低。这个项目里我没有把每个特性当独立 Demo 堆一起而是模拟了一个常见的“主界面 两个业务模块 一个公共基础设施模块”的系统让每个特性都在真实协作场景里发挥作用。1.2 特性全景清单与模块划分我整理的这套项目覆盖了 Prism 9 里几乎全部常用能力具体分成六个维度维度核心能力本项目中的应用位置MVVM 基础设施BindableBase、DelegateCommand、属性验证所有 ViewModel 基类依赖注入容器注册、构造器注入、接口映射全局服务注册模块化IModule、模块目录、按需加载业务模块 A / 模块 B导航体系NavigationService、导航参数、NavigationJournal主界面页面切换、Tab 流程区域与视图注册RegionManager、RegisterViewWithRegion主内容区、功能区插槽事件聚合器PubSubEvent、订阅令牌、过滤器模块间状态联动模块划分上我做了三个工程一个壳工程负责应用启动、主窗口外壳、全局服务注册一个公共模块提供日志、消息提示等公共服务两个业务模块一个负责数据列表和详情一个负责流程流转内容。这样的好处是壳工程本身不依赖任何业务模块业务模块之间也不互相引用所有通信都走接口和事件后面细说。2. 壳工程搭建先把骨架支起来2.1 环境准备与依赖版本选择做这个项目我用的是 .NET 8 和 Prism 9。安装依赖只需要在壳工程里装三个包Prism.DryIocPrism 默认推荐 DryIoc 容器轻量且性能好。也可以选 Prism.UnityUnity 是老牌容器资料多但项目里既然没有历史包袱我直接用 DryIoc。版本选择有一点要注意Prism 8 和 Prism 9 的 API 基本一致但 9 代开始全面支持 .NET 8对 AOT 和 trimming 的兼容也更好。如果你还在 .NET Framework 4.x 上就得用 Prism 7.x那时候的写法是继承 PrismApplication 类而不是 PrismBootstrapper老教程里的 Bootstrapper 已经废弃了看资料时要注意区分。2.2 启动流程与窗口外壳Prism 应用启动时入口不是 MainWindow而是 App.xaml 里的 App 类。继承 PrismApplication 以后整个启动过程被拆成几个虚方法各自有明确职责RegisterTypes注册全局服务、导航页面。CreateShell创建主窗口实例。ConfigureModuleCatalog告诉 Prism 系统里有哪几个模块可以加载。InitializeShell主窗口初始化并做初始导航。App.xaml 里不需要设置 StartupUri因为窗口是容器创建的不是 XAML 直接 new 出来的。创建主窗口的代码是走容器的这样主窗口的构造函数可以注入 RegionManager 等服务不用自己手动拼依赖。主窗口外壳我用一个很经典的布局顶部是功能区左侧是导航区中间主内容区。这三个区域都用 ContentControl 加上 RegionName 标注分别叫 RibbonRegion、NavRegion、ContentRegion。壳本身只提供框架不关心每个区域里最终放什么视图由模块往里注册。2.3 命名约定与自动绑定机制Prism 里最舒服的机制是 ViewModelLocator。只要遵守命名规范页面和 ViewModel 会自动绑定不用写任何代码。约定是视图叫 ViewAViewModel 就叫 ViewAViewModel放在同名命名空间下即可。这个自动绑定依赖的是 Prism 的 ViewModelLocationProvider它在视图创建后去容器里解析对应的 ViewModel然后赋给 DataContext。壳工程里我习惯在 App 初始化时对默认约定做一个显式声明顺便讲一下我加的一个小技巧当某个页面确实不需要 ViewModel、或者想用其他方式构造时用 ViewModelLocationProvider.SetDefaultViewTypeToViewModelTypeResolver 做全局兜底映射避免所有页面都靠在类名上猜。3. 六个核心特性逐一拆开看3.1 属性通知与命令写 ViewModel 的基本功这个部分很多人会以为太基础直接跳过但 Prism 的 BindableBase 和 DelegateCommand 有几个细节会影响后续所有代码的正确性。BindableBase 核心是 SetProperty 方法它帮你完成 INotifyPropertyChanged 的触发逻辑。重新赋同一个值时不触发通知、只在值变化时触发这是数据绑定的基础性能保障。另外 Prism 9 里 SetProperty 有带验证回调的重载比如 SetProperty(ref _name, value, () ValidateName())这个我在数据验证那节再用。DelegateCommand 比标准 ICommand 多做了一件事它能把 UI 操作安全地带回 UI 线程同时支持异步操作。异步命令是我们在业务代码里用到最多的比如点击按钮后要请求数据、处理耗时逻辑直接写 DoWork() 会在 UI 线程上阻塞。Prism 提供的 DelegateCommand 在 Execute 里会通过 Dispatcher 调度我再配合 AsyncDelegateCommand 处理需要 await 的逻辑public class MainViewModel : BindableBase { private string _statusText; public string StatusText { get _statusText; set SetProperty(ref _statusText, value); } private DelegateCommand _loadDataCommand; public DelegateCommand LoadDataCommand _loadDataCommand ?? new DelegateCommand(async () { StatusText 加载中...; await Task.Delay(1000); StatusText 加载完成; }); public MainViewModel() { _loadDataCommand new DelegateCommand(ExecuteLoad); } private void ExecuteLoad() { // 如果 LoadDataCommand 绑定的按钮还有 CanExecute 相关交互用这个重载更合适 } }有一点值得强调命令的 CanExecute 在参数变化后不会自动刷新所以需要手动调用 RaiseCanExecuteChanged。最典型的场景是主界面上的“保存”按钮需要表单校验通过才能点。我这边是通过属性 setter 里调用命令的 RaiseCanExecuteChanged 来主动唤醒界面状态而不是让界面轮询这种方式更可控。另外绑定命令时 XAML 里的 CommandParameter 传参类型要跟 DelegateCommand 的泛型参数一致否则会一直执行不了。我踩过一次命令参数定义成 object但 XAML 里传的是字符串命令永远 CanExecute 为 false界面上按钮看起来就像坏了一样。3.2 依赖注入从 new 到构造器注入Prism 的容器让每个 ViewModel 的构造函数变成“我要什么就给什么”的声明式写法。注册服务时用 RegisterTypes 方法接口映射到实现protected override void RegisterTypes(IContainerRegistry containerRegistry) { containerRegistry.RegisterIDataService, DataService(); containerRegistry.RegisterSingletonILogService, LogService(); containerRegistry.RegisterForNavigationMainPage(MainPage); containerRegistry.RegisterForNavigationDetailPage(DetailPage); containerRegistry.RegisterDialogConfirmDialogView, ConfirmDialogViewModel(); }构造器注入的深层价值在于你一眼就能看出一个 ViewModel 依赖哪些服务模块之间的依赖关系从代码结构上就是显式的比到处 new 一堆 Helper 类不知道清晰多少。业务模块里我让两个模块的 ViewModel 都依赖公共模块定义的接口public class ModuleAViewModel : BindableBase { private readonly IDataService _dataService; private readonly IEventAggregator _eventAggregator; public ModuleAViewModel(IDataService dataService, IEventAggregator eventAggregator) { _dataService dataService; _eventAggregator eventAggregator; } }注册生命周期这里很有讲究。数据服务一般用 Register每次解析拿新实例日志服务用 RegisterSingleton全应用共用一个页面导航注册用 RegisterForNavigationPrism 会把它注册成瞬态每次导航创建新页面实例这其实是默认行为但也别混淆 RegisterForNavigation 和 Register 的区别。模块化场景里模块内部服务不需要暴露到全局时就在模块的 RegisterTypes 里注册容器会按作用域隔离。依赖注入还有一个常见误解以为所有对象都必须通过容器创建。实际上有些实体类、事件参数类根本不需要注册直接用 new 构造就行了。用不用容器判断标准是“这个对象是否承载了稳定的行为职责”纯数据 DTO 就别往容器里塞。3.3 模块化把大系统拆成可以独立演进的积木模块化是我在这个项目里最受益的部分。最初业务方给的需求是“两个业务模块可能单独迭代、单独发布”如果放在同一个壳工程里两边改代码会不断互相污染每次发布都牵一发动全身。用 IModule 拆完以后壳工程两年没动过业务代码都是在自己模块里改完直接集成。一个模块的核心是 IModule 接口两个方法public class ModuleA : IModule { public void RegisterTypes(IContainerRegistry containerRegistry) { containerRegistry.RegisterForNavigationModuleAView(ModuleAView); } public void OnInitialized(IContainerProvider containerProvider) { var regionManager containerProvider.ResolveIRegionManager(); regionManager.RegisterViewWithRegion(NavRegion, typeof(ModuleANavView)); } }RegisterTypes 里注册模块内部需要的服务OnInitialized 里一般做两件事往区域注册视图、或者发布一些初始事件。值得注意的是模块的注册顺序由 ModuleCatalog 决定Prism 默认按程序集扫描顺序加载如果你有严格的加载顺序要求可以通过模块依赖属性标注但这个项目里没有这个需求。壳工程发现模块的方式有三种代码注册、XAML 注册、配置文件注册。最省心的是在 App 的 ConfigureModuleCatalog 里直接 AddModuleprotected override void ConfigureModuleCatalog(IModuleCatalog moduleCatalog) { moduleCatalog.AddModuleModuleAModule(); moduleCatalog.AddModuleModuleBModule(); }这种方式要求壳工程引用业务模块程序集但 Prism 也支持按目录动态加载模块程序集。用 Prism.Modularity 的 DirectoryModuleCatalog 可以从指定文件夹加载 dll适合做插件式系统。不过调试时动态加载的问题比较隐蔽模块程序的依赖版本冲突会被封装在模块目录内部不直接暴露排查很麻烦所以小中型项目直接代码引用更省心。模块间通信我一直坚持两条原则模块 A 想用模块 B 的界面绝不直接 new 模块 B 的视图而是通过导航 URI 跳过去或者通过事件聚合器发消息让模块 B 里的视图自行响应。这样模块 B 以后怎么变模块 A 完全不在乎。3.4 导航像浏览器一样管理页面导航是 Prism 里最重的一块能力。导航的核心思路是界面跳转不直接操纵窗口对象而是通过 NavigationService 发起一个带目标标识的导航请求由区域来承载页面展示。首先视图在容器里注册containerRegistry.RegisterForNavigationModuleAView(ModuleAView);然后在 ViewModel 里注入 INavigationAware 相关的导航服务发起跳转_navigationService.NavigateAsync(ModuleAView);如果需要传参var parameters new NavigationParameters { { id, 1024 }, { name, 示例记录 }, }; _navigationService.NavigateAsync(ModuleAView, parameters);目标页面的 ViewModel 实现 INavigationAware 接口来接收参数和感知导航生命周期public class ModuleAViewModel : BindableBase, INavigationAware { public void OnNavigatedTo(NavigationContext navigationContext) { var id navigationContext.Parameters.GetValueint(id); var name navigationContext.Parameters.GetValuestring(name); LoadDetail(id, name); } public void OnNavigatedFrom(NavigationContext navigationContext) { // 离开页面时的收尾工作 } public bool IsNavigationTarget(NavigationContext navigationContext) { return true; } }生命周期这里有个最常见的坑OnNavigatedTo 只在导航发生时调用一次但页面通过区域切换时如果用的是同一个视图实例重复导航到同一 URI 不一定触发重新加载。IsNavigationTarget 返回 true 表示导航系统可以复用当前实例如果每次都想拿到新数据可能需要返回 false。我项目里详情页是每次进入都重新加载的所以我返回 false让导航系统每次创建新实例配合构造函数注入新数据服务。导航记录的返回支持也很实用NavigationService 内部有 NavigationJournal可以 GoBack 和 GoForward。不过在模块化系统里我不太建议依赖复杂的返回堆栈管理推荐用导航链路上的业务状态机来控制页面流转否则用户乱点返回按钮容易进入奇怪状态。3.5 区域与视图发现页面上的插槽机制区域是 Prism 界面布局的核心抽象。一个 ContentControl 一旦被标记为区域它就变成了一个可被外部填充的插槽。区域背后有一套 RegionAdapter 机制在支撑Prism 默认支持 ContentControl、ItemsControl、Selector 等常见容器每种控件的区域行为略有差异。拿项目主界面的主内容区举例ContentControl prism:RegionManager.RegionNameContentRegion /模块在初始化时注册视图到该区域regionManager.RegisterViewWithRegion(ContentRegion, typeof(ModuleAView));这样当 APP 启动后区域就会自动把 ModuleAView 放进去显示。这种方式的优点是视图和区域的耦合降到最低壳工程不知道业务模块的视图类型模块挂载进来就自动填充。注册视图还有另一种方式是导航式填充先定义区域然后用 NavigationService 导航到目标视图视图会抢占区域。我项目中功能区用了 RegisterViewWithRegion静态注册主内容区用了导航式填充动态切换两者各有适用场景。区域的一个隐蔽问题是同名区域的冲突。如果你在壳工程里定义 ContentRegion某个模块里又写了一个同名的 ContentRegion区域管理器会直接报错。排查这类问题最快的方法是看输出窗口里的区域管理器日志它能列出当前所有已注册区域的名字和宿主控件。我平时写复杂界面时会主动给区域起带业务语义的名字比如 ContentRegion、NavRegion、RibbonRegion避免重名。3.6 事件聚合器模块间的“消息广播站”事件聚合器是模块解耦通信的利器。它的用法是先定义一个事件类这个类继承 PubSubEvent泛型参数是消息负载类型public class RefreshListMessage : PubSubEventstring { }发送方通过 IEventAggregator 拿到事件实例并发布_eventAggregator.GetEventRefreshListMessage().Publish(DatasetChanged);接收方在构造函数或者 OnInitialized 里订阅_eventAggregator.GetEventRefreshListMessage().Subscribe(OnRefreshList, ThreadOption.UIThread, false, msg msg DatasetChanged);订阅方法的四个参数很有意思第一个参数是处理函数。ThreadOption.UIThread 保证回调在 UI 线程执行涉及界面刷新时必须用。keepSubscriberReferenceAlive 默认 false表示事件引用是弱引用防止订阅者造成内存泄漏。如果传 true则订阅者一直存活需要手动退订。第四个参数是过滤器可以精确控制只处理特定消息。我在业务模块之间传“数据更新了你刷新列表吧”这类状态变化时用的就是这个。对比传统做法里模块 A 直接调用模块 B 的公开方法事件方式有一个明显优势模块 A 不需要知道模块 B 的存在两个模块可以独立编译、独立测试。但事件聚合器也不是万能药。消息满天飞的时候代码阅读和调试会变得很难受。实际开发中我会给事件起非常明确的名字比如 DataSavedMessage、UserLoggedInMessage并且事件参数的命名也尽量语义化让订阅处不用去看发布方代码就知道消息含义。发布频率高的数据流不推荐走事件聚合器那更应该用响应式流或者直接回调。4. 容易被忽略的高级特性4.1 对话框服务弹窗也不再手动创建传统 WPF 里弹窗都是 new 一个 Window 再 ShowDialog这会导致弹窗的 DataContext、依赖注入全部要手动处理。Prism 的 IDialogService 把这个流程规范化了。注册对话框跟注册页面类似containerRegistry.RegisterDialogConfirmDialogView, ConfirmDialogViewModel();调用时不 new 窗口而是通过服务弹出var parameters new DialogParameters { { title, 请确认 }, { content, 确认要删除这条记录吗 } }; _dialogService.ShowDialog(ConfirmDialog, parameters, callback { if (callback.Result ButtonResult.OK) { // 用户点击了确定 } });弹窗的 ViewModel 要实现 IDialogAwarepublic class ConfirmDialogViewModel : BindableBase, IDialogAware { public string Title { get; set; } public DialogParameters DialogParameters { get; set; } public event Action IDialogAware.RequestClose; public bool CanCloseDialog() true; public void OnDialogOpened(IDialogParameters parameters) { Title parameters.GetValuestring(title); } private DelegateCommand _confirmCommand; public DelegateCommand ConfirmCommand _confirmCommand ?? new DelegateCommand(() { RequestClose?.Invoke(new DialogResult(ButtonResult.OK)); }); }这里比较关键的是 RequestClose 事件这是 Prism 对话框关闭的机制。你想让弹窗关闭不能直接 this.Close()因为 Prism 管理着弹出窗口的生命周期。通过触发 RequestClose 事件窗口宿主才能正确回收资源。我第一次用的时候直接在命令里调 Close 方法弹窗虽然关了但孤立窗口的引用没有被释放后台残留了怪物窗口。DialogParameters 和 NavigationParameters 一样本质上都是键值对参数体支持复杂对象。注意 DialogParameters 是一个独立类型不要混淆成 NavigationParameters虽然用起来很像但接口类型不同。4.2 数据验证把校验逻辑从代码后置里解脱出来表格表单类界面校验逻辑很容易散落在各处。我在项目里用的是 Prism 8 之后提供的 BindableBase 扩展校验能力核心是 INotifyDataErrorInfo 的封装。ViewModel 里做字段级验证private string _phone; public string Phone { get _phone; set { SetProperty(ref _phone, value); ValidatePhone(); } } private void ValidatePhone() { if (!Regex.IsMatch(Phone, ^1[3-9]\d{9}$)) { SetErrors(() Phone, new[] { 手机号格式不正确 }); } else { ClearErrors(() Phone); } }优点很明显每个字段校验逻辑写在字段自己的 setter 里和 UI 层完全解耦。比较遗憾的是 Prism 对 INotifyDataErrorInfo 的封装对集合类型支持不够直接复杂模型校验比如整个表单的跨字段校验还是需要手写一个表单模型类来承载这个我在项目里专门封装了一个 ValidatableObject 类来处理。4.3 日志服务与全局异常处理传统 WPF 项目里日志是可有可无的但综合项目一旦跑起来日志就成了排查问题的重要抓手。我在公共模块里定义了 ILogService 接口壳工程注册一个文件日志实现业务模块的 ViewModel 通过构造器注入写日志。全局异常处理推荐在 App 里挂两个钩子AppDomain.CurrentDomain.UnhandledException 捕获托管异常DispatcherUnhandledException 捕获 UI 线程异常。日志里记录异常堆栈、消息、发生时间再配合 IDialogService 弹窗提示用户“系统出错请联系管理员”。注意别把详细异常堆栈弹给用户一是信息过于敏感二是普通用户根本看不懂只会更焦虑。5. 实战中绕不开的那些坑5.1 导航到同一页面却不刷新这个前面简单提过再展开一下。Prism 的导航系统默认复用已存在的视图实例如果 IsNavigationTarget 返回 true 并且区域里已经有这个视图导航系统会选择复用而不重新创建。这本身是性能优化但业务上每次进入详情页理应重新拉数据于是数据总是不更新。我的处理方式是在 OnNavigatedTo 里强制刷新数据或者把 IsNavigationTarget 返回 false。后者的代价是每次导航都新创建页面频繁切换会积累内存需要配合区域的视图清理机制。实际项目中我一般用前一种刷新数据放在 OnNavigatedTo 里让复用机制保留状态但具体数据加载逻辑总是重置。5.2 区域视图注册了却不显示这类问题十有八九是区域名拼错了或者区域宿主控件忘了是不是真正的区域。有个判断技巧ContentControl 上如果设置了 RegionName但视图没显示先检查输出窗口里区域管理器日志有没有报“No region adapter found”或者“View does not implement IRegionMemberLifetime”。另外别忘了每个模块的 OnInitialized 执行时机。模块是在 Shell 初始化完成后才被加载的如果你的 RegisterViewWithRegion 代码写在模块构造函数里此时区域可能还没准备好注册自然无效。5.3 ViewModelLocator 绑定不上Hmm我遇到过挺多次视图已经显示但 DataContext 是空的情况。先确认命名空间一致ViewModel 必须和 View 在同一个命名空间或者子命名空间下名字必须是 xxxViewModel 对应 xxxView默认约定。其次如果这个页面不是通过导航或区域注册创建的而是直接 new 出来的自动绑定机制不会生效需要手动 new ViewModel 或者用 ViewModelLocator 显式绑一下。还有一个隐蔽场景同一个视图既被导航使用又被对话框服务使用。对话框服务创建的视图和导航创建的视图走不同的创建路径如果你在对话框里发现 ViewModelLocator 没绑上检查是不是忘了 RegisterDialog注册后对话框也是容器创建的自动绑定应该同样生效。5.4 事件订阅导致的内存泄漏事件聚合器最危险的地方在于订阅者忘记退订。使用弱引用订阅时keepSubscriberReferenceAlive 默认 false一般不会泄漏但如果你传了 true就必须在页面关闭时主动退订。我的习惯是在 OnNavigatedFrom 里把订阅令牌保存起来页面退出时做统一清理private SubscriptionToken _token; protected override void OnNavigatedFrom(NavigationContext navigationContext) { _token?.Dispose(); base.OnNavigatedFrom(navigationContext); }还有个容易忽略的点事件回调里如果操作了 ViewModel 的 UI 属性但事件在后台线程触发界面会崩。订阅时一定记得用 ThreadOption.UIThread或者回调里自行 Dispatcher 调度。不崩也不代表安全潜在线程问题经常是偶发的最烦人。5.5 设计时数据与容器的冲突XAML 设计器里预览数据时Prism 的容器参与不进来很多 ViewModel 构造函数注入的服务在编码时是没有实例的。设计时数据我做了一个专门的方案定义设计时 ViewModel 类通过 d:DataContext 指定设计时实例。这样设计器里能看到假的列表数据运行时走真实容器两套不干扰。要注意 d:DataContext 不会影响运行时的 DataContext运行时的 DataContext 仍然由 ViewModelLocator 自动赋值所以这个方案完全安全。6. 项目扩展方向与最终体会这套全特性项目做完以后我又在多处做了扩展尝试。如果你也想拿它当模板继续加深我觉得可以往这几个方向走接入实时数据流比如 SignalR 推送、做一组真正有业务含义的模块、把日志系统换成持久化数据库存储、给导航加一个面包屑控件展示当前路径。最后说点个人体会。Prism 全特性项目真正的学习门槛其实不在单个特性的用法而在于理解这些特性为什么被设计在一起。依赖注入让模块化成为可能模块化让区域和导航有了发挥空间事件聚合器让模块之间保持解耦。当你把它们组合到一个项目里你会发现每个特性都在解决一个特定的架构痛点而不是为了炫技。踩过几次坑之后我给你最实在的一条建议不要在项目一开始就想着把所有特性全部用上。先把依赖注入、MVVM 基类、导航这三件套跑顺等项目的界面数量超过十个窗口、模块之间的交互开始让你头疼的时候再引入模块化和事件聚合器。渐进式引入比一步到位更稳妥不然调试复杂度会和代码量一起爆炸。这套项目写到后期我已经能明显感觉到改动某一块业务代码时其他模块完全不受影响这种安全感是手写 MVVM 的时候从来没有过的。希望你也能在工程里体会到。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑