November 25, 2019

Moxy — реализация MVP под Android с щепоткой магии

Что такое MVP

MVP – это способ разделения ответственности в коде приложения. Model предоставляет данные для Presenter. View выполняет две функции: реагирует на команды от пользователя (или от элементов UI), передавая эти события в Presenter, и изменяет gui по требованию Presenter. Presenter выступает как связующее звено между View и Model. Presenter получает события из View, обрабатывает их (используя или не используя Model) и командует View о том, как она должна себя изменить.

У такого подхода к разделению ответственности есть ряд плюсов:

  1. Сильно упрощается написание тестов к коду.
  2. Легко менять какую-то часть, не ломая при этом другую.
  3. Код разбивается на мелкие кусочки, за счёт чего он становится более понятным и читабельным.

В то же время, конечно, есть и минусы:

  1. Кода становится больше.
  2. К этому подходу нужно привыкать.
  3. На момент написания статьи не сильно распространённый (но известный) подход, поэтому приходится всем рассказывать о нём.

(Здесь и далее стоит учитывать, что статья написана в феврале 2016 года и часть информации в ней может оказаться немного устаревшей - прим.ред.)

MVP в Android

Activity в Android является God object. На ней обычно лежит следующая ответственность:

  • Полное управление GUI.
  • Обработка взаимодействия с пользователем.
  • Запуск асинхронных задач.
  • Обработка результата асинхронной задачи.

Самое печальное, наш God Object не бессмертен – Activity ещё и умирает при смене конфигурации.

MVP снимает часть ответственности с Activity. Вся работа с асинхронными задачами уходит в Presenter. Вся бизнес-логика – в Presenter и Model. Activity, в свою очередь, становится View. Она начинает просто отображать то, что скажет Presenter, и передаёт события в Presenter, чтобы тот решал, как быть дальше.

Перед написанием своего решения мы изучили множество статей и реализаций концепции MVP в Android (см. ссылки в конце статьи). На основании анализа сложился список требований к решению:

  1. View должна привязываться к уже имеющемуся Presenter при смене конфигурации.
  2. После привязывания View к уже имеющемуся Presenter, View должна отображать актуальное состояние Presenter.
  3. Presenter должен уметь (при необходимости) жить независимо от того, кто на него подписан или от него отписался.

На данный момент ни одно из существующих решений не умеет делать все эти пункты одновременно. Как нам сперва показалось, больше всего нам подходила библиотека Mosby. Но позже выяснилось, что при её использовании нам пришлось бы писать слишком много кода, каждый раз. Особенно для реализации первых двух пунктов из нашего списка требований. Поэтому было принято решение разработать собственное решение.

Moxy – теория

Наше решение сильно отличается от всех прочих (даже сама концепция MVP была модернизирована) тем, что между View и Presenter затесался ViewState. Причём он там абсолютно необходим. Он отвечает за то, чтобы каждая View всегда выглядела именно так, как того хочет Presenter. ViewState хранит в себе список команд, которые были переданы из Presenter во View. И когда „новая“ View присоединяется к Presenter, ViewState автоматически применяет к ней все команды, которые Presenter выдавал раньше. Таким образом получается, что, не зависимо от того, что произойдёт со View по вине Android, View останется всё равно в правильном состоянии. Для этого вам нужно будет только привыкнуть изменять View исключительно командами из Presenter. Заметим, что это одно из основных правил MVP и распространяется не только на Moxy.

Схематичная иллюстрация того, как это работает:

Что происходит на этой схеме (фигуры-картинки были заменены на подходящие по цвету и форме эмодзи):

  1. Во View происходит событие 🟦, которое передаётся в Presenter.
  2. Presenter передаёт команду 🔴 во ViewState.
  3. Presenter стартует асинхронный запрос 🟩 в Model.
  4. ViewState складывает команду 🔴 в очередь команд, после чего передаёт её во View.
  5. View приводит себя в состояние, указанное в команде 🔴.
  6. Presenter получает результат запроса 🟩 из Model.
  7. Presenter передаёт во ViewState две команды 🟢 и 🔵.
  8. ViewState сохраняет команды 🟢 и 🔵 в очередь команд и передаёт их во View.
  9. View приводит себя в состояние, указанное в командах 🟢 и 🔵.
  10. Новая/пересозданная View присоединяется к уже имеющемуся Presenter.
  11. ViewState передаёт сохранённый список команд в новую/пересозданную View.
  12. Новая/пересозданная View приводит себя в состояние, указанное в командах 🔴, 🟢 и 🔵.

