设计模式-具有Model-View-ViewModel设计模式的WPF应用

设计模式-具有Model-View-ViewModel设计模式的WPF应用


翻译自:MSDN杂志问题/2009年/二月

有许多流行的设计模式可以帮助驯服这种笨拙的野兽,但是要正确地分离和解决众多问题可能很困难。模式越复杂,以后使用这种快捷方式破坏以前所有以正确方式做事的努力的可能性就越大。

并非总是设计模式有问题。有时,我们使用复杂的设计模式,这需要编写大量代码,因为使用的UI平台无法很好地适应更简单的模式。所需要的是一个平台,该平台可轻松使用经过时间验证,开发人员认可的简单设计模式来构建UI。幸运的是,Windows Presentation Foundation(WPF)正是提供了这一点。

随着软件界越来越多地采用WPF,WPF社区一直在开发自己的模式和实践生态系统。在本文中,我将回顾一些使用WPF设计和实现客户端应用程序的最佳实践。通过结合使用WPF的某些核心功能和Model-View-View Model(MVVM)设计模式,我将遍历一个示例程序,该程序演示以“正确的方式”构建WPF应用程序有多么简单。

到本文结束时,将很清楚如何将数据模板,命令,数据绑定,资源系统和MVVM模式组合在一起,以创建一个简单,可测试的健壮框架,任何WPF应用程序都可以在此框架上蓬勃发展。本文随附的演示程序可以用作使用MVVM作为其核心体系结构的实际WPF应用程序的模板。演示解决方案中的单元测试表明,在一组ViewModel类中存在该功能时,测试该应用程序用户界面的功能是多么容易。在深入探讨细节之前,让我们回顾一下为什么首先应该使用像MVVM这样的模式。

秩序与混乱

在简单的“ Hello,World!”程序中使用设计模式是不必要的,而且会适得其反。任何有能力的开发人员一眼就能理解几行代码。但是,随着程序中功能部件数量的增加,代码行和移动部件的数量也相应增加。最终,系统的复杂性及其所包含的反复出现的问题促使开发人员以一种易于理解,讨论,扩展和排除故障的方式来组织代码。通过将众所周知的名称应用于源代码中的某些实体,我们减少了复杂系统的认知混乱。我们通过考虑代码在系统中的功能角色来确定要应用于一段代码的名称。

开发人员经常有意根据设计模式来构造代码,而不是让这些模式有机地出现。两种方法都没有错,但是在本文中,我研究了显式使用MVVM作为WPF应用程序体系结构的好处。某些类的名称包含MVVM模式中的知名术语,例如,如果该类是视图的抽象,则以“ ViewModel”结尾。这种方法有助于避免前面提到的认知混乱。相反,您可以快乐地处于受控的混乱的状态,这是大多数专业软件开发项目中的自然状态!

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

自从人们开始创建软件用户界面以来,一直存在流行的设计模式来帮助使它变得更容易。例如,Model-View-Presenter(MVP)模式在各种UI编程平台上都非常流行。MVP是Model-View-Controller模式的变体,已经存在了数十年。如果您以前从未使用过MVP模式,则这里有一个简化的说明。您在屏幕上看到的是视图,显示的数据是模型,演示者将两者挂钩。该视图依赖于Presenter来向其填充模型数据,对用户输入做出反应,提供输入验证(可能通过委派给模型)以及其他此类任务。如果您想了解有关Model View Presenter的更多信息,建议您阅读Jean-Paul Boodhoo的2006年8月“设计模式”专栏。

早在2004年,Martin Fowler就发表了一篇有关模式的文章,该模型名为Presentation Model(PM)。PM模式与MVP相似,因为它将视图与其行为和状态分开。PM模式有趣的部分是创建了视图的抽象,称为Presentation Model。这样,视图仅成为表示模型的呈现。在Fowler的解释中,他表明表示模型经常更新其视图,从而使两者保持同步。该同步逻辑作为代码存在于Presentation Model类中。

