一、前言

这篇文是2009年2月份的,原文在 原文地址

年代有些久远了。但整篇看下来,里面有些思想放现在也仍是精髓的,甚至是许多当下主流框架的核心思想,所以决定过一遍学习学习。

我一直以为技术点的学习固然重要,但技术随谈类的文也要看。因为技术点是学不完的,而有些随谈中的思想了解后会受益良多。

这篇文本身讲的很口头了,所以简单以翻译的方式去学习。


二、正文:模式 - WPF应用程序使用MVVM设计模式

“有些流行的设计模式能帮助我们驯服这头笨重的野兽,但正确分离和解决各种问题可能是困难的。模式越复杂,以后就越有可能走捷径,从而破坏之前以正确方式所作的努力”。

这并不总是设计模式的问题。有时我们会使用需要编写大量代码的复杂设计模式,因为所用的UI平台不支持更简单的模式。我们需要的是一个能轻松构建UI的平台,该平台必须简单易用、经过时间验证、且受开发人员认可。幸运的是,WPF做到了。

WPF在快速发展,WPF社区也一直在开发自己的模式和完善自己的生态系统。本文中,我将回顾一些使用WPF设计和实现客户端应用程序的最佳实践。通过使用WPF的一些核心功能以及MVVM设计模式,我将演示一个示例程序,该程序演示以“正确的方式”构建WPF应用程序是多么简单。

读完本文,你将清楚 数据模板(data templates)、命令(commands)、数据绑定(data binding)、资源系统(resource system)和MVVM模式是如何组合在一起来创建一个简单、可测试、健壮的框架的,之后所有WPF程序都可以在该框架上进行开发。本文附带的demo程序可以作为使用MVVM为其核心架构的实际WPF应用程序模板使用(即实际项目中你也可以按照该架构来用)。demo程序中的单元测试也展示了当应用程序功能存在于一组ViewModel类中时,测试该功能是多么容易。在深入讨论细节之前,我们首先回顾一下为什么应该使用MVVM这样的模式。

2.0 一些术语

下文中,
view即视图,
model即模型,
viewmodel即视图模型。

2.1 秩序与混乱

在简单的"Hello,World!"程序中使用设计模式就没必要,而且强行使用会适得其反。一个合格的开发人员扫一眼就能看懂这几行代码。然而,随着程序功能增加,代码行数以及可重用部分也会增加。最终,系统的复杂性以及各种反复出现的问题会促使开发人员以一种更易理解、更易扩展和易于排除故障的方式去重新组织代码。我们通过将一些众所周知的名称应用于源代码中的某些实体来减少复杂系统的认知混乱。我们通过考虑代码在系统中的功能角色来确定其代码名称(这很好理解,比如我有一个日志模块,那代码中取名就是log相关,名称要和其功能对应)。

开发人员通常会有意识地根据设计模式来构建代码,而不是让模式自然出现(指通常某个项目用哪种模式在我写代码之前就确定了,而不是我边写边确定模式)。实际上这两种方法都没问题,但在本文中,我将研究显式使用MVVM作为WPF应用程序架构的好处。某些类的名称包括MVVM模式中众所周知的术语,例如如果该类是视图的抽象(注意是视图抽象,而不是本身),则以"ViewModel"结尾。这种方法有助于避免前面提到的认知混乱。

2.2 MVVM模式的演变

自人们开始创建软件用户界面以来,就出现了各种设计模式来帮助简化开发。例如,MVP(Model - View - Presenter)模式,在各种UI编程平台上很受欢迎。MVP是MVC(Model - View - Controller)模式的变体,该模式已经存在了几十年。如果你以前从未使用过MVP模式,可以看一下这边的简化解释。
What you see on the screen is the View, the data it displays is the model, and the Presenter hooks the two together.
即,屏幕上看到的是View,它显示的数据是Model,Presenter将两者连在一起。View依赖于Presenter来用Model数据作填充、对用户输入做出反应、提供输入验证(可能通过委托给到model)以及其他诸如此类的任务。若你想要了解MVP的更多信息,可以看 这篇文

