- A+
为何模块化
模块化是一种分治思想,不仅可以分离复杂的业务逻辑,还可以进行不同任务的分工。模块与模块之间相互独立,从而构建一种松耦合的应用程序,便于开发和维护。
开发技术
.Net 6 + WPF + Prism (v8.0.0.1909) + HandyControl (v3.4.0)
知识准备
什么是MVVM
Model-View-ViewModel 是一种软件架构设计,它是一种简化用户界面的事件驱动编程方式。Model:数据模型,用来存储数据。 View:视图界面,用来展示UI界面和响应用户交互。ViewModel:连接View和Model的中间件,起到了桥梁的作用。
什么是Prism
Prism 是一套桌面开发框架,用于在WPF和Xamarin Forms中构建松耦合、可维护、可以测试的XAML应用程序。Prism提供了一组设计模式的实现,这些模式有助于编写结构良好且可维护的XAML应用程序,包括MVVM、依赖注入、命令、事件聚合器等。
什么是HandyControl
HandyControl 是一套WPF控件库,它几乎重写了所有原生样式,同时包含80余款自定义控件。
搭建项目
假设现在有一套叫Lapis的业务系统,包含A和B两块业务。业务A含有<页面1>和<页面2>,业务B含有<页面3>。界面设计如下:
下面我们就按照上述要求,来搭建一套MVVM + 模块化的桌面应用程序。
首先,新建一个名为Lapis.WpfDemo的解决方案,分别创建以下四个不同项目:其中Lapis.Shell是WPF应用程序,其余是WPF类库。如图所示:
Lapis.Share: 是一个共享库,用来定义抽象基类和一些公共方法,供上层调用。它引用了Prism.Wpf、Prism.Core和HandyControl第三方Nuget包。BaseViewModel 是一个视图模型基类,继承自 BindableBase,分别定义了EventAggregator、RegionManager、LoadCommand 属性。代码如下:
1 /// <summary> 2 /// 视图模型基类 3 /// </summary> 4 public abstract class BaseViewModel : BindableBase 5 { 6 private DelegateCommand _loadCommand; 7 protected IEventAggregator EventAggregator { get; } //事件聚合器 8 protected IRegionManager RegionManager { get; } // 区域管理器 9 public DelegateCommand LoadCommand => _loadCommand ??= new(OnLoad); //界面加载命令 10 11 public BaseViewModel() 12 { 13 RegionManager = ContainerLocator.Current.Resolve<IRegionManager>(); 14 EventAggregator = ContainerLocator.Current.Resolve<IEventAggregator>(); 15 } 16 17 /// <summary> 18 /// 界面加载时,由Loaded事件触发 19 /// </summary> 20 protected virtual void OnLoad() 21 { 22 } 23 24 /// <summary> 25 /// 根据区域名称查找视图 26 /// </summary> 27 /// <param name="regionName">区域名称</param> 28 protected TView TryFindView<TView>(string regionName) where TView : class 29 { 30 return RegionManager.Regions[regionName].Views 31 .Where(v => v.GetType() == typeof(TView)) 32 .FirstOrDefault() as TView; 33 } 34 }
BaseViewModel.cs
Lapis.ModuleA 和 Lapis.ModuleB: 对应前端业务模块A和B, 模块A包含 PageOne 和 PageTwo 两个视图及视图模型,模块B只含 PageThree 一个视图及视图模型。按照Prism框架规定,视图模型最好以 视图名称 + ViewModel 来命名。如图所示:
其中,ModuleA 和 ModuleB 表示模块类,用于初始化模块和注册类型。ModuleA 代码如下:
1 [Module(ModuleName = "ModuleA", OnDemand = true)] 2 public class ModuleA : IModule 3 { 4 public void OnInitialized(IContainerProvider containerProvider) 5 { 6 var regionManager = containerProvider.Resolve<IRegionManager>(); 7 regionManager.RegisterViewWithRegion(ModuleARegionNames.RegionOne, typeof(PageOne)); // 将页面一注册到区域一 8 regionManager.RegisterViewWithRegion(ModuleARegionNames.RegionTwo, typeof(PageTwo)); // 将页面二注册到区域二 9 } 10 11 public void RegisterTypes(IContainerRegistry containerRegistry) 12 { 13 } 14 }
第7和第8行代码:分别将 PageOne 和 PageTwo 注册到 RegionOne 和 RegionTwo。为了方便,区域名称用字符串常量表示。
Lapis.Shell: 是一个启动模块,负责启动/初始化应用程序(加载模块和资源),它包含App启动类、主窗口、侧边菜单和Tab页内容视图及对应的视图模型等。其中 PageSelectedEvent 是一个页面选中事件,用于 ViewModel 之间传递消息,起到解耦作用。如图所示:
MainWindow 此处作为启动窗口/主窗口。为了让 MainWindow 代码保持简洁,我们只把它当作布局页面来使用。代码片段如下:
1 <Grid> 2 <Grid.ColumnDefinitions> 3 <ColumnDefinition Width="auto" /> 4 <ColumnDefinition /> 5 </Grid.ColumnDefinitions> 6 <!-- 侧边菜单栏内容 --> 7 <ContentControl Name="sideMenuContentControl" Width="200px" Margin="5" /> 8 <!-- Tab页主内容 --> 9 <ContentControl Name="tabPagesContentControl" Grid.Column="1" Margin="0,5,5,5" /> 10 </Grid>
第7和第9行代码:sideMenuContentControl 和 tabPagesContentControl 是两个内容控件,用来呈现左侧菜单和Tab页面视图。看到这里,大家一定会问:ContentControl 是通过什么来关联视图的?没错,就是上面提到的Region,我们可以在MainWindow.cs中进行区域设置,代码如下:
1 public partial class MainWindow : Window 2 { 3 public MainWindow() 4 { 5 InitializeComponent(); 6 RegionManager.SetRegionName(this.sideMenuContentControl, ShellRegionNames.SideMenuContentRegion); 7 RegionManager.SetRegionName(this.tabPagesContentControl, ShellRegionNames.TabPagesContentRegion); 8 } 9 }
然后,同样在 ShellModule 类里对 SideMenuContent 和 TabPagesContent 视图进行区域注册,这样主窗口就能显示左侧菜单和Tab页面了。代码如下:
1 [Module(ModuleName = "ShellModule", OnDemand = true)] 2 public class ShellModule : IModule 3 { 4 public void OnInitialized(IContainerProvider containerProvider) 5 { 6 var regionManager = containerProvider.Resolve<IRegionManager>(); 7 regionManager.RegisterViewWithRegion(ShellRegionNames.SideMenuContentRegion, typeof(SideMenuContent)); // 注册侧边菜单内容视图 8 regionManager.RegisterViewWithRegion(ShellRegionNames.TabPagesContentRegion, typeof(TabPagesContent)); // 注册Tab页面内容视图 9 } 10 11 public void RegisterTypes(IContainerRegistry containerRegistry) 12 { 13 } 14 }
ShellModule.cs
App 是WPF应用启动入口,由于使用了第三方Prism框架和HandyControl控件库,我们需要对 App.xaml 和 App.xaml.cs 两个文件做一些修改。代码如下:
1 <unity:PrismApplication 2 x:Class="Lapis.Shell.App" 3 xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 4 xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" 5 xmlns:local="clr-namespace:Lapis.Shell" 6 xmlns:unity="http://prismlibrary.com/"> 7 <Application.Resources> 8 <ResourceDictionary> 9 <ResourceDictionary.MergedDictionaries> 10 <ResourceDictionary Source="pack://application:,,,/HandyControl;component/Themes/SkinDefault.xaml" /> 11 <ResourceDictionary Source="pack://application:,,,/HandyControl;component/Themes/Theme.xaml" /> 12 </ResourceDictionary.MergedDictionaries> 13 </ResourceDictionary> 14 </Application.Resources> 15 </unity:PrismApplication>
App.xaml
1 public partial class App : PrismApplication 2 { 3 protected override void ConfigureModuleCatalog(IModuleCatalog moduleCatalog) 4 { 5 base.ConfigureModuleCatalog(moduleCatalog); 6 // 7 moduleCatalog.AddModule<ShellModule>(); //添加宿主模块 8 moduleCatalog.AddModule<ModuleA.ModuleA>(); //添加业务模块A 9 moduleCatalog.AddModule<ModuleB.ModuleB>(); //添加业务模块B 10 } 11 12 protected override Window CreateShell() 13 { 14 return Container.Resolve<MainWindow>(); //返回主窗体 15 } 16 17 protected override void RegisterTypes(IContainerRegistry containerRegistry) 18 { 19 } 20 }
App.xaml.cs
接下来,要做的就是左侧菜单和Tab页面之间的交互动作。不同于传统Winform的事件驱动机制,我们使用MVVM模式将视图和UI逻辑分离。因此一般情况下,所有的界面逻辑都应该在 ViewModel 里完成。SideMenuContentViewModel 通过事件聚合器发布页面选中事件,TabPagesContentViewModel 则通过订阅该事件来进行页面切换,代码如下:
1 /// <summary> 2 /// 侧边菜单内容视图模型 3 /// </summary> 4 public class SideMenuContentViewModel : BaseViewModel 5 { 6 private DelegateCommand<string> _menuSelectedCommand; 7 8 private List<PageInfo> _pages = new() 9 { 10 new PageInfo { Id = "1" ,RegionName = "RegionOne", DisplayName = "子菜单1" }, 11 new PageInfo { Id = "2", RegionName = "RegionTwo", DisplayName = "子菜单2" }, 12 new PageInfo { Id = "3", RegionName = "RegionThree", DisplayName = "子菜单3" }, 13 }; 14 15 public DelegateCommand<string> MenuSelectedCommand => _menuSelectedCommand ??= new DelegateCommand<string>(ExecuteMenuSelectedCommand); 16 17 private void ExecuteMenuSelectedCommand(string id) 18 { 19 var info = _pages.Find(x => x.Id == id); 20 if (info != null) 21 { 22 EventAggregator.GetEvent<PageSelectedEvent>().Publish(info); 23 } 24 } 25 }
SideMenuContentViewModel.cs
1 /// <summary> 2 /// Tab页面内容视图模型 3 /// </summary> 4 public class TabPagesContentViewModel : BaseViewModel 5 { 6 private TabControl _tabControl; 7 8 protected override void OnLoad() 9 { 10 _tabControl = TryFindView<TabPagesContent>(ShellRegionNames.TabPagesContentRegion)?.FindName("tabControl") as TabControl; 11 12 EventAggregator.GetEvent<PageSelectedEvent>().Subscribe(OnPageSelected); 13 } 14 15 /// <summary> 16 /// 页面选中事件处理 17 /// </summary> 18 /// <param name="page"></param> 19 private void OnPageSelected(PageInfo page) 20 { 21 try 22 { 23 var existItem = FindItem(_tabControl, page.RegionName); 24 if (existItem != null) 25 { 26 existItem.IsSelected = true; 27 } 28 else 29 { 30 // 创建页面区域控件 31 var pageContentControl = new ContentControl(); 32 pageContentControl.SetRegionName(page.RegionName); 33 34 var item = new TabItem 35 { 36 Name = page.RegionName, // 区域名称,如:RegionOne、RegionTwo 37 Header = page.DisplayName, // 页面名称 38 IsSelected = true, 39 Content = pageContentControl 40 }; 41 42 _tabControl.Items.Add(item); 43 } 44 } 45 catch { } 46 } 47 48 private TabItem FindItem(TabControl tc, string name) 49 { 50 foreach (TabItem item in tc.Items) 51 { 52 if (item.Name == name) 53 { 54 return item; 55 } 56 } 57 return null; 58 } 59 }
TabPagesContentViewModel.cs
整个UI交互过程,如图所示:
至此,整个桌面前端应用就基本完成了。界面如图所示:
参考资料
Introduction to Prism | Prism (prismlibrary.com)
.NET Core 3 WPF MVVM框架 Prism系列文章索引 - RyzenAdorer - 博客园 (cnblogs.com)