[译]WPF 应用程序和MVVM设计模式 ——Josh Smith
这篇文章讨论:
模式与WPF
MVP模式
为什么MVVM更加适用于WPF
用MVVM构建一个应用程序
译文地址:
http://www.cnblogs.com/lujiao_cs/archive/2011/10/30/2229419.html
内容:
专业的软件用户界面开发不太简单。它可能混杂了数据、交互设计、视觉设计、连接、多线程、安全性、国际化、验证、单元测试以及可触摸技术。考虑到用户界面暴露了系统的底层并且必须满足用户的不可预知的需求,它可能是许多应用程序中最不稳定的部分。有一些常用的设计模式可以解决这个问题,但是隔离并且访问这些关注点可能比较难。模式越是复杂,更多的捷径可能会被用到,这些捷径渐渐的破坏了以前所有正确方式做事情的努力。
不总是设计模式的问题。有时候我们会用到复杂的设计模式,由于现有的UI平台不能提供一个很好的设计模式,它需要写很多的代码。我们需要的是这样一种平台,它使得利用简单的、能够经受时间考验的、并且能让开发者接受的设计模式来构建UI变得简单。幸运的是,WPF很好的提供了这些。
由于软件界采用WPF的比率不断增长,WPF团队已经开发了它自己的模式生态系统和实践。在这篇文章中,我将回顾一些很好的设计实践,并且用WPF实现一个应用程序客户端。利用WPF的一些核心特性结合MVVM设计模式,我将介绍一个实例程序,它展示了利用正确的方式构建WPF程序是多么简单。
在这篇文章的结尾,我们将用数据模板、命令、数据绑定、资源系统和MVVM模式结合在一起创建一个简单、可测试的、简单的框架。利用这个框架,我们可以创建任何的WPF程序。此文章的示例程序可以当做一个真实的WPF项目的模板,它运用MVVM作为核心架构。程序中的单元测试项目会告诉你测试应用程序的UI功能是多么简单,这些功能存在于ViewModel类中。在进入细节之前,让我们首先回顾一下为什么要用MVVM这样的模式。
有序与混乱
在一个简单的“hello world”程序里面使用设计模式是多余的,并且会适得其反。任何一个合格的开发人员能一目了然的理解几行代码。 然而,当程序的功能增加时,代码行和组件的数量相应地也会增加。最后,系统的复杂性和重复出现的问题增加,这促使开发者以一种易于理解、讨论、扩展、故障排除的的方式去组织代码。在源代码中,我们通过对实体的良好命名来减少认知的混乱。我们通过考虑它在系统中的功能角色来确定适用于一段代码的名称。
开发者经常通过设计模式特意的组织代码,而不是自然的运用它们。这两种方式都没有错,但是在这篇文章里,我考察了在一个WPF应用程序中明确地使用MVVM作为架构的好处。这些类的名字包含了MVVM设计模式中熟悉的条款。例如,以“ViewModel”结尾的类是View的抽象。这有助于减少避免前面提到的认知的混乱。你能很愉快的控制这种混乱,在许多专业的软件开发项目中这是一个自然的状态。
MVVM的演化
自从人们开始创建软件的用户界面,就有一些常用的设计模式使它变得变得简单。例如,MVP模式在各种UI编程平台下大受欢迎。MVP 是MVC模式的变种,它已经存在了数十年了。如果你以前没有用过MVP模式,这里做一个简单的说明。你在屏幕上看到的就是VIew,它所展示的数据就是Model,Presenter连接这两者。View依赖于Presenter 利用Model的数据去填充它、对用户的输入做出反应、提供输入验证以及一些其它的验证。如果你想要学习更多的MVP,推荐你阅读Jean-Paul Boodhoo 的 August 2006 Design Patterns column。在2004年,Martin Fowler 发表了关于Presentation Model (PM)模式的文章。PM 模式在将View和他的状态和行为分离上面比较相似。PM模式有趣的地方是创造了名为Presentation Model的View的抽象。View仅仅只是Presentation Model的表现。按照Fowler的解释,Presentation Model频繁更新的是它的View,以便这两者之间保持一致。 同步的逻辑作为代码存在于Presnetation Model类里。
在2005年,微软的WPF和SL架构师John Gossman在他的博客中公开了MVVM模式。 MVVM与 Fowler的PM模式完全相同,这两种模式都专注于View的抽象,它包含了View的状态和行为。Fowler引入Presentation Model作为一种与UI平台无关的View的抽象的创建,而Gossman引入MVVM作为一种利用WPF的核心特征去简化用户界面的创建的标准化的方式。 在这个以意义上看,MVVM相对于通用的PM更加专一化,是为WPF和Silverlight平台而量身定做的。
Glenn Block在2008年九月发表了这篇优秀的文章:Prism: Patterns for Building Composite Applications with WPF。他解释了针对于WPF的Microsoft Composite Application Guidance。ViewModel 还未被用到。反而,Presentation Model 被用来描述View的抽象。贯穿这篇文章,我将这种模式叫做MVVM、View的抽象叫做ViewModel。我发现这个术语在WPF和MVVM社区更加流行。
不像MVP的提出,ViewModel 不需要View的引用。View绑定到ViewModel中的一个属性。这个属性展示了Model对象的数据以及View的其它状态特性。由于ViewModel 对象被设置为View的DataContent,View和ViewModel之间的绑定易于构建。如果ViewModel 里面的属性值改变了,那些新的值会通过数据绑定传递到View。当用户点击View的一个按钮,ViewModel 里面的一个命令就会执行完成需要的动作。ViewModel而不是View执行了Model数据的修改。View类不知道Model类的存在,ViewModel 和Model也不知道View。实际上,Model完全不知道ViewModel和View存在的事实。 这是一种松耦合的设计,你将看到这种方式的好处。
为什么WPF开发者喜欢MVVM
一旦开发人员喜欢上了MVVM和WPF,就很难区分他们两者。MVVM 是WPF开发者的通用语,因为他非常适合WPF平台,WPF被设计使它很容易使用MVVM模式去构建应用程序。实际上,微软内部利用MVVM开发应用程序,例如Expression Blend,而核心WPF平台正在构建中。WPF的许多方面,例如look-less控件模型和数据模板,通过MVVM将显示和状态和行为分离。使得MVVM成为一种优秀的设计模式的最重要的一个方面就是数据绑定。通过将View的属性绑定到ViewModel,可以在这两者之间得到松耦合,完全地移除了在ViewModel里直接写代码更新一个view的需要。数据绑定系统同时提供了输入验证,提供了一个标准的方式将验证错误传到View。
WPF的另外两个使得MVVM模式有用的特性的数据模板和资源系统。View的数据模板将ViewModel的对象显示到用户界面。你可以在Xaml中定义模板,并且在运行时让资源系统自动查找和应用这些模板。你可以在我的2008年7月的文章Data and WPF: Customize Data Display with Data Binding and WPF中学习更多的数据绑定和数据模板。如果WPF不支持Commands,MVVM将远没有如此强大。在这篇文章中,我将告诉你ViewModel如何将Commands公开到View,从而让View使用它的功能。如果你对Command不熟悉,我推荐你阅读Brian Noyes在2008年9月的综合性的文章Advanced WPF: Understanding Routed Events and Commands in WPF。
除了WPF(以及Silverlight2)的特征使得MVVM自然地方式去构建应用程序,这个模式流行的也因为ViewModel类容易做单元测试。当一个程序的交互逻辑存在于一套ViewModel类中时,你可以很容易写出代码测试它。从某种意义上说看,Views 和单元测试是ViewModel 的两种不同的消费者。应用程序的ViewModel的一套测试程序提供了一个自由快速的回归测试,帮助减少程序维护的消耗。
除了促进创造自动回归测试,ViewModel 的可测试性有利于正确地设计易于换肤的用户界面。当你设计一个应用程序的时候,想象你需要你需要写一个单元测试来测试ViewModel,这样你就明白一些功能是该写在View里面还是ViewModel里面。如果你能为Viewmodel写单元测试而没有创建任何UI对象,你也就能完全地剥离ViewModel因为它不依赖于指定的可见元素。
最后,对于视觉设计的开发人员,使用MVVM使得创建平滑的设计者/开发者工作流变得更加简单。由于View只是ViewModel的任意消费者,很容易剥离View而换一个新的View。这个简单的步骤允许我们进行快速的原型设计以及设计人员进行UI的评估。
开发团队可以将注意力集中于健壮的ViewModel类的创建。设计人员可以专注于创建用户友好的Views。连接这两个团队的输出除了确保Xaml里面的绑定存在可能设计更多。
演示程序
此刻,我已经查阅了MVVM的历史和理论操作。我调查了为什么它在WPF开发者中这么流行。现在,我们看看这个模式是如何运作的。这篇文章的演示程序用多种方式来实现MVVM。它提供了丰富的实例用以将概念融入实际的背景中。我在Visual Studio 2008 SP1,.NET框架3.5 SP1中创建这个演示程序。单元测试运行在Visual Studio单元测试系统。
程序可能包含多个“工作区”,用户可以点击左边导航区域将它们打开。所有的“工作区”在主要内容区域的TabControl 中展示。用户可以点击Tab项的关闭按钮来关闭一个工作区。应用程序有两个可用的工作区:"All Customers" 和"New Customer" 。
运行程序并且打开一些工作区之后,界面如下:
图 1 工作区
一次只能打开一个"All Customers" 工作区,但是可以同时打开多个"New Customer" 工作区。当用户想创建一个新客户的时候,他必须填写图2 的表格数据。
图2 用户填写数据表格
当用户填写有效的数据,并且点击Save按钮的后,新的用户名字出现在标签项中并且这个用户被添加在 All customers中。此程序不支持编辑和删除已存在的用户,但是此功能以及其它很多相似的功能在建立好应用程序框架之后是很容易实现的。现在你已经很好的理解了这个示例程序所作的,让我们研究它是如何设计与实现的吧!
Relaying Command 逻辑
除了类构造函数里面的InitializeComponent 生成的标准模板代码,程序的每一个View都有一个codebehind 文件。实际上,你可以从工程中移除掉view的codebehind 文件,应用程序将仍然能正确地编译和运行。 尽管View中没有事件处理方法,当用户点击按钮的时候,程序仍然能够响应用户的请求。这是因为绑定建立在UI控件,例如超链接,按钮和菜单项控件上的Command属性上。绑定使得用户在控件上点击的时候,ViewModel 的ICommand对象被执行。你可以把command对象作为一个适配器,它使得从XAML中声明的视图中调用ViewModel的功能变得简单。当ViewModel展示了ICommand的一个实例时,Command对象通过ViewModel去完成它的工作。一种可行的实现模式是在ViewModel类中创建一个私有的嵌套类,一遍Command可以使用ViewModel类中的私有成员。并且不会污染到命名空间。这个嵌套类实现了ICommand接口,并且将ViewModel的一个应用注入到它的一个构造函数中。然而为每一个实现了ICommand接口的Command创建嵌套类可能使得ViewModel类变得膨胀,越多的代码意味着越多的Bug存在的可能性。
在本程序中,RelayCommand 类解决了这个问题。RelayCommand 允许你通过代理注入Command的逻辑到构造函数中。这种方法允许在ViewModel类中以简洁的,简明的命令实现。RelayCommand 是Microsoft Composite Application Library种中DelegateCommand 的简化。图3 展示了RelayCommand 类:
public class RelayCommand : ICommand
{
#region Fields
readonly Action<object> _execute;
readonly Predicate<object> _canExecute;
#endregion // Fields
#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 // Constructors
#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 Members
}
图 3
ICommand 接口实现的一部分——CanExecuteChanged 事件有一个有趣的特征。它将事件的订阅委托给CommandManager.RequerySuggested 事件。它使得无论什么时候请求内置的命令,如果可以执行的话,WPF的命令请求所有的RelayCommand 对象。下面的代码来自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:
图4 继承层次
ViewModelBase类
ViewModelBase 是继承关系中的根类,这就是为什么它实现通用的INotifyPropertyChanged接口并且有一个DisplayName属性。INotifyPropertyChanged 接口包含了一个叫PropertyChanged的事件。无论什么时候ViewModel 对象的一个属性有一个新值,它会激活PropertyChanged 事件通知数据绑定系统这个新值。根据通知,数据绑定系统查询到这个属性,一些UI元素上的属性也会接受到这个新值。为了让WPF知道ViewModel对象的哪一个属性发生改变,PropertyChangedEventArgs类公开一个String类型的PropertyName属性。你必须小心的传递属性名到事件参数中。否则,WPF将停止为一个新值查询错误的属性。
ViewModelBase 的一个有趣的地方是它提供了验证在ViewModel对象中实际存在的一个属性名。这在重构中是非常有用的,因为通过VS2008 重构属性改变一个属性的名字将不会修改源代码中包含了属性名的字符串。在事件参数中用错误的属性名触发PropertyChanged 事件将会导致难以捕获的Bug,因此这个小的特性将节省很多时间。在图 5 中ViewModelBase 的代码中增加了此支持。
// 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);
}
}
图 5
CommandViewModel类
ViewModelBase 最简单的子类是CommandViewModel。它展示了一个叫Command 的ICommand类型的属性。MainWindowViewModel 通过它的Commands 属性展示了这些对象集合。主界面的左边的区域展示了MainWindowViewModel中每个CommandViewModel 的链接。例如"view all customers" 和"Create new customer"。当用户点击一个链接,就会执行他们其中的一个命令,一个工作区就会在commands中打开。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"的数据模板,MainWindow用模板去映射前面提到的CommandViewModels 集合。 这个模板简单的将每一个CommandViewModel 对象作为ItemsControl 的一个链接。每一个HyperLink的Command属性绑定到CommandViewModel的Command属性。XAML 在图6中显示:
<!-- In MainWindowResources.xaml-->
<!--
This template explains how to render the list of commands on
the left sidein 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>
图 6
MainWindowViewModel 类
正如在前面的类图中看到了,WorkspaceViewModel 类从ViewModelBase 继承并且添加了关闭的功能。通过“Close”,在运行时可以将一些用户界面从工作区移除。有三个类继承自WorkspaceViewModel:MainWindowViewModel, AllCustomersViewModel,和CustomerViewModel。MainWindowViewModel类的关闭请求是被App类来处理。它创建了MainWindow 以及它的ViewModel。如图7:
// In App.xaml.cs
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
MainWindow window= new MainWindow();
// Create the ViewModel to which
// the main window binds.
string path= "Data/customers.xml";
var viewModel= new MainWindowViewModel(path);
// When the ViewModel asks to be closed,
// close the window.
viewModel.RequestClose+= delegate
{
window.Close();
};
// Allow all controls in the window to
// bind to the ViewModel by setting the
// DataContext, which propagates down
// the element tree.
window.DataContext= viewModel;
window.Show();
}
图7
MainWindow 包含了一个菜单项,它的Command 属性绑定到MainWindowViewModel的CloseCommand 属性。当用户点击菜单项时,App类调用window's Close 来响应,如下:
<!-- In MainWindow.xaml-->
<Menu>
<MenuItem Header="_File">
<MenuItem Header="_Exit" Command="{Binding Path=CloseCommand}" />
</MenuItem>
<MenuItem Header="_Edit" />
<MenuItem Header="_Options" />
<MenuItem Header="_Help" />
</Menu>
MainWIndowViewModel包含了一个 WorkspaceViewModel 类型对象的observable集合,名为Workspaces。主窗体包含了一个TabControl,它的ItemsSource 绑定到这个集合。每一个tab 项有一个关闭按钮,它的Command属性绑定到相应WorkspaceViewModel 实例的CloseCommand 。一个配置每个Tab项的模板的缩减版本显示在下面的代码中。这些代码可以在MainWindowResources.xaml中找到,这些模板解释了如何将Tab 项和关闭按钮映射。
<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>
当用户点击Tab项的关闭按钮,此工作区ViewModel的CloseCommand 命令就会执行。引起RequestClose 事件被触发。MainWindowViewModel 监视工作区的RequestClose 事件,并且根据请求将工作区移除。由于MainWindow的TabControl 的ItemsSource属性绑定到了WorkspaceViewModels的observable 集合,从集合中移除一项会导致TabControl移除相应的工作区。MainWindowViewModel 的逻辑如 图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 workspacein e.NewItems)
workspace.RequestClose+= this.OnWorkspaceRequestClose;
if (e.OldItems!= null && e.OldItems.Count!= 0)
foreach (WorkspaceViewModel workspacein e.OldItems)
workspace.RequestClose-= this.OnWorkspaceRequestClose;
}
void OnWorkspaceRequestClose(object sender, EventArgs e)
{
this.Workspaces.Remove(senderas WorkspaceViewModel);
}
图8
在UnitTests 工程中,MainWindowViewModelTests.cs包含了方法验证此功能是否正常工作。很容易为ViewModel 创建单元测试是MVVM的一个很大的亮点。因为它允许无需写UI代码的情况下进行一些简单的功能测试。测试方法如 图9:
// In MainWindowViewModelTests.cs
[TestMethod]
public void TestCloseAllCustomersWorkspace()
{
// Create the MainWindowViewModel, but not the MainWindow.
MainWindowViewModel target = new MainWindowViewModel(Constants.CUSTOMER_DATA_FILE);
Assert.AreEqual(0, target.Workspaces.Count,"Workspaces isn't empty.");
// Find the command that opens the "All Customers" workspace.
CommandViewModel commandVM=
target.Commands.First(cvm=> cvm.DisplayName== "View all customers");
// Open the "All Customers" workspace.
commandVM.Command.Execute(null);
Assert.AreEqual(1, target.Workspaces.Count,"Did not create viewmodel.");
// Ensure the correct type of workspace was created.
var allCustomersVM= target.Workspaces[0]as AllCustomersViewModel;
Assert.IsNotNull(allCustomersVM,"Wrong viewmodel type created.");
// Tell the "All Customers" workspace to close.
allCustomersVM.CloseCommand.Execute(null);
Assert.AreEqual(0, target.Workspaces.Count,"Did not close viewmodel.");
}
图9
将View运用到ViewModel
MainWindowViewModel 间接的从主窗体的TabControl控件添加与移除MainWindowViewModel 对象。通过依赖数据绑定,TabItem的Content 属性接受到派生自ViewModelBase的类对象去显示。ViewModelBase 不是UI元素,因此它没有内在的支持去显示自己。在WPF中默认展示一个不可视对象是通过TextBlock中调用此对象的ToString方法得到的字符串来显示。这明显不是你所想要的,除非用户强烈的希望看到ViewModel 的类型名称。
你可以简单的通过类型化的DataTemplate告诉WPF如何展示一个ViewModel 对象。类型化的DataTemplate没有一个x:Key值分配给它,但是它有DataType属性去设置为一个Type类的实例。如果WPF试着展示一个ViewModel 对象,它会确认资源系统是否有一个类型化的DataTemplate,它的DataType 与ViewModel 对象的类型是一样的(或者是它的基类)。如果找到了,它用那个模板在Tab item的Content属性里去呈现引用的ViewModel对象。MainWindowResources.xaml文件有一个ResourceDictionary。那个字典被添加到主窗体的资源体系中,这意味着它包含的资源在窗口的资源范围里。当一个Tab项的Content被设置为ViewModel 的对象时,字典中的一个DataTemplate提供一个View去显示它。如图 10:
<!--
This resource dictionaryis used by the 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 AllCustomersViewModelclass shownin the main window.
-->
<DataTemplate DataType="{x:Type vm:AllCustomersViewModel}">
<vw:AllCustomersView/>
</DataTemplate>
<!--
This template applies a CustomerView to an instance
of the CustomerViewModelclass shownin the main window.
-->
<DataTemplate DataType="{x:Type vm:CustomerViewModel}">
<vw:CustomerView/>
</DataTemplate>
<!-- Other resources omittedfor clarity...-->
</ResourceDictionary>
图 10
你不需要写任何代码来决定哪一个View来显示ViewModel 对象。WPF资源系统为你做好了一切,使你集中精力于更加重要的东西。在更多复杂的场景中,可能需要编程去选择View,但是大多数情况下那是不需要的。
数据模型和资源库
你已经看到ViewModel对象如何被应用程序框架加载,显示和关闭。 现在基本的框架已经搭建好了,你可以专注与程序的实现细节。在深入的了解程序的两个工作区"All Customers" and "New Customer"之前,让我们查看一个数据模型和数据访问类。那些类的设计几乎与MVVM模式无关。因为你可以创建一个ViewModel类去适应任何WPF支持的数据对象。示例中唯一的Model类是Customer。这个类有一些属性代表了一个公司员工的信息,例如他们的姓名,Email地址。它通过实现标准的IDataErrorInfo 接口实现了验证消息,这在WPF流行之前已经存在好几年了。Customer 类没有任何东西表明它被用在MVVM架构或是WPF程序中。这种类能很容易源自业务库。
数据必须来源并存在于某个地方,在这个程序中CustomerRepository 的一个实例加载并且存储所有的Customer 对象。它从XML 文件中加载数据,但是外部的数据源是无关紧要的。数据可以源自数据库、Web Service、命名管道、磁盘文件、甚至信鸽,这完全都无所谓。只要你有一个包含了数据的.Net对象,不管它来自哪里,MVVM模式都可以得到数据并显示在屏幕上。
CustomerRepository 类提供了一些方法让你访问所有可用的Customer 对象,添加新的Customer到集合中,同时判断Customer 是否已经存在。由于程序不允许用户删除customer,因此不能从集合中删除一个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");
}
}
当用户创建一个新的Customer并且点击CustomerView的Save按钮时,与View关联的CustomerViewModel 将添加一个新的Customer 到Customer集合。这导致了CustomerAdded 事件被激活,这个事件通知AllCustomersViewModel应该将新的Customer添加到AllCustomers 集合。在某种意义上,CustomerRepository类在各种处理Customer对象的ViewModel类之间起到一个同步机制的作用。可能有人会把这当做Mediator 设计模式的使用。在下面的部分我将细述它是如何工作的,但是现在看一下图11 对各部分如何组装的有一个整体的了解。
图 11
新Customer数据输入表格
当用户点击"Create new customer"链接,MainWindowViewModel 会在它的工作区列表中添加一个新的CustomerViewModel,同时一个新的CustomerView 控件去显示它。当用户将有效的类型值输入到输入框中,Save 按钮变为可用状态以便用户可以保存新的Customer 的信息。没有什么与众不同的东西,仅仅是一个包含输入验证和Sava按钮的数据表格。
Customer 类已经通过实现IDataErrorInfo 接口内置了输入验证的支持。此验证确保了Customer有一个FirstName,正确格式的e-mail 地址,并且如果用户是一个人的话,还有一个LastName。如果Customer 的IsCompany返回 true,LastName 就不能有值(因为一个公司没有LastName)。这个验证的逻辑从客户对象的角度来看可能是有意义的,但是它不满足界面的需求。 用户界面需要用户去选择一个新的Customer是人还是公司。客户类型选择器初始值为"Not Specified"。那么如果一个Customer的IsCompany属性仅仅允许true或者false的值,界面是怎样去告诉用户客户类型是"Not Specified" 的呢?
假设你已经完成了整个软件系统,你应该将IsCompany 属性改为Nullable<bool>类型 类型,这可以允许"unselected"值。 但是,真实的世界没有这么简单,假设你不能改变Customer 类因为它来自你们公司的另一个团队的类库。如果存在数据库的设计而不能保存"unselected"值将会怎样?如果其他应用程序已经使用Customer类而且依赖于它是一个正常的布尔值将会怎样?又一次地,ViewModel来帮忙。图12 的测试方法展示了此功能如何在CustomerViewModel工作的。CustomerViewModel 暴露了一个CustomerTypeOptions 的属性以便Customer 的类型可以显示为第三种字符串。它也暴露了一个CustomerType属性来存储选择的字符串。当CostomerType被赋值时,它将字符串值转换为一个布尔值并提供给为潜在的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= (targetas IDataErrorInfo)["CustomerType"];
Assert.IsFalse(String.IsNullOrEmpty(error),"Error message should
be returned");
}
图12
// 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");
}
}
图13
CustomerView控件包含一个ComboBox来绑定到这些属性,如下:
<ComboBox
ItemsSource="{Binding CustomerTypeOptions}"
SelectedItem="{Binding CustomerType, ValidatesOnDataErrors=True}" />
当ComboBox 的选择改变的时候,程序会查询数据源的IDataErrorInfo接口以检查新的值是否有效。此过程发生的原因是SelectedItem属性绑定将ValidateOnDataErrors设置为true。由于数据源是CustomerViewModel 对象,绑定系统在CustomerViewModel的 CutomerType属性上查询验证错误。大多数情况下,CustomerViewModel 会代理所有Customer 对象包含的错误验证请求。然而,由于Customer 的IsCompany属性没有为选择状态,CustomerViewModel 类必须处理在ComboBox 控件上新的选择项的验证。代码如 图14:
// In CustomerViewModel.cs
string IDataErrorInfo.this[string propertyName]
{
get
{
string error= null;
if (propertyName== "CustomerType")
{
// The IsCompany property of the Customer class
// is Boolean, so it has no concept of being in
// an "unselected" state. The CustomerViewModel
// class handles this mapping and validation.
error= this.ValidateCustomerType();
}
else
{
error= (_customeras IDataErrorInfo)[propertyName];
}
// Dirty the commands registered with CommandManager,
// such as our Save command, so that they are queried
// to see if they can execute now.
CommandManager.InvalidateRequerySuggested();
return error;
}
}
string ValidateCustomerType()
{
if (this.CustomerType== "Company" || this.CustomerType== "Person")
return null;
return "Customer type must be selected";
}
图14
代码的关键是CustomerViewModel对IDataErrorInfo 接口的实现ViewModel指定属性验证的请求并且将其它的请求委托到Customer 对象。这允许你使用Model 类中的验证逻辑,而且附加的属性验证仅仅对于ViewModel类有意义。
通过SaveCommand 属性使得CustomerViewModel 可以被保存。此命令运用RelayCommand 类去检查CustomerViewModel 是否能保存自己并且当被告知保存它的状态时该做什么。在此程序中保存一个新的Customer意味着把它添加到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;
}
}
图15
在这里,使用ViewModel的使用使得下面这些事情变得更加简单:创建一个现实Customer 对象的View以及允许Bool属性为"unselected" 状态。它也使得告知Customer保存它的状态变得简单。如果将Customer 直接绑定到View,View可能需要许多代码以使得程序正常工作。在一个设计良好的MVVM 架构中,大部分的Views 的后置代码应该是空的,或者之多包含一些控制控件或资源的代码。有时候在View的后置代码中写一些代码与ViewModel 交互是必须得。比如捕获一个事件或调用一个方法,这个方法直接从ViewModel对象自己调用时非常困难。
所有Customer视图
演示程序同样包含了一个在ListView中显示所有Customer的工作区,此类表中的Customer是通过它是工公司或是个人来分类的。用户可以同时选择一个或者多个Customer并且在底部右下角查看总销售额。
用户界面是AllCustomersView控件,它对应一个AllCustomersViewModel对象。每一个列表项代表一个AllCustomersViewModel对象暴露的AllCustomers集合中的一个CustomerViewModel对象。在前面的章节中,你看到了一个CustomerViewModel怎样对应一个数据输入表格,现在同样的CustomerViewModel对象被映射作为列表中的一项。CustomerViewModel类并不知道什么样的可见的元素来显示它,这就是为什么它可以重用的原因。AllCustomersView 在ListView上创建了分组。这是通过绑定ListView的ItemsSource到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>
图16
ListView' 的 ItemContainerStyle属性确定了ListViewItem 和 CustomerViewModel 对象之间的联系,此属性的Style 被应用到每个ListViewItem,它使得ListViewItem 中的属性绑定到CustomerViewModel对象。一个重要的绑定是在列表项的IsSelected属性和CustomerViewModel的IsSelected属性之间创建链接,如下:
<Style x:Key="CustomerItemStyle" TargetType="{x:Type ListViewItem}">
<!-- Stretch the content of each cell so that we can right-align textin the Total Sales column.-->
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<!--
Bind the IsSelected property of a ListViewItem to the
IsSelected property of a CustomerViewModelobject.
-->
<Setter Property="IsSelected" Value="{Binding Path=IsSelected, Mode=TwoWay}" />
</Style>
当CustomerViewModel 被选择或是反选的时候,会导致所有选择的Customer的总销售额改变。AllCustomersViewModel 负责维护这个值,以便在列表之下能显示正确的数值。图17 显示了AllCustomersViewModel怎样监视每个客户被选择或反选,并通知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";
// Make sure that the property name we're
// referencing is valid. This is a debugging
// technique, and does not execute in a Release build.
(senderas CustomerViewModel).VerifyPropertyName(IsSelected);
// When a customer is selected or unselected, we must let the
// world know that the TotalSelectedSales property has changed,
// so that it will be queried again for a new value.
if (e.PropertyName== IsSelected)
this.OnPropertyChanged("TotalSelectedSales");
}
UI绑定到TotalSelectedSales 并且将值转换为货币格式。通过返回一个字符串代替从TotalSelectedSales属性得到的浮点数,ViewModel对象可以应用货币格式。在.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对应用程序开发者提供了许多,学习利用这个需要心态上的转变。 MVVM设计模式对于设计和实现应用程序是简单的并且有指导意义。它使得你可以创建数据、行为、展现的分离,使得控制软件开发的混乱更加简单。
点击,代码下载。