先声明此文是转自MSDN杂志的。之前我和一些同行讨论过MVVM模式,很多人都觉得Silverlight或者WPF项目中没多大必要使用它。今天看到这篇神作,我觉得有必要给大家转过来看看。。。仔细阅读,相信你会受益匪浅。原文地址:http://msdn.microsoft.com/en-us/magazine/ff798279.aspx
关于作者:Robert McCarter 是加拿大的一位兼职软件开发人员、架构师和企业家。您可以在 robertmccarter.wordpress.com 阅读他的博客。
Windows Presentation Foundation (WPF) 和 Silverlight 提供了丰富的 API 用来构建现代应用程序,但是了解并和谐一致地应用所有 WPF 特性来构建设计精良、易于维护的应用程序可能非常困难。从何处入手?什么样的方法才算是正确的应用程序设计方法? Model-View-ViewModel (MVVM) 设计模式描述了构建 WPF 或 Silverlight 应用程序的常用方法。它还是一款构建应用程序的强大工具,以及一种与开发人员讨论应用程序设计的通用语言。虽然 MVVM 确实很有用,但它发展时间不长,用户尚未形成正确的认识。 MVVM 设计模式什么时候是适用的,什么时候又是不必要的?应该如何设计应用程序的结构?ViewModel 层有多少代码要编写和维护,有什么替代方式能够减少 ViewModel 层的代码量?如何妥善处理 Model 中的相关属性?应该如何在 View 中显示 Model 中的集合?应该在哪里实例化 ViewModel 对象,并将其挂接到 Model 对象? 在本文中,我将解释 ViewModel 的工作原理,并讨论在您的代码中实现 ViewModel 的优缺点。我还会介绍一些具体的示例,演示如何使用 ViewModel 作为文档管理器,以便在 View 层中显示 Model 对象。 Model、ViewModel 和 View到目前为止,我设计过的每个 WPF 和 Silverlight 应用程序都具有相同的高层组件设计。Model 是应用程序的核心,需要投入大量精力,按照面向对象的分析和设计 (OOAD) 最佳做法进行设计。 对我来说,Model 是应用程序的核心,代表着最大、最重要的业务资产,因为它记录了所有复杂的业务实体、它们之间的关系以及它们的功能。 Model 之上是 ViewModel。ViewModel 的两个主要目标分别是:使 Model 能够轻松被 WPF/XAML View 使用;将 Model 从 View 分离并对 Model 进行封装。这些目标当然非常好,但是由于一些现实的原因,有时并不能达到这些目标。 您构建的 ViewModel 知道用户在高层上将如何与应用程序交互。但是,ViewModel 对 View 一无所知,这是 MVVM 设计模式的重要部分。这使得交互设计师和图形设计师能够在 ViewModel 的基础上创建优美、有效的 UI,同时与开发人员密切配合,设计适当的 ViewModel 来支持其工作。此外,View 与 ViewModel 的分离还使得 ViewModel 更有利于单元测试和重用。 为了在 Model、View 和 ViewModel 层之间实施严格的分离,我喜欢将每一层构建为一个单独的 Visual Studio 项目。与可重用的实用工具、主要的可执行程序集以及任何单元测试项目(您有大量这些内容,对吗?)结合之后,这会产生大量项目和程序集,如图 1 所示。
图 1 MVVM 应用程序的组成部分 由于这种严格分离的方法会产生大量项目,因此它显然最适合大型项目。对于只有一两位开发人员的小型应用程序来说,这种严格分离带来的好处可能无法抵消创建、配置和维护多个项目所带来的不便,因此仅仅将您的代码分离到同一个项目的不同命名空间中,可能比充分隔离更好用。 编写和维护 ViewModel 并不容易,不应轻率地对待。但是,一些最基本问题(MVVM 设计模式什么时候是适用的,什么时候又是不必要的)的答案经常包含在您的域模型中。 在大型项目中,域模型可能非常复杂,需要精心设计数以百计的类,使它们能够在任何类型的应用程序(包括 Web 服务、WPF 或 ASP.NET 应用程序)中顺畅地结合在一起。Model 可能由几个相互配合的程序集组成,甚至在超大型组织中,域模型有时是由一个专门的开发团队构建和维护的。 如果您有一个复杂的大型域模型,则引入 ViewModel 层几乎总是会带来好处。 另一方面,域模型有时很简单,可能仅仅是覆盖在数据库上的一个薄层。类可以自动生成,而且通常会实现 INotifyPropertyChanged。UI 通常是一系列可供编辑的列表或表格,允许用户对底层数据进行操作。Microsoft 工具集一直都极其擅长轻松快捷地构建这类应用程序。 如果您的模型或应用程序是这种类型的,则 ViewModel 很可能会带来难以接受的高开销,而对您的应用程序设计并没有足够的好处。 尽管如此,即使在这些情况下,ViewModel 也仍然有其价值。例如,ViewModel 非常适合用来实现“撤消”功能。另外,您也可以选择在应用程序的某个部分(例如文档管理,我将在后面讨论)使用 MVVM 直接向 View 提供 Model。 为什么要使用 ViewModel?即使 ViewModel 看起来适合您的应用程序,在开始编写代码之前,仍然还有问题需要解答。其中最重要的问题是如何减少代理属性的数量。 MVVM 设计模式将 View 从 Model 分离,这种做法是该模式的一个重要且有价值的方面。因此,如果 Model 类有 10 个属性需要在 View 中显示出来,则 ViewModel 最终通常会有 10 个等效的属性,这些属性只是代理了对底层模型实例的调用。这些代理属性在设置时通常会引发属性更改事件,通知 View 该属性已更改。 并非每个 Model 属性都要有 ViewModel 代理属性,但是每个需要在 View 中显示的 Model 属性通常都有一个代理属性。代理属性通常如下所示: 任何稍微复杂一点的应用程序都会有数十或上百的 Model 类,这些类都需要按这种方式,通过 ViewModel 向用户显示出来。这正是 MVVM 所提供的分离的本质。 编写这些代理属性很繁琐,因此很容易出错,尤其是在引发属性更改事件时需要一个字符串,该字符串必须与属性的名称相匹配(并且不会包含在任何自动代码重构中)。为了消除这些代理事件,常见的解决方法是直接从 ViewModel 包装器显示模型实例,然后让域模型实现 INotifyPropertyChanged 接口:
借助这种方法,View 对 Model 有一定的依赖性,但是这仅仅是通过数据绑定实现的间接依赖,而不需要从 View 项目对 Model 项目进行项目引用。因此,纯粹从实用角度出发,此方法有时候很有用。 但是,此方法实际上违背了 MVVM 设计模式的精神,并且会降低您在将来引入新 ViewModel 功能(例如“撤消”功能)时的能力。我遇到过这种方法导致大量返工的情况。想象一下这种并不罕见的情况:深度嵌套的属性上有一个数据绑定。如果 Person ViewModel 是当前的数据上下文,且 Person 拥有 Address,则数据绑定可能如下所示: 如果您还需要在 Address 对象上引入更多 ViewModel 功能,您就需要删除对 WrappedDomainObject.Address 的数据绑定引用,而改为使用新的 ViewModel 属性。这就会带来问题,因为对 XAML 数据绑定(可能还包括数据上下文)的更新很难进行测试。View 组件没有自动化的全面回归测试。 动态属性我解决代理属性过多的方法是使用新的 .NET Framework 4 以及支持动态对象和动态方法调度的 WPF。后者使您能够在运行时决定如何处理类上并不存在的属性的读写操作。这意味着您可以消除 ViewModel 中的所有手写的代理属性,同时仍能封装底层模型。但是请注意,Silverlight 4 不支持绑定到动态属性。 实现此功能的最简单方法是让 ViewModel 基类扩展新的 System.Dynamic.DynamicObject 类,并重写 TryGetMember 和 TrySetMember 成员。当被引用的属性在类上不存在时,动态语言运行时 (DLR) 就会调用这两个方法,使该类能够在运行时决定如何实现缺少的属性。结合少量的反射之后,只需编写几行代码,ViewModel 类就能动态代理对底层模型实例的属性访问:
该方法开始时使用反射来查找底层 Model 实例上的属性。(有关详细信息,请参见 2007 年 6 月的“CLR 全面透析”专栏反射之反思。)如果该模型没有这样一个属性,该方法将失败并返回 False,数据绑定也失败。如果属性存在,该方法将使用属性信息来检索并返回 Model 的属性值。与传统代理属性的 get 方法相比,这是额外的工作,但这也是您需要为所有模型和所有属性编写的唯一实现。 动态代理属性方法的真正强大之处在于属性设置器。在 TrySetMember 中,您可以包含常见的逻辑,例如引发属性更改事件。其代码如下所示:
同样,该方法开始时使用反射从底层 Model 实例获取属性。如果属性不存在或是只读的,该方法将失败并返回 False。如果属性存在于域对象上,将使用属性信息来设置 Model 属性。然后您就可以包含对所有属性设置器均通用的逻辑。在此示例代码中,我只是为刚才设置的属性引发了属性更改事件,但您可以轻松完成更多任务。 封装 Model 的一大难点是 Model 经常具有统一建模语言 (Unified Modeling Language, UML) 称为“派生属性”的属性。例如,Person 类可能具有 BirthDate 属性和派生的 Age 属性。Age 属性是只读的,会根据生日和当前日期自动计算年龄: 当 BirthDate 属性更改时,Age 属性也随之更改,因为年龄是根据生日通过数学方法计算得到的。因此在设置 BirthDate 属性时,ViewModel 类需要同时为 BirthDate 属性和 Age 属性引发属性更改事件。借助动态 ViewModel 方法,您可以在模型中显式表达出这种内部属性关系,从而自动完成此操作。 首先,您需要一个自定义特性来记录属性关系: 我将 AllowMultiple 设置为 True,以便支持一个属性能够影响其他多个属性的情况。应用此特性来将 BirthDate 和 Age 之间的关系直接编写进模型的代码,这种操作非常简单: 为了在动态 ViewModel 类中使用这种新的模型元数据,我现在可以在 TrySetMember 方法中添加三行代码,使其如下所示: 重要的是要认识到,如果您对动态 ViewModel 类(更有可能是具体模型派生的 ViewModel 类)上的某个属性执行显式编程操作,DLR 不会调用 TryGetMember 和 TrySetMember 方法,而改为直接调用这些属性。在这种情况下,您会丢失这种自动行为。但是,您可以轻松重构代码,使得自定义属性也能使用此功能。 让我们回到深度嵌套属性上的数据绑定问题(在这种情况下,ViewModel 是当前 WPF 数据上下文),其代码如下所示: 使用动态代理属性意味着底层包装的域对象不再直接提供,因此数据绑定可能实际上如下所示: 在这种情况下,Address 属性仍然能够直接访问底层模型的 Address 实例。但是现在,当您希望围绕 Address 引入 ViewModel 时,您只需向 Person ViewModel 类添加新属性。新的 Address 属性非常简单: 不需要更改任何 XAML 数据绑定,因为该属性仍然名为 Address,但是现在 DLR 调用新的实际属性,而不是调用动态 TryGetMember 方法。(请注意,此 Address 属性中的延迟实例化不是线程安全的。但是,只有正在访问 ViewModel 和 WPF/Silverlight 视图的 View 是单线程的,因此这不成问题。) 甚至当模型实现 INotifyPropertyChanged 时,也可以使用此方法。ViewModel 会注意到这种情况,并且选择不通过代理来引发属性更改事件。在这种情况下,它会从底层模型实例侦听这些事件,然后自行重新引发事件。在动态 ViewModel 类的构造函数中,我执行了检查并记下了结果:
为了防止重复属性更改事件,我还需要对 TrySetMember 方法稍做修改。 因此,您可以使用动态代理属性来消除标准的代理属性,从而大大简化 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 类有一个地址集合,则它可以提供如下事件: 最好引发专门为 WPF ViewModel 设计的自定义集合事件。但是,仍然很难在 ViewModel 中公开集合的更改。同样,唯一的办法是在整个 ViewModel 集合属性上引发属性更改事件。这种解决方法充其量不过是差强人意。 集合的另一个问题是确定何时或是否要将每个 Model 实例包装到 ViewModel 实例内的集合中。对于较小的集合,ViewModel 可能公开一个新的可见集合,并将底层 Model 集合中的每一项都复制到 ViewModel 可见集合中,从而在运行时将每个 Model 项包装到相应 ViewModel 实例内的集合中。ViewModel 可能需要侦听集合更改事件,以便将用户更改传递回底层 Model。 但是,对于大型集合而言,它可能会显示在某种形式的虚拟化面板中,因此最简单、最实用的方法是直接公开 Model 对象。 实例化 ViewModelMVVM 设计模式很少讨论的另一个问题是应该在何处以及何时实例化 ViewModel 实例。在讨论相似的设计模式(例如 MVC)时,此问题也经常会被忽视。 我倾向于编写 ViewModel 单一实例来提供主要的 ViewModel 对象,使 View 能够根据需要,从此处轻松检索其他所有 ViewModel 对象。这种主控 ViewModel 对象通常会提供命令实现方式,因此 View 支持打开文档。 但是,我参与过的大部分应用程序都提供了以文档为中心的界面,而且通常使用类似于 Visual Studio 的选项卡式工作区。因此在 ViewModel 层中,我想以文档为例,而且文档中提供了一个或多个包装了特定 Model 对象的 ViewModel 对象。然后,ViewModel 层中的标准 WPF 命令可以使用持久性层来检索必要的对象,将它们包装到 ViewModel 实例中,然后创建 ViewModel 文档管理器来显示它们。 在本文附带的示例应用程序中,用于创建新 Person 的 ViewModel 命令为: 最后一行中引用的 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 文档
当适配器创建新的 View 窗口控件时,它会将新窗口控件上的相关属性设置为底层 ViewModel 文档(请参见图 5)。您也会看到此处配置了标题数据绑定,以及适配器如何配置 View 文档控件的数据上下文和内容。 图 5 设置相关属性
通过设置 View 文档控件的内容,我让 WPF 处理繁重的任务:找出显示这种特定类型的 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 应用程序。有关 MVVM 的更多信息,请参见 MSDN 杂志(2009 年 2 月)中刊载的 Josh Smith 的文章。 由于 GetCustomAttributes 方法已经获得了反射的属性信息,因此它可以返回模型属性上的任何 AffectsOtherProperty 特性。然后,该代码循环遍历这些特性,并为每个特性引发属性更改事件。因此通过 ViewModel 对 BirthDate 属性执行的更改,现在会自动同时为 BirthDate 和 Age 属性引发属性更改事件。 因此,ViewModel 仍然可以提供视图所需的命令和更多属性,而无需重复 Model 属性或创建大量代理属性。这种方法当然有其吸引力,尤其是在 Model 类已经实现了 INotifyPropertyChanged 接口的情况下。让模型实现此接口并不一定是坏事,它甚至是 Microsoft .NET Framework 2.0 和 Windows 窗体应用程序中常见的做法。尽管它会使域模型变得很散乱,但确实对 ASP.NET 应用程序或域服务很有用。 |