Хотя стабильная версия Jetpack Compose вышла уже достаточно давно, вопрос навигации в Compose до сих пор остается одним из самых обсуждаемых в сообществе Android-разработчиков. Именно поэтому мы решили перевести статью, в которой автор указывает на проблемы официального решения навигации для Compose, перечисляет плюсы и минусы популярных библиотек-альтернатив, а также на основе своего опыта разработки рассуждает о том, какой функциональностью должен обладать хороший фреймворк навигации. 

Данная статья будет полезна Android-разработчикам, которые встали перед выбором подходящей библиотеки навигации для проекта на Jetpack Compose.

Обдуманный выбор фреймворка навигации сегодня сократит уйму проблем с миграцией завтра

Прошло более года с того момента, как Google анонсировала стабильную сборку Jetpack Compose 1.0. А это означает, что разработчики наконец-то смогут использовать новенький UI toolkit в продакшен. Но стоит ли? Имейте в виду: всё, о чем я говорю в данной статье — это сугубо мое мнение.

За многие годы термин «стабильный» многократно использовался не по назначению, и похоже, это касается и Jetpack Compose. Хотя Google утверждает, что библиотека готова к внедрению в продакшн, нам всё же стоит быть осторожными, особенно при использовании в крупных проектах. Перед использованием библиотеки нужно знатно покопаться: посмотреть текущие проблемы в issue-tracker, написать небольшие тестовые приложения, чтобы понять, как работают те или иные функции, проверить есть ли в toolkit нужные нашему проекту виджеты или нет, как они повлияют на размер и производительность приложения. А также важнее всего определить, какой эффект библиотека несет на продуктивность разработчика и есть ли поддержка инструментов? Всё перечисленное распространяется не только на Compose, но и на любую другую библиотеку/фреймворк, который вы хотели бы использовать. Дабы не помечать свою библиотеку «альфой», создатели очень часто берут на вооружение аннотацию @Experimental, чтобы обозначить некоторые функции как нестабильные (не то чтобы эти функции не работают, но в любой момент API может измениться, что приведет к поломке бинарной совместимости с предыдущими версиями). Нужно очень тщательно изучать подобные вещи перед выбором нового UI-фреймворка.

В таком случае, когда Compose станет по-настоящему стабильной для использования? Не поймите меня неправильно, библиотеку можно применять, но если вы разрабатываете приложение для миллионов пользователей, то перед применением необходимо знать о тонкостях и подводных камнях библиотеки. Имейте в виду, что у нынешних инструментов для работы с View было 10 лет для развития, поэтому очевидно Compose нужно дать хотя бы 3-4 года, чтобы догнать их уровень и стать достаточно стабильной для массового внедрения командами разработчиков. Даже на сегодняшний день, по метрикам Google Play, всего в ~70% приложений используется Kotlin, и еще остается значительная часть ~30%, которую не используют. Я всего лишь хочу сказать, что не стоит становиться ярым фанатом новой технологии — сначала всё хорошенько исследуйте, ведь для каждой крутой фишки нового фреймворка нужно суметь назвать хотя бы две плохие. И если вам не удается это сделать (даже после упорного поиска), то примите мои поздравления — фреймворк/библиотека готова к использованию.

Возвращаясь к Jetpack Compose — если вы всё же решились внедрить библиотеку в свой проект, то наилучший способ включения нового фреймворка — это постепенный и плавный переход из старого к новому. Причем не стоит сразу же заменять фрагменты на чистые Compose-функции. Ведь у Compose есть удобная особенность: совместимость с существующей системой View через множество API, таких, как ComposeView и AndroidView, чтобы иметь доступ к библиотекам Exoplayer/Media3 и так далее через composable-функцию.

Я поднимаю данную тему потому, что приложение чаще всего состоит из множества экранов, а чтобы перемещаться между ними, нужна система навигации. На десктопе всё просто — есть главные и дочерние окна (зависимые и независимые), которые находятся под управлением ОС. Всё меняется, когда мы разрабатываем приложения для недесктопных устройств, таких как смартфоны. Чтобы сохранить память и поддерживать различные размеры экранов, были внедрены некоторые улучшения в плане оптимизации: в один момент виден только один экран, а другой может находиться в спящем состоянии (Lifecycle.onStop); многозадачность всё еще на раннем этапе, учитывая то, что на данный момент foldable-устройства не сравнимы по мощности со стационарными. Получается, что для перехода с одного экрана на другой система навигации должна отменять все активные задачи на предыдущем экране, освобождать неиспользуемые объекты, чтобы они могли быть собраны GC (для сохранения памяти), а также создавать плавные переходы, чтобы пользователь понял, что произошла смена контекста.

