GUI 架构

组织富客户端系统代码有很多不同的方法。在这里,我将讨论一些我认为最有影响力的方法,并介绍它们与模式的关系。

2006 年 7 月 18 日



这是我在 2000 年代中期进行的 进一步的企业应用程序架构开发 编写的一部分。遗憾的是,此后太多其他事情吸引了我的注意力,所以我没有时间进一步研究它们,而且在可预见的未来我也没有看到太多时间。因此,这些材料非常草稿,我不会进行任何更正或更新,直到我有时间再次处理它。

图形用户界面已成为我们软件环境中熟悉的一部分,无论是作为用户还是作为开发人员。从设计的角度来看,它们代表了系统设计中的一组特定问题 - 这些问题导致了许多不同但相似的解决方案。

我的兴趣是确定应用程序开发人员在富客户端开发中使用的通用且有用的模式。我在项目审查中看到了各种设计,以及以更永久的方式编写的各种设计。在这些设计中包含有用的模式,但描述它们通常并不容易。以模型-视图-控制器为例。它通常被称为模式,但我发现将其视为模式并没有太大用处,因为它包含许多不同的想法。不同的人在不同的地方阅读有关 MVC 的内容,从中获取不同的想法并将其描述为“MVC”。如果这没有造成足够的混乱,那么您就会看到通过 语义扩散 产生的对 MVC 的误解。

在这篇文章中,我想探索一些有趣的架构,并描述我对它们最有趣特征的理解。我希望这将为理解我描述的模式提供一个背景。

在某种程度上,您可以将这篇文章看作一种智力史,它追溯了多年来 UI 设计中通过多种架构传播的想法。但是,我必须对此发出警告。理解架构并不容易,尤其是在许多架构发生变化和消亡的情况下。追踪想法的传播更加困难,因为人们从同一个架构中读出了不同的东西。特别是,我没有对所描述的架构进行详尽的检查。我所做的是参考了对这些设计的常见描述。如果这些描述遗漏了一些东西,我对此一无所知。因此,不要将我的描述视为权威。此外,如果我认为与我无关,我会省略或简化一些内容。请记住,我的主要兴趣是底层模式,而不是这些设计的历史。

(这里有一个例外,即我确实可以使用运行的 Smalltalk-80 来检查 MVC。同样,我不会将我对它的检查描述为详尽,但它确实揭示了常见描述中没有的东西 - 这让我更加谨慎地对待我对这里其他架构的描述。如果您熟悉这些架构之一,并且您发现我有一些重要的错误和遗漏,请告诉我。我还认为,对该领域的更详尽的调查将是一个很好的学术研究对象。)

表单和控件

我将从一个既简单又熟悉的架构开始探索。它没有一个通用的名称,因此为了本文的目的,我将称之为“表单和控件”。它是一个熟悉的架构,因为它是在 90 年代由客户端-服务器开发环境鼓励的 - 诸如 Visual Basic、Delphi 和 Powerbuilder 之类的工具。它继续被广泛使用,尽管也经常被像我这样的设计极客所贬低。

为了探索它,以及其他架构,我将使用一个常见的例子。在我居住的新英格兰,有一个政府项目监控大气中冰淇淋颗粒的数量。如果浓度过低,这表明我们没有吃足够的冰淇淋 - 这对我们的经济和公共秩序构成严重风险。(我喜欢使用与您通常在这样的书籍中发现的现实情况一样现实的例子。)

为了监测我们的冰淇淋健康状况,政府在新英格兰各州设立了监测站。该部门使用复杂的空气动力学模型为每个监测站设定目标。每隔一段时间,工作人员就会外出进行评估,他们会前往各个站点并记录实际的冰淇淋颗粒浓度。此 UI 允许他们选择一个站点,并输入日期和实际值。然后,系统计算并显示与目标的偏差。当偏差低于目标 10% 或更多时,系统会将偏差突出显示为红色,当偏差高于目标 5% 或更多时,系统会将偏差突出显示为绿色。

图 1:我将用作示例的 UI。

当我们查看此屏幕时,我们可以看到在我们将其组合在一起时有一个重要的划分。表单特定于我们的应用程序,但它使用的是通用的控件。大多数 GUI 环境都附带大量我们可以直接在应用程序中使用的通用控件。我们可以自己构建新的控件,而且这样做通常是一个好主意,但仍然存在通用可重用控件和特定表单之间的区别。即使是专门编写的控件也可以在多个表单中重用。

