在VCL应用中运用MVC模式

[Mental Studio]猛禽[Blog]

(这篇文章始作于两年前,当时本来是想以我为备份自己的BLOG而写的一个小程序为例来说明我的这一想法。不过因为那个程序缺乏通用性,后来没有再去完善它,结果 这篇文章也就被放下了。然后又有很长一段时间忙于别的事情,没有接触原生应用开发。直到最近才因为在研究SQLite时,写了个简单的程序,并且在其中运用了两 年前的这一思路,故决定以新程序为例把这篇文章完成掉。)

本文的起因虽然只是源自一个小程序。不过从中可以总结出很多很有价值的东西,特别是关于GUI应用的测试问题。

问题的提出

RAD诞生至今已经十几年了,当初以简单高效的GUI应用开发能力而倍受欢迎。但是近几年来,RAD却开始越来越经常地被人所诟病。正如杜玄和苏丽辉所说[1]:

它(指RAD)象一剂毒药,慢慢麻痹你的思维,使你养成坏的开发习惯--使你背离敏捷的思想,使你远离单元测试,使你的体系层次不分,使你的模块紧密耦 合……。最终,界面部分成为整个软件系统的软肋,界面软件模块是一个没有单元测试覆盖的,业务与界面混杂的所在。她是一个BUG的集聚区,她是一个极难重 构的地方。

坦白说,这样的说法一点都不夸张。自从有了RAD,大大降低了GUI应用开发的技术门槛,使得开发工作变得容易得多了。但同时我们也发现,在GUI 应用开发不需要太多的技术含量,GUI应用程序员变成了控件拖放工人的情况下,却有着越来越多的RAD开发的软件项目失败了或正在走向失败。

这是为什么?

我们来回顾一下RAD开发的过程:在开发工作的初期,通过拖放控件,我们可以很快地搭出一个GUI来。然后就是不断地双击事件响应插入代码,把软件功能逐步插入到这个由GUI主导的框架中,并最终完成软件的开发工作。我把这叫做“视图驱动设计”——一种以界面为中心,由表及里的设计方法。

到目前为止,一切似乎很完美。

但是,这是一个需要拥抱变化的世界。如果没有任何变化,上面说的开发过程基本上不会表现出什么问题,而一旦情况发生了变化,问题就表现出来了:在这样的应用中,功能代码都是在事件中处理,与界面混在一块,基本上不可维护,任何微小的改动都可能导致连环作用的“蝴蝶效应”。最终陷入不能自拔的“焦油坑”[2]。

还是Brooks,他在《没有银弹》一文中指出:软件开发的根本困难在于——软件本身固有的复杂性、一致性、可变性和不可见性[2]。而像RAD这样的技术只能是在一定程度上解决除此之外的非根本困难。这就是“没有银弹”理论的基础。所以我很赞同gigix的观点:现在软件开发的工程化程度不是太低,而是太高了,所以对工程化的改进所能带来的边际效用日渐下降[3]。

说了这么多废话,我只是想说明一件事:做GUI开发也是需要技术含量的,光会拖控件可不行

问题的分析

现在的RAD开发基本上都是基于PME模式——即基于Property, Method, Event三者组合运作的模式。这种模式固然有明显的优点,那就是将GUI开发技术难度大大降低,但是也存在不少的问题。

首先就是上文说过的,程序逻辑混杂在Event响应中,使得程序结构混乱,难以修改,代码中充满了bad smell。

其 次,Event的响应是被动的,对于开发者来说,通常是不透明的。也就是说,各个控件的Event将会在什么情况下触发,将会以怎样的顺序执行等情况并不 是完全明确的——大部分情况下它们都会如我们所预料的那样被触发,但是在一些特殊情况下,它们的响应却往往出乎我们的意料。最典型的情况就是程序退出的时 候,会有一些Event被触发——这往往非我们所期望。导致的直接结果就是常常在程序退出时发生莫名其妙的Access Violation错误——原因就是在程序退出的过程中,一些数据已经被释放,但Event却响应执行,访问了那些已经被释放的数据。虽然现在.net之 类通过GC可以防止出现这种AV错误,但是这种治标不治本的办法并不能解决由于Event不正常的响应可能带来的潜在的逻辑错误。

第三, Property只在各个控件中记录控件状态,所以实际上,整个Form的状态是分散在各个控件中的,不好管理。因为在典型的应用中,Form的状态常常 是相对简单的,而这些状态又往往要同时影响到多个控件,如果不能对这些分散的状态作统一管理,则很容易在操作中出现个别控件状态异常的情况。

最后,控件的Method只用于操作控件,与逻辑无关,而Form里的Method主要也只是用于事件响应。

探寻拯救之道

要解决这些问题,首要的任务就是要把这一大堆混乱的东西理顺。而在这一堆乱麻中,混乱之源就是Event的响应代码部分。所以,必须想办法将它分离 出来。而要摆脱这种以Event为中心的设计思路,就必须抛弃原先那种看似简单的“视图驱动设计”方法——严格地说,这种设计基本上就是“没有设计”。