Что следует ожидать от системы навигации?

При разработке приложений с несколькими экранами стоит уделять пристальное внимание процессу выделения памяти под объекты и тому, сколько места они занимают. Если рассматривать ViewModel со скоупом на фрагмент, то, когда мы переходим на другой экран то, всё, за чем ViewModel наблюдала на предыдущем фрагменте должно быть отменено. Так мы можем избежать траты ресурсов процессора, а также утечки памяти. К счастью, такие компоненты как Fragment, ComponentActivity являются lifecycle-aware компонентами, то есть у них есть собственный жизненный цикл (далее будем называть ЖЦ), и они реализуют интерфейс  LifecycleOwner. Это значит, что подобные компоненты имеют доступ к SavedStateRegistry, так что любая работа, запущенная через LifecycleOwner.lifecycleScope, будет автоматически отменена, как только компонент перейдет в состояние destroyed (переход на другой экран, закрытие приложения и так далее). Состояния можно сохранить в SavedStateHandle у ViewModel, и когда придет время для смерти процесса, эти данные будут помещены в Parcel (onSaveInstanceState).

В Compose UI отсутствуют компоненты, предлагающие подобные оптимизации из коробки — всё, что можно делать, это описывать UI через функции, а система будет отрисовывать их на экране. По сути каждый экран в Compose — это compose-функция, и нам необходимо сделать так, чтобы эти функции имели возможность следить за Lifecycle, ViewModelScope и SavedStateRegistry. Мы можем делать это своими руками или довериться библиотеке навигации, которая сделает всю работу за нас.

Теперь мы наконец перейдем к самой теме. Первым делом при выборе библиотеки навигации нужно понять, управляет ли каждый экран Lifecycle и SavedStateRegistry, чтобы ограничить область видимости ViewModel до экрана и быть уверенным, что при уничтожении экрана viewModel также будут очищены из памяти. В Compose есть rememberSaveable, который сохраняет значения при смене конфигурации и смерти процесса. Это происходит путём привязывания к LocalSaveableStateRegistry, который является composition local ближайшего SaveableStateHolder. Данный интерфейс предназначен для сохранения значения rememberSaveable в SavedStateRegistry Activity или Fragment с уникальным строковым ключом. Это происходит путем регистрации лямбды, используя SavedStateRegistry.registerSavedStateProvider, который в свою очередь сохраняет все значения rememberSaveable в бандл во время onSaveInstanceState и восстанавливает их при первом вызове rememberSaveable. Вы должны убедиться, что выбранная вами библиотека навигации верно реализовывает SaveableStateHolder. Чтобы проверить, просто убедитесь, что где-то внутри библиотеки есть реализация данного псевдокода:

// где-то в начале создать и запомнить экземпляр SaveableStateHolder
val saveableStateHolder = rememberSaveableStateHolder()

// вызывается для каждого экрана-назначения с уникальным ключом в структуре
// навигации
// (когда создан или активен)
saveableStateHolder.SaveableStateProvider(key = ...) {
  // содержание composable, значения rememberSaveable которого будут сохранены
  // с данным ключом
}

// удалить сохраненное состояние; выполняется при навигации “назад”, то есть экран
// назначения удален из backstack.
saveableStateHolder.removeState(key = ...)

В Compose мы описываем UI через Kotlin — язык со статической типизацией. Так что желательно в навигации использовать по полной систему типов, то есть передавать и получать типизированные аргументы при переходе с одного экрана на другой. Строго типизированным должно быть и место назначения (destination) в виде какого-либо объекта сущности, который можно будет легко указать при навигации (автозаполнение IDE). Однако, указанное есть далеко не в каждой библиотеке (далее мы это обсудим).

Помимо всего перечисленного, от библиотеки навигации также стоит ожидать неплохую систему анимаций. У Compose уже есть хорошие API для анимации, одним из которых является AnimatedContent. Хотя эта функция пока еще нестабильна, возможность объединения переходов в цепочки (fadeIn() + scaleIn()) уже делает её достаточно мощной. Так как экраны в Compose не представляют ничего более функции с аннотацией @Composable, то переход с одного экрана на другой по сути изменяет всё содержимое экрана. Именно для таких переходов лучше всего подходит AnimatedContent.