Moxy – возможности

У Moxy есть несколько весомых преимущества перед другими решениями:

  • Presenter не пересоздаётся при пересоздании Activity (это в разы упрощает работу с многопоточностью).
  • Автоматизация полного восстановления того, что видит пользователь при пересоздании Activity (в том числе при динамическом добавлении элементов Android View).
  • Возможность из одного Presenter менять сразу несколько View (на практике оказалось чрезвычайно удобно).

Для этого в Moxy есть несколько механизмов, которые можно комбинировать между собой так, как вам будет угодно. Самыми весомыми механизмами являются аннотации, на основании которых генерируется код. А во время исполнения программы инструмент под названием MvpDelegate начинает полноценно использовать сгенерированный код.

Доступны следующие аннотации:

  • @InjectPresenter – аннотация для управления жизненным циклом Presenter.
  • @InjectViewState – аннотация для привязывания ViewState к Presenter.
  • @StateStrategyType – аннотация для управления стратегией добавления и удаления команды из очереди команд во ViewState.
  • @GenerateViewState – аннотация для генерации кода ViewState для определенного интерфейса View.

Обо всём этом далее.

Moxy – MvpPresenter

Каждое приложение содержит в себе какую-то бизнес-логику. В концепции MVP, вся бизнес-логика располагается в Presenter и в Model. По факту это значит, что вы практически не программируете во View. Для того чтобы ваш Presenter не превратился в God Object, нужно разделять каждый отдельный блок бизнес-логики в отдельный Presenter. В таком случае у вас получится много Presenter, но они будут очень простыми и понятными. Например, если у вас на одном экране было две бизнес-логики, а затем они разошлись на 2 разных экрана, то вы просто измените View. А Presenter какими были, такими и останутся. Также в этом случае вы сможете легко переиспользовать один Presenter в нескольких местах (например, BasketPresenter, сквозной через всё приложение). Ещё это упростит тестирование кода – вы просто проверите небольшой Presenter, что он всё делает правильно.

Для Presenter в Moxy заведен класс MvpPresenter<View : MvpView>. В MvpPresenter содержится экземпляр ViewState, который в то же время должен реализовывать тот самый тип View, который пришёл в MvpPresenter. Доступ к этому экземпляру ViewState можно получить из метода fun getViewState(): View. А во время разработки вы не думаете, что работаете со ViewState, а просто даёте через этот метод команды для View как ей измениться. Также есть методы для привязывания/отвязывания View от Presenter (fun attachView(view: View) и fun detachView(view: View)). Обратите внимание на то, что к одному Presenter может быть привязано несколько View. Они будут всегда иметь актуальное состояние (за счёт ViewState). А если вы хотите, чтобы привязывание/отвязывание View проходило не через стандартное поле ViewState, то можете переопределить эти методы и работать с пришедшей View как хотите.

Например, вы можете захотеть использовать нестандартный ViewState, который не реализует интерфейс View, если вам нужно.

В классе MvpPresenter также есть интересный метод protected fun onFirstViewAttach(). Очень важно понять, когда этот метод будет вызван и зачем он нужен. Этот метод вызывается тогда, когда к конкретному экземпляру Presenter первый раз будет привязана любая View. А когда к этому Presenter будет привязана другая View, к ней уже будет применено состояние из ViewState. И здесь уже не важно, эта новая View – совсем другая View или пересозданная в результате смены конфигурации. Этот метод подходит для того, чтобы, например, загрузить список новостей при первом открытии экрана списка новостей.

В момент, когда во View пришла команда, вам может потребоваться понять, это новая команда, или это команда для восстановления состояния? Например, если это свежая команда, то нужно применить команду с анимацией. А иначе не надо применять анимацию. Можно это сделать через разные StateStrategy или через сложные флаги в Bundle savedState. Но правильным решение будет использовать метод Presenter (или ViewState) fun isInRestoreState(view: View): Boolean, который сообщит вам, в каком состоянии находится конкретная View. Таким образом вы сможете понять, нужна ли вам анимация, или нет.

