构建 MVP 应用:MVP 第一部分

Chris Ramsdale,Google 开发者关系

更新于 2010 年 3 月

另请参阅本文的第二部分

构建任何大型应用程序都会遇到障碍,GWT 应用程序也不例外。多个开发人员同时处理同一个代码库,同时维护旧功能,很快就会变成乱码。为了帮助整理代码,我们引入了设计模式,以便在项目中创建责任划分的独立区域。

有很多设计模式可供选择:Presentation-abstraction-control、Model-view-controller、Model-view-presenter 等等。虽然每种模式都有自己的优点,但我们发现 Model-view-presenter (MVP) 架构在开发 GWT 应用程序时效果最好,主要有两个原因。首先,MVP 模型与其他设计模式类似,它以一种允许多个开发人员同时工作的方式解耦了开发。其次,这种模型使我们能够最大限度地减少对 GWTTestCase 的使用,GWTTestCase 依赖于浏览器的存在,对于我们的大部分代码,我们可以编写轻量级(且快速)的 JRE 测试(不需要浏览器)。

该模式的核心是将功能分解成逻辑上合理的组件,但在 GWT 的情况下,明显侧重于使 视图 尽可能简单,以最大程度地减少我们对 GWTTestCase 的依赖,并减少运行测试的总时间。

一旦您理解了这种设计模式的基本原理,构建基于 MVP 的应用程序就会变得简单易行。为了帮助解释这些概念,我们将使用一个简单的联系人应用程序作为示例。该应用程序将允许用户查看、编辑和将联系人添加到存储在服务器上的联系人列表中。

首先,我们将应用程序分解成以下组件

然后,我们将通过深入了解以下内容,来了解这些组件如何相互交互

示例项目

本教程中提到的示例项目可在 Tutorial-Contacts.zip 中找到。

diagram

模型

模型包含业务对象,在我们的联系人应用程序中,我们有

  • Contact:联系人列表中联系人的表示。为了简单起见,该对象包含姓氏、名字和电子邮件地址。在更复杂的应用程序中,该对象将包含更多字段。
  • ContactDetails:Contact 的简化版本,仅包含唯一标识符和显示名称。Contact 对象的这种“简化”版本将使联系人列表检索更高效,因为序列化和传输到网络的数据量将减少。就我们的示例应用程序而言,这种优化带来的影响小于在更复杂的应用程序中的影响,在更复杂的应用程序中,Contact 对象包含更多字段。初始 RPC 将返回 ContactDetails 列表,我们添加了显示名称字段,以便能够在不进行后续 RPC 的情况下显示一定量的数据(在 ContactsView 中)。

视图

视图包含构成应用程序的所有 UI 组件。这包括任何表格、标签、按钮、文本框等等。视图负责 UI 组件的布局,并且不了解模型。也就是说,视图不知道它正在显示一个 Contact,它只知道它有,例如,3 个标签、3 个文本框和 2 个按钮,这些按钮以垂直方式组织。视图之间的切换与 演示 层中的 历史记录管理 相关联。

我们的联系人应用程序中的视图是

  • ContactsView
  • EditContactView

EditContactView 用于添加新联系人以及编辑现有联系人。

演示者

演示者包含联系人应用程序的所有逻辑,包括 历史记录管理、视图转换和通过 RPC 与服务器同步数据。作为一般规则,对于每个视图,您都希望有一个演示者来驱动视图并处理来自视图中 UI 小部件的 事件

对于我们的示例,我们有以下演示者

  • ContactsPresenter
  • EditContactPresenter

与视图类似,EditContactPresenter 添加新联系人以及编辑现有联系人。

AppController

为了处理不特定于任何演示者而在应用程序层驻留的逻辑,我们将引入 AppController 组件。此组件包含 历史记录管理 和视图转换逻辑。视图转换直接与历史记录管理相关联,并在下面更详细地讨论。