Проблемы с navigation-compose

Ознакомившись с сутью проблемы, давайте немного затронем официальную поддержку Navigation Component для Jetpack Compose, а именно “navigation-compose”.

В “navigation-compose” каждое место назначения — это объект типа NavBackStackEntry, а NavBackStackEntry в свою очередь является владельцем Lifecycle, ViewModelStore и SavedStateRegistry. Это позволяет внедрить поддержку для ViewModel совместно с SavedStateHandle, в то время как SavedStateRegistry выполняет операции сохранения и восстановления в ответ на определенное событие ЖЦ. То есть когда экран-назначение переходит onStart -> onStop (он кладется в бэкстек, все состояния сохраняются, включая состояния из rememberSaveable) -> onDestroy (экран-назначение удаляется из backstack). Это всё легко можно проверить, добавив LifecycleEventObserver в NavBackStackEntry.lifecycle. Но всё же хотелось бы иметь встроенную поддержку для ViewModel со скоупом на navGraph (как и в Navigation Component), чтобы не приходилось каждый раз вручную искать родителя NavBackStackEntry и скоупить на него ViewModel (сюда бы идеально подошла функция-расширение).

Навигация основана на строках (URI), что я считаю огромным недостатком. Я могу понять, почему было решено сделать именно так: чтобы была встроенная поддержка deep link, как и для фрагментов в Navigation Component. Но в то же время мы теряем безопасность типов при навигации и возможность передачи аргументов в место назначения. Безусловно, deep link важны, но нет большой необходимости в их неявной обработке, всегда можно просто написать helper-класс для управления и перевода их в требуемое destination, нужно всего несколько строчек кода. Таким образом, можно легко разделить и кастомизировать логику в зависимости от поставленных целей.

Здесь место назначения и аргументы передаются при помощи конкатенации строк, к примеру: destination/{arg1}&{arg2}. Так что сначала придется преобразовать типизированный объект в представление URI, при этом игнорируя escape-символы, то есть можем попрощаться с возможностью передачи сериализуемых объектов. Очевидно, авторы библиотеки хотят, чтобы мы избегали передачи типизированных объектов в качестве параметров, и вместо этого использовали viewModel или любой объект состояния для получения данных из кэша (базы данных, БД) — передаем id как аргумент и получаем объект из БД. Однако такую манипуляцию не всегда возможно выполнить. Также возможен случай, когда нужно показать временный кэш объекта из памяти и сразу же его удалить, как только этот объект больше не требуется. К примеру, если нужно показать диалоговое окно, отображающее ответ об успешной оплате, вместе с некими дополнительными данными, причем требуется, чтобы они были уничтожены, как только пользователь закроет диалог.

На текущий момент “navigation-compose” использует стандартную анимацию cross-fade при переходе между экранами-назначениями (при этом их нельзя отключить). Полная поддержка анимаций доступна через инкубационную библиотеку “Accompanist” (navigation-animation). Ниже представлен фрагмент кода с ее применением:

// Предположим, что имеется три назначения: First, Second, Third
@Composable
fun Main() {
    ...
    AnimatedNavHost(navController = rememberNavController(), startDestination = "") {
        composable(
            route = "First",
            enterTransition = {
                when(initialState.destination.route) {
                  "Second" -> fadeIn() + slideIn(initialOffset = { IntOffset(-it.width, 0) })
                  else -> fadeIn()
                }
            },
            exitTransition = { fadeOut() + slideOut(targetOffset = { IntOffset(-it.width, 0)}) }
        ) {
            // контент...
        }
        composable(
            route = "Second",
            ...
        )
        composable(
            route = "Third",
            ...
        )
}

В параметрах Compose функций можно указать вид перехода: enter, exit, popEnter, popExit — эти анимации реализованы при помощи AnimatedContent — так мы и получаем лямбду с типом AnimatedContentScope. Единственное, что меня смущает в этой системе — это потеря гибкости при определении анимаций. Во фрагменте кода выше для enterTransition мы указывали анимации fadeIn() + slideIn(), когда “Second” начальное место назначения (то есть когда переходим из “Second” в “First”), и fadeIn для всех остальных случаев. Гибкости нет, за исключением того, что нам известны текущее и целевое назначения, по которым мы можем выбрать подходящую анимацию. Такое решение продвигает императивный стиль, при котором необходимо предугадывать анимации в зависимости от значений состояния (места назначения).

Если взять пример из мира фрагментов, то мы можем использовать FragmentTransaction.setCustomAnimations(..), чтобы определить анимации для различных операций (add, replace, remove). Такая цепочка дает нам достаточно гибкости для определения анимаций в зависимости от логических условий, а также позволяет обращаться к любым локальным переменным в этих условиях.

// декларативный стиль

supportFragmentManager.commit {

    if (...) { // анимации зависят от условий; более гибкие

        setCustomAnimations(android.R.anim.fade_in, android.R.anim.fade_out)

    } 

    replace(binding.container.id, SettingsFragment()) // для любой операции

}

На фрагменте кода выше видим, что анимация определяется в сам момент перехода к месту назначения —  такое невозможно с navigation-animation от Accompanist. Данный подход более гибкий и декларативный, то есть он не ограничен простыми условными анимациями от начального до целевого назначений. Хотя также стоит отметить и положительную сторону для использования императивного подхода: все анимации собраны в одном месте. В таком случае читающему код достаточно взглянуть на настройку навигации, чтобы понять, когда какая анимация будет выполняться.

Compose Destinations

Если вы уже работали с “navigation-compose”, то тогда наверняка знаете об этой библиотеке (если нет, то ознакомиться с ней можно по этой ссылке). Вкратце, Compose Destinations — это кодогенерирующая библиотека, основанная на KSP, которая пытается разрешить многие проблемы, связанные с “navigation-compose”. Библиотека помогает во многих местах избавиться от написания шаблонного кода, а также разрешает проблему с навигацией по строкам, заменяя строки на типизированные объекты и генерируя места назначения, обозначенных специальной аннотацией @Destination в composable функциях. Типизированный параметр composable функции становится аргументом для навигации наряду с DestinationsNavigator, который помогает управлять навигацией для соответствующего navGraph-а.

Библиотека основана на “navigation-compose”, так что она предоставляет всё ту же функциональность, но в разы лучше (да, в библиотеке есть конвертации в base64, но они выполняются под капотом). Не составит никаких проблем сделать вложенную или даже кастомную навигацию. Только не забудьте проаннотировать вложенный граф родительской аннотацией (например, @RootNavGraph), или же готовьтесь к падению приложения при переходе на любой вложенный экран-назначение. Это единственная проблема, с которой я столкнулся при использовании этой библиотеки: хотя здесь и присутствует типизированная навигация, её тип не определяется текущим навигационным графом (у них у всех тип Direction) для того, чтобы можно было перемещаться к любому назначению дочерней навигации без вложения навигационного графа в родительскую навигацию.

Другая проблема состоит в том, что эта библиотека использует кодогенерацию, а она всегда увеличивает время сборки (даже если несущественно). К тому же при клонировании проекта всегда встретятся какие-либо типы, свойства, методы, которые не существуют до первой сборки проекта. При любом изменении в структуре навигации необходимо заново запускать build или kspDebugKotlin, что может привести к понижению производительности разработки. Ревьюерам также будет нелегко проверять код в браузере, так как назначения разбросаны по всему проекту, и без поиска по @Destination будет очень сложно понять, какое назначение относится к какому графу.

Вам может показаться, что я преувеличиваю, но лично я стараюсь избегать решения с кодогенерацией в крупных бизнес-проектах с огромной командой разработчиков и переходить на другие навигационные библиотеки, или же придумывать собственное решение (если возможно). Тем не менее, вышеописанная библиотека предоставляет неплохое решение, основанное на “navigation-compose”. Так что, если вы всё же хотите использовать “navigation-compose”, но без всех описанных мною проблем, я очень рекомендую ознакомиться с этой библиотекой по ссылке.

Суждением выше я ни в коем случае не хотел выставлять “navigation-compose” в плохом свете. У библиотеки есть свои плюсы и минусы, но в большинстве своём минусы вытесняют плюсы, так что да, она оставляет желать лучшего. Такие библиотеки, как Compose Destinations, помогут избежать некоторые проблемные места, но они все же генерируют тот самый код, который нам пришлось бы писать самостоятельно. Я рекомендую присмотреться к другим вариантам (описанным ниже). Также есть множество очень талантливых разработчиков, которые предлагают собственные решения для навигации в Compose, и было бы неплохо, если бы вы с ними ознакомились.

Исследуем другие библиотеки

1. compose-navigation-reimagined

Если вы уже пользовались “navigation-compose” и хотели бы перейти на похожую библиотеку, то лучше всего начать с compose-navigation-reimagined. API очень похож на “navigation-compose” (как понятно из названия библиотеки), а также, как я считаю, у неё довольно неплохая документация.

Здесь используется NavComponentEntry вместо NavBackStackEntry, есть встроенная типизированная навигация с типизированными аргументами (также поддерживаются parcelable и serializable). У NavController есть generic параметр <T>, чтобы он мог наследовать и позволять навигацию только по типу T. Назначения могут быть любого типа, но лучше всего использовать enum (если вам не хочется передавать аргументы, к примеру, в нижнюю навигацию) или sealed-классы, в которых каждый параметр конструктора становится аргументом для назначения.

Так же как и в “navigation-compose”, в данной библиотеке имеются NavHost и AnimatedNavHost. Диалоговые окна также поддерживаются через DialogNavHost, реализация/использование которой заметно отличается от той, что была в  “navigation-compose”. Мне очень нравится, что диалоговые окна рассматриваются как отдельные навигации с собственным NavContoller, а не одним из потомков общего NavHost-а. API для NavController в библиотеке во много раз более гибкий и явный: не составляет труда сделать снимок бэкстека при помощи NavController.backstack. По сути здесь присутствуют все функции, которые существуют в “navigation-compose” (исключая встроенную поддержку deeplink).

Представляю вам небольшой пример подключения библиотеки:
// 1. Создать destination
sealed class Destination : Parcelable { // parcelable, чтобы можно было положить в
   // bundle.
  object First : Destination()
  data class Second(val name: String, val hobbies: List<String>) : Destination()
}

// 2. Настроить навигацию
@Composable
fun MainScreen() {
  // Создать NavController
  val controller = rememberNavController<Destination>(
    startDestination = Destination.First
  )
  
  NavBackHandler(controller) // Очень важно, иначе системная кнопка “назад” не будет
// выполнять pop для backstack.
  
  // Может быть NavHost или что-то другое
  AnimatedNavHost(
    controller = controller,
    transitionSpec = ...
  ) { destination ->
    when(destination) {
      Destination.First -> { /* composable контент*/ }
      Destination.Second -> { /* composable контент*/ }
    }
  }
}

Единственная проблема, с которой я столкнулся при использовании этой библиотеки — в том, что отсутствует встроенная поддержка для ViewModel-ей, внедренных через Hilt. Хотя на самом деле это не такая уж и большая проблема, так как достаточно скопировать этот кусок кода, чтобы можно было использовать hiltViewModel() — функцию для создания ViewModel-ей, аннотированных @HiltViewModel. Для Даггера необходимо будет переопределить getDefaultViewModelProviderFactory() в Activity или фрагменте, чтобы передавать собственный ViewModelFactory, так как на данный момент NavComponentEntry не имеет возможности задавать кастомный ViewModelFactory.

2. voyager

Это первая мультиплатформенная библиотека навигации для Jetpack Compose. На данный момент есть поддержка Android и десктопа, но для настоящего KMM не хватает iOS.

Система навигации основана на стеке, что очень напоминает фрагменты. Здесь отсутствует хорошо определенная типизированная навигация, вместо этого нужно создавать классы, наследуясь от Screen или AndroidScreen, чтобы определить экран назначения. Так что любой параметр этого класса становится аргументом для этого назначения, и сможет спокойно пережить смену конфигурации или смерть процесса (так как Screen сериализуем). AndroidScreen — это особая реализация Screen, которая может следить за Lifecycle, ViewModelStore и SavedStateRegisty, а также имеет поддержку для scope ViewModel-ей (а для мультиплатформенных приложений следует использовать ScreenModel вместо ViewModel).

Также в этой библиотеке существует класс Navigator для управления навигацией, определенной в Navigator composable функции. Понимаю, что легко запутаться с такими именами, но думаю, станет понятнее на примере:

// 1. Создать ViewModel-и и экраны-назначения
class SecondViewModel : ViewModel() { ... }

class FirstScreen : AndroidScreen() {
  @Composable override fun Content() = FirstScreenContent()
}
class SecondScreen(private val name: String) : AndroidScreen() {
  @Composable override fun Content() = SecondScreenContent()
}

// 2. Настроить навигацию,
@Composable
fun MainScreen() {
  Navigator(FirstScreen())
}

// Контент для первого экрана
@Composable
fun FirstScreenContent() {
  val navigator = LocalNavigator.currentOrThrow
  Button(onClick = { 
    navigator.push(SecondScreen(name = "Test")) // <-- Переходим на второй экран
  }) {
    // ...
  }
}

// Контент для второго экрана
@Composable
fun SecondScreenContent() {
  val vm = getViewModel<DemoViewModel>() // <-- ViewModel-и внедряются через Hilt
  // ...
}

Класс Navigator обладает разнообразными операциями, определенные через Stack API. Всё подробно описано в документации от автора библиотеки.

Добавлять анимации/переходы проще простого — достаточно лишь обернуть контент в FadeTransition, ScaleTransition, SlideTransition или же в кастомную реализацию, используя ScreenTransition API.

@Composable
fun MainScreen() {
  Navigator(FirstScreen()) { navigator ->
    FadeTransition(navigator) 
    // ^ При любом изменении места назначения будет применяться Fade переход
    // в рамках этого Navigator.
    // Для кастомного поведения используйте ScreenTransition(), который предоставит
    //вам initialState & targetState, чтобы можно было сделать цепочку из анимаций.
  }
}

Библиотека поддерживает deeplink, хотя их использование сильно отличается. По сути, если известно назначение до экрана (через intent.getExtras()), предположим, ThirdScreen, то также нужно прицепить предыдущие экраны (то есть FirstSceen, SecondScreen) к Navigator() composable-функции (одна из перегрузок берет список Screen). Таким образом, на экране отобразится последний добавленный в список экран, а pop вернет предыдущие экраны. Какая может быть проблема с этим подходом? Допустим, мы перемещаемся в глубоко вложенную навигацию с аргументами, тогда велика вероятность, что эти аргументы придется передавать в каждый Navigator(), а также предусматривать отдельную логику для них.

Помимо этого, в библиотеке есть встроенная поддержка навигаций в bottom sheet, tab и bottom bar, которую невероятно легко настроить (процесс очень хорошо проиллюстрирован в документации).

Что мне нравится в этой библиотеке, так это прекрасно написанная документация, в которой четко прописано использование API с примерами применения. Библиотека готова к внедрению в продакшен, и, как я понимаю, многие разработчики её уже используют. Проблемы в GitHub скорее всего связаны с запросом новых фичей и багами для крайне специфичных кейсов. Достаточно посмотреть на закрытые задачи, чтобы понять, насколько развилась библиотека перед стабильным релизом 1.0.

3. navigator-compose

Еще одна библиотека для управления навигацией — это navigator-compose. Эта библиотека также поддерживает навигацию фрагментов через родительскую библиотеку — navigator.

Установка навигации очень схожа с compose-navigation-reimaged. Так мы запросто получаем типобезопасную навигацию, в которой используются только sealed-классы, чьи параметры конструктора становятся аргументами для этого места назначения. Здесь мы также имеем класс Controller<T>, который используется для управления навигацией типа T. Она также предоставляет широкий выбор гибких API для управления backstack навигации.

Каждое destination, которое создается через реализацию интерфейса Route, имеет собственный LifecycleController. Так как это контроллер управляет Lifecycle, ViewModelStore и SavedStateRegisty, то в библиотеке присутствует поддержка ViewModel со скоупом, а также ViewModel, внедряемых Hilt через специальную библиотеку дополнений. В документации наглядно описан процесс смены Lifecycle Event при изменениях в бэкстеке.

Помимо Controller<T>, есть ещё и огромный класс ComposeNavigator, который имеет глобальное представление о backstack (то есть знает о родительской и дочерних навигациях), вследствие чего навигация основана на очереди (в map-е). API для класса ComposeNavigator очень похож на Controller<T> за одним исключением: любое изменение также влияет на родительскую навигацию. Например, в классе ComposeNavigator есть API под названием goBackUtil, который, как следует из названия, возвращается на требуемое место назначения (включая то, что было из множества иерархий родительской навигации), и это выполняется за счет просмотра всей очереди навигации.

Представляю небольшой пример применения этой библиотеки:
// 1. Инициализировать Navigator в Activity или Fragment

class MainActivity : ComponentActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    ...
    val navigator = ComposeNavigator.with(this, savedInstanceState).initialize()
    setContent {
      ...
      MainScreen(navigator) // <-- Строка: 34
    }
  }
}