Moxy – MvpView и MvpViewState

Самым простым компонентом MVP является View. Вам нужно завести интерфейс, который наследуется от интерфейса-маркера MvpView и описать в нём методы, которые будет уметь выполнять View. В дополнение ко View, наша библиотека имеет сущность ViewState, которая непосредственно связана со View. ViewState является наследником MvpViewState<View : MvpView>. Он управляет одним или несколькими View (все одного типа View). И каждый ра, когда во ViewState приходит команда из Presenter, ViewState отправляет её всем View, о которых он знает. Также у MvpViewState есть метод protected abstract fun restoreState(view: View), который будет вызван, когда какая-нибудь View будет пересоздана или когда к Presenter ко ViewState будет привязана новая View. Именно после того как выполнится этот метод, "новая" View примет нужное состояние.

Стоит заметить, что MvpViewState хранит в себе список всех привязанных к нему View. И будет хорошо, если вы не будете забывать отвязывать View, которые уже уничтожены. Но если вы вдруг забудете это сделать, сильно не переживайте – в MvpViewState хранятся не прямые ссылки на View, а WeakReference, что всё-таки поможет GC. А в случае если вы используете такой механизм как MvpDelegate, то можете не беспокоиться об этом – он как привязывает View к Presenter, так и отвязывает их.

Moxy – @GenerateViewState и @InjectViewState

Так как ViewState в большинстве случаев является довольно однообразной прослойкой между View и Presenter, был написан генератор кода, который сделает за вас всю грязную работу. Применяя аннотацию @GenerateViewState к вашему интерфейсу View, вы получите сгенерированный класс ViewState. И чтобы вам не пришлось в Presenter самостоятельно искать и создавать экземпляр этого класса, есть аннотация @InjectViewState. Достаточно просто применить её к классу вашего Presenter. Дальше MvpPresenter сам всё сделает – он создаст экземпляр этого ViewState, сложит его себе в качестве поля и будет везде использовать его. Вам же просто останется работать с методом fun getViewState(): View из MvpPresenter.

В том случае если вы не хотите использовать @GenerateViewState, но ваш ViewState реализует интерфейс View, вы можете по прежнему использовать аннотацию @InjectViewState. В таком случае передайте в эту аннотацию в качестве параметра класс вашего ViewState.

Будьте аккуратны при применении аннотации @InjectViewState к типизируемому Presenter.

Например, если у вас есть такой код:

@InjectViewState
class MyPresenter<T : MvpView> : MvpPresenter<T> {
    // pass
}

annotation processor неправильно поймёт класс View, ViewState которого нужно использовать. В таком случае вы можете явно передать класс View в параметр view аннотации @InjectViewState.

Также учтите, что интерфейс, аннотированный @GenerateViewState, должен быть нетипизированным.

Загвоздка заключается в том, что при генерации кода мы должны знать типы всех параметров всех методов. Иначе полученный код будет неработоспособным.

Поэтому такой код писать можно:

@GenerateViewState
interface ConcreteInterface : AbstractInterface<String> { 
    // pass
}

А такой код писать нельзя:

@GenerateViewState
interface ConcreteInterface<Type> : AbstractInterface<Type> {
    // pass
}

Moxy – StateStrategy для команд во ViewState

По умолчанию все команды для View сохраняются во ViewState просто в том порядке, в котором они туда поступали. И после того как команды были применены, они продолжают лежать в этой очереди. Но это поведение можно поменять, применяя аннотацию @StateStrategyType к интерфейсу View и к его методам. На вход эта аннотация получает параметр, в котором вы должны указать класс StateStrategy, который вы хотите использовать. Если применить эту аннотацию ко всему интерфейсу View, то те методы, для которых стратегия не указана, будут использовать эту стратегию.

StateStrategy управляет очередью команд через два метода: fun beforeApply и fun afterApply. Первый метод будет вызван перед тем, как команда будет отправлена во View (метод beforeApply будет вызван сразу, как только поступит какая-то команда из Presenter). В этом месте, в стратегии, указанной по умолчанию, и происходит добавление команды в очередь. Второй метод afterApply будет вызван каждый раз, когда команда будет применена ко View. И в первом, и во втором методе вы можете менять список команд как хотите.

