细究 MVVM
熟悉 WPF 或 Silverlight 的同学应该不会对 MVVM 模式感到陌生了,它把应用程序划分成视图、视图模型和模型三层,如图 1 所示:
图 1
表面上,这个层次结构还蛮清楚的,但如果你细究每层应该包含什么,事情就没那么简单了。
视图应该是最容易理解的一个部分了,它通常是指用户可以看到的界面,一般都是通过 XAML 代码来实现的。但是,XAML 代码并不能实现一切想要的效果,这个时候很多人会把目光投向代码隐藏文件,在里面通过事件处理程序实现某些效果。那么,究竟什么样的代码可以放在代码隐藏文件里?这些代码是否包含原本应该放在视图模型里的代码?这些效果是否还有其他途径可以实现?
视图模型从字眼上看应该是视图的抽象,这意味着每个视图都应该有一个对应的视图模型,这也是我们经常看到的做法。不过,有人对此表示反对,他们认为视图和视图模型不是一一对应的关系,整个应用程序应该有一个主要的视图模型,各个视图将会绑到这个主要的视图模型上,同时有一些次要的视图模型,用来表示诸如配置等辅助方面。那种做法才是正确的?视图模型里面又该包含什么样的代码?
最后是模型,大多数人对它的一个共识就是,它会包含具体的数据,这些数据最终会显示到用户界面上。问题是,它到底是简单的 POCO 还是业务逻辑的复杂类型?我们是否应该在这里放置验证逻辑?是否允许它和视图直接绑定,还是需要另外创建对应的包装类?如果使用 LINQ to SQL 存储数据的话,模型里的类是否就是 LINQ to SQL 的实体类?如果需要访问 Web Service 获取数据,模型里的类和添加 Web Service 时自动生成的类又是什么关系?
哇,问题还真不少啊!你是否曾经遇到这些问题?你对它们有什么想法?诚然,这些问题没有唯一的标准答案,它们都是开发者在具体实践中提炼和总结出来的智慧,但它们通常只针对于特定的应用场景,或者说,它们是为了满足某些需要而产生的。举个例子吧,有些人可能会觉得添加 Web Service 时自动生成的类和他们要创建的模型类基本上是一致的,为了避免重复劳动,他们选择直接使用那些自动生成的类,这种做法一般不会出现问题,直到由于需求的变更,模型不再和 Web Service 对应起来,但此时应用程序的其他部分已经通过这些自动生成的类和 Web Service 紧密耦合起来了,修改应用程序就可能变得非常困难。相反,如果应用程序的功能比较单一、专注,Web Service 的接口也比较稳定,那么特意为那些自动生成的类创建一组一模一样的模型类显然增加劳动成本,埋下潜在的维护问题。
最简单的实现
假设我经常去图书馆借书,我需要一个应用查看所有图书的归还日期,如图 2 所示:
图 2
这是一个非常简单的应用,从 MVVM 模式的角度来看,图 2 所展示的用户界面就是视图了,而视图模型和模型也都非常简单,分别为图 3 的 MainViewModel 类和 Book 类:
图 3
页面的 ListBox 控件将会绑到 MainViewModel 的 Books 属性,书名将会绑到 Book 的 Title 属性,而归还日期则绑到 Book 的 DueDate。
到目前为止,一切都非常自然顺畅,直到我对它提出两个新的需求:
- 图书列表根据归还日期从小到大排序,即最先要还的书拍在最上面。
- 今天和明天要还的书字体使用强调色。
这两个新的需求都非常合理。第一个需求属于页面的抽象逻辑,不与页面的任何控件挂钩,这种需求一般会在视图模型里面实现,具体地就是在 MainViewModel 的构造函数里初始化 Books 属性时进行升序排序。
至于第二个需求,它涉及到具体的 TextBlock 控件以及对 Book 类的 DueDate 属性的二次处理,原则上不应该在 Book 类里面实现,根据个人偏好,这个需求有两种不同的实现方式:
- 创建一个 ItemViewModel 类,包装 Book 类并暴露相关属性,同时提供一个 Foreground 属性用于和 TextBlock 控件的对应属性绑定,Foreground 属性可以在初始化 ItemViewModel 时根据 Book 的 DueDate 属性计算。
- 通过转换器实现相同的效果。
有人说,使用 MVVM 模式可以消除转换器的需要,是的,任何时候当你需要一个转换器,你都可以通过创建包装类并提供额外的属性获得相同的效果,但我们不应该把这个问题绝对化,转换器的存在价值体现在可以在不同的绑定关系上重用相同的逻辑,而且更符合 Expression Blend 用户的使用习惯。
看到这里,有些同学可能会问,如果用户要求同时提供根据图书标题和归还日期两种排序方式呢?每当我们遇到一个新的需求时,请不要马上动手实现或者考虑如何实现,应该先想想用户为什么有这样的需求。根据归还日期进行排序这个需求对应着帮助用户避免逾期归还所受的惩罚,但根据图书标题排序呢?很多时候,我们会想当然地认为用户需要某些功能,而忽略用户真正的需求,这样不但会导致功能冗余,还会分散用户对于最重要功能的注意。事实上,根据图书标题排序这个需求很可能是想帮助用户了解某本书是否已经存在于列表中,或者某本书的具体信息,如还可以读多久,本质上,这个需求很可能是帮助用户从列表中快速查找某本书。如果是这样,为什么不考虑给出一个即时搜索的功能,比如说,当用户单击搜索按钮时,会显示一个搜索框,用户在里面输入关键字,图书列表马上显示包含该关键字的图书?
命令与操作
到目前为止,这个应用几乎可以说一无是处,因为它不支持添加、编辑和删除等操作,那么,实现这些操作又有哪些东西需要考虑呢?假设我们在用户界面上添加相应的按钮和菜单,如图 4 所示:
图 4
以往的做法是在代码隐藏文件里通过事件处理程序来实现,但在 MVVM 模式里,我们提倡通过命令对象来实现,问题是,这些命令对象在哪实现?添加操作是页面范围的,与之对应的命令对象可以在 MainViewModel 类里实现,但编辑和删除两个操作对应于 Book 类,那么,我们是否应该在 Book 类里添加相应的行为?
有一部分人对此的观点是,模型并非单纯的 POCO,而是完整的领域模型,可以包含区别于页面逻辑的业务逻辑,并且不会和 ORM 的实体类等同起来,这样做的好处是我们有一个统一的地方来维护整个应用的状态,也和具体的数据层解耦,无论数据最终来自本地还是远程服务,都不会影响在此之上的东西,与此同时,我们也不必在为不同的视图模型之间如何传递数据感到烦恼。当然,这样做的坏处也是明显的,它引入了大量可能不必要的复杂性,对于小型项目具有不少杀伤力。
如果我们不在 Book 类里添加行为,又不想在代码隐藏文件里通过事件处理程序来实现这些操作,那么我们就需要考虑一下 Expression Blend 的行为(Behavior)了。具体的想法是这样的,假设用户单击编辑菜单项的时候将会打开 EditItemPage.xaml 页面,而这个页面需要知道用户选中哪本图书,那么整个操作就可以看作通过 NavitagionService.Navigate 方法打开“EditItemPage.xaml?title=XXX”这样的链接了。要实现这样的效果,你可以使用 AppBarUtils for Windows Phone SDK 7.1 的 NavigateWithQueryStringAction,如代码 1 所示:
<AppBarUtils:NavigateWithQueryStringAction TargetPage="/EditItemPage.xaml"> <AppBarUtils:Parameter Field="title" Value="{Binding Title}"/> </AppBarUtils:NavigateWithQueryStringAction>
代码 1
配合 EventTrigger 在 MenuItem 上使用就可以实现预期的效果了。
除此之外,你也可以考虑创建一个 ItemViewModel 类,然后在上面实现编辑操作的命令对象,然后和 MenuItem 的 Command 属性进行绑定。如果你选择这种做法,就会无可避免地遇到在视图模型里打开页面的问题。我们通常用来打开页面的 NavitagionService.Navigate 方法必须在页面的范围内才可访问,但视图模型对于视图一无所知,怎么调用这个方法?
常见的做法是封装 PhoneApplicationFrame 的 Navigate 方法。当你用 Visual Studio 创建一个 Windows Phone 项目时,App 类里面会有一个 RootFrame 属性,你可以通过这个属性调用 PhoneApplicationFrame 的 Navigate 方法。事实上,PhoneApplicationFrame 和页面是共用同一个 NavitagionService 对象的。
删除操作是一种很特别的操作,它同时涉及到集合以及里面的元素,但在 XAML 里,MenuItem 只能从父元素继承对应的 Book 对象,却无从知晓包含该对象的集合,这为实现删除操作造成极大困扰。常见的解决办法是把 MainViewModel 作为一个静态属性放在 App 类里,这样你就可以轻易访问到包含 Book 对象的 Books 集合。从这个角度来看,如果我们一开始就把模型设计成领域模型,负责管理和维护领域对象的状态,那么现在就不必把某个视图模型硬塞到 App 类里了。
应用程序栏以及其他
在 Windows Phone 上使用 MVVM 模式必定会遇到的一个障碍就是应用程序栏,它的问题在于它不是 Silverlight 控件,而是系统组件,这意味着它无法像通常的 Silverlight 控件那样进行数据绑定。市面上有不少解决方案,其中之一就是前面提到的 AppBarUtils for Windows Phone SDK 7.1 ,有兴趣的可以看看 Allen Lee 写的《AppBarUtils 使用指南》。
如果你把模型设计成领域模型,那么你一定要注意Windows Phone 的“深度链接”(deep link),这种情况会在你使用Toast 通知和次要磁贴(secondary tile),并在用户单击打开应用的某个页面时出现。由于用户仅对某个页面感兴趣,而且当用户按返回键时会直接退出应用而不是按照应用的常规逻辑返回上一页,因此构建整个领域模型会显得劳师动众、耗费资源。
有人认为,使用MVVM 模式的一大好处是为Expression Blend 用户带来便利,确实是这样,数据绑定和命令对象的应用使得Expression Blend 用户更易通过可视化操作使用开发人员的后台代码。如果你的视图模型也会给Expression Blend 用户使用,那么你必须考虑的一点就是在视图模型里提供设计时数据,尤其是你的逻辑包含访问本地数据库或者Web Service,因为在Expression Blend 的设计器里无法执行这些代码。
最后不得不提的是,MVVM 模式使得我们可以绕过用户界面对应用的功能进行测试,包括单元测试,如果你有兴趣,可以看看Chenkai 的《Windows phone 应用开发[9]- 单元测试》。
感谢侯伯薇对本文的审校。
给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ )或者腾讯微博( @InfoQ )关注我们,并与我们的编辑和其他读者朋友交流。
评论