WPF应用程序和Model-View-ViewModel设计模式

WPF Apps With The Model-View-ViewModel Design Pattern

Josh Smith著 siyu77译 原文链接http://msdn.microsoft.com/zh-cn/magazine/dd419663.aspx#id0090120

 

本文讨论:       
  • 设计模式和WPF            
  • MVP模式
  • 为什么对WPF来说MVVM更好
  • 使用MVVM创建应用程序     
本文还使用了以下技术:        
WPF, 数据绑定

源代码可以从 MSDN Code Gallery下载 (译注:本地下载链接C#版 VB版
在线浏览代码
 
本文内容
秩序和混乱
Model-View-ViewModel的演变
为什么WPF开发者热爱MVVM
演示程序
中继命令逻辑
ViewModel类层次结构
ViewModelBase类
CommandViewModel类
MainWindowViewModel类
应用View到ViewModel
数据模型和资料库
New Customer数据输入表
All Customers视图
结束语
                                               

 

开发的专业软件应用程序的用户界面是不容易的。它可以说是一个晦涩的混合体,包含数据,交互设计,视觉设计,连通性,多线程,安全性,国际化,验证,单元测试,甚至对巫术的触碰。考虑到用户界面需要展示其背后隐藏的系统,并且必须满足用户的不可预知的需求,它是许多应用程序的最不稳定的区域。

有流行的设计模式,可以帮助驯服这头笨拙的野兽,但可能难以正确分离和解决众多我们关注的问题。模式越复杂,以后越有可能被抄捷径,继而破坏了所有先前的努力和正确的实现方式。

这并不完全是设计模式的过错。有时我们使用复杂的设计模式,需要编写大量的代码,因为正在使用的UI平台不提供一个简单的模式来应用它。我们需要的是一个平台,一个可以很容易构建的用户界面,使用简单,经过时间考验的的,开发者认可的设计模式。幸运的是,的Windows Presentation Foundation(WPF)提供了这一点。

由于软件业界采用WPF的增速越来越快,WPF社区已经系统性地发展出了自己的模式和实践。在这篇文章中,我将回顾一些最佳的模式和实践,并且使用WPF实现一个客户端应用程序。通过利用WPF的一些核心功能,结合Model-View-ViewModel(MVVM)设计模式,我将一步步展示一个示例程序的开发过程,演示通过“正确的方式”构建一个WPF应用程序是多么简单。

在这篇文章的末尾,你将明白如何使用数据模板,命令,数据绑定,资源系统,MVVM模式组合在一起来创建一个简单的,可测试的,健壮的框架,任何WPF应用程序都可以应用这个框架。本文附带的演示程序,可以作为一个使用MVVM作为其核心架构的真正的WPF应用程序模板。示例程序的单元测试展示了测试用户界面也是如此的方便,用户界面功能都包含在一组ViewModel类中。在深入讨论细节之前,让我们回顾一下,为什么你应该把使用MVVM这样的设计模式摆在首位。

 

秩序和混乱

在一个简单的“Hello,World!”程序中使用设计模式是不必要的和适得其反的。任何一个合格的开发人员都可以一眼就理解几行代码。然而,程序增加功能,代码行和不稳定部件的数量也相应增加。最后,系统的复杂性,以及它所包含的重复出现的问题,鼓励开发人员使用一种方式来组织他们的代码,使其容易理解,讨论,扩展和除错。我们给一些固定功能的代码使用一些众所周知的命名,来减少一个复杂的系统的认知混乱。我们根据一段代码在系统中的功能来决定它的名称。

开发人员经常故意重构他们的代码使其符合某种设计模式,而不是让模式出现地出现。两种方法都没有错,但在这篇文章中,我明确使用MVVM作为WPF应用程序的设计模式并解读它的好处。某些类的命名明确地来自MVVM模式,比如:如果类是一个视图的抽象,就用“ViewModel”后缀。这种方法有助于避免前面提到的认知混乱。相反,你可以愉快地存在于一个可控的混乱状态,这是最专业的软件开发项目的自然状态!

 

Model-View-ViewModel的演变

随着人们开始创建软件的用户界面,一些流行的设计模式也出现使其变得更加容易。例如,Model-View-Presenter (MVP)在各种UI编程平台得到普及。 MVP是这几十年来一直使用的Model-View-Controller模式的一种变体。如果你之前从来没有使用过MVP模式,这里有一个简单的解释。你在屏幕上看到的是视图View,它显示的数据是模型Model,View依赖于Presenter与Model挂钩。Presenter 泵送模型数据,对用户输入作出反应,提供输入验证(可能是委派给模型),以及其它诸如此类的任务。如果您想了解Model View Presenter的更多信息,我建议你阅读Jean-Paul Boodhoo的2006年八月的设计模式专栏

早在2004年,Martin Fowler发表了一篇关于名为Presentation Model(PM)的设计模式的文章。 PM模式是类似MVP的,将View从它的行为和状态中分离出来。PM模式的有趣的部分是创建一个视图的抽象,称为Presentation Model。之后View变成一个纯粹的Presentation Model的渲染呈现。在福勒的解释中,他表示,Presentation Model的经常更新它的View,使两者彼此同步。同步逻辑以代码方式存在于Presentation Model中。2005年,目前在微软任职的WPF和Silverlight架构师之一,John Gossman,在他的博客发表了Model-View-ViewModel (MVVM) 模式。 MVVM与Fowler的PM模式在某些方面的是相同的,这两种模式都介绍了抽象视图,其中包含视图的状态和行为。Fowler介绍Presentation Model,作为创建一个与UI平台无关的抽象视图的一种手段,而Gossman介绍MVVM作为一个标准化的方式来充分利用WPF的核心功能,以简化用户界面的创建。从这个意义上说,我认为MVVM的是一个更一般的PM模式,更专业化,为WPF和Silverlight平台量身定制。

Glenn Block在2008九月发表了优秀文章“Prism: Patterns for Building Composite Applications with WPF”,他解释了Microsoft Composite Application Guidance for WPF。ViewModel这个词是从来没被使用。相反,Presentation Model这个词被用来描述视图的抽象。不过,在本文中,我将把这种模式称为MVVM,把视图的抽象称为ViewModel。我觉得这个术语在WPF和Silverlight社区中更普遍。

不像MVP中的Presenter,一个ViewModel不需要引用View。而是View绑定一个ViewModel的各种属性,ViewModel公开模型对象以及其他状态给特定视图。View和ViewModel之间的绑定很容易构建,因为ViewModel对象被设为一个View的DataContext。如果在ViewModel属性值变化了,这些新的值会自动通过数据绑定传递给View。当用户在View中点击一个按钮,ViewModel中的命令被触发,执行请求的操作。 ViewModel执行模型中的数据的所有修改,而不是View。View类不知道该Model类的存在,同时ViewModel和Model也不了解View。事实上,Model完全无视ViewModel和View的存在。这是一个非常松耦合的设计,你会很快看到它在许多方面都有好处。


为什么WPF开发者热爱MVVM

一旦一个开发人员适应了WPF和MVVM,分开它俩就变得很难。 MVVM是WPF开发者的通用语,因为它非常适合WPF平台。而且WPF被设计成适用MVVM。相对于其他模式,使用MVVM模式构建应用程序对WPF来说更加简单。事实上,当WPF平台的核心正在构建的时候,微软就曾内部使用MVVM来开发WPF应用程序,如Microsoft Expression Blend。

WPF的很多方面,比如不可见的control model和data template,都利用了MVVM提供的强大的分离技术,分离显示和状态行为。一个最重要的方面,使得MVVM成为一个伟大的模式被使用,那就是WPF的数据绑定机制。通过绑定一个View到ViewModel,你会得到松耦合的两者,完全不需要在ViewModel中编写更新视图的代码。数据绑定系统还支持输入验证,它提供了一个标准化的方式发送验证错误到视图。

WPF的另外两个功能,使这种模式广泛应用,那就是数据模板data template和资源系统。数据模板将View附着到需要在用户界面中显示的ViewModel对象。您可以在XAML中声明模板,并让资源系统在运行时自动定位和应用这些模板。在我2008年7月的一篇文章中,你可以学到更多有关绑定和数据模板的东西:“Data and WPF: Customize Data Display with Data Binding and WPF”。

如果不支持WPF中的命令,MVVM模式将弱小得多。在本文中,我会告诉你一个ViewModel如何对一个View公开命令,从而使视图实现其功能。如果您不熟悉命令,我建议你仔细阅读Brian Noyes的综合性文章,“Advanced WPF: Understanding Routed Events and Commands in WPF”,来自2008年9月MSDN杂志。

WPF(和Silverlight 2)的功能使MVVM成了构筑应用程序的一种自然方式。不过撇开WPF不说,该模式也很受欢迎,因为ViewModel类很容易进行单元测试。当一个应用程序的交互逻辑存在于一组ViewModel类中,您可以轻松地编写代码,测试它。从某种意义上说,视图和单元测试只是两个不同类型的ViewModel使用者。拥有一组对ViewModels的测试用例,使你能够自由和快速地进行回归测试,这有助于降低维护应用程序随着时间推移而堆高的成本。

除了促进建立自动化的回归测试,可测性ViewModel类可以帮助你正确设计用户界面,使其容易更换皮肤。当你正在设计一个应用程序的时候,想象你要编写一个单元测试使用ViewModel的东西,你常常可以通过这种想象来决定某些东西是应在放在View中还是应该放在ViewModel中。如果你能编写ViewModel的单元测试,而无需创建任何UI对象,那么你也完全可以给ViewModel蒙上一层皮,因为它有没有依赖具体的可视元素。

最后,对于与视觉设计师协作的开发者来说,使用MVVM,使得它更容易创建一个平滑的设计师/开发人员工作流。由于视图只是一个ViewModel的一个任意使用者,很容易抹掉一个View,用另一个View来呈现ViewModel。这个简单的步骤允许快速原型设计和对设计师的用户界面做出评价。

开发团队可以专注于创造强大的ViewModel类,设计团队可以专注于友好的用户界面。连接两支团队的输出成果,要做的事情只不过是确保XAML文件中实现了正确的绑定,或者略多于此。


演示程序

至今,我回顾了MVVM的历史和操作理论。也分析了为什么它在WPF开发者中如此受欢迎。现在是时候卷起你的袖子,来亲自体验这种模式。本文附带的演示应用程序以多种方式使用MVVM。它提供了丰富的例子,以帮助理解在实际情况下如何应用这些概念。我用Visual Studio 2008 SP1和Microsoft .NET Framework 3.5 SP1创建了演示应用程序,单元测试在Visual Studio的单元测试系统中运行。

应用程序可以包含任意数量的“workspaces”,每个都可以通过用户点击左侧导航区域命令链接来打开。所有工作区都存在于主内容区的TabControl中。用户可以在工作区的选项卡上按一下“Close”按钮关闭工作区。应用程序有两个可用的工作区:“All Customers”和“New Customer”。运行应用程序并打开一些工作区后,用户界面如图1所示。

图1工作区

“All Customers”的工作区一次只能打开一个实例,但任意数量的“New Customer”工作区可以被同时打开。当用户决定创建一个新的顾客,她必须在图2中的数据输入表单填写。

图2 New Customer的输入表单

用有效的值填充数据输入表单,然后点击“保存”按钮后,新客户的名字会出现在选项卡上,并被添加到所有客户的列表中。应用程序不支持删除或编辑现有的客户,但该功能和许多其他功能类似,在现有的应用程序架构上很容易实现。现在,你对演示应用程序能做什么有了一个宏观的理解,接下来让我们探讨它是如何设计和实现的。


中继命令逻辑

应用程序中的每个View都有一个对应文件,除了类构造函数中的标准的样板代码InitializeComponent,这个文件里面没有内容。事实上,你可以从项目中删除这个代码隐藏文件,应用程序将仍然正确编译和运行。尽管View缺少事件处理方法,当用户点击按钮是,应用程序反馈并满足用户的要求。这能够起作用,依赖于一些绑定,绑定在界面上显示的超链接、按钮、菜单控件的Command属性上。这些绑定确保当用户点击控件时,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的一个简化版。Relay-Command 类如图3所示。

图3 RelayCommand类

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


ViewModel类层次结构

大多数ViewModel类需要相同的功能。他们往往需要实现INotifyPropertyChanged接口,它们通常需要一个友好的显示名称,而且,像workspaces那种情况,他们要能够被关闭(也就是说,从UI移除)。这个问题自然地引导我们创建一两个ViewModel基类,使新的ViewModel类可以从基类继承所有常用的功能。 ViewModel类继承层次结构如图4所示。

 

图4继承层次结构

为你所有的ViewModels提供一个基类绝不是一个必要条件。如果您通过希望组合许多更小的类来获得功能,而不是使用继承,也没有问题。就像任何其他的设计模式,MVVM是一套准则(guideline),而不是规则。


ViewModelBase类

ViewModelBase是类层次结构中的根,这就是为什么它实现了常用的INotifyPropertyChanged接口,并具有DisplayName属性。 INotifyPropertyChanged接口包含一个所谓的PropertyChanged事件。每当一个ViewModel对象的属性有了一个新值,它可以通过PropertyChanged事件通知WPF的绑定系统。收到该通知后,绑定系统查询该属性,一些UI元素的绑定属性也接收新的值。

为了让WPF知道ViewModel对象的那个属性已经改变,PropertyChangedEventArgs类公开了一个String类型的PropertyName属性。你必须小心地传递属性名称到事件的参数;否则,WPF将最终向一个错误的属性查询新值。

ViewModelBase的一个有趣的方面是,它具有能力验证一个指定名称的属性是否确实存在于ViewModel对象。这是在重构时非常有用,因为通过Visual Studio 2008的重构功能修改一个属性的名称,不会在你的源代码中更新包含该属性名称的字符串(也不应该更新)。用一个不正确的属性名作为参数发起PropertyChanged事件,可能导致微妙的错误,很难追查,所以这个小功能可以节省大量的时间。给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);
    }
}

 图5验证属性

 

