Pull to refresh

Введение в MVP GWT 2.1

Google Web Toolkit *
Когда я писал топик об использовании шаблона Command для организации RPC-вызовов в GWT то упоминал об MVP-паттерне для построения архитектуры GWT-проектов. Сразу после выступления товарища Ray Rayan’а с докладом о проектировании сложных приложений на GWT трудящиеся по всему миру принялись реализовывать озвученные идеи в виде библиотек и фреймворков. Результатом этих трудов стали средства, позволяющие применять как некоторые моменты MVP-подхода (GWT-Presenter) так и его целиком (Mvp4G). Это все замечательно, но мне лично (уверен, что и остальным GWT-разработчикам) хотелось бы иметь стандартизированный (если можно так сказать) фреймворк/подход для организации GWT-приложений по MVP-схеме. И вот команда, которая отвечает в Google за GWT, наконец-то в версии 2.1 наряду с остальными вкусными плюшками предложила встроенный MVP-фреймворк.
В данной заметке я постараюсь осветить основные моменты встроенного GWT MVP-фреймворка. Как пример будет использовано небольшое приложение, построенное с применением этого подхода.
Данная заметка является вольным трактатом (GWT MVP Development) и на уникальность подхода никоим образом не претендует. Если кого-то заинтересовало такое немаленькое введение

Скажу сразу, что я не буду заострять особого внимания на самом шаблоне проектирования MVP. Ознакомиться с его схемой можно, к примеру на (MVP на Wiki). Для того, чтобы заинтересовать %username% приведу скриншот приложения, которое у меня в итоге получилось.
Demo Project
Да, я понимаю, что приложение ничего полезного не делает, но на его основе постараюсь показать, как разделить архитектуру на отдельные независимые части (mail, contacts и tasks, кстати, взятые просто так, с потолка), организовать переключение и связь между ними с помощью встроенных в GWT 2.1 механизмов. Помимо этого не будет рассмотрена M-составляющая MVP-паттерна, т.е. модель, поскольку в приложении в иллюстративных целях нет никакой привязки к данным.

Основные составляющие встроенного MVP


Команда GWT предложила следующие ключевые составляющие для построения приложения с использованием MVP:
  • Activity — в классическом подходе это Presenter. Отвечает за логику открытого в данный момент вида. Не содержит в себе никаких GWT-виджетов или связанного с UI кода. Но в свою очередь имеет связанный объект вида (view). Запускается и останавливается автоматически с помощью ActivityManager-а
  • ActivityManager — встроенный объект, который управляет жизненным циклом Activities, которые в нем зарегистрированы
  • Place — отвечает за состояние текущего вида. В основном состояние вида передается с использование URL-ов (например, открыть контакт с ID=<таким-то> на редактирование) или по другому, history-токенов. Благодаря объекту PlaceHistoryHandler, который “слушает” изменения адресной строки браузера может быть воссоздано нужное состояние объекта Place. При воссоздании или сохранении состояния объекта Place используется PlaceTokenizer-объект, методы которого вызываются при воссоздании и сохранении состояния описываемого объекта
  • PlaceHistoryMapper, ActivityMapper — классы-мапперы, которые по сути отвечают за регистрацию всех Place’ов и Activity’ей приложения. ActivityMapper также на основании переданного объекта Place (который в свою очередь был воссоздан из history-токена) принимает решение какой объект Activity будет связан с соответствующим состоянием URL
  • View — простые Composite-виджеты, которые могут состоять из других вложенных виджетов. Содержат в себе инстанс связанного презентера (activity). Не содержат в себе логики, кроме как логики, нужной для UI-нужд, например, переключение стилей и т.д. Выполнение всей полезной логики вид делегирует связанному презентеру путем вызова его методов.

Рассмотрим теперь более подробно все эти компоненты в отдельности на примере реального кода. Кстати, код приложения доступен для cвободного доступа. Так что в него можно подглядывать по мере чтения.

Визуальная составляющая или View


Прежде всего стоит сказать, что в MVP принято “обмениваться” между презентером и видом только интерфейсами. Поэтому для каждого вида в приложении есть соответствующий интерфейс
package com.gshocklab.mvp.client.mvp.view;

import com.google.gwt.user.client.ui.IsWidget;

public interface IMailView extends IsWidget {
    public void setPresenter(IMailPresenter presenter);
    
    public interface IMailPresenter { }
}

В интерфейсе вида описан соответствующий интерфейс презентера, который будет выполнять всю полезную нагрузку (посылка запросов на получение данных, обработка событий из шины событий и т.д.).
Реализация этого интерфейса будет простой и не должна вызвать каких-то особых вопросов.
public class MailView extends Composite implements IMailView {
    interface MailViewUiBinder extends UiBinder<Widget, MailView> {    }
    private static MailViewUiBinder uiBinder = GWT.create(MailViewUiBinder.class);
    
