UI设计模式之:MVVM模式经典

本文是翻译大牛Josh Smith的文章,WPF Apps With The Model-View-ViewModel Design Pattern,译者水平有限,如有什么问题请看原文,或者与译者讨论(非常乐意与你讨论)。

 


本文讨论的内容:

WPF与设计模式

MVP模式

对WPF来说为什么MVVM是更好的选择

用MVVM构建WPF程序

本文涉及的技术:

WPF、 数据绑定


内容列表

有序与混乱

模型-视图-视图模型的演变

为什么WPF开发者喜欢MVVM

演示程序

中继命令逻辑

ViewModel类层级结构

ViewModelBase类

CommandViewModel类

MainWindowViewModel类

View对应ViewModel

数据模型和Repository

新增客户数据表单

所有客户视图

总结


开发UI,对一个专业软件并不容易。它需要未知数据、交互式设计,可视化设计、联通性,多线程、国际化、验证、单元测试以及其他的一些东西才能完成。考虑到UI要展示开发的系统并且必须满足用户对系统风格不可预知的变更,因此它是很多应用程序最脆弱的地方。

有很多的设计模式可以帮助解决UI不断变更这头难缠的野兽,但是恰当的分离和描述多个关注点可能很困难。模式越复杂,之后用到的捷径越可能破坏之前正确的努力。

这并不总是设计模式的错。有时使用要写很多的代码复杂设计模式,这是因为我们使用的UI平台并不适合简单是设计模式。UI平台需要做的是很容易使用简单的,久经考验的,开发者认识的设计模式构建UI。庆幸的是,WPF就是这样一个平台。

随着是使用WPF开发的比例不断升高,WPF社区发展了自己的模式与实践生态圈子。在本文,我将讨论一些设计与实现客户端应用程序的WPF最佳实践。利用WPF和MVVM设计模式衔接的一些核心功能,我将通过一个例子介绍,用“正确”的方式构建一个WPF程序是多么的简单。

data templates, commands, data binding, the resource system以及 MVVM 模式怎么揉合到一起创建一个简单的、可测试的、健壮的框架,并且任何WPF程序都能使用,到文章最后,这一切都很清晰明了。文中的例程可以作为现实中一个WPF应用程序的模版,并且使用MVVM设计模式作为其核心架构。例程解决方案中的单元测试部分,展示了测试ViewModel类的功能是很容易的。在深入本文之前,我们首先看一下我们要使用像MVVM这样的设计模式。

有序与混乱

没有必要在一个”Hello,World!”的程序中使用设计模式。任何一个合格的开发者看一眼就指导那几行代码是干什么的。然而随着程序功能点的增加,随之代码的数量以及移动部件也会增多。最终系统的复杂度以及不断出现问题,促使开发者组织他们的代码,以便它们更容易理解,讨论、扩展以及维护。我们通过给代码中某些实体命以众所周知的名字,减少复杂系统认知误区。我们给函数块命名主要依据系统中的功能角色。

开发者有意识的根据设计模式组织他们的代码,而不是根据设计模式自动去组织。无论哪一种,都没有什么问题。但是在本文中,我说明在WPF程序中明确使用MVVM模式的好处。

某些类的名称,包括MVVM模式中著名的术语,如果类是view的抽象类就以ViewModel结束。这种方式有助于避免之前提到的认知误区。相反,你也可以让那种受控的误区存在,这正是大部分软件开发项目的自热状态。

模型-视图-视图模型的演变

自从人们开始构建UI时,就有很多流行的设计模式让UI构建更容易。比如,MVP模式在各种UI编程平台中都非常流行。MVP是MVC模式的一种变体,MVC模式已经流行了几十年了。以防你之前从没用过MVP模式,这里做一个简单的解释。你在屏幕上看到的是View,它显示的数据是Model,Presenter就是把两者联系起来。View依赖Presenter并通过Presenter展示Model数据,响应用户输入,提供数据验证(或许委托给Model去完成)以及其他的一些任务。如果你想了解更过关于MVP模式,我建议你去读Jean-Paul Boodhoo的 August 2006 Design Patterns column