CommandViewModel类

最简单具体的ViewModelBase子类是CommandViewModel。它公开了类型为ICommand的一个名为Command的属性。 MainWindowViewModel通过它的Commands属性公开这些命令对象的集合。主窗口左侧导航区域显示MainWindowViewModel暴露的各个CommandViewModel的链接,如“View all customers”和“Create new customer”。

当用户点击一个链接,也就是执行一个命令之,将在主窗口的TabControl中打开相应的workspace。 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文件中存在一个DataTemplate,它的key是“CommandsTemplate”。 MainWindow的使用该模板来呈现前面提到的CommandViewModels集合。该模板在一个ItemsControl中简单地将一个个CommandViewModel对象呈现为一个个链接。每个链接的Command属性绑定到的CommandViewModel的Command属性。该XAML如图6所示。

图6 呈现Command列表

<!-- 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的关闭请求被App类处理,App类创建了MainWindow和它的ViewModel,在图7可以看到。

图7 创建ViewModel

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


主窗口包含一个菜单项,其命令属性绑定到的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,指可以发出属性变化通知的集合,MVVM中集合的ViewModel处理起来挺麻烦),称为Workspaces。主窗口包含一个TabControl,它的ItemsSource属性绑定到该集合。每个选项卡的项目有一个关闭按钮,其命令属性绑定到其对应的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监视到它的Workspaces的RequestClose事件,并根据请求从Workspaces集合中删除工作区。由于MainWindow的TabControl已经将其ItemsSource属性绑定到WorkspaceViewModels可视集合,从集合中移除一个元素会导致TabControl中删除相应的workspace。MainWindowViewModel的这个逻辑如图8所示。

 图8从UI中删除工作区 