表单包含两个主要职责

  • 屏幕布局:定义控件在屏幕上的排列方式,以及它们彼此之间的层次结构。
  • 表单逻辑:无法轻松编程到控件本身的行为。

大多数 GUI 开发环境允许开发人员使用图形编辑器来定义屏幕布局,该编辑器允许您将控件拖放到表单中的某个空间。这几乎处理了表单布局。这样,很容易在表单上设置一个令人愉悦的控件布局(尽管它并不总是最好的方法 - 我们稍后会谈到这一点。)

控件显示数据 - 在这种情况下是关于读数的数据。这些数据几乎总是来自其他地方,在这种情况下,让我们假设一个 SQL 数据库,因为这是大多数这些客户端-服务器工具所假设的环境。在大多数情况下,涉及到数据的三份副本

  • 一份数据副本位于数据库本身中。此副本是数据的持久记录,因此我称之为记录状态。记录状态通常是共享的,并且可以通过各种机制供多人查看。
  • 另一份副本位于应用程序内的内存 记录集 中。大多数客户端-服务器环境都提供了使此操作变得容易的工具。这些数据仅与应用程序和数据库之间的一次特定会话相关,因此我称之为会话状态。本质上,这提供了用户在保存或提交回数据库之前所处理数据的临时本地版本 - 此时它与记录状态合并。我不会在这里担心协调记录状态和会话状态的问题:我在 [P of EAA] 中介绍了各种技术。
  • 最后一份副本位于 GUI 组件本身内部。严格来说,这是他们在屏幕上看到的数据,因此我称之为屏幕状态。屏幕状态和会话状态如何保持同步对 UI 非常重要。

保持屏幕状态和会话状态同步是一项重要的任务。一个有助于简化此操作的工具是 数据绑定。其理念是,对控件数据或底层记录集的任何更改都会立即传播到另一个。因此,如果我更改屏幕上的实际读数,文本字段控件实际上会更新底层记录集中相应的列。

通常,数据绑定会变得很棘手,因为您必须避免循环,其中对控件的更改会更改记录集,从而更新控件,从而更新记录集……。使用流程有助于避免这些问题 - 我们在打开屏幕时从会话状态加载到屏幕,之后对屏幕状态的任何更改都会传播回会话状态。一旦屏幕打开,会话状态通常不会直接更新。因此,数据绑定可能并非完全双向 - 仅限于初始上传,然后将更改从控件传播到会话状态。

数据绑定 很好地处理了客户端-服务器应用程序的大部分功能。如果我更改实际值,该列将被更新,甚至更改所选站点也会更改记录集中当前选定的行,这会导致其他控件刷新。

框架构建者内置了大部分这种行为,他们关注常见需求并使其易于满足。特别是,这是通过设置控件上的值(通常称为属性)来完成的。控件通过一个简单的属性编辑器设置其列名来绑定到记录集中的特定列。

使用数据绑定,以及正确类型的参数化,可以走很远。但是,它不能带您走完所有路程 - 几乎总有一些逻辑不适合参数化选项。在这种情况下,计算偏差是一个不适合这种内置行为的示例 - 因为它特定于应用程序,因此通常位于表单中。

为了使此操作正常工作,表单需要在实际字段的值发生更改时收到通知,这需要通用文本字段在表单上调用一些特定行为。这比获取类库并通过调用它来使用它更复杂,因为涉及到控制反转。

有各种方法可以使这种事情正常工作 - 客户端-服务器工具包的常见方法是事件的概念。每个控件都有一个它可以引发的事件列表。任何外部对象都可以告诉控件它对某个事件感兴趣 - 在这种情况下,控件将在事件被引发时调用该外部对象。本质上,这只是对 观察者 模式的一种重新表述,其中表单正在观察控件。框架通常提供了一些机制,允许表单的开发人员在事件发生时在子例程中编写代码。事件和例程之间的链接方式在平台之间有所不同,对于本次讨论来说并不重要 - 关键是存在某种机制来实现它。

一旦表单中的例程获得控制权,它就可以执行任何必要的操作。它可以执行特定行为,然后根据需要修改控件,依靠数据绑定将这些更改中的任何一个传播回会话状态。

这也是必要的,因为数据绑定并不总是存在。窗口控件市场很大,并非所有控件都执行数据绑定。如果数据绑定不存在,则表单负责执行同步。这可以通过最初将数据从记录集拉到小部件中,并在按下保存按钮时将更改后的数据复制回记录集来实现。