// 2. Определить destination

sealed class MainRoute : Route {
  @Immutable @Parcelize
  data class First(val data: String) : MainRoute()
  @Immutable @Parcelize
  data class Second(private val noArg: String = "") : MainRoute() // путь без аргументов
  
  companion object Key : Route.Key<MainRoute> // <-- Уникальный ключ для корня
}

@Composable // ассоциируется с MainRoute.First
fun FirstScreen(data: String, changed: (screen: Route) -> Unit) {...}

@Composable // ассоциируется с MainRoute.Second
fun SecondScreen() {...}

// 3. Настройка навигации

@Composable
fun MainScreen(navigator: ComposeNavigator) {
  // запомнить экземпляр контроллера, он поможет в управлении навигацией
  // для типа MainRoute
  val controller = rememberNavController<MainRoute>()
  
  navigator.Setup(key = MainRoute.key, initial = MainRoute.First("Hello world"), controller = controller) { dest ->
    val goToSecond: () -> Unit = { value ->
      controller.navigateTo(MainRoute.Second()) // <-- Перейти на второй путь
    }
    when (dest) {
      is MainRoute.First -> FirstScreen(dest.data, goToSecond)
      is MainRoute.Second -> SecondScreen()
    }
  }
}

Создание анимаций в данной библиотеке не требует особых усилий, но стоит заметить, что здесь анимации реализованы не с использованием AnimatedContent API, а путем ручной интерполяции от текущего до целевого назначения, при этом изменяя графический слой. Такой подход более гибкий, но в то же время он отнимает простоту создания кастомных анимаций (хотя уже добавили pull request на апгрейд системы анимаций на AnimatedContent, так что, если вы если вы уже пользуетесь этой библиотекой, то можете попробовать уговорить автора на апгрейд). На данный момент доступны встроенные анимации fade и slide.