// 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所示。

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


应用View到ViewModel

MainWindowViewModel间接地把Workspace-ViewModel对象添加到主窗口的Tab-Control或者从Tab-Control删除。依靠数据绑定,TabItem的Content属性收到一个从ViewModelBase派生的对象来显示。 ViewModelBase不是一个UI元素,所以它不支持呈现自己。默认情况下,在WPF中呈现一个非可视化对象是调用其ToString方法,并在TextBlock中显示结果。这显然??不是你所需要的,除非你的用户有强烈的需求,想看到我们的ViewModel类的类型名称!(有这样的用户么o(╯□╰)o)

通过使用DataTemplate,您可以轻松地告诉WPF如何呈现一个ViewModel对象。类型化的DataTemplate不分配给它的一个x:Key值,但它确实有其DataType属性,值是Type类的一个实例。如果WPF尝试呈现您一个ViewModel对象,它会检查,看看作用域内资源系统中是否存在类型化的DataTemplate,与ViewModel对象的类型(或基类)一致。如果找到一个,它就使用这个模板来呈现选项卡的Content属性所引用的ViewModel对象。

MainWindowResources.xaml文件有一个ResourceDictionary。该词典被添加到主窗口的资源层次结构中,这意味着它包含的资源被引入了窗口资源范围(作用域)。当一个选项卡的内容设置为一个ViewModel对象,来自这个词典的已定义类型的DataTemplate提供一个视图(也就是说,一个用户控件)来呈现它,如图10所示。

 图10提供一个View

