Навигация в 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 или нет — на ваше усмотрение. ?
Комментарии (6)
kloun_za_2rub
16.06.2025 10:08По поводу первой части. Есть же safety api для навигации через Serializable классы, и toRoute<Class> функцию, все удобно и без этих строк рандомных.
По поводу второй части, кодогенерация это хорошо, но как будто не сюда) Люблю Compose за его линейность, когда можно покликать по функциями или же просто по названиям искать код, а тут появляется случайный код в build. В библиотеках по типу room/dagger, так как в одном это возможность скрыть весь ужас sqlite апи, а в другом создание графа зависимостей во время компиляции для определения вероятных ошибок. Здесь же не вижу причин это делать, только размывает контекст. Но работа проделана хорошая)
Прим. Под частью подразумевал не части из статьи
Eugen_prog Автор
16.06.2025 10:08Огромное спасибо за такой развернутый и очень вдумчивый комментарий! Это именно та техническая дискуссия, ради которой и пишутся статьи на Хабре. Вы подняли два абсолютно верных и важных вопроса.
1. По поводу
toRoute<Class>
иSerializable
API.Вы на 100% правы. Официальная библиотека сделала гигантский шаг вперед, и возможность навигироваться на
Serializable
/Parcelize
класс — это фантастическое улучшение. Оно действительно решает проблему типобезопасной передачи данных.Моя основная "боль", которую я пытался решить, лежит немного в другой плоскости — не в самой передаче данных, а в объявлении навигации и ее вызове. Даже с новым API, разработчику все еще нужно вручную:
Создавать
composable
блок вNavHost
для каждого экрана.Вручную прописывать
arguments = listOf(...)
, если у аргументов есть значения по умолчанию.Вручную настраивать
enterTransition
,exitTransition
и другие анимации для каждого экрана, если они отличаются от стандартных.Вручную реализовывать логику
popUpTo
.
Моя библиотека с помощью KSP берет на себя именно эту рутину, генерируя весь
NavHost
целиком на основе одной лишь аннотации над экраном. То есть, она является "надстройкой" над новым type-safety API, которая автоматизирует его настройку.2. По поводу философии кодогенерации.
Насчет второго пункта — о "линейности" Compose и "размытии контекста" — это 100% валидный и, я бы сказал, философский вопрос. Я очень уважаю эту точку зрения, и для меня самого это был компромисс.
Мы действительно "платим" за магию кодогенерации небольшой потерей "прозрачности" (сгенерированный код не виден сразу), но взамен я старался получить другие преимущества:
Единый источник правды: Вся конфигурация экрана (его аргументы, анимация, DI-потребности) теперь находится в одной аннотации над Composable-функцией. Она не "размазана" по
enum
-классам, билдеруNavHost
и ViewModel. На мой взгляд, это наоборот, концентрирует контекст в одном месте.Сокращение рутины: В проектах с десятками экранов это экономит сотни, если не тысячи, строк повторяющегося кода, который нужно писать и поддерживать.
Но я абсолютно согласен, что это выбор. Кому-то комфортнее видеть весь код явно, даже если он повторяющийся. Моя библиотека — для тех, кто, как и я, готов немного "отпустить контроль" в обмен на автоматизацию этой конкретной рутины.
Еще раз огромное спасибо за такой качественный фидбэк! Это очень ценно.
keymusicman
16.06.2025 10:08При чтении статьи действительно большим упущением казалось то, что автором не изучен вопрос с type safe api. Type-safe подход уже стал стандартом в современных приложениях на Compose. Например, в приложении "Now in Android" даже роуты без аргументов объявлены с помощью type safe api, в виде object. То есть стандартная библиотека уже есть и работает похожим образом, но требует писать меньше кода и и не требует кодогенерации.
Type safe api не требует прописывать
arguments
, так что этой боли также уже не должно быть.В "Продвинутые возможности и результат" предлагается использовать опять же строки для объявления роута (параметр route типа
NavigationAction
). То есть магические строки фактически остаются.Также вопросы вызывает тезис об экономии тысяч строк на десятках экранов. С использованием type safe api объявление
composable
экрана занимает 3 строки, его добавление в NavHost — еще одну строку. Чтобы экономить хотя бы сотни строк, проект должен быть уровня бигтеха, а на десятках экранов экономия будет незначительной и лично для меня не оправдала бы внедрение новой библиотеки.В целом, приятно видеть инициативу и попытки улучшить навигацию, но, на мой взгляд, важно учитывать уже существующие решения.
Nek_12
16.06.2025 10:08Не понимаю, чем это решение лучше, чем compose-destinations? CD существуют уже больше 3х лет, задолго до создания этой библиотеки, и обладает бОльшим функционалом.
-
Также не понял где был "обгон" заявленный в заголовке. У самой compose навигации давно есть тоже своя система типобезопасной навигации через котлин-сериализацию, около года.
Однако библиотека в статье была создана около недели назад, судя по истории коммитов.
По структуре первого коммита можно увидеть что она недавно была сгенерирована из свежего шаблона, аргумент с тем, что она была в закрытом доступе, отпадает.В чем был обгон?
Krokochik
Я не андроид разработчик и не пишу на jetpack compose, но статья мне понравилась. Приятно написано и идеи вроде интересные)
Eugen_prog Автор
Очень вам благодарен за такой отзыв! Я как раз старался написать не просто сухую техническую инструкцию, а поделиться самой историей и подходом. Вдвойне приятно, что это было интересно читать даже за пределами Android-комьюнити. Спасибо!