// анимация из (текущий) -> в (целевой) назначения
navController.navigateTo(MainRoute.Second()) {
  withAnimation { // <-- Гибкие анимации с DSL.
    target = SlideRight
    current = Fade
  }
}

Как можно заметить, анимации в этой библиотеке более гибкие, то есть логика может быть динамичнее. Схожие DSL предоставляются для popUpTo (возвращения в требуемое место назначения).

Библиотека всё еще находится в альфе, так что смена API может сломать обратную совместимость.

4. simple-stack-compose-integration

Данная библиотека является расширением над библиотекой simple-stack. Simple-stack существует уже достаточно долгое время, можно даже утверждать, что она является одним из самых зрелых навигационных фреймворков. Множество идей перекочевало из библиотеки simple-stack в её версию для Compose. Данная библиотека является абстрактным фреймворком навигации — это значит, что она зависит не от деталей реализации (какой конкретный компонент используется в навигации), а от того, как навигация должна происходить (изменение состояния). Именно поэтому здесь определяются разные реализации навигации для обычных вью, фрагментов и compose-функций. Выполняется это через интерфейс StateChanger (ViewStateChanger для вью, FragmentStateChanger для Fragment и ComposeStateChanger для Compose).

Каждое назначение представляет собой класс, расширяющий либо DefaultViewKey (для навигаций вью), либо DefaultFragmentKey (для навигаций фрагментов), либо DefaultComposeKey (для навигаций composable-функций). Для Compose можно переопределить метод ScreenComposable, в котором обычно описывается содержимое composable. Как и для других библиотек навигации, которые мы рассматривали ранее, параметры конструктора этого класса становятся аргументами для этого назначения.