2004年晚些时候,Martin Fowler发表了一篇叫Presentation Model(PM)的模式。PM模式和MVP类似,MVP是把一个View从行为和状态分离出来。PM中令人关注的部分是创建view的抽象,叫做Presentation Model。之后,View就仅仅是Presentation Model的展示了。在Fowler的论文中,他展示了Presentation Model经常更新View,以便两个彼此同步。同步逻辑组作为代码存在于Presentation Model类中。

2005年,John Gossman,目前是微软WPF和Silverlight架构师,在他的博客上披露了Model-View-ViewModel (MVVM)模式。MVVM和Fowler的Presentation Model是一致的,两个模式的特征都是View的抽象,都包含了View的行为和状态。Fowler引入Presentation Model是作为创建独立平台的View的抽象,而Gossman引入MVVM是作为标准化的方法,利用WPF的核心特点去简化UI的创建。从这种意义上来讲,我把MVVM作为一般PM模式的一个特例。

在Glenn Block一遍优秀的文章"Prism: Patterns for Building Composite Applications with WPF",于2008年9月微软大会发布,他解释了WPF微软组合程序开发向导。术语ViewModel没有用到,然而PM却用来描述View的抽象。这篇文章自始至终,都没没有出现我要将MVVM模式,以及View的抽象ViewModel。我发现这个术语在WPF和Silverlight社区中比较流行。

不像MVP中的Presenter,ViewModel不需要引用View。View 绑定ViewModel的属性,ViewMode向Viewl暴露Model对象的数据以及其他的状态。View和ViewModel之间的绑定很容易构造,因为ViewModel对象可以设置为View的DataContext。如果ViewModel中的属性值发生改变,新值将通过绑定自动传送给View。当用户点击View中的按钮时,ViewMode对于的Command将执行请求的动作。ViewModel,绝不是View,去执行实体对象的修改。

View类并不知道Model类是否存在,同时ViewModel和Model也不知道View。实际上,,Model完全不知道ViewModel和View存在,这是一个非常松耦合的设计,在很多方面都有好处,这不就你就会看到。

为什么WPF开发者喜欢MVVM

一旦开发者适应了WPF和MVVM,就很难区别两者。因为MVVM非常适合WPF平台,并且WPF被设计使用MVVM模式更容易构建应用程序,MVVM就成了WPF开发者的通用语。事实上,微软内部正在用MVVM开发WPF应用程序,像Microsoft Expression Blend,然而当时WPF平台的核心功能依然在开发之中。WPF的很多方面,像控制模型以及数据模版,都利用了MVVM推荐的显示状态和行为分离技术。

MVVM之所以成为一个伟大设计模式,是因为WPF的一个最重要的特征数据绑定构造。通过把Viewde 属性绑定到ViewModel,你就可以得到两者松耦合的设计,并且完全去除ViewModel更新View的那部分代码。数据绑定系统支持输入验证,并且输入验证提供了传递错误给View的标准方法。

另两个WPF的特点,数据模版和资源系统让MVVM模式更加可用。数据模版把View应用在ViewModel对象上,以便其能够在UI上显示。你可以在Xaml中声明模版,让资源系统在系统运行过程中自动定位并应用这些模版。你可以从我2008年7月写的一篇文章, "Data and WPF: Customize Data Display with Data Binding and WPF.",获取更多关于绑定和数据模版的信息。

要不是WPF对Command的支持,MVVM模式就不会那么强大。本文中,我会为你展示ViewModel怎样把Commands暴露给View,并且让View消费它的功能。如果你对Command不是很熟悉,我推荐你读一下2008年9月Brian Noyes发布的文章, "Advanced WPF: Understanding Routed Events and Commands in WPF"。

除了WPF(Silverlight2)本身让MVVM以一种自然的方式去构建程序之外,造成MVVM模式流行还有一个原因,那就是ViewModel类很容易进行单元测试。从某种意义来讲,View和单元测试只是ViewModel两个不同类型的消费者。拥有一套应用程序的单元测试,可以为提供更自由、快速的回归测试,而回归测试有助于降低之后应用的维护成本。

除了促进创建自动化回归测试外,ViewModel类的可测试性也有助于设计更容易分离的UI。当你设计应用时,你可以通过想象某些东西是否要创建单元测试消费ViewModel,来确定它们是放到View里面还是ViewModel里面。如果你可以为ViewModel写单元测试而不用创建任何UI控件,你也可以把ViewModel剥离出来,因为它不依赖任何具体可视化的组件。