<!-- 
This resource dictionary is 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 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>

 <!-- Other resources omitted for clarity... -->

</ResourceDictionary>


您不需要编写任何代码来指明哪个视图显示一个ViewModel对象。 WPF资源系统为您处理了所有繁重的事情,使你解放出来关注更重要的事情。在更复杂的情况下,也可以通过编程选择视图,但在大多数情况下是不必要的。


数据模型和资料库

您已经看到了ViewModel对象是如何被应用程序外壳加载、显示和关闭的。现在,基础设施已经就绪,您可以更具体地查看应用程序域的实现细节。在深入应用程序的两个工作区“All Customers”和“New Customer”之前,让我们先看一下数据模型和数据访问类。这些类的设计几乎什么和MVVM模式没什么关系,因为你可以创建ViewModel类来做任何数据对象和友好界面的适配。

在演示程序中的唯一的模型类是Customer。这个类有少数的属性,代表公司客户的信息,如他们的名字,姓氏,和e-mail地址。它实现标准的IDataErrorInfo接口(在WPF面市之前已存在多年)来提供验证消息。 Customer类中没有任何东西表明它需要在MVVM架构中使用,甚至任何东西表明它在WPF应用程序中使用。这个类可以很容易地取自传统业务库。

数据必须存储和来自某处。在此应用中,一个CustomerRepository类的实例加载和存储所有的客户对象。它恰巧从一个XML文件加载客户数据,不过外部数据源的类型无关紧要。数据可以来自数据库、Web服务、命名管道、磁盘上的文件、甚至信鸽:这根本无所谓(我想知道怎么从信鸽身上读数据)。只要你有带数据的.NET对象,不管它来自何处,MVVM模式可以把得到的数据搬到屏幕上。

