[荐][MVVM专题]__MSDN上最详细的介绍和剖析(下)
封装 Model 的一大难点是 Model 经常具有统一建模语言 (Unified Modeling Language, UML) 称为“派生属性”的属性。例如,Person 类可能具有 BirthDate 属性和派生的 Age 属性。Age 属性是只读的,会根据生日和当前日期自动计算年龄:
public class Person : DomainObject { public DateTime BirthDate { get; set; } public int Age { get { var today = DateTime.Now; // Simplified demo code! int age = today.Year - this.BirthDate.Year; return age; } } ...
当 BirthDate 属性更改时,Age 属性也随之更改,因为年龄是根据生日通过数学方法计算得到的。因此在设置 BirthDate 属性时,ViewModel 类需要同时为 BirthDate 属性和 Age 属性引发属性更改事件。借助动态 ViewModel 方法,您可以在模型中显式表达出这种内部属性关系,从而自动完成此操作。
首先,您需要一个自定义特性来记录属性关系:
[AttributeUsage(AttributeTargets.Property, AllowMultiple=true)] public sealed class AffectsOtherPropertyAttribute : Attribute { public AffectsOtherPropertyAttribute( string otherPropertyName) { this.AffectsProperty = otherPropertyName; } public string AffectsProperty { get; private set; } }
我将 AllowMultiple 设置为 True,以便支持一个属性能够影响其他多个属性的情况。应用此特性来将 BirthDate 和 Age 之间的关系直接编写进模型的代码,这种操作非常简单:
[AffectsOtherProperty("Age")] public DateTime BirthDate { get; set; }
为了在动态 ViewModel 类中使用这种新的模型元数据,我现在可以在 TrySetMember 方法中添加三行代码,使其如下所示:
public override bool TrySetMember( SetMemberBinder binder, object value) { ... var affectsProps = property.GetCustomAttributes( typeof(AffectsOtherPropertyAttribute), true);
foreach(AffectsOtherPropertyAttribute otherPropertyAttr in affectsProps) this.RaisePropertyChanged( otherPropertyAttr.AffectsProperty); }
由于 GetCustomAttributes 方法已经获得了反射的属性信息,因此它可以返回模型属性上的任何 AffectsOtherProperty 特性。然后,该代码循环遍历这些特性,并为每个特性引发属性更改事件。因此通过 ViewModel 对 BirthDate 属性执行的更改,现在会自动同时为 BirthDate 和 Age 属性引发属性更改事件。
重要的是要认识到,如果您对动态 ViewModel 类(更有可能是具体模型派生的 ViewModel 类)上的某个属性执行显式编程操作,DLR 不会调用 TryGetMember 和 TrySetMember 方法,而改为直接调用这些属性。在这种情况下,您会丢失这种自动行为。但是,您可以轻松重构代码,使得自定义属性也能使用此功能。
让我们回到深度嵌套属性上的数据绑定问题(在这种情况下,ViewModel 是当前 WPF 数据上下文),其代码如下所示:
{Binding WrappedDomainObject.Address.Country}
使用动态代理属性意味着底层包装的域对象不再直接提供,因此数据绑定可能实际上如下所示:
{Binding Address.Country}
在这种情况下,Address 属性仍然能够直接访问底层模型的 Address 实例。但是现在,当您希望围绕 Address 引入 ViewModel 时,您只需向 Person ViewModel 类添加新属性。新的 Address 属性非常简单:
public DynamicViewModel Address { get { if( addressViewModel==null ) addressViewModel = new DynamicViewModel(this.Person.Address); return addressViewModel; } } private DynamicViewModel addressViewModel;
不需要更改任何 XAML 数据绑定,因为该属性仍然名为 Address,但是现在 DLR 调用新的实际属性,而不是调用动态 TryGetMember 方法。(请注意,此 Address 属性中的延迟实例化不是线程安全的。但是,只有正在访问 ViewModel 和 WPF/Silverlight 视图的 View 是单线程的,因此这不成问题。)
甚至当模型实现 INotifyPropertyChanged 时,也可以使用此方法。ViewModel 会注意到这种情况,并且选择不通过代理来引发属性更改事件。在这种情况下,它会从底层模型实例侦听这些事件,然后自行重新引发事件。在动态 ViewModel 类的构造函数中,我执行了检查并记下了结果:
public DynamicViewModel(DomainObject model) { Contract.Requires(model != null, "Cannot encapsulate a null model"); this.ModelInstance = model; // Raises its own property changed events if( model is INotifyPropertyChanged ) { this.ModelRaisesPropertyChangedEvents = true; var raisesPropChangedEvents = model as INotifyPropertyChanged; raisesPropChangedEvents.PropertyChanged += (sender,args) => this.RaisePropertyChanged(args.PropertyName); } }
if(this.ModelRaisesPropertyChangedEvents==false) this.RaisePropertyChanged(property.Name);
因此,您可以使用动态代理属性来消除标准的代理属性,从而大大简化 ViewModel 层。这可以大大减少编写代码、测试、文档和长期维护工作。向模型中添加新属性不再需要更新 ViewModel 层,除非新属性拥有非常特殊的 View 逻辑。而且,这种方法可以解决关联属性等难题。常用的 TrySetMember 方法也能帮您实现“撤消”功能,因为由用户执行的属性更改全部都会流经 TrySetMember 方法。
优点和缺点
由于性能问题,许多开发人员都不愿意使用反射(和 DLR)。在我自己的工作中,我还没有发现这会带来问题。在 UI 中设置单个属性时所造成的性能损失,用户很可能不会注意到。当然,这可能不适用于高度敏感的交互式 UI,例如多点触摸设计图面。
唯一重大的性能问题出现在初次填充有大量字段的视图时。易用性方面的考虑很自然地就会限制您在屏幕上显示的字段数,因此通过这种 DLR 方法执行初识数据绑定时的性能很难被人察觉。
不过,始终应该仔细监控性能,并且要理解性能与用户体验的关系。前文所述的简单方法可通过反射缓存来改写。有关更多详情,请参见 MSDN 杂志(2005 年 7 月)中刊载的 Joel Pobar 的文章。
有人认为使用此方法会对代码的可读性和易维护性产生负面影响,这种异议有一定的道理,因为 View 层似乎是在引用 ViewModel 上并不实际存在的属性。但是,我相信消除大部分手工编写的代理属性所产生的好处要远远超过其带来的问题,尤其是在 ViewModel 拥有完善的文档的情况下。
动态代理属性方法不会降低或取消打乱 Model 层的能力,因为 Model 上的属性现在是通过名称在 XAML 中引用的。使用传统代理属性不会限制您打乱 Model 的能力,这是因为属性是直接引用的,会与应用程序的其余部分一起打乱。但是,由于大多数打乱工具还不能处理 XAML/BAML,因此这基本上没什么作用。在两种情况下,代码破解者都可以从 XAML/BAML 入手,逐步进入 Model 层。
最后,由于模型属性具有安全相关元数据并且希望 ViewModel 负责实施安全问题,这种方法可能会被滥用。安全性看起来不是 View 特有的责任,我相信这给 ViewModel 赋予了太多责任。在这种情况下,在 Model 中应用面向方面的方法,可能是更恰当的做法。
----------------------------------------------------------------------------
集合
集合是 MVVM 设计模式中最难、最不能让人满意的方面之一。如果底层 Model 中的集合被 Model 更改了,将由 ViewModel 负责以某种方式来传达该更改,使 View 能够相应地更改其本身。
遗憾的是,Model 很可能不会公开实现 INotifyCollectionChanged 接口的集合。在 .NET Framework 3.5 中,此接口位于 System.Windows.dll 中,而我们强烈建议您不要在 Model 中使用该库。幸好在 .NET Framework 4 中,此接口已迁移到 System.dll 中,因此在 Model 中使用可见集合就会自然得多。
Model 中的可见集合为 Model 开发提供了新的机会,并且可用在 Windows 窗体和 Silverlight 应用程序中。目前,这是我首选的方法,因为它比其他任何方法都简单,我很高兴 INotifyCollectionChanged 接口移到了一个更常用的程序集中。
如果 Model 中没有可见集合,最好的做法是在 Model 上提供其他某种机制(最有可能的是自定义事件),来指示集合何时发生了更改。这可以通过 Model 特有的方式完成。例如,如果 Person 类有一个地址集合,则它可以提供如下事件:
public event EventHandler<AddressesChangedEventArgs> NewAddressAdded; public event EventHandler<AddressesChangedEventArgs> AddressRemoved;
最好引发专门为 WPF ViewModel 设计的自定义集合事件。但是,仍然很难在 ViewModel 中公开集合的更改。同样,唯一的办法是在整个 ViewModel 集合属性上引发属性更改事件。这种解决方法充其量不过是差强人意。
集合的另一个问题是确定何时或是否要将每个 Model 实例包装到 ViewModel 实例内的集合中。对于较小的集合,ViewModel 可能公开一个新的可见集合,并将底层 Model 集合中的每一项都复制到 ViewModel 可见集合中,从而在运行时将每个 Model 项包装到相应 ViewModel 实例内的集合中。ViewModel 可能需要侦听集合更改事件,以便将用户更改传递回底层 Model。
但是,对于大型集合而言,它可能会显示在某种形式的虚拟化面板中,因此最简单、最实用的方法是直接公开 Model 对象。
实例化 ViewModel
MVVM 设计模式很少讨论的另一个问题是应该在何处以及何时实例化 ViewModel 实例。在讨论相似的设计模式(例如 MVC)时,此问题也经常会被忽视。
我倾向于编写 ViewModel 单一实例来提供主要的 ViewModel 对象,使 View 能够根据需要,从此处轻松检索其他所有 ViewModel 对象。这种主控 ViewModel 对象通常会提供命令实现方式,因此 View 支持打开文档。
但是,我参与过的大部分应用程序都提供了以文档为中心的界面,而且通常使用类似于 Visual Studio 的选项卡式工作区。因此在 ViewModel 层中,我想以文档为例,而且文档中提供了一个或多个包装了特定 Model 对象的 ViewModel 对象。然后,ViewModel 层中的标准 WPF 命令可以使用持久性层来检索必要的对象,将它们包装到 ViewModel 实例中,然后创建 ViewModel 文档管理器来显示它们。
internal class OpenNewPersonCommand : ICommand { ... // Open a new person in a new window. public void Execute(object parameter) { var person = new MvvmDemo.Model.Person(); var document = new PersonDocument(person); DocumentManager.Instance.ActiveDocument = document; } }
最后一行中引用的 ViewModel 文档管理器是管理所有打开的 ViewModel 文档的单一实例。问题是,如何在 View 中显示 ViewModel 文档的集合呢?
内置的 WPF 选项卡控件没有提供这种用户希望获得的功能强大的多文档界面。幸好还有第三方的停靠和选项卡式工作区产品可以使用。其中大部分都尽力模仿 Visual Studio 的选项卡式文档外观,包括可停靠的工具窗口、拆分视图、Ctrl+Tab 弹出窗口(带有微型文档视图)及其他功能。
遗憾的是,这些组件大部分都没有提供对 MVVM 设计模式的内置支持。但是这也没关系,因为您可以轻松应用适配器设计模式,将 ViewModel 文档管理器链接到第三方视图组件。
文档管理器适配器
图 2 所示的适配器设计确保 ViewModel 不需要对 View 的任何引用,因此它符合 MVVM 设计模式的主要目标。(但在这种情况下,文档的概念是在 ViewModel 层中,而不是在 Model 层中定义的,因为这是纯 UI 概念。)
图 2 文档管理器视图适配器
ViewModel 文档管理器负责维护打开的 ViewModel 文档的集合和了解哪个文档当前处于活动状态。这种设计使 ViewModel 层能够使用文档管理器打开和关闭文档以及更改活动文档,而无需了解 View。此方法的 ViewModel 端相当简单。示例应用程序中的 ViewModel 类如图 3 所示。
图 3 ViewModel 层的文档管理器和 Document 类
Document 基类提供几种内部的生命周期方法(Activated、LostActivation 和 DocumentClosed),这些方法由文档管理器调用,使文档始终保持最新状态。文档还实现了 INotifyPropertyChanged 接口,因此它支持数据绑定。例如,适配器将视图文档的 Title 属性数据绑定到 ViewModel 的 DocumentTitle 属性。
此方法中最复杂的部分是适配器类,我在本文附带的项目中提供了一个能够正确运行的副本。适配器订阅文档管理器上的事件,并使用这些事件来使选项卡式工作区控件保持最新状态。例如,当文档管理器指示打开了新文档时,适配器会收到一个事件,将 ViewModel 文档包装到所需的 WPF 控件中,然后在选项卡式工作区中显示该控件。
适配器还有一项其他任务:使 ViewModel 文档管理器与用户的操作保持同步。因此,适配器还必须侦听来自选项卡式工作区控件的事件,从而在用户更改活动文档或关闭文档时,通知文档管理器。
此逻辑并不十分复杂,但还是有一些要注意的事项。在有些情况下,代码变得可以重新进入,因此这种情况必须得到完善的处理。例如,如果 ViewModel 使用文档管理器来关闭文档,适配器将从文档管理器收到事件,并且在视图中关闭实际的文档窗口。这会使选项卡式工作区控件也引发文档关闭事件,而适配器也会收到此事件,并且适配器的事件处理程序当然也会通知文档管理器:该文档应被关闭。该文档已被关闭,因此文档管理器必须能够支持这种情况。
另一项难点是 View 的适配器必须能够将 View 的选项卡式文档控件链接到 ViewModel Document 对象。最可靠的解决方法是使用 WPF 相关依赖属性。适配器声明了一个私有的相关依赖属性,用于将 View 窗口控件链接到其 ViewModel 文档实例。
在本文的示例项目中,我使用了一个开源的选项卡式工作区组件 AvalonDock,因此我的相关依赖属性类似于图 4 所示。
图 4 链接 View 控件和 ViewModel 文档
private static readonly DependencyProperty ViewModelDocumentProperty = DependencyProperty.RegisterAttached( "ViewModelDocument", typeof(Document), typeof(DocumentManagerAdapter), null); private static Document GetViewModelDocument( AvalonDock.ManagedContent viewDoc) { return viewDoc.GetValue(ViewModelDocumentProperty) as Document; } private static void SetViewModelDocument( AvalonDock.ManagedContent viewDoc, Document document) { viewDoc.SetValue(ViewModelDocumentProperty, document); }
当适配器创建新的 View 窗口控件时,它会将新窗口控件上的相关属性设置为底层 ViewModel 文档(请参见图 5)。您也会看到此处配置了标题数据绑定,以及适配器如何配置 View 文档控件的数据上下文和内容。
图 5设置相关属性
private AvalonDock.DocumentContent CreateNewViewDocument( Document viewModelDocument) { var viewDoc = new AvalonDock.DocumentContent(); viewDoc.DataContext = viewModelDocument; viewDoc.Content = viewModelDocument; Binding titleBinding = new Binding("DocumentTitle") { Source = viewModelDocument }; viewDoc.SetBinding(AvalonDock.ManagedContent.TitleProperty, titleBinding); viewDoc.Closing += OnUserClosingDocument; DocumentManagerAdapter.SetViewModelDocument(viewDoc, viewModelDocument); return viewDoc; }
ViewModel 文档的方法。ViewModel 文档的实际数据模板位于主 XAML 窗口所包含的资源字典中。
我已经在 WPF 和 Silverlight 中成功使用过这种 ViewModel 文档管理器方法。仅有的 View 层代码是适配器,而这可以轻松进行测试,然后就可以随它去了。这种方法使 ViewModel 与 View 之间完全独立。我曾经更换过我的选项卡式工作区组件供应商,这次更换仅仅在适配器类中造成了最低限度的更改,而 ViewModel 和 Model 则彻底不受影响。
在 ViewModel 层中处理文档的能力看起来很简单,而实现跟我的演示相似的 ViewModel 命令也非常容易。很明显,ViewModel 文档类也非常适合提供与文档相关的 ICommand 实例。
View 挂接到这些命令中,而 MVVM 设计模式的优点也在此过程中发挥得淋漓尽致。此外,如果您需要在用户创建任何文档之前显示数据(可能是在可折叠的工具窗口中),ViewModel 文档管理器方法还能与单一实例方法配合使用。
总结
MVVM 设计模式是一种强大而实用的模式,但是没有任何一种设计模式能够解决所有问题。就像我在本文所演示的,将 MVVM 模式和目标与其他模式(例如适配器和单一实例)结合使用,既能利用新的 .NET Framework 4 功能(例如动态调度),又能解决实现 MVVM 设计模式时遇到的许多常见问题。按照这种方式使用 MVVM,可创造出更加优美、更容易维护的 WPF 和 Silverlight 应用程序。