Связка Moxy MVP + Cicerone Navigation

С момента моего знакомства с фрагментами в Android и использования архитектуры Single Activity в приложениях мне приходилось писать менеджер навигации вручную. Иногда это доставляло много неудобств и проблем с сохранением состояния изменений параметров конфигурации в Activity. Я постоянно изучал что-то новое в работе с фрагментами, и в скором времени развил у себя в голове необходимую базу знаний, и всё же писать навигацию под конкретный случай занимало приличное количество времени...
Буквально год назад коллеги по работе посоветовали мне 2 хорошие библиотеки, по их словам они "должны облегчить мою жизнь". Я посмотрел и поначалу отнесся к ним скептически, т.к. навигацию я уже более-менее сам писать умел, также был немного знаком с MVVM. И всё же, начался новый проект, и была поставлена задача протестировать на нем связку Moxy + Cicerone.

В этой статье я хочу рассказать, что из этого вышло, что мне понравилось и в каких ситуациях эта связка мне показалась неудобной.
Немного о Moxy

Moxy - это реализация паттерна MVP для Android. MVP - это способ разделения ответственности в приложениях. Я не буду описывать как происходит взаимодействие между компонентами MVP в теории, это можно посмотреть в Wikipedia, и более того, большая часть разработчиков уже это знают. Важно то, что в Android всё усложняется пересозданием Activity при изменении любого параметра конфигурации (локализация, ориентация и размер экрана, размер шрифта и другие), и если это изменение происходит во время работы приложения, то состояние Activity необходимо где-то хранить. Есть встроенные способы сохранения состояния через переопределение методов onSaveInstanceState и onRestoreInstanceState, но зачастую этот метод нарушает архитектуру приложения. Разработчики Moxy сделали всё, чтобы нам можно было не думать о сохранении состояния Activity или Fragment. Взглянем на схему:
Рис. 1 - Схема работы Moxy реализации
Как видно, здесь не совсем обычный MVP, т.к. есть четвертый компонент: ViewState. Именно он отвечает за сохранение состояние View после пересоздания. Также ViewState позволяет одному Presenter обслуживать несколько View одновременно или по очереди. Как видно из схемы, новое View, которое присоединилось к Presenter позже первого, получило то же самое состояние, что и первая View. В некоторых случаях это очень удобно.

Если данная схема кажется вам очень сложной, то это только поначалу. На практике становится всё понятно. Взгляните на код:


Как видно из примера, Moxy полностью придерживается изначальной идеи паттерна MVP. При правильном распределении обязанностей между View и Presenter (а также правильной настройке Moxy Strategy) переопределение методов onSaveInstanceState и onRestoreInstanceState вообще не понадобится, т.к. все данные будут хранится непосредственно в Presenter.
Немного о Cicerone

Название никак не связано с древнеримским политическим деятелем. Скорее оно связано со значением старого итальянского слова "чи-че-ро-не", что означало "гид для иностранцев".

Если подходить к навигации с точки зрения паттерна, то это больше бизнес-логика. Но чтобы открыть Activity в Android, как минимум нужен Context, и передача его в сущность, занимающуюся бизнес-логикой (presenter, viewModel, и т.д.) - это анти-паттерн. А если вы используете архитектуру Single Activity, то для переключения фрагментов требуется учитывать жизненный цикл контейнера (отсылка к java.lang.IllegalStateException: Can not perform this action after onSaveInstanceState у фрагментов).

Решение от Cicerone полностью устраняет эти проблемы. Чтобы понять, как это работает, взглянем на структуру:

Рис. 2 - Реализация навигации через Cicerone
Navigator - непосредственная реализация переключения экранов;

Command - команда перехода, которую выполняет Navigator;

CommandBuffer - менеджер, осуществляющий доставку команд навигатору, а также хранение, если навигатор по определенным причинам не может их выполнить в момент поступления;

NavigatorHolder - посредник между Navigator и CommandBuffer (не указан на схеме);

Router - объект, генерирующий низкоуровневые команды для Navigator посредством вызова высокоуровневых методов разработчиком для осуществления навигации;

Screen - высокоуровневая реализация конкретного экрана.


Изначально Cicerone имеет 4 базовые команды переходов:

  • Back() - удаляет текущий экран из стека и делает активным предыдущий экран;
  • BackTo(Screen) - возвращает на указанный экран, если он есть в цепочке, и удаляет все экраны, что были впереди. Если указанный экран не будет найден в стеке или вместо экрана передать null, то переход осуществится на корневой (root) экран;
  • Forward(Screen) - добавляет экран в стек и делает его активным;
  • Replace(Screen) - заменяет активный экран на указанный.

Из этих команд в классе Router сформированы шесть базовых высокоуровневых команд:

  1. navigateTo(Screen) - переход на новый экран;
  2. newScreenChain(Screen) - сброс стека до корневого экрана и открытие одного нового;
  3. newRootScreen(Screen) - сброс стека и замена корневого экрана;
  4. replaceScreen(Screen) - замена активного экрана;
  5. backTo(Screen) - возврат на любой экран в стеке;
  6. exit() - выход с активного экрана.


Оба этих списка могут быть расширены под конкретные нужды разработчика. В первом случае нужно реализовать интерфейс Command, во втором - расширить класс Router.

Чтобы добавить анимацию переходов между экранами, нужно либо полностью реализовать интерфейс Navigator, либо переопределить у базового экземпляра SupportAppNavigator метод setupFragmentTransaction, в котором в качестве параметра имеется экземпляр класса FragmentTransaction. Именно ему нужно добавить либо Transition, либо Animation.

Предлагаю посмотреть небольшой пример использования Cicerone в связке с Moxy:

В моем случае я использовал Dagger, поэтому не стал делать Cicerone, Router и NavigatorHolder статическими (companion), как это описывается в туториале Cicerone.

Как можно заметить, Cicerone полностью абстрагируется от Fragment и Activity, используя класс SupportAppScreen. Кстати, в SupportAppScreen можно передавать не только Fragment, но и Activity, ScreenKey и параметры, которые можно передать через Bundle. У нас в команде принято создавать статический метод newInstance(params), который возвращает экземпляр фрагмента или Intent для Activity и заполняет Bundle параметрами, поэтому я не воспользовался передачей параметров через Cicerone.

Заключение
Данная связка показала себя очень хорошо, особенно вместе с Dagger. Но не всё так хорошо, как хотелось бы.

Я не смог с помощью Moxy расшарить Presenter от родительского фрагмента между дочерними фрагментами, которые находились во ViewPager. Поскольку Dagger не умеет внедрять приватные поля, то все поля, помеченные аннотациями @Inject или @InjectPresenter, должны быть публичными. Единственный быстрый выход, который я нашел, это обращаться к parentFragment, приводить его к нужному типу и таким образом обращаться к Presenter. Также я успел почитать про Dagger Scopes, но не успел с этим разобраться.

Я не смог с помощью Cicerone сделать 2 и более отдельных стека экранов с возможностью переключения между ними. В одном проекте мне требовалось реализовать это, и мне пришлось писать свою навигацию. Также возникают трудности, если хочется сделать несколько FragmentContainer, то приходится создавать несколько Navigator и переключаться между ними.

Я не исключаю того факта, что я плохо ищу информацию или я слишком много хочу. В любом случае, такие специфические требования появляются довольно редко, поэтому в большинстве случаев использование данных библиотек оправдывает себя на 100%.

Список ресурсов, которые помогли написать мне эту статью:
Спасибо за внимание!