Давайте рассмотрим стратегии, которые уже реализованы в Moxy:

  • AddToEndStrategy – добавит пришедшую команду в конец очереди. Используется по умолчанию.
  • AddToEndSingleStrategy – добавит пришедшую команду в конец очереди команд. Причём, если команда такого типа уже есть в очереди, то уже существующая будет удалена.
  • SingleStateStrategy – очистит всю очередь команд, после чего добавит себя в неё.
  • SkipStrategy – команда не будет добавлена в очередь и никак не изменит ее.

Если же у вас какая-то специфичная логика и вам не хватает этих стратегий, то вы можете сделать свою стратегию. В этом случае вам поможет механизм тегирования методов. В аннотацию @StateStrategyType можно передать параметр tag (по умолчанию является названием метода). Затем, по этому тегу, вы сможете в методах fun beforeApply(currentState: List<ViewCommand<View>>, incomingCommand: ViewCommand<View>) и fun afterApply(currentState: List<ViewCommand<View>>, incomingCommand: ViewCommand<View>) понять, что за ViewComand вам пришли (из метода ViewCommand fun getTag(): String).

Перед написанием своих стратегий посмотрите на код уже реализованных – возможно он будет вам полезен.

Moxy – MvpDelegate и жизненный цикл MvpPresenter

Сам по себе Presenter нигде не создаётся, нигде не хранится и ниоткуда не достаётся. И чтобы вам не пришлось ничего придумывать для решения этих задач, мы сделали такой механизм как MvpDelegate. Он следит за тем, чтобы там, где есть его экземпляр, были правильно инициализированы все Presenter. Для этого от вас требуется только передать в него все основные моменты жизненного цикла вашей View. Посмотреть, какие методы когда вызывать, вы можете в классе MvpActivity или MvpFragment.

Для того чтобы MvpDelegate нашел все Presenter, вы должны отметить их аннотацией @InjectPresenter. Эта аннотация очень мощная. Через неё вы можете управлять тем, сколько времени будет жить Presenter. Если вы хотите, чтобы Presenter жил, только пока есть View, в которой он содержится (+ пока происходит смена конфигурации), то просто добавьте эту аннотацию к полю Presenter. В случае если вы хотите, чтобы Presenter жил независимо от того, кто и когда на него подписан, вам нужно будет сделать две вещи. Первое – нужно сообщить MvpDelegate, что Presenter не привязан к жизненному циклу того, кто его запросил. Для этого нужно выставить значение параметра type аннотации @InjectPresenter как PresenterType.GLOBAL. Второе – вы должны передать MvpDelegate информацию, по которой он сможет найти нужный вам Presenter в хранилище всех Presenter. Есть два варианта, как это сделать.

Первый вариант. В аннотации @InjectPresenter вы выставляете значение для параметра tag. Тогда MvpDelegate попытается найти в глобальном хранилище Presenter с таким тэгом. Если он его найдёт, то просто установит его в это поле. Иначе он создаст подходящий Presenter, сложит его в хранилище, и установит его в это поле. С учётом того, что к одному Presenter может быть привязано несколько View, этот механизм открывает очень много возможностей перед вами.

Второй вариант (для параметризированного тэга). По сути, он похож на первый вариант. Отличие лишь в том, что во втором случае вы не можете заранее знать, какой тэг будет у Presenter. Т.е. тэг должен генерироваться динамически. Тогда вам придётся немного постараться:

  1. Создайте свою реализацию PresenterFactory.
  2. В аннотацию @InjectPresenter установите параметры:
    – В factory установите класс вашей PresenterFactory.
    – В presenterId установите строковый идентификатор Presenter (это нужно для того, чтобы различать в одном классе Presenter с одинаковыми фабриками).
  3. Заведите свой интерфейс, содержащий один и только один метод, который будет возвращать параметр для factory нужного типа:
    – Аннотируйте этот интерфейс как @ParamsProvider(PresenterFactoryClass), передав аннотации, в качестве параметра, класс вашей PresenterFactory.
    – Опишите метод, который будет возвращать параметр, должен на вход получать один параметр String (в этот параметр придёт тот самый параметр presenterId из аннотации @InjectPresenter).
  4. Объект, который содержит Presenter, в аннотации @InjectPresenter которого указана эта PresenterFactory, обязан реализовывать созданный в п.3 интерфейс.