早在2004年,Martin Fowler就发表了一篇文章——关于一种名为"Presentation Model(PM)"的模式。PM模式与MVP类似,它将视图从其行为和状态中分离开来。
PM模式有趣的部分是创建了View的抽象,称为Presentation Model(即呈现模型,或者叫表示模型)。紧接着,view就仅仅变成了PM的画面呈现(有点前后端分离的意思,前端只有画面的代码,不含处理逻辑)。Fowler解释到,PM会经常更新其View,以使两者保持同步。该同步逻辑以代码的形式存在于PM类中。

2005年,微软WPF和Silverlight架构师之一的约翰·高斯曼在他的博客上公开了 Model - View - ViewModel(MVVM)模式。MVVM与Fowler的PM相同,因为这两种模式都具有View的抽象,其中包含View的状态(state)和行为(behavior)。Fowler引入了PM,作为创建View独立于UI平台的抽象的一种方式;而高斯曼引入MVVM为一种标准化的方式,以利用WPF的核心功能来简化用户界面的创建。从这个意义上来讲,MVVM是更通用的PM模式的特化,是为WPF合Silverlight平台量身打造的。

2008年,Glenn Block发了一篇优秀文章“Prism: Patterns for Building Composite Application with WPF”,文章介绍了WPF微软复合应用程序指南,术语ViewModel首次亮相。前面提到,PM术语用于描述view的抽象。然而,在这篇文章中,将该模式称为MVVM,并将view的抽象称为ViewModel。我也发现MVVM这个术语在WPF和Silverlight社区中更为流行。

与MVP中的Presenter不同,。view绑定到ViewModel上的属性,而ViewModel会暴露model对象中包含的数据以及view的一些状态。view和ViewModel之间的绑定构造起来很简单,因为ViewModel对象被设置为view的DataContext。如果ViewModel中的属性值更改,这些新值会通过数据绑定自动传到view。当用户单击view中的按钮时,ViewModel上的命令将执行请求的操作。ViewModel(而不是View)执行对model数据所作的所有修改(就是说model数据更改是在viewmodel中做的)。

view类不知道model类的存在,同时ViewModel和model也不知道view的存在。实际上,model也完全未察觉ViewModel和view的存在。所以这是一个非常松耦合(loosely coupled)的设计,你很快就会明白。

2.3 为何WPF开发者喜爱MVVM

一旦开发人员熟悉了WPF和MVVM,就很难将两者区别开来。MVVM是WPF开发人员的通用语(lingua franca),因为它非常适合WPF平台,而且WPF就是被设计成能轻松构建使用MVVM模式的程序的。事实上,微软在内部就使用MVVM来开发WPF应用程序,例如Microsoft Expression Blend,与此同时核心WPF平台正在建设中。WPF的许多方面,例如无外观控制(look-less control)模型和数据模板(data template),都利用了MVVM所提倡的状态与行为的强分离。

WPF使得MVVM成为一种实用模式的最重要的一个点是使用了数据绑定(data binding)这一基础结构。通过将view的属性绑定到ViewModel,即可在两者之间实现松散耦合,且无需在ViewModel中编写直接更新view的代码。数据绑定系统还支持输入验证,这提供了将验证错误传输到view的标准化方式。

WPF的另外两个功能也使得该模式变得好用,它们是数据模板(data template)和资源系统(resource system)。数据模板将view应用于用户界面中显示的ViewModel对象。你可以在XAML中声明模板,并让资源系统在运行时自动为你查找并应用这些模板。

若WPF不支持命令,MVVM模式会逊色许多。在本文中,我将向您展示ViewModel如何向View公开命令,从而允许view使用其功能。