最后,对于要和设计者合作的开发者来说,使用MVVM模式使得创建平滑的开发/设计工作流更加容易。既然View可以是ViewModel的任意一个消费者,就很容易去掉一个View通过新增一个View去渲染ViewModel。这个简单的步骤允许设计师构建快速原型以及评估UI设计。

这样开发团队可以关注创建健壮的ViewModel类,而设计团队可以关注设计界面友好的View。要融合两个团队输出只需要在View的xaml上进行正确的绑定即可。

演示程序

到此为止,我们回顾了MVVM的历史以及具体操作理论。我也说明了它在WPF开发者中间如此流行的原因。现在是时候继续我们的步伐,看一下MVVM模式在实际中的应用。这篇文章中的演示程序以各种方式使用MVVM设计模式,它提供了丰富的例子,帮助在上下文中理解MVVM的概念。我用VS2008 SP1创建的这个演示程序, 框架是Microsoft .NET Framework 3.5 SP1。单元测试是用的Visual Studio unit testing。

应用可以包含任意数量的“Workspace”,每一个都可以由用户点击左侧导航区的命令链接打开。所有的Workspace寄宿在主区域TabControl中,用户可以通过点击workspace的 tab item上关闭按钮关闭workspace。应用程序有两个可用的workspace:"All Customers" 和 "New Customer"。运行程序,打开一些workspace,UI看起来如图1所示。

clip_image002

图1 Workspaces

一次只有一个“All Customers“ Workspace的实例可以打开,但是可以打开多个New Customer Workspace。当用户决定创建一个新的客户时,她必须填完图2所示的数据输入表单。

clip_image003

图2 新客户数据输入表单

填完数据输入表单的所有有效值点击“Save”按钮,新客户的名称将会出现在tab item 上面,同时新客户也会增加到客户列表中。应用程序不支持删除或者编辑客户,但是这和其它功能类似,很容易在已有的程序架构上去实现。现在你已经对演示程序有了更深层次的理解了,接下来我们研究它是如何设计以及实现的。

中继命令逻辑Relaying Command Logic)

除了类构造器里调用初始化组件标准的样板代码,应用中的每一View的codebehind文件都是空的。实际上你可以移除View的codebehind文件,程序让人能够争正确的编译和运行。尽管View中没有事件处理方法,但是当用户点击按钮时,程序依然能够响应并满足用户的请求。之所以这样,是因为UI上Hyperlink、 Button以及MenuItem控件的Command属性被绑定了。绑定机制确保当用户在控件上点击时,由ViewModel暴露的ICommand对象能够执行。你可以把command对象看作一个适配器,这个适配器让command对象很容易消费在View中声明的ViewModel功能。

当ViewModel暴露ICommad类型的实例属性,被暴露的Command对象使用ViewModel中的对象去完成它的工作。其中一个可能的实现模式是在ViewModel内创建一个私有嵌套类,以便command能够访问包含在ViewModel中的私有成员,而不至于污染命名空间。嵌套类实现了ICommand接口,包含在ViewModel中对象的引用注入到其构造器中。但是为ViewModel暴露的每个Command创建实现ICommad的嵌套类,会增加ViewModel类的大小。更多的代码意味着存在BUGS潜力更大。

在演示程序中,RelayCommand类解决了这个问题。RelayCommand允许通过把委托传给其构造器,以实现对命令逻辑的注入。这种方式允许在ViewMode类中可以简单明了的实现Command。

RelayCommand是DelegateCommand的一个简单的变体,DelegateCommand可以在Microsoft Composite Application Library找到。RelayCommand类代码如图3所示。

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

作为接口ICommad实现一部分,事件CanExecuteChanged有一些值得关注的特征。它委托订阅CommandManager. RequerySuggested事件。这样以确保无论何时调用内置命令时,WPF命令架构都能调用所有能够执行的RelayCommand对象。

RelayCommand _saveCommand;
public ICommand SaveCommand
{
    get
    {
        if (_saveCommand == null)
        {
            _saveCommand = new RelayCommand(param => this.Save(),
                param => this.CanSave );
        }
        return _saveCommand;
    }
}

ViewModel类层级图