    private IMailPresenter presenter;

    public MailView() {
   	 initWidget(uiBinder.createAndBindUi(this));
    }

    @Override
    public void setPresenter(IMailPresenter presenter) {
   	 this.presenter = presenter;
    }
}

Связанный ui.xml-файл содержит всего один виджет, Label с простым текстом. Приводить его код нет смысла, его можно посмотреть на сайте проекта.
Это все, что касается View-части. Перейдем теперь к более интересному, к Activity.

Логика страниц (видов) или Activity


В демо-приложении слева есть навигационная панель с ссылками. При кликах на этих ссылка выполняется переключение между видами и устанавливается CSS-стиль для текущей ссылки. Это действие я вынес в абстрактный родительский класс AbstractMainActivity, который является наследником встроенного класса AbstractActivity
package com.gshocklab.mvp.client.mvp.activity;

public abstract class AbstractMainActivity extends AbstractActivity {
    private static Map<String, Element> navLinks = new LinkedHashMap<String, Element>();
    static {
     navLinks.put(AppConstants.MAIL_LINK_ID, DOM.getElementById(AppConstants.MAIL_LINK_ID));
     navLinks.put(AppConstants.CONTACTS_LINK_ID, DOM.getElementById(AppConstants.CONTACTS_LINK_ID));
     navLinks.put(AppConstants.TASKS_LINK_ID, DOM.getElementById(AppConstants.TASKS_LINK_ID));
    }
    
    public void applyCurrentLinkStyle(String viewId) {
   	 for (String linkId : navLinks.keySet()) {
   		 final Element link = navLinks.get(linkId);
   		 if (link == null) continue;
   		 if (linkId.equals(viewId)) {
   			 link.addClassName("b-current");
   		 } else {
   			 link.removeClassName("b-current");
   		 }
   	 }
    }
}

И конкретная реализация конкретной Activity
package com.gshocklab.mvp.client.mvp.activity;

public class MailActivity extends AbstractMainActivity implements IMailView.IMailPresenter {
    private ClientFactory clientFactory;
    
    public MailActivity(ClientFactory clientFactory) {
   	 this.clientFactory = clientFactory;
    }
    
    @Override
    public void start(AcceptsOneWidget container, EventBus eventBus) {
   	 applyCurrentLinkStyle(AppConstants.MAIL_LINK_ID);
   	 
   	 final IMailView view = clientFactory.getMailView();
   	 view.setPresenter(this);
   	 container.setWidget(view.asWidget());
    }
}

Как это работает: ActivityManager при получении события изменения URL от PlaceHistoryManager создает с помощью ActivityMapper-а нужный инстанс Activity и запускает ее на выполнение с помощью метода start(). В этот метод одним из параметров передается контейнер, в который будет подставлен виджет вида. Вид мы получаем из ClientFactory, о которой будет немного ниже. В полученный инстанс вида мы инжектим презентер и выполняем отображение вида как виджета. Да, еще устанавливается CSS-правило для ссылки, которая ведет на текущий вид. Но это чисто визуальное оформление.
ClientFactory это простая фабрика, которая создает нужные объекты. Ее интерфейс описан следующим образом
public interface ClientFactory {
    public EventBus getEventBus();
    public PlaceController getPlaceController();
    
    public IMailView getMailView();
    public IContactsView getContactsView();
    public ITasksView getTasksView();
}

Его реализация не отличается “умом и сообразительностью”
public class ClientFactoryImpl implements ClientFactory {
    private final EventBus eventBus = new SimpleEventBus();
    private final PlaceController placeController = new PlaceController(eventBus);
    
    private final IMailView mailView = new MailView();
    private final IContactsView contactsView = new ContactsView();
    private final ITasksView tasksView = new TasksView();
    
    @Override public EventBus getEventBus() { return eventBus; }
    @Override public PlaceController getPlaceController() { return placeController; }
    @Override public IMailView getMailView() { return mailView; }
    @Override public IContactsView getContactsView() { return contactsView; }
    @Override public ITasksView getTasksView() { return tasksView;}
}

Инстанциирование объекта ClientFactory будет выполняться с помощью Deffered binding’а по правилу, которое описывается в файле описания GWT-модуля. Но об этом опять же позже в разделе, где будет рассмотрено конфигурирование всего MVP-хозяйства в единую рабочую систему. Тут стоит отметить, что в реальных проектах для задач, которые решает ClientFactory лучше воспользоваться Google GIN’ом. Достоинства DI-инструмента смысла описывать нет, они и так понятны.
Последним из ключевых элементов встроенного MVP является объект, который отвечает за состояние UI и выполняет манипуляции с history-токенами.

