构建 MVP 应用程序:MVP 第二部分
Chris Ramsdale,谷歌开发者关系
更新于 2010 年 3 月
本教程中提到的示例项目可从以下地址下载 Tutorial-Contacts2.zip.
在我们的基于 MVP 的应用程序的基础构建完毕后,你可能会问自己,“嘿,为什么我不能使用之前讨论过的那个 fancy UiBinder 功能?”答案是,你可以,只需要在视图和演示者中进行一些调整即可。除此之外,你可能会发现代码流程更加流畅,我们实施的技术在解决实现更 复杂的 UI、优化的 UI 和 代码拆分 的主题时,将非常契合。
使用 UiBinder 的方法
首先,让我们看一下构建主要 ContactList 视图的代码。之前我们在 ContactsView 构造函数中以编程方式设置了 UI。
public class ContactsView extends Composite implements ContactsPresenter.Display {
...
public ContactsView() {
DecoratorPanel contentTableDecorator = new DecoratorPanel();
initWidget(contentTableDecorator);
contentTableDecorator.setWidth("100%");
contentTableDecorator.setWidth("18em");
contentTable = new FlexTable();
contentTable.setWidth("100%");
contentTable.getCellFormatter().addStyleName(0, 0, "contacts-ListContainer");
contentTable.getCellFormatter().setWidth(0, 0, "100%");
contentTable.getFlexCellFormatter().setVerticalAlignment(0, 0, DockPanel.ALIGN_TOP);
// Create the menu
//
HorizontalPanel hPanel = new HorizontalPanel();
hPanel.setBorderWidth(0);
hPanel.setSpacing(0);
hPanel.setHorizontalAlignment(HorizontalPanel.ALIGN_LEFT);
addButton = new Button("Add");
hPanel.add(addButton);
deleteButton = new Button("Delete");
hPanel.add(deleteButton);
contentTable.getCellFormatter().addStyleName(0, 0, "contacts-ListMenu");
contentTable.setWidget(0, 0, hPanel);
// Create the contacts list
//
contactsTable = new FlexTable();
contactsTable.setCellSpacing(0);
contactsTable.setCellPadding(0);
contactsTable.setWidth("100%");
contactsTable.addStyleName("contacts-ListContents");
contactsTable.getColumnFormatter().setWidth(0, "15px");
contentTable.setWidget(1, 0, contactsTable);
contentTableDecorator.add(contentTable);
}
...
}
使用 UiBinder 的方法的第一步是将此代码移至 Contacts.ui.xml 文件中并执行相关的转换。如前几章所述,构建基于 UiBinder 的 UI 允许你以声明的方式执行此操作,这更像是 HTML 而不是纯粹的 Java 代码。因此,结果如下:
ContactsView.ui.xml
<ui:UiBinder
xmlns:ui="urn:ui:com.google.gwt.uibinder"
xmlns:g="urn:import:com.google.gwt.user.client.ui">
<ui:style>
.contactsViewButtonHPanel {
margin: 5px 0px 0x 5px;
}
.contactsViewContactsFlexTable {
margin: 5px 0px 5px 0px;
}
</ui:style>
<g:DecoratorPanel>
<g:VerticalPanel>
<g:HorizontalPanel addStyleNames="{style.contactsViewButtonHPanel}">
<g:Button ui:field="addButton">Add</g:Button>
<g:Button ui:field="deleteButton">Delete</g:Button>
</g:HorizontalPanel>
<g:FlexTable ui:field="contactsTable" addStyleNames="{style.contactsViewContactsFlexTable}"/>
</g:VerticalPanel>
</g:DecoratorPanel>
</ui:UiBinder>
在这里,我们布局了一个 VerticalPanel,它包装了我们的添加/删除按钮和包含联系人列表的 FlexTable。然后,这些内容被 DecoratorPanel 包裹,以提供一些样式。ContactsView 构造函数和成员随后简化为以下内容:
public class ContactsViewImpl<T> extends Composite implements ContactsView<T> {
...
@UiTemplate("ContactsView.ui.xml")
interface ContactsViewUiBinder extends UiBinder<Widget, ContactsViewImpl> {}
private static ContactsViewUiBinder uiBinder =
GWT.create(ContactsViewUiBinder.class);
@UiField FlexTable contactsTable;
@UiField Button addButton;
@UiField Button deleteButton;
public ContactsViewImpl() {
initWidget(uiBinder.createAndBindUi(this));
}
...
}
稍后在讨论“复杂的 UI - 哑视图”时,我们将了解为什么它是模板化的、为什么它是一个“impl”类以及为什么我们使用 UiTemplate 注解。目前,重要的是要注意,a) 我们将通过 UiField 注解访问底层小部件,以及 b) 构造函数代码明显更小。我的意思是,只有一行代码。UiBinder 在删除设置 UI 所需的样板代码方面做得很好,并允许你进一步将声明 UI 的代码与驱动 UI 的代码分离。
现在我们已经构建了 UI,我们需要连接相关的 UI 事件,即添加/删除按钮点击和与联系人列表 FlexTable 的交互。在这里,我们将开始注意到应用程序设计总体布局的重大变化。主要原因是我们希望通过 UiHandler 注解将视图中的方法链接到 UI 交互。第一个主要变化是我们希望 ContactsPresenter 实现一个 Presenter 接口,该接口允许 ContactsView 在收到点击、选择或其他事件时回调到演示者。Presenter 接口定义以下内容:
public interface Presenter<T> {
void onAddButtonClicked();
void onDeleteButtonClicked();
void onItemClicked(T clickedItem);
void onItemSelected(T selectedItem);
}
再次,接口模板化将在下一节中介绍,但有了这个接口,你就可以开始了解 ContactsView 将如何与 ContactsPresenter 通信。连接所有内容的第一部分是让 ContactsPresenter 实现 Presenter 接口,然后将其自身注册到底层视图。为了注册自身,我们需要 ContactsView 公开 setPresenter() 方法:
private Presenter<T> presenter;
public void setPresenter(Presenter<T> presenter) {
this.presenter = presenter;
}
现在我们可以看一下我们将如何通过 UiHandler 注解连接 ContactsView 中的 UI 交互:
public class ContactsViewImpl<T> extends Composite implements ContactsView<T> {
...
@UiHandler("addButton")
void onAddButtonClicked(ClickEvent event) {
if (presenter != null) {
presenter.onAddButtonClicked();
}
}
@UiHandler("deleteButton")
void onDeleteButtonClicked(ClickEvent event) {
if (presenter != null) {
presenter.onDeleteButtonClicked();
}
}
@UiHandler("contactsTable")
void onTableClicked(ClickEvent event) {
if (presenter != null) {
HTMLTable.Cell cell = contactsTable.getCellForEvent(event);
if (cell != null) {
if (shouldFireClickEvent(cell)) {
presenter.onItemClicked(rowData.get(cell.getRowIndex()));
}
if (shouldFireSelectEvent(cell)) {
presenter.onItemSelected(rowData.get(cell.getRowIndex()));
}
}
}
}
...
}
使用这种技术,我们为 UiBinder 生成器提供了相应的应该在小部件具有“ui:field”属性设置为“addButton”、“deleteButton”和“contactsTable”时调用的方法。在 ContactsPresenter 的一边,我们最终得到了以下内容:
public class ContactsPresenter implements Presenter {
...
public void onAddButtonClicked() {
eventBus.fireEvent(new AddContactEvent());
}
public void onDeleteButtonClicked() {
deleteSelectedContacts();
}
public void onItemClicked(ContactDetails contactDetails) {
eventBus.fireEvent(new EditContactEvent(contactDetails.getId()));
}
public void onItemSelected(ContactDetails contactDetails) {
if (selectionModel.isSelected(contactDetails)) {
selectionModel.removeSelection(contactDetails);
}
else {
selectionModel.addSelection(contactDetails);
}
}
...
}
结果方法实现与非 UiBinder 示例相同,除了 onItemClicked() 和 onItemSelected() 方法。虽然这些方法看起来很简单,但我们需要深入了解它们的由来,并解释这个“SelectionModel”到底是什么。下一节将对这些内容进行更详细的解释。
复杂的 UI - 哑视图
我们当前的解决方案是让演示者将数据模型的简化版本传递给我们的视图。在 ContactsView 的情况下,演示者获取 DTO(数据传输对象)列表,并构建一个字符串列表,然后将其传递给视图。
public ContactsPresenter implements Presenter {
...
public void onSuccess(ArrayList<ContactDetails> result) {
contactDetails = result;
sortContactDetails();
List<String> data = new ArrayList<String>();
for (int i = 0; i < result.size(); ++i) {
data.add(contactDetails.get(i).getDisplayName());
}
display.setData(data);
}
...
}
传递给视图的“data”对象是一个非常(我指的是非常)简单的 ViewModel——基本上是使用基元来表示更复杂的数据模型。对于简单的视图来说,这很好,但只要你开始做一些更复杂的事情,你就会很快意识到必须有所改变。要么演示者需要更多地了解视图(使其难以将视图替换为其他平台),要么视图需要更多地了解数据模型(最终让你的视图更智能,从而需要更多 GwtTestCases)。解决方案是使用泛型和一个第三方,该第三方抽象出对单元格数据类型的任何了解,以及如何呈现该数据类型。
首先,我们将依赖于这样一个事实,即数据类型通常在列边界内是同质的。这样做允许我们定义一个 ColumnDefinition 抽象类,该类包含任何类型特定的代码(这是上面提到的第三方)。
public abstract class ColumnDefinition<T> {
public abstract Widget render(T t);
public boolean isClickable() {
return false;
}
public boolean isSelectable() {
return false;
}
}
通过将这些类的列表串联起来,并提供必要的 render() 实现以及 isClickable()/isSelectable() 覆盖,你就可以开始了解我们将如何定义布局。让我们看一下如何在 Contacts 示例中使其生效。
public class ContactsViewColumnDefinitions<ContactDetails> {
List<ColumnDefinition<ContactDetails>> columnDefinitions =
new ArrayList<ColumnDefinition<ContactDetails>>();
private ContactsViewColumnDefinitions() {
columnDefinitions.add(new ColumnDefinition<ContactDetails>() {
public Widget render(ContactDetails c) {
return new CheckBox();
}
public boolean isSelectable() {
return true;
}
});
columnDefinitions.add(new ColumnDefinition<ContactDetails>() {
public Widget render(ContactDetails c) {
return new HTML(c.getDisplayName());
}
public boolean isClickable() {
return true;
}
});
}
public List<ColumnDefinition<ContactDetails>> getColumnDefnitions() {
return columnDefinitions;
}
}
这些 ColumnDefinition 将在演示者之外创建,以便我们无论将自身附加到哪个视图(无论是 iPhone、Android、桌面等视图),都可以重用其逻辑。这可以通过使用特定于平台的 ContactsViewColumnDefinitions 类来实现,该类在每个排列基础上加载(或使用 GIN 注入)。无论使用何种技术,我们都需要更新我们的视图,以便我们能够设置其 ColumnDefinition。
public class ContactsViewImpl<T> extends Composite implements ContactsView<T> {
...
private List<ColumnDefinition<T>> columnDefinitions;
public void setColumnDefinitions(
List<ColumnDefinition<T>> columnDefinitions) {
this.columnDefinitions = columnDefinitions;
}
...
}
注意,我们的 ContactsView 现在是 ContactsViewImpl
public class AppController implements Presenter, ValueChangeHandler<String> {
...
public void onValueChange(ValueChangeEvent<String> event) {
String token = event.getValue();
if (token != null) {
Presenter presenter = null;
if (token.equals("list")) {
// lazily initialize our views, and keep them around to be reused
//
if (contactsView == null) {
contactsView = new ContactsViewImpl<ContactDetails>();
if (contactsViewColumnDefinitions == null) {
contcactsViewColumnDefinitions = new ContactsViewColumnDefinitions().getColumnDefinitions();
}
contactsView.setColumnDefiniions(contactsViewColumnDefinitions);
}
}
presenter = new ContactsPresenter(rpcService, eventBus, contactsView);
}
...
}
...
}
有了 ColumnDefinition,我们将开始看到我们工作的成果。主要体现在我们将模型数据传递给视图的方式。如上所述,我们之前将模型简化为一个字符串列表。有了我们的 ColumnDefinition,我们可以原封不动地传递模型。
public class ContactsPresenter implements Presenter,
...
private void fetchContactDetails() {
rpcService.getContactDetails(new AsyncCallback<ArrayList<ContactDetails>>() {
public void onSuccess(ArrayList<ContactDetails> result) {
contactDetails = result;
sortContactDetails();
view.setRowData(contactDetails);
}
...
});
}
...
}
并且我们的 ContactsViewImpl 具有以下 setRowData() 实现:
public class ContactsViewImpl<T> extends Composite implements ContactsView<T> {
...
public void setRowData(List<T> rowData) {
contactsTable.removeAllRows();
this.rowData = rowData;
for (int i = 0; i < rowData.size(); ++i) {
T t = rowData.get(i);
for (int j = 0; j < columnDefinitions.size(); ++j) {
ColumnDefinition<T> columnDefinition = columnDefinitions.get(j);
contactsTable.setWidget(i, j, columnDefinition.render(t));
}
}
}
...
}
这是一个明显的改进;演示者可以原封不动地传递模型,而视图没有我们需要测试的任何呈现代码。而且乐趣并没有到此为止。还记得 isClickable() 和 isSelectable() 方法吗?好吧,让我们看看它们如何与视图中接收到的 ClickEvents 结合使用。
public class ContactsViewImpl<T> extends Composite implements ContactsView<T> {
...
@UiHandler("contactsTable")
void onTableClicked(ClickEvent event) {
if (presenter != null) {
HTMLTable.Cell cell = contactsTable.getCellForEvent(event);
if (cell != null) {
if (shouldFireClickEvent(cell)) {
presenter.onItemClicked(rowData.get(cell.getRowIndex()));
}
if (shouldFireSelectEvent(cell)) {
presenter.onItemSelected(rowData.get(cell.getRowIndex()));
}
}
}
}
private boolean shouldFireClickEvent(HTMLTable.Cell cell) {
boolean shouldFireClickEvent = false;
if (cell != null) {
ColumnDefinition<T> columnDefinition =
columnDefinitions.get(cell.getCellIndex());
if (columnDefinition != null) {
shouldFireClickEvent = columnDefinition.isClickable();
}
}
return shouldFireClickEvent;
}
private boolean shouldFireSelectEvent(HTMLTable.Cell cell) {
boolean shouldFireSelectEvent = false;
if (cell != null) {
ColumnDefinition<T> columnDefinition =
columnDefinitions.get(cell.getCellIndex());
if (columnDefinition != null) {
shouldFireSelectEvent = columnDefinition.isSelectable();
}
}
return shouldFireSelectEvent;
}
...
}
这里的想法是,你希望根据被点击的单元格类型以不同的方式响应用户交互。鉴于我们的 ColumnDefinition 与单元格类型交织在一起,我们不仅能够将它们用于呈现目的,还能够用于定义如何解释用户交互。
为了更进一步,我们将从 ContactsView 中删除任何应用程序状态。为此,我们将用演示者持有的 SelectionModel 替换视图的 getSelectedRows()。SelectionModel 只是对模型对象列表的包装器。
public class SelectionModel<T> {
List<T> selectedItems = new ArrayList<T>();
public List<T> getSelectedItems() {
return selectedItems;
}
public void addSelection(T item) {
selectedItems.add(item);
}
public void removeSelection(T item) {
selectedItems.remove(item);
}
public boolean isSelected(T item) {
return selectedItems.contains(item);
}
}
ContactsPresenter 持有一个此类的实例,并根据对 onItemSelected() 的调用相应地更新它。
public class ContactsPresenter implements Presenter,
...
public void onItemSelected(ContactDetails contactDetails) {
if (selectionModel.isSelected(contactDetails)) {
selectionModel.removeSelection(contactDetails);
}
else {
selectionModel.addSelection(contactDetails);
}
}
...
}
当它需要获取所选项目的列表时,例如当用户点击“删除”按钮时,它就可以直接获取到。
public class ContactsPresenter implements Presenter,
...
public void onDeleteButtonClicked() {
deleteSelectedContacts();
}
private void deleteSelectedContacts() {
List<ContactDetails> selectedContacts = selectionModel.getSelectedItems();
ArrayList<String> ids = new ArrayList<String>();
for (int i = 0; i < selectedContacts.size(); ++i) {
ids.add(selectedContacts.get(i).getId());
}
rpcService.deleteContacts(ids, new AsyncCallback<ArrayList<ContactDetails>>() {
public void onSuccess(ArrayList<ContactDetails> result) {
...
}
...
});
}
...
}
好吧,这需要消化相当多的内容,用代码片段描述它可能会导致一些人“迷失在翻译中”。如果是这样,请务必查看完整的源代码,该源代码可从 此处 获取。
优化的 UI - 哑视图
我们已经了解了如何创建复杂 UI 的基础,同时坚持我们的要求,即视图保持尽可能哑(并最小限度地可测试),但这并不是让我们停止的理由。虽然功能是解耦的,但仍有优化空间。让 ColumnDefinition 为每个单元格创建一个新的小部件过于繁重,并且随着应用程序的增长,可能会很快导致性能下降。导致这种情况的两个主要因素是:
与通过 DOM 操作插入新元素相关的低效率
每个小部件的沉没事件相关开销
为了克服这个问题,我们将更新我们的应用程序以执行以下操作(按顺序)
- 用一个 HTML 小部件替换我们的 FlexTable 实现,我们将通过调用 setHTML() 来填充它,有效地将所有 DOM 操作批处理到单个调用中。
- 通过将事件沉没到 HTML 小部件上而不是单个单元格上,来减少事件开销。
这些更改包含在我们的 ContactsView.ui.xml 文件以及我们的 setRowData() 和 onTableClicked() 方法中。首先,我们需要更新我们的 ContactsView.ui.xml 文件以使用 HTML 小部件而不是 FlexTable 小部件。
<ui:UiBinder>
...
<g:DecoratorPanel>
<g:VerticalPanel>
<g:HorizontalPanel addStyleNames="{style.contactsViewButtonHPanel}">
<g:Button ui:field="addButton">Add</g:Button>
<g:Button ui:field="deleteButton">Delete</g:Button>
</g:HorizontalPanel>
<g:HTML ui:field="contactsTable"></g:HTML>
</g:VerticalPanel>
</g:DecoratorPanel>
</ui:UiBinder>
我们还需要更改我们在 ContactsViewImpl 类中引用的 Widget。
public class ContactsViewImpl<T> extends Composite implements ContactsView<T> {
...
@UiField HTML contactsTable;
...
接下来,我们将对 setRowData() 方法进行必要的更改。
public class ContactsViewImpl<T> extends Composite implements ContactsView<T> {
...
public void setRowData(List<T> rowData) {
this.rowData = rowData;
TableElement table = Document.get().createTableElement();
TableSectionElement tbody = Document.get().createTBodyElement();
table.appendChild(tbody);
for (int i = 0; i < rowData.size(); ++i) {
TableRowElement row = tbody.insertRow(-1);
T t = rowData.get(i);
for (int j = 0; j < columnDefinitions.size(); ++j) {
TableCellElement cell = row.insertCell(-1);
StringBuilder sb = new StringBuilder();
columnDefinitions.get(j).render(t, sb);
cell.setInnerHTML(sb.toString());
// TODO: Really total hack! There's gotta be a better way...
Element child = cell.getFirstChildElement();
if (child != null) {
Event.sinkEvents(child, Event.ONFOCUS | Event.ONBLUR);
}
}
}
contactsTable.setHTML(table.getInnerHTML());
}
...
}
以上代码类似于我们原始的 setRowData() 方法,我们遍历 rowData 并为每个项目要求我们的列定义相应地渲染。主要区别在于:a)我们期望每个列定义将自己渲染到 StringBuilder 中,而不是传回一个完整的 Widget,以及 b)我们对 HTML Widget 调用 setHTML 而不是对 FlexTable 调用 setWidget。这将减少你的加载时间,特别是当你的表格开始变大时。
现在让我们看看用于将事件沉没到表格上的代码。
public class ContactsViewImpl<T> extends Composite implements ContactsView<T> {
...
@UiHandler("contactsTable")
void onTableClicked(ClickEvent event) {
if (presenter != null) {
EventTarget target = event.getNativeEvent().getEventTarget();
Node node = Node.as(target);
TableCellElement cell = findNearestParentCell(node);
if (cell == null) {
return;
}
TableRowElement tr = TableRowElement.as(cell.getParentElement());
int row = tr.getSectionRowIndex();
if (cell != null) {
if (shouldFireClickEvent(cell)) {
presenter.onItemClicked(rowData.get(row));
}
if (shouldFireSelectEvent(cell)) {
presenter.onItemSelected(rowData.get(row));
}
}
...
}
这里,我们的 onTableClicked() 代码变得更加复杂,但这与我们应用程序的其余部分相比并不算什么大问题。重申一下,我们正在减少在每个单元格 Widget 上沉没事件的开销,而是沉没在一个单个容器(我们的 HTML Widget)上。ClickEvents 仍然通过我们的 UiHandler 注释进行连接,但通过这种方法,我们将获得被点击的元素,并遍历 DOM 直到找到父 TableCellElement。从那里,我们可以确定行,从而确定相应的 rowData。
我们需要进行的另一个调整是更新我们的 shouldFireClickEvent() 和 shouldFireSelectEvent() 以接受 TableCellElement 而不是 HTMLTable.Cell 作为参数。实现保持不变,如下所示。
public class ContactsViewImpl<T> extends Composite implements ContactsView<T> {
...
private boolean shouldFireClickEvent(TableCellElement cell) {
boolean shouldFireClickEvent = false;
if (cell != null) {
ColumnDefinition<T> columnDefinition =
columnDefinitions.get(cell.getCellIndex());
if (columnDefinition != null) {
shouldFireClickEvent = columnDefinition.isClickable();
}
}
return shouldFireClickEvent;
}
private boolean shouldFireSelectEvent(TableCellElement cell) {
boolean shouldFireSelectEvent = false;
if (cell != null) {
ColumnDefinition<T> columnDefinition =
columnDefinitions.get(cell.getCellIndex());
if (columnDefinition != null) {
shouldFireSelectEvent = columnDefinition.isSelectable();
}
}
return shouldFireSelectEvent;
}
...
}
代码分割 - 只需相关部分即可
到目前为止,我们已经讨论了基于 MVP 的应用程序的代码可维护性和测试性优势。另一个可能被忽视的优势是通过代码分割实现更快的启动时间。我知道,你可能想知道 MVP 架构与代码分割到底有什么关系,但使你的应用程序更易于维护和测试的相同技术,也使它更容易使用 runAsync() 点进行分割。让我们先快速回顾一下。代码分割是指将应用程序的分割部分包装到“分割”点中的行为,方法是在 runAsync() 调用中声明它们。只要你的代码的分割部分是纯粹分割的,并且没有被应用程序的其他部分引用,它将在需要运行的时候下载并执行。
例如,我们在上一节中编写的代码。它很长,对吧?现在假设我们的应用程序有一个登录屏幕,并且像往常一样,一旦用户登录,他们就会被带到主应用程序屏幕(在本例中是他们的联系人列表)。我们真的要在用户登录之前下载所有这些代码吗?不太可能。如果我们只获取登录代码,并在实际需要的时候(例如用户登录后)再获取其余代码,那就太好了。我们可以做到,方法如下。
public void onValueChange(ValueChangeEvent<String> event) {
String token = event.getValue();
if (token != null) {
if (token.equals("list")) {
GWT.runAsync(new RunAsyncCallback() {
...
public void onSuccess() {
// lazily initialize our views, and keep them around to be reused
//
if (contactsView == null) {
contactsView = new ContactsViewImpl<ContactDetails>();
}
new ContactsPresenter(rpcService, eventBus, contactsView).go(container);
}
});
}
...
}
在这里,我们所做的只是将创建 ContactsView 和 ContactsPresenter 的代码包装在一个 runAsync() 调用中,因此它直到我们第一次去显示联系人列表时才会被下载。在那之后,后续调用会意识到代码已经被下载,并将使用它,而不是重新下载。
有点令人扫兴,不是吗?好吧,这并不总是件坏事。让基于 MVP 的应用程序运行所需的样板代码量通常很大。但所花费的时间并没有浪费,因为像这样的一种优化变得越来越容易实现。