Cicerone — простая навигация в Андроид приложении
На этой схеме не скелет древнего обитателя водных глубин и не схема метро какого-то мегаполиса, это карта переходов по экранам вполне реального Андроид приложения! Но, несмотря на сложность, нам удалось её удачно реализовать, а решение оформить в виде небольшой библиотеки, о которой и пойдет речь в статье.
Чтобы заранее избежать вопросов о названии, уточню: Cicerone ("чи-че-ро́-не") – устаревшее слово с итальянскими корнями, со значением «гид для иностранцев».
В наших проектах мы стараемся придерживаться архитектурных подходов, которые позволяют отделить логику от отображения.
Так как я в этом плане предпочитаю MVP, то далее по тексту будет часто встречаться слово «презентер», но хочу отметить, что представленное решение никак не ограничивает вас в выборе архитектуры (можно даже использовать в классическом подходе «все во Fragment’ах», и даже в этом случае Cicerone даст свой профит!).
Навигация – это скорее бизнес-логика, поэтому ответственность за переходы я предпочитаю возлагать на презентер. Но в Андроиде не все так гладко: для осуществления переходов между Activity, переключения Fragment’ов или смены View внутри контейнера
- не обойтись без зависимости от Context’a, который не хочется передавать в слой логики, связывая тем самым его с платформой, усложняя тестирование и рискуя получить утечки памяти (если забыть очистить ссылку);
- надо учитывать жизненный цикл контейнера (например, java.lang.IllegalStateException: Can not perform this action after onSaveInstanceState у Fragment’ов).
Поэтому и появилось решение, реализованное в Cicerone. Начать, думаю, стоит со структуры.
Структура
На схеме есть четыре сущности:
- Command – это простейшая команда перехода, которую выполняет Navigator.
- Navigator – непосредственная реализация «переключения экранов» внутри контейнера.
- Router – это класс, который превращает высокоуровневые вызовы навигации презентера в набор Command.
- CommandBuffer – отвечает за сохранность вызванных команд навигации, если в момент их вызова нет возможности осуществить переход.
Теперь о каждой подробнее.
Команды переходов
Мы заметили, что любую карту переходов (даже достаточно сложную, как на первом изображении) можно реализовать, используя четыре базовых перехода, комбинируя которые, мы получим необходимое поведение.
ForwardForward (String screenKey, Object transitionData) – команда, которая осуществляет переход на новый экран, добавляя его в текущую цепочку экранов. screenKey – уникальный ключ, для каждого экрана. transitionData – данные, необходимые новому экрану.
Буквой R обозначен корневой экран, его особенность только в том, что при выходе с этого экрана, мы выйдем из приложения.
Back() – команда, удаляющая последний активный экран из цепочки, и возвращающая на предыдущий. При вызове на корневом экране ожидается выход из приложения.
BackToBackTo(String screenKey) – команда, позволяющая вернуться на любой из экранов в цепочке, достаточно указать его ключ. Если в цепочке два экрана с одинаковым ключом, то выбран будет последний (самый «правый»).
Стоит отметить, что если указанный экран не найден, либо в параметр ключа передать null, то будет осуществлен переход на корневой экран.
ReplaceReplace (String screenKey, Object transitionData) – команда, заменяющая активный экран на новый. Кто-то может возразить, что этого результата удастся достичь, вызвав подряд команды Back и Forward, но тогда на корневом экране мы выйдем из приложения!
Вот и всё! Этих четырёх команд на практике достаточно для построения любых переходов. Но есть ещё одна команда, которая не относится к навигации, однако очень полезна на практике.
SystemMessageSystemMessage (String message) – команда, отображающая системное сообщение (Alert, Toast, Snack и т. д.).
Иногда необходимо выйти с экрана и показать сообщение пользователю. Например, что мы сохранили сделанные изменения. Но экран, на который мы возвращаемся, не должен знать о чужой логике, и поэтому мы вынесли показ таких сообщений в отдельную команду. Это очень удобно!
Navigator
Команды сами по себе не реализуют переключение экранов, а только описывают эти переходы. За их выполнение отвечает Navigator.
В зависимости от задачи, Navigator будет реализован по-разному, но он всегда будет там, где находится контейнер для переключаемых экранов.
- В Activity для переключения Fragment’ов.
- Во Fragment’е для переключения вложенных (child) Fragment’ов.
- … ваш вариант.
Так как в подавляющем большинстве Андроид приложений навигация опирается на переключение Fragment’ов внутри Activity, чтобы не писать однотипный код, в библиотеке уже есть готовый FragmentNavigator (и SupportFragmentNavigator для SupportFragment’ов), реализующий представленные команды.
1) передать в конструктор ID контейнера и FragmentManager; 2) реализовать методы выхода из приложения и отображения системного сообщения; 3) реализовать создание Fragment’ов по screenKey.
В приложении необязательно должен быть один Navigator. Пример (тоже реальный, кстати): в Activity есть BottomBar, который доступен для пользователя ВСЕГДА. Но в каждом табе есть собственная навигация, которая сохраняется при переключении табов в BottomBar’е.
Router
Как было сказано выше, комбинируя команды, можно реализовать любой переход. Именно этой задачей и занимается Router.
Например, если стоит задача по некоторому событию в презентере:
1) скинуть всю цепочку до корневого экрана; 2) заменить корневой экран на новый; 3) и еще показать системное сообщение;
то в Router добавляется метод, который передает последовательность из трёх команд на выполнение в CommandBuffer:
В библиотеке есть готовый Router, используемый по-умолчанию, с самыми необходимыми переходами, но как и с навигатором, никто не запрещает создать свою реализацию.
navigateTo() – переход на новый экран. newScreenChain() – сброс цепочки до корневого экрана и открытие одного нового. newRootScreen() – сброс цепочки и замена корневого экрана. replaceScreen() – замена текущего экрана. backTo() – возврат на любой экран в цепочке. exit() – выход с экрана. exitWithMessage() – выход с экрана + отображение сообщения. showSystemMessage() – отображение системного сообщения.
CommandBuffer
CommandBuffer – класс, который отвечает за доставку команд навигации Navigator’у. Логично, что ссылка на экземпляр навигатора хранится в CommandBuffer’е. Она попадает туда через интерфейс NavigatorHolder:
Кроме того, если в CommandBuffer поступят команды, а в данный момент он не содержит Navigator’а, то они сохранятся в очереди, и будут выполнены сразу при установке нового Navigator’а. Именно благодаря CommandBuffer’у удалось решить все проблемы жизненного цикла.
Конкретный пример для Activity:
От теории к практике. Как использовать Cicerone?
Предположим, мы хотим реализовать навигацию на Fragment’ах в MainActivity: Добавляем зависимость в build.gradle
В классе SampleApplication инициализируем готовый роутер
В MainActivity создаем навигатор:
Теперь из любого места приложения (в идеале из презентера) можно вызывать методы роутера:
Частные случаи и их решение
Single Activity?Нет! Но Activity я не рассматриваю как экраны, только как контейнеры. Смотрите: Router создан в классе Application, поэтому при переходе с одного Activity на другое, просто будет меняться активный навигатор, поэтому вполне можно делить приложение на независимые Activity, внутри которых будут уже переключения экранов. Конечно, стоит понимать, что цепочки экранов в таком случае будут привязаны к отдельным Activity, и команда BackTo() сработает только в контексте одного Activity.
Вложенная навигацияЯ выше приводил пример, но повторюсь снова:
Есть Activity с табами. Стоит задача, чтобы внутри каждого таба была независимая цепочка экранов, сохраняющаяся при смене таба.
Решается это двумя типами навигации: глобальной и локальной.
GlobalRouter – роутер приложения, связанный с навигатором Activity. Презентер, обрабатывающий клики по табам, вызывает команды у GlobalRouter.
LocalRouter – роутеры внутри каждого Fragment’а-контейнера. Навигатор для LocalRouter'а реализует сам Fragment-контейнер. Презентеры, относящиеся к локальным цепочкам внутри табов, получают для навигации LocalRouter.
Где связь? Во Fragment’ах-контейнерах есть доступ и к глобальному навигатору! В момент, когда локальная цепочка внутри таба закончилась и вызвана команда Back(), то Fragment передает её в глобальный навигатор.
А что с системной кнопкой Back?Этот вопрос специально не решается в библиотеке. Нажатие на кнопку Back надо воспринимать как взаимодействие пользователя и передавать просто как событие в презентер.
Но есть ведь Flow или Conductor?Мы смотрели другие решения, но отказались от них, так как одной из главных задач было использовать максимально стандартный подход и не создавать очередной фреймворк со своим FragmentManager’ом и BackStack’ом.
Во-первых, это позволит новым разработчикам быстро подключаться к проекту без необходимости изучения сторонних фреймворков.
Во-вторых, не придется всецело полагаться на сложные сторонние решения, что чревато затрудненной поддержкой.