2005年,John Gossman(目前是Microsoft的WPF和Silverlight架构师之一)在他的博客中公开了Model-View-ViewModel(MVVM)模式。MVVM与Fowler的Presentation Model相同,因为这两种模式都具有View的抽象,其中包含View的状态和行为。Fowler引入了Presentation Model作为创建与UI平台无关的视图抽象的一种方式,而Gossman引入了MVVM作为利用WPF核心功能简化用户界面创建的标准化方法。从这个意义上讲,我认为MVVM是针对WPF和Silverlight平台量身定制的更为通用的PM模式的专业化。

在2008年9月发行的Glenn Block的优秀文章“棱镜:使用WPF构建复合应用程序的模式”中,他解释了针对WPF的Microsoft复合应用程序指南。从未使用术语ViewModel。相反,术语“表示模型”用于描述视图的抽象。但是,在整篇文章中,我将模式称为MVVM,将视图的抽象称为ViewModel。我发现该术语在WPF和Silverlight社区中更为流行。

与MVP中的Presenter不同,ViewModel不需要引用视图。视图绑定到ViewModel的属性,而ViewModel的属性又公开了模型对象中包含的数据以及该视图特定的其他状态。view和ViewModel之间的绑定很容易构造,因为ViewModel对象被设置为视图的DataContext。如果ViewModel中的属性值发生更改,这些新值将通过数据绑定自动传播到视图。当用户单击视图中的按钮时,将执行ViewModel上的命令以执行请求的操作。ViewModel(而不是View)执行对模型数据所做的所有修改。

视图类不知道存在模型类,而ViewModel和模型不知道视图。实际上,该模型完全忽略了ViewModel和视图的存在。您将很快看到,这是一个非常松散的耦合设计,它以多种方式带来收益。

为什么WPF开发人员喜欢MVVM

一旦开发人员对WPF和MVVM感到满意,可能很难区分两者。MVVM是WPF开发人员的通用语,因为它非常适合WPF平台,并且WPF旨在简化使用MVVM模式构建应用程序的过程。实际上,Microsoft在内部使用MVVM来开发WPF应用程序,例如Microsoft Expression Blend,而核心WPF平台正在建设中。WPF的许多方面,例如免看控制模型和数据模板,都充分利用了显示与MVVM促进的状态和行为的强烈分离。

WPF使MVVM成为一种绝佳模式的WPF的最重要方面是数据绑定基础结构。通过将视图的属性绑定到ViewModel,可以使两者之间松散耦合,并且完全不需要在直接更新视图的ViewModel中编写代码。数据绑定系统还支持输入验证,这提供了将验证错误传输到视图的标准化方法。

WPF的其他两个使该模式可用的功能是数据模板和资源系统。数据模板将视图应用于用户界面中显示的ViewModel对象。您可以在XAML中声明模板,然后让资源系统在运行时自动为您查找和应用这些模板。您可以在我2008年7月的文章“数据和WPF:使用数据绑定和WPF自定义数据显示”中了解有关绑定和数据模板的更多信息。

如果不支持WPF中的命令,则MVVM模式的功能将大大降低。在本文中,我将向您展示ViewModel如何将命令公开给View,从而允许视图使用其功能。如果您对命令不熟悉,建议您阅读2008年9月版的Brian Noyes的综合文章“高级WPF:了解WPF中的路由事件和命令”。

除了WPF(和Silverlight 2)功能使MVVM成为构建应用程序的自然方式之外,该模式也很流行,因为ViewModel类易于进行单元测试。当应用程序的交互逻辑位于一组ViewModel类中时,您可以轻松编写测试它的代码。从某种意义上说,视图和单元测试只是两种不同类型的ViewModel使用者。为应用程序的ViewModel拥有一套测试可以提供免费和快速的回归测试,这有助于降低随着时间的推移维护应用程序的成本。