CustomerRepository类公开了一些方法,让你可以得到所有可用的Customer对象,可以添加新的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”按钮,与该视图关联的CustomerViewModel将添加新的Customer对象到CustomerRepository。这导致库CustomerAdded事件被触发,使得AllCustomersViewModel知道它应该添加一个新的CustomerViewModel到它的AllCustomers集合。从某种意义上说,CustomerRepository在各种ViewModel之间扮演同步机制的行为,并处理Customer对象。也许有人认为这是在使用Mediator设计模式。关于它是如何工作的,我将在接下来的章节中揭示更多。但现在,请参阅图11,对所有东西是如何结合在一起的有一个大致的理解。


图11 Customer关系


New Customer数据输入表

当用户点击“Create new customer”链接时,MainWindowViewModel增加了一个新的CustomerViewModel到它的workspace列表中,并且一个CustomerView控件将其显示出来。用户在输入框中键入有效字段后,“保存”按钮进入启用状态,使用户能保存新的客户信息。这里所有东西都很普通,一个普通的带验证和保存功能的数据输入表。

Customer类具有内置的验证支持,可通过其IDataErrorInfo接口实现来获得。该验证确保客户有一个名字,一个格式正确的的e-mail地址,而且,如果客户是一个人,需要一个姓氏。如果客户的IsCompany属性返回true,LastName属性不能有一个值(想到的是,公司没有姓氏)。从Customer对象的角度来看,这个验证逻辑有意义,但它并不满足用户界面的需求。用户界面要求用户选择新的Customer是一个人还是一个公司。Customer Type选择器具有“(未指定)”的初值。可是Customer对象的IsCompany属性只有true或者false值,UI怎么才能让用户知道客户类型未指定?

假设你已经对整个软件系统有完全控制权,你可以把IsCompany属性的类型改为Nullable<bool>,这种类型运行你设置“unselected”值。然而,现实世界并不总是那么简单。假如你不能改变Customer类,因为它是公司的其他团队以库的形式遗留下来的。如果没有简单的方法保存“unselected”值,因为现有的数据库表不支持,怎么办?如果其他应用程序已经使用Customer类并且正将该属性当做布尔值使用,怎么办?又一次,ViewModel来拯救你了。图12中的测试方法显示,这个功能如何在CustomerViewModel中运作。

CustomerViewModel公开了CustomerTypeOptions属性,使Customer Type选择器有三种字符串可以显示。它也公开出一个CustomerType属性,用来存储选择器上所选字符串。当CustomerType被置值,它的字符串值映射到一个布尔值,赋给潜在的Customer对象的IsCompany属性。图13演示了这两个属性。

