Типобезопасная навигация в Jetpack Compose: маршруты как данные, а не строки
Как заменить строковые маршруты Compose Navigation сериализуемыми Kotlin-объектами — типобезопасные аргументы, вложенные графы и deep links, переживающие рефакторинг.
В первые несколько лет существования Compose Navigation каждый маршрут был строкой. Вы собирали "item/{itemId}", сопоставляли его в NavHost и доставали itemId обратно из NavBackStackEntry с помощью getString("itemId") — надеясь, что ключ, который вы напечатали на выходе, совпадёт с тем, что напечатали на входе. Компилятор ничем не мог помочь. Опечатка в любой из строк спокойно компилировалась и падала во время выполнения — обычно на экране, на который вы не смотрели в момент, когда вносили изменение. Типобезопасные маршруты Navigation Compose решают это, делая маршрут Kotlin-объектом вместо строки, и после переноса на них каждого экрана в реальном приложении этот класс багов — опечатка в аргументе — исчез полностью: компилятор ловит её ещё до завершения сборки.
Маршруты как sealed-иерархия
Идея проста: определите пункты назначения как sealed interface, аннотируйте каждый @Serializable и позвольте kotlinx.serialization заниматься превращением их в маршрут и обратно.
sealed interface Screen {
@Serializable
data object Home : Screen
@Serializable
data class ItemDetail(val itemId: Long) : Screen
@Serializable
data class EditItem(val itemId: Long, val fromDetail: Boolean = false) : Screen
}
Никакого строкового шаблона, никакого имени ключа, которое нужно держать в синхроне, — аргументы и есть свойства класса. Навигация — это вызов функции с настоящими типами:
navController.navigate(Screen.ItemDetail(itemId = item.id))
Если бы itemId переименовали или его тип поменялся с Long на String где-то выше по цепочке, каждое место вызова перестало бы компилироваться — вместо того чтобы не распарситься во время выполнения.
Подключение NavHost
Со стороны NavHost всё симметрично: перегрузка composable<T>() принимает тип как reified-параметр вместо строкового шаблона:
NavHost(navController = navController, startDestination = Screen.Home) {
composable<Screen.Home> {
HomeScreen(onItemClick = { id -> navController.navigate(Screen.ItemDetail(id)) })
}
composable<Screen.ItemDetail> { backStackEntry ->
val args: Screen.ItemDetail = backStackEntry.toRoute()
ItemDetailScreen(itemId = args.itemId)
}
composable<Screen.EditItem> { backStackEntry ->
val args: Screen.EditItem = backStackEntry.toRoute()
EditItemScreen(itemId = args.itemId, cameFromDetail = args.fromDetail)
}
}
toRoute() десериализует запись back stack прямо в тип назначения. Нет ключа Bundle, в котором можно опечататься, и нет ручной проверки на null для отсутствующего аргумента — обязательное свойство, которого нет, просто не скомпилируется, а необязательное получит объявленное значение по умолчанию — так же, как любой другой аргумент функции в Kotlin.
Вложенные графы тоже остаются типобезопасными
Тот же паттерн распространяется на вложенные графы навигации — именно там строковые маршруты когда-то доставляли по-настоящему много боли: опечатка в начальном пункте назначения вложенного графа падает молча и просто показывает пустой экран. С sealed-маршрутами вложенный граф — это собственный тип:
sealed interface OnboardingGraph : Screen {
@Serializable data object Welcome : OnboardingGraph
@Serializable data object Permissions : OnboardingGraph
@Serializable data class Done(val skippedPermissions: Boolean) : OnboardingGraph
}
navigation<OnboardingGraph>(startDestination = OnboardingGraph.Welcome) {
composable<OnboardingGraph.Welcome> { /* ... */ }
composable<OnboardingGraph.Permissions> { /* ... */ }
composable<OnboardingGraph.Done> { entry ->
val args: OnboardingGraph.Done = entry.toRoute()
// ...
}
}
Поскольку OnboardingGraph расширяет Screen, коду в других частях приложения, который выполняет навигацию по внешнему графу, не нужно знать о существовании вложенного графа — он просто вызывает navigate(OnboardingGraph.Welcome), а граница графа определяется структурно, а не сопоставлением строковых префиксов.
Deep links без URI-шаблона, в котором можно ошибиться
Deep links привязываются к тем же типизированным пунктам назначения, поэтому входящий URI по-прежнему разрешается в настоящий объект, а не в набор строк, которые нужно парсить вручную:
composable<Screen.ItemDetail>(
deepLinks = listOf(navDeepLink<Screen.ItemDetail>(basePath = "myapp://item"))
) { backStackEntry ->
val args: Screen.ItemDetail = backStackEntry.toRoute()
ItemDetailScreen(itemId = args.itemId)
}
И myapp://item/42, и вызов navigate(Screen.ItemDetail(42)) внутри приложения попадают в один и тот же composable с одним и тем же типизированным аргументом — существует ровно одно место, которое определяет, как выглядит пункт назначения ItemDetail, независимо от того, как до него добрались: через тап по уведомлению, виджет или кнопку внутри приложения.
Две вещи, которые стоит знать перед миграцией
Собственным типам в маршруте нужен свой NavType. Аргумент типа Long или String работает из коробки, но маршрут, содержащий enum или value class, требует небольшой реализации NavType<T>, зарегистрированной на вызове composable, — ведь back stack в конечном счёте хранит маршрут как сериализованную строку и должен знать, как кодировать и декодировать в неё ваш тип. Это всего несколько строк, но не бесплатно — заложите на это время на первом же экране, которому понадобится непримитивный аргумент.
Мигрируйте от листьев к корню, а не наоборот. Если сначала конвертировать самые внутренние экраны, а уже потом графы, которые их размещают, можно держать строковые и типобезопасные маршруты бок о бок по ходу дела — вместо переписывания всего разом, когда ничего не компилируется, пока не готов каждый маршрут. Я переносил по одному флоу за раз — сначала онбординг, потом детали товара, потом настройки, — вручную проверяя навигацию после каждого, и никогда не имел больше горстки файлов в неконсистентном состоянии.
В итоге код навигации не становится быстрее — он становится таким, где класс багов, раньше переживавших код-ревью (переименованный аргумент, который всё ещё парсится, но не в тот тип), просто больше не существует. Для приложения, которое поддерживает один человек и где нет второго ревьюера, способного заметить опечатку, это и есть самый весомый аргумент.
// По теме
Ещё из журнала
In-App Review API в Android: как просить оценку, не раздражая пользователя
Практическое руководство по In-App Review API от Google — как он на самом деле работает, когда его запускать и почему обычный попап «оцените нас» незаметно портит вашу оценку в Play Store.
Ярлыки приложений Android и плитка быстрых настроек: логирование воды без открытия приложения
Практическое руководство по динамическому ShortcutManager API и TileService в Android — как позволить действию в один тап полностью обойти приложение, и о подводных камнях, на которых спотыкается большинство реализаций.
Типы foreground-сервисов в Android в 2026 году: как выбрать тот, что реально подходит вашей функции
Практическое руководство по ограничениям типов foreground-сервисов Android — dataSync, mediaPlayback, specialUse, shortService — и как выбрать правильный, не получив ни принудительной остановки, ни отказа в проверке.