除了促进创建自动回归测试之外,ViewModel类的可测试性还可以帮助正确设计易于使用的用户界面。在设计应用程序时,通常可以通过想象要编写一个使用ViewModel的单元测试来决定是在视图中还是在ViewModel中。如果可以在不创建任何UI对象的情况下为ViewModel编写单元测试,则还可以完全使ViewModel外观化,因为它不依赖于特定的可视元素。

最后,对于与视觉设计师合作的开发人员,使用MVVM可以更轻松地创建流畅的设计师/开发人员工作流程。由于视图只是ViewModel的任意使用者,因此很容易将一个视图撕下并放入新视图以呈现ViewModel。这个简单的步骤可以使设计人员快速建立原型并评估用户界面。

开发团队可以专注于创建健壮的ViewModel类,而设计团队可以专注于创建用户友好的View。连接两个团队的输出仅涉及确保视图的XAML文件中存在正确的绑定而已。

演示应用

在这一点上,我已经回顾了MVVM的历史和操作理论。我还研究了为什么它在WPF开发人员中如此受欢迎。现在是时候收起袖子,看看模式在起作用。本文随附的演示应用程序以多种方式使用MVVM。它提供了丰富的示例资源,以帮助将概念引入有意义的上下文中。我在Visual Studio 2008 SP1中针对Microsoft .NET Framework 3.5 SP1创建了演示应用程序。单元测试在Visual Studio单元测试系统中运行。

该应用程序可以包含任意数量的“工作区”,用户可以通过单击左侧导航区域中的命令链接来打开每个工作区。所有工作空间都位于主内容区域上的TabControl中。用户可以通过单击该工作区的选项卡项上的“关闭”按钮来关闭该工作区。该应用程序有两个可用的工作区:“所有客户”和“新客户”。运行该应用程序并打开一些工作区后,UI类似于图1。

Figure 1 Workspaces
图1工作区

一次只能打开“所有客户”工作区的一个实例,但是一次可以打开任何数量的“新客户”工作区。当用户决定创建新客户时,她必须填写图2中的数据输入表单。


图2新客户数据输入表单

在使用有效值填写数据输入表单并单击“保存”按钮之后,新客户的名称将显示在选项卡项中,并且该客户将添加到所有客户的列表中。该应用程序不支持删除或编辑现有客户,但是通过在现有应用程序体系结构上进行构建,可以轻松实现该功能以及与之类似的许多其他功能。现在,您已经对演示应用程序的功能有了一个高级的了解,让我们研究一下它是如何设计和实现的。

中继命令逻辑

应用程序中的每个视图都有一个空的代码隐藏文件,但在类的构造函数中调用InitializeComponent的标准样板代码除外。实际上,您可以从项目中删除视图的代码隐藏文件,并且该应用程序仍将正确编译并运行。尽管视图中没有事件处理方法,但是当用户单击按钮时,应用程序会做出反应并满足用户的请求。这之所以有效,是因为在UI中显示的Hyperlink,Button和MenuItem控件的Command属性上建立了绑定。这些绑定确保当用户单击控件时,将执行ViewModel公开的ICommand对象。您可以将命令对象视为一个适配器,可以轻松地从XAML中声明的视图中使用ViewModel的功能。

当ViewModel公开ICommand类型的实例属性时,命令对象通常使用该ViewModel对象来完成其工作。一种可能的实现模式是在ViewModel类内创建一个私有嵌套类,以便该命令可以访问其包含ViewModel的私有成员,并且不会污染名称空间。该嵌套类实现ICommand接口,并将对包含的ViewModel对象的引用注入其构造函数中。但是,为ViewModel公开的每个命令创建实现ICommand的嵌套类,可能会使ViewModel类的大小膨胀。更多的代码意味着更大的潜在错误。

在演示应用程序中,RelayCommand类解决了此问题。RelayCommand允许您通过传递给其构造函数的委托来注入命令的逻辑。这种方法允许在ViewModel类中简洁,简洁地执行命令。RelayCommand是Microsoft Composite Application Library中的DelegateCommand的简化变体。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 
}