让我们检查一下我们对实际值的编辑,假设数据绑定存在。表单对象保存对通用控件的直接引用。屏幕上每个控件都会有一个,但我在这里只对实际、偏差和目标字段感兴趣。

图 2:表单和控件的类图

文本字段声明了一个文本更改事件,当表单在初始化期间组装屏幕时,它会订阅该事件,并将其绑定到自身的一个方法 - 这里为 actual_textChanged

图 3:使用表单和控件更改类型的序列图。

当用户更改实际值时,文本字段控件会触发其事件,通过框架绑定的魔力,actual_textChanged 会运行。此方法从实际文本字段和目标文本字段获取文本,进行减法,并将值放入方差字段。它还会确定应以什么颜色显示值,并相应地调整文本颜色。

我们可以用一些简短的语录来概括架构。

  • 开发人员编写使用通用控件的特定于应用程序的表单。
  • 表单描述了其上控件的布局。
  • 表单观察控件,并具有处理程序方法来响应控件触发的有趣事件。
  • 简单的数据编辑通过数据绑定处理。
  • 复杂更改在表单的事件处理方法中完成。

模型视图控制器

可能 UI 开发中最广泛引用的模式是模型视图控制器 (MVC) - 它也是被引用最多的模式。我已经记不清多少次看到一些被描述为 MVC 的东西,结果却与它完全不同。坦率地说,造成这种情况的原因很大一部分是经典 MVC 的某些部分对于当今的富客户端来说并不真正有意义。但目前,我们将看一下它的起源。

当我们查看 MVC 时,重要的是要记住,这是在任何规模上进行严肃的 UI 工作的首次尝试之一。图形用户界面在 70 年代并不常见。我刚刚描述的表单和控件模型是在 MVC 之后出现的 - 我首先描述了它,因为它更简单,并不总是以一种好的方式。我将再次使用评估示例来讨论 Smalltalk 80 的 MVC - 但请注意,为了做到这一点,我对 Smalltalk 80 的实际细节做了一些改动 - 首先,它是一个单色系统。

MVC 的核心,以及对后来框架影响最大的想法,就是我所说的 分离式呈现分离式呈现 背后的理念是在建模我们对现实世界的感知的域对象和作为我们在屏幕上看到的 GUI 元素的呈现对象之间做出明确的区分。域对象应该是完全自包含的,并且可以在没有引用呈现的情况下工作,它们还应该能够支持多个呈现,可能同时支持。这种方法也是 Unix 文化的重要组成部分,并且在今天继续允许许多应用程序通过图形界面和命令行界面进行操作。

在 MVC 中,域元素被称为模型。模型对象完全不知道 UI。为了开始讨论我们的评估 UI 示例,我们将模型视为一个读数,其中包含所有有趣数据的字段。(正如我们稍后将看到的那样,列表框的存在使得什么是模型这个问题更加复杂,但我们将暂时忽略该列表框。)

在 MVC 中,我假设的是 域模型 的常规对象,而不是我在表单和控件中使用的 记录集 概念。这反映了设计背后的普遍假设。表单和控件假设大多数人希望轻松地操作来自关系数据库的数据,MVC 假设我们正在操作常规的 Smalltalk 对象。

MVC 的呈现部分由剩余的两个元素组成:视图和控制器。控制器的作用是接收用户的输入并确定如何处理它。

在这一点上,我应该强调,并非只有一个视图和控制器,而是为屏幕的每个元素、每个控件以及整个屏幕都有一对视图-控制器。因此,响应用户输入的第一部分是各种控制器协作以查看谁被编辑了。在本例中,即实际的文本字段,因此该文本字段控制器将处理接下来发生的事情。

图 4:模型、视图和控制器之间的基本依赖关系。(我称之为基本,因为实际上视图和控制器确实直接链接到彼此,但开发人员大多不使用这一事实。)

与后来的环境一样,Smalltalk 发现您希望使用可以重复使用的通用 UI 组件。在这种情况下,组件将是视图-控制器对。两者都是通用类,因此需要插入特定于应用程序的行为。将有一个评估视图来表示整个屏幕并定义较低级别控件的布局,从这个意义上说,它类似于表单和控制器中的表单。但是,与表单不同,MVC 在评估控制器上没有针对较低级别组件的事件处理程序。

图 5:用于 MVC 版本的冰淇淋监控显示的类

