March 28, 2020

Cicerone — простая навигация в Android приложении

На этой схеме не скелет древнего обитателя водных глубин и не схема метро какого-то мегаполиса, это карта переходов по экранам вполне реального Android приложения! Но, несмотря на сложность, нам (см. источник) удалось её удачно реализовать, а решение оформить в виде небольшой библиотеки, о которой и пойдет речь в статье.

Чтобы заранее избежать вопросов о названии, уточню: Cicerone ("чи-че-ро́-не") – устаревшее слово с итальянскими корнями, со значением «гид для иностранцев».

В наших проектах мы стараемся придерживаться архитектурных подходов, которые позволяют отделить логику от отображения.

Так как я в этом плане предпочитаю MVP, то далее по тексту будет часто встречаться слово «презентер», но хочу отметить, что представленное решение никак не ограничивает вас в выборе архитектуры (можно даже использовать в классическом подходе «все во Fragment’ах», и даже в этом случае Cicerone даст свой профит!).

Навигация – это скорее бизнес-логика, поэтому ответственность за переходы я предпочитаю возлагать на презентер. Но в Android не все так гладко: для осуществления переходов между Activity, переключения Fragment’ов или смены View внутри контейнера

  1. не обойтись без зависимости от Context’a, который не хочется передавать в слой логики, связывая тем самым его с платформой, усложняя тестирование и рискуя получить утечки памяти (если забыть очистить ссылку);
  2. надо учитывать жизненный цикл контейнера (например, java.lang.IllegalStateException: Can not perform this action after onSaveInstanceState у Fragment’ов).

Поэтому и появилось решение, реализованное в Cicerone.
Начать, думаю, стоит со структуры.

Структура

На схеме есть четыре сущности:

  • Command – это простейшая команда перехода, которую выполняет Navigator.
  • Navigator – непосредственная реализация «переключения экранов» внутри контейнера.
  • Router – это класс, который превращает высокоуровневые вызовы навигации презентера в набор Command.
  • CommandBuffer – отвечает за сохранность вызванных команд навигации, если в момент их вызова нет возможности осуществить переход.

Теперь о каждой подробнее.

Команды переходов

Мы заметили, что любую карту переходов (даже достаточно сложную, как на первом изображении) можно реализовать, используя четыре базовых перехода, комбинируя которые, мы получим необходимое поведение.

Forward

Forward(screenKey: String, transitionData: Any) – команда, которая осуществляет переход на новый экран, добавляя его в текущую цепочку экранов.
screenKey – уникальный ключ, для каждого экрана.
transitionData – данные, необходимые новому экрану.

Буквой R обозначен корневой экран. Его особенность только в том, что при выходе с этого экрана мы выйдем из приложения.

Back

Back() – команда, удаляющая последний активный экран из цепочки и возвращающая на предыдущий. При вызове на корневом экране ожидается выход из приложения.

BackTo

BackTo(screenKey: String) – команда, позволяющая вернуться на любой из экранов в цепочке, достаточно указать его ключ. Если в цепочке два экрана с одинаковым ключом, то выбран будет последний (самый «правый»).

Стоит отметить, что если указанный экран не найден, либо в параметр ключа передать null, то будет осуществлен переход на корневой экран.

На практике эта команда очень удобна. Например, при авторизации осуществляем переход по двум экранам - Телефон -> СМС, а потом возвращаемся на тот, с которого была запущена авторизация.

Replace

Replace(screenKey: String, transitionData: Any) – команда, заменяющая активный экран на новый.
Кто-то может возразить, что этого результата удастся достичь, вызвав подряд команды Back и Forward. Но тогда при таком вызове на корневом экране мы выйдем из приложения!

Вот и всё! Этих четырёх команд на практике достаточно для построения любых переходов. Но есть ещё одна команда, которая не относится к навигации, однако очень полезна на практике.

SystemMessage

SystemMessage(message: String) – команда, отображающая системное сообщение (Alert, Toast, Snack и т. д.).

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

Все команды отмечены интерфейсом-маркером Command. Если вам по какой-то причине понадобилась новая команда, просто создайте её, никаких ограничений!

Navigator

Команды сами по себе не реализуют переключение экранов, а только описывают эти переходы. За их выполнение отвечает Navigator.

interface Navigator {
    fun applyCommand(command: Command)
}