大部分ViewModel类有共同的特征,他们要实现INotifyPropertyChanged接口,需要显示一个友好的名字,以之前说道Workspace为例,它需要能够关闭(即从UI上移除)。要解决这个问题,自然就需要创建一个或二个ViewModel基类,以便新的ViewModel类能够从基类集成通用的功能。所有的ViewModel类形成如图4的层级图。

clip_image004

图4 继承层级图

为你的ViewModel创建一个基类并不是必须。如果你喜欢在类中通过组合几个小一点的类以获得那些功能,而不是用继承的方式,这并没有什么问题。就像任何其他的设计模式一样,MVVM是一套指导方针,而不是规则。

ViewModelBase 类

ViewModelBase 是层级中的根类,这就是它要实现通用INotifyPropertyChanged接口以及有一个DisplayName属性的原因。INotifyPropertyChanged接口包含一个叫PropertyChanged的事件。无论何时ViewModel对象的属性的发生改变时,它都会触发PropertyChanged事件,把新值通知给WPF绑定系统。根据通知,绑定系统检索属性,UI组件上绑定的属性将接受新值。

为了让WPF知道是那一个属性发生了改变,PropertyChangedEventArgs类暴露了一个string类型的属性PropertyName 。你一定要为事件参数传递正确的属性名,否则WPF将会为新值检索出一个错误的属性。

ViewModelBase一个值得关注的地方就是它为给定的属性名提供了验证,验证属性是否存在ViewModel对象上。重构时,这非常有用。因为通过VS 2008重构功能去改变属性名,不会更新源代码中字符串,而这些字符串正好包含属性名(其实不应该包含)。在事件参数中传递不正确的属性名,触发PropertyChanged事件时,可能会导致微小的BUGs,并且这些BUGs很难追踪,因此这个细微的特征将会节省大量的时间。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 类

CommandViewModel是最简单的ViewModelBase子类,它暴露了一个类型为ICommad的Command属性。MainWindowViewModel通过Commands属性暴露了CommandViewModel对象的一个集合。主窗口左手侧的导航区域,显示了MainWindowView­Model暴露每个CommandViewModel对象链接,像“View all customers”和“Create new customer”。当用户点击链接,将会执行相应的Command,在主窗口的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文件中存在一个key为CommandsTemplate的数据模版,主窗口(MainWindow)使用这个模版渲染之前提到的CommandViewModel对象集合。这个模版是简单在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并增加了“关闭”的能力。这个“关闭”,我的意思是在运行的时候能把workspace从UI上移除。有三个类继承于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();
}

MainWindow包含一个菜单项,该菜单项的Command属性绑定到MainWindowViewModel上的CloseCommand属性上。当用户点击该菜单,App类响应请求,调用窗体的关闭方法。菜单Xaml如下所示:

<!-- 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 item都有一个关闭按钮,其Command属性绑定到它对应WorkspaceViewModel实例的CloseCommand上。模版展示了如何渲染一个带关闭按钮的tab item。配置tab item模版的简化版会展示在下面代码中,这段代码可以在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>

当用户点击tab item上的关闭按钮时,会执行Workspace­ViewModel的CloseCommand,触发它的Request­Close事件。MainWindowViewModel会监控workspace的Request­Close事件,根据请求从Workspaces集合中移除相应的workspace。因为Main­Window的TabControl的ItemsSource绑定到Workspace­ViewModel的observable集合,从集合中移除对象,会引起从TabControl中移除相应的workspace。Main­WindowViewModel相应的逻辑如图8所示

图8 从UI上移除workspace

// 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间接从主窗体的Tab­Control控件中增加移除Workspace­ViewModel对象。通过数据绑定,TabItem的Content属性显示继承于ViewModelBase的对象。ViewModelBase并不是一个UI元件,因此他并不支持渲染它自己。默认在TextBlock中,WPF的一个非可视化对象通过调用ToString方法以显示该对象。很明显这不是你想要的,除非你的用户迫切的想知道ViewModel的类型名。

我们通过强类型数据模版很容易告诉WPF如何渲染ViewModel对象。强类型数据模版key属性名没有赋值,但是其DataType属性要赋以类型类的实例。如果WPF要去渲染ViewModel对象,它会检查在资源系统范围内是否有一个强类型数据模版的DataType和ViewModel对象(或者其基类)的类型一样。如果找到一个这样的模版的话,他会用该模版去渲染被TabItem Content属性绑定的ViewModel对象。