为了摆脱这种“视图驱动设计”的困扰,让我们回到没有RAD的时代,那时的GUI应用所用的是最经典的架构设计模式:MVC模式。


(典型的MVC模式结构图,来自Internet,版权属原作者所有)

我刚知道MVC模式时是在八年前,在一本介绍用BC31开发Windows图形图像应用的书上看到的。那时还觉得不可理解,本来简单的事情为什么要 多此一举,搞得复杂化了。后来用了RAD以后就更加没有关注这个MVC模式了。现在我才理解了它为什么会成为经典——它可以将界面代码和功能代码更好地分 离出来。

而界面代码与功能代码分离的好处除了把各部分关系理顺以外,还能带来一些附加好处——其中最主要的就是可测试性的改进。因为在M-V-C三者分离开之后,各部分之间的耦合关系下降,很容易对三者分别进行测试,使得重构等敏捷方法得以方便地运用。

新的设计

现在,我们将把传统的MVC模式引入到RAD的GUI开发中。

首先从最简单的单窗体应用开始。

在只有一个窗体的最简单GUI应用中,可以简单地套用标准的MVC结构来设计,不过我的实际设计与前面那个经典设计相比,作了一点改变,如下图所示:


从 图上可以看出,区别就在于View与Module之间的State Query依赖关系被我改成View与Control之间的依赖关系。这样的实现在某种程度上增加了Control部分的工作,并且使Control与 Module之间的关系增加了复杂性。但是可以带来一些有价值的好处:

一方面,可以形成一个三层结构,对于未来如果需要移植到多层结构时会 有很大的便利;另一方面三层结构相对于三角结构来说,降低了耦合的复杂度,可以改进测试;最后是可以借助这样的结构,把部分View中的逻辑移到 Control中,增加测试的覆盖面(因为实际上,View层的单元测试还是很困难的,详见下文)。

由于View与Control的关系十分紧密,对于简单的应用来说,我会把二者合并,即不单独定义一个Control类,而是把它的定义都放到Form类中去。如下图:


这 样做的好处是:因为View与Control之间的相互调用较多,耦合比较紧,分开成独立的类实现会增加一些调用方面的代码量。但是这样也会带来一些麻 烦,特别是在多窗体的情况下,会增加窗体间的耦合。所以这种做法只适用于窗体较少或是窗体间相互调用较少的情况,一般不推荐这样做,因为这很容易又堕落回 从前的混乱状态中。

完成了单窗体应用的设计后,推广到多窗体应用只不过是增加一些View和Control罢了——Module部分一般是整个应用程序共用一个。但因为引入了多个View和Control——特别是多个Control会带来一些复杂性,主要是如下图所示:


可见,在多个Form的应用中,各个Form的Control之间会有一些联系。在这种情况下就不宜像前面那样把Control和View合在一起了,否则的话,由于各个Form之间的相互引用很容易将程序带回到从前的RAD那样的混乱状况。

单元测试

前面已经说过,将MVC引用RAD开发以后,可以带来可测试性方面的改进,现在就来看看这种改进的应用。

TDD 对于现在软件开发的重要性是不言而喻的,如果你还没有体会到TDD所带来的巨大福利,我只能对此表示遗憾。对于软件开发来说,有了TDD的支持,前进道路 上的每一步都将是坚实可靠的。而对于程序员来说,有了TDD的支持,每一次的修改心里都是踏实的。没有TDD的时候,程序员只有在开发工作完成的时候才能 体会到成功的喜悦,而有了TDD,则几乎每天都能体会到。

所谓的单元测试其实也没有什么神秘的。即使任何一个合格的程序员,在不了解的TDD的时候,写完一个函数时,有时也会顺手写一段测试代码去调用一下这个函数,看看它是不是如自己所预期的那样运行。而单元测试技术不过是把这种测试代码进行了一番规范化,更便于使用了。

从前面的几个图可以看出,其中的Module部分是相对独立的,即使没有Control和View部分,它也可以实现一定的逻辑功能,也就意味着它可以单独被测试。所以我们首先来考虑一下它的测试用例要如何编写。

其实很简单,就如前面的简化类图中那样,只是把调用Module的Control类换成测试用例类即可。在测试用例中模拟Control对Module的调用动作,并在测试用例中检查Module返回的结果是否与预期相符即可。测试程序结构如下图:


Control 的测试以此类推,只不过因为Control依赖于Module,所以必须是在Module的相应功能通过测试以后,才能进行Control的相应功能测 试。复杂的情况同样将出现在多Form的时候,由于多个Control之间存在相互依赖的情况,所以需要对存在相互依赖的Control一并进行测试。测 试程序结构如下图:


至 于View的测试因为已经不是光靠代码可以实现的,所以本文略过不谈。而且从另一方面说,因为绝大部分功能实现都是在Control和Model里,所以 实际上View里剩下的代码已经很少了,通常也不会带来什么隐患性的问题(操作逻辑上的问题只要试用一下应该都能发现),因此基本上只要把Control 和Module测试好了,View也几乎不需要什么测试。

应用实例

记得我刚开始学DELPHI的时候,写的第一个完整的数据库应用程序就是一个简单的通讯录程序。这回还是以一个简单的通讯录程序为实例来说明——这个程序虽然简单,但比起我十年前写的那个当然还是要像样多了。这回的这个通讯录程序我把它叫做:MyContact。

这个程序虽然用到了两个Form,但因为那个ContactDlg实际上是被当作一个控件来使用的,所以我还是按照单窗体应用的方式来实现,总体结构如下:


从源代码上粗略看来,好像只是简单地把从前的一些夹杂在事件响应中的逻辑代码剥离出来,放到独立的TMainController类中(仍然在 MainForm单元里)。这种貌似多此一举的做法带来好处在开始可能并不明显,但是当你需要不断地为程序增加功能或是修改功能的时候,就会发现这样的结 构带来了很大的好处——因为逻辑清晰,可以很快很准地找到修改之处,并且将可能带的副作用影响降到最低。

至于各逻辑功能代码如何分,哪些部分应该放到Module里?哪些部分应该放到Control里?哪些部分应该留在View里?我个人的经验是这样:

一个功能的最核心部分实现放在Module里,这部分实现与界面完全没有关系,只要在调用这部分代码时传入必要的参数,并返回执行的结果即可。

而在View层尽量不要有任何逻辑功能代码,它只需要简单地调用Control里的相应功能即可。

那些从View中剥离出来,但又不能放入Module(比如需要与界面交互)的部分则全部放到Control中去。

以MyContact程序为例,在FormShow事件中要做的唯一的事情就是初始化左边TreeView的内容,所以在这个事件响应里就简单地调用一下Control的CmdRefreshTree方法。

而Tree的内容当然是要从数据库里查询出来,这个工作就由Module的GetTreeData方法来实现。但是这个GetTreeData实现的功能很单一:就是根据传入的过滤参数去查询数据库,然后把结果以一个数据集的形式返回给调用者。

剩 下的工作就在Control中实现了。Control中的CmdRefreshTree的功能主要是在FilterTree这个内部方法中实现的——因为 需要与过滤功能复用。这个方法就是调用Module的GetTreeData方法取得数据,然后显示到TreeView上。

其它的功能分离方式与此类似。

这 种实现结构其实与Web应用中的MVC实现还是略有不同的,因为View层的工作太少了,特别是把原本属于View的State Query工作交给Control去做以后,Control部分的内容多了很多——包括转发View的State Query,并且将Module返回的结果显示到View中去。

我曾经设想过给View增加一些功能,但又要防止退回到从前的混乱中去。这 个设想的大体的思路是仿照Web应用中的template机制,实现一个Generic View——它的功能是根据template的定义,把Object Data处理成一个用户界面,实现的方法是借助于XML和XSLT。不过经过五六年的设想,最后还是停留在想法阶段。因为这样做的话,不可避免地将把传统 GUI应用变成一个Request/Response方式运作的Web应用,失去了GUI应用交互性好的优势。

所以我认为,目前这个方案还算是一个比较好的折衷办法。

前面已经说过,这种MVC结构除了对修改带来的好处以外,更大的好处就是改进了测试的方便性。包含了单元测试以后的结构如下:


因为各部分的分离之后,Module的功能就很单纯,可以很简单地进行测试。与一般的类测试方法没有什么不同,无非是在TestCase里写一些测试方法去调用Module中的相关方法。

但是因为Control与View的关系紧密,测试代码的编写相对复杂一些,不过通过这样的测试代码编写,可以让你更容易地区别哪些代码应该放在Control里,哪些应该放在View里——凡是不便于测试的部分,最好放在View里。

当然这个MyContact只是作为一个例子而写,还是有很多不足之处。比如拖放那个部分其实是可以作一个封装的。另外,测试代码也都是作为例子而写,对被测代码的覆盖率还是很低的。

程序的实现是用Turbo C++ Explorer(TCX,即免费版的BCB2006),数据库采用的是SQLite3,数据库访问控件是用ZeosDBO(使用方法参见文章《在TCX中使用SQLite3》),单元测试用的是DUnit(参见文章《在BCB中使用DUnit》)。UML图使用的工具是argoUML。

补 充说明:因为在其中使用了大量的Delphi代码(ZeosDBO和DUnit都是用Delphi编写的),所以用TCX编译时经常发生编译错误,这不是 本程序的问题,实在是免费的TCX编译器不靠谱。如出现这种问题,请退出TCX并删除所有中间文件后重启TCX再编译。

全部源代码在这里下载:34KB


参考文献
[1]杜玄,苏丽辉《用户界面的可视化开发工具--一朵美丽的罂粟花》(《程序员》2005年第一期)
[2]Frederick P. Brooks Jr.《人月神话》,2002年,清华大学出版社
[3]出自gigix的BLOG,不过他在blogdriver上的那个BLOG已经关闭了

[Mental Studio]猛禽 Jun.8-05, Aug.14-07