此时,我们示例项目的整体层次结构应该如下所示

screenshot

在组件结构就位后,在我们开始连接它们之前,我们需要看一下启动所有内容的过程。一般流程如下所示代码所示

  1. GWT 的引导过程调用 onModuleLoad()
  2. onModuleLoad() 创建 RPC 服务、事件总线和 AppController
  3. AppController 被传递 RootPanel 实例,并接管控制权
  4. 从那时起,AppController 负责创建特定的 演示者 并提供 演示者 将驱动的 视图
public class Contacts implements EntryPoint {

  public void onModuleLoad() {
    ContactsServiceAsync rpcService = GWT.create(ContactsService.class);
    EventBus eventBus = new SimpleEventBus();
    AppController appViewer = new AppController(rpcService, eventBus);
    appViewer.go(RootPanel.get());
  }
}

绑定演示者和视图

为了将 演示者 与关联的 视图 绑定在一起,我们将依赖于在演示者中定义的 Display 接口。以 ContactsView 为例

screenshot

此视图有 3 个小部件:一个表格和两个按钮。为了使应用程序执行有意义的操作,演示者 需要

  • 响应按钮点击
  • 填充列表
  • 响应用户点击列表中的联系人
  • 查询视图以获取选定的联系人

对于 ContactsPresenter,我们将 Display 接口定义为如下所示


public class ContactsPresenter implements Presenter { ... public interface Display extends HasValue<List<String>> { HasClickHandlers getAddButton(); HasClickHandlers getDeleteButton(); HasClickHandlers getList(); void setData(List<String> data); int getClickedRow(ClickEvent event); List<Integer> getSelectedRows(); Widget asWidget(); } }

虽然 ContactsView 使用按钮和 FlexTable 实现上述接口,但 ContactsPresenter 并不知道。此外,如果我们想在移动浏览器中运行该应用程序,我们可以切换视图,而无需更改任何周围的应用程序代码。坦率地说,使用 getClickedRow() 和 getSelectedRows() 等方法,演示者假设视图将以列表的形式显示数据。也就是说,它处于足够高的级别,视图仍然能够切换列表的具体实现,而不会产生任何副作用。setData() 方法是一种简单的方法,可以将 模型 数据放入 视图 中,而视图本身不了解模型。显示的数据直接与模型的复杂性相关联。更复杂的模型可能会导致在视图中显示更多数据。使用 setData() 的好处是可以更改模型而无需更新视图代码。

为了向您展示它是如何工作的,让我们看一下从服务器接收联系人列表后执行的代码

public class ContactsPresenter implements Presenter {
  ...
  private void fetchContactDetails() {
    rpcService.getContactDetails(new AsyncCallback<ArrayList<ContactDetails>>() {
      public void onSuccess(ArrayList<ContactDetails> result) {
          contacts = result;
          List<String> data = new ArrayList<String>();

          for (int i = 0; i < result.size(); ++i) {
            data.add(contacts.get(i).getDisplayName());
          }

          display.setData(data);
      }

      public void onFailure(Throwable caught) {
        ...
      }
    });
  }
}

为了监听 UI 事件,我们有以下代码


public class ContactsPresenter implements Presenter { ... public void bind() { display.getAddButton().addClickHandler(new ClickHandler() { public void onClick(ClickEvent event) { eventBus.fireEvent(new AddContactEvent()); } }); display.getDeleteButton().addClickHandler(new ClickHandler() { public void onClick(ClickEvent event) { deleteSelectedContacts(); } }); display.getList().addClickHandler(new ClickHandler() { public void onClick(ClickEvent event) { int selectedRow = display.getClickedRow(event); if (selectedRow >= 0) { String id = contacts.get(selectedRow).getId(); eventBus.fireEvent(new EditContactEvent(id)); } } }); } }

为了响应 UI 事件,例如用户删除一组选定的联系人,我们有以下代码

