在 “视图模型(View-Model)”这个术语出现之后,很多开发者都有不少疑问。视图模型需要处理视图、模型和外部服务间的交汇的问题,这一点是清晰的,但准确的做法却往往被一笔带过。它应该包含哪些内容,不应该包含哪些内容,没有清晰的列表,它们往往最终会成为所有东西的大杂烩。本文无意给出明确的答案,而是要探索视图模型所承担的众多角色中的几个。
在你阅读本文中的不同角色和模式的时候,请记住以下三点:
- 这些示例都来自真实项目。
- 大多数视图模型都会承担多个角色。
- 严格遵守某个模式的重要性低于可运行的程序。
模型端的角色
为视图提供数据是视图模型至关重要的角色。然而,即便仍然使用XAML数据绑定技术,提供数据的方式也是多种多样的。
用视图模型替代数据上下文
用视图模型替代数据上下文是最简单的模型端(model-side)模式,可能也是最常见的。真正的数据通过视图模型的一个或多个简单的属性暴露出来。 在某种程度上,这并不是模式。它只是将视图模型和视图的真正数据上下文属性关联起来,并且注入其他功能,如包装导航或服务调用的ICommand。本文后面还会讨论这个话题。
将视图模型作为活动记录
遗憾的是,“将模型作为活动记录(Active Record)”是一个常见的错误模式。在这种模式下,应用程序中没有真正的模型。相反,所有的字段都由视图模型本身直接提供。 例如,CustomerViewModel可能包含FirstName、LastName、CustomerId字段。由于这是一个视图模型,所以很可能还挂接了外部服务。可以通过LoadCustomerCommand和SaveCustomerCommand等ICommand将他们暴露出来,这就将视图模型成功地转变成活动记录。
需要注意的是,活动记录模式本身在某些场景下是相当有效的。问题是,活动记录的用途一定程度上被夸大了,若再让它们担任视图模型的其他角色,就几乎成了“万能对象(god object)”。
如图所示,单元测试没有安身之处。你可以通过使用附带模拟(mock)服务的集成测试来创建虚拟的单元测试,但这种方法往往非常耗时,并且容易出错。
如果没有模型,就不是MVVM。
将视图模型作为适配器或装饰器
视图模型可以作为适配器(adapter)或装饰器(decorator),以临时包装一个模型,提供额外信息或新格式。然而,这是非常危险的实践,只要有可能就应该避免。
我们使用经典的FullName来作为示例,这样做会带来两种风险。
基于推送的包装器
在基于推送的包装器(wrapper)中,我们假设只有视图模型能向视图推送数据更新。
public class PersonViewModel : INotifyPropertyChanged
{ private readonly Person m_Model; public PersonViewModel(Person model)
{ m_Model = model; } public string FirstName
{ get{ return m_Model.FirstName} set { m_Model.FirstName = value; OnPropertyChanged(new PropertyChangedEventArgs("FirstName"));
OnPropertyChanged(new PropertyChangedEventArgs("FullName")); }
} |
这意味,如果不通过PersonViewModel包装器而直接更改模型,此操作就会失败。这些更改不会传播到视图,导致同步问题。
基于事件的包装器
基于推送包装器的一个替代方案是,依赖由模型引发并由视图模型转发的事件。如下面的代码所示:
public class PersonViewModel :
INotifyPropertyChanged { private readonly Person m_Model; public PersonViewModel(Person model) { m_Model = model; m_Model.PropertyChanged += Model_PropertyChanged; }
public string FirstName { get{ return m_Model.FirstName} set { m_Model.FirstName = value; } } void Model_PropertyChanged(object sender, PropertyChangedEventArgs e) { OnPropertyChanged(e); switch (e.PropertyName) { case "FirstName": case "LastName:": OnPropertyChanged(new PropertyChangedEventArgs("FullName")); break; } } |
在此方案中,从模型向视图模型附加属性变化通知是存在风险的。当同一个模型被多个视图模型访问时,将可能出现内存泄露。
(点击图片放大)
基于XAML的控件通过监听模型事件可摆脱该问题,因为数据绑定基础架构(infrastructure)可以防止内存泄露。而在WinForm中,可以使用组件的基础架构释放(dispose)事件处理程序。但是,视图模型的生命周期并不依赖于控件,也不能依赖于控件,因为控件可以重新加载,因此,它没有适当的地方供我们释放这些事件处理程序。
这未必就是问题。如果你100%确定没有其他东西持有到模型的引用,就可以使用视图模型包装器。但更安全的做法是增强模型本身(如将FullName属性放到模型内)或使用值转换器。这两种方法还能让你在不涉及视图模型的情况下编写单元测试。
如果视图模型监听模型中的事件,就要检查是否会产生内存泄露。
将视图作为聚合根
根据维基百科,聚合在领域驱动设计的词条中被定义为:
与某个根实体对象绑定在一起的对象集合,这个根实体对象也叫聚合根。聚合根通过禁止外部对象持有对其成员的引用,来确保聚合内所发生的变化的一致性。
聚合根与适配器或装饰器的主要区别是,其模型永远不会暴露。你根本无法访问任何模型、模型的属性或事件。只有聚合根持有对模型的引用,因此可以完全防止上一节看到的内存泄露。
如果视图模型完全隐藏了复杂对象图的细节,那么它可能是一个聚合根。
将视图模型作为Web MVC模型
这是我在ASP.NET MVC社区中见到的一个相对较新的现象。很多开发者把由控制器创建或加载,并传递给视图的类叫做“视图模型”。这是与“领域模型”和“输入模型”相对应的。Dino Esposito的文章“ASP.NET MVC应用中的三种模型”很好地解释了这一点。
这种类型的视图模型很容易识别,因为它除了包含数据和完全依赖于数据的业务规则外,不拥有其他任何角色和职责。因此,它具有其他纯模型所具备的所有优点,如易于单元测试。
这时就没有必要责怪这种命名方法了。就像传统MVC和Web MVC之间的区别一样,这是我们不得不接受的习惯之一。
注意不要混淆“视图的模型”和“视图模型”,特别是MVVM应用程序可能包含这两种模型。
视图端的角色
视图和视图模型之间存在不同程度的耦合。我们先来看看最紧密的耦合,然后讨论更理想化的视图模型。
将视图模型作为代码隐藏
对于XAML初学者来说,往往会犯一个反模式错误。人们常说在“xaml.cs”或“xaml.vb”当中不应该放入过多的代码。善良的初学者经常将其误解为不能放入任何代码。因此,他们将所有东西都扔进“视图模型”,使其变成一种放错位置的代码隐藏文件。
这时,如果把所有东西都扔到代码隐藏文件中,所面临的问题是一模一样的。此外,把所有东西都扔进视图模型还会使得静态分析更加困难,并且带来内存泄露的可能。
如果将所有的代码从Xxx.xaml.cs移到XxxViewModel.cs,视图模型就变成了“代码隐藏”文件。
将视图模型作为传统的MVC控制器
传统MVC和Web MVC一个关键的不同在于控制器和视图的关系。在Web MVC中,控制器可以创建并返回它想要的任何视图。除了为这些视图提供数据模型之外,与它们没有任何交互。
而传统的MVC视图和控制器则总是紧密耦合在一起。每个控制器都是特别构建的,为某个特殊视图的用户生成的事件提供服务。这种模式也出现在MVVM中,视图模型扮演控制器的角色,而ICommand则取代了事件。
将视图模型作为控制器和将其作为代码隐藏文件之间的差别非常细微,因此有一些通用的准则:
将视图模型作为控制器的标志
- 用ICommand处理外部资源
- 通过暴露附加属性的方式来触发可视状态(Visual State)的改变
- 公开了控件能够响应的属性和事件
- 只侦听视图上的一般事件,如Loaded和Unloaded
将视图作为代码隐藏文件的标志
- 使用EventToCommand处理视图模型中的所有事件
- 通过直接调用可视状态管理器(Visual State Manager)的方式触发可视状态的改变
- 直接与控件交互
- 依赖于视图的具体布局
- 为了让视图模型正确工作,公开了控件必须响应的事件 下面的层关系图展示了控制器式和代码隐藏式视图模型的不同。
(点击图片放大)
控制器和代码隐藏之间的这种区别对应用程序本身来说影响不大,但如果要对视图模型进行不依赖于视图的测试,这种差别就显现出来了。尽管我不赞成对视图模型这样的集成组件进行单元测试,但执行集成测试来确保其与外部资源(数据库、Web服务)正常工作将是十分有帮助的。视图与视图模型之间的这种双向耦合将使集成测试变得更加困难。
如果视图模型与视图之间是一对一映射的,那么视图模型就成了一个控制器。
将视图模型作为共享的控制器
包括我在内的很多人都宣扬,如果视图模型不能被多个视图共享,就不是真正的视图模型。尽管我现在对这个理论不那么教条了,但我仍然将其视为一个有用的设计模式。
如果你要在多个使用相同数据的视图之间进行同步,就可以使用共享控制器方法。例如,某个窗体中有一个数据网格(data grid),另一个窗体中有一个展示这些数据的图表。这时你可以直接共享模型,但共享视图模型却更不至于犯错。这样,如果你切换到完全不同的数据,两个视图将同时被告知。
注意,共享的视图模型写起来要比控制器式的视图模型难得多。而且它们还非常不灵活,你必须在代码隐藏文件中放入比平时更多的代码。其好处是,共享的视图模型更易于测试,因为它们必然排除了很多复杂性。
一个可行的替代方案是,在传统的MVC模式中对模型(而非控制器)进行共享。XAML数据绑定能很好地支持这两种设计,因此就看哪种方案对整体设计的破坏性更小了。
如果将视图模型设计为共享的,它们将更易于测试。
将视图模型作为导航器
4个主要的基于XAML的UI框架都包含导航风格的应用程序,但Windows Phone或Windows 8 Metro应用则对它更加情有独钟,因为在这些应用中,导航框架都处于核心地位。
幸运的是,如果用其他东西(如顶层视图模型)来包装导航框架,也非常适于单元测试和集成测试。当触发页面转移时,视图模型不会将URI发送给导航框架,而会发送给一个可以检查的模拟类。可以参考Granite.Xaml的NavigatorViewModel和SimpleNavigator类。
抽象的导航框架对于构建可测试的视图模型来说是至关重要的。
将视图模型作为MVP的展示器
模型-视图-展示器模式与传统MVC模式最主要的区别在于模型和视图间的交互方式不同。在MVC模式中,模型直接触发视图监听的事件。而在MVP模式中,展示器监听事件,并更新视图本身。
由于XAML十分强大,在所谈论的技术中,我们几乎察觉不到这种模式的存在。但实际上它在多线程应用程序中扮演了重要的角色。
当模型在没有任何用户交互的情况下被后台线程更新时,常常会用到这种模式。在这种情况下,视图模型负责将通知封送(marshal)给UI线程。这可能会涉及将数据复制给数据绑定的模型,或直接更新控件本身。
值得注意的是,这并不是处理多线程的唯一方式。如果计算复杂度不高,最好简单地将所有异步消息封送到UI线程,并在那里进行处理。一些库(如响应式扩展)可以简化这些操作。
考虑使用展示器模式来确保不在UI线程中处理异步消息。
服务/资源角色
到目前为止,我们都将“外部服务”作为黑盒,它表示应用程序可能需要访问的文件系统、数据库、Web服务以及其他外部资源。现在,到了该考虑如何真正访问这些服务的时候了。有两种基本的方式可以实现这一点。
将视图模型作为数据访问层
对于小应用和定义良好的外部服务,通过视图模型直接调用是非常简单的。这种模型对于简单应用是非常理想的,并且WCF注重接口的设计使得实现依赖注入变得更加容易。
这样做的缺点是伸缩性不够好。随着应用程序越来越复杂,你会发现视图模型中的逻辑越来越多。在.NET 4.5引入async/await关键字之前尤其如此。
如果你发现你的视图模型被回调逻辑搞得不堪重负,就可以考虑添加一个单独的客户端服务层。
注意,在使用这种模式时,很多开发者会选择放弃将外部服务作为独特的组件进行测试。其理论是,如果只有视图模型可以访问服务层,那么针对视图模型的测试就可以同时承担测试服务层的职责。
WCF接口是依赖注入和模拟的完美选择,特别是当应用程序不包含单独的数据访问层时。
视图模型加数据访问层
有两种情况你可能需要单独的数据访问层:
- 视图模型变得过于庞大,很难维护。
- 在多个视图模型之间重复相同的外部服务逻辑。
这种模式很难写正确。如果在定义应用程序中DAL和VM的特定角色时不加注意,最后可能所有逻辑都会位于DAL或VM之中。这不仅违背了该模式的本意,而且当维护者看到几乎为空的组件作为没必要的样板代码时,也会感到十分迷惑。
在编写集成测试时,你有两种选择。可以像之前那样针对视图模型,同时测试这两个组件。或者,也可以针对数据访问层。
后者的优势在于为我们提供了引入基于接口的依赖注入的干净的做法。反过来,它使我们可以使用模拟的单元测试对视图模型进行测试。
结论:掌握视图模型
真正掌握视图模型并避免其成为大杂烩的唯一途径是,先判断该术语对你来说具体意味着什么。列出所有关注点,看看它属于哪个组件。
下面这个示例来自我的一个应用程序;你的列表也许非常相似,也许会截然不同。
关注点 |
组件 |
在页面间导航 |
基本的视图模型(Silverlight导航框架) |
格式化 |
视图(值转换器) |
在视图中显示对话框 |
视图代码隐藏 |
加载依赖项 |
基本的视图模型 |
创建控制器 |
基本的视图模型 |
触发WCF调用来加载或保存模型 |
视图特定的控制器 |
验证用户输入 |
模型(INotifyDataErrorInfo) |
错误报告 |
基本的视图模型 |
当然,该表格只包含客户端代码。对于真正的项目,还需要建立两个这样的表格,一个为服务端代码,另一个表示哪个数据表对应哪个数据库架构。随着项目的发展和设计的演化,需要更新这些表格以反应新的代码,或重构代码直到再次符合这些表格。如此一来,我们总是能在添加新功能之前就知道它们所属的组件。
如果能从本文学到点想法的话,我希望是:
设计模式仅仅是建议;如何将其转化成实际的设计以满足你的需求,完全是由你来决定的。
|