作为ICommandinterface实现的一部分的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接口,通常需要具有用户友好的显示名称,对于工作空间,他们需要具有关闭功能(即,从UI中删除)。这个问题自然会导致创建一个或两个ViewModel基类,以便新的ViewModel类可以继承基类的所有常用功能。ViewModel类构成图4中所示的继承层次结构。


图4继承层次结构

绝不是所有ViewModel的基类。如果您希望通过将许多较小的类组合在一起而不是使用继承来获得类中的功能,那么这不是问题。就像任何其他设计模式一样,MVVM是一组准则,而不是规则。

ViewModelBase类

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

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

ViewModelBase的一个有趣方面是,它提供了验证ViewModel对象上确实存在具有给定名称的属性的功能。这在重构时非常有用,因为通过Visual Studio 2008重构功能更改属性名称将不会更新源代码中恰好包含该属性名称的字符串(也不应该)。在事件参数中使用不正确的属性名称引发PropertyChanged事件可能会导致难以跟踪的细微错误,因此此小功能可能会节省大量时间。图5显示了ViewModelBase中添加了此有用支持的代码。

图5验证属性

// In ViewModelBase.cs 
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged(string propertyName)
{
    this.VerifyPropertyName(propertyName);
    PropertyChangedEventHandler handler = this.PropertyChanged;
    if (handler != null)
    {
        var e = new PropertyChangedEventArgs(propertyName); 
        handler(this, e);
    }
}
[Conditional("DEBUG")]
[DebuggerStepThrough]
public void VerifyPropertyName(string propertyName)
{
    // Verify that the property name matches a real, 
    // public, instance property on this object. 
    if (TypeDescriptor.GetProperties(this)[propertyName] == null)
    {
        string msg = "Invalid property name: " + propertyName;
        if (this.ThrowOnInvalidPropertyName) 
          throw new Exception(msg);
        else 
          Debug.Fail(msg);
    }
}
CommandViewModel类

最简单的具体ViewModelBase子类是CommandViewModel。它公开了一个名为ICommand类型的Command属性。MainWindowViewModel通过其Commands属性公开了这些对象的集合。主窗口左侧的导航区域显示MainWindowViewModel公开的每个CommandViewModel的链接,例如“查看所有客户”和“创建新客户”。当用户单击链接从而执行这些命令之一时,将在主窗口的TabControl中打开一个工作区。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中的链接。每个超链接的Command属性都绑定到CommandViewModel的Command属性。该XAML如图6所示。

图6呈现命令列表

<!-- In MainWindowResources.xaml -->
<!-- This template explains how to render the list of commands 
on the left side in the main window (the 'Control Panel' area). -->
<DataTemplate x:Key="CommandsTemplate">
    <ItemsControl ItemsSource="{Binding Path=Commands}">
        <ItemsControl.ItemTemplate>
            <DataTemplate>
                <TextBlock Margin="2,6"> 
                    <Hyperlink Command="{Binding Path=Command}"> 
                    <TextBlock Text="{Binding Path=DisplayName}" /> 
                    </Hyperlink> 
                </TextBlock>
            </DataTemplate>
        </ItemsControl.ItemTemplate>
    </ItemsControl>
</DataTemplate>
MainWindowViewModel类

如之前在类图中所示,WorkspaceViewModel类派生自ViewModelBase并添加了关闭功能。“关闭”是指某些东西在运行时从用户界面中删除了工作空间。从WorkspaceViewModel派生三个类:MainWindowViewModel,AllCustomersViewModel和CustomerViewModel。MainWindowViewModel的关闭请求由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类通过调用窗口的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对象集合,称为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监视其工作空间的RequestClose事件,并根据请求将其从Workspaces集合中移除。由于MainWindow的TabControl具有绑定到WorkspaceViewModels的observablecollection的ItemsSource属性,因此从collection中删除项目会导致将相应的工作空间从TabControl中删除。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.");
}
将视图应用于ViewModel

