В данной статье описываются преимущества использования Moxy в качестве вспомогательной библиотеки при использовании MVP для Android-приложения.
Важно: здесь не сравнивается MVP с другими архитектурными подходами типа MVVM, MVI и т.п. В статье описывается почему, если в качестве архитектуры UI-ной части приложения выбран MVP, то лучше не использовать самописную реализацию MVP, а использовать Moxy.
Библиотека Moxy позволяет избежать boilerplate кода для обработки lifecycle фрагментов и активитей, и работать с View как будто оно всегда активно.
Далее под View понимается имплементация View в виде фрагмента или активити.
Под интерактором понимается сущность бизнес-логики, т.е. класс, который лежит на более низком уровне абстракции, чем Presenter.
Общие преимущества Moxy
- Активная поддержка и разработка библиотеки.
- Поддержка фичей Kotlin вроде val presenter by moxyPresenter { component.myPresenter } и presenterScope для корутин.
- Автоматическое восстановление состояния View.
- Автоматическая увязка с жизненным циклом (а отсюда отсутствие утечек Активити и прочей подобной прелести).
- Обращения к View происходят через не-nullable viewState. Нет риска, что какая-то команда View потеряется.
- Весь lifecycle экрана сводится к двум коллбэкам презентера — onFirstViewAttach() и onDestroy().
- Время разработки экранов сокращается — не нужно писать лишний код для обработки lifecycle и сохранения состояний.
Типичные задачи и решения
Рассмотрим, как решаются типичные задачи при разработке UI с использованием Moxy и без.
При решениях без Moxy предполагается следующая типичная реализация MVP. В presenter хранится nullable-ссылка на view. Presenter аттачится (передаётся ссылка на View) при создании View (в onCreate()) и детачится (зануляется ссылка на View) при уничтожении View (в onDestroy()).
Задача: асинхронный запрос данных и отображение результата на UI
Пусть у нас есть класс (MyListInteractor), который возвращает список данных. В presenter мы можем позвать его асинхронно для запроса данных.
class MyPresenter
...
// Функция запрашивает список и отображает его на UI
override fun onDisplayListClicked() {
myListInteractor.requestList()
.subscribe { displayList(it) }
Решение с Moxy
private fun displayList(items: List<Item>) {
viewState.setListItems(items)
}
Обращаемся к не-nullable viewState и передаём туда загруженные данные. Моху прикопает результат и отправит View, когда оно будет активно. При пересоздании View команда может быть повторена (зависит от стратегии) при этом заново данные не будут запрашиваться.
Решение без Moxy
private fun displayList(items: List<Item>) {
view?.setListItems(items)
}
Обращаемся к View по nullable-ссылке. Эта ссылка зануляется при пересоздании View. Если к моменту завершения запроса view не приаттачена, то данные потеряются.
Возможное решение проблемы.
private fun displayList(items: List<Item>) {
view?.let { it.setListItems(items)
}?: let {
cachedListInteractor.saveList(items)
}
}
Прикапывать данные в какой-то сущности, которая не связана с View (например, в интеракторе). При onResume() запрашивать прихранённые данные и отображать их.
Минусы решения.
- Лишняя работа по сохранению результата из-за особенностей платформы (lifecycle Android активитей и фрагментов).
- Прихранённые данные нужны только View, бизнес-логика будет перегружена проблемами сохранения результата.
- Нужно следить за своевременной очисткой результата. Если объект, хранящий состояние, используется где-то ещё, то этот код также должен следить за его состоянием.
- Presenter будет знать о View lifecycle, т.к. нужно уведомлять его об onResume(). Плохо, что Presenter знает об особенностях платформы.
Задача: сохранение состояния отображения
Часто возникает ситуация, когда нам нужно хранить какое-то состояние отображения.
Решение с Moxy
class MyPresenter
...
private var stateData: StateData = ...
Храним состояние в presenter. Presenter выживает при пересоздании View, поэтому там можно хранить состояние. Можно хранить данные любых типов, в т.ч. ссылки на интерфейсы, например.
Решения без Moxy
Можно хранить состояние в savedInstanceState или аргументах фрагмента.
Минусы решения
- View будет знать о state и, скорее всего, передавать его в presenter. Т.е. логика размазывается между View и presenter.
- Возможно, понадобится явно из presenter обращаться к View с целью сохранить состояние, таким образом, в интерфейсе View будут «лишние» методы сохранения состояния (должны быть только методы для управления отображениям).
- Presenter может не успеть обратиться к View для сохранения состояния, т.к. ссылка на View может занулиться и сам presenter может погибнуть.
- Сохранить можно только примитивные и serializable/parcelable данные.
- Boilerplate код для сохранения данных в Bundle и извлечения из Bundle.
Можно хранить данные на уровне бизнес-логики, в специальном классе (пусть этот класс будет отвечать только за state конкретной View и не будет использоваться где-то ещё).
Минусы решения
- Нужно следить за актуальностью данных.
- Не просто разобраться, когда создавать и уничтожать класс, хранящий данные. Обычно его просто делают singleton'ом и он существует всегда, хотя нужен только одной View.
Задача: обмен данными между экранами
Часто бывает нужно результат действий на одном View отобразить на другом View.
Решение с Moxy
Обмен данными между экранами осуществляется так же как и асинхронный запрос. Разве что подписка на изменения идёт на subject или channel в интеракторе, в который presenter другой View кидает изменённые данные. Подписка в Presenter.onFirstViewAttach(), отписка — в Presenter.onDestroy().
Решения без Moxy
- Как и выше, через интерактор с subject’ами или channel’ами. В этом случае подписываться/отписываться нужно в каждом onCreate()/onDestroy(). Так же есть риск потери данных, как в случае асинхронного запроса.
- Через Broadcast или интент активити. Данные передаются через Bundle. Отсюда тот же Boilerplate с Bundle, как описано в разделе о сохранении состояния. Кроме того, в случае интента активити, логика по обмену данными ложится на View, хотя должна быть исключительно в presenter.
Задача: инициализация чего-либо, связанного с экраном
Часто бывает нужно проинициализировать что-то, связанное с данным экраном. Например, какой-то внешний компонент.
Решение с Moxy
Проинициализировать компонент можно в Presenter.onFirstViewAttach() и освободить в Presenter.onDestroy() — это единственные коллбэки, о которых нам нужно задумываться.
Presenter.onFirstViewAttach() — вызывается при самом первом создании View,
Presenter.onDestroy() — вызывается при окончательном уничтожении View.
Решение без Moxy
Можно проинициализировать в onCreate() и освободить в onDestroy() активити или фрагмента.
Минусы решения
- Постоянная переинициализация компонента.
- Если компонент содержит коллбеки, то возможна утечка памяти (объекта presenter или активити/фрагмента).
Задача: показ AlertDialog
Особенностью использования AlertDialog является то, что он пропадает при пересоздании View. Поэтому при пересоздании View нужно заново отображать AlertDialog.
class MyFragment : ... {
private val myAlertDialog = AlertDialog.Builder(context)...
override fun switchAlertDialog(show: Boolean) {
if (show) myAlertDialog.show() else myAlertDialog.dismiss()
}
Решение с Moxy
@StateStrategyType(AddToEndSingleStrategy::class)
fun switchAlertDialog(show: Boolean)
Выбрать правильную стратегию. Диалог сам перепокажется при пересоздании View.
Решения без Moxy
- Можно в View хранить состояние отображения AlertDialog и перепоказывать при пересоздании View. Получается лишний boilerplate просто чтобы восстановить диалог.
- Можно использовать DialogFragment. Для простых диалогов — лишний overhead. И это добавляет проблемы с commit() фрагментов после onSaveInstanceState().
Особенности использования Moxy
Moxy позволяет избежать boilerplate кода при использовании MVP в android-приложении. Но, как и любой другой библиотекой, Moxy нужно научиться пользоваться. К счастью, использовать Moxy легко. Далее описаны моменты, на которые нужно обратить внимание.
- Как команда View (т.е. вызов метода View) будет повторяться при пересоздании View – зависит от стратегии над данным методом интерфейса View. Важно понимать, что означают стратегии. Неправильный выбор стратегии может негативно сказаться на UX. К счастью, стандартных стратегий не много (всего 5) и они хорошо документированы, а создание кастомных стратегий требуется не часто.
- Moxy обращается к View с того потока, с которого обратился к нему presenter. Поэтому нужно самостоятельно следить в presenter за тем, чтобы методы viewState вызвались из главного потока.
- Moxy не решает проблему commit() фрагментов после выполнения onSaveInstanceState(). Разработчики Moxy рекомендуют использовать commitAllowingStateLoss(). Однако, это не должно вызывать беспокойство, т.к. за состояние View полностью отвечает Moxy. То, что где-то внутри android потеряется состояние View — нас не должно волновать.
- Взаимно отменяющие команды view лучше объединять в один метод View. Например, скрытие и показ прогресса лучше сделать так:
@StateStrategyType(AddToEndSingleStrategy::class)
fun switchProgress(show: Boolean)
а не так:
@StateStrategyType(AddToEndSingleStrategy::class)
fun showProgress()
@StateStrategyType(AddToEndSingleStrategy::class)
fun hideProgress()
Либо можно использовать кастомную стратегию с тегами. Например, как описано тут: https://habr.com/ru/company/redmadrobot/blog/341108/
Это нужно чтобы команда показа прогресса не вызвалась больше после команды скрытия прогресса.
- Presenter должен по-особому инжектится через dagger.
Например, может возникнуть желание сделать так:
class MyFragment : ... {
@Inject
lateinit var presenter: MyPresenter
@ProvidePresenter
fun providePresenter(): MyPresenter {
return presenter
}
Так делать нельзя. Нужно чтобы функция @ProvidePresenter гарантированно создавала новый инстанс presenter. Здесь при пересоздании фрагмента появится новый инстанс presenter. А Moxy будет работать со старым, т.к. функция providePresenter() вызывается только один раз.
Как вариант, можно в providePresenter() просто создать новый инстанс presenter:
@ProvidePresenter
fun providePresenter(): MyPresenter {
return MyPresenterImpl(myInteractor, schedulersProvider)
}
Это не очень удобно — ведь придётся инжектить в фрагмент все зависимости этого presenter.
Можно из компонента dagger сделать метод для получения presenter и позвать его в providePresenter():
@Component(modules = ...)
@Singleton
interface AppComponent {
...
fun getMyPresenter(): MyPresenter
}
class MyFragment : ... {
...
@ProvidePresenter
fun providePresenter(): MyPresenter {
return TheApplication.getAppComponent().getMyPresenter()
}
Важно, чтобы провайдер presenter’а и сам presenter не были помечены аннотацией Singleton.
В последних версиях Moxy можно использовать делегаты kotlin:
private val myPresenter: MyPresenter by moxyPresenter {
MyComponent.get().myPresenter
}
Ещё можно заинжектить через Provider:
@Inject
lateinit var presenterProvider: Provider<MyPresenter>
private val presenter by moxyPresenter { presenterProvider.get() }
Итог
Moxy — замечательная библиотека, которая позволяет значительно упростить жизнь android-разработчика при использовании MVP.
Как и с любой новой технологией или библиотекой, в начале использования Moxy неизбежно возникают ошибки, например, не верный выбор стратегии или не правильный inject Presenter'а. Однако с опытом всё становится просто и понятно и уже сложно себе представить MVP без использования Moxy.
Выражаю благодарность сообществу Moxy за такой замечательный инструмент. А так же участникам telegram-чата Moxy за ревью и помощь в написании статьи.
Ссылки
Moxy — реализация MVP под Android с щепоткой магии – отличная статья от разработчиков Moxy с описанием того, для чего создавалась Moxy и как с ней работать.
Стратегии в Moxy (часть 1) — статья хорошо описывает стандартные стратегии Moxy.
Стратегии в Moxy (Часть 2) — руководство по созданию кастомных стратегий Moxy.
Об использовании популярных практик в разработке под Android – высокоуровнево описывается MVP и Moxy.
Moxy. Как правильно пользоваться? / Юрий Шмаков (Arello Mobile) – запись с конференции AppsConf, где разработчик Moxy рассказывает о том, как пользоваться библиотекой.
ChPr
Я долгое время использовал Moxy, и меня не покидало ощущение, что с этим ViewState я делаю абсолютно тоже самое что и в MVVM. 99% времени я использовал только
AddToEndSingleStrategy
иOneExecutionStateStrategy
(как наверное и все пользователи этой библиотеки).В итоге с кодогенерацией получался некий мост между MVP и MVVM, когда ViewState всегда хранил актуальное состояние экрана и выступал в роли ViewModel, на которую "подписывалась" View, а Presenter менял состояние через ViewState. Так нужен ли этот мост сейчас, когда есть LiveData (либо любой другой Android-независимый Observable) и ViewModel? (раньше точно нужен был, т.к. в то время Jetpack'a еще не было)
foxspa Автор
Что использовать — MVP или MVVM — дело вкуса и поставленной цели.
Хоть внутри MVP c Moxy и напоминает MVVM, но снаружи, т.е. для пользователя библиотеки, это чистый MVP.
Отсюда и все преимущества MVP — более тщательное покрытие unit-тестами поведения UI. С MVVM можно протестировать модель, но не как она используется в имплементации.
Например, в случае MVVM, в модели может быть метод типа fun getData(): Data. Мы можем протестировать unit-тестом что вернул этот метод, но не то, как его использует View.
В случае с MVP в presenter не может быть get-методов. View очень глупая и только presenter говорит View что делать, а значит можно тщательнее протестировать поведение UI unit-тестом presenter'а.
Так что если покрытие тестами — не важно, то можно использовать MVVM. Если важно — то лучше MVP.