Place или хеш-URL’ы и их обработка


Как было упомянуто выше объект Place отвечает за текущее состояние UI. Состояние передается с помощью URL посредством history-токенов. По сути в этом объекте можно хранить параметры, которые передаются с хэш-URL’ой. Состояние URL кодируется/декодируется с помощью объекта Tokenizer’а. При работе с параметрами запроса, которые передаются в хэш-URL’е очень важно выдерживать следующее правило: все параметры, которые нам “заходят” из URL после обработки должны в таком же виде кодироваться назад в URL. Именно эта логика и реализуется в методах класса Tokenizer.
По соглашению, которое принято командой GWT и описывается в официальном руководстве класс токенайзера принято описывать как внутренний статический класс объекта Place. Это упрощает код для сохранения в переменных объекта Place параметров запроса. Хотя можно применять подход с отдельными классами для токенайзеров.
Чтобы не быть голословным рассмотрим код класса MailPlace
package com.gshocklab.mvp.client.mvp.place;

import com.google.gwt.place.shared.Place;
import com.google.gwt.place.shared.PlaceTokenizer;
import com.google.gwt.place.shared.Prefix;

public class MailPlace extends Place {
    private static final String VIEW_HISTORY_TOKEN = "mail";
    
    public MailPlace() { }
    
    @Prefix(value = VIEW_HISTORY_TOKEN)
    public static class Tokenizer implements PlaceTokenizer<MailPlace> {
   	 @Override
   	 public MailPlace getPlace(String token) {
   		 return new MailPlace();
   	 }

   	 @Override
   	 public String getToken(MailPlace place) {
   		 return "";
   	 }
    }
}

Этот класс отнаследован от встроенного класса Place. В нем константой объявлена часть хэш-URL, которая будет однозначно идентифицировать состояние. В данном случае это “mail”. За воссоздание состояния и его сохранение через history-токены отвечает класс Tokenizer. Привязка конкретной хэш-URL к токенайзеру осуществляется с помощью аннотации Prefix.
Обработка истории вообще интересная тема и заслуживает отдельной статьи. Здесь ограничимся тем, что у нас с каждым объектом Place будет связана своя хэш-URL’а. Эта URL обязательно должна заканчиваться на “:”. После этого двоеточия можно указывать дополнительные параметры, например, можно формировать URL вида #mail:inbox, #contacts:new и т.д. и эти токены будут обработаны в методе getPlace(). По сути, первая часть хэш-URL’ы является определителем подсистемы (mail, tasks etc.), все, что следует после двоеточия можно расценивать как action’ы подсистемы.
В демо-проекте дополнительные токены (или actions) не используются, поэтому метод getToken() во всех токенайзерах возвращает пустую строку, а метод getPlace() возвращает созданный объект Place.

Определение нужной Activity и регистрация обработчиков


При поступлении новой URL и успешной инстанциации объекта Place менеджер ActivityManager с помощью ActivityMapper’а принимает решение о том, какой объект презентера нужно запустить. Определение это реализовано просто и банально
public class DemoActivityMapper implements ActivityMapper {
    private ClientFactory clientFactory;
    
    public DemoActivityMapper(ClientFactory clientFactory) {
   	 super();
   	 this.clientFactory = clientFactory;
    }
    
    @Override
    public Activity getActivity(Place place) {
   	 if (place instanceof MailPlace) {
   		 return new MailActivity(clientFactory);
   	 } else if (place instanceof ContactsPlace) {
   		 return new ContactsActivity(clientFactory);
   	 } else if (place instanceof TasksPlace) {
   		 return new TasksActivity(clientFactory);
   	 }
   	 return null;
    }
}

Регистрация обработчиков хэш-URL’ов, т.е. токенайзеров выполняется в интерфейсе PlaceHistoryMapper
package com.gshocklab.mvp.client.mvp;

import com.google.gwt.place.shared.PlaceHistoryMapper;
import com.google.gwt.place.shared.WithTokenizers;
import com.gshocklab.mvp.client.mvp.place.ContactsPlace;
import com.gshocklab.mvp.client.mvp.place.MailPlace;
import com.gshocklab.mvp.client.mvp.place.TasksPlace;

@WithTokenizers({MailPlace.Tokenizer.class, ContactsPlace.Tokenizer.class, TasksPlace.Tokenizer.class})
public interface DemoPlaceHistoryMapper extends PlaceHistoryMapper { }

Все что нужно на данном этапе это просто перечислить в аннотации @WithTokenizers классы токенайзеров приложения.

Собирая все вместе