public class ContactsPresenter implements Presenter {
  ...
  private void deleteSelectedContacts() {
    List<Integer> selectedRows = display.getSelectedRows();
    ArrayList<String> ids = new ArrayList<String>();

    for (int i = 0; i < selectedRows.size(); ++i) {
      ids.add(contactDetails.get(selectedRows.get(i)).getId());
    }

    rpcService.deleteContacts(ids, new AsyncCallback<ArrayList<ContactDetails>>() {
      public void onSuccess(ArrayList<ContactDetails> result) {
        contactDetails = result;
        List<String> data = new ArrayList<String>();

        for (int i = 0; i < result.size(); ++i) {
          data.add(contactDetails.get(i).getDisplayName());
        }

        display.setData(data);
      }

      public void onFailure(Throwable caught) {
        ...
      }
    });
  }
}

同样,为了充分利用 MVP 模型,演示者 不应了解任何基于小部件的代码。只要我们将视图包装在可以模拟的 Display 接口中,并且我们的 JRE 测试永远不会调用 asWidget(),一切都很完美。这就是您既能享用蛋糕又能吃蛋糕的方法:最大限度地减少 GWT 依赖性,以使非 GWTTestCase 变得有用,但仍然能够将 Display 实例放入面板中。

事件和事件总线

一旦您拥有 演示者 接收由 视图 中的小部件引发的事件,您可能希望对这些事件采取一些操作。为此,您需要依赖于构建在 GWT 的 EventBus 之上的事件总线,例如 SimpleEventBus。事件总线是一种机制,用于 a) 传递事件和 b) 注册以接收这些事件的某个子集的通知。

重要的是要记住,并非所有事件都应该放在事件总线上。盲目地将应用程序中所有可能的事件都放在事件总线上会导致消息繁多的应用程序,这些应用程序会陷入事件处理的泥潭。更不用说,您会发现自己编写了大量的样板代码来定义、源、接收和处理这些事件。

应用程序范围的事件实际上是您希望在事件总线上传递的唯一事件。应用程序对诸如“用户点击了回车键”或“即将进行 RPC 调用”之类的事件不感兴趣。相反(至少在我们示例应用程序中),我们传递诸如联系人生成更新、用户切换到编辑视图或从服务器成功返回的删除用户的 RPC 之类的事件。

以下是我们定义的事件列表。

  • AddContactEvent
  • ContactDeletedEvent
  • ContactUpdatedEvent
  • EditContactCancelledEvent
  • EditContactEvent

这些事件中的每一个都会扩展 Event 并覆盖 dispatch() 和 getAssociatedType()。方法 dispatch() 接受一个类型为 EventHandler 的参数,对于我们的应用程序,我们为每个事件定义了处理程序接口。

  • AddContactEventHandler
  • ContactDeletedEventHandler
  • ContactUpdatedEventHandler
  • EditContactCancelledEventHandler
  • EditContactEventHandler

为了演示这些部分如何协同工作,让我们看一下用户选择编辑联系人生成时发生的情况。首先,我们需要 AppController 注册 EditContactEvent。为此,我们调用 EventBus.addHandler() 并传入 Event.Type 以及事件触发时应调用的处理程序。下面的代码展示了 AppController 如何注册以接收 EditContactEvents。

public class AppController implements ValueChangeHandler {
  ...
  eventBus.addHandler(EditContactEvent.TYPE,
      new EditContactEventHandler() {
        public void onEditContact(EditContactEvent event) {
          doEditContact(event.getId());
        }
      });
  ...
}

在这里,AppController 具有 EventBus 的实例,称为 eventBus,并且正在注册一个新的 EditContactEventHandler。此处理程序将获取要编辑的联系人生成的 ID,并在触发 EditContactEvent.getAssociatedType() 的事件时将其传递给 doEditContact() 方法。多个组件可以监听单个事件,因此,当使用 EventBus.fireEvent() 触发事件时,EventBus 会查找已为 event.getAssociatedType() 添加处理程序的任何组件。对于具有处理程序的每个组件,EventBus 都会使用该组件的 EventHandler 接口调用 event.dispatch()。