Здесь нет ViewModel, но есть концепция под названием Scoped Services. Идея заключается в том, что мы прикрепляем сервис (обычно это класс) к месту назначения с тэгом, скоупнутым на это назначение (очень схоже с scoped ViewModel) и используем его через функцию lookup(...) в компоненте (может быть фрагментом или Composable-функцией). Эти сервисы (прикрепленные классы) будут живы до тех пор, пока место назначения еще находится в бэкстеке. Допустим, вы установили нижнюю навигацию на назначении A, тогда с помощью lookup(...) можно найти сервис со скоупом на A в любом дочернем назначении этой вкладки, и сразу же автоматически вы поделились этим сервисом со всеми потомками нижней навигации. В мире Navigation Component мы называем их ViewModel-и со скоупом на навигационный граф (создается через navGraphViewModel<T>()), единственное отличие — это то, что реализация платформонезависимая. Помимо этого сервисы со скоупом могут реализовывать интерфейсы, такие как Bundleable, чтобы иметь возможность сохранять/получать состояние в/из StateBundle (схоже с SavedStateHandle).

Простая настройка навигации с этой библиотекой выглядит вот так:

// 1. Настроить simple-stack (для краткости некоторые детали были опущены)
//    Для полноценной настройки: https://github.com/Zhuinden/simple-stack-compose-integration/blob/master/README.md#what-does-it-do 