除了WPF(和Silverlight 2)能用MVVM自然地构建应用程序,该模式本身也很流行,因为ViewModel类易于单元测试。当应用程序的交互逻辑位于一组ViewModel类中时,你可以轻松编写测试它的代码。从某种意义上来说,view和单元测试只是两种不同类型的ViewModel消费者。对应用程序的ViewModel进行一套测试可以提供免费且快速的回归测试,这有助于降低长期维护应用程序的成本。

除了促进自动回归测试的创建之外,ViewModel类的可测试性还可以帮助正确设计易于换肤的用户界面。当你设计应用程序时,你通常可以通过想象你想要编写的单元测试来消费ViewModel来决定那些内容应该在view中还是在ViewModel中。如果你可以在不创建任何UI对象的情况下为ViewModel编写单元测试,那么你也可以完全为ViewModel换肤,因为它不依赖于特定的视觉元素。

最后,对于与视觉设计师一起工作的开发人员来说,使用MVVM可以更轻松地创建流畅的设计师/开发人员工作流。由于view只是ViewModel的任意消费者之一,因此很容易删除一个view并放入一个新的view来呈现ViewModel。这个简单的步骤使得设计人员能快速制作用户界面原型并对其进行评估。

开发团队可以专注于创建强大的ViewModel类,设计团队可以专注于制作用户友好的视图。连接两个团队的输出只需确保view的XAML文件中存在正确的绑定即可。

2.4 Demo应用程序

到这里,我已经回顾了MVVM的历史和运作原理。我还研究了为什么它在WPF开发人员中如此受欢迎。现在是时候撸起袖子看看实际的模式了。本文附带的demo应用程序以多种方式使用了MVVM。它提供了丰富的示例来源,以将一些概念放入有实际意义的上下文中。我在VS2008 SP1中针对Mircrosoft .NET Framework 3.5 SP1 创建了demo应用程序。单元测试在VS单元测试系统中运行。

该应用程序可以包含任意数量的“工作区(workspace)”,用户可以通过点击左侧导航区域中的命令链接来打开每个“工作区”。所有工作区都存在于主内容区域的TabControl中。用户可以通过点击工作区选项卡(tabitem)上的 “Close” 按钮来关闭工作区。该应用程序有两个可用工作区:“All Customers” 和 “New Customer” 。运行应用程序并打开一些工作区后,UI如下图所示:
WPF应用程序使用MVVM模式-LMLPHP
一次只能打开一个"All Customers" 工作区实例,但可以一次打开任意数量的 “New Customer” 工作区。当用户决定创建新客户(customer)时,必须填写下图的数据输入表单。

WPF应用程序使用MVVM模式-LMLPHP
使用有效值填写数据输入表单并点击 “Save” 按钮后,新客户的名称将显示在选项卡项中,并且该客户将添加到"all customer"的列表中。本应用程序不支持删除或编辑已有客户,但该功能以及其他类似功能可以通过构建在已有应用程序架构之上轻松实现。现在你已经对demo程序有了较高了解,让我们研究一下它是如何设计和实现的。

2.5 路由命令逻辑

程序中的每个view都有一个"空的"代码后置(codebehind)文件,该文件中只有类的构造函数调用 InitializeComponent 的标准样板代码。事实上,你可以从项目中删除view的代码后置文件,应用程序仍然可以正确编译和运行。尽管view中缺少事件处理方法(指的是前端代码里正常一般不写处理逻辑),但当用户点击按钮时,应用程序仍会做出反应并满足用户的请求。这是因为在UI中显示的超链接、按钮和菜单项控件的 Command 属性上都建立了绑定。这些绑定确保当用户点击控件时,ViewModel公开的 ICommand 对象会执行。你可以将 Command 对象看成一个适配器,可以轻松从XAML中声明的view中使用ViewModel的功能。

①当ViewModel 公开 ICommand 类型的实例属性时,命令对象通常使用该ViewModel对象(内的成员,或者叫字段)来完成其作业。②一种可能的实现模式是在ViewModel类中创建一个私有嵌套类,以使该命令可以访问其包含的ViewModel的私有成员,并且不会污染命名空间。该嵌套类实现 ICommand 接口,并且一个包含的 ViewModel 对象的引用被注入其构造函数中。但是,创建这样一个为ViewModel公开的每个命令都实现 ICommand 的嵌套类可能会使ViewModel类变得臃肿。更多代码意味着更可能出现bug。

