干这行最怕的不是技术难,而是项目做到一半,界面、业务、模块全搅在一起,改一个按钮都能牵连一堆代码。WPF 本身框架成熟,但原生写法在大型应用面前天花板很低。把 Prism 的模块化、MVVM、依赖注入和 HandyControl 的控件库、主题皮肤组合起来,基本就是给 WPF 项目补上了现代工程化的两条腿。这篇文章就围绕这套组合,从零开始讲清楚怎么搭出一个可扩展、可维护、界面还不丑的模块化应用框架,内容偏实操,适合已经写过几个 WPF Demo、想往正式项目结构的开发者参考。
1. 方案选型与技术栈梳理
1.1 为什么选择 Prism 做模块化
先聊一个本质问题:模块化到底解决什么?举个例子,你做一个后台管理系统,将来可能有订单模块、用户模块、报表模块。如果全部写在一个项目里,每天几百人提交代码,编译时间越来越长,模块之间互相引用,改个订单功能还要担心把用户模块改坏。把功能拆成独立程序集之后,每个团队负责一块,互不干扰,这才是模块化的核心价值。
Prism 在这个体系里的定位非常明确:它提供了模块化加载机制、依赖注入容器、MVVM 框架、导航系统和事件聚合器。其中模块化是它最突出的能力。Prism 允许你把一个功能封装成一个 Module,这个 Module 可以有自己的 View、ViewModel、Service、配置,甚至自己的依赖注入注册。主程序启动时,通过 ModuleCatalog 告诉 Prism 有哪些模块需要加载,Prism 负责解析程序集、注册类型、创建界面。
WPF 生态里不是没有其他 MVVM 框架,也有轻量级的工具库能做依赖注入,但能把“模块化 + 导航 + 通信 + 依赖注入”整合得这么完整的,Prism 是独一档。它虽然有一定学习曲线,但一旦理解了它的运行机制,后面扩展新功能就是加一个模块的事。
1.2 HandyControl 在界面层的定位
Prism 管的是架构和逻辑,界面风格它不管。原生 WPF 控件长什么样大家心知肚明,做内部工具还行,做正式产品就有点寒碜。HandyControl 就是来解决这个问题的。
HandyControl 是一套开源 WPF 控件库,最大的特点是对中国开发者使用习惯非常友好。它包含几十个常用控件,比如 Button、TextBox、ComboBox、TabControl、Card、Drawer、Dialog、MessageBox,以及一套完整的光明/暗黑主题。它不像某些控件库那样追求花哨的视觉效果,而是走简洁、实用、易定制的路线,和 Prism 这种偏稳重的框架搭配起来风格很协调。
HandyControl 对 Prism 没有强依赖,两者可以独立使用。但组合起来有天然互补:Prism 的 Region、Navigation、DialogService 负责功能流转,HandyControl 提供界面控件和视觉统一。这样项目里既不会出现原生控件那种娄旧感,也不会因为用了第三方控件库而跟 MVVM 架构冲突。
1.3 依赖版本搭配的讲究
版本匹配是这套组合最先踩的坑。很多初学者直接从 NuGet 拉最新版,结果编译报错或者运行时崩溃,然后开始怀疑框架本身有问题。
我推荐一套目前经过实践验证的稳定组合:
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| WPF | .NET 8.0 | 长期支持版本,性能不错 |
| Prism | 8.1.97 | 目前 Prism 8 系列较成熟的版本 |
| HandyControl | 3.5.1 | 3.x 系列功能完整,主题稳定 |
| Unity | 5.11.0 | Prism 的容器实现,可选 DryIoc |
注意 Prism 的 NuGet 包选择有门道。Prism.DryIoc 和 Prism.Unity 对应不同容器,两者不能同时引用。如果你不确定用哪个,建议用 Prism.Unity,资料多,排查问题方便。
2. 从零搭建项目骨架
2.1 创建 WPF 项目与清理默认代码
打开 Visual Studio 创建一个新项目,选择 WPF Application。框架选 .NET 8.0,项目名建议直接叫CompanyName.App这种格式,后面加模块时不容易混淆。
创建完项目后,默认代码有一堆用不上的东西。App.xaml 里的 StartupUri 要删掉,因为 Prism 有自己的启动方式,不再通过 StartupUri 指定启动窗口。MainWindow.xaml 暂时保留,后面也会被 Prism 的导航框架接管。Properties 里还有一些默认资源,可以留着不管。
这里有个很多人忽略的细节:项目文件 csproj 里,.NET 6 之后的默认配置会启用<Nullable>enable</Nullable>和<ImplicitUsings>enable</ImplicitUsings>。这两个选项对 Prism 工程很友好,建议保留。Nullable 能帮你在写 ViewModel 时提前发现空引用问题,ImplicitUsings 能少写一堆 using。
2.2 引入 NuGet 包的正确姿势
在这个项目里安装 Prism.Unity 和 HandyControl 两个核心包就够了。如果你后面需要做日志,再加 Serilog 之类的第三方库。不建议一次性装太多包,每多一个包就多一层版本冲突风险。
安装命令:
dotnet add package Prism.Unity dotnet add package HandyControl需要注意,Prism.Unity 会自动带上 Prism.Core 和 Prism.Wpf。HandyControl 会自动带上 HandyControl.Data 相关的内部依赖。所以实际项目里的包数量不会太多。
安装完成后,检查 csproj 文件,确认 TargetFramework 是net8.0-windows,并且 UseWPF 为 true。如果是 net8.0 而不是 net8.0-windows,编译会报错。
2.3 启动流程改造:从 App.xaml 到 Prism 引导
Prism 的起点是 App 类继承 PrismApplication。改造方式如下。
App.xaml 里删除 StartupUri,修改为:
<Application x:Class="CompanyName.App.App" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> <Application.Resources> </Application.Resources> </Application>App.xaml.cs 改为:
using System.Windows; using Prism.DryIoc; using Prism.Ioc; namespace CompanyName.App { public partial class App : PrismApplication { protected override Window CreateShell() { return Container.Resolve<MainWindow>(); } protected override void RegisterTypes(IContainerRegistry containerRegistry) { // 服务注册统一放这里 } } }如果你选了 Prism.Unity,继承的类就是 PrismUnityApplication,对应容器 API 略有不同。差别主要在于依赖注入的扩展方法。
CreateShell 方法返回主窗口。很多教程在这时候直接new MainWindow(),这其实不推荐。正确做法是用容器去解析 MainWindow,这样 MainWindow 的构造函数参数才能被容器自动注入。普通 new 的方式没法解析依赖。
从这一步开始,Prism 已经接管了应用的启动生命周期。后面所有窗体创建、ViewModel 绑定、依赖对象解析,都应该交给 Prism 的容器来完成。
3. 模块化架构落地实操
3.1 模块项目的组织方式与引用关系
现在开始拆模块。我建议的解决方案结构是这样:
CompanyName.App.sln ├─ CompanyName.App(主程序,启动、配主题) ├─ CompanyName.Core(公共契约,放共享接口、模型、常量) ├─ CompanyName.Module.Dashboard(仪表盘模块) ├─ CompanyName.Module.Order(订单模块) └─ CompanyName.Module.System(系统设置模块)每个模块项目都是一个 WPF 用户控件库(User Control Library)。它本质上是一个类库项目,但允许包含 XAML 资源。创建时选 WPF User Control Library 模板,创建完成后把模板自动生成的 UserControl1.xaml 删掉。
引用关系有一个铁律:主程序引用所有模块项目,模块之间不允许互相引用。如果模块 A 需要模块 B 的某个服务,那应该把这个服务接口下沉到 Core 项目,模块 B 实现它,主程序统一注册,模块 A 只依赖接口。这条规则能保证模块之间解耦,不然模块化就变成拆分文件,没有任何意义。
3.2 注册导航与依赖注入的细节
每个模块需要实现 Prism 的 IModule 接口,里面有 OnInitialized 和 RegisterTypes 两个方法。RegisterTypes 用来注册该模块自己的服务、ViewModel、View,OnInitialized 里处理模块初始化逻辑,比如注册导航。
模块注册导航的写法:
public class OrderModule : IModule { public void OnInitialized(IContainerProvider containerProvider) { var regionManager = containerProvider.Resolve<IRegionManager>(); regionManager.RegisterViewWithRegion("MainRegion", typeof(Views.OrderView)); } public void RegisterTypes(IContainerRegistry containerRegistry) { containerRegistry.RegisterForNavigation<Views.OrderView, ViewModels.OrderViewModel>("Order"); } }RegisterForNavigation 是 Prism 提供的快捷注册方法,把 View 和 ViewModel 绑定到一起,并指定一个导航名称。这里重点看第二个参数 "Order",它是后续导航时用的唯一标识。
主程序里,Prism 默认会自动扫描当前程序集内的模块。如果模块放在单独的类库中,需要在 ModuleCatalog 里手动添加。主程序 App 构造函数中可以这样做:
protected override void ConfigureModuleCatalog(IModuleCatalog moduleCatalog) { moduleCatalog.AddModule<DashboardModule>(); moduleCatalog.AddModule<OrderModule>(); }也可以按程序集路径加载,适合模块是插件化单独分发的情况。手动注册的好处是启动顺序可控,调试时每一个模块的加载情况一目了然。
3.3 模块间通信:事件聚合器实战
模块之间不能直接引用,那数据怎么交换?Prism 提供 EventAggregator,本质是一个发布订阅模式的事件总线。各模块可以订阅自己关心的事件,不需要知道事件发布者是谁。
举个例子,订单模块下单成功后,要为仪表盘模块发送一条消息,让仪表盘刷新统计数字。先在 Core 项目定义事件:
public class OrderCreatedEvent : PubSubEvent<int> { // 泛型参数是消息负载类型,这里传订单ID }订单模块发布事件:
private readonly IEventAggregator _eventAggregator; public OrderViewModel(IEventAggregator eventAggregator) { _eventAggregator = eventAggregator; } private void OnCreateOrder() { // 业务处理后 _eventAggregator.GetEvent<OrderCreatedEvent>().Publish(orderId); }仪表盘模块订阅事件:
private readonly IEventAggregator _eventAggregator; public DashboardViewModel(IEventAggregator eventAggregator) { _eventAggregator = eventAggregator; _eventAggregator.GetEvent<OrderCreatedEvent>().Subscribe(OnOrderCreated); } private void OnOrderCreated(int orderId) { // 刷新统计 }用 EventAggregator 有三个注意点。第一,事件类的写法要放在公共项目里,不然模块间没法识别同一个事件类型。第二,订阅事件后要在页面销毁时调用 Unsubscribe 取消订阅,否则 ViewModel 已经被 GC 回收了但事件还在触发,容易造成内存泄漏和报错。第三,Publish 默认是同步的,如果需要异步要考虑线程上下文,不要在里面做太重的耗时操作。
4. HandyControl 主题皮肤接入
4.1 资源字典合并与主题初始化
HandyControl 的接入比想象中简单,核心是把它的资源字典合并到 App 级资源里。最推荐的做法是在 App.xaml 里直接引用:
<Application.Resources> <ResourceDictionary> <ResourceDictionary.MergedDictionaries> <ResourceDictionary Source="pack://application:,,,/HandyControl;component/Themes/SkinDefault.xaml"/> <ResourceDictionary Source="pack://application:,,,/HandyControl;component/Themes/Theme.xaml"/> </ResourceDictionary.MergedDictionaries> </ResourceDictionary> </Application.Resources>SkinDefault.xaml 是默认浅色皮肤,Theme.xaml 是控件样式定义。两个文件缺一不可。如果你想要深色主题,把 SkinDefault 换成 SkinDark 就行。HandyControl 还提供运行时切换主题的方法,通过ThemeManager.Current.ChangeTheme可以动态换肤,实际项目中用得非常多。
这里有一个关键坑:如果你想在模块自己的 UserControl 里使用 HandyControl 的样式,只要 App.xaml 里已经合并了主题,全局就生效,模块里不需要再重复合并。如果重复合并,可能导致资源样式被覆盖成默认值,反而出现界面错乱。
4.2 常用控件改造与样式覆盖
接入 HandyControl 后,原生控件会自动换上新外观吗?答案是大部分基础控件会变,但有一些需要你显式指定 Style。比如 Button,原生写法是灰色默认样式,换成 HandyControl 后 Button 默认变蓝色主题带圆角。但 TabControl 这种相对复杂的控件,可能需要你在 XAML 里设置Style="{StaticResource TabControlDefault}"之类的资源键。
实际开发里我习惯的做法是这样:先用默认主题跑通全流程,再针对个别控件覆盖样式,而不是一上来就大改。比如一个标题卡片:
<Border Style="{StaticResource CardBorder}" Background="#FFFFFF" CornerRadius="8" Padding="16"> <StackPanel> <TextBlock Text="今日订单数" FontSize="14" Foreground="#888888"/> <TextBlock Text="{Binding OrderCount}" FontSize="28" FontWeight="Bold"/> </StackPanel> </Border>CardBorder 是 HandyControl 内置样式,适用做统计卡片效果。需要自定义颜色、圆角、阴影时,基于这个资源样式覆盖更省事,而不是去改主题源文件。
4.3 配合 Prism 的对话框与消息通知
Prism 有自己的一套 DialogService,HandyControl 也有自己的弹窗控件。两套东西怎么配合?我的建议是:业务消息提示用 HandyControl 的消息控件,比如 Growl 和 MessageBox;需要交互式、带 ViewModel 的复杂弹窗用 Prism 的 DialogService。
简单消息提示可以注入 HandyControl 提供的方式:
HandyControl.Controls.MessageBox.Show("保存成功", "提示", MessageBoxButton.OK, MessageBoxImage.Information);复杂弹窗场景,比如确认订单审核,需要展示一批明细数据,用 Prism DialogService 更合适。注册弹窗:
containerRegistry.RegisterDialog<Views.PreviewWindow, ViewModels.PreviewWindowViewModel>("PreviewDialog");使用时:
var dialogService = _dialogService; var result = await dialogService.ShowDialogAsync("PreviewDialog", parameters);弹出窗口本身如果想用 HandyControl 控件的风格,需要在弹窗视图里引用 HandyControl 的 Window 样式。把弹窗的 Window 类型换成 HandyControl 的,或者在弹窗根节点上套用对应主题资源,这样视觉就统一了。
5. 常见坑与排查技巧实录
5.1 启动黑屏或资源找不到
启动后程序黑屏或者弹出 XamlParseException,是初学者最常遇到的问题。XamlParseException 的报错信息往往指向某个资源文件不存在。最常见的原因就是 App.xaml 里资源字典的路径写错,或者项目名和命名空间不一致导致 pack URI 解析失败。
排查方法很简单,先把 App.xaml 的资源合并注释掉,看程序能不能启动。能启动说明问题出在资源引用上,逐个加回资源字典定位问题。还有一个小技巧,HandyControl 的皮肤资源路径中项目名必须和程序集名完全一致,大小写也敏感。你可以在 csproj 里确认 AssemblyName,再对照 XAML 里的路径。
5.2 模块加载失败与程序集扫描问题
模块加载失败的报错五花八门。如果是自己写的模块,最常见的问题有三个:模块类没有公开无参构造函数、模块所在的程序集没有拷贝到主程序输出目录、模块引用的某个 DLL 版本冲突。
第一种情况,Prism 在加载模块时是通过 Activator 创建模块实例的,如果你的模块构造函数里传了自定义参数又没有走容器注册,就会失败。保证模块的构造函数要么无参,要么只注入容器能解析的类型。
第二种情况,多项目结构下如果没有设置模块项目的构建输出复制选项,主程序跑起来后找不到对应 DLL。最简单的解决办法是主程序直接项目引用模块项目,Visual Studio 默认会复制依赖 DLL。
版本冲突的问题最隐性。比如模块 A 依赖了 Prism 8.1,模块 B 依赖了 Prism 8.2,NuGet 在自动还原时可能把版本升到 8.2,但现有代码没重新编译,就会出现 MethodNotFound 之类的异常。解决办法是保持所有项目使用统一 Prism 版本,不要个别升级。
5.3 调试技巧与日志定位
Prism 程序一出问题,很多人习惯性地到处设断点,但模块加载、导航这类流程问题,靠断点效率很低。我建议在模块入口和导航方法里加日志。不用太复杂,自己封装一个静态日志类,或者用现成的 Serilog,在关键节点输出几行信息就够用。
建议日志埋点位置:App 启动开始、每个模块 OnInitialized 开始和结束、CreateShell 返回前、导航请求发出时、导航完成回调里。五个关键点记录后,哪里卡住一目了然。
另外一个调试技巧是启用 Prism 的配置日志。在 App 启动之前,设置 Prism 的一些调试参数,让它在诊断视图中不直接崩掉。不过这个方式老版本和新版本有差异,遇到模块加载异常时最简单的还是 try-catch 输出异常堆栈,别让异常被吞掉。
protected override void OnInitialized() { try { var regionManager = Container.Resolve<IRegionManager>(); regionManager.RegisterViewWithRegion("MainRegion", typeof(MainContent)); base.OnInitialized(); } catch (Exception ex) { MessageBox.Show(ex.ToString()); } }5.4 性能优化与内存释放记录
模块化应用运行久了容易出现内存占用过高的问题,这在 WPF 应用里很常见。一个重要的原因是 UI 元素和事件订阅没有被正确释放。
Region 在切换视图时,Prism 默认会保留视图实例,方便导航返回。如果你的模块有大量视图,建议在每个模块页面里实现 INavigationAware,并在 IsNavigationTarget 返回 false,让非活跃视图可以被释放。
csharp public class OrderViewModel : INavigationAware { public bool IsNavigationTarget(NavigationContext navigationContext) { return false; } }另外,使用 EventAggregator 订阅的事件如果忘记取消,会导致 ViewModel 无法被垃圾回收。一个可行的做法是在 View 的 Unloaded 事件里统一调用 ViewModel 的清理方法。注意 WPF 的 Unloaded 在某些场景下会被多次触发,清理前判空和防重入操作要做。
还有大数据集合。像订单列表这种可能加载几千条数据的界面,建议在 ViewModel 里使用虚拟化集合实现,比如通过 CollectionViewSource 的虚拟化。HandyControl 的 DataGrid 默认有虚拟化支持,但如果你在 ScrollViewer 里面放 DataGrid,虚拟化会失效。这个细节很多教程不提,实际一测试性能差距很大。
6. 项目扩展与多窗口协作
6.1 三个以上模块时的区域划分策略
Prism 的区域机制是页面布局的核心。主窗口通常有一个主内容区,可能还需要侧边导航和顶部工具条。每个区域用 RegionName 标识,模块里的视图注册到对应区域。窗口结构简洁一点,通常定义两个区域就够了:
<Grid> <Grid.RowDefinitions> <RowDefinition Height="56"/> <RowDefinition Height="*"/> </Grid.RowDefinitions> <ContentControl Grid.Row="0" prism:RegionManager.RegionName="HeaderRegion"/> <ContentControl Grid.Row="1" prism:RegionManager.RegionName="MainRegion"/> </Grid>HeaderRegion 放顶部工具条、用户信息等,MainRegion 放各个模块的内容。模块多的时候要防止多个模块往同一个区域塞视图导致的覆盖问题。每条区域只应由一个主导视图拥有,其他模块的内容用子区域或弹窗承载。
6.2 注入配置与程序集级管理的分寸
模块多了,配置项也多了。连接字符串、服务地址、功能开关这类配置,建议统一放在主程序的 appsettings.json,通过主程序读取后注册到容器。模块内部不要单独去读配置文件,不然配置管理会失控。
我在一个模拟项目里试过把配置读取下沉到每个模块,结果配置项散落各处,改一个数据库地址要改四个项目。后面重构为统一读取,在 Core 里定义强类型配置实体,主程序绑定后注入,清爽很多。
7. 一些额外的工程化建议
7.1 从建模到交付的节奏把控
这套架构的好处是前期多花一点时间搭建骨架,后面每个新功能的开发路径非常固定。理解 Prism 的模块接口与导航注册方式之后,新模块的开发轨迹就三条:建模块项目、写 IModule 注册、写 View 和 ViewModel。交付节奏自然就稳定下来。
7.2 对团队协作的规范意义
当多个开发者同时工作时,清晰的模块边界能减少很多冲突。互相之间不直接引用类,只通过 EventAggregator 和接口通信。配合 Git 的分支管理,每个模块一个分支,合并时冲突极少。这一点在团队协作中的价值,甚至比框架本身的功能更重要。
7.3 我能给到的最真诚的建议
这套架构并不是 WPF 里唯一的解,但它是我自己实际用过、踩过坑之后觉得最值得推荐的一套组合。
最开始折腾 Prism 的时候,我也被模块加载时的各种诡异问题搞到怀疑人生。后来我总结出一个习惯:永远不急着改代码,而是先把启动流程、模块注册、导航映射这三条链路理清楚,再动手写功能。框架的出错点就那么几个,链路清晰了,问题就藏不住了。
7.4 版本升级时的“三看”原则
Prism 迭代速度不算快,但每次大版本升级都有破坏性变更。从 Prism 7 到 Prism 8,注册对话框的方式变了,一些导航 API 也调整了。升级前先做三件事:看官方升级文档、看当前项目用到的 Prism API 是否在新版本存在、看社区反馈的兼容性问题。不要拿到新版本直接替换包。
升级后最直观的验证方式,就是把所有模块的启动和导航手动跑一遍,同时打开日志观察是否有异常输出。没有异常不代表没问题,最好再跑一遍内存分析,看是否出现非预期的程序集版本冲突。
7.5 最后分享一个实用小技巧
我建议在解决方案里单独建一个 Tools 项目,专门放开发期工具。比如为了检查模块加载情况,可以写一个诊断界面,列出所有已注册的 Regional 视图和导航名称。这样开发时能直接看到哪个模块没有正确注册,不用靠猜。
这个小工具不复杂,做一个带列表的窗口,通过 Prism 的 RegionManager 反射获取已注册的视图集合,再配合导航日志展示出来。一套下来不到半天时间,后面用起来能省无数个排查的下午。
WPF 加 Prism 加 HandyControl 这套组合,上限很高,但学习曲线也在。按文中的顺序一步步来,优先把主程序骨架跑通,再逐步拆模块、接主题、加通信,过程中遇到问题多打日志、少猜原因。后面你大概率会发现,真正让人崩溃的不是框架本身,而是把各种思路揉在一起时的混沌状态。希望这些经验能帮你少走几步弯路。