图12 测试方法

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

 

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


当ComboBox的选定内容变化,数据源的IDataErrorInfo接口被调用,看看新的值是不是有效的。发生这种情况,是因为SelectedItem属性绑定把ValidatesOnDataErrors设成了true。由于数据源是一个CustomerViewModel对象,绑定系统向CustomerViewModel询问CustomerType属性上的验证错误。大部分情况下,CustomerViewModel委托所有验证错误的请求到它所包含的Customer对象。然而,由于Customer没有IsCompany属性未选择状态的概念,CustomerViewModel类必须处理和验证ComboBox控件中选中的新选项。这部分代码可以在图14看到。

 

 图14验证CustomerViewModel对象

// 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 = (_customer as 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";
}


这段代码的一个重要方面是,CustomerViewModel对IDataErrorInfo的实现,可以为ViewModel特定的属性处理验证请求,也可以委托Customer对象处理其他验证请求。这使您可以利用模型类的验证逻辑,同时也可以拥有额外的对ViewModel类才有意义的验证功能。

View可以通过CustomerViewModel的SaveCommand属性来获取保存能力。该命令使用了前面提到的RelayCommand类,使CustomerViewModel能判断是否可以保存自己,被告知需要保存自己的状态时应该怎么做。在此应用中,保存一个新的Customer仅仅意味着将它添加到CustomerRepository。决定新的Customer是否可以保存,需要双方的同意。必须问Customer对象它是有效还是无效,然后CustomerViewModel必须决定它是否有效。由于ViewModel的特有属性和前置检查,这个两部分的决定都是必要的。 CustomerViewModel保存逻辑如图15所示。

 图15CustomerViewModel保存逻辑

// 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对象的视图更容易,还能应付带“未选中”状态的布尔属性。它还提供了方便的途径告诉Customer保存其状态。如果视图被直接绑定到一个Customer对象,为了正常工作视图将需要大量的代码。在精心设计的MVVM的架构下,大多数视图的代码隐藏文件应该是空的,或者,顶多只包含操作视图控件和资源的代码。有时还必须在视图写代码使其与ViewModel对象交互,比如hook事件或调用方法,诸如此类ViewModel本身很难实现的东西。


All Customers视图

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

UI是AllCustomersView控件,是AllCustomersViewModel对象的呈现。每个ListViewItem代表一个AllCustomerViewModel对象公开的AllCustomers集合中的CustomerViewModel对象。在前面的章节中,你已经知道CustomerViewModel可以被显示为一个数据输入窗体,而现在,完全相同的CustomerViewModel对象要在ListView的item中呈现。 CustomerViewModel类已经不知道自己会被什么视觉元素显示出来,这就是为什么它能够被重用。AllCustomersView在ListView中创建分组。绑定ListView的ItemsSource到如图16所示的CollectionViewSource即可实现。 

 图16 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属性之上。分配给该属性的样式被应用到每个ListViewItem,使ListViewItem的各个属性绑定到CustomerViewModel的各个属性。该样式中的一个重要绑定,创建了ListViewItem 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被选择或取消选择,导致所有选定客户的销售总额值变化。 AllCustomersViewModel类是负责维护那个值的,所以ListView下面的ContentPresenter可以正确显示该数字。图17显示了AllCustomersViewModel如何监视每个客户被选择或取消选择,并通知视图需要更新显示值。

  图17选择或取消选择的监视

// 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.
    (sender as 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");
}

 

用户界面绑定到TotalSelectedSales属性并对数值应用货币格式。 ViewModel对象可以代替View套用货币格式,通过返回字符串,而不是返回TotalSelectedSales属性的Double值。 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>


 

结束语

WPF提供了很多东西开发者,需要转换观念来学习和利用。Model-View-ViewModel模式,是简单而有效的WPF设计实现指导。它使你能创建一个数据、行为和显示之间的强烈分离,使软件开发中的混乱更容易控制。

我想感谢John Gossman对本文的帮助。

Josh Smith热衷于使用WPF创造出色的用户体验。由于他在WPF社区的工作,他被授予微软MVP称号。Josh在Experience Design Group为Infragistics效力。不在电脑旁的时候,他喜欢弹钢琴,阅读历史,还有和他的女朋友逛纽约市。您可以访问Josh的博客joshsmithonwpf.wordpress.com

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值