在demo程序中,RelayCommand 类解决了这个问题。
RelayCommand 允许你通过给其构造函数传入委托来注入命令逻辑。这种方法使你可以在 ViewModel 类中实现简洁的命令。 RelayCommand 是Microsoft复合应用程序中 DelegateCommand 的简化的变体。其类如下所示:

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 
}

CanExecuteChanged 事件是 ICommand 接口实现的一部分,具有一些有趣的功能。它将事件订阅委托给 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;
	}
}

2.6 ViewModel类层次结构

大多数ViewModel类需要同一功能。它们通常需要实现 INotifyPropertyChanged 接口,需要一个用户友好的显示名称,并且就工作区来讲,也要能被关闭(即,从UI中移除)。ViewModel类形成了下图所示的继承层次结构。

WPF应用程序使用MVVM模式-LMLPHP
并不是所有ViewModel都要有基类。如果你更喜欢通过将较小的类组合在一起而不是使用继承来获得类中的功能,那也没问题。就像任何其他设计模式一样,MVVM是一组指南,而不是规则。

2.7 ViewModelBase类

ViewModelBase是层次结构中的根类,这就是为什么它实现常用的 INotifyPropertyChanged 接口并具有 DisplayName 属性。INotifyPropertyChanged 接口包含一个叫 PropertyChanged 的事件。每当 ViewModel对象上的属性具有新值时,它就可以引发 PropertyChanged 事件以向WPF绑定系统通知新值。在收到通知后,绑定系统会查询属性,并且某些UI元素上的绑定属性会收到新值。

为了让WPF了解ViewModel对象上的哪个属性已更改, PropertyChangedEventArgs 类公开了 String 类型的 PropertyName 属性。你必须小心将正确的属性名称传递到该事件参数中;否则,WPF最终将查询错误的属性以获取新值。

ViewModelBase的一个有趣的点是它提供了验证具有给定名称的属性是否确实存在于ViewModel对象上的能力。这在重构时非常有用,因为通过VS2008重构功能更改属性名称不会更新源代码中碰巧包含该属性名称的字符串(也不应该)。在事件参数中使用不正确的属性名称引发 PropertyChanged 事件可能会导致难以追踪的细微错误,因此这个功能可以节省大量时间。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);
    }
}

2.8 CommandViewModel类

最简单的具体(Concrete class,与抽象类相对)ViewModelBase子类是 CommandViewModel。它公开了一个名为Command的ICommand类型的属性。
MainWindowViewModel通过其Commands属性公开这些对象的集合。主窗口左侧的导航区域显示 MainWindowViewModel公开的每个CommandViewModel的链接,例如"View all customers" 和 “Create new customer” 。当用户点击链接,以执行其中一个命令时,主窗口上的TabControl中会打开一个工作区。CommandViewModel类定义如下所示:

public class CommandViewModel : ViewModelBase
{
	public CommnandViewModel(string displayName, ICommand command)
	{
		if (command == null)
		{
			throw new ArgumentException("command");
		}
		base.DisplayName = displayName;
		this.Command = command;
	}
	public ICommand Command { get; private set; }
}

在MainWindowResources.xaml 文件中存在一个DataTemplate,其键(key)为 “CommandsTemplate” 。MainWindow使用该模板来呈现前面提到的 CommandViewModel 集合。该模板只是将每个 CommandViewModel 对象渲染为ItemsControl中的链接。每个超链接的Command属性都绑定到CommandViewModel的Command属性。该XAML如下所示:

<!--In MainWindowResources.xaml-->
<!-- This template explains how to render the list of commands
on the left side in the main window(the 'Control Panel' ared). -->
 <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>