Здесь вам стоит знать, что вам не показалось, что это место слишком запутанно. Так и есть, оно запутанно. Просто знайте, что если вам потребуется такая функциональность, следуйте этому небольшому списку правил, и вы сами всё поймёте и у вас всё получится.

Кроме указанной ранее функциональности, MvpDelegate умеет быть родительским/дочерним делегатом для другого. Это необходимо для того, чтобы вы могли автоматизировать жизненный цикл Presenter не только внутри Activity/Fragment, но и внутри других элементов, у которых нет самостоятельного жизненного цикла (например, в адаптере или даже во ViewHolder элемента адаптера). Если вы установите для одного MvpDelegate в качестве родительского другой MvpDelegate, то делегат-потомок будет получать все события жизненного цикла делегата-родителя. Для этого просто вызовите у целевого MvpDelegate метод fun setParentDelegate(delegate: MvpDelegate, childId: String). В качестве delegate он ожидает получить родительский MvpDelegate. В качестве childId вы должны указать уникальный идентификатор, по которому локальные Presenter одного делегата-потомка будут отличаться от локальных Presenter другого делегата-потомка.

Отметим, что если у родительского MvpDelegate уже был вызван метод onCreate. то вам необходимо самостоятельно вызвать метод onCreate у делегата-потомка. Почему это важно? Чтобы это понять, разберёмся, как работает MvpDelegate.

MvpDelegate, кроме того что управляет инициализацией полей Presenter, делает ещё одну очень важную вещь. Он привязывает и отвязывает View от Presenter. Привязывание View к Presenter происходит в методе onStart, а отвязывание – в методе onDestroy. У Fragment немного по-другому, см. на github.

Почему именно в этих методах?

После вызова onCreate у MvpDelegate все поля, отмеченные аннотацией @InjectPresenter, готовы к работе. Но к ним ещё не привязана View. View будет привязан к Presenter после того, как будет вызван метод MvpDelegate fun onStart().

После этого Presenter может взаимодействовать со View (и тогда, если к этому Presenter впервые была привязана View, будет вызван метод Presenter fun onFirstViewAttached()). После вызова fun onDestroy() у MvpDelegate, View будет отвязана от Presenter. И тут возникает два вопроса. Во-первых, почему View привязывается к Presenter не в onCreate, а в onStart? Во-вторых, раз привязывание произошло в onStart, то почему отвязывание не в onStop, а в onDestroy? Вполне резонные вопросы. А ответ на них заключается в том, что так а) удобней, и б) проще.

Удобней это тем, что ViewState применяется ко View сразу, как только View была привязана к Presenter. И если выполнять привязывание View к Presenter в onCreate, то получается, что вам нужно будет в Activity самостоятельно вызвать метод делегата onCreate, после того как вы в onCreate Activity выполните всю инициализацию Android View. Это неудобно. Удобно просто сделать одну Activity, от которой будут наследоваться все Activity вашего приложения, и в методе onCreate этой Activity просто выполнить метод делегата onCreate. А с учётом того, что привязывание View происходит в onStart, никаких проблем не будет.

Во-вторых, если делать отвязывание View в onStop, тогда привязывание точно будет происходит при каждом onStart (сейчас привязывание View происходит в onStart, только если до этого был выполнен onCreate). А значит и ViewState будет восстановлен при каждом onStart. А значит всё состояние будет накатываться заново, даже если Activity View не было уничтожено, а просто становилось невидимым на время. Поэтому отвязывание View от Presenter происходит в onDestroy.

Прим.: onDestroy не будет вызван, если Android решит убить процесс Activity, но в таком случае и Presenter будет уничтожен.

MvpDelegate использует специальное хранилище для Presenter. Доступ к этому хранилищу он получает через MvpFacade. MvpFacade содержит в себе хранилище Presenter и некоторые другие элементы, призванные помочь MvpDelegate делать его работу оптимально. Несмотря на то что MvpFacade является синглтоном, будет здорово, если вы выполните его метод fun init(), например, в методе onCreate() вашего Application. Или вы можете наследовать ваш Application от MvpApplication, поставляемого в Moxy. Тогда в момент, когда MvpDelegate обратится к этому синглтону, он уже будет готов к работе.

Moxy – Model

