Навигация в Compose больше не проблема
Всем привет! Меня зовут Евгений, и я — Android-разработчик. Я не собираюсь соревноваться с Google, но, кажется, кое в чем я их все-таки обогнал.
Получив задачу написать новое приложение, я стал накидывать план: архитектуру, паттерны, фреймворки и библиотеки, которые мне понадобятся. Было решено писать полностью на Compose и для навигации использовать Jetpack Navigation. Тогда я еще не знал, какой ящик Пандоры открываю.
Стандартный подход и его "болевые точки"
Для стандартной библиотеки Jetpack Navigation есть официальная документация и множество туториалов. Они достаточно легко гуглятся, и приводить их здесь я не вижу смысла. Давайте сразу разберемся, как это работает на практике и с какими проблемами мы сталкиваемся.
Сначала все выглядит просто. Нам нужен NavHost, в котором нужно указать startDestination, а каждый экран описывается внутри блока composable. Чтобы избежать "магических строк", мы создаем enum со списком всех экранов.
@Composable fun AppNavHost( modifier: Modifier = Modifier, navController: NavHostController, startDestination: String = AppNavHostNavigationScreens.Start.route ) { NavHost( modifier = modifier, navController = navController, startDestination = startDestination ) { composable( route = AppNavHostNavigationScreens.Start.route, ) { BonusInfoScreen( navController = navController, ) } } }
Далее, чтобы заставить приложение переходить между экранами, необходимо вызвать navController.navigate("screen_name"). Оставлять маршруты в виде простых строк мы, конечно, не можем себе позволить во избежание опечаток и случайных изменений, поэтому создаем enum со списком всех экранов.
enum class AppNavHostNavigationScreens(val route: String) { Settings("settings"), Faq("faq"), Main("main"), BonusInfo("bonusinfo"), }
Теперь и в NavHost, и при навигации мы просто используем элемент из enum — все красиво и хорошо работает.
Но эта идиллия длится ровно до тех пор, пока нам не понадобится передать данные между экранами — а такая задача возникает довольно часто. Стандартный подход превращает ее в настоящее испытание:
Сначала в наш enum со списком экранов придется добавить все параметры. Маршрут превращается в сложную строку, которую легко сломать.
enum class AppNavHostNavigationScreens(val route: String) { // ... Interest("interest?idn={idn}¤cy={currency}&startYear={startYear}"), }
Затем в NavHost их нужно зарегистрировать как аргументы. Это превращается в довольно громоздкую конструкцию, где для каждого параметра нужно описать его тип и значение по умолчанию.
composable( route = AppNavHostNavigationScreens.Interest.route, arguments = listOf( navArgument("idn") { defaultValue = 0L type = NavType.LongType nullable = false }, navArgument("currency") { defaultValue = "" type = NavType.StringType nullable = false }, navArgument("startYear") { defaultValue = 0 type = NavType.IntType nullable = false }, ), )
А в месте назначения — правильно прочитать и распарсить, что выглядит небезопасно и многословно.
{ InterestScreen( navController = navController, idn = it.arguments?.getLong("idn") ?: 0, currency = it.arguments?.getString("currency").orEmpty(), startYear = it.arguments?.getInt("startYear") ?: 0, ) }
И при этом Jetpack Navigation из коробки предоставляет инструменты только для работы со строками, числами и другими примитивными типами. А передать собственный класс данных Parcelable или Serializable просто невозможно. (Но это неточно 😉).
ЧАСТЬ 2: Первые шаги к решению и "поворотный момент"
Я задался целью все это упростить и обезопасить: писать меньше кода и получать более надежный результат. В итоге родился простой план из нескольких шагов:
Создаем типизированный список аргументов с указанием типов данных и значений по умолчанию.
Пишем
extension-функции для безопасного чтения и записи этих аргументов.Создаем
extensionдля навигации, который умеет работать с нашими аргументами и избавляет от рутины.Бонусом добавляем логирование, чтобы видеть, куда происходит навигация, и отслеживать потенциальные ошибки.
Давайте реализуем этот план по шагам.
Шаг 1: Создаем типизированные аргументы
Начнем с самого главного — избавимся от "магических строк" в качестве ключей для аргументов. Вместо них мы создадим sealed class, который будет централизованно хранить всю информацию о каждом аргументе: его имя, тип и значение по умолчанию.
sealed class NavArgNames<T( val name: String, val argType: NavType<T, val argDefaultValue: T? = null ) { data object PhoneNumber : NavArgNames<String?("phoneNumber", NavType.StringType, "") data object FaqType : NavArgNames<String?("faqType", NavType.StringType, "") // ... и другие возможные аргументы }
Шаг 2: Extension-функции для работы с аргументами
Теперь создадим несколько вспомогательных функций, которые станут основой нашего механизма.
Для начала, напишем небольшую функцию, которая поможет нам формировать строку маршрута.
fun setNavArgs(vararg args: NavArgNames<*): String { return args.joinToString(separator = "&", prefix = "?") { "${it.name}={${it.name}}" } }
Затем, создадим extension, который превращает наш типизированный аргумент в NamedNavArgument, понятный для NavHost:
fun NavArgNames<*.getArgument(): NamedNavArgument { return navArgument(this.name) { defaultValue = argDefaultValue type = argType } }
И самое главное — напишем extension для NavBackStackEntry, который будет безопасно извлекать и приводить тип нашего аргумента, используя sealed class в качестве ключа.
inline fun <reified T NavBackStackEntry.getArgument(type: NavArgNames<T): T { return when (type.argType) { NavType.StringType - this.arguments?.getString(type.name) as T NavType.IntType - this.arguments?.getInt(type.name) as T NavType.LongType - this.arguments?.getLong(type.name) as T // ... и так далее для всех остальных примитивных типов else - type.argDefaultValue as T } }
Примечание: Ключевые слова inline и reified позволяют нам работать с обобщенным типом T внутри функции почти как с реальным типом, что делает получение аргумента типобезопасным и избавляет от множества проблем с приведением типов.
Шаг 3: Создаем extension для навигации
Теперь, когда у нас есть все вспомогательные "кирпичики", мы можем собрать их вместе в одной удобной extension-функции для NavHostController. Эта функция станет нашей единой точкой входа для всех переходов в приложении. Она будет принимать специальный объект NavigationAction, сама конструировать маршрут и применять настройки popUpTo.
fun <T NavHostController.navigateSafety( action: NavigationAction, ) { val baseRoute = action.toScreen.route.split("?").first() val route = when { action.args.isNotEmpty() - { baseRoute.plus( action.args.joinToString( separator = "&", prefix = "?", ) { "${it.first.name}=${it.second}" } ) } else - baseRoute } this.navigate( route = route ) { if (action.popUpTo != null) { popUpTo(action.fromScreen.route) { inclusive = action.inclusive } } } }
Шаг 4: Бонусом добавляем логирование
В качестве приятного бонуса, давайте добавим немного логирования в нашу функцию navigateSafety, чтобы в Logcat было видно все детали переходов. Для этого просто добавим в самое начало функции следующий блок кода:
// Этот код вставляется в начало функции navigateSafety val logText = buildString { append(" ---------------------------------------------------------------------------------\n") append("| Navigation action: ${action.javaClass.simpleName}\n") append("| ${action.fromScreen} - ${action.toScreen}\n") if (action.args.isNotEmpty()) { append("| ${action.args.joinToString("&") { "${it.first.name}=${it.second}" }}\n") } action.popUpTo?.let { append("| popUpTo: $it\n") } if (action.inclusive) append("| is inclusive: ${action.inclusive}\n") append(" ---------------------------------------------------------------------------------") } Timber.d(logText)
Теперь при каждом вызове navigateSafety в Logcat мы будем видеть подробный и понятный отчет:
Фрагмент кода
--------------------------------------------------------------------------------- | Navigation action: OpenInterestScreen | Main - Interest | idn=123¤cy=USD&startYear=2025 | popUpTo: Main ---------------------------------------------------------------------------------
Промежуточные итоги
Итак, что мы имеем? В целом, выглядит неплохо, не так ли? Теперь, чтобы при создании экрана передать в него данные, нужно всего лишь добавить его в наш sealed class NavArgNames, потом в NavHost, описать все его аргументы и их типы с помощью наших extension-функций.
Вам нравится?
Мне — нет!
Да, стало гораздо легче и безопаснее, чем было. Но я все еще не вижу здесь красивого и удобного инструмента. Это все еще "сделай сам" с кучей рутинной работы, где легко ошибиться.
ЧАСТЬ 3: Магия кодогенерации
А что, если не писать этот код вообще?
Эта рутина и навела меня на мысль: ведь весь этот код для регистрации экранов, аргументов и навигации подчиняется одному и тому же алгоритму. А что, если написать инструмент, который будет генерировать весь этот код за нас?
К счастью, у Kotlin есть прекрасный инструмент для кодогенерации — KSP (Kotlin Symbol Processing). С ним мой план кардинально изменился:
1. Нам нужно получить от разработчика список экранов с их аргументами.
Для этого создаем простую аннотацию @KoGenScreen. (Я очень скромный, поэтому назвал библиотеку своим именем 😉).
У этой аннотации всего три параметра:
val startDestination: Boolean = false, val navHostName: String = "AppNavHost", val animation: NavigationAnimation = NavigationAnimation.None,
startDestination— флаг, указывающий, что этот экран является стартовым в графе навигации.navHostName— название хоста. Это нужно в тех случаях, если вам требуется несколько независимых графов навигации. Все экраны будут группироваться по этому признаку. Если не задавать его, все будет в одном хосте по умолчанию.animation— тип анимации перехода (про это расскажу позже).
2. Получив эти данные на этапе компиляции, мы можем начать генерировать код. Нам нужно создать несколько вещей:
Список экранов в виде
enum.Готовый
NavHost.Все
extension-функции, которые мы писали руками ранее.NavigationAction— специальный класс для управления переходами (про него тоже чуть позже).
Как работает кодогенерация
Генерация Enum со списком экранов
Список экранов — достаточно простая задача. Генератор берет все ваши Composable-функции, отмеченные аннотацией @KoGenScreen, убирает из названия слово "Screen" (просто мне так захотелось) и использует оставшуюся часть как имя для элемента enum. Внутрь этого элемента помещается строка route со всеми переменными, нужными для экрана.
enum class AppNavHostNavigationScreens(override val route: String): kz.evko.navigation.routes.RouteScreenType { Main("main"), Second("second?title={title}"), Third("third?screenNumber={screenNumber}&screenColor={screenColor}"), Fourth("fourth?screenColor={screenColor}&titleColor={titleColor}&title={title}"), }
Генерация NavHost
NavHost сгенерировать чуть сложнее. Сначала KSP-процессор ищет экран с флагом startDestination. Если не находит — просто берет первый попавшийся экран в качестве стартового. Впрочем, это не принципиально, так как вы всегда можете переопределить стартовый экран при вызове NavHost.
Далее генератор описывает каждый экран ровно так, как мы это делали вручную (разницу в анимации мы обсудим позже).
Для экрана без аргументов код получается простым:
composable( route = AppNavHostNavigationScreens.Main.route, ) { MainScreen( navController = navController, ) }
Если же аргументы есть, генератор создает более сложный код с их регистрацией:
composable( route = AppNavHostNavigationScreens.Second.route, arguments = listOf( navArgument("title") { defaultValue = "" type = NavType.StringType nullable = false }, ), ) { SecondScreen( navController = navController, title = it.arguments?.getString("title").orEmpty(), ) }
Работа с типами данных
Здесь стоит подробнее осветить тему типов. Jetpack Navigation нативно поддерживает передачу следующих типов: String, Boolean, Int, Long, Float и их Array-версии. Моя библиотека также поддерживает List для этих типов, автоматически преобразуя их в массивы и обратно для вашего удобства.
А что же с остальными типами, спросите вы? А вот и главная магия: все, что не входит в этот список (например, ваши собственные классы данных), автоматически сериализуется в JSON-строку при передаче и десериализуется обратно в объект на целевом экране.
Генерация вспомогательных функций
Теперь, чтобы использовать всю эту красоту, необходимы extension-функции. Их мы тоже сгенерируем автоматически.
Функция для безопасного перехода:
fun NavHostController.navigateSafety( action: kz.evko.navigation.routes.NavigationAction, popUpTo: kz.evko.navigation.routes.RouteScreenType? = null, inclusive: Boolean = false, ) { Log.d("NavigateSafety", action.navigationLog(popUpTo, inclusive)) navigate(action.route) { popUpTo?.let { popUpTo(it.route) { this.inclusive = inclusive } } } }
И функция для безопасного возврата на предыдущий экран:
fun NavHostController.popBackSafety() { if (previousBackStackEntry != null) { Log.d( "PopBackSafety", kz.evko.navigation.routes.navigationBackLog( fromScreen = this@popBackSafety.currentDestination?.route ?.split("?")?.firstOrNull()?.capitalize(Locale.current), toScreen = this@popBackSafety.previousBackStackEntry?.destination?.route ?.split("?")?.firstOrNull()?.capitalize(Locale.current) ) ) popBackStack() } }
ЧАСТЬ 4: Продвинутые возможности и результат
Безопасная передача аргументов: NavigationAction
Это, пожалуй, та часть, над которой я ломал голову дольше всего. Мне отчаянно хотелось создать что-то, похожее на Safe Args из мира XML, — инструмент, который ловит ошибки передачи аргументов еще на этапе компиляции (compile time), а не в crash log-ах, отчетах от QA (ребята, я вас обожаю) или в гневных отзывах клиентов.
Идея проста: нам нужен класс, который будет инкапсулировать в себе все аргументы, необходимые для перехода на конкретный экран. Этот класс будет наследоваться от одного общего предка.
Как это работает
Генератор создает базовый класс NavigationAction:
open class NavigationAction( val route: String, )
А дальше для каждого экрана генерируется свой класс-наследник.
Если у экрана нет аргументов, создается легковесный data object.
Если аргументы есть, создается обычный
class. (Помним про оптимизацию: нам не нужны методыequals,hashCodeиtoStringдля этих классов, поэтому используетсяclass, а неdata class).
Пример для экрана без аргументов:
data object ActionToMain : NavigationAction( route = "main", )
Пример для экрана с аргументами:
class ActionToSecond( title: String, ) : NavigationAction( route = "second?title=$title", )
Особые случаи: NavController и ViewModel
Есть два типа параметров, которые обрабатываются особым образом:
NavHostController: Он автоматически исключается из списка аргументов вNavigationAction, так как мы можем напрямую передать его изNavHostв каждую Composable-функцию экрана.ViewModel: Обычно при использовании DI-фреймворков мы привязываем ViewModel прямо в аргументах функции:MyScreen(viewModel: MyViewModel = koinViewModel()). Генератор также распознает это и исключаетViewModelизNavigationAction.
Но в качестве бонуса я добавил возможность автоматизировать и это! Вы можете в build.gradle передать аргумент viewModelInjector. Он принимает значения koin или hilt, и в этом случае реализация ViewModel будет подставлена в NavHost автоматически, и вам даже не придется писать ее на экране.
Без авто-инъекции:
SecondViewModel = koinViewModel()С использованием авто-инъекции:
SecondViewModel
Работа с Nullable-типами
И последнее, но не менее важное: библиотека корректно обрабатывает nullable-аргументы, передавая и получая их без проблем.
А что с анимациями? NavigationAnimation
Всем известно, что пользователи любят красивую и плавную анимацию переходов между экранами. Но как реализ��вать ее с минимальными усилиями для программиста?
В моей библиотеке этот процесс устроен максимально просто и гибко. В первой версии доступно 5 готовых типов анимации: Fade, SlideLeft, SlideRight, SlideUp, SlideDown.
Управлять ими можно на двух уровнях:
Глобальная анимация по умолчанию. Чтобы не указывать анимацию для каждого экрана, вы можете задать ее один раз для всего модуля. Для этого в файле
build.gradleвашего модуля нужно добавить параметрdefaultAnimationв настройки KSP.Индивидуальная анимация для экрана. Если для какого-то конкретного экрана нужна особая анимация, вы можете легко переопределить глобальную настройку, указав нужный тип прямо в аннотации
@KoGenScreen(animation = ...)на этом экране.
Генератор сам подставит в NavHost весь необходимый код. Вот пример кода, который генератор создает для анимации SlideLeft:
enterTransition = { androidx.compose.animation.slideIn( initialOffset = { androidx.compose.ui.unit.IntOffset(it.width, 0) } ) }, exitTransition = { androidx.compose.animation.slideOut( targetOffset = { androidx.compose.ui.unit.IntOffset(-it.width, 0) } ) }, popEnterTransition = { androidx.compose.animation.slideIn( initialOffset = { androidx.compose.ui.unit.IntOffset(-it.width, 0) } ) }, popExitTransition = { androidx.compose.animation.slideOut( targetOffset = { androidx.compose.ui.unit.IntOffset(it.width, 0) } ) }
Финальный штрих: Возвращаем результат с экрана (NavigationResult)
Иногда возникает задача, которую стандартная навигация решает не очень элегантно: как вернуть результат с одного экрана на предыдущий? Например, пользователь на экране B выбрал какой-то элемент, и нам нужно обновить экран A этим выбором после возвращения.
Чтобы и этот процесс максимально упростить, в библиотеке есть механизм NavigationResult.
Шаг 1: Определяем ключ для результата
Для начала, мы снова используем sealed class, чтобы типобезопасно описать ключ и тип данных, которые мы хотим вернуть.
// Этот класс можно создать в любом месте проекта sealed class NavigationResultValues<T(override val key: String, override val defaultValue: T) : NavigationResultKey<T { data object ShowToast : NavigationResultValues<Boolean("showToast", false) }
Шаг 2: Возвращаем результат
Теперь с экрана, который должен вернуть данные (экран B), мы вызываем нашу popBackSafety функцию, передавая в нее специальный объект BackStackData.
// Пример вызова на экране B backClick = { navController.popBackSafety( backStackData = BackStackData(NavigationResultValues.ShowToast, true) ) }
Шаг 3: Получаем результат
А на предыдущем экране (экран A), который ожидает результат, мы "ловим" его с помощью getResultData внутри LaunchedEffect.
// Пример получения результата на экране A LaunchedEffect(Unit) { if (navController.getResultData(NavigationResultValues.ShowToast) == true) { Toast.makeText(context, "It's a toast from nav result", Toast.LENGTH_SHORT).show() } }
Благодаря такой конструкции, getResultData возвращает нам данные нужного типа, и мы можем безопасно с ними работать.
Как это работает "под капотом"?
Вся магия происходит в двух extension-функциях, которые также генерируются автоматически.
Во-первых, мы немного дополняем наш popBackSafety, чтобы он умел записывать данные в SavedStateHandle предыдущего экрана:
fun NavHostController.popBackSafety(backStackData: BackStackData?) { // ... (старый код с логированием) // Добавляется обработка результата backStackData?.let { previousBackStackEntry?.savedStateHandle?.set(it.data.key, it.value) } popBackStack() }
Во-вторых, добавляется новый extension для чтения этого результата:
fun <T NavHostController.getResultData( data: kz.evko.navigation.helpers.NavigationResultKey<T, clearData: Boolean = true, ): T? { val result = this.currentBackStackEntry?.savedStateHandle?.get(data.key) as T? if (clearData) this.currentBackStackEntry?.savedStateHandle?.remove<T(data.key) return result }
ЧАСТЬ 5: Финал, руководство и заключение
Так как же теперь выглядит навигация?
Итак, к чему мы пришли после всех этих улучшений?
После однократной настройки проекта все, что вам теперь нужно сделать для добавления нового экрана в граф навигации, — это поставить над его Composable-функцией одну аннотацию @KoGenScreen.
Вот и все.
// Просто создаем Composable-функцию экрана, описывая все нужные ей параметры @KoGenScreen @Composable fun MyAwesomeScreen( // NavController будет предоставлен автоматически navController: NavHostController, // Этот параметр будет автоматически превращен в обязательный аргумент навигации myArgument: String, // ViewModel будет обработан и исключен из списка аргументов viewModel: MyViewModel = koinViewModel() ) { // ... ваш UI ... }
После следующей сборки проекта KSP автоматически сгенерирует для вас класс ActionToMyAwesome со всеми необходимыми параметрами (myArgument в нашем случае). Вам больше не нужно переживать, что вы или ваш коллега забудете передать какой-то параметр или передадите его с неверным типом — проект просто не скомпилируется.
Мы получили safe args, причем такой, который даже не нужно объявлять в XML.
Ограничения и особенности
Но, как и у любого инструмента, у моего подхода есть свои особенности, о которых будет честно рассказать сразу.
Это KSP, и он работает во время сборки. Это создает определенный порядок действий: вы создаете новую
Composable-функцию, ставите аннотацию, собираете проект, и только после этого появляются сгенерированный классActionи другие компоненты, которые можно использовать в коде.Сгенерированный код находится в вашем модуле. Из-за некоторых особенностей KSP,
extension-функции иNavHostпомещаются не в саму библиотеку, а генерируются прямо в вашем проекте. Это значит, что при первоначальной настройке вам нужно добавить хотя бы один экран с аннотацией и собрать проект, чтобы эти функции появились.KSP иногда "сходит с ума". Это крайне редкое явление, но иногда кэш KSP может "засориться", и генерация перестает корректно работать. Если вы столкнулись с необъяснимым поведением, стандартное лечение — полная очистка проекта (например, через
./gradlew clean) и пересборка.Необходимость ручного подключения зависимостей. Моя библиотека является удобной надстройкой над стандартным Jetpack Navigation, а не его полной заменой. Поэтому для ее работы вам потребуется самостоятельно подключить в свой проект саму библиотеку навигации: androidx.navigation:navigation-compose. Полный список необходимых зависимостей вы найдете в документации на GitHub.
Руководство по установке и настройке
Библиотека опубликована в Maven Central (за что огромное спасибо моим друзьям-девопсам, без них я бы не справился!). Чтобы начать ей пользоваться, нужно выполнить несколько простых шагов по настройке.
Шаг 1: Подключаем плагин KSP
Сначала убедитесь, что плагин KSP подключен к вашему проекту.
В файле build.gradle.kts корневого проекта:
plugins { // ... id("com.google.devtools.ksp") version "2.0.0-1.0.21" apply false }
В файле build.gradle.kts вашего модуля:
plugins { // ... id("com.google.devtools.ksp") }
Шаг 2: Добавляем зависимости
В dependencies вашего модуля добавьте все необходимые зависимости: саму библиотеку Jetpack Navigation, вашу библиотеку и ее KSP-процессор.
dependencies { // Сама библиотека Jetpack Navigation implementation("androidx.navigation:navigation-compose:2.7.7") // Наша библиотека implementation("io.github.eugenprog:navigation-compose:1.0.0") ksp("io.github.eugenprog:navigation-compose:1.0.0") }
Важно: Всегда проверяйте последние актуальные версии библиотек. Версии для implementation и ksp вашей библиотеки должны совпадать.
Шаг 3: Настраиваем кодогенерацию
В build.gradle.kts вашего модуля добавьте блок ksp для настройки генератора.
ksp { arg("packageName", "com.myawesome.project") arg("defaultAnimation", "slideLeft") arg("viewModelInjector", "koin") }
Объяснение параметров:
packageName(обязательный) — нужен для того, чтобы сгенерированные классы находились в правильном пространстве имен вашего проекта.defaultAnimation(опциональный) — анимация по умолчанию для всех переходов. Принимает значения:slideLeft,slideRight,slideUp,slideDown,fade,none.viewModelInjector(опциональный) — для автоматической подстановки ViewModel. Принимает значения:koin,hilt.
Вот и все! После этих настроек и первой сборки проекта вы можете начать пользоваться библиотекой, наслаждаться приятными отзывами клиентов и в освободившееся время пить кофе.
P.S. Вашим ПМ необязательно знать, что работы стало меньше. 😉
Вместо заключения
Спасибо, что дочитали до конца!
Это лишь первая версия библиотеки, и я планирую ее активно развивать: исправлять ошибки (если они найдутся) и делать ее еще удобнее для коллег-андроидщиков. Я сам использую ее уже в трех проектах и наслаждаюсь ее прекрасной работой.
Полный демо-проект, а также документацию по использованию вы можете найти в репозитории на GitHub.
Вывод, обогнал я Google или нет — на ваше усмотрение. 😉