文本字段的配置来自为其提供一个指向其模型(读数)的链接,并告诉它在文本更改时调用哪个方法。这在屏幕初始化时设置为 '#actual:'(在 Smalltalk 中,以 '#' 开头的符号表示符号或内部字符串)。然后,文本字段控制器对读数进行反射调用该方法以进行更改。从本质上讲,这与 数据绑定 中发生的机制相同,控件链接到底层对象(行),并告知它操作哪个方法(列)。

图 6:更改 MVC 的实际值。

因此,没有观察低级小部件的整体对象,而是低级小部件观察模型,模型本身处理表单将做出的许多决策。在本例中,当涉及到计算方差时,读数对象本身就是进行此操作的自然位置。

观察者确实出现在 MVC 中,事实上,它是 MVC 的一项功劳。在本例中,所有视图和控制器都观察模型。当模型发生变化时,视图会做出反应。在本例中,实际文本字段视图会收到通知,告知读数对象已发生更改,并调用定义为该文本字段方面的那个方法 - 在本例中为 #actual - 并将其值设置为结果。(它对颜色做了类似的事情,但这会引发我将在稍后提到的自己的问题。)

您会注意到,文本字段控制器没有在视图本身中设置值,而是更新了模型,然后只让观察者机制处理更新。这与表单和控件方法完全不同,在表单和控件方法中,表单更新控件,并依赖于数据绑定来更新底层记录集。我将这两种风格描述为模式:流同步观察者同步。这两种模式描述了处理屏幕状态和会话状态之间同步触发的替代方法。表单和控件通过应用程序流操作需要直接更新的各种控件来实现这一点。MVC 通过对模型进行更新,然后依赖于观察者关系来更新观察该模型的视图来实现这一点。

流同步 在数据绑定不存在时更为明显。如果应用程序需要自己进行同步,那么它通常是在应用程序流中的重要点完成的 - 例如,打开屏幕或点击保存按钮时。

观察者同步 的一个结果是,控制器对用户操作特定小部件时哪些其他小部件需要更改一无所知。虽然表单需要跟踪事物并确保整体屏幕状态在更改时保持一致,这在复杂屏幕中可能会变得非常复杂,但 观察者同步 中的控制器可以忽略所有这些。

这种有用的无知在有多个屏幕打开查看相同模型对象时尤其有用。经典的 MVC 示例是一个类似电子表格的数据屏幕,在单独的窗口中包含该数据的几个不同图表。电子表格窗口不需要知道哪些其他窗口是打开的,它只需更改模型,观察者同步 就会处理其余的事情。使用 流同步,它需要某种方法来了解哪些其他窗口是打开的,以便告诉它们刷新。

虽然 观察者同步 很不错,但它确实有一个缺点。观察者同步 的问题是观察者模式本身的核心问题 - 您无法通过阅读代码来了解正在发生的事情。当我试图弄清楚一些 Smalltalk 80 屏幕是如何工作的时,我被这一点非常有力地提醒了。我可以通过阅读代码来理解到一定程度,但一旦观察者机制启动,我唯一能看到正在发生的事情的方法是通过调试器和跟踪语句。观察者行为很难理解和调试,因为它是一种隐式行为。

虽然不同的同步方法从序列图中特别明显,但最重要的也是影响最大的区别是 MVC 使用了 分离式呈现。计算实际值和目标值之间的方差是域行为,它与 UI 无关。因此,遵循 分离式呈现 的说法是,我们应该将此放置在系统的域层中 - 这正是读数对象所代表的。当我们查看读数对象时,方差特征在没有任何用户界面概念的情况下完全有意义。

然而,在这一点上,我们可以开始关注一些复杂情况。有两个领域,我跳过了阻碍 MVC 理论的一些尴尬点。第一个问题领域是处理设置方差的颜色。这实际上不应该适合域对象,因为我们用来显示值的颜色不是域的一部分。处理此问题的第一个步骤是认识到部分逻辑是域逻辑。我们在这里做的是对方差做出定量陈述,我们可以将其称为好(超过 5%)、坏(低于 10%)和正常(其余)。做出这种评估无疑是域语言,将其映射到颜色并更改方差字段是视图逻辑。问题在于我们将这种视图逻辑放在哪里 - 它不是我们标准文本字段的一部分。

早期的 Smalltalk 用户遇到了这种问题,他们提出了一些解决方案。我在上面显示的解决方案是肮脏的解决方案 - 为了让事情正常工作,牺牲了域的某些纯度。我承认偶尔会做出不纯的行为 - 但我尽量不要养成习惯。

