- Cicerone — простая навигация в Андроид приложении
- Структура
- Команды переходов
- Forward
- BackTo
- Replace
- SystemMessage
- Navigator
- Router
- CommandBuffer
- От теории к практике. Как использовать Cicerone?
- Частные случаи и их решение
- Single Activity?
- Вложенная навигация
- А что с системной кнопкой Back?
- Но есть ведь Flow или Conductor?
- Navigation Architecture Component. Практический взгляд
- Постановка задачи
- Реализация
- Выводы
- Тестовый проект
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 – отвечает за сохранность вызванных команд навигации, если в момент их вызова нет возможности осуществить переход.
Теперь о каждой подробнее.
Команды переходов
Мы заметили, что любую карту переходов (даже достаточно сложную, как на первом изображении) можно реализовать, используя четыре базовых перехода, комбинируя которые, мы получим необходимое поведение.
Forward
Forward (String screenKey, Object transitionData) – команда, которая осуществляет переход на новый экран, добавляя его в текущую цепочку экранов.
screenKey – уникальный ключ, для каждого экрана.
transitionData – данные, необходимые новому экрану.
Буквой R обозначен корневой экран, его особенность только в том, что при выходе с этого экрана, мы выйдем из приложения.
Back() – команда, удаляющая последний активный экран из цепочки, и возвращающая на предыдущий. При вызове на корневом экране ожидается выход из приложения.
BackTo
BackTo(String screenKey) – команда, позволяющая вернуться на любой из экранов в цепочке, достаточно указать его ключ. Если в цепочке два экрана с одинаковым ключом, то выбран будет последний (самый «правый»).
Стоит отметить, что если указанный экран не найден, либо в параметр ключа передать null, то будет осуществлен переход на корневой экран.
На практике эта команда очень удобна. Например, для авторизации: два экрана. Телефон -> СМС, а потом выход на тот, с которого была запущена авторизация.
Replace
Replace (String screenKey, Object transitionData) – команда, заменяющая активный экран на новый.
Кто-то может возразить, что этого результата удастся достичь, вызвав подряд команды Back и Forward, но тогда на корневом экране мы выйдем из приложения!
Вот и всё! Этих четырёх команд на практике достаточно для построения любых переходов. Но есть ещё одна команда, которая не относится к навигации, однако очень полезна на практике.
SystemMessage
SystemMessage (String message) – команда, отображающая системное сообщение (Alert, Toast, Snack и т. д.).
Иногда необходимо выйти с экрана и показать сообщение пользователю. Например, что мы сохранили сделанные изменения. Но экран, на который мы возвращаемся, не должен знать о чужой логике, и поэтому мы вынесли показ таких сообщений в отдельную команду. Это очень удобно!
Все команды отмечены интерфейсом-маркером Command. Если вам по какой-то причине понадобилась новая команда, просто создайте её, никаких ограничений!
Navigator
Команды сами по себе не реализуют переключение экранов, а только описывают эти переходы. За их выполнение отвечает Navigator.
В зависимости от задачи, Navigator будет реализован по-разному, но он всегда будет там, где находится контейнер для переключаемых экранов.
- В Activity для переключения Fragment’ов.
- Во Fragment’е для переключения вложенных (child) Fragment’ов.
- … ваш вариант.
Так как в подавляющем большинстве Андроид приложений навигация опирается на переключение Fragment’ов внутри Activity, чтобы не писать однотипный код, в библиотеке уже есть готовый FragmentNavigator (и SupportFragmentNavigator для SupportFragment’ов), реализующий представленные команды.
1) передать в конструктор ID контейнера и FragmentManager;
2) реализовать методы выхода из приложения и отображения системного сообщения;
3) реализовать создание Fragment’ов по screenKey.
За более подробным примером советую заглянуть в Sample-приложение.
В приложении необязательно должен быть один Navigator. Пример (тоже реальный, кстати): в Activity есть BottomBar, который доступен для пользователя ВСЕГДА. Но в каждом табе есть собственная навигация, которая сохраняется при переключении табов в BottomBar’е.
Решается это одним навигатором внутри Activity, который переключает табы, и локальными навигаторами внутри каждого Fragment’а-таба.
Таким образом, каждый отдельный презентер не завязан на то, где он находится: внутри цепочки одного из табов или в отдельном Activity. Достаточно предоставить ему правильный Router. Один Router связан только с одним Navigator’ом в любой момент времени. Об этом чуть дальше.
Router
Как было сказано выше, комбинируя команды, можно реализовать любой переход. Именно этой задачей и занимается Router.
Например, если стоит задача по некоторому событию в презентере:
1) скинуть всю цепочку до корневого экрана;
2) заменить корневой экран на новый;
3) и еще показать системное сообщение;
то в Router добавляется метод, который передает последовательность из трёх команд на выполнение в CommandBuffer:
Если бы презентер сам вызывал эти методы, то после первой команды BackTo(), он был бы уничтожен (не совсем так, но суть передаёт) и не завершил работу корректно.
В библиотеке есть готовый Router, используемый по-умолчанию, с самыми необходимыми переходами, но как и с навигатором, никто не запрещает создать свою реализацию.
navigateTo() – переход на новый экран.
newScreenChain() – сброс цепочки до корневого экрана и открытие одного нового.
newRootScreen() – сброс цепочки и замена корневого экрана.
replaceScreen() – замена текущего экрана.
backTo() – возврат на любой экран в цепочке.
exit() – выход с экрана.
exitWithMessage() – выход с экрана + отображение сообщения.
showSystemMessage() – отображение системного сообщения.
CommandBuffer
CommandBuffer – класс, который отвечает за доставку команд навигации Navigator’у. Логично, что ссылка на экземпляр навигатора хранится в CommandBuffer’е. Она попадает туда через интерфейс NavigatorHolder:
Кроме того, если в CommandBuffer поступят команды, а в данный момент он не содержит Navigator’а, то они сохранятся в очереди, и будут выполнены сразу при установке нового Navigator’а. Именно благодаря CommandBuffer’у удалось решить все проблемы жизненного цикла.
Конкретный пример для Activity:
Почему именно onResume и onPause? Для безопасной транзакции Fragment’ов и отображения системного сообщения в виде алерта.
От теории к практике. Как использовать 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 передает её в глобальный навигатор.
Совет: для настройки зависимостей между компонентами, используйте Dagger 2, а для управления их жизненным циклом – его CustomScopes.
А что с системной кнопкой Back?
Этот вопрос специально не решается в библиотеке. Нажатие на кнопку Back надо воспринимать как взаимодействие пользователя и передавать просто как событие в презентер.
Но есть ведь Flow или Conductor?
Мы смотрели другие решения, но отказались от них, так как одной из главных задач было использовать максимально стандартный подход и не создавать очередной фреймворк со своим FragmentManager’ом и BackStack’ом.
Во-первых, это позволит новым разработчикам быстро подключаться к проекту без необходимости изучения сторонних фреймворков.
Во-вторых, не придется всецело полагаться на сложные сторонние решения, что чревато затрудненной поддержкой.
- не завязана на Fragment’ы;
- не фреймворк;
- предоставляет короткие вызовы;
- легка в расширении;
- приспособлена для тестов;
- не зависит от жизненного цикла!
Источник
Navigation Architecture Component. Практический взгляд
На недавнем Google IO 2018 в числе прочего было представлено решение, помогающее в реализации навигации в приложениях.
Сразу бросилось в глаза то, что граф навигации можно просмотреть в UI редакторе, чем-то напоминающем сториборды из iOS-разработки. Это действительно интересно и ново для Android-разработчиков.
А так как я еще являюсь создателем довольно популярной библиотеки Cicerone, которая позволяет работать с навигацией на андроиде, то я получил множество вопросов на тему того, пора ли мигрировать на решение Google, и чем оно лучше/хуже. Поэтому, как только появилось свободное время, я создал тестовый проект и стал разбираться.
Постановка задачи
Я не буду повторять вводные слова про новый архитектурный компонент Navigation Architecture Component, про настройку проекта и основные сущности в библиотеке (это уже сделал Саша Блинов), а пойду с практической стороны и попробую библиотеку в бою, изучу возможности и применимость к реальным кейсам.
Я накидал на листе бумаги граф экранов с переходами, которые хотел бы реализовать.
Хочу отметить, что навигация в данном случае рассматривается в пределах одного Activity, такой подход я практикую уже не на первом проекте, и он себя отлично показал (обсуждение этого подхода — не цель данной статьи).
Таким графом я постарался описать наиболее частые кейсы, которые встречаются в приложениях:
- A — стартовый экран. С него приложение начинает работу при запуске из меню.
- Есть несколько прямолинейных переходов: A->B, B->C, C->E, B->F
- Из A можно запустить отдельный флоу a-b
- Из F можно переходить на новые экраны F, выстраивая цепочку одинаковых F-F-F-.
- Из F тоже можно запустить флоу a-b
- Флоу a-b — это последовательность двух экранов, где с экрана b можно выйти из флоу на тот экран, с которого был произведен запуск флоу
- Из экрана C можно перейти на D, заменив экран, то есть, из цепочки A-B-C, перейти к A-B-D
- Из экрана E можно перейти на D, заменив два экрана, то есть из цепочки A-B-C-E, перейти к A-B-D
- С экрана E можно вернуться на экран B
- С экрана D можно вернуться на экран A
Реализация
После переноса такого графа в код тестового Android-приложения получилось красиво:
На первый взгляд, очень похоже на ожидаемый результат, но давайте по порядку.
1. Стартовый экран устанавливается свойством
работает как и предполагалось, приложение запускается именно с него.
2. Прямолинейные переходы выглядят и работают тоже правильно
3. и 5. Вложенная навигация легко описывается и запускается как простой экран. И тут первое интересное свойство: я ожидал, что вложенная навигация будет как-то связана с вложенными фрагментами, но оказалось, что это просто удобное представление в UI-графе, а на практике все работает на одном общем стеке фрагментов.
4. Запуск цепочки одинаковых экранов делается без проблем. И тут можно воспользоваться специальным свойством перехода
При указании этого режима перед переходом на новый экран будет проверка текущего верхнего экрана в стеке. Если новый и текущий экраны совпадают по типу, текущий экран будет удален, то есть верхний экран будет постоянно пересоздаваться, не увеличивая стек.
Тут скрыт первый баг: если открыть несколько раз экран F с параметром app:launchSingleTop=»true» , а потом нажать кнопку «назад», то будет произведен возврат на экран B, но экран F останется «висеть» сверху.
Могу предположить, что это происходит из-за того, что фрагмент менеджер внутри сохранил транзакцию с B на первый инстанс F, а мы его уже сменили на новый, который не будет ему известен.
6. Отдельный флоу из двух экранов работает прекрасно, но проблема в том, что нельзя явно описать поведение, что из второго экрана этого флоу надо вернуться на запустивший экран!
Здесь мы подошли к важному свойству UI-графа: на графе нельзя описать переходы назад! Все стрелки — это создание нового экрана и переход на него!
То есть чтобы сделать переход «назад» по стеку, надо обязательно работать с кодом. Самое простое решение для нашего случая — это вызвать два раза подряд popBackStack() , новая навигация здесь ничем не помогает. А для более сложных флоу надо будет выдумывать другие решения.
7. Заменить экран C на D можно, указав в свойствах перехода
как видите, здесь мы жестко завязали логику перехода между C и D на предыдущий экран B. Тоже не самое лучшее место в новой библиотеке.
8. Аналогично и с переходом с E на D — добавляем app:popUpTo=»@+id/BFragment» , и работает как мы хотели, но с привязкой к экрану B
9. Как я объяснил выше, возвратов на UI-графе нет. Поэтому возврат с экрана E на экран B можно сделать только из кода, вызвав popBackStack(R.id.BFragment) до необходимого экрана. Но если вам не обязательно именно вернуться на экран B, а допустимо создать новый экран B, то делается это переходом на UI-графе с установкой двух параметров:
Таким образом будет скинут стек до экрана B, включая его самого, а потом сверху создан новый экран B.
В реальных проектах такая логика конечно не подойдет, но как возможность — интересно.
10. Возврат на экран A, как вы уже поняли, невозможно описать на графе. Надо делать его из кода. Но по примеру предыдущего кейса, можно скинуть все экраны и создать новый экран A в корне. Для этого создаем переход из D на A и добавляем специальное свойство
Теперь при вызове такого перехода, сначала будет очищен весь стек фрагментов.
Остался один неизученный параметр из UI редактора навигации:
Я так и не понял его предназначение, а скорее всего он просто не работает так, как заявлено в документации:
Launch a navigation target as a document if you want it to appear as its own entry in the system Overview screen. If the same document is launched multiple times it will not create a new task, it will bring the existing document task to the front.
If the user presses the system Back key from a new document task they will land on their previous task. If the user reached the document task from the system Overview screen they will be taken to their home screen.
Выводы
Из моих экспериментов и небольшого изучения исходников можно сделать вывод: новая навигация — это просто UI обертка работы с фрагмент менеджером (для случая приложения в одном Activity). Всем стрелкам-переходам выдается уникальный ID и набор параметров, по которым совершается транзакция фрагментов на старом «добром» фрагмент менеджере. Библиотека еще в глубокой альфе, но уже сейчас заметны баги, которые прямо указывают на известные проблемы фрагмент менеджера.
Если нам надо как-то повлиять на стек фрагментов и сделать возврат дальше предыдущего экрана, то придется работать по старинке с фрагмент менеджером.
Если вызвать навигацию при свернутом приложении, приложение упадет с классическим
java.lang.IllegalStateException: Can not perform this action after onSaveInstanceState
Чтобы совершить навигацию нужен доступ к view для поиска navController
Navigation.findNavController(view)
Именно эти проблемы и решает библиотека Cicerone, поэтому сравнивать их мало смысла. Зато можно объединить! Потому что у Google-навигации есть и полезные стороны:
- Визуальный граф. Для небольших проектов может быть очень полезной подсказкой.
Описание входных параметров экрана и проверка без запуска приложения прямо в студии.
Для этого надо подключить дополнительный Gradle плагин и описывать обязательные параметры в xml свойствах экрана.
Мне не удалось проверить эту фичу, но есть подозрение, что она так же сделана в лоб, поэтому просто будет другой корневой экран, а не полная цепочка экранов, как хотелось бы. Но это не точно.
Тестовый проект
Все эксперименты можно посмотреть и потрогать на Github
Источник