要查看事件是如何触发的,让我们看一下引发 EditContactEvent 的代码。如上所述,我们已将自己添加为 ListContactView 列表上的点击处理程序。现在,当用户点击联系人生成列表时,我们将通过使用初始化了要编辑的联系人生成 ID 的 EditContactEvent() 类调用 EventBus.fireEvent() 方法来通知应用程序的其余部分。


public class ContactsPresenter { ... display.getList().addClickHandler(new ClickHandler() { public void onClick(ClickEvent event) { int selectedRow = display.getClickedRow(event); if (selectedRow >= 0) { String id = contactDetails.get(selectedRow).getId(); eventBus.fireEvent(new EditContactEvent(id)); } } }); ... }

视图转换是一种主动的事件流,其中事件源让应用程序的其余部分知道,“嘿,视图转换即将发生,因此如果您有一些最后的清理工作要做,建议您现在就做。”对于 RPC,情况略有不同。当 RPC 返回时,而不是在 RPC 发生之前,事件就会触发。原因是应用程序实际上只关心某些状态是否已更改(联系人生成值已更改、联系人生成已删除等……),这种情况发生在 RPC 返回之后。

以下是成功更新联系人生成后触发的事件示例。

public class EditContactPresenter {
  ...
  private void doSave() {
    contact.setFirstName(display.getFirstName().getValue());
    contact.setLastName(display.getLastName().getValue());
    contact.setEmailAddress(display.getEmailAddress().getValue());

    rpcService.updateContact(contact, new AsyncCallback<Contact>() {
        public void onSuccess(Contact result) {
          eventBus.fireEvent(new ContactUpdatedEvent(result));
        }
        public void onFailure(Throwable caught) {
           ... 
        }
    });
  }
  ...
}

历史记录和视图转换

任何 Web 应用程序中更重要的部分之一是处理 历史记录 事件。历史记录事件是表示应用程序中某些新状态的标记字符串。可以将它们视为应用程序中您所在位置的“标记”。例如,用户从“联系人生成列表”视图导航到“添加联系人生成”视图,然后点击“后退”按钮。结果操作应该将用户带回到“联系人生成列表”视图,为此,您将初始状态(“联系人生成列表”视图)推入历史记录堆栈,然后推送“添加联系人生成”视图。因此,当他们点击后退按钮时,“添加联系人生成”标记将从堆栈中弹出,当前历史记录标记将是“联系人生成列表”视图。

现在我们已经了解了流程,我们需要确定将代码放在哪里。鉴于历史记录不特定于特定视图,因此将其添加到 AppController 类中是有意义的。

首先,我们需要让 AppController 实现 ValueChangeHandler 并声明自己的 onValueChange() 方法。接口和参数的类型为 String,因为历史记录事件只是推入堆栈的标记。


public class AppController implements ValueChangeHandler<String> { ... public void onValueChange(ValueChangeEvent<String> event) { String token = event.getValue(); ... } }

接下来,我们需要注册以接收历史记录事件,就像我们注册从事件总线上接收的事件一样。

public class AppController implements ValueChangeHandler<String> {
  ...
  private void bind() {
    History.addValueChangeHandler(this);
    ...
  }
}

在上面的示例中,用户从“联系人生成列表”视图导航到“添加联系人生成”视图,我们提到了设置初始状态。这一点很重要,因为它不仅为我们提供了一些起点,而且也是检查现有历史记录标记(例如,如果用户将应用程序中的特定状态添加为书签)并将用户路由到相应视图的代码片段。AppController 的 go() 方法(在所有内容都连接好之后调用)是我们添加此逻辑的地方。


