【转载】模式——使用MVVM设计模式的WPF程序开发
原文:模式 - 具有模型-视图-视图模型设计模式|的 WPF 应用微软学习 (microsoft.com)
翻译:https://blog.csdn.net/weixin_37537723/article/details/106916294
案例:下载 https://github.com/julid29/confsamples/tree/master/WPF/MvvmDemoApp/DemoApp
说明
本文是笔者自己翻译的 Josh Smith在MSDN上发布的期刊Patterns-WPF Apps With The Model-View-ViewModel Design Pattern。纯属于笔者个人喜好,如有侵权请相关人员告知。并且笔者精力、经验和水平有限,翻译存在很多不当之处,也望大家批评指正。
文章中的演示代码笔者也注释了中文说明,具体的源代码文件待笔者整理好之后择日更新在本文中。
正文
有很多流行的设计模式可以用来驯服开发程序这个“野兽”,但是想要合理的分离和解决其中大部分的问题是非常困难的。设计模式越复杂,越有可能通过使用快捷方式取代之前所做的大量的工作。
这不总是设计模式的错误,有时候我们需要编写非常多的代码来使用复杂的设计模式因为使用的UI平台可能并不适用一些简单的设计模式。所以真正需要的是简单的、经过事件考验的、开发者认可的创建UI的设计模式。幸运的是,WPF(Windows Presentation Foundation)正是所需要的设计模式。
随着WPF在软件世界中使用率的提升,WPF社区建立了自己的关于开发模式和应用实践的生态。在这篇文章中,我将讨论用于WPF中设计和实现客户端应用程序的最佳实践方式。结合利用WPF的一些特性和MVVM设计模式,我将通过一个示例程序来讲解如何简单正确的创建一个WPF应用。
文章的末尾将会阐明数据模板(data templates),命令(commands),数据绑定(data biding),资源(resource)系统和MVVM模式是怎样结合在一起创建一个简单的、可检验的、框架稳定的WPF应用。本文实现的演示程序可以作为一个真实使用MVVM设计模式的WPF应用程序模板。演示方案中的单元测试的使用说明了将业务逻辑分离到ViewModel类之后,测试一个应用程序的用户界面是多么简单。在深入去了解细节之前,让我们回顾一下为什么你应该首先考虑使用MVVM设计模式。
秩序VS混沌
没有必要在简单的"Hello, World!"项目中使用设计模板。任何有能力的开发者瞥一眼就可以读懂这几行代码。然而,随着程序中特性的增加、代码量的增加以及相应的用户控件的增加。最终,重复的代码和复杂的系统都激励开发者朝着更易于理解、交流、扩展和问题解决的方向去优化代码。我们通过使用标准的名称(以代码在系统中扮演的角色来定义)来命名源代码中特定的实体以减少系统中的识别混乱。
开发者经常刻意的根据设计模板来构建自己的代码以便为了让实现方法有层次结构,这种方式并没有错。但是在本文里,我验证了使用MVVM作为WPF应用框架的好处,包括使用标准术语来定义MVVM中的特定的类的名称,例如如果一个类是视图(View)的抽象类则类名以"ViewModel"结尾。这个方法可以避免之前提到的识别混乱的问题。相反,你可以称心的应对各种可控的混乱状态,这种混乱的状态是普遍存在于大部分软件开发项目中的。
MVVM的演变
随着用户交互的软件设计应运而生了很多出名的设计模式。例如,MVP(Model-View-Presenter)模式就在各种UI软件设计平台广受欢迎。MVP是发展了数十年的MVC(Model-View-Controller)模式的变种。防止你从未使用过MVP模式,这里做一个简单的介绍。你在屏幕前看到的就是View,它显示出来的数据就是Model(模型),Presenter(展示器)将两者连接起来。View依赖于Presenter实现对Model的数据的填充、对用户输入做出反应、提供输入验证(可能通过委托发送给模型)以及其他类似的任务。如果你有意愿了解更多关于MVP的内容,我推荐你阅读Jean-Paul Boodhoo的August 2006 Design Patterns column。
回到2004年,Martin Fowler发表了一篇关于名为PM(Presentation Model)模式的文章。PM模式将View从行为和状态中分离开,类似于MVP模式。PM模式中有趣的地方是创建了一个名为Presentation Model的抽象的View。View也就变成了Presentation Model的渲染。在Fowler的解释中,他说明了Presentation Model需要频繁的更新View, 这样能够保证两者之间的同步性。同步的逻辑代码编写在于Presentation Model类中。
在2005年,John Gossman(现在是微软公司的一名WPF和Silverlight架构师)将MVVM模式公布在他的博客中。MVVM等价于Fowler提出的Presentation Model,两个模式的特性都是有一个包含View状态和行为的抽象。Fowler介绍Presentation Model作为一个创建UI平台独立的抽象的View,然而Gossman介绍MVVM作为一个简化用户操作界面的核心功能的标准化方式。从这方面考虑,我认为MVVM是一个为WPF和Silverlight平台特殊定制的更常规的PM模式。
在2008年9月的期刊中Glenn Block发表了一篇非常棒的文章:Prism: Patterns for Building Composite Applications with WPF。他为微软WPF的复合应用指导做出了解释。从未使用ViewModel这个术语,取而代之,使用Presentation Model这个术语用于描述View的抽象。贯穿全文,然而我更倾向于MVVM模式,并且将抽象的View作为ViewModel。因为我发现这个术语在WPF和Silverl社区更为流行。
与MVP中的Presenter不同,ViewModel不需要实现对View的引用。View将属性绑定到ViewModel中,反向的,ViewModel暴露的属性包含在Model对象和View中特殊的状态。View和ViewModel之间的绑定的构建非常简单只需将ViewModel对象被作为View的上下文(DataContext)来设置。如果ViewModel中的属性值发生改变,新的值会通过绑定自动传送到View。当用户点击View中的按钮,ViewModel中的命令将执行相应的请求。无论是ViewModel还是View,他们执行Model数据的所有变更。
View类不知道Model类的存在,同时ViewModel和Model对View也一无所知。事实上,Model显然知道ViewModel和View的存在。这是一个非常低耦合的设计,接下来你会看到这其中的好处。
为什么WPF开发者热爱MVVM
一旦一个开发者非常熟悉WPF和MVVM就会变得对这两者很难区分。MVVM是WPF开发者的通用语言因为它非常适用于WPF平台,并且WPF被设计用来(与其他平台相比)让MVVM更简单的创建应用。事实上,微软内部也使用MVVM来开发WPF应用,例如Microsoft Express Blend,同时核心的WPF平台也正在建设中。MVVM凸显了WPF的很多方面的特性,例如宽松的控件模型和数据模板,对状态和行为强分离的显示方式。
使MVVM成为WPF的一个优秀的模式的最重要的原因在于使用了数据绑定的基础结构。通过将View的属性绑定给ViewModel,降低了两者之间的耦合度并且完全避免了在ViewModel中写代码而直接更新View。数据绑定系统同时也支持输入验证,这就提供了一个标准的方式来将输验证错误信息传递给View。
另两个使得该模式在WPF下非常好用的原因是使用数据模板和资源系统。数据模板应用于在用户界面中显示ViewModel对象的View中。你可以在XAML中声明模板并让资源系统在运行时自动的定位和应用这些模板。你可以在我2008年7月刊的文章Data and WPF: Customize Data Display with Data Binding and WPF中了解更多关于绑定和数据模板的内容。
如果WPF不支持命令,那么MVVM模式将不会那么强大。在本文中,我将展示ViewModel如何将命令暴露给View的,也就是说View如何能够使用ViewModel的功能的。如果你对命令不是很熟悉,我推荐你阅读Brain Noyes在2008年9月刊出的一篇综合性强的文章Advanced WPF: Understanding Routed Events and Commands in WPF。
除了WPF和Silverlight2的特性让MVVM成为构建应用程序理所当然的方法之外,还有就是MVVM模式也因为ViewModel类能够非常容易的用在单元测试(uint test)中而出名。当一个应用程序的内部逻辑存放在一组ViewModel的类中,你就可以很容易的写代码来测试它。在某种意义上,Views和单元测试就是两种不同类型的ViewModel使用者。对ViewModels的一整套测试方法为应用程序提供了免费和快速的回归测试的能力,有助于降低维护应用程序时间的成本。
除了提升创建自动回归测试能力之外,ViewModel类的可测试性能够帮助开发者更简便的设计用户界面的外观。当你设计一个应用时,你通常可以考虑对ViewModel写一个单元测试,从而决定内容时放在View还是ViewModel中。如果你能够在不创建任何UI对象的情况下对ViewModel写一个单元测试,那么你就完全可以单独设计ViewModel的外观因为它独立于任何特定的可视化元素。
最后,对于那些使用可视化设计器的开发者,使用MVVM能够更简单的创建一个平滑的设计器/开发器工作流。由于View可以是任意ViewModel的使用者,所以更换不同的View来渲染ViewModel也就非常简单。这能够让设计者快速的构建和评估应用的用户交互。
开发团队可以专注于创建稳定的ViewModel类,设计团队可以专注于实现对用户友好的Views。只需要确保View的XAML文件中实现了正确的绑定就能保证两个团队之间输出的匹配。
演示应用程序
到目前为止,我回顾了MVVM的历史和操作原理,还说明了为何它在WPF开发者之间如此流行。现在是时候撸起你的袖子干的实际的工作了。文章中的演示示例使用了多种方式来应用MVVM。它提供了丰富的样例资源来帮助实现将概念转变为现实。我是在Visual Studio 2015 SP1与Microsoft .NET Framework 5.1 SP1的环境下创建的演示应用。在Visual Studio的单元测试系统中运行单元测试。
应用包含任意多的工作空间(Workspace),用户通过单击左侧导航栏的命令链接打开其中一个。所有的工作空间都保存在主内容显示区域的选项卡控件(TabControl)中。用户可以通过单击工作空间选项卡上的关闭按钮关闭选项卡。应用包含两个可用的工作空间:“查看所有客户”和“创建新客户”。在运行应用程序并打开一些工作空间后,用户界面入图1所示。
应用中“查看所有客户”工作空间只有一个实例只能打开一次,但是“创建新客户”工作空间可以打开任意多个。当用户决定创建一个新客户时,他需要填写图2的表格。
在表格里输入有效值之后点击保存按钮,新的客户的姓名就会显示在该选项卡上并且新的客户会被添加到所有客户的列表中。应用程序不支持删除和编辑已存在的客户,但是这个功能包括其他类似的特性都可以在已有的应用框架的顶层实现。现在你应该对这个演示应用有了较深的了解,那么让我们来研究它是如何设计并实现的吧。
依赖的命令逻辑
这个应用的每个View背后的都有一个空的代码文件,除了在构造函数中生成的标准的名为InitializeComponent的样例代码。实际上,你是可以将View的后台代码从项目中移除,应用程序依然可以正常的编译运行。尽管在View中缺乏事件处理的方法,但当用户点击按钮时,应用程序任然可以响应满足用户的请求。这个能实现的原因是因为在用户界面的超链接、按钮和菜单栏的命名属性中建立了绑定机制。这些绑定确保了当用户点击这些控件,由ViewModel暴露出来的ICommand对象能够执行。你可以将命令对象想象成一个适配器,它的功能是让在XAML中声明的来自View的ViewModel的功能变得更容易使用。
当一个ViewModel暴露了ICommand类型的实列属性,命令对象就会使用这个ViewModel对象来完成它的工作。一个可能的实现模式就是在ViewModel类中创建一个私有的嵌套类,以便命令可以访问其包含的ViewModel的私有成员并且不影响到命名空间。该嵌套类实现ICommand的接口,并在构造函数中声明对包含ViewModel对象的引用。但是,为ViewModel中暴露出来的每个命令创建一个嵌入类实现ICommand会增大ViewModel的大小。并且更多的代码意味着更多bug的可能。
在演示应用中,使用RelayCommand类在解决上面的问题。RelayCommand允许你在它的构造函数中传递委托来实现命令的逻辑。这个方法可以让ViewModel类中使用简洁明了的命令实现。RelayCommand是Microsoft Composite Application Library中DelegateCommand的一个简单的变体。RelayCommand实现如下图3。
public class RelayCommand : ICommand { #region Fields readonly Action<object> _execute; readonly Predicate<object> _canExecute; #endregion // 字段 #region Constructors public RelayCommand(Action<object> execute) : this(execute, null) { } public RelayCommand(Action<object> execute, Predicate<object> canExecute) { if (execute == null) throw new ArgumentNullException("execute"); _execute = execute; _canExecute = canExecute; } #endregion // 构造函数 #region ICommand Members [DebuggerStepThrough] public bool CanExecute(object parameter) { return _canExecute == null ? true : _canExecute(parameter); } public event EventHandler CanExecuteChanged { add { CommandManager.RequerySuggested += value; } remove { CommandManager.RequerySuggested -= value; } } public void Execute(object parameter) { _execute(parameter); } #endregion // ICommand 成员 }
CanExecuteChanged事件作为Icommanmd接口实现的一部分,有一些有趣的特性。它通过委托订阅了CommandManager.ReuqerySuggested事件。这个确保了WPF命令架构在执行内部命令时询问所有的RelayCommadn对象是否可以执行。下面的代码来自CustomerViewModel类,展示了如何使用lambda表达式配置一个RelayCommand,我会在之后对其进行深入分析。
RelayCommand _saveCommand; public ICommand SaveCommand { get { if (_saveCommand == null) { _saveCommand = new RelayCommand(param => this.Save(), param => this.CanSave); } return _saveCommand; } }
ViewModel类层次结构
大部分ViewModel类都有一些公共的特性。通常需要实现INotifyPropertyChanged接口,需要有一个用户友好的显示名称,在本示例的工作空间中,还需要关闭窗口的功能。这个问题自然联想到创建一个或两个ViewModel的基类,这样新建的ViewModel类可以继承基类中的共用的功能。ViewModel类窗体的继承层次结构如图4。
为你创建的ViewModel创建一个基类是必要的。如果你倾向于在你的类中组合很多小的类来实现特性,而不使用层次结构也是没有问题的。跟其他的设计模式一样,MVVM是一个指导推荐,并不是一个规则。
ViewModelBase类
ViewModelBase是层次结构中的根类,它实现了常用的INotifyPropertyChanged接口并且拥有一个DisplayName属性。INotifyPropertyChanged接口包含一个叫做PropertyChanged的事件。任何时候在ViewModel对象中的一个属性有了新的值时,它会触发PropertyChanged事件来通知WPF的绑定系统有一个新值。一旦接受到通知,绑定系统开始查询这个属性,绑定了该属性的UI元素将获取这个新值。
为了让WPF知道是ViewModel对象中哪个属性发生了变化,PropertyChangedEventArgs类暴露了一个类型为字符串的PropertyName属性。你必须非常仔细的将正确的属性名传递到属性参数中,否则WPF将查询到错误的属性的值。
ViewModelBase的一个有趣的方面是它提供了验证给定名称属性是否真实存在在ViewModel对象中的功能。这个功能在重构时非常有用,因为Visual Studio 2008的重构特性中改变属性名不会在你的源代码中更新包含属性名的字符串。事件参数中使用不正确的属性名触发了PropertyChanged事件将会产生难以跟踪的非常微弱的bug,所以这个验证特性能够节省非常多的错误诊断时间。添加了这个有用特性的ViewModelBase代码如下图5。
// In ViewModelBase.cs public event PropertyChangedEventHandler PropertyChanged; protected virtual void OnPropertyChanged(string propertyName) { this.VerifyPropertyName(propertyName); PropertyChangedEventHandler handler = this.PropertyChanged; if (handler != null) { var e = new PropertyChangedEventArgs(propertyName); handler(this, e); } } [Conditional("DEBUG")] [DebuggerStepThrough] public void VerifyPropertyName(string propertyName) { // Verify that the property name matches a real, // public, instance property on this object. if (TypeDescriptor.GetProperties(this)[propertyName] == null) { string msg = "Invalid property name: " + propertyName; if (this.ThrowOnInvalidPropertyName) throw new Exception(msg); else Debug.Fail(msg); } }
CommandViewModel类
最简单具体的ViewModelBase子类是CommandViewModel。它暴露了一个类型为ICommand,名称为Command的属性。MainWindowViewModel通过它的命令属性暴露了一个CommandViewModel的集合。主窗口左侧导航栏区域显示了一个链接,链接每一个由MainWindowViewModel暴露出来的CommandViewModel,比如“查看所有客户”和“创建新客户”。当用户点击其中一个链接,从而执行其中的一个命令打开主窗口选项卡的一个工作空间。CommandViewModel类的定义如下:
public class CommandViewModel : ViewModelBase { public CommandViewModel(string displayName, ICommand command) { if (command == null) throw new ArgumentNullException("command"); base.DisplayName = displayName; this.Command = command; } public ICommand Command { get; private set; } }
在MainWindowResources.xaml文件中存在一个键为"CommandsTemplate"的一个数据模板。主窗口使用这个模板来渲染前文提及的CommandViewModel的集合。这个模板只是简单将CommandViewModel对象渲染为ItemsControl中的一个链接。每个超链接的Command属性绑定到CommandViewModel中的Command属性上。XAML显示如下图6。
<!-- In MainWindowResources.xaml --> <!-- This template explains how to render the list of commands on the left side in the main window (the 'Control Panel' area). --> <DataTemplate x:Key="CommandsTemplate"> <ItemsControl ItemsSource="{Binding Path=Commands}"> <ItemsControl.ItemTemplate> <DataTemplate> <TextBlock Margin="2,6"> <Hyperlink Command="{Binding Path=Command}"> <TextBlock Text="{Binding Path=DisplayName}" /> </Hyperlink> </TextBlock> </DataTemplate> </ItemsControl.ItemTemplate> </ItemsControl> </DataTemplate>
MainWindowViewModel类
从前面的类结构图可以看到,WorkspaceViewModel派生自ViewModelBase并添加了关闭的功能。“关闭”指的是在运行时从用户交互界面中移除某些工作空间。WorkspaceViewModel派生出三个类:MainWindowViewModel,AllCustomersViewModel和CustomerViewModel。MainWindowViewModel的关闭请求由创建了MainWindow和它的ViewModel的App类处理,如下图7所示。
// In App.xaml.cs protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); MainWindow window = new MainWindow(); // 创建主窗口绑定的ViewModel string path = "Data/customers.xml"; var viewModel = new MainWindowViewModel(path); // 当ViewModel被询求关闭,则关闭窗口 viewModel.RequestClose += delegate { window.Close(); }; // 通过设置上下文遍历元素树,将窗口中所有的控件绑定到ViewModel window.DataContext = viewModel; window.Show(); }
MianWindow包含了一个菜单栏目,菜单的Command属性绑定到MainWindowViewModel的CloseCommand属性上。当用户点击了菜单上个该栏目,通过调用窗口的Close方法来触发App类的响应,如下:
<!-- In MainWindow.xaml --> <Menu> <MenuItem Header="_文件"> <MenuItem Header="_退出" Command="{Binding Path=CloseCommand}" /> </MenuItem> <MenuItem Header="_编辑" /> <MenuItem Header="_设置" /> <MenuItem Header="_帮助" /> </Menu>
MainWindowViewModel包含一个名为Workspaces的WorkspaceViewModel对象的可视化集合。主窗口包含一个选项卡控件,其ItemsSource属性绑定了这个集合。每一个选项卡都有一个关闭按钮,其Command属性绑定了与之相关的WorkspaceViewModel实例的CloseCommand。一个配置每个选项卡的模板的缩略代码如下图所示。代码在MainWindowResource.xaml文件中,模板展示了如何渲染一个带关闭按钮的选项卡:
<DataTemplate x:Key="ClosableTabItemTemplate"> <DockPanel Width="120"> <Button Command="{Binding Path=CloseCommand}" Content="X" DockPanel.Dock="Right" Width="16" Height="16" /> <ContentPresenter Content="{Binding Path=DisplayName}" /> </DockPanel> </DataTemplate>
当用户点击了选项卡上的关闭按钮,WorkspaceViewModel的CloseCommand命令将会执行,从而触发它的RequestClose事件。MainWindowViewModel监控着它的工作空间的RequestClose事件,一旦事件发生将会从Workspaces集合中移除对应的工作空间。由于主窗口选项卡控件的ItemsSource属性绑定了WorkspaceViewModel的这个集合,从集合中移除一个项目将会导致相应的工作空间从选项卡控件中移除。MianWindowViewModel的逻辑如下图8所示。
// In MainWindowViewModel.cs ObservableCollection<WorkspaceViewModel> _workspaces; public ObservableCollection<WorkspaceViewModel> Workspaces { get { if (_workspaces == null) { _workspaces = new ObservableCollection<WorkspaceViewModel>(); _workspaces.CollectionChanged += this.OnWorkspacesChanged; } return _workspaces; } } void OnWorkspacesChanged(object sender, NotifyCollectionChangedEventArgs e) { if (e.NewItems != null && e.NewItems.Count != 0) foreach (WorkspaceViewModel workspace in e.NewItems) workspace.RequestClose += this.OnWorkspaceRequestClose; if (e.OldItems != null && e.OldItems.Count != 0) foreach (WorkspaceViewModel workspace in e.OldItems) workspace.RequestClose -= this.OnWorkspaceRequestClose; } void OnWorkspaceRequestClose(object sender, EventArgs e) { this.Workspaces.Remove(sender as WorkspaceViewModel); }
在单元测试(UnitTests)项目中,MainWindowViewModelTests.cs文件中包含了验证功能正常运行的测试方法。能够轻而易举的为ViewModel类创建单元测试是MVVM设计模式的一个巨大卖点,因为它能够在不编写与UI有关的代码的情况下进行简单的程序测试。这个测试方法如下图9所示。
// In MainWindowViewModelTests.cs [TestMethod] public void TestCloseAllCustomersWorkspace() { // 创建的是MainWindowViewModel,不是MainWindow. MainWindowViewModel target = new MainWindowViewModel(Constants.CUSTOMER_DATA_FILE); Assert.AreEqual(0, target.Workspaces.Count, "Workspaces isn't empty."); // 查找打开“查看所有客户”工作空间的命令. CommandViewModel commandVM = target.Commands.First(cvm => cvm.DisplayName == "View all customers"); // 打开“查看所有客户”的工作空间. commandVM.Command.Execute(null); Assert.AreEqual(1, target.Workspaces.Count, "Did not create viewmodel."); // 确保创建的工作空间的格式正确. var allCustomersVM = target.Workspaces[0] as AllCustomersViewModel; Assert.IsNotNull(allCustomersVM, "Wrong viewmodel type created."); // 通知“查看所有客户”的工作空间关闭. allCustomersVM.CloseCommand.Execute(null); Assert.AreEqual(0, target.Workspaces.Count, "Did not close viewmodel."); }
将View应用到ViewModel
MainWindowViewModel间接实现添加和移除主窗口选项卡项目上个WorkspaceViewModel对象。依靠数据绑定,选项卡的Content属性接收显示一个依赖于ViewModelBase的对象。ViewModelBase不是一个UI元素,所以其内部不支持对自身的渲染。默认情况下,WPF支持在文本块(TextBlock)中调用非可视化对象的ToString方法来渲染该对象。这显然不是你需要的,除非你的用户非常渴望看到ViewModel类型的类型名。
通过使用类型化的DataTemplates你可以非常容易的告诉WPF如何渲染一个ViewModel对象。一个类型化的DataTemplate没有分配x:Key值,但是在Type类的实例中包含一个DataType属性。如果WPF尝试渲染某一个ViewModel对象,它将会检查在使用范围内的资源系统中是否有一个类型化的DataTemplate的DataType与ViewModel(或者基类)对象一样。如果找到这样一个,它就用这个模板来渲染这个由选项卡Content属性引用的ViewModel对象。
MainWindowResource.xaml文件有一个资源字典(ResourceDictionary)。这个字典被添加到了主窗口的资源结构中,这意味着字典包含的资源都加进了窗口使用资源的范围中。当一个选项卡的内容被设置为ViewModel对象,那么源自这个字典的类型化的DataTemplate将会提供视图(这是一个用户控件)来渲染它。如下图10所示。
<!-- 这个资源字典供MainWindow使用. --> <ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="clr-namespace:DemoApp.ViewModel" xmlns:vw="clr-namespace:DemoApp.View" > <!-- This template applies an AllCustomersView to an instance of the AllCustomersViewModel class shown in the main window. --> <DataTemplate DataType="{x:Type vm:AllCustomersViewModel}"> <vw:AllCustomersView /> </DataTemplate> <!-- This template applies a CustomerView to an instance of the CustomerViewModel class shown in the main window. --> <DataTemplate DataType="{x:Type vm:CustomerViewModel}"> <vw:CustomerView /> </DataTemplate> <!-- 其它资源在这里忽略了... --> </ResourceDictionary>
你不需要编写任何代码来决定ViewModel对象使用哪个视图显示。WPF资源系统会为你完成所有的这些繁重的工作,你可以专注在更重要的事情上。更复杂的情况是有可能会以编程的方式来选中视图,但是这个在大多数情况下是不必要的。
数据模型和仓库
通过应用程序的视窗你已经看到了ViewModel对象的加载、显示和关闭。现在基础建设已经完成,你可以仔细的回顾应用程序主体实现的细节。在深入到应用的两个工作空间“查看所有客户”和“新建客户”之前,让我们首先检查数据模型和数据访问类。这些类的设计几乎与MVVM设计模式无关,因为你可以创建任意一个适用于WPF的数据对象的View Model类。
在演示程序中Customer是唯一一个Model类。这个类包含少量的表示公司客户信息的属性,例如第一名称,第二名称,邮箱地址。通过实现标准的IDataErrorInfo接口,该类还提供了验证信息。这个Customer类跟MVVM架构或者甚至WPF应用其实没有任何的关联内容,所以这个类可以来自于旧的业务库中。
数据必须来自并驻留在某处。在该应用中,使用CustomerRepository类的一个实例用来加载和存储所有客户的对象。这发生在从XML文件中加载客户数据的时候,但是与外部数据源的类型无关。数据可以来自数据库、网页服务、命名通道,硬盘的文件甚至信鸽:这其实不重要。不管数据来自哪里(MVVM设计模式可以从窗口获取数据),只要你的.NET对象中有数据。
CustomerRepository类暴露了一些方法,允许你获取有效的Customer对象,添加一个客户到仓库中,检查仓库中是否已经包含这个客户。既然应用不允许用户删除客户,仓库也不允许移除客户。当使用AddCustomer方法添加一个客户到CustomerRepository时会响应CustomerAdded事件。
显然与实际业务应用程序需要的数据模型相比这个应用的数据模型非常小,但是这个不重要。重要的是了解ViewModel类如何使用Customer和CustomerRepository。注意到CustomerViewModel是对Customer对象的一个封装。使用一组属性暴露了Customer的状态以及其它被CustomerView控件使用的状态。CustomerViewModel没有复制Customer的状态而是通过委托将其暴露出来,如下:
public string FirstName { get { return _customer.FirstName; } set { if (value == _customer.FirstName) return; _customer.FirstName = value; base.OnPropertyChanged("FirstName"); } }
当用户在CustomerView控件中创建了一个新的客户并点击保存按钮时,与该View关联的CustomerViewModel将添加一个Customer对象到CustomerRepository中。这将会触发CustoemrRepository的CustomerAdded事件,该事件目的是通知AllCustomersViewModel添加一个新的CustomerViewModel到它的AllCustomers集合中。在某种意义上,CustomerRepository充当了一个在处理各种Customer对象的ViewModels的同步机制。可能有人会将其视为使用的是中介设计模式。在接下来的章节我会介绍更多有关这个机制如何工作的。但是现在通过关联图11的结构图让我们先深入了解这些部件是如何组合在一起的。
新客户数据输入窗体
当用户点击“创建新客户”链接时,MainWindowViewModel添加一个新的CustomerViewModel到它的工作空间列表中,添加一个CustomerView控件来显示它。在用户输入了有效值到输入字段中,保存按钮将会使能这样用户就可以保存新客户的信息。没有什么比这更寻常的了,就是一个常规的带输入验证的数据输入窗体和保存按钮。
Customer类通过实现IDataErrorInfo接口支持内部验证。这个验证确保客户有一个第一名称,标准格式的邮箱地址,(如果客户是一个人)有一个第二名称。如果客户的IsCompany属性为true,那么第二名称不允许有值(公司名称没有第二名称)。从Customer对象的角度来说这个验证逻辑是有意义的,但是这不是用户界面上需要呈现出来的。用户界面需要的是让用户去选择新建的客户是一个人还是一个公司。Customer类型选择器初始化的值为“未选定”。如果客户的IsCompany属性值只允许是true或false,那么用户界面该怎样告诉用户这个用户类型是未选定呢?
假设你可以完全控制整个软件系统, 你可以改变IsCompany属性为一个可空类型的布尔量,这也就支持了未选定的值。然后,实际情况没有这么简单。假设你不能改变Customer类因为这个类来自于你公司另一个团队的旧库里。如果没有简单的方式来保存“未选中”的值因为现存的数据结构?如果其它应用程序已经使用这个Customer类并且依赖的这个属性就是一个正常的布尔量?再次,可以通过使用ViewModel来解决它。
图12中的测试方法展示了CustomerViewModel中的这个功能如何使用的。CustomerViewModel暴露了一个CustomerTypeOptions属性,这样客户类型选择器可以有三个字符串用来显示。同样它也暴露了一个CustomerType属性,该属性用来存储选择器中选择的字符串。当设置了CustomerType,它将字符串的值映射为基础Customer对象的IsCompany属性的布尔量。图13显示了这两个属性。
// In CustomerViewModelTests.cs [TestMethod] public void TestCustomerType() { Customer cust = Customer.CreateNewCustomer(); CustomerRepository repos = new CustomerRepository(Constants.CUSTOMER_DATA_FILE); CustomerViewModel target = new CustomerViewModel(cust, repos); target.CustomerType = "Company"; Assert.IsTrue(cust.IsCompany, "Should be a company"); target.CustomerType = "Person"; Assert.IsFalse(cust.IsCompany, "Should be a person"); target.CustomerType = "(Not Specified)"; string error = (target as IDataErrorInfo)["CustomerType"]; Assert.IsFalse(String.IsNullOrEmpty(error), "Error message should be returned"); }
// In CustomerViewModel.cs public string[] CustomerTypeOptions { get { if (_customerTypeOptions == null) { _customerTypeOptions = new string[] { "(Not Specified)", "Person", "Company" }; } return _customerTypeOptions; } } public string CustomerType { get { return _customerType; } set { if (value == _customerType || String.IsNullOrEmpty(value)) return; _customerType = value; if (_customerType == "Company") { _customer.IsCompany = true; } else if (_customerType == "Person") { _customer.IsCompany = false; } base.OnPropertyChanged("CustomerType"); base.OnPropertyChanged("LastName"); } }
CustomerView控件包含一个下拉列表(ComboBox)来绑定这两个属性,如下所示:
<ComboBox ItemsSource="{Binding CustomerTypeOptions}" SelectedItem="{Binding CustomerType, ValidatesOnDataErrors=True}" />
当下拉列表中选择的项发生改变时,数据源的IDataErrorInfo接口被查询检测新值是否有效。这个发生的原因是SelectedItem属性绑定具有ValidatesOnDataErrors,其设置为true。由于数据源是一个CustomerViewModel对象,绑定系统将向CustomerViewModel询问CustomerType属性的验证错误信息。大多数情况下,CustomerViewModel将所有错误验证的请求委托给它包含的Customer对象中。然而,由于Customer中对IsCompany属性没有未选中状态的定义,CustomerViewModel类必须实现下拉列表中新选中项的验证任务。代码如下图14
// In CustomerViewModel.cs string IDataErrorInfo.this[string propertyName] { get { string error = null; if (propertyName == "CustomerType") { // Customer类的IsCompany属性是一个布尔值,所以它没有“未选中”这 //个状态值. //CustomerViewModel类处理这个映射和验证 error = this.ValidateCustomerType(); } else { error = (_customer as IDataErrorInfo)[propertyName]; } //将命令注册到CommadManager,例如Save命令,实现对命名是否执行的查询. CommandManager.InvalidateRequerySuggested(); return error; } } string ValidateCustomerType() { if (this.CustomerType == "Company" || this.CustomerType == "Person") return null; return "Customer type must be selected"; }
这个代码的关键内容是使用CustomerViewModel的IDataErrorInfo的实现来处理ViewModel特定属性的验证请求和Customer对象的其它请求的委托。这允许你在Model类中使用验证逻辑并且拥有一个仅在ViewModel类中有意义的附加验证属性。
通过SaveCommand属性可以使得View能够保存一个CustoemrViewModel。在RelayCommand类检验之前,SaveCommand用来允许CustomerViewModel决定它是否能够保存自己并且当被告知要保存自己的状态时应该做什么。这这个应用中,保存一个新的客户就是简单的将其添加到CustomerRepository中。决定新的客户是否能够被保存取决于两个方面。在Customer对象中询问新客户是否有效,在CustomerViewModel中决定它是否有效。双重验证是必要的因为先发生的是ViewModel特定属性和验证的检查。CustomerViewModel的保存逻辑如下图15。
// In CustomerViewModel.cs public ICommand SaveCommand { get { if (_saveCommand == null) { _saveCommand = new RelayCommand(param => this.Save(), param => this.CanSave); } return _saveCommand; } } public void Save() { if (!_customer.IsValid) throw new InvalidOperationException("..."); if (this.IsNewCustomer) _customerRepository.AddCustomer(_customer); base.OnPropertyChanged("DisplayName"); } bool IsNewCustomer { get { return !_customerRepository.ContainsCustomer(_customer); } } bool CanSave { get { return String.IsNullOrEmpty(this.ValidateCustomerType()) && _customer.IsValid; } }
这里使用ViewModel是为了使得创建显示Customer对象和事件(如“未选中”状态的布尔量属性)的View更加简单。它也提供了用于通知客户去保存它的状态的能力。如果View直接绑定到Customer对象,那么View将需要大量的代码来实现这些功能。在标准的MVVM架构中,大部分View的后台代码都应该是空的,或者最多只包含View中的资源和操作控件的代码。有时候在View中编写用于与ViewModel对象交互的后台代码也是有必要的,比如触发一个事件或者调用一个方法,这个事件或方法通常是在ViewModel自身中难以调用的。
All Customer视图
演示应用包含一个显示所有客户列表的工作空间。列表中的客户根据输入的是个人还是公司进行分组。用户可以同时选中其中一个或多个客户并且在右下角可以查看选中这些客户的销售总额。
用户界面是一个用于渲染AllCustomerViewModel对象的AllCustomerView控件。每个列表项(ListViewItem)代表了AllCustomerViewModel对象暴露出的AllCustomers集合中的一个CustomerViewModel对象。在前面的章节中,你看到了CustomerViewModel如何渲染成一个数据输入窗体,现在同样的CustomerViewModel对象被渲染成了列表中的一项。CustomerViewModel类对显示它的可视化元素类别一无所知,这也就是说明了为什么它可以被反复使用。
AllCustomerView创建了如列表所示的组,这是通过将列表的ItemsSource绑定到一个CollectionViewSource上实现的。CollectionViewSource配置如下图16。
<!-- In AllCustomersView.xaml --> <CollectionViewSource x:Key="CustomerGroups" Source="{Binding Path=AllCustomers}" > <CollectionViewSource.GroupDescriptions> <PropertyGroupDescription PropertyName="IsCompany" /> </CollectionViewSource.GroupDescriptions> <CollectionViewSource.SortDescriptions> <!-- Sort descending by IsCompany so that the ' True' values appear first, which means that companies will always be listed before people. --> <scm:SortDescription PropertyName="IsCompany" Direction="Descending" /> <scm:SortDescription PropertyName="DisplayName" Direction="Ascending" /> </CollectionViewSource.SortDescriptions> </CollectionViewSource>
列表项和CustomerViewModel对象的关联是由列表的ItemContainerStyle属性建立的。允许列表项的属性绑定到CustomerViewModel属性上的样式(Style)被分配给每一个列表项的属性中。一个重要的绑定样式是创建列表项的IsSelected属性和CustomerViewModel的IsSelected属性之间的关联,如下所示:
<Style x:Key="CustomerItemStyle" TargetType="{x:Type ListViewItem}"> <!-- Stretch the content of each cell so that we can right-align text in the Total Sales column. --> <Setter Property="HorizontalContentAlignment" Value="Stretch" /> <!-- Bind the IsSelected property of a ListViewItem to the IsSelected property of a CustomerViewModel object. --> <Setter Property="IsSelected" Value="{Binding Path=IsSelected, Mode=TwoWay}" /> </Style>
当一个CustomerViewModel被选中或者不被选中时会改变所选中的客户总数量。AllCustomerViewModel类负责维护该值以便列表下的内容显示器(ContentPresenter)可以显示正确的值。图17显示了AllCustomerViewModel如何监控每个客户是被选中还是不被选中并通知View去更新显示值。
// In AllCustomersViewModel.cs public double TotalSelectedSales { get { return this.AllCustomers.Sum(custVM => custVM.IsSelected ? custVM.TotalSales : 0.0); } } void OnCustomerViewModelPropertyChanged(object sender, PropertyChangedEventArgs e) { string IsSelected = "IsSelected"; //确保我们引用的属性是有效的 //这个一个调试技术,在Release生成中不会执行 (sender as CustomerViewModel).VerifyPropertyName(IsSelected); //当一个客户的选择状态发生变更时,需要让系统知道选中的总数量的属性发生乐变化, //以便为了该属性获取一个新的值 if (e.PropertyName == IsSelected) this.OnPropertyChanged("TotalSelectedSales"); }
用户界面绑定TotalSelectedSales属性并应用货币格式来显示值。通过从TotalSelectedSales属性中返回一个字符串来替换double值,实现ViewModel对象(不是View)应用货币格式。在.NET框架3.5 SP1的ContentPresenter中增加了ContentStringFormat属性,所以如果是老版本的WPF应用需要添加下面所示的货币格式的代码:
<!-- In AllCustomersView.xaml --> <StackPanel Orientation="Horizontal"> <TextBlock Text="Total selected sales: " /> <ContentPresenter Content="{Binding Path=TotalSelectedSales}" ContentStringFormat="c" /> </StackPanel>
概括
WPF给予了应用开发者很多的特性,开发者需要转变思维方式去学以致用,更大的发挥WPF的力量。MVVM设计模式是一个简单有效的设计和实现WPF应用的指南。它能够让你创建在数据、逻辑和显示上更独立的,更易于混乱控制的开发软件。