我们可以做与表单和控件几乎相同的事情 - 让评估屏幕视图观察方差字段视图,当方差字段发生变化时,评估屏幕可以做出反应并设置方差字段的文本颜色。这里的问题包括更多地使用观察者机制 - 随着使用量的增加,它会变得越来越复杂 - 以及各种视图之间额外的耦合。

我更喜欢的一种方法是构建一种新的 UI 控件类型。从本质上讲,我们需要的是一种 UI 控件,它向域请求定量值,将其与内部值和颜色表进行比较,并相应地设置字体颜色。评估视图在组装自身时会设置表格和向域对象询问的消息,就像它设置要监控的字段的方面一样。如果我可以轻松地对文本字段进行子类化以添加额外的行为,这种方法可以很好地工作。这显然取决于组件的设计是否易于进行子类化 - Smalltalk 使其变得非常容易 - 其他环境可能会使其变得更加困难。

图 7:使用可以配置为确定颜色的文本字段的特殊子类。

最后一条路线是创建一个新的模型对象,它以屏幕为中心,但仍然独立于小部件。它将成为屏幕的模型。与阅读对象上的方法相同的方法将被委托给阅读,但它会添加支持仅与 UI 相关行为的方法,例如文本颜色。

图 8:使用中间 演示模型 处理视图逻辑。

最后一个选项适用于许多情况,正如我们将会看到的那样,它成为 Smalltalk 程序员遵循的常见路线——我称之为 演示模型,因为它是一个真正为演示层设计并成为演示层一部分的模型。

演示模型 也适用于另一个演示逻辑问题——演示状态。基本的 MVC 概念假设视图的所有状态都可以从模型的状态推断出来。在这种情况下,我们如何确定列表框中选择了哪个车站? 演示模型 为我们解决了这个问题,它为我们提供了一个放置这种状态的地方。如果我们有仅在数据发生更改时才启用的保存按钮,也会出现类似的问题——这再次是我们与模型交互的状态,而不是模型本身。

所以现在我认为是时候谈谈 MVC 的一些要点。

  • 在演示(视图和控制器)和领域(模型)之间建立强烈的分离——分离的演示
  • 将 GUI 小部件划分为控制器(用于响应用户刺激)和视图(用于显示模型的状态)。控制器和视图应该(主要)不直接通信,而是通过模型通信。
  • 让视图(和控制器)观察模型,以允许多个小部件更新,而无需直接通信——观察者同步

VisualWorks 应用程序模型

正如我在上面讨论的那样,Smalltalk 80 的 MVC 非常有影响力,具有一些出色的功能,但也有一些缺陷。随着 Smalltalk 在 80 年代和 90 年代的发展,这导致了经典 MVC 模型的一些重大变化。事实上,如果认为视图/控制器分离是 MVC 的一个重要组成部分——而名称确实暗示了这一点——那么可以说 MVC 消失了。

从 MVC 中明确有效的是 分离的演示观察者同步。因此,这些在 Smalltalk 发展过程中保留了下来——事实上,对许多人来说,它们是 MVC 的关键要素。

Smalltalk 在这些年里也出现了碎片化。Smalltalk 的基本思想,包括(最小的)语言定义保持不变,但我们看到多个 Smalltalk 发展了不同的库。从 UI 的角度来看,这变得很重要,因为几个库开始使用原生小部件,即 Forms and Controls 样式使用的控件。

Smalltalk 最初是由 Xerox Parc 实验室开发的,他们从 ParcPlace 分拆了一家独立的公司来营销和开发 Smalltalk。ParcPlace Smalltalk 被称为 VisualWorks,并强调成为一个跨平台系统。早在 Java 之前,你就可以在 Windows 上编写一个 Smalltalk 程序,然后立即在 Solaris 上运行它。因此,VisualWorks 没有使用原生小部件,而是将 GUI 完全保留在 Smalltalk 中。

在我对 MVC 的讨论中,我以 MVC 的一些问题结束——特别是如何处理视图逻辑和视图状态。VisualWorks 改进了其框架以解决这个问题,它提出了一个名为应用程序模型的构造——一个朝着 演示模型 方向发展的构造。使用类似于 演示模型 的想法对 VisualWorks 来说并不新鲜——最初的 Smalltalk 80 代码浏览器非常相似,但 VisualWorks 应用程序模型将其完全烘焙到框架中。

这种 Smalltalk 的一个关键要素是将属性转换为对象的想法。在我们通常对具有属性的对象的理解中,我们认为 Person 对象具有名称和地址的属性。这些属性可能是字段,但也可能是其他东西。通常有一个访问属性的标准约定:在 Java 中,我们会看到 temp = aPerson.getName()aPerson.setName("martin"),在 C# 中,它将是 temp = aPerson.nameaPerson.name = "martin"