Весь код по инициализации и запуску механизма MVP-фреймворка собран в onModuleLoad()-методе EntryPoint’а
package com.gshocklab.mvp.client;

import com.google.gwt.activity.shared.ActivityManager;
import com.google.gwt.activity.shared.ActivityMapper;
import com.google.gwt.core.client.EntryPoint;
import com.google.gwt.core.client.GWT;
import com.google.gwt.event.shared.EventBus;
import com.google.gwt.place.shared.PlaceController;
import com.google.gwt.place.shared.PlaceHistoryHandler;
import com.google.gwt.user.client.History;
import com.google.gwt.user.client.ui.RootLayoutPanel;
import com.google.gwt.user.client.ui.SimplePanel;
import com.gshocklab.mvp.client.layout.AppLayout;
import com.gshocklab.mvp.client.mvp.DemoActivityMapper;
import com.gshocklab.mvp.client.mvp.DemoPlaceHistoryMapper;
import com.gshocklab.mvp.client.mvp.place.MailPlace;

public class MvpInActionEntryPoint implements EntryPoint {
    private SimplePanel containerWidget;
    private MailPlace defaultPlace = new MailPlace();
    
    @Override
    public void onModuleLoad() {
   	 final AppLayout mainLayout = new AppLayout();
   	 containerWidget = mainLayout.getAppContentHolder();
   	 
   	 final ClientFactory clientFactory = GWT.create(ClientFactory.class);
   	 EventBus eventBus = clientFactory.getEventBus();
   	 PlaceController placeController = clientFactory.getPlaceController();
   	 
   	 // activate activity manager and init display
   	 ActivityMapper activityMapper = new DemoActivityMapper(clientFactory);
   	 ActivityManager activityManager = new ActivityManager(activityMapper, eventBus);
   	 activityManager.setDisplay(containerWidget);
   	 
   	 // display default view with activated history processing
   	 DemoPlaceHistoryMapper historyMapper = GWT.create(DemoPlaceHistoryMapper.class);
   	 PlaceHistoryHandler historyHandler = new PlaceHistoryHandler(historyMapper);
   	 historyHandler.register(placeController, eventBus, defaultPlace);
   	 
   	 RootLayoutPanel.get().add(mainLayout);
   	 
   	 History.newItem("mail:");
    }
}

Думаю, по коду пояснения будут лишними, все просто и понятно. Следует отметить, что вызов History.newItem(“mail:”) может является лишним. MailActivity и так запуститься, потому что в качестве умолчательного Place’а указан MailPlace. Другое дело что при старте мы не увидим в адресной строке браузера хэш-URL’у #mail:. Если отображение стартовой хеш-URL для проекта не критично то вызов History.newItem() можно убрать.
Чтобы встроенный MVP-фреймворк заработал нужно подключить соответствующие GWT-модули в файле описания GWT-модуля (gwt.xml-файл)
<?xml version="1.0" encoding="UTF-8"?>
<module rename-to='mvpinaction'>
    <inherits name='com.google.gwt.user.User' />
	<inherits name="com.google.gwt.activity.Activity"/>
	<inherits name="com.google.gwt.place.Place"/>
    
        <entry-point class='com.gshocklab.mvp.client.MvpInActionEntryPoint' />
    
	<replace-with class="com.gshocklab.mvp.client.ClientFactoryImpl">
    	<when-type-is class="com.gshocklab.mvp.client.ClientFactory" />
	</replace-with>
    
    <source path='client' />
</module>

Здесь же указывается правило deferred binding’а для создания инстанса ClientFactory.

Вместо заключения


Вот вроде и все. Файл с корневым лэйаутом приложения AppLayout я не привожу, его можно посмотреть в исходниках. В этом файле есть ссылки, в атрибутах href которых указаны хэш-URL’ы для перехода к подсистемам приложения. Также открыть ту или иную подсистему можно просто набрав в адресной строке браузера корректную URL. Автоматически будет запущен процесс преобразования состояния из URL в соответствующий place с запуском соответствующей activity, которая и отобразит нужный нам вид.
Замечу дополнительно, что в заметке и демо-проекте не были рассматрены такие важные в жизни момена, как использование шины событий (eventBus), обработка параметров хэш-URL’ов и многое другое.

Работающий demo-проект, исходный код Google Code. Осторожно, Mercurial!
Жду отзывов и комментариев.

P.S. Сорри за многобукв. Надеюсь, что эта вводная заметка оказалась кому-то полезна (хоть и не полностью освещает всю мощь встроенного MVP), я не даром писал ее и она послужит отправной точкой при реализации действительно клёвых GWT-приложений
Tags:
Hubs:
Total votes 13: ↑9 and ↓4 +5
Views 21K
Comments Comments 10