[Перевод] Расширяемый код Android-приложений с MVP
От переводчика: — я давненько интересуюсь тем, как сделать код Android-приложений чище, и это, наверное, первая статья, после которой у меня не возникло мыслей: «Зачем вот это вот все?» и «Он вообще пробовал когда-то это использовать в жизни?» Поэтому решил перевести, может, еще кому-то будет полезно.
Написать Hello World всегда легко. Код выглядит просто и прямолинейно, и кажется, что SDK очень адаптирована под ваши нужды. Но если у вас есть опыт написания более сложных Android-приложений, вы знаете, что с рабочим кодом все не так. Можно провести часы за попыткой понять, почему ваша корзина покупок не обновляется после изменения ориентации телефона, если недоступен WiFi. Вы предполагаете, что решением проблемы, возможно, будет добавить ещё один if
в 457-строчном методе onCreate()
вашей активити — где-то между тем кодом, который исправляет падение на самсунгах с Android 4.1 на борту, и тем, который показывает купон на 5$ в день рождения пользователя. Что ж, есть способ получше.
Мы в Remind (прим. пер. — название компании, где работает автор) выкатываем новые функции каждые две недели, и для того чтобы поддерживать эту скорость и высокое качество продукта, нужен способ сохранять код простым, поддерживаемым, разделённым (прим. пер. — «decoupled», в смысле слабой связанности) и тестируемым. Использование архитектурного паттерна MVP позволяет нам делать это и сосредоточиваться на самой значимой части нашего кода — нашей бизнес-логике.
MVP, или Model-View-Presenter, это один из нескольких паттернов, который способствует разделению ответственности при реализации пользовательского интерфейса. В каждом из этих паттернов роли слоев слегка отличаются. Целью этой статьи не является описание отличий между паттернами, а показать, как это можно применить на андроиде (по аналогии с современными UI-фреймворках, такими как Rails и iOS), и как от этого выиграет ваше приложение.
Пример кода, который иллюстрирует большинство подходов, описанных далее, вы можете найти здесь:
https://github.com/remind101/android-arch-sample
Разделение ответственности, которое подразумевается Android-фреймворком, выглядит так: Модель может быть любым POJO, Представление — это XML-разметка, а фрагмент (или изначально активити) выступает в роли Контроллера/Презентера. В теории это работает довольно неплохо, но как только ваше приложение разрастается, в Контроллере появляется много кода, относящегося к Представлению. Все потому, что не так много можно сделать с XML, так что вся привязка данных (дата-биндинг), анимации, обработка ввода и т. д. производится во фрагменте, наряду с бизнес-логикой.
Все становится ещё хуже, когда сложные элементы интерфейса размещаются в списках или гридах (прим. пер. — имеет в виду GridView/GridLayout, да и вообще «сеточные элементы»). Теперь на адаптер ложится ответственность не только хранить код представления и контроллера для всех этих элементов, но и управлять ими как коллекцией. Так как все эти элементы сильно связаны, их становится очень трудно поддерживать и ещё сложнее тестировать.
MVP предоставляет нам возможность выделить весь тот скучный низкоуровневый Android-код, который нужен для отображения нашего интерфейса и взаимодействия с ним, в Представление, а более высокоуровневую бизнес-логику того, что наше приложение должно делать, выселить в Презентер.
Для достижения этого на андроиде надо рассматривать активити или фрагмент как слой представления, и предоставить легковесный презентер для того, чтобы контролировать представление. Самое важное — определить ответственность каждого слоя, и стандартизировать интерфейс между ними. Вот общее описание разделения, которое весьма неплохо работает у нас:
Представление (активити или фрагмент) отвечает за:
- Создание экземпляра презентера и механизм его присоединения/отсоединения;
- Оповещение презентера о важных для него событиях жизненного цикла;
- Сообщение презентеру о входных событиях;
- Размещение вьюх и соединение их с данными;
- Анимации;
- Отслеживание событий;
- Переход на другие экраны.
Презентер отвечает за:
- Загрузку моделей;
- Сохранение ссылки на модель и состояния представления;
- Форматирование того, что должно быть отображено на экране, и указание представлению отобразить это;
- Взаимодействие с репозиториями (база данных, сеть и т. д.) (прим. пер. Repository — это паттерн, на всякий случай);
- Определение необходимых действий, когда получены входные события от представления.
Вот пример того, каким может быть интерфейс между представлением и презентером:
interface MessageView {
// Методы представления должны звучать как указания, так как представление только вызывает инструкции у презентера
// Методы для обновления представления
void setMessageBody(String body);
void setAuthorName(String name);
void showTranslationButton(boolean shouldShow);
// Методы навигации
void goToUserProfile(User user);
}
interface MessagePresenter {
// Методы презентера в основном должны быть коллбеками, так как представление сообщает презентеру о событиях
// Методы событий жизненного цикла
void onStart();
// Методы входных событий
void onAuthorClicked();
void onThreeFingersSwipe();
}
Есть пара интересных моментов, которые стоит рассмотреть в связи с этим интерфейсом:
- Методы обновления представления должны быть простыми и нацеленными на отдельный элемент. Это лучше, чем иметь один метод
setMessage(Message message)
, который будет обновлять все, так как форматирование того, что надо отобразить, должно быть ответственностью презентера. Например, в будущем вы захотите отображать «Вы» вместо имени пользователя, если текущий пользователь является автором сообщения, а это является частью бизнес-логики. - Методы событий жизненного цикла презентера просты и не должны отображать истинный (переусложненный) системный жизненный цикл. Вы не обязаны обрабатывать какой бы то ни было из них. Но если хотите, чтобы презентер совершал какие-то действия на разных этапах этого цикла, можете обрабатывать в нем столько, сколько считаете нужным.
- Входные события у презентера должны оставаться высокоуровневыми. Например, если вы хотите определять сложный жест, например, трехпальцевый свайп, это и другие события должны определяться представлением.
- Можно обратить внимание на методы
MessagePresenter.onAuthorClicked()
иMessageView.goToAuthorProfile()
. Реализация представления, вероятно, будет иметь клик лисенер, который будет вызывать данный метод презентера, а тот в свою очередь будет вызыватьgoToAuthorProfile()
. Не нужно ли убрать все это и переходить в профиль автора непосредственно из клик лисенера. Нет! Решение, переходить ли в профиль пользователя при нажатии на его имя, является частью вашей бизнес-логики, и за это отвечает презентер.
Как выяснено на практике, если код вашего презентера содержит код Android-фреймворка, а не только pure Java, вероятно, вы что-то делаете неверно. И соответственно, если ваши представления нуждаются в ссылке на модель, видимо, вы также делаете что-то неправильно.
Как только возникнет вопрос тестов, большинство кода, который вам необходимо протестировать, будет в презентере. Что круто, так это то, что этому коду не нужен Android для запуска, так как у него есть только ссылки на интерефейс представления, а не на его реализацию в контексте Android. Это значит, что вы можете просто мокнуть интерфейс представления и написать чистые JUnit-тесты для бизнес-логики, проверяющие правильность вызова методов у мокнутого представления. Вот так теперь выглядят наши тесты.
До настоящего момента мы предполагали, что наши представления — это активити и фрагменты, но в реальности они могут быть чем угодно. У нас довольно неплохо получилось работать со списками, имея ViewHolder, реализующий интерфейс представления (как RecyclerView.ViewHolder
, так и обычный старый ViewHolder для использования в связке с ListView). В адаптере вам всего лишь нужна базовая логика для обработки присоединения/отсоединения презентеров (пример всего этого есть в гит-репозитории).
Если вы посмотрите на пример экрана, содержащего список сообщений, прогресс загрузки и пустую вьюху, разделение ответственности будет следующим:
- Презентер списка ответственен за загрузку сообщений и логику отображения вьюх списка/прогресса/пустой заглушки;
- Фрагмент отвечает за реализацию логики отображения вьюх списка/прогресса/заглушки и перехода на другие экраны;
- Адаптер сопоставляет презентеры их ViewHolder-ам;
- Презентер сообщения отвечает за бизнес-логику отдельного сообщения;
- ViewHolder ответственен за отображение сообщения.
Все эти компоненты слабо связаны и могут быть протестированы отдельно друг от друга.
Более того, если у вас есть экран списка сообщений и экран подробностей, вы можете переиспользовать тот же презентер сообщения и просто иметь две разные реализации интерфейса представления (во ViewHolder-е и фрагменте). Это сохраняет ваш код DRY (прим. пер. — «Don’t Repeat Yourself», или «Не повторяйтесь», кто не знает).
Подобным образом, интерфейс представления могут реализовывать кастомные вьюхи. Это позволяет вам использовать MVP в кастомных виджетах, чтобы переиспользовать это в разных частях приложения, или же просто разбивать сложные интерфейсы на блоки попроще.
Если вы уже какое-то время пишете под Android, вы знаете, сколько боли доставляет поддержка смены ориентации и конфигурации:
- Фрагмент/активити должны уметь восстанавливать свое состояние. Каждый раз при работе с фрагментом вы должны спрашивать себя, как эта штука должна действовать при смене ориентации, что надо поместить в бандл saveInstanceState и т. д.
- Долгие операции в фоновых потоках очень сложно сделать правильно. Одна из самых популярных ошибок — хранить ссылку на фрагмент/активити в фоновом потоке, так как ему надо обновить UI после завершения работы. Это приводит к утечке памяти (и, вероятно, падению приложения из-за увеличения потребления памяти), а также к тому, что новая активити никогда не получит колбек и, соответственно, не обновит UI.
Правильное использование MVP может решить этот вопрос без необходимости вообще задумываться об этом. Так как у презентеров нет сильной ссылки на текущий UI, они очень легковесные и могут быть восстановлены при смене ориентации! Так как презентер хранит ссылку на модель и состояние представления, он может восстановить нужное состояние представления после смены ориентации. Вот примерное описание того, что происходит при повороте экрана, если используется данный паттерн:
- Изначально активити создана (назовем её «первый экземпляр»);
- Создаётся новый презентер;
- Презентер привязывается к активити;
- Пользователь нажимает кнопку «Скачать»;
- В презентере запускается долгая операция;
- Меняется ориентация;
- Презентер отвязывается от первого экземпляра активити
- На первый экземпляр активити больше нет ссылок, и теперь она доступна сборщику мусора;
- Презентер сохранен, фоновая операция продолжается;
- Создаётся второй экземпляр активити;
- Второй экземпляр активити привязывается к презентеру.
- Завершается загрузка;
- Презентер обновляет представление (второй экземпляр активити).
Как сохранять фрагменты между сменами ориентации, можно увидеть в репозитории, в классе PresenterManager
.
Да, это конец. Надеюсь, получилось продемонстрировать, как разделение ответственности наподобие MVP поможет вам писать поддерживаемый и тестируемый код.
Резюмируя:
- Отделяйте вашу бизнес-логику, вынося её в голый java-объект презентера;
- Потратьте время на чистый интерфейс между вашими презентерами и представлениями;
- Пусть ваши активити, фрагменты и кастомные вьюхи реализуют интерфейс представления;
- Для списков реализовывать интерфейс представления должен ViewHolder;
- Тщательно тестируйте ваши презентеры;
- Сохраняйте презентеры при смене ориентации.
Реализацию вышеописанного можно найти в репозитории ArchExample.
Также есть множество библиотек, которые могут помочь вам использовать такой подход, например, Mosby, Flow и Mortar, или Nucleus. Советую их рассмотреть.