MainWindowViewModel间接在主窗口的TabControl中添加和从其中删除WorkspaceViewModel对象。通过依赖数据绑定,aTabItem的Content属性接收到一个ViewModelBase派生的对象来显示.ViewModelBase不是UI元素,因此它不具有渲染自身的固有支持。默认情况下,在WPF中,通过在TextBlock中显示对其ToString方法的调用结果来呈现非可视对象。显然,这不是您所需要的,除非您的用户迫切希望查看我们的ViewModel类的类型名称!

您可以轻松地告诉WPF如何使用键入的DataTemplates呈现ViewModel对象。类型化的DataTemplate没有分配anx:Key值,但确实将其DataType属性设置为Type类的实例。如果WPF尝试呈现您的ViewModel对象之一,它将检查资源系统在范围内是否具有类型化的DataTemplate,其DataType与ViewModel对象的类型相同(或作为其基本类)。如果找到一个,它将使用该模板来呈现选项卡项的Content属性引用的ViewModel对象。

MainWindowResources.xaml文件有一个ResourceDictionary。该字典已添加到主窗口的资源层次结构中,这意味着它包含的资源在窗口的资源范围内。当选项卡项的内容被设定为ViewModelobject,一个从该字典用品类型的DataTemplate的视图(即,用户控制)来呈现,如图所示在图10。

图10提供视图

<!-- 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对象如何由应用程序外壳程序加载,显示和关闭。现在已经有了常规的管道,您可以查看更特定于应用程序领域的实现细节。在深入了解应用程序的“所有客户”和“新客户”这两个工作区之前,让我们首先检查数据模型和数据访问类。这些类的设计几乎与MVVM模式无关,因为您可以创建一个ViewModel类来使几乎所有数据对象都适合WPF。

演示程序中唯一的模型类是Customer。该类具有一些属性,这些属性表示有关公司客户的信息,例如他们的名字,姓氏和电子邮件地址。它通过实现标准IDataErrorInfo接口来提供验证消息,该接口在WPF出现之前已经存在了多年。Customer类中没有任何内容表明它已在MVVM体系结构甚至WPF应用程序中使用。该类很可能来自旧式业务库。

数据必须来自并驻留在某个地方。在此应用程序中,CustomerRepository类的实例加载并存储所有Customer对象。它恰巧从XML文件加载客户数据,但是外部数据源的类型无关紧要。数据可能来自数据库,Web服务,命名管道,磁盘上的文件,甚至是信鸽:这根本没有关系。只要您有一个包含一些数据的.NET对象,无论它来自何处,MVVM模式都可以在屏幕上获取该数据。

CustomerRepository类提供了一些方法,这些方法使您可以获取所有可用的Customer对象,将新的Customer添加到存储库,并检查Customer是否已在存储库中。由于应用程序不允许用户删除客户,因此存储库不允许您删除客户。当新客户通过AddCustomer方法进入CustomerRepository时,将触发CustomerAdded事件。

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

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

当用户创建一个新客户并单击CustomerView控件中的Save按钮时,与该视图关联的CustomerViewModel会将新的Customer对象添加到CustomerRepository。这将导致触发存储库的CustomerAdded事件,从而使AllCustomersViewModel知道应将新的CustomerViewModel添加到其AllCustomers集合中。从某种意义上说,CustomerRepository充当处理客户对象的各种ViewModel之间的异步机制。也许有人会想到使用Mediator设计模式。我将在接下来的部分中回顾更多有关其工作原理的信息,但现在请参考图11中的图,以高层次地了解所有部分如何组合在一起。

图11客户关系

新客户数据输入表

当用户单击“创建新客户”链接时,MainWindowViewModel将新的CustomerViewModel添加到其工作区列表中,然后由CustomerView控件显示。用户在输入字段中输入有效值后,“保存”按钮进入启用状态,以便用户可以保留新的客户信息。这里没有什么与众不同的地方,只有带有输入验证和保存按钮的常规数据输入表单。

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

