去年的《 C 是 MVC 的 C 》简单写了些 MVC 的基本原则,特别是关于 controller 这个经常被忽视和误解的模块。有人认为对 controller 的不同认识叫做 MVC 的不同「流派」或者「变种」,我不能认同。我相信只有符合《 C 是 MVC 的 C 》里所述原则的 MVC 是降低整体复杂度的最佳方案。另外,我最近在读《 The No-Designer’s Design Book 》,并且向没机会系统学习设计的 UI 开发者推荐这本书。为了在一本书的篇幅里让 no-designers 把握设计的关键,它没有讨论什么不同的「流派」,而是把产生好设计的要素归结为四大通用原则。
有人可能会对上面两件事产生同样的质疑 —— 面对一个博大的领域,拘泥于几条有限的原则,是不是纸上谈兵,削足适履?我这篇 blog 就是想写些实际开发中遭遇的细节与原则之间的冲突。对于这种冲突,《 The No-Designer’s Design Book 》有两句精彩的总结:
- You must know what the rule is before you can break it.
- Either do it all the way or don’t do it. Don’t be a wimp.
UI 程序的设计开发就是关于遵循和打破 MVC 原则的权衡,而这个权衡恰恰说明了坚持唯一原则的重要性。我相信在唯一原则下有意识地考虑取舍优于在几个所谓 「流派」之间选择或者摇摆。
反面向对象的 Model
设计模式产生于面向对象的方法。但 MVC 最有意思的一点是 model 的设计原则体现出反面向对象思想。面向对象的第一要旨是数据和行为的统一。Model 表示一组数据,对于一组数据来说,最重要的行为有两类,一是在某种场景中体现自己,二是对外界的交互产生反应改变自己。MVC 模式里,第一类行为被划分为 view 的职责,第二类行为被归为 controller 的职责。Model 呢?Model 就是一组数据而已。Model 模块被剥夺了「对象」的特质。MVC 的理想状态是回归到「数据」加「算法」的传统模型。从另一个角度看,我经常说面向对象技术不适合 UI 之外的领域。作为 MVC 中和 UI 本身关系最远的模块,model 印证了这个观点。
主要行为被 view 和 controller 剥夺之后,model 勉强保留了一个行为 —— 保持数据的一致性。在《无毒副作用》中提到过,保持一致性并非本质上不可或缺的能力,没有冗余的理想 model 无需任何行为就能天然地保持数据一致性。所谓冗余就是指 model 中的一部分数据总是可以被另一部分数据计算出来。那些不能从其它数据计算出来的最基本的数据即「自由变量」,被计算出的数据即「依赖变量」。保持一致性的行为就是用自由变量重新计算和更新依赖变量的操作。在理想条件下,「依赖变量」应该作为 view 重新绘制时的临时数据而非 model 的一部分。
但现实中 model 终归无法避免冗余,所以本质上不必要的行为在实际中是不可或缺的。保持数据一致性的行为是 model 设计中细节复杂度的主要来源。比如在复杂耗时的数据变换中,如何在达到最终数据一致的过程中,找到保持局部一致性的中间状态,并且通知 view 进行平滑的显示。这在《并发与并行》中有所讨论。Model 是理想中的傀儡,在向现实的妥协中成长为一个负责的对象。
性能与一致性
在计算机领域,最伟大最纯粹的设想往往只为一个原因妥协 —— 性能。MVC 也不例外,它的理想是通过牺牲性能来降低整体复杂度:无冗余的 model 从根本上杜绝数据不一致的问题。一旦需要,view 就遍历整个 model,计算所有绘制所需的「依赖变量」,最后重新绘制界面。每个 UI 的实现,为了避免 premature optimization,都应该从这个状态开始。但是当 UI 真正发布的时候不可能还保持这种干净的状态。UI 迭代开发过程的重要部分就是不断根据需要在 model 中添加「依赖变量」来满足 view 的性能需求。引入「依赖变量」会增加 model 数据不一致的机会。如果为了抵消这种风险而过多添加和调用保持数据一致的代码,反而会导致性能损失。开发 UI 的整个过程是仔细权衡数据冗余和一致性级别的调优过程。也许构建强大的服务后端难度不亚于建设一个电站,但构建精致的 UI 也不亚于设计一台精巧的引擎。
如上所述,model 的设计要考虑几种平衡:
- 如何以及在何种程度上引入「依赖变量」。
- 在何时进行保持数据一致性的工作。其可能的时机包括 controller 的事件处理,view 的更新,后台 thread 或者 timer。
这些决定会影响 controller 和 view 的处理时间,而 controller 和 view 的处理方式恰恰是不同平台处理方式差异最大的地方:
- 平台会以什么样的频度对 controller 和 view 分别发出事件?比如,在不同系统中,mouse move 的频度有很大区别。
- Model/controller 通知 view 更新的机制会被如何处理?在有些平台上,这种通知是不可靠的, 在频率过高的时候会被 view 忽略,而在另一些平台上,每次通知都会得到 view 的完全处理。
所以,UI 的用户体验问题经常会从这种平台差异中显现。虽然没有统一的方式来解决它们,但是有一个很好用的简单方法来判断问题的类属:如果在 controller 或 view 的处理函数中稍稍改变处理频度(比如三次调用中的两次处理成 no-op 直接返回),问题的症状会发生变化甚至消失,那么基本上就是数据冗余和保持一致性的级别问题。对于如何设计「依赖变量」,不光要考虑冗余数据量的多少,也要考虑冗余的层次,即某些「依赖变量」是由其它依赖变量计算而来,为高一级的依赖变量。在需要快速响应的情况下,不更新或者只更新少量低一级的依赖变量,同时尽量使用之前计算好的依赖变量。在可以放慢响应速度的时候再更新各级依赖变量。
另一方面,某些「自由变量」在加入时间约束之后,可以简化为更少量更精简的数据,也就是在 model 暂时存入「变化量」,让 model 暂时维护 change 而非纯粹的 state,可以进一步减少 controller 的处理时间。从这个意义上说,自由变量是时间和变化量的「依赖变量」,在 model 中暂存变化量是对自由变量的「延迟存储」,是另一个方向上的冗余。
背离理想状态是为了更好的处理某些操作,但背离的程度越高,MVC 系统的弹性就越差。MVC 的弹性表现在可以处理设计者预料之外的用户操作。理想状态的 MVC 可能会因为性能低下而表现出响应不够敏捷,或者显示的更新不够平滑,但是不会陷入不一致的状态,甚至是除非关闭程序否则无法恢复的状态。而增加数据冗余的系统面对用户的随意操作其崩溃的危险也会增加。关于理想 MVC 系统弹性的讨论详见《 C 是 MVC 的 C 》。
Model 的 View 还是 Viewer 的 View
View 是 model 的 view 吗?回想一下别人或者你自己解释为什么系统中要有多个 view 的时候是否举过这个例子:有一组数据,又要显示成直方图,又要显示成饼图,又要做成表格 ⋯⋯ 这个例子里,view 是 viewer 的 view 而不是 model 的,因为 view 的类型是为了满足 viewer 的解读而不是 model 的结构。但是,作为 viewer 的 view 面临一个难题,viewer 需要的并非总是直方、饼、表那样泾渭分明的形式,他可能需要一个表格里有饼图,另一个饼图里有表格。另一方面,model 里的某些数据可能在多个 view 里都要显示成同一种形式。
如果把 view 按照 viewer 的解读形式来组织,那么在处理混合显示时就可能不得不在多个 view 模块里加入重复的代码。有人说给不同 view 加上 common base class,建立 view 的继承树。我不相信这种方法指导下产生的代码是可持续维护的。另一个诱惑是把这些代码移到 model 中,让数据自己显示自己。但 model 夺取本该属于 view 的行为是对「关注隔离 (separation of concern) 」的违背。而且,MVC 系统只有一个 model,让它背负 view 的责任容易让 model 需要实现的操作数量面临组合爆炸 (combination explorsion) 的困境。
所以,除了 viewer 的 view,在复杂系统里还必须实现 model 的 view。为某些数据编写负责显示它们的 view 模块,让 viewer 的 view 来重用。在有些 UI framework 里有这种 model 的 view,比如 Java Swing 中的 renderer。这些模块始终都应该是 view 的一部分,而非 model 的行为。除了「维护数据一致性」,不要随意给 model 增加任何行为,因为「维护数据一致性」本身已经是背离理想状态的产物。
最成功的对象
如果说面向对象在 UI 之外的领域是失败的,那么反过来考虑,如果 view 都不能作为「名副其实」的对象那么面向对象这门技术也就一无是处了。而 view 确实在某种意义上是最「成功」的对象,它的行为最复杂,同时也拥有数据。
View 的行为复杂不必多说,它的绘制代码是用户直接接触的整个应用最炫的部分。但 view 不适合直接拥有数据,因为那是 model 的责任。那么 view 如何拥有数据?答案是:MVC 是可以嵌套的,而这种嵌套就直接作用在 view 上。在嵌套形式中,内层 MVC 作为外层 MVC 的 view 的一部分,外层 view 操作或者直接作为内存 MVC 的 controller ,外层 view 的一部分或者全部绘制行为是从外层 model 向内层 model 搬运(经过转换的)数据。经典设计模式中的「组合模式 (composition patter) 」的一些实际应用就是多层嵌套 MVC。常用 UI framework 提供的现成 control 通常就是局部的微型 MVC 系统。嵌套 MVC 是 view 的主要数据构成,也是 model 数据冗余的另一种形式,维护一致性的时机一般在最外层 view 的绘制操作中。
所以,当系统的某一部分过于复杂但又有很强的内在联系,不必强求把数据和行为分拆到系统的最外层 MVC,而是可以把复杂的部分实现成内层 MVC。代价是内外层的 model 之间会有一定程度的数据冗余。
MVC 模式的三个模块,controller 趋于分散(见《 C 是 MVC 的 C 》),model 趋于纯数据结构,view 强于行为,有时也间接拥有数据。MVC 和面向对象是共同产生的,它们的外延也趋于一致。MVC 看似描述了三个对象,其实是一个对象拆开的三个部分。
发表评论