一个 属性对象 通过让属性返回一个包装实际值的来改变这一点。因此,在 VisualWorks 中,当我们请求一个名称时,我们会得到一个包装对象。然后,我们通过询问包装对象的值来获取实际值。因此,访问一个人的姓名将使用 temp = aPerson name valueaPerson name value: 'martin'

属性对象使小部件和模型之间的映射变得更容易。我们只需要告诉小部件发送什么消息来获取相应的属性,而小部件知道使用 valuevalue: 访问适当的值。VisualWorks 的属性对象还允许你使用消息 onChangeSend: aMessage to: anObserver 设置观察者。

你实际上不会在 Visual Works 中找到一个名为属性对象的类。相反,有许多类遵循 value/value:/onChangeSend: 协议。最简单的是 ValueHolder——它只包含它的值。与本讨论更相关的是 AspectAdaptor。AspectAdaptor 允许属性对象完全包装另一个对象的属性。这样,你就可以在 PersonUI 类上定义一个属性对象,该对象通过类似于以下代码的代码包装 Person 对象上的属性

adaptor := AspectAdaptor subject: person
adaptor forAspect: #name
adaptor onChangeSend: #redisplay to: self

那么让我们看看应用程序模型如何融入我们的运行示例。

图 9:运行示例中 Visual Works 应用程序模型的类图

使用应用程序模型和经典 MVC 之间的主要区别在于,我们现在在领域模型类(Reader)和小部件之间有一个中间类——这就是应用程序模型类。小部件不直接访问领域对象——它们的模型是应用程序模型。小部件仍然被分解为视图和控制器,但除非你正在构建新的小部件,否则这种区别并不重要。

当你组装 UI 时,你是在 UI 画笔中完成的,而在该画笔中,你为每个小部件设置方面。方面对应于应用程序模型上的一个方法,该方法返回一个属性对象。

图 10:显示如何更新实际值更新方差文本的序列图。

图 10 显示了基本更新序列是如何工作的。当我更改文本字段中的值时,该字段随后会更新应用程序模型中属性对象中的值。该更新会传递到底层的领域对象,更新其实际值。

此时,观察者关系开始生效。我们需要进行设置,以便更新实际值会导致阅读指示它已更改。我们通过在 actual 的修改器中放置一个调用来实现这一点,以指示阅读对象已更改——特别是方差方面已更改。在为方差设置方面适配器时,很容易告诉它观察阅读器,因此它会接收更新消息,然后将其转发到其文本字段。然后,文本字段会启动获取新值,同样是通过方面适配器。

使用应用程序模型和属性对象,我们可以帮助我们连接更新,而无需编写太多代码。它还支持细粒度同步(我认为这不是一件好事)。

应用程序模型允许我们将特定于 UI 的行为和状态与真正的领域逻辑分离。因此,我之前提到的一个问题,在列表中保存当前选定的项目,可以通过使用一种特殊类型的方面适配器来解决,该适配器包装领域模型的列表并存储当前选定的项目。

然而,所有这些的局限性在于,对于更复杂的行为,你需要构建特殊的小部件和属性对象。例如,提供的对象集没有提供将方差的文本颜色链接到方差程度的方法。分离应用程序和领域模型确实允许我们将决策以正确的方式分离,但随后要使用观察方面适配器的小部件,我们需要创建一些新类。这通常被认为是工作量太大,因此我们可以通过允许应用程序模型直接访问小部件来简化这种事情,如 图 11 所示。

图 11:应用程序模型通过直接操作小部件来更新颜色。

直接更新小部件并不属于 演示模型,这就是为什么 Visual Works 应用程序模型不是真正的 演示模型。这种直接操作小部件的需要被许多人视为一种肮脏的变通方法,并促进了 Model-View-Presenter 方法的发展。

现在,让我们谈谈应用程序模型的要点

  • 遵循 MVC 使用 分离的演示观察者同步
  • 引入了一个中间应用程序模型作为演示逻辑和状态的归宿—— 演示模型 的部分发展。
  • 小部件不直接观察领域对象,而是观察应用程序模型。
  • 广泛使用属性对象来帮助连接各个层,并支持使用观察者的细粒度同步。
  • 应用程序模型操作小部件并不是默认行为,但在复杂情况下通常会这样做。

模型-视图-演示器 (MVP)