MainWindowResources.xaml文件中有一个Resource­Dictionary(资源字典),该字典被增加到主窗体的资源层级中,这意味着文件包含的资源在正窗体范围内有效。当一个TabItem的Content属性设置ViewModel对象时,该字典中的强类型数据模版会提供一个View(即用户自定义控件)去渲染TabItem Content。具体如图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>

你不需要写任何代码去决定哪一个View去展示ViewModel对象。WPF资源系统把你从繁重的工作解脱出来,让你去关注更重要的事情。在复杂的场景中,可能需要通过编程去选择View,但是在大部分情况下,通过编程选择View是不必要的。

The Data Model and Repository 数据模型和存储库

你已经知道应用程序如何去加载,显示以及关闭一个ViewModel对象。现在一切已经就位,你可以在整个应用程序范围内,回顾一下具体实现的细节。在深入理解应用程序的两个workspace,“All Customers” 和 “New Customer”之前,我们先审视一下数据模型和数据存取类。这些类的设计和MVVM模式并没有什么关系,因为你可以创建一个ViewModel类,以适应任何对WPF友好的数据对象。

演示程序中唯一的模型类Customer类,该类有些属性表征一个公司的客户,像他们的姓名,email等。它通过实现IDataErrorInfo接口提供属性验证信息,该接口在WPF大行其道之前已存在多年。Customer类里面并没有什么,并不是建议其用于MVVM架构,或者甚至应用和WPF应用程序。这个类很容易从遗留的业务库中获取。

数据必须存到某个地方,在这个应用程序中,CustomerRepository类的实例加载并存储所有的Customer对象。该CustomerRepository从xml文件加载所有的客户数据,这与外部的数据源无关。数据可能来自数据库、Web服务、命名管道、磁盘上的文件甚至信鸽,这并没有什么关系。只要你有一个有数据.net对象,不管它来自何方,MVVM模式都能在屏幕上获取其包含数据。

CustomerRepository类暴露了一些方法,这些方法允许你获取所有的Customer对象,增加一个Customer对象到存储室并检查其子啊存储室是否存在。既然应用程序不允许删除客户,存储室也不允许你去删除客户。当一个新Customer通过AddCustomer方法增加到CustomerRepository时,会触发CustomerAdded事件。

很明显,和真实业务程序所需的相比,该程序的数据模型是轻量级的,但是这并没有关系。重要的是,要理解ViewModel类如何利用Customer和CustomerRepository类。知道Customer­ViewModel是对Customer对象的封装,其通过一系列的属性暴露了Customer的状态,以及被Customer­View使用的状态。CustomerViewModel并不是复制Customer对象的状态,而是通过委托暴露这状态,具体如下:

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

当用户在CustomerView控件中创建新客户点击保存按钮时,与该视图关联的CustomerViewModel会增加一个Customer对象到Customer­Repository 。这会触发存储库的CustomerAdded事件,该事件让AllCustomers­ViewModel知道他应该增加一个Customer­ViewModel对象到AllCustomers集合。从某种意义说,Customer­Repository在各自ViewModel和他们要处理的Customer对象间扮演数据同步的角色,或许有人会把这当成中介者模式。我会在接下来的内容中介绍其实现机理,但是为了更进一步了解这些类如何连接在一起,我们现在先看一下图11所示的类图

clip_image006

图11 Customer关系图

New Customer Data Entry Form新客户数据输入表单

当用户点击“Create new customer”链接,MainWindowViewModel会增加一个CustomerViewModel到workspaces集合,相应的CustomerView回去显示。用户在输入框输入有效值之后,Save按钮变为可用状态,以便用户能存储增加客户信息。这并没有超出常规的地方,只是一个带有验证信息和Save按钮的常规输入表单而已。

Customer类内置输入验证支持,这是通过实现IDataErrorInfo接口获得。输入验证确保客户有一个名字,合法的email地址,如果客户是个人客户,还需要姓氏。如果Customer对象的IsCompany属性为真,则其LastName属性不能有值。该验证逻辑从Customer对象的角度看是有意义的,但是它并不能满足UI的需要,UI要求用户选择客户类别是个人还是公司。Customer类别选择器初始值是: (Not Specified),如果Customer对象的IsCompany属性只允许是true和false,客户类别是unspecified时,UI如何告诉用户?