public class AppController implements ValueChangeHandler<String> { ... public void go(final HasWidgets container) { this.container = container; if ("".equals(History.getToken())) { History.newItem("list"); } else { History.fireCurrentHistoryState(); } } }

有了上述管道,我们需要在每次用户点击“后退”或“前进”按钮时调用的 onValueChange() 方法中执行有意义的操作。使用事件的 getValue(),我们将确定接下来显示哪个视图。

public class AppController implements ValueChangeHandler<String> {
  ...
  public void onValueChange(ValueChangeEvent<String> event) {
    String token = event.getValue();

    if (token != null) {
      Presenter presenter = null;

      if (token.equals("list")) {
        presenter = new ContactsPresenter(rpcService, eventBus, new ContactView());
      }
      else if (token.equals("add")) {
        presenter = new EditContactPresenter(rpcService, eventBus, new EditContactView());
      }
      else if (token.equals("edit")) {
        presenter = new EditContactPresenter(rpcService, eventBus, new EditContactView());
      }

      if (presenter != null) {
        presenter.go(container);
      }
    }
}

现在,当用户从“添加联系人生成”视图点击后退按钮时,GWT 的历史记录机制将使用先前历史记录标记调用 onValueChange() 方法。在我们的示例中,之前的视图是“联系人生成列表”视图,先前的历史记录标记(在 go() 方法中设置)是“list”。

以这种方式处理历史记录事件并不限于“后退”和“前进”处理,它们可以用于所有视图转换。回到 AppController 的 go() 方法,您会注意到,如果当前历史记录标记不为空,我们会调用 fireCurrentHistoryState()。因此,如果用户指定 http://myapp.com/contacts.html#add,则初始历史记录标记将是“add”,fireCurrentHistoryState() 将依次使用此标记调用 onValueChange()。这并不限于仅使用应用程序设置初始视图;其他导致视图转换的用户交互可以调用 History.newItem(),这将把新的历史记录标记推入堆栈,进而触发对 onValueChange() 的调用。

以下是将 ContactsPresenter 连接到“添加联系人生成”按钮、在收到点击事件后触发相关事件以及作为结果转换为“添加联系人生成”视图的示例。


public class ContactsPresenter implements Presenter { ... public void bind() { display.getAddButton().addClickHandler(new ClickHandler() { public void onClick(ClickEvent event) { eventBus.fireEvent(new AddContactEvent()); } }); } }
public class AppController implements ValueChangeHandler<String> {
  ...
  private void bind() {
    ...
    eventBus.addHandler(AddContactEvent.TYPE,
      new AddContactEventHandler() {
        public void onAddContact(AddContactEvent event) {
          doAddNewContact();
        }
    });
  }