В зависимости от задачи, Navigator будет реализован по-разному, но он всегда будет там, где находится контейнер для переключаемых экранов.

  • В Activity для переключения Fragment’ов.
  • Во Fragment’е для переключения вложенных (child) Fragment’ов.
  • … ваш вариант.

Так как в подавляющем большинстве Android приложений навигация опирается на переключение 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:

fun navigateToNewRootWithMessage(screenKey: String, data: Any, message: String) {
    executeCommand(BackTo(null))
    executeCommand(Replace(screenKey, data))
    executeCommand(SystemMessage(screenKey, data))
}

Если бы презентер сам вызывал эти методы, то после первой команды BackTo(), он был бы уничтожен (не совсем так, но суть передаёт) и не завершил работу корректно.

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

navigateTo() – переход на новый экран.
newScreenChain() – сброс цепочки до корневого экрана и открытие одного нового.
newRootScreen() – сброс цепочки и замена корневого экрана.
replaceScreen() – замена текущего экрана.
backTo() – возврат на любой экран в цепочке.
exit() – выход с экрана.
exitWithMessage() – выход с экрана + отображение сообщения.
showSystemMessage() – отображение системного сообщения.

CommandBuffer

CommandBuffer – класс, который отвечает за доставку команд навигации Navigator’у. Логично, что ссылка на экземпляр навигатора хранится в CommandBuffer’е. Она попадает туда через интерфейс NavigatorHolder:

interface NavigatorHolder {
    fun setNavigator(navigator: Navigator)
    fun removeNavigator()
}

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

Конкретный пример для Activity:

override fun onResume() {
    super.onResume()
    SampleApplication.instance.navigatorHolder().setNavigator(navigator)
}

override fun onPause() {
    SampleApplication.instance.navigatorHolder().removeNavigator()
    super.onPause()
}

Почему именно onResume и onPause? Для безопасной транзакции Fragment’ов и отображения системного сообщения в виде алерта.

От теории к практике. Как использовать Cicerone?

Предположим, мы хотим реализовать навигацию на Fragment’ах в MainActivity:
Добавляем зависимость в build.gradle

dependencies {
    //Cicerone
    compile 'ru.terrakok.cicerone:cicerone:5.1.0'
}

В классе SampleApplication инициализируем готовый роутер

class SampleApplication : Application() {

    private lateinit var cicerone: Cicerone<Router>

    override fun onCreate() {
        super.onCreate()
        instance = this
        cicerone = Cicerone.create()
    }

    fun navigatorHolder() = cicerone.getNavigatorHolder()

    fun router() = cicerone.getRouter()

    companion object {
        lateinit var instance: SampleApplication
    }
}

В MainActivity создаем навигатор:

private val navigator = object : SupportFragmentNavigator(supportFragmentManager, R.id.main_container) {
    override fun createFragment(screenKey: String, data: Any) = when (screenKey) {
        LIST_SCREEN -> ListFragment.newInstance(data)
        DETAILS_SCREEN -> DetailsFragment.newInstance(data)
        else -> throw RuntimeException(“Unknown screen key!”)
    }

    override fun showSystemMessage(String message) {
        Toast.makeText(this@MainActivity, message, Toast.LENGTH_SHORT).show()
    }

    override fun exit() {
        finish()
    }
}

override fun onResume() {
    super.onResume()
    SampleApplication.instance.navigatorHolder().setNavigator(navigator)
}

override fun onPause() {
    super.onPause()
    SampleApplication.instance.getNavigatorHolder().removeNavigator()
}

Теперь из любого места приложения (в идеале из презентера) можно вызывать методы роутера:

SampleApplication.instance.router().backTo(...)

Частные случаи и их решение

Только 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’ом.

Во-первых, это позволит новым разработчикам быстро подключаться к проекту без необходимости изучения сторонних фреймворков.

Во-вторых, не придется всецело полагаться на сложные сторонние решения, что чревато затрудненной поддержкой.

Итог

Библиотека Cicerone:

  • не завязана на Fragment’ы;
  • не фреймворк;
  • предоставляет короткие вызовы;
  • легка в расширении;
  • приспособлена для тестов;
  • не зависит от жизненного цикла!

GitHub

Библиотеку Cicerone вы можете найти на GitHub.

Источник: Cicerone — простая навигация в Андроид приложении