MVP 是一种架构,它首次出现在 IBM,并在 1990 年代在 Taligent 更加明显。它最常通过 Potel 论文引用。这个想法被 Dolphin Smalltalk 的开发者进一步推广和描述。正如我们将会看到的那样,这两个描述并不完全一致,但它背后的基本思想已经变得流行起来。

为了理解 MVP,我发现将两种 UI 思维方式之间的重大差异考虑在内很有帮助。一方面是 Forms and Controller 架构,它是 UI 设计的主流方法,另一方面是 MVC 及其衍生品。Forms and Controls 模型提供了一个易于理解的设计,并在可重用的小部件和应用程序特定代码之间建立了良好的分离。它所缺乏的是 MVC 所强力拥有的 分离的演示,以及使用 领域模型 进行编程的上下文。我认为 MVP 是朝着统一这些流派迈出的一步,试图从每个流派中汲取精华。

Potel 的第一个要素是将视图视为小部件的结构,这些小部件对应于 Forms and Controls 模型的控件,并删除任何视图/控制器分离。MVP 的视图是这些小部件的结构。它不包含任何描述小部件如何响应用户交互的行为。

对用户行为的主动反应存在于一个单独的演示者对象中。用户手势的基本处理程序仍然存在于小部件中,但这些处理程序只是将控制权传递给演示者。

然后,演示者决定如何响应事件。 Potel 主要通过命令和选择系统来讨论这种交互,它通过命令和选择系统对模型进行操作。这里值得强调的一点是,将对模型的所有编辑都打包在一个命令中——这为提供撤销/重做行为提供了良好的基础。

随着演示者更新模型,视图通过与 MVC 使用相同的 观察者同步 方法进行更新。

Dolphin 的描述类似。同样,主要相似之处在于演示者的存在。在 Dolphin 的描述中,没有演示者通过命令和选择对模型进行操作的结构。还明确讨论了演示者直接操作视图。Potel 没有谈论演示者是否应该这样做,但对于 Dolphin 来说,这种能力对于克服应用程序模型中那种让我难以对变化字段中的文本进行着色的缺陷至关重要。

关于 MVP 的一个变体是,演示者在多大程度上控制视图中的小部件。一方面,所有视图逻辑都保留在视图中,演示者不参与决定如何渲染模型。这种风格是 Potel 所暗示的。 Bower 和 McGlashan 背后的方向是我称之为 监督控制器 的东西,其中视图处理了大量可以用声明方式描述的视图逻辑,然后演示者介入处理更复杂的情况。

你也可以完全转向让演示者完成所有小部件的操控。这种风格,我称之为 被动视图,不是 MVP 的原始描述的一部分,而是随着人们探索可测试性问题而发展起来的。我将在后面讨论这种风格,但它也是 MVP 的一种变体。

在我对比 MVP 与之前讨论的内容之前,我应该提到,这里提到的两篇 MVP 论文也做了类似的事情,但解释方式略有不同。Potel 暗示 MVC 控制器是全局协调器,而我并不这么认为。Dolphin 谈论了很多 MVC 中的问题,但他们所说的 MVC 指的是 VisualWorks 应用程序模型设计,而不是我所描述的经典 MVC(我并不责怪他们,因为获取关于经典 MVC 的信息并不容易,现在也不容易,更不用说当时了)。

所以现在是时候进行一些对比了。

  • 表单和控件:MVP 有一个模型,演示者应该使用 观察者同步 来操作这个模型,然后更新视图。虽然允许直接访问小部件,但这应该作为使用模型的补充,而不是首选。
  • MVC:MVP 使用 监督控制器 来操作模型。小部件将用户手势传递给 监督控制器。小部件没有被分成视图和控制器。你可以将演示者看作是控制器,但没有初始处理用户手势。然而,重要的是要注意,演示者通常位于表单级别,而不是小部件级别,这可能是一个更大的区别。
  • 应用程序模型:视图将事件传递给演示者,就像它们传递给应用程序模型一样。但是视图可以直接从领域模型更新自身,演示者不充当 演示模型。此外,演示者可以自由地直接访问小部件,以执行不适合 观察者同步 的行为。

MVP 演示者和 MVC 控制器之间存在明显的相似之处,演示者是 MVC 控制器的松散形式。因此,许多设计将遵循 MVP 风格,但使用“控制器”作为演示者的同义词。当我们谈论处理用户输入时,使用控制器通常是有道理的。

图 12:MVP 中实际读取更新的时序图。