假定你对整个软件系统拥有控制权限,你可以把IsCompany属性类型改变为Nullable<bool>,该类型允许“未选择”值(即空值-译者注)。然而在现实世界中,不是这么简单。假设你不能改变Customer类,因为它来自公司其他团队所开发系统。要是因为数据库的原因,没有简单方法存储未选择的值,怎么办?要是其它程序已经使用Customer类,并且其依赖正常Boolean类型的IsCompany属性,怎么办?诸如此类,可以使用ViewModel去解决。

图12所示的测试方法展示了该功能如何在CustomerViewModel中工作,CustomerViewModel暴露了一个CustomerTypeOptions属性,以便UI上的客户类型选择器有三个字符串显示。同时它也暴露了一个CustomerType属性,该属性存放选择器选中的字符串。当CustomerType被赋值时,它会潜在的Customer对象IsCompany属性,把字符类型转化为Boolean类型。图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 CustomerTypeOptions和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中的选择项发生改变时,其数据源的将会扫描IDataErroInfo接口查看新值是否有效。之所以这样,是因为绑定的SelectedItem属性有一个ValidateOnDataErrors设置为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实现了IDataErrorsInfo接口,可以处理对CustomerViewModel具体属性的验证请求,同时把其它请求委托给Customer对象处理。这样允许我们使用Model类的验证逻辑,其它属性验证在ViewModel类中才有意义。

通过SaveCommand属性去保存CustomerViewModel对象,该命令使用了之前陈述的RelayCommand,允许CustomerViewModel决定其是否能保存自己以及被告知保存其状态时做什么。在该程序中,保存一个新客户只是把其增加到CustomerRepository。决定一个新客户是否能够保存,需要两方面的许可,一是Customer对象是否有效,二是CustomerViewModel必须是有效的。这两方面是必要条件,由于前面陈述的ViewModel其特定属性以及Customer对象验证信息。CustomerViewModel的保存逻辑如图15所示

图15 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类型未选中这样事情存在。同时它很容易告诉客户保存其状态。如果View直接绑定到Customer对象,View将会需要很多代码才能恰当的工作。在一个设计良好的MVVM架构中,大部分View的背后代码应该为空,或者最多只包含操纵View内的控件以及资源的代码。有时在View后面写一些代码也是必须的,因为要和ViewModel对象进行交互,像传递事件或者调用方法否则从ViewModel做些事情很难。

All Customers View

演示程序也包含了一个在ListView中显示所有客户列表的workspace。这些客户通过根据其是个人客户还是公司客户进行分组。用户一次可以选择一个或者多个客户,在右下方查看其总销售额。

该UI是AllCustomersView控件,用以渲染AllCustomersViewModel对象。每个ListView­Item代表一个CustomerViewModel对象,该对象存在于AllCustomerViewModel对象暴露的AllCustomers集合中。在前一部分,你看到CustomerViewModel如何渲染成数据输入表单,而现在一模一样的CustomerViewModel对象却被渲染成ListView中的一个Item。CustomerViewModel并不知道那一个可视化的组件去显示它,这使得其重用成为可能。

AllCustomersView创建了在ListView中看到的分组,这是通过把ListView的ItemsSource绑定到配置如图16所示的Collection­ViewSource中实现的。

图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属性建立的。指定给该属性的Style应用于每个ListViewItem,这使得ListViewItem的属性可以绑定到CustomerViewModel对象的属性上。这个Style一个重要的绑定就是在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如何监控被选中或未选中的每个客户,并通知View更新要显示的值。

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

UI绑定了TotalSelectedSales属性,并把该值置为货币格式。ViewModel对象,而不是View,通过返回TotalSelectedSales属性Double类型值的字符串形式,设置其货币格式。.NET Framework 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>

Wrapping Up 总结

WPF为应用程序开发者提供了很多,学习利用WPF赋予的力量,但需要转变思维模式。MVVM模式是设计和开发WPF程序的一种简单而又有效的一套指导方针。它允许你创建数据、行为和展示强分离的程序,这更容易控制软件开发中的混乱因素

展开阅读全文

没有更多推荐了,返回首页