class MainActivity : AppCompatActivity() {
  private val composeStateChanger = ComposeStateChanger()

  override fun onCreate(savedInstanceState: Bundle?) {
    ...
    val backstack = Navigator.configure()
      .setScopedServices(DefaultServiceProvider()) // <-- Поддержка для сервисов со
         // скоупом
      .setStateChanger(AsyncStateChanger(composeStateChanger)) 
      .install(this, findViewById(R.id.container), History.of(FirstKey())) // <-- Начальное 
// назначение это FirstKey

    setContent {
      BackstackProvider(backstack) {
        composeStateChanger.RenderScreen() 
      }
    }
  }

  override final fun onBackPressed() {
    if (!Navigator.onBackPressed(this)) {
      super.onBackPressed()
    }
  }
}

// 2. Определить назначения

@Immutable @Parcelize 
data class FirstKey(private val noArgsPlaceholder: String = ""): DefaultComposeKey(), DefaultServiceProvider.HasServices {
  override val saveableStateProviderKey: Any = this // <-- Важно для `rememberSaveable`-ов
  override fun getScopeTag(): String = javaClass.name // <-- тэг
  
  override fun bindServices(serviceBinder: ServiceBinder) {
    serviceBinder.add(FirstModel()) // <-- Регистрация сервиса класса FirstModel
  }
  
  @Composable
  override fun ScreenComposable(modifier: Modifier) {
    val vm = rememberService<FirstModel>() // <-- Использовать необходимый сервис
    //.. контент
  }
}


@Immutable @Parcelize 
data class SecondKey(private val name: String): DefaultComposeKey() {
  @Composable
  override fun ScreenComposable(modifier: Modifier) {
    // Получить экземпляр FirstModel если назначение
    // существует в бэкстеке, и является родителем текущего назначения.
    val vm = rememberService<FirstModel>(FirstKey.getScopeTag())
    //.. контент
  }
}

Анимация при навигации осуществляется путем изменения графического слоя через интерполяцию между текущим и конечным назначениями (схоже с п. 3. navigator-compose). К сожалению, в библиотеке отсутствуют встроенные переходы, а по умолчанию установлен Slide. Анимации можно кастомизировать через опциональный параметр конструктора ComposeStateChanger, в котором можно указать собственную реализацию AnimationConfiguration. Также, используя параметр stateChange, можно определять отдельные анимации для разных назначений (topPreviousKey() и topNewKey()).

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

Заключение

Я знаю, что есть еще больше навигационных библиотек, и что рассмотрел только малую часть. Я решил выбрать именно эти библиотеки потому что я видел, как они развивались, а также использовал в POC (proof of concept), чтобы понять, какую лучше использовать в следующем личном проекте. Так что я хорошо знаком с фишками каждой из них. Еще хочу учесть, что в своей статье я указывал только на те фичи, которые мне больше/меньше всего понравились, поэтому крайне рекомендую лично ознакомится с библиотеками, понять, как они работают под капотом.

Еще один важный аспект всех этих библиотек — они могут быть протестированы при помощи unit-тестов, инструментальных тестов или же обоими одновременно (также и на CI). Это показывает, насколько серьезен автор или тот, кто занимается поддержкой при разработке проекта.

Если для нового проекта, написанного на чистом Compose, без фрагментов, потребовалось бы выбрать библиотеку для навигации из списка, то я остановился бы на voyager. Причина заключается в том, что это единственная библиотека, которая используется моими знакомыми в достаточно крупном приложении с большой пользовательской базой (примерно 5 миллионов скачиваний). Они выбрали именно эту библиотеку, изучив все детали и подводные камни, а не тыкнув пальцем в небо. Также я считаю, что это единственная библиотека, которая близка к стабильной версии и покрывает различное множество применений (скоро будет стабильный релиз 1.0).

Спасибо за внимание! Надеемся, что эта статья была вам полезна. Помимо переводов, наши эксперты делятся собственными материалами по разработке в соцсетях – ВК и Telegram.

Комментарии (0)