  private void doAddNewContact() {
    History.newItem("add");
  }
}

由于视图转换逻辑已内置到 onValueChange() 方法中,因此这提供了一种集中式、可重复使用的在应用程序中导航的方式。

测试

MVP 模型消除了对 GWT 应用程序进行单元测试的痛苦。这并不是说您不能在不使用 MVP 模型的情况下编写单元测试。实际上您可以,但它们通常会比平均 JRE 基于的 JUnit 测试慢。为什么?简而言之,不使用 MVP 模型的应用程序将需要测试依赖于 DOM 存在的组件、Javascript 引擎等的测试用例……本质上,这些测试用例需要在浏览器中运行。

GWT 的 GWTTestCase 使这成为可能,因为它将启动一个“无头”浏览器来运行每个测试。浏览器的启动以及测试用例的实际执行是这些测试通常比标准 JRE 测试花费更长时间的原因。在 MVP 模型中,我们努力使视图(包含依赖于 DOM 和 Javascript 引擎的大部分代码的组件)尽可能小和简单。代码越少,需要测试的代码就越少,这意味着实际运行测试所花费的时间就越少。如果应用程序中的大部分代码都包含在演示者中,并且该演示者严格依赖于 JRE 基于的组件,则可以构建大多数测试用例作为高效的普通 JUnit 测试。

为了演示使用 MVP 模型来驱动 JRE 基于的单元测试(而不是基于 GWTTestCase 的单元测试)的好处,我们在 Contacts 应用程序中添加了以下测试。

screenshot

每个示例都设置为测试添加 ContactDetails 列表、对这些 ContactDetails 进行排序,然后验证排序后的列表是否正确。看一下 ExampleJRETest,我们有以下代码。


public class ExampleJRETest extends TestCase { private ContactsPresenter contactsPresenter; private ContactsServiceAsync mockRpcService; private EventBus eventBus; private ContactsPresenter.Display mockDisplay; protected void setUp() { mockRpcService = createStrictMock(ContactsServiceAsync.class); eventBus = new SimpleEventBus(); mockDisplay = createStrictMock(ContactsPresenter.Display.class); contactsPresenter = new ContactsPresenter(mockRpcService, eventBus, mockDisplay); } public void testContactSort(){ List<ContactDetails> contactDetails = new ArrayList<ContactDetails>(); contactDetails.add(new ContactDetails("0", "c_contact")); contactDetails.add(new ContactDetails("1", "b_contact")); contactDetails.add(new ContactDetails("2", "a_contact")); contactsPresenter.setContactDetails(contactDetails); contactsPresenter.sortContactDetails(); assertTrue(contactsPresenter.getContactDetail(0).getDisplayName().equals("a_contact")); assertTrue(contactsPresenter.getContactDetail(1).getDisplayName().equals("b_contact")); assertTrue(contactsPresenter.getContactDetail(2).getDisplayName().equals("c_contact")); } }

因为我们已将视图结构化为 Display 接口,所以我们能够将其模拟出来(在本示例中使用 EasyMock),消除了对访问浏览器资源(DOM、Javascript 引擎等……)的需要,并避免了必须将测试基于 GWTTestCase。

然后,我们使用 GWTTestCase 创建了相同的测试。

public class ExampleGWTTest extends GWTTestCase {
  private ContactsPresenter contactsPresenter;
  private ContactsServiceAsync rpcService;
  private EventBus eventBus;
  private ContactsPresenter.Display display;

  public String getModuleName() {
    return "com.google.gwt.sample.contacts.Contacts";
  }

  public void gwtSetUp() {
    rpcService = GWT.create(ContactsService.class);
    eventBus = new SimpleEventBus();
    display = new ContactsView();
    contactsPresenter = new ContactsPresenter(rpcService, eventBus, display);
  }

  public void testContactSort(){
    List<ContactDetails> contactDetails = new ArrayList<ContactDetails>();
    contactDetails.add(new ContactDetails("0", "c_contact"));
    contactDetails.add(new ContactDetails("1", "b_contact"));
    contactDetails.add(new ContactDetails("2", "a_contact"));
    contactsPresenter.setContactDetails(contactDetails);
    contactsPresenter.sortContactDetails();
    assertTrue(contactsPresenter.getContactDetail(0).getDisplayName().equals("a_contact"));
    assertTrue(contactsPresenter.getContactDetail(1).getDisplayName().equals("b_contact"));
    assertTrue(contactsPresenter.getContactDetail(2).getDisplayName().equals("c_contact"));
  }
}

鉴于我们的应用程序是使用 MVP 模型设计的,因此实际上没有理由以这种方式构建测试。但这并不是重点。重点是 ExampleGWTTest 运行需要 **15.23 秒**,而轻量级的 ExampleJRETest 运行需要 **0.01 秒**。如果您能够将应用程序逻辑与基于小部件的代码分离,那么您的单元测试将更加高效。想象一下,这些数字在每个构建中运行的数百个自动化测试中得到应用。

有关测试和 MVP 模型的更多信息,请查看文章 使用 GWT 的测试方法

后续主题

MVP 架构非常广泛;在以后的文章中,我们将讨论以下概念