假设您完全控制了整个软件系统,则可以将IsCompany属性更改为Nullable类型,以允许使用“ unselected”值。但是,现实世界并不总是那么简单。假设您不能更改Customer类,因为它来自公司中其他团队拥有的旧版库。如果由于现有的数据库模式,没有简单的方法来保留“未选定”的值怎么办?如果其他应用程序已经在使用Customer类并且依赖于该属性为普通布尔值怎么办?再一次,有了ViewModel可以解决问题。

图12中的测试方法显示了此功能在CustomerViewModel中的工作方式。CustomerViewModel公开一个CustomerTypeOptions属性,以便“客户类型”选择器显示三个字符串。它还公开了CustomerType属性,该属性将所选的String存储在选择器中。设置CustomerType时,它将String值映射为基础Customer对象的IsCompanyproperty的布尔值。图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询问CustomerTypeproperty的验证错误。在大多数情况下,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对象。这使您可以使用Model类中的验证逻辑,并对仅对ViewModel类有意义的属性进行附加验证。

通过SaveCommand属性,视图可以使用保存CustomerViewModel的功能。该命令使用前面检查过的RelayCommand类来允许CustomerViewModel决定它是否可以保存自身以及在被告知保存其状态时该怎么做。在此应用程序中,保存新客户仅意味着将其添加到CustomerRepository。决定是否准备好保存新客户需要征得两方的同意。必须询问Customer对象是否有效,CustomerViewModel必须确定其是否有效。由于先前检查了ViewModel特定的属性和验证,因此必须由两部分组成。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对象并允许诸如Boolean属性的“未选择”状态之类的视图的视图。它还提供了轻松告知客户保存其状态的功能。如果视图直接绑定到Customer对象,则该视图将需要大量代码才能使其正常工作。在设计良好的MVVM架构中,大多数视图的代码应为空,或者最多只能包含操纵该视图中包含的控件和资源的代码。有时,也有必要在与ViewModel对象进行交互的View的代码隐藏中编写代码,例如,钩住事件或调用否则很难从ViewModel本身调用的方法。

所有客户查看

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

UI是AllCustomersView控件,该控件呈现anAllCustomersViewModel对象。每个ListViewItem代表由AllCustomerViewModel对象公开的AllCustomers集合中的CustomerViewModel对象。在上一节中,您了解了如何将CustomerViewModel呈现为数据输入表单,现在,将完全相同的CustomerViewModel对象呈现为ListView中的项目。CustomerViewModel类不知道显示什么视觉元素,这就是为什么可以重用的原因。

AllCustomersView创建在ListView中看到的组。它通过将ListView的ItemsSource绑定到配置为Figure16的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的属性。一个重要的绑定n是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如何监视每个客户的选中或未选中状态,并通知视图它需要更新显示值。

图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属性,并将并发(货币)格式应用于该值。通过从TotalSelectedSales属性返回String而不是Double值,ViewModel对象可以应用货币格式而不是视图。在.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>
包起来

WPF为应用程序开发人员提供了很多服务,而要充分利用其功能,则需要转变思维方式。Model-View-ViewModel模式是用于设计和实现WPF应用程序的一组简单有效的准则。它使您能够在数据,行为和表示之间建立强烈的分隔,从而更容易控制软件开发的混乱情况。

我要感谢约翰·高斯曼(John Gossman)对本文的帮助。

Josh Smith 对使用WPF创建出色的用户体验充满热情。他在WPF社区中的工作被授予Microsoft MVP头衔。Josh在体验设计小组工作于基础设施。当他不在电脑旁时,他喜欢弹钢琴,阅读历史记录以及与女友一起探索纽约。您可以通过joshsmithonwpf.wordpress.com访问Josh的博客。

posted @ 2020-12-23 18:49  Fallever  阅读(471)  评论(0编辑  收藏  举报