☰
WPF Prism实战:模块化+依赖注入+导航,构建工程化客户端
2026/10/15 6:05:43 网站建设 项目流程

如果你在WPF项目里用过MVVM,大概率会碰到这样的尴尬局面:界面拆成了View和ViewModel,但模块之间怎么通信、页面怎么切换、弹窗怎么统一管理,全都得自己造轮子。这次我整理的“实战项目2”,就是为了把Prism框架全特性完整走一遍而做的WPF桌面端项目,覆盖模块化、依赖注入、区域导航、命令体系、事件聚合、对话框服务六大核心内容。

项目本身模拟的是一个设备台账管理场景,包含仪表盘、设备台账、巡检记录、报表四个业务模块,从登录进入主界面,到菜单切换、列表跳详情、跨模块刷新、弹窗确认,所有环节都按真实业务链路跑通。这个项目很适合正在从“单窗体开发”往“工程化客户端开发”进阶的人参考,也能帮已经用上Prism但只用了其中一部分特性的同学补齐短板。

1. 项目背景与整体设计思路

1.1 为什么要选Prism,而不是手写MVVM

在WPF领域,MVVM本身只是一个模式,不是框架。手写的话,每个人都会写出一套自己的PropertyChanged基类、Command实现、消息机制。小项目还好,一旦界面数量超过二十个,问题就来了:每个开发者的风格不一样,ViewModel的创建方式不一样,页面切换逻辑散落在各个窗体的代码里,跨窗体通信全靠事件或静态类,最后维护成本非常高。

Prism的价值不在于“帮你少写几行代码”,而在于把客户端应用里最容易失控的几个问题用一套统一机制解决掉。我按项目里实际用到的情况捋了一遍,对应关系如下:

Prism特性项目里的落地场景
模块化菜单、台账、巡检、报表各自独立编译、独立加载
依赖注入仓储、基础服务、ViewModel由容器统一创建和管理
区域与导航Shell固定左侧菜单,右侧内容区按导航切换页面
命令体系保存、删除、查询、复合保存等操作的统一封装
事件聚合设备数据变更后,通知巡检模块和报表模块刷新
对话框服务删除确认、设备编辑弹窗的统一管理

这套组合对于“多模块、多页面、多人协作”的桌面应用来说,几乎是刚需。当初做技术选型时我对比过轻量级MVVM框架,发现它们多数只解决绑定和命令,模块化与导航还是得自己搞定。Prism把这些问题一揽子给全了,社区资料也多,踩坑的时候不至于没人可问。

1.2 项目结构规划:Shell、Shared与Modules的边界

项目没有按“普通窗体+普通类”的方式堆代码,而是严格按Prism推荐的方案拆分。顶层结构是这样的:

src/ ├─ Shell/ │ ├─ App.xaml / App.xaml.cs │ ├─ MainWindow.xaml # 主窗体,只负责搭布局和定义Region │ └─ Views/ ├─ Shared/ │ ├─ Contracts/ # 接口、模型、事件定义 │ ├─ Infrastructure/ # 通用基础服务 │ └─ Resources/ # 全局样式、转换器 └─ Modules/ ├─ MenuModule/ # 左侧导航菜单 ├─ DashboardModule/ # 仪表盘 ├─ DeviceModule/ # 设备台账 ├─ InspectionModule/ # 巡检记录 └─ ReportModule/ # 报表

命名这块有个重要原则:Shell层不引用任何业务模块,MainWindow里不加业务逻辑,只定义区域、放全局外壳。业务模块之间也不互相引用,只依赖Shared层里的接口和事件。一开始有人会觉得这么绕,业务代码直接从模块A调用模块B的ViewModel不香吗?实际做下来,边界划清楚之后,新增一个模块只需要往Modules目录丢一个程序集,在主程序零改动的情况下被加载起来,这个收益长期看是非常值的。

2. 模块化与依赖注入:先把地基打好

2.1 模块目录与IModule的加载流程

Prism里的模块化不是简单地把项目拆成多个类库,而是每个模块实现一个IModule接口,由框架负责发现、加载和初始化。

App启动时,通过重写CreateModuleCatalog告诉Prism去哪里找模块。项目里用了目录方式加载:

protected override IModuleCatalog CreateModuleCatalog() => new DirectoryModuleCatalog { ModulePath = @"Modules" };

这样发布时把编译好的模块DLL放进Modules文件夹,主程序启动时自动扫描并加载。好处是后续扩展新模块,不需要改主程序的任何代码,部署时丢一个DLL进去就行。代价是调试时要记得把各模块的生成输出路径改到Shell的Modules目录,或者写个复制任务,否则运行时找不到模块会一脸懵。

模块本身的写法很固定:

public class DeviceModule : IModule { public void OnInitialized(IContainerProvider containerProvider) { var regionManager = containerProvider.Resolve<IRegionManager>(); regionManager.RegisterViewWithRegion("ContentRegion", typeof(DeviceListView)); } public void RegisterTypes(IContainerRegistry containerRegistry) { containerRegistry.RegisterSingleton<IDeviceRepository, DeviceRepository>(); containerRegistry.RegisterForNavigation<DeviceListView, DeviceListViewModel>(); } }

这里有两个时机容易混淆:RegisterTypes在模块加载时先执行,用于向容器注册服务;OnInitialized在所有注册都完成之后执行,这时可以安全地解析服务、注册区域视图。项目里最开始我习惯把业务代码一股脑写进OnInitialized,后来发现启动速度会越来越慢。合理做法是OnInitialized里只做“把视图放到区域里”这类轻量操作,真正的业务初始化放到页面加载后再说。

模块之间如果存在加载顺序要求,用特性声明依赖关系:

[ModuleDependency("DeviceModule")] public class ReportModule : IModule { // 报表模块引用设备模块的数据,确保设备模块先加载 }

Prism在加载时会根据依赖关系自动排序,避免你手忙脚乱地调库引用顺序。

2.2 容器注册与ViewModel的自动定位

Prism应用启动后,框架会自动注册一批基础设施服务,比如IRegionManager、IEventAggregator、IDialogService。我们自己写的业务服务,统一在RegisterTypes里注册。

protected override void RegisterTypes(IContainerRegistry containerRegistry) { containerRegistry.RegisterSingleton<ISessionService, SessionService>(); containerRegistry.Register<IDeviceRepository, DeviceRepository>(); containerRegistry.RegisterForNavigation<DeviceListView, DeviceListViewModel>(); containerRegistry.RegisterForNavigation<DeviceDetailView, DeviceDetailViewModel>(); }

Register和RegisterSingleton的区别,一句话讲清楚:Register每次解析都创建新实例,适合页面、列表这种临时对象;RegisterSingleton全局只有一份,适合会话状态、配置、缓存这类需要共享的数据。

ViewModel的获取不需要手动new,Prism提供了一套ViewModelLocator机制。只要View放在Views命名空间下,ViewModel放在同名模块的ViewModels命名空间下,并且类名把View后缀替换成ViewModel后缀,框架会自动绑定。比如DeviceListView自动找DeviceListViewModel。

项目里不少ViewModel的构造函数会有一长串参数,例如:

public DeviceListViewModel( IDeviceRepository repository, IEventAggregator eventAggregator, IRegionManager regionManager, IDialogService dialogService) { // 所有依赖都由容器注入,不在构造函数里new }

第一次看到这种写法的人会担心“谁传这些参数给我”,其实容器在创建ViewModel时已经把所有注册好的服务按构造函数参数列表自动注入进来了。这也带来一个开发习惯上的改变:尽量通过构造函数表达依赖,而不是在方法里到处Resolve。依赖一目了然,测试时也方便传Mock对象。

3. 界面导航与命令交互:Prism的日常

3.1 Shell布局与Region注册

主窗体在Prism里叫Shell,它只负责两件事:布局和定义区域。项目里的MainWindow长这样:

<Window x:Class="Shell.Views.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:prism="http://prismlibrary.com/" Title="设备资产管理系统" Height="700" Width="1100"> <Grid> <Grid.ColumnDefinitions> <ColumnDefinition Width="220"/> <ColumnDefinition Width="*"/> </Grid.ColumnDefinitions> <ContentControl prism:RegionManager.RegionName="MenuRegion"/> <ContentControl Grid.Column="1" prism:RegionManager.RegionName="ContentRegion"/> </Grid> </Window>

Region是Prism的核心抽象。可以把它理解成界面上一个带名字的占位符,业务模块自己往这个位置填充内容。Shell不需要知道具体填充的是哪个界面,也不用引用业务模块的代码。

菜单模块启动时,把菜单视图注册到MenuRegion:

public void OnInitialized(IContainerProvider containerProvider) { var regionManager = containerProvider.Resolve<IRegionManager>(); regionManager.RegisterViewWithRegion("MenuRegion", typeof(MenuView)); }

菜单项的点击切换业务页面,通过导航完成:

public class MenuViewModel : BindableBase { private readonly IRegionManager _regionManager; public DelegateCommand<string> NavigateCommand { get; } public MenuViewModel(IRegionManager regionManager) { _regionManager = regionManager; NavigateCommand = new DelegateCommand<string>(target => _regionManager.RequestNavigate("ContentRegion", target)); } }

菜单按钮通过CommandParameter指定要导航的页面名称,例如“DeviceList”“Dashboard”“Report”,主内容区就会切换到对应视图。整个切换过程对业务模块完全透明,新增一个模块不需要改菜单模块代码,只需要新模块自己注册新的导航目标。

3.2 带参数导航:从列表到详情的完整闭环

设备台账列表需要点击某行进入设备详情页。这个场景如果自己写,一般会给详情窗体构造函数传一个实体对象,但Prism里更推荐用导航参数传递。

列表页发起导航:

var parameters = new NavigationParameters { { "deviceId", selectedDevice.Id } }; _regionManager.RequestNavigate("ContentRegion", "DeviceDetail", parameters);

详情页的ViewModel实现INavigationAware接口来接收参数:

public class DeviceDetailViewModel : BindableBase, INavigationAware { private readonly IDeviceRepository _repository; private Device _device; public DeviceDetailViewModel(IDeviceRepository repository) { _repository = repository; } public void OnNavigatedTo(NavigationContext navigationContext) { // 导航参数可能是null,必须先做判断 if (navigationContext.Parameters.TryGetValue("deviceId", out int deviceId)) { _device = _repository.GetById(deviceId); // 刷新界面绑定 } } public bool IsNavigationTarget(NavigationContext navigationContext) => true; public void OnNavigatedFrom(NavigationContext navigationContext) { // 离开页面时的清理逻辑 } }

这里有一个容易踩的细节:导航参数里建议只传ID这类轻量值,不要传整个实体对象。传实体会让两个页面强耦合,而且详情页改完数据后列表页拿到的还是旧对象,容易出隐性bug。传ID,详情页自己通过仓储重新取,数据一致性有保障。

还有一个初学者常问的问题:导航到新页面后,旧页面是销毁了还是保留?Prism默认会把当前区域里的旧视图移除,再放入新视图。如果需要保留页面状态,就得在IsNavigationTarget或者页面实例管理上做处理。项目里设备列表每次重新进入都重新加载数据,正好符合默认行为,省了很多精力。

3.3 命令管理:DelegateCommand与CompositeCommand的实战

WPF的命令系统是Prism里使用频率最高的部分。项目里几乎每个页面都有查询、保存、删除操作,统一用DelegateCommand封装。

public class DeviceListViewModel : BindableBase { public DelegateCommand DeleteCommand { get; } public DelegateCommand<string> SearchCommand { get; } public DeviceListViewModel() { DeleteCommand = new DelegateCommand(DeleteSelected, () => SelectedDevice != null) .ObservesCanExecute(() => SelectedDevice); SearchCommand = new DelegateCommand<string>(keyword => { // 按关键字查询设备 }); } }

ObservesCanExecute是个非常好用的扩展方法,它会自动监听括号里属性的变化,当SelectedDevice有值时自动刷新按钮可用状态。最开始我用原生WPF的CommandManager,CanExecute状态半天不刷新,按钮一直灰着,换成Observes之后舒服多了。

泛型命令的参数绑定也有坑。XAML里CommandParameter如果绑定到null,泛型int参数会被框架转成默认值0,字符串则可能是null。项目里查询关键字的参数我绑定的是TextBox.Text,输入为空字符串时不会触发查询,这符合预期。但删除按钮的CommandParameter绑定SelectedItem,如果SelectedDevice为null,命令会自动禁用,逻辑也顺了。

多个命令需要合并执行时,用CompositeCommand。项目里主界面有个“保存全部”按钮,需要同时触发台账和巡检两个页面的保存命令:

var saveAllCommand = new CompositeCommand(); saveAllCommand.RegisterCommand(dashboardViewModel.SaveCommand); saveAllCommand.RegisterCommand(deviceViewModel.SaveCommand);

CompositeCommand会在所有子命令的CanExecute都为true时才允许执行,执行时按注册顺序依次调用。这种聚合能力在“一键操作多个模块”的场景下特别实用,比到处订阅事件要直观。

4. 跨模块通信与弹窗服务:项目细节里的功和坑

4.1 事件聚合器:改完设备,所有模块都收到通知

设备模块修改一条设备记录后,巡检模块的待检列表、报表模块的统计数字都需要跟着刷新。如果让设备模块直接引用另外两个模块的ViewModel,模块间就死死地绑在一起了。这里用的是事件聚合器。

先定义一个事件,并在Shared层共享:

public class DeviceUpdatedEvent : PubSubEvent<DeviceUpdatedPayload> { } public class DeviceUpdatedPayload { public int DeviceId { get; set; } }

设备编辑保存后发布事件:

_eventAggregator.GetEvent<DeviceUpdatedEvent>() .Publish(new DeviceUpdatedPayload { DeviceId = device.Id });

巡检模块的页面在进入时订阅事件,离开时取消订阅:

public void OnNavigatedTo(NavigationContext navigationContext) { _eventAggregator.GetEvent<DeviceUpdatedEvent>() .Subscribe(OnDeviceUpdated, ThreadOption.UIThread); } public void OnNavigatedFrom(NavigationContext navigationContext) { _eventAggregator.GetEvent<DeviceUpdatedEvent>() .Unsubscribe(OnDeviceUpdated); }

订阅时指定ThreadOption.UIThread非常关键。如果发布事件时所在线程是后台线程,订阅者默认也会在后台线程收到回调,这时去操作UI控件就会抛跨线程异常。指定UIThread后,框架会通过Dispatcher把回调调度到UI线程。

事件聚合器还有两个使用细节值得注意。第一,订阅默认是弱引用,也就是说如果订阅者对象在外部没有被强引用,可能被垃圾回收导致事件收不到。项目里的订阅方都是被容器托管的ViewModel,生命周期稳定,没有这个问题。第二,一旦订阅了事件但忘了取消订阅,即使内部是弱引用,长期运行仍然可能积累额外开销。所以我的习惯是:页面进入时订阅、离开时取消,短线操作不依赖框架自动清理。

事件聚合器是“广播式”通信,用多了会发现事件满天飞,最后很难定位是谁在发、谁在听。我给项目定了一条规则:两个模块如果本来就是强业务关系,优先通过Shared层的接口调用;事件只用于“一个事实发生,多个不相关模块需要知道”的场景。设备数据变更通知就是一个典型。

4.2 对话框服务:用IDialogService统一弹窗

项目里有两类弹窗:删除确认框、设备编辑窗。第一版我直接用了MessageBox,界面统一性差,而且在ViewModel里弹MessageBox没法测试,样式的定制能力也几乎没有。后来换成Prism的DialogService统一管理。

先在模块的RegisterTypes里注册对话框:

containerRegistry.RegisterDialog<ConfirmDialog, ConfirmDialogViewModel>(); containerRegistry.RegisterDialog<DeviceEditDialog, DeviceEditDialogViewModel>();

调用弹窗:

var parameters = new DialogParameters { { "message", $"确定要删除设备 {device.Name} 吗?" } }; _dialogService.ShowDialog("ConfirmDialog", parameters, callback => { if (callback.Result == ButtonResult.OK) { _repository.Delete(device.Id); // 刷新列表 } });

对话框的ViewModel实现IDialogAware接口:

public class ConfirmDialogViewModel : BindableBase, IDialogAware { private string _message; public string Title => "操作确认"; public string Message { get => _message; set => SetProperty(ref _message, value); } public bool CanCloseDialog() => true; public void OnDialogOpened(IDialogParameters parameters) => Message = parameters.GetValue<string>("message"); public void OnDialogClosed() { } public event Action<IDialogResult> RequestClose; public void Confirm() => RequestClose?.Invoke(new DialogResult(ButtonResult.OK)); public void Cancel() => RequestClose?.Invoke(new DialogResult(ButtonResult.Cancel)); }

View里的两个按钮分别绑定Confirm和Cancel命令,业务流程全部收敛在ViewModel里。相比MessageBox,这套方案最大的好处是弹窗本身也是一个View+ViewModel,可以享受依赖注入、模板样式和单元测试。维护起来和普通页面完全一样。

DialogParameters的取值有一个容易忽略的点:如果参数不存在,GetValue返回的是目标类型的默认值。比如GetValue ("deviceId"),键不存在时返回0,和“设备ID为0”容易混淆。所以建议要么传引用类型并在取值后判空,要么用TryGetValue模式,项目里统一按后者处理。

5. 复盘清单:常见问题与调优建议

5.1 高频问题排查速查表

项目做完后我整理了一份问题清单,几乎每个都是实际遇到过并且花时间排查过的。

现象可能原因解决办法
导航后内容区白屏RegionName拼写不一致,或View未通过RegisterForNavigation注册检查Shell里的RegionName与RegisterViewWithRegion/导航目标名称是否一致
模块已加载但界面没内容模块未加入ModuleCatalog,或OnInitialized里注册Region名写错确认模块DLL在Modules目录,检查Initialize调用
按钮一直禁用CanExecute没有随属性变化刷新用ObservesCanExecute,或在属性setter里调用RaiseCanExecuteChanged
事件收不到订阅线程不对,弱引用被回收,或事件类型不匹配订阅指定ThreadOption.UIThread,确认订阅者被外部持有,检查泛型类型
弹窗不出现RegisterDialog注册名称与ShowDialog参数不一致统一对话框名称,优先使用注册类型对应的常量
ViewModel没绑定上View与ViewModel命名空间或类名不符合约定检查Views/ViewModels命名空间,或手动设置ViewModelLocator
启动卡顿模块OnInitialized里做了大量初始化,或启动时连接数据库模块启动只注册轻量内容,耗时逻辑延后到页面首次加载
循环依赖异常两个服务构造函数互相注入对方抽取公共依赖到新服务,或改用属性注入配合初始化方法
内存持续增长事件订阅未取消,导航后旧页面被静态引用页面离开时取消订阅,检查是否有静态集合持有ViewModel

5.2 使用Prism的项目该怎么调优

项目运行稳定之后,我回头看了一遍整个工程,有几处经验值得沉淀下来。

启动性能方面,Prism默认在应用启动时加载并初始化所有已发现的模块,如果某些模块初始化逻辑过重,启动体验会很差。这类模块建议把耗时操作放到页面首次加载时执行,或者用后台线程预加载数据,不要阻塞主线程。项目里报表模块的初始聚合计算就是从OnInitialized移到了报表页面加载后执行,冷启动体感快了不少。

内存管理方面,模块化应用最容易出的问题就是跨页面引用。一个页面被导航移除后,如果还被其他模块的事件订阅或者静态集合引用着,就永远不会被回收。因此我给自己定了一个习惯:所有事件订阅必须在OnNavigatedFrom里取消,长生命周期对象严禁持有短生命周期页面的实例。

调试体验方面,构造函数注入带来的便利是巨大的。设备列表出现数据异常时,我可以直接写一个单元测试,给DeviceListViewModel传入Mock仓储,模拟各种返回值,不需要启动整个客户端就能复现问题。这在纯手写MVVM的年代想都不敢想。

最后再分享一个小心得:事件聚合器虽然好用,但也要克制。项目初期我把所有模块间交互全都设计成事件,结果后期全局搜“GetEvent”能搜出几十处,维护时根本分不清事件流。后来定下规则:只有“一对多广播”的场景才用事件,一对一的调用走接口,一对一但跨模块且不方便直接引用的场景再考虑事件。规则虽然简单,但让项目结构清爽了很多。

这半年做下来,我的体会是Prism并不是什么魔法,它没办法把烂业务逻辑变好,但它能让架构在变复杂的过程中保持有序。真正的收益是做模块划分时被迫去思考依赖关系,界面之间是收敛还是发散,数据和通信的链路是否清晰。这些思考本身的成长,可能比框架带来的生产力提升更有价值。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询