2.9 MainWindowViewModel类

正如前面在类图中看到的, WorkspaceViewModel 类派生自 ViewModelBase 并添加了"关闭"功能。这个 “关闭” 功能是指在运行时某些东西从用户界面中删除。从 WorkspaceViewModel派生出三个类:MainWindowViewModel、AllCustomersViewModel和CustomerViewModel。
MainWindowViewModel的关闭请求由App类处理,该类创建MainWindow及其ViewModel,如下所示:

// In App.xaml.cs
protected override void OnStartup(StartupEventArgs e)
{
	base.OnStartup(e);
	MainWindow window = new MainWindow();
	// 创建MainWindow要绑定的ViewModel
	string path = "Data/customers.xml";
	var viewModel = new MainWindowViewModel(path);
	// 当ViewModel要求关闭时,关闭窗口
	viewModel.RequestClose += delegate { window.Close(); };
	// 通过设置DataContext,允许窗口中的所有控件绑定到ViewModel,以向下传播到元素树
	window.DataContext = viewModel;
	window.Show();
}

MainWindow 包含一个菜单项,其Command属性绑定到MainWindowViewModel的CloseCommand属性。当用户点击该菜单项时,App类通过调用窗口的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 collection(可观察集合),称为Workspaces。主窗口包含一个TabControl,其ItemsSource属性绑定到该集合。每个标签项都有一个关闭按钮,其Command属性属性绑定到相应WorkspaceViewModel实例的CloseCommand。下面代码展示了配置每个标签项的模板的缩略版本。该代码位于 MainWindowResources.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集合中删除工作区。由于MainWindow的TabControl的ItemsSource属性绑定到WorkspaceViewModel的observableCollection,因此从集合中删除项会导致相应的工作区从TabControl中删除。MainWindowViewModel中的逻辑如下所示:

// 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);
}

在单元测试项目中, MainWindowViewModelTests.cs 文件包含一个测试方法,用于验证此功能是否正常工作。能轻松地为ViewModel类创建单元测试是MVVM模式的一个巨大卖点,因为它允许简单地测试应用程序功能,而无需编写涉及UI的代码,该测试方法如下所示:

// In MainWindowViewModelTests.cs
[TestMethod]
public void TestCloseAllCustomersWorkspace()
{
	// 创建MainWindowViewModel,不用创建ManWindow
	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.");
}

2.10 将View应用到ViewModel

MainWindowViewModel间接向主窗口的TabControl添加或删除WorkspaceViewModel对象。通过数据绑定,TabItem的Content属性接收ViewModelBase派生的类对象进行显示。ViewModelBase不是UI元素,因此它本身没有对渲染的固有支持。
这显然不是我们所需要的,除非你希望用户看到ViewModel类的类型名称。

你可以轻松地告诉WPF,如何使用类型化DataTemplate来呈现ViewModel对象。类型化DataTemplate没有被分配一个 x:key 值,但它的DataType属性设置为Type类的实例。如果WPF尝试渲染你的ViewModel对象之一,它会检查资源系统是否在作用域内具有类型化的DataTemplate,其DataType与你的ViewModel对象的类型相同(或其基类)。若找到了,它会使用该模板来渲染标签项的Content属性引用的ViewModel对象。

MainWindowResources.xaml文件有一个ResourceDictionary。该字典被添加到主窗口的资源层次结构中,这意味着它包含的资源位于窗口的资源作用域内。当标签项的内容设置为设置为ViewModel对象时,该字典中的类型化DataTemplate会提供一个视图(即用户控件,user control)来渲染它,如下所示:

<!-- 该资源字典由MainWindow使用 -->
<ResourceDictionary xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation" 
                    xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml" 
                    xmlns:vm="clr-namespace:DemoApp.ViewModel" 
                    xmlns:vw="clr-namespace:DemoApp.View" >
	<!-- 该模板将应用AllCustomersView于显示在主窗口中AllCustomersViewModel类的实例上 -->   
	<DataTemplate DataType="{x:Type vm:AllCustomersViewModel}">
		<vw:AllCustomersView/>
	</DataTemplate>
	<!-- 该模板将应用CustomerView到主窗口中显示的CustomerViewModel类的实例上 -->
	<DataTemplate DataType="{x:Type vm:CustomerViewModel}">
		<vw:CustomerView />
	</DataTemplate>
	<!-- 其他资源省略 -->
</ResourceDisctionary>

你不需要编写任何代码来确定ViewModel对象显示哪个view。WPF资源系统会为你完成所有繁重的工作,让你能专注于更重要的事情。更复杂的场景中,可以通过编程来选择view,但大多数情况下,这是不必要的。

2.11 数据模型与存储库

现在,你已经了解了应用程序如何加载、显示和关闭ViewModel对象。现在你可以看一些具体到应用程序的实现细节。在深入研究应用程序的两个工作区"All Customers" 和 "New Customer"之前,我们首先检查数据模型和数据访问类。这些类的设计几乎与MVVM模式无关,因为你可以创建一个ViewModel类来将几乎任何数据对象调整为对WPF友好的东西。

demo程序中唯一的模型类是Customer。该类有一些属性,表示公司客户相关的信息,如名字、姓氏和邮件地址。它通过实现标准 IDataErrorInfo 接口来提供验证信息,该接口在WPF出现之前就已存在多年。Customer类中没有任何内容表明它是专用于MVVM架构或WPF应用程序的。该类可以非常轻易地从一些传统商业库中获取。

数据必定是源于和驻留在某些地方。在此应用程序中,CustomerRepository类的实例加载并存储所有Customer对象。它恰好是从XML文件加载客户数据,与外部数据源的类型无关。数据可能来自数据库、Web服务、有名管道、磁盘文件、甚至邮件:这些都不重要。只要你有一个包含一些数据的.NET对象,无论它来自何处,MVVM模式都可以在屏幕上获取该数据。

CustomerRepository类公开了一些方法,允许你获取所有可用的Customer对象、向存储库添加新的Customer以及检查Customer是否已在存储库中。因为应用程序不允许用户删除customer,因此存储库也不允许你删除customer。当一个新的Customer通过AddCustomer方法进入CustomerRepository时,CustomerAdded事件将被触发。

显然,与实际业务应用程序所需的数据模型相比,本
应用程序的数据模型非常小,但这不重要。重要的是理解ViewModel类如何使用Customer和CustomerRepository。要注意,CustomerViewModel是Customer对象的包装器。它通过一组属性公开Customer的状态以及CustomerView控件使用的其他状态。CustomerViewModel不会复制Customer的状态;它只是通过委托(delegation)公开,如下所示:

public string FirstName
{
	get { return _customer.FirstName; }
	set 
	{
		if (value == _customer.FirstName) return;
		_customer.FirstName = value;
		base.OnPropertyChanged("FirstName");
	}
}

当用户创建新客户并点击CustomerView控件中的保存按钮时,与该view关联的CustomerViewModel会将新的Customer对象添加到CustomerRepository中。这会引起存储库的CustomerAdded事件触发,从而让AllCustomersViewModel知道它应该将新的CustomerViewModel添加到其AllCustomers集合中。从某种意义上说,CustomerRepository充当处理Customer对象的各种ViewModel之间的异步机制。也许有人会认为这是使用了中介者(Mediator)设计模式。我将在接下来部分中介绍其工作原理,不过现在请先参考下图,以深入了解所有部分是如何组合在一起的。
WPF应用程序使用MVVM模式-LMLPHP

2.12 新建客户数据输入表单

当用户点击"Create new customer"链接时,MainWindowViewModel会将新的CustomerViewModel添加到其工作区列表中,并由CustomerView控件显示它。在用户输入栏中输入有效值后,保存按钮将进入启用状态,以便用户可以持久化新的客户信息。这里没什么特殊的,只是一个带有输入验证和保存按钮的常规输入表单。

