简介Prism-master 是一份面向 WPF 开发者的 Prism 框架源码与示例合集适合希望掌握模块化、MVVM、依赖注入和导航机制的中高级开发者对照学习。压缩包共 1452 个文件约 5.11MB以 1079 个 C# 源码和 84 个 XAML 界面文件为主体辅以 50 个 csproj 工程文件、解决方案文件、JSON/XML 配置、Markdown 文档及 CI 脚本能够较完整地呈现框架的实现结构与示例项目组织方式。已有 301 人学习说明其在 WPF 框架实践领域具有一定参考价值。读者可从源码中梳理模块定义与动态加载流程观察 Unity/Autofac 等容器配置、Region 管理、事件总线和命令绑定的落地方式并借助示例项目理解 View 与 ViewModel 的职责划分。对于需要提升大型 WPF 应用可维护性与扩展性的开发者这份材料适合作为框架研读和项目结构参考的样本。1. 从八百行的 MainWindow.xaml.cs 说起接手一个做了三年的 WPF 后台MainWindow.xaml.cs八百多行按钮点击里连着数据库、弹窗、日志和状态刷新想拆的时候发现每个控件都在互相引用——这是我动手翻 Prism-master 源码的直接原因。Prism 是 WPF 生态里做 MVVM、模块化和区域导航最成熟的一套基础设施它把「界面长什么样」和「逻辑放在哪、什么时候装进来」拆成两件事View 只管 XAML 和数据绑定Region 负责把各模块的 View 塞进 Shell 预留的坑位Module 负责按需装配再配上容器注入和事件总线插件式的扩展才算真正落地。它适合写中大型桌面端、需要多人分模块并行开发、后续要按插件卖功能的团队如果只是一个单窗口小工具注册和约定带来的成本会大过收益这属于用得对不如用得值。2. 源码分层与构建先分清 Core、Wpf、Container 再动手把 Prism-master 解压出来第一眼是几十个 csproj直接dotnet build整个解决方案大概率会在非 Windows 环境下翻车。先花十分钟看清楚依赖方向后面所有「这行为什么编译不过」「这接口在哪个包里」的问题都能自己定位。2.1 目录分层与依赖方向源码树的骨架大致是这样src下按「抽象核心 → 平台实现 → 容器适配」三段切分目录职责依赖src/Prism.CoreBindableBase、DelegateCommand、PubSubEvent、导航抽象接口无平台依赖src/Prism.WpfRegionManager、RegionAdapter、ViewModelLocator、对话框服务Prism.Coresrc/Containers/Prism.DryIoc.WpfDryIoc 容器适配 PrismApplication基类Prism.Wpfsrc/Containers/Prism.Unity.WpfUnity 版本的同名适配Prism.Wpfsamples/可直接运行的示例工程全部tests/单元测试—依赖是单向的Core 不认 WPFWpf 不认具体容器容器包只在最外层做拼装。这个分层决定了两个实用结论一是你想把 MVVM 基类和命令层复用到其他 UI 框架只引 Core 就够了二是业务代码里写using Prism.DryIoc是分层污染的坏味道换成 Unity 那天要改一大片只引Prism.Ioc、Prism.Mvvm、Prism.Navigation才是干净写法。2.2 把源码编译起来的最小步骤编译顺序建议从内到外先确认 Core 能过再编 WPF 层最后才是容器包和示例。这样出错时能立刻判断是环境问题还是某一层的问题# 1. 确认 SDK 版本WPF 目标框架需要 net*-windows dotnet --list-sdks # 2. 先编核心库这一层跨平台任何系统都该过 dotnet build src/Prism.Core/Prism.Core.csproj -c Release # 3. 再编 WPF 实现层这一步必须在 Windows 上做 dotnet build src/Prism.Wpf/Prism.Wpf.csproj -c Release # 4. 最后编容器适配包 dotnet build src/Containers/Prism.DryIoc.Wpf/Prism.DryIoc.Wpf.csproj -c Release-c Release不是洁癖Debug 产物在跑示例时更容易被 PDB 加载拖慢启动尤其模块数量上去之后差别明显。如果第 3 步报的是找不到Microsoft.WindowsDesktop.App之类的框架引用那不是源码问题是当前机器缺桌面开发工作负载或者不在 Windows 上——这一条在 CI 上尤其常见后面避坑章节会展开。跑示例之前还要注意一点示例工程通常直接引用src下的项目而不是包所以你得先把被引用的项目编过否则 Visual Studio 里的错误列表会堆一堆「找不到类型 PrismApplication」看着吓人其实只是编译顺序没走对。2.3 新建一个能被 Prism 接管的 WPF 工程看懂源码之后落地动作是把自己的工程改造成 Prism 能接管的样子。三步换根元素、写App.xaml.cs、在 Shell 里留区域占位。第一步App.xaml的根元素从Application换成prism:PrismApplication标准 xaml 命名空间声明保持不变额外加一行xmlns:prismhttp://prismlibrary.com/!-- App.xaml根元素替换为 prism:PrismApplication -- prism:PrismApplication x:ClassOrders.Desktop.App xmlns:prismhttp://prismlibrary.com/ !-- 全局资源字典照旧放这里 -- /prism:PrismApplication第二步App.xaml.cs里重写三个方法这是整个应用的装配入口public partial class App : PrismApplication { // Shell 由容器创建构造注入才生效 protected override Window CreateShell() Container.ResolveMainWindow(); // 所有接口映射、导航视图、对话框都在这注册 protected override void RegisterTypes(IContainerRegistry containerRegistry) { containerRegistry.RegisterSingletonIOrderRepository, SqliteOrderRepository(); containerRegistry.RegisterForNavigationOrderListView, OrderListViewModel(); } // 模块清单也在这组装 protected override void ConfigureModuleCatalog(IModuleCatalog moduleCatalog) { moduleCatalog.AddModuleOrdersModule(InitializationMode.WhenAvailable); } }CreateShell必须走Container.Resolve而不是new MainWindow()否则 Shell 的构造函数注入会全部拿到 null这是新手最常翻的一次车。RegisterTypes里RegisterSingleton与Register的区别就是生命周期仓储、配置、HTTP 客户端这类无状态的用单例带请求上下文的用瞬时选错了会出现「上一个页面的数据串到下一个页面」这类玄学问题。第三步Shell 的 XAML 里留坑位ContentControl是最常用的区域宿主!-- MainWindow.xaml区域占位xmlns:prism 需在根节点声明 -- Grid !-- 导航区运行时由 RegionManager 填充内容 -- ContentControl prism:RegionManager.RegionNameMainRegion / /Grid区域名是全局字符串键写错一个字母就是白屏而且不报错。我一般会把区域名抽成常量类两边都引同一个常量省得靠肉眼比对。3. 启动链路拆解从 CreateShell 到区域可用的全过程搞不清启动顺序就会出现「我在构造函数里拿 RegionManager 拿到 null」「我在 RegisterTypes 里 Resolve 视图直接抛异常」这类问题。PrismApplication 的初始化是一条固定流水线把顺序记住很多异常看一眼就知道卡在哪一步。3.1 PrismApplication 的八步初始化基类内部的调用顺序大致是CreateContainerExtension确定用哪个容器DryIoc 还是 UnityRegisterRequiredTypes注册框架内置服务IRegionManager、IEventAggregator、IDialogService、IModuleManager都在这一步进容器RegisterTypes你的业务注册也就是上一步重写的那个方法ConfigureModuleCatalog组装模块清单CreateShell从容器解析主窗口InitializeShell默认实现里已经Show()了窗口OnInitialized此时窗口已显示适合做首屏数据加载InitializeModules按清单加载模块模块的RegisterTypes和OnInitialized在这一步执行。关键点在第 8 步模块加载发生在 Shell 显示之后。所以你在 Shell 的构造函数里去找模块注册的视图一定找不到正确姿势是在OnInitialized里或者干脆交给模块自己往区域里注册视图。另外如果你重写了InitializeShell想加个登录窗口记得别把基类的Show()逻辑丢掉否则应用起来是个后台进程任务栏都看不见。3.2 RegisterTypes 里到底该注册什么这个方法容易被写成杂物间什么都往里塞。按类型分三类就够了顺序也按这个来protected override void RegisterTypes(IContainerRegistry containerRegistry) { // 1. 基础服务接口到实现的映射生命周期一次说清 containerRegistry.RegisterSingletonIOrderRepository, SqliteOrderRepository(); containerRegistry.RegisterIClock, SystemClock(); // 2. 导航视图注册后才能被 RequestNavigate 按名字找到 containerRegistry.RegisterForNavigationOrderListView, OrderListViewModel(); // 带名字的重载适合同一个 View 多种入参形态 containerRegistry.RegisterForNavigationOrderDetailView, OrderDetailViewModel(OrderDetail); // 3. 对话框名字默认取 ViewModel 类型名去掉 ViewModel 后缀 containerRegistry.RegisterDialogConfirmDialog, ConfirmDialogViewModel(); }RegisterForNavigationTView, TViewModel()做两件事把 View 和 ViewModel 都注册进容器同时建立「名字 → View 类型」的映射表默认名字是 View 的类型名。带字符串重载的那个版本会覆盖默认名字之后RequestNavigate必须用你指定的名字两边不一致就是运行时异常。RegisterDialog同理默认对话框名是 ViewModel 去掉后缀ShowDialog(ConfirmDialog)里的字符串必须能对上。这里有个细节值得单独说视图注册成瞬时还是单例由框架决定默认是瞬时每次导航新建实例。如果你希望某个页面常驻保留滚动位置和输入状态别在容器上改生命周期那会影响所有解析点正确做法是在 View 或 ViewModel 上实现IRegionMemberLifetime并让KeepAlive返回true。3.3 Region 宿主与 ViewModelLocator 的匹配约定区域宿主选错控件行为会完全不同这是设计期就该定下来的事宿主控件适配器典型场景ContentControlContentControlRegionAdapter单视图导航区同一时刻只有一个ItemsControlItemsControlRegionAdapter平铺列表式多视图TabControl/ListBox/ComboBoxSelectorRegionAdapter多视图切换选中项联动第三方控件需自行实现IRegionAdapter停靠面板、多文档界面ContentControl只能容纳一个活动视图往里塞第二个会把第一个顶掉很多「我注册了两个视图怎么只显示一个」的疑问都源于此。另一半是 ViewModel 的自动挂接。ViewModelLocator靠命名约定解析View 在Views命名空间、ViewModel 在ViewModels命名空间、类名以ViewModel结尾三者齐了才会自动赋值DataContext。任何一条不满足DataContext就是空的界面一片空白但不报错。约定匹配不上时有两个兜底在 View 上显式写prism:ViewModelLocator.AutoWireViewModelTrue强制开启或者用ViewModelLocationProvider.Register手工建立映射// 约定对不上时的手工映射放在 App 的 OnInitialized 里执行一次 ViewModelLocationProvider.RegisterOrderListView, OrderListViewModel(); // 也支持工厂写法适合 ViewModel 需要额外构造参数的场景 ViewModelLocationProvider.RegisterOrderListView(() new OrderListViewModel(new DesignClock()));手工映射的优先级高于命名约定适合历史项目里命名空间已经被搞乱、又不方便整体重命名的情况。4. 模块化与导航IModule 与 INavigationAware 的配合Prism 真正值钱的地方是模块化一个功能一个程序集运行时按清单加载删掉 dll 功能就没了这才叫插件。而模块之间的连接靠两样东西——区域导航传参和事件聚合一个管「跳过去带上数据」一个管「这边发生了那边要知道」。4.1 IModule 的三段式生命周期一个模块就是一个实现了IModule的类只有两个方法但各自的职责边界要分清public class OrdersModule : IModule { private readonly IRegionManager _regionManager; // 模块实例本身由容器解析构造注入在这里有效 public OrdersModule(IRegionManager regionManager) _regionManager regionManager; // 第一阶段注册本模块的类型此时不要碰 UI public void RegisterTypes(IContainerRegistry containerRegistry) { containerRegistry.RegisterForNavigationOrderListView, OrderListViewModel(); containerRegistry.RegisterSingletonIOrderService, OrderService(); } // 第二阶段模块已就绪可以往区域里挂视图 public void OnInitialized(IContainerProvider containerProvider) { _regionManager.RegisterViewWithRegion(MainRegion, typeof(OrderListView)); } }两个阶段的区别很关键RegisterTypes执行时其他模块可能还没注册完这时候去解析跨模块的服务会抛「未注册」OnInitialized执行时所有模块的注册都完成了才是互相拿服务、挂视图、订阅事件的时机。我见过把数据库连接初始化写在RegisterTypes里的一旦模块加载顺序变了就连不上库血泪经验。RegisterViewWithRegion是「懒挂载」它只登记一个类型等区域真正可用时才去容器解析并塞进去比直接region.Add(view)安全得多后者在 Shell 还没显示时容易拿到 null 区域。4.2 ModuleCatalog 的来源与加载模式模块清单可以写死也可以从目录扫描、从配置文件读各有各的适用面protected override void ConfigureModuleCatalog(IModuleCatalog moduleCatalog) { // 随应用启动加载 moduleCatalog.AddModuleOrdersModule(InitializationMode.WhenAvailable); // 按需加载登录后判断权限再调 LoadModule moduleCatalog.AddModuleReportsModule(InitializationMode.OnDemand); // 带依赖声明AuditModule 依赖 OrdersModule加载器会先保证前者就绪 moduleCatalog.AddModule(new ModuleInfo { ModuleName AuditModule, ModuleType typeof(AuditModule).AssemblyQualifiedName, InitializationMode InitializationMode.WhenAvailable, DependsOn new Collectionstring { OrdersModule } }); }WhenAvailable和OnDemand的差别不只是时机还是启动速度。模块多了以后全用WhenAvailable冷启动会明显变慢因为每个模块的RegisterTypes都要跑一遍。我一般的分法是Shell 骨架、主菜单、登录相关用WhenAvailable业务功能页全部OnDemand用户在菜单上点进去时再IModuleManager.LoadModule(ReportsModule)加载完的模块会缓存重复调用不会重复初始化。DependsOn只影响加载顺序不会替你解决类型依赖跨模块调用还是得通过接口或者事件别指望靠它拿到对方的内部实现。4.3 RequestNavigate 传参与 INavigationAware 回调导航是 Prism 里最高频的操作参数用NavigationParameters这个字典式容器传递发送端和接收端靠字符串 key 对齐public class OrderListViewModel : BindableBase { private readonly IRegionManager _regionManager; private Order _selectedOrder; public OrderListViewModel(IRegionManager regionManager) { _regionManager regionManager; // CanExecute 依赖 SelectedOrder属性变化时手动触发重算 OpenDetailCommand new DelegateCommandOrder(OpenDetail, o o ! null); } public DelegateCommandOrder OpenDetailCommand { get; } public Order SelectedOrder { get _selectedOrder; set { if (SetProperty(ref _selectedOrder, value)) OpenDetailCommand.RaiseCanExecuteChanged(); // 关键不刷新按钮永远是灰的 } } private void OpenDetail(Order order) { var parameters new NavigationParameters { { order, order }, { mode, readonly } }; // 第二个参数是注册导航时给的名字必须与 RegisterForNavigation 一致 _regionManager.RequestNavigate(MainRegion, OrderDetail, result { if (result.Success) return; // 失败通常是没注册或区域名写错日志里至少留个堆栈 Debug.WriteLine(result.Exception); }, parameters); } }接收端实现INavigationAware三个方法的语义分别是「进来时」「要不要复用现有实例」「离开时」public class OrderDetailViewModel : BindableBase, INavigationAware { private Order _current; public void OnNavigatedTo(NavigationContext navigationContext) { // key 与发送端完全一致大小写敏感 _current navigationContext.Parameters.GetValueOrder(order); var mode navigationContext.Parameters.GetValuestring(mode); } // 返回 true 复用当前实例false 新建按订单 Id 判断是否同一个页面 public bool IsNavigationTarget(NavigationContext navigationContext) { var target navigationContext.Parameters.GetValueOrder(order); return target ! null target.Id _current?.Id; } public void OnNavigatedFrom(NavigationContext navigationContext) { } }IsNavigationTarget是性能与体验的分水岭。默认返回false意味着每次导航都新建 ViewModel页面状态全丢返回true复用实例但要注意在OnNavigatedTo里刷新数据否则用户看到的是上一次的内容。判断依据一般用业务主键别用引用相等序列化过的对象引用必然不同。NavigationParameters传复杂对象只在同一进程内有效如果你要做导航状态持久化或者跨进程恢复参数得能序列化最好只传 id 这类简单值页面自己再去查一次。4.4 EventAggregator模块之间的松耦合通道导航是「A 跳到 B」事件是「A 发生了谁关心谁处理」。事件类继承PubSubEventT泛型参数就是载荷类型// 事件定义放在 Core 层或公共契约层发布订阅双方都引它 public class OrderSavedEvent : PubSubEventOrder { }发布一行订阅三行重点全在订阅参数上// 发布端不需要知道有谁在听 _eventAggregator.GetEventOrderSavedEvent().Publish(order); // 订阅端线程、弱引用、过滤三个参数决定行为 _token _eventAggregator.GetEventOrderSavedEvent() .Subscribe(OnOrderSaved, ThreadOption.UIThread, // 回调切回 UI 线程省掉手动 Dispatcher keepSubscriberReferenceAlive: false, // 弱引用防止忘取消订阅导致页面不释放 filter: o o.Amount 0); // 过滤在订阅端做发布端保持干净ThreadOption有三个值PublisherThread是默认谁发布就在谁线程回调UIThread会切回界面线程WPF 里更新控件必须用它BackgroundThread丢线程池适合做耗时但不碰 UI 的后续处理。第三个参数keepSubscriberReferenceAlive设成false用的是弱引用订阅者被回收后回调自动失效代价是如果订阅者是个临时对象可能还没收到事件就被回收了这一点很容易在短生命周期对象上翻车。Publish是同步的订阅者里做耗时操作会把发布线程卡住需要异步就自己开任务别指望事件帮你排队。5. 五个真实踩过的坑从白屏到按钮永远灰这一节的内容基本都是我在项目里真遇到过、并且排查超过半小时的问题按「现象 → 原因 → 解决」记录遇到了能直接对照。5.1 区域一片空白连报错都没有现象Shell 跑起来了区域位置是空的输出窗口没有任何异常。原因prism:RegionManager.RegionName这行 XAML 依赖xmlns:prism声明漏了声明时 XAML 解析器会当成未知属性直接忽略不报错。另一种情况是模块还没加载RegisterViewWithRegion自然没执行。解决先检查 XAML 根节点的xmlns:prismhttp://prismlibrary.com/再在OnInitialized里遍历_regionManager.Regions把区域名打出来确认目标区域确实已注册。区域名拼写大小写不一致也会静默失败建议抽成常量。5.2 按钮一直是灰的CanExecute 明明返回 true现象DelegateCommand的CanExecute逻辑没问题界面上按钮永远不可点改别的输入框之后偶尔又能点了。原因命令的CanExecute只在特定时机被求值属性变化不会自动触发重算。老代码里有ObservesProperty(() Xxx)的链式写法新工程里更容易忘的是手动RaiseCanExecuteChanged。解决在相关属性的 setter 里、SetProperty返回true之后立刻调RaiseCanExecuteChanged()只调一次。注意这个方法有线程亲和性在后台线程调用会抛异常异步加载完数据要切回 UI 线程再刷新。5.3 模块加载顺序变了事件收不到现象A 模块保存数据后发布事件B 模块的列表页不刷新重启几次有时候又正常。原因EventAggregator不做消息缓存发布时没有订阅者这次事件就丢了。两个模块都用WhenAvailable加载时谁先完成取决于依赖树和程序集扫描顺序看起来就是随机。解决模块 B 在OnInitialized里就订阅别等到页面打开才订阅同时把 B 的DependsOn声明到 A 上保证订阅先于发布。如果业务上确实无法保证顺序就在发布端落一份「最近一次状态」订阅方初始化时主动拉一次用事件做增量、用状态做兜底。5.4 切了几十个页面之后内存一直涨现象任务管理器里的内存曲线只涨不跌页面切走后对象还在。原因两个来源。一是视图被区域常驻默认实例没有销毁二是EventAggregator订阅用了强引用且没有Unsubscribe事件总线一直持有 ViewModel。解决视图实现IRegionMemberLifetime让KeepAlive按需返回订阅时优先用弱引用确实需要强引用就保存SubscriptionToken在 ViewModel 的Dispose里Unsubscribe(_token)。代理对象的强引用是最隐蔽的一种事件回调里捕获this就等于延长了生命周期能不用 lambda 捕获就别用。5.5 在 CI 上编译整个解决方案必挂现象本地 Visual Studio 里一切正常流水线上dotnet build报框架引用找不到。原因WPF 项目目标框架是net8.0-windows这类带平台后缀的 TFM并且用了UseWPF构建必须在带桌面组件的 Windows 环境里跑。跨平台的构建代理编不过不是源码问题。解决流水线上给构建任务加平台条件Prism.Core这类纯库放到跨平台任务里编WPF 相关项目单独用 Windows 代理或者在解决方案级别用条件判断跳过不适用的项目。顺带一提dotnet test跑单元测试也建议分层Core 层的测试在哪都能跑界面逻辑的测试依赖 UI 线程分开跑省一半时间。6. 二次开发把 Prism 的契约换成自己的基建源码读到这里剩下的就是改造。Prism 的扩展点设计得很规矩几乎每个关注点都留了接口改的时候照着接口实现别去碰框架内部的类。6.1 用自定义 RegionAdapter 接管第三方控件ContentControl、ItemsControl、Selector这三种宿主已经覆盖大部分场景但遇到停靠面板、多文档容器这类控件就得自己写适配器。以DockPanel为例核心就两个重写public class DockPanelRegionAdapter : RegionAdapterBaseDockPanel { public DockPanelRegionAdapter(IRegionBehaviorFactory factory) : base(factory) { } protected override void Adapt(IRegion region, DockPanel regionTarget) { region.Views.CollectionChanged (s, e) { if (e.Action NotifyCollectionChangedAction.Add) { foreach (FrameworkElement view in e.NewItems) regionTarget.Children.Add(view); // 视图入区域即在容器中呈现 } else if (e.Action NotifyCollectionChangedAction.Remove) { foreach (FrameworkElement view in e.OldItems) regionTarget.Children.Remove(view); } }; } // AllActiveRegion 允许多个视图同时活动SingleActiveRegion 只保留一个 protected override IRegion CreateRegion() new AllActiveRegion(); }写完之后在PrismApplication里注册映射否则框架还是用默认适配器protected override void ConfigureRegionAdapterMappings(RegionAdapterMappings mappings) { base.ConfigureRegionAdapterMappings(mappings); mappings.RegisterMapping(typeof(DockPanel), Container.ResolveDockPanelRegionAdapter()); }两个容易忽略的点适配器要能被容器解析构造函数参数得注册Adapt里只处理增删视图的显示隐藏靠IRegionMemberLifetime和ActiveViews控制别在里面塞业务逻辑。6.2 给导航加一层统一拦截权限校验、导航埋点、离开前确认这类横切需求最适合挂在导航事件上而不是每个 ViewModel 里复制一遍protected override void OnInitialized() { var region RegionManager.Regions[MainRegion]; region.NavigationService.Navigating OnNavigating; region.NavigationService.Navigated OnNavigated; } private void OnNavigating(object sender, RegionNavigationEventArgs e) { // 目标视图名在 e.NavigationContext.Uri 里可用于埋点和权限判断 Logger.Info($navigate - {e.NavigationContext.Uri}); }Navigating事件不能直接取消导航需要拦截就实现IConfirmNavigationRequest在ConfirmNavigationRequest里异步询问用户后调continuationCallback(true)。没确认就调回调导航会一直挂着界面看着像卡死这也是一个常见坑。如果需要更细的控制可以装饰INavigationService在RequestNavigate前统一改写参数比如自动补齐租户 id、自动带上当前用户上下文。6.3 改造后的验证清单改完不要只看「能跑」按这几条过一遍基本能筛掉大部分隐患验证项怎么验不通过说明什么模块独立可插拔删掉模块 dll 后启动Shell 里有硬引用或者编译期依赖区域宿主正确连续导航两次同一区域可能用错了 SingleActive 适配器命令状态同步改输入框观察按钮漏了RaiseCanExecuteChanged页面可回收反复进出页面看内存事件订阅或区域常驻未处理传输参数可空缺 key 时导航一次接收端没用GetValueT的默认值最后一条我最推荐养成习惯单元测试里用容器把 ViewModel 解析出来构造注入一旦写错立刻红比跑起来点半天快得多。从那以后我每次加新模块都会先写一个RegisterTypes的冒烟测试确认所有接口都能解析出来再往下写业务——这个习惯替我挡掉的空引用异常比任何调试技巧都多。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?