界面设计常常是模式产生的根源,无论是架构模式,还是设计模式,比如 MVC 模式,Observer,Facade 等,也是整个软件行业向前发展的动力。遗憾的是,即使在软件技术发达的今天,界面设计仍是软件设计中的难以突破的瓶颈之一。我们用过 Java swing 或 Eclipse SWT 作过项目的都知道,要将界面进行分解是很困难的,它不像我们的业务逻辑,可以方便地按职责分解到不同的类中去实现,因为各个业务逻辑之间耦合度很低。但界面逻辑不一样,你不可能将一个文本框的读取操作委任到另一个类中去,而且各个界面元素之间相互依赖,无法去除耦合,一般的做法只能是在界面元素的事件触发 ( 比如按钮点击事件 ) 时,将输入数据封装成一个数据对象传给后台的逻辑处理类来处理。
Eclipse 的 Wizard 框架在界面分解上提供了一种很好的实践,它可以将按钮区和其他界面区分离出来,用类似 MVC 的方式实现了 Wizard 框架。但这个实现并非没有瑕疵,一个缺点是 wizard 是一个 plug-in,这样的话就减少了可重用性,不能移植到 eclipse 以外的环境。另一个缺点就是它引入了很大的复杂性,而且在一些对界面元素的控制上丧失了一些精细控制的能力,这可能是它过度地强调了自动化和用户扩展的方便性的缘故。比如,用户不能将自己的逻辑插入按钮区的按钮事件控制中,而只能在自定义区的界面元素 Listener 中设定按钮区状态,如果用户自定义的界面元素很多,就需要很多个 Listener 来组合判断一个按钮状态(如是否进行“下一步”),这样的话就很影响性能,而且无端地多了一堆复杂的逻辑判断,也就是说本来只需在按钮 Listener 事件中处理的逻辑现在要分散在各个界面元素的 Listener 中去处理。这也正是设计上一个值得反复强调的普遍问题:当你要保持架构或设计的完美性时必然会以丧失其他特性为代价。世上永远没有完美的东西,我们只关注适合我们的。
我下面要提出的这个架构模式的灵感来自于我的一个真实项目,一个用 RSA(Rational Software Architect)/Eclipse 建模的项目,在 RSA 环境中,读写模型都必须在一个特有的 context 下才能操作,这就意味着我在界面的启动之前必须封装好输入数据,关闭之后返回输出数据,而不是直接处理数据,必须对输入 / 输出数据对象进行封装。正如前面提到的,这种情况界面设计中很普遍。所以,在模式命名时我用了组装器 -assembler 这个词,有一层意思是输入 / 输出数据对象的组装,另一层意思就是界面部件(界面元素的集合)的组装,这里的组装还有更深层次的涵义就是指界面部件的可装配性,可以在运行时动态组装。而且这个模式可以用任何语言(Java,C++ 等)来实现。在这里我会从一个简单的设计模型开始,一步步走向一个完整的架构。借此也向大家展示一个架构设计的思维历程。本文中给出了 Eclipse SWT(Standard Widget Toolkit) 的示例。
在 Eclipse SWT 中,有几个重要的界面部件,一个是 Shell- 界面的最外层容器,类似 Java Swing 中的 Frame,另一个就是 Composite- 界面元素的集合的容器,类似 Java Swing 中的 Panel。我们的界面分解将从 Composite 开始,(Shell 本身是不需要分解的)。我们可以在 Shell 中装配上一个空的 Composite ,然后我们的具体界面元素都定义在这个 Composite 里。这样就把 Composite 逻辑从 Shell 中分离出来了,因此我们现在有了 2 个类 ( 目前我们用概念类来表示 ):
图 1. 把 Composite 逻辑从 Shell 中分离出来
Editor : 该类处理 Shell 的逻辑,如显示 -show,关闭 -close,它负责创建和销毁 EditorComposite。
EditorComposite: 该类处理 Composite 的界面逻辑,如创建界面元素。
有两点值得注意,第一,Editor 负责 EditorComposite 的创建和销毁,也就是生命周期的管理。那么我们可以想到,如果我们的界面需要 transaction- 事务或 session- 会话的管理,那么我们完全可以让 Editor 来负责这项职责,而不是分散在各个 EditorComposite 中。怎么扩展界面的事务功能可能会很复杂,这已经超出本文的讨论范围,我只是从架构的层面来分析可能有的可扩展性。第二,一个 Editor 可以包括多个 EditorComposite,比如我们的属性页,此时我们在 Shell 中定义的空的 Composite 将会是一个 TabFolder. 还有一种情况,就是我们可以根据某种逻辑来判断我们需要装配哪个 EditorComposite。这就要求我们有一个装配的行为。
当我们的装配逻辑很简单时,我们可以定义一个 assemble() 方法来负责装配行为。但是当我们的界面需要组装一系列 EditorComposite 时,就会牵涉到选择逻辑,选择逻辑不一定很复杂,但我们还是应该把这种行为从 Editor 中分离出来,这样 Editor 可以集中精力负责与用户交互方面的职责,而装配行为被分配到一个新的类 EditorAssembler 中,这样做还有一个好处,就是我们一旦有新的 EditorComposite 需要添加时,我们只需要改变 EditorAssembler 的代码,而不用修改 Editor 的代码,这就把变化隔离出来,对 Editor 的修改关闭,对装配行为的扩展开放。这正是面向对象设计领域反复强调的基本原则 - 开放 - 封闭原则 (Open-Close Principle)。经过重构后的架构如下图:
图 2. 重构后的架构
EditorAssembler:该类处理 EditorComposite 的创建,还包括多个 EditorComposite 的选择逻辑。
这里的选择逻辑我们可以用 if/else 或 switch/case 来硬编码,如果逻辑不是很复杂而且今后的修改不会太频繁的话,用这种方法就足够了,当然可以考虑将多个 EditorComposite 的装载信息专门用一个资源 / 信息类来存储,这在 EditorComposite 比较多的情况下很有效,这样每次添加 EditorComposite 就只需要改变这个资源类,这是一个很有用的建模原则(为了简化我们的核心模型,我在这里不将这个资源类表示出来)。
如果进一步考虑到我们的组装逻辑会比较复杂,或会比较容易改变,甚至在运行时动态改变,我们就可以将众多的 EditorComposite 和复杂的逻辑存储在一个元数据文件中,如 XML 或配置文件。这样,有新的 EditorComposite 需要支持,或修改装配逻辑时,不用修改 EditorAssembler 类,只要修改元数据文件即可。这样就可以很动态的配置我们的界面。这里会有一个架构权衡的问题,元数据由它的优点,也有它的缺点,其一,必须编写解析它的类,复杂性增加了,其二,不需要编译是它的优点也是它的缺点,对 XML 或配置文件我们可以随意修改,只有在运行时发现异常才知道改错了,而且也可能被人蓄意破坏掉。所以我们只在真的需要很频繁地修改 EditorComposite 的配置或经常需要增加 EditorComposite 时才采用元数据方案。在这里我倾向于采用资源类方案。
模型设计进行到这里,我们似乎缺少了对数据流的建模,在一个标准的界面程序中,我们首先会有一组输出数据,比如按”OK”按钮之后,我们需要将界面元素上的输入信息输出到后台逻辑类来处理或直接调用好几个逻辑类分别处理不同的界面元素输入信息了。我们一般习惯上可能直接将这个数据传递到逻辑类来处理。这样做三个缺点:其一,如果我们的数据读写处理要求必须在特定的 context 中才能进行,这样的话我们不能在界面中直接调用后台逻辑处理类了。其实这种限制并不罕见,在一些涉及底层(比如协议层)的开发时,经常会碰到只能读不能写的情况。其二,UI 的可替代性差,假如我们今后需要一种方案可以在运行时可以替换不同的 UI 但输出的数据是一样的,也就是说后台逻辑处理完全一致,那么这种情况我们就需要每一个 UI 自己去调用后台逻辑类,重复编码,而且可能由于程序员的失误每一个 UI 用了一个逻辑类,从而导致一个完全相同行为的类有了好几个不一致实现版本,这样不仅严重违反了面向对象设计,而且还可能产生难以预料的 bug,难以维护。其三,UI 的可重用性差,对于上面多个 UI 对应一种逻辑处理的例子,由于 UI 依赖了后台逻辑类,如果今后要修改逻辑类结构的话,我们就需要修改每一个 UI。如果我们还有一种需求是要支持一个 UI 在不同的环境下需要不同的后台逻辑类时,我们可能要专门在一个 UI 中设置一个属性来标识后台将要使用的逻辑类。这会很复杂。
解决上面几个缺点只有一种方法,就是将后台逻辑类与 UI 解耦。如果我们把要处理的输出数据打包成一个输出数据对象从界面统一输出,再由 UI 的调用者决定调用哪一个后台逻辑类来处理数据,而不是 UI 自己决定调用行为。
还有一个输入数据对象就很好理解了,我们调用 UI 时,可能某些界面元素需要的从环境中动态装载数据,比如一个下列列表,还有一些我们上一次配置好的数据这次需要更新,也需要将已有数据导入。所以我们需要一个输入数据对象。这就得到下面的模型:
图 3. 输入数据对象
InputDataObject:该类封装了输入数据。由 EditorComposite 负责解析这些数据。
OutputDataObject:该类封装了输出数据。由 EditorComposite 负责产生这些数据。
Editor 负责传输这两个数据对象。
从上面的模型我们可以看出 Editor 类其实相当于一个 Facade,所有的界面与用户的交互都由它负责集中调度管理,Editor 会将装配行为分配给 EditorAssembler 类来处理,它还负责临时存储输入输出数据,当然如果我们有类似 transaction 或 session 之类的处理会由 Editor 委派到别的相关类去处理。应用 Facade 设计模式,我们可以给 Editor 改个名字叫 EditorFacade,这样更能体现设计者的意图,千万不要忽视类的命名,设计是一门严肃的科学,每一个细节我们都不能苟且,对架构的设计更要严谨。命名可以起到沟通的作用,还能起到提醒的功能,EditorFacade 提醒我们以后要给它添加新的行为是记住它是一个 Facade,不能将不相干的职责分配进来。
另外,我发现添加了 InputDataObject 类后,EditorComposite 就有两个职责:装载界面元素初始化数据(一些需要从环境中动态获得的输入数据,从 InputDataObject 对象中获得)和显示上一次编辑的数据 ( 也从 InputDataObject 对象中获得 ),我们定义两个方法来分别处理:loadDataInfo()- 装载初始化数据;showPreInfo()- 显示上一次编辑的数据。当然,一般来说这两个方法是私有的 -private,因为这是 EditorComposite 自身的内部逻辑,但我们在这个架构中让它成为公有的 -public,是因为我们可以在 EditorAssembler 类中集中控制它的调用,而且每一个 EditorComposite 都会有装载初始化数据和显示已有数据的行为,那么为什么不抽象出来呢,以便让 EditorComposite 的开发提供者更清楚自己的职责,虽然这么做有点破坏 EditorComposite 的封装性和其中方法的私密性,但从架构的角度来讲这种破坏是合适的,值得的。
再看看前面的 EditorAssembler 类,它其实有两个职责,一个是创建 EditorComposite,还有一个就是从几个 EditorComposite 选择出一个的判断逻辑。如果我们把这两个不相干的职责解耦,应用 Factory 设计模式,就可以将创建 EditorComposite 的工作委任给一个 EditorCompositeFactory 的新类。
经过以上几项重构后得到以下概念类模型:
图 4. 概念类模型
经过上面的分析建模,我们可以开始实现架构了,从上面的概念模型我们可以很容易地抽象出相应的接口来。首先,我们看看 EditorFacade 类,基于我们上面的讨论,不同的界面可能有不同的需求,比如有的要支持 transaction- 事务,那么 EditorFacade 的实现就会不同,所以我们有必要提取出一个接口来表示,下面列出了这个接口 IEditorFacade:
清单 1:IEditorFacade.java
public interface IEditorFacade { public void show(); public IInputDataObject getInputData(); public void setInputData(IInputDataObject inputData); public IOutputDataObject getOutputData(); public void setOutputData(IOutputDataObject outputData); public boolean isFinishedOK(); public Composite getRootComposite(); public void setAssembler(IEditorAssembler assembler); public void close(boolean status); } |
那么 EditorFacade 类的部分代码如下:
清单 2:EditorFacade.java
public class EditorFacade implements IEditorFacade { private Shell shell; // validate if editor is closed with OK or Cancel private boolean finishedOK; // input data private IInputDataObject inputData; // output data private IOutputDataObject outputData; private Composite composite; private IEditorAssembler assembler; private void createSShell() { shell = new Shell(); shell.setLayout(new GridLayout()); createComponent(); } private void createComponent() { composite = new Composite(shell, SWT.NONE); ……… assembler.create(this); } public void show() { this.shell.open(); assembler.showPreInfo(); } public EditorFacade(IEditorAssembler assembler, IInputDataObject inputData) { this.assembler = assembler; this.inputData = inputData; this.createSShell(); } public Composite getRootComposite() { return composite; } public void close(boolean status) { finishedOK = status; this.shell.close(); } } |
下一步,我们将两个 IO 数据类定义出来,很显然,不同的界面会有不同的输入输出数据,在这里我们只能定义出两个抽象的接口 IInputDataObject 和 IOutputDataObject,它们继承了序列化 java.io.Serializable 接口,里面并无其它内容。这里注意一点,空的接口并非无意义,它可以起到标识的作用,另外,它隐藏了具体实现,在传递数据时传递者不用知道具体数据内容,这样传递者类具有更好的重用性,而且具体数据类也不用暴露给不该知道它的类 - 传递者类,这正是另一个面向对象的基本原则 - 迪米特法则(LoD):不要和陌生人说话。下面给出 IInputDataObject 的清单:
清单 3:IInputDataObject.java
public interface IInputDataObject extends Serializable { } |
接下来,我们看看 EditorAssembler 类的实现,根据前面的讨论,它封装了界面的装配逻辑,一定会被修改的,那么我们就需要一个接口 IEditorAssembler 来规范它的行为,在这里我还给出了一个抽象类 AbstractEditorAssembler,实现了装载单个 EditorComposite 的方法,另外我还给出了一个具体的 EditorAssembler 类,这是一个每次只装载一个 EditorComposite 的例子,代码清单如下:
清单 4:IEditorAssembler.java
public interface IEditorAssembler { /** * create editor body and init * @param editor */ public void create(IEditorFacade editor); /** * create editor composite * @param editor * @param compositeClassID * :composite class name,e.g. test.view.TestComposite * @return */ public IEditorComposite createComposite(IEditorFacade editor, String compositeClassID); /** * show exist info in UI for update. */ public void showPreInfo(); } |
清单 5:AbstractEditorAssembler.java
public abstract class AbstractEditorAssembler implements IEditorAssembler { public IEditorComposite createComposite(IEditorFacade editor, String compositeClassID) { IEditorComposite body; body = EditorCompositeFactory.createComposite(compositeClassID, editor); body.create(editor.getRootComposite()); body.setEditor(editor); return body; } ………………………………… . } |
清单 6:StandaloneEditorAssembler.java
public class StandaloneEditorAssembler extends AbstractEditorAssembler { private String compositeClassID; private IEditorComposite bodyComposite; /** * * @param compositeClassID * :composite class qulified name,e.g. com.ibm..XXComposite; */ public StandaloneEditorAssembler(String compositeClassID) { this.compositeClassID = compositeClassID; } public void create(IEditorFacade editor) { bodyComposite = createComposite(editor, compositeClassID); if (bodyComposite != null) bodyComposite.loadDataInfo(); } public void showPreInfo() { bodyComposite.showPreInfo(); } } |
接下来,是 EditorCompositeFactory 的实现,这个类的实现比较简单,只是根据类名产生类:
清单 7:EditorCompositeFactory.java
public class EditorCompositeFactory { /** * create IEditorComposite * @param clsName * @param editor * @return */ public static IEditorComposite createComposite(String clsName, IEditorFacade editor) { IEditorComposite composite = null; try { Class cls = Class.forName(clsName); if (cls != null) composite = (IEditorComposite) cls.newInstance(); } catch (Exception e) { e.printStackTrace(); } if (composite != null) { composite.setEditor(editor); } return composite; } } |
最后,就是 EditorComposite 的实现了,很显然每个界面的 EditorComposite 都不一样,所以我们在这里只定义了一个接口来规范一下行为,具体的 EditorComposite 实现我会在代码附件中的测试包中给出。
清单 8:IEditorComposite.java
public interface IEditorComposite { /** set up composite UI */ public void create(Composite parent); /** set the current editor for shell close and data set */ public void setEditor(IEditorFacade editor); /** show previous data information in UI */ public void showPreInfo(); public void loadDataInfo(); } |
下面,我们编写一些测试代码来测试它,这个测试应用是要编写一个电话簿,为了简单起见我只定义了一个 EditorComposite-PhoneBookComposite, 在编写组装逻辑时也只是示例性地改变了一下界面的标题和尺寸。(详细代码见 代码下载)
清单 9:PhoneBookEditorAssembler.java
public void create(IEditorFacade editor) { if (compositeType == 0) { //it is a phone book. bodyComposite = createComposite(editor, "test.PhoneBookComposite"); editor.getShell().setText("Phone Book"); editor.getShell().setSize(400, 300); editor.getShell().redraw(); if (bodyComposite != null) bodyComposite.loadDataInfo(); } else if (compositeType == 1) { //it is a memo book. bodyComposite = createComposite(editor, "test.PhoneBookComposite"); editor.getShell().setText("Memo Book"); editor.getShell().setSize(500, 300); editor.getShell().redraw(); if (bodyComposite != null) bodyComposite.loadDataInfo(); } } |
清单 10:Main.java
public static void main(String[] args) { // 定义 PhoneBook EditorAssembler。 IEditorAssembler assembler = new PhoneBookEditorAssembler(0); // 定义 PhoneBook 输入数据 IInputDataObject inputData = new PhoneBookInputDO("LYL", "010-8000100"); // 定义 PhoneBook editor EditorFacade editor = new EditorFacade(assembler, inputData); editor.show(); if (editor.isFinishedOK()) { // 取出 PhoneBook 输出数据。 if (editor.getOutputData() instanceof PhoneBookOutputDO) { PhoneBookOutputDO outputData = (PhoneBookOutputDO) editor .getOutputData(); String name = outputData.getName(); String phone = outputData.getPhone(); System.out.println("name:" + name + "; phone:" + phone); } } } |
接下来,我们可以看一下架构的实现模型,注意,我在画下面的 UML 图时采用了分层的方式,所有的接口都会在上面一层,实现在下面一层,这种分层画 UML 图的方法有助于我们理清架构的思路,也便于与开发组的其他成员沟通。
图 5. 架构的实现模型
至此,我们完成了界面组装器的核心架构的实现,注意,这只是一种实现,并不是界面组装模式的全部,作为一种模式,它必须有更广的外延,下面我们将要探讨它的模式本质。
这个模式是一种架构模式,模式的定义有三个要素:问题,环境,解决方案,这在前面我们已经详细地论述过了,在这里我们讨论一下其他的参量。每个模式都有它自己独特的价值观,那么界面组装器模式给我们提供了什么样的价值观呢?
首先,它的精髓在于这种分解界面,将界面和组装行为解耦的设计思想,这在拥有多个界面的应用中很有益处,当界面多的时候,如果没有一个比较集中的调度控制方式来对这些界面进行管理,就会形成界面行为无法规范,风格各异,更难以作 transaction 事务或 session 会话控制。这在小型应用开发中也许不很明显,但在一个大中型应用中对分散的不规范的界面行为进行控制将会是一场恶梦,到最后可能整个开发组都沉浸于 bug 的修复和界面修改中,而无暇顾及领域逻辑代码的编写。而通过将界面和组装行为解耦就可以让开发人员集中精力于界面逻辑和领域逻辑的开发,而不用每一个界面都去编写管理界面的代码。其实这也是模式化的一个优点,模式可以优化我们的架构,可以规范开发行为,因此也会节省开发成本。
其二,它将界面逻辑处理与领域逻辑处理(也就是数据逻辑处理)解耦。我们将数据输入输出从界面模型中抽取出来,没有与界面耦合在一起,这就获得巨大的好处,第一,我们可以在界面之外来处理数据,在我们的领域类中处理这些数据,也就是说界面只是提供了一个定义数据的载体,而这些数据是被领域逻辑类使用的,而我们开发的主要精力也应该放在处理业务逻辑的领域类上。第二,现在我们将界面和领域类解耦,这样我们的界面和领域类都可以独立地变化,相互之间没有任何依赖,这就很方便于我们开发人员的分工,编写界面的开发组不用依赖于编写后台逻辑类的开发组。第三,在做单元测试 -unit test 时,开发后台逻辑类的人员可以单独测试领域类,而开发界面的人员也可以单独测试界面逻辑。第四,当我们有多套界面机制时,我们的后台逻辑类可以很方便地接插上去,比如我们要支持 GUI(SWT/Java Swing)和 Web 方式,那么我们的领域类和数据类无需任何更改就可以方便的切换。第五,我们还能获得好处,就是数据类的可重用,如果我们没有输入输出数据类的封装行为,那可能我们会将各条数据散落在界面类中直接处理,这样当你要换一种界面机制时就必须重写这部分逻辑,无法重用。
作为一种模式,它会有很多的变体,也就是说它不拘泥于我们给出的这种外在实现方式,它还有其它的实现,例子中我们只是组装一个 EditorComposite,我们当然可以一次组装几个 EditorComposite,比如一个复杂的界面会有好几个 EditorComposite 组成,或者像属性页,并列着有好几个 EditorComposite,我们只需要自己实现一个组装器类 Assembler 就可以。又或者我们可以在运行界面时动态地在几个界面之间切换界面,这可能会复杂一些,也受限于平台或语言的技术实现,但也并非不可实现。
对于该模式的适用性,我想它主要适用于那些每次装载一个 EditorComposite 或属性页的情况,至于是否可以作为 Wizard 向导界面的实现架构,还需进一步探索,不过从这个模式的概念层次上来看,它的关键的价值观是完全可以用于实现 Wizard 向导界面的,只不过具体实现时可能会对现在的架构变动较大。另外这个模式主要适用于 GUI 客户端界面,对于 Web 形式的界面,已经有别的模式可以考虑。
我们还可以讨论一下界面组装器模式与别的模式之间的关系。在界面架构界我们已经有了大名鼎鼎的 MVC 模式,为什么还需要界面组装器模式呢?虽然 MVC 模式解决的也是界面与领域逻辑处理的解耦,但它的出发点主要是针对一个业务逻辑处理后会有好几个界面同时需要更新显示,也就是说它的贡献在于他的及时传播数据变更的能力,这和我们的模式是不一致的,我们主要解决界面的分解组装和数据剥离的问题,当然他们在结构上有些相似之处,我们的 EditorFacade 有点像 MVC 中的控制器。
本文所讲述的界面组装器模式为我们提供了将界面和组装行为解耦,将界面逻辑处理与领域逻辑处理解耦的价值观,在 GUI 胖客户端型界面中可以大量应用,笔者已经在几个大型项目中应用了它,所以它的可行性是经过实践检验的。当然,任何模式,不管是设计模式还是架构模式,都有它的适用性,只有合适的,没有绝对的优劣,我们是否应用模式是在于模式为我们提供的价值观是否和我们的需求期望符合,而不是因为别的原因。
- 《 Pattern-Oriented Software Architecture, Volume 1: A System of Patterns 》-《面向模式的软件体系结构 - 卷 1 模式系统》:FRANK BUSCHMANN,REGINE MEUNIER,HANS ROHNERT,PETER SOMMERLAD,MICHAEL STAL
- 《 Design Patterns:Elements of Reusable Object-Oriented software 》-《设计模式:可复用面向对象软件的基础》:ERICH GAMMA RICHARD HELM RALPH JOHNSON JOHN VLISSIDES
- 《 Agile Software Development-Principles, Patterns, and Practices 》-《敏捷软件开发 - 原则,模式与实践》:Robert C. Martin
描述 | 名字 | 大小 | 下载方法 |
---|---|---|---|
Source code | UIAssembler.jar | 26KB | HTTP |
刘岳林,IBM 中国软件实验室(CSDL BJ)成员,在 OOAD, RUP, XP, Architecture/Design Pattern 方面有着丰富的项目实践经验,对架构设计,项目、过程管理有过深入的研究,技术方向为 J2EE, SOA, Grid, AOP ,PKI。你可以通过 [email protected] 或 [email protected] 联系他。