Важным элементом MVP является Model. Но в Moxy эта часть MVP никак не затронута. Всё дело в том, что в этом нет смысла. В каждом проекте свои требования к Model. Где-то Model – это просто набор классов для работы с API и сама работа с API (например, через Retrofit). Где-то в Model входит ещё и дополнительная бизнес-логика. В каких-то проектах актуально использование подхода Clean Architecture. В таком случае внутри Model появляются дополнительные сущности, например, Interactor и Repository. А с учётом того, что Presenter полностью отвязан от жизненного цикла Activity, вы можете спокойно создавать экземпляр конкретной Model внутри Presenter и работать с ним. Используя DI, вы можете подключать нужную Model в Presenter. А в будущем, используя тот же DI, спокойно подменять Model для тестов.

В любом случае, крайне удобно для работы с Model использовать Rx. Тогда вы можете сделать так, чтобы публичные методы Model возвращали Observable. В таком случае будет легко сделать взаимодействие Model ⇒ Presenter, и в то же время Model ⇔ Model. Это даст возможность легко сделать параллельное исполнение запросов из Presenter в Model.

Moxy – итого

В результате мы имеем библиотеку, которая решает все проблемы жизненного цикла. Вы всегда будете показывать пользователю именно то состояние, которое для него актуально, и, в то же время, вам не придётся делать ничего лишнего. Только опишите все команды для View отдельными методами. И избегайте изменения View из самого View. Если вы показали диалог командой из Presenter, то и при закрытии диалога должна быть команда из Presenter. Иначе ViewState снова покажет вам диалог после смены конфигурации.

Хотелось бы заметить, что библиотека никак не ограничивает вас в выборе реализации многопоточности в вашем приложении. Вы можете использовать Rx, AsyncTask, Thread, Executor. Главное – будьте аккуратны, работайте со View только с главного потока. Ещё Moxy не решит проблем с commit() фрагментов после выполнения onSaveInstanceState(). Поэтому не забудьте закрывать транзакцию, используя commitAllowingStateLoss(). Также она не решит проблем с утечкой памяти – если вы передадите ссылку на Context/Activity/Fragment в Presenter (а потом ещё и во ViewState), то память может утечь. Будьте аккуратны.

Полезные материалы

Moxy не получилась бы такой, какой она получилась, если бы не многочисленные труды других людей. Вот некоторые из них:

  • Android Application Architecture (Android Dev Summit 2015)
  • Android Testing Codelab – тема MVP затронута не сильно, но можно что-то для себя почерпнуть. Также можно посмотреть, как тестировать MVP.
  • Nucleus – пример реализации MVP с замашкой на обработку жизненного цикла.
  • Mosby – лучшая реализация MVP до релиза Moxy. Отлично расписаны сами принципы MVP, которые критично понять.
  • Old Mosby – руководство к первой версии Mosby. Крайне полезное для понимания того, что такое MVP.
  • STINSON'S PLAYBOOK FOR MOSBY – набор советов, который очень поможет вам определиться с некоторыми понятиями MVP. Также дополнительно объяснит, какая часть программы каким компонентом должна стать.
  • Android Reactive MVP: практика – ещё одно видение, как должна выглядит структура Android-приложения, построенного на MVP.
  • Android Clean Architecture – поможет понять, что такое модель и на какие компоненты её можно разложить.
  • Алексей Макаров. Speaker Clean Architecture и MVP – хороший доклад про Clean Architecture и хорошие вопросы в конце.
  • Mosby issues 85 – помогает понять, что из себя должен представлять Repository.

Moxy – где брать

Исходники библиотеки можно найти на Github:

https://github.com/Arello-Mobile/Moxy

Полноценный пример приложения, использующего Moxy:

https://github.com/Arello-Mobile/Moxy/tree/master/sample-github

В момент, когда мы соберём репрезентативный список вопросов по нашей библиотеке, по тому, как её использовать, по MVP в целом, будет сделана отдельная статья, в которой будут освещены самые популярные/интересные вопросы. Вопросы можно задавать в комментариях, писать мне (senneco) и ещё одному автору библиотеки – Xanderblinov. Или можете обращаться ко всему отделу Android-разработки Arello Mobile, написав на [email protected].

Источник: Moxy — реализация MVP под Android с щепоткой магии