Customer类具有内置的验证支持,可通过实现 IDataErrorInfo 接口获得。该验证可确保客户拥有名字、格式正确的电子邮件地址,如果是个人用户,则还有姓氏。如果Customer的 IsCompany 属性返回true,则LastName属性不能有值(公司没有姓氏)。从Customer对象的角度来看,此验证逻辑可能有意义,但它不能满足UI的需求。UI要求用户选择新客户是个人还是公司。客户类型选择器最初的值为“未指定(No Specified)”。如果客户的IsCompany属性只允许值为true或false,那么UI如何告诉用户客户类型是未指定呢。

假设你对整个软件系统具有完全控制权,则可以将IsCompany属性更改为Nullable类型,这将允许"unselected"值。然而,现实世界并不总是那么简单。很可能你无法更改Customer类,因为它可能来自公司中不同团队拥有的历史库。如果由于现有的数据库模式而没有简单的方法来保留"unselected"的值该怎么办?如果其他应用程序已经使用Customer类并依赖于一个普通的Boolean值的属性怎么办?这次,ViewModel仍然可以解决该问题。

下面的测试方法展示了此功能如何在CustomerViewModel中工作。CustomerViewModel公开了CustomerTypeOptions属性,以便客户类型选择器可以显示三个字符串。它还公开了一个CustomerType属性,该属性将选定的字符串存储在选择器中。设置CustomerType后,它将字符串值映射到底层Customer对象的IsCompany属性的Boolean上。如下所示:

// 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");
	
}

CustomerType属性:

// 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 ItemSource="{Binding CustomerTypeOptions}"
	SelectedItem="{Binding CustomerType, ValidatesOnDataErrors=True}"/>

当ComboBox中选中项发生更改时,将会查询数据源的IDataErrorInfo接口以查看新值是否有效。这是因为SelectedItem属性绑定将ValidatesOnDataErrors设置为true。由于数据源是CustomerViewModel对象,因此绑定系统会向CustomerViewModel询问CustomerType属性的验证错误。大多数时候,CustomerViewModel将所有验证错误请求委托给它包含的Customer对象。但是,由于Customer不知道IsCompany属性有一个未选定的状态,因此CustomerViewModel类必须处理对ComboBox控件中新选项的验证。代码如下:

// In CustomerViewModel.cs
string IDataErrorInfo.this[string propertyName]
{
	get
	{
		string error = null;
		if (propertyName == "CustomerType")
		{
			// Customer的IsCompany属性是Boolean类型,因此它对"unselected"选项没有概念。
			// CustomerViewModel类处理该映射和验证
			error = this.ValidateCustomerType();
		}
		else { error = (_customer as IDataErrorInfo)[propertyName]; }
		
		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可以使用保存CustomerViewModel的功能。该命令使用前面检查的RelayCommand类来让CustomerViewModel决定是否可以保存自身以及保存其状态时要执行的操作。在此应用程序中,保存新客户仅意味着将其添加至CustomerRepository。决定是否准备好保存新客户需要两方同意。必须询问Customer对象是否有效,且CustomerViewModel必须决定它是否有效。由于之前检查了特定于ViewModel的属性和验证,因此这两部分组成的决策是必要的。

CustomerViewModel的保存逻辑:

// 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,并且还允许Boolean类型的诸如"unselected"状态之类的属性。它还提供了轻松告知客户保存其状态的能力。如果view直接绑定到Customer对象,则view需要大量代码才能使其正常工作。在设计良好的MVVM架构中,大多数view的背后代码应该是空的,或者最多只包含操作该view中包含的控件和资源的代码(即没有业务代码)。有时还需要在View的后置代码中编写与ViewModel对象交互的代码,例如钩子事件或调用很难从ViewModel本身调用的方法(即也不绝对)。

2.13 所有客户视图

Demo应用程序还包含一个工作区,该工作区在ListView中显示所有客户。列表中的客户根据他们是公司还是个人进行分组。用户可以一次选择一个或多个客户,并在右下角查看其销售额的总和。

UI是AllCustomersView控件,它呈现AllCustomersViewModel对象。每个ListViewItem代表AllCustomersViewModel对象公开的AllCustomers集合中的一个CustomerViewModel对象。在前面小节,介绍了CustomerViewModel如何呈现为输入表单,现在完全相同的CustomerViewModel对象呈现为ListView中的项。CustomerViewModel类不感知什么视觉元素去显示它,这就是这种重用可行的原因。

AllCustomersView创建了组,可在ListView中看到。它通过将ListView的ItemsSource绑定到如下图配置的CollectionViewSource上来实现此目的。

<!-- 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>

ListViewItem和CustomerViewModel对象之间的关联是通过ListView的ItemContainerStyle属性建立的。分配给该属性的Style会应用于每个ListViewItem,这使得ListViewItem上的属性能够绑定到CustomerViewModel上的属性。Style的一个重要绑定,是创建了在ListViewItem的IsSelected属性和CustomerViewModel的IsSelected属性间的连接,如下:

<Style x:key="CustomerItemStyle" TargetType="{x:Type ListViewItem}">
	<!--拉伸每个单元格的内容,以使 总销售额 列中的文本右对齐-->
	<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
	<!--将ListViewItem的IsSelected属性绑定到CustomerViewModel对象的IsSelected属性-->
	<Setter Property="IsSelected" Value="{Binding Path=IsSelected, Mode=TwoWay}"/>
</Style>

当选中或取消选中CustomerViewModel时,会导致所有选中客户的总销售额的总和发生变化。AllCustomersViewModel类负责维护该值,以便ListView下方的ContentPresenter能显示正确的数字。下图展示了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";
	// 确保引用的属性名称是有效的。这是debug技术,无法执行于release构建。
	(sender as CustomerViewModel).VerifyPropertyName(IsSelected);
	// 当一个客户被选中或取消选中时,我们必须让这个世界知道TotalSelectedSales属性发生更改,以再次查询新值
	if (e.PropertyName == IsSelected)
		this.OnPropertyChanged("TotalSelectedSales");
}

UI绑定到TotalSelectedSales属性,并对其值应用货币格式。ViewModel对象可以通过从TotalSelectedSales属性返回的String代替Double值来应用货币格式,而不是view。ContentPresenter的ContentStringFormat属性是在.NET Framework 3.5 SP1中添加的,因此如果必须以旧版本的WPF为目标,则需要在代码中应用货币格式:

<!-- In AllCustomersView.xaml -->
<StackPanel Orientation="Horizontal">
	<TextBlock Text="Total Selected sales:"/>
	<ContentPresenter 
		Content="{Binding Path=TotalSelectedSales}"
		ContentStringFormat="c" />
</StackPanel>

2.14 总结

WPF可以为应用程序开发人员提供很多功能,学习使用这些功能需要转变思维方式。Model-View-ViewModel模式是用于设计和实现WPF应用程序的一组简单而有效的指南。它允许你在数据、行为和呈现之间创建一种强健的分离,从而更轻松地在混乱中控制软件。

三、结尾

文章有点长,前面部分讲MVVM的历史与演变,后面用一个示例来演示实际应用,看到后半部分或许有些乏味了。

当下WPF使用MVVM开发,通常是用一些流行的框架,MVVMLight、MVVMToolkit、prism等。
如果你用过这些框架,会很容易看到框架中有上面示例的缩影。

写本文更多的是看一看官方人员是怎么看WPF和MVVM,是怎么用它来写代码的。
因为国内C#开发人员,大多是野生程序员,没有或很少有人系统学习过相关内容,不同人代码写法也千差万别(当然,虽然大厂都有代码规范;但作为个人而言,你爱怎么写怎么写,合理就行。)。通过了解历史和看官方示例这样一种方式,可以向大佬看齐,更加了解为何这样写。

02-29 07:47