让我们看一下 MVP(监督控制器)版本的冰淇淋监控器( 图 12)。它与表单和控件版本开始的方式非常相似——当文本字段的文本发生变化时,它会触发一个事件,演示者监听这个事件并获取字段的新值。此时,演示者更新读取领域对象,而方差字段会观察到这个对象并使用它来更新自己的文本。最后一步是设置方差字段的颜色,这是由演示者完成的。它从读取中获取类别,然后更新方差字段的颜色。

以下是 MVP 的要点:

  • 用户手势由小部件传递给 监督控制器
  • 演示者协调领域模型的变化。
  • MVP 的不同变体以不同的方式处理视图更新。这些变体从使用 观察者同步 到让演示者完成所有更新,中间还有很多方法。

谦虚视图

在过去的几年里,编写自测试代码已经成为一种时尚。尽管我是最后一个谈论时尚感的人,但这却是我完全沉浸其中的一个运动。我的许多同事都是 xUnit 框架、自动化回归测试、测试驱动开发、持续集成以及类似流行语的忠实粉丝。

当人们谈论自测试代码时,用户界面很快就会成为一个问题。许多人发现测试 GUI 介于困难和不可能之间。这主要是因为 UI 与整个 UI 环境紧密耦合,很难分离出来并逐块测试。

有时这种测试难度被夸大了。通过创建小部件并在测试代码中操作它们,你通常可以取得惊人的进展。但有时这不可能,你会错过重要的交互,存在线程问题,而且测试速度太慢,无法运行。

因此,一直有一种趋势,即以一种最大限度地减少难以测试的对象中的行为的方式设计 UI。Michael Feathers 在 谦逊的对话框 中简洁地总结了这种方法。 Gerard Meszaros 将这个概念推广到谦逊对象的概念——任何难以测试的对象都应该具有最小的行为。这样,如果我们无法将它包含在测试套件中,我们就可以最大限度地减少未检测到故障的可能性。

谦逊的对话框 论文使用了一个演示者,但它比原始的 MVP 更深入。演示者不仅决定如何对用户事件做出反应,它还处理 UI 小部件本身的数据填充。因此,小部件不再拥有,也不需要对模型的可见性;它们形成了一个由演示者操控的 被动视图

这不是使 UI 谦逊的唯一方法。另一种方法是使用 演示模型,尽管这样你确实需要在小部件中添加更多行为,足以让小部件知道如何将自己映射到 演示模型

这两种方法的关键是,通过测试演示者或测试演示模型,你可以在不触碰难以测试的小部件的情况下测试 UI 的大部分风险。

使用 演示模型,你可以通过让所有实际决策由 演示模型 完成来实现这一点。所有用户事件和显示逻辑都路由到 演示模型,因此所有小部件需要做的就是将自己映射到 演示模型 的属性。然后,你可以在没有任何小部件存在的情况下测试 演示模型 的大部分行为——剩下的风险只存在于小部件映射中。只要这个映射很简单,你就可以忍受不测试它。在这种情况下,屏幕并不像使用 被动视图 方法那样谦逊,但差别很小。

由于 被动视图 使小部件完全谦逊,甚至没有映射存在,因此 被动视图 消除了 演示模型 中存在的微小风险。然而,代价是你需要一个 测试替身 来在测试运行期间模拟屏幕——这是你需要构建的额外机制。

监督控制器 也存在类似的权衡。让视图进行简单的映射会引入一些风险,但好处是(与 演示模型 一样)能够以声明方式指定简单的映射。 监督控制器 的映射往往比 演示模型 的映射更小,因为即使是复杂的更新也将由 演示模型 确定并映射,而 监督控制器 将在没有映射的情况下操作小部件以处理复杂情况。


进一步阅读

有关进一步发展这些想法的最新文章,请查看 我的博客

致谢

Vassili Bykov 慷慨地让我获得了他 Hobbes 的副本——他实现的 Smalltalk-80 版本 2(来自 20 世纪 80 年代初),它在现代 VisualWorks 中运行。这为我提供了一个模型-视图-控制器的实时示例,这在回答有关它如何工作以及它如何在默认图像中使用的详细问题方面非常有帮助。在那些日子里,许多人认为使用虚拟机是不切实际的。我想知道我们以前的自我会怎么想,看到我在 Windows XP 上运行的 VisualWorks 虚拟机中运行的 VisualWorks 虚拟机中运行的 Smalltalk 80,而 Windows XP 则在 Ubuntu 上运行的 VMware 虚拟机中运行。

重大修订

2006 年 7 月 18 日:首次在开发网站上发布。