Перейти к содержимому
Все записи

Типобезопасная навигация в Jetpack Compose: маршруты как данные, а не строки

Как заменить строковые маршруты Compose Navigation сериализуемыми Kotlin-объектами — типобезопасные аргументы, вложенные графы и deep links, переживающие рефакторинг.

MFKAPPS 4 мин чтения

В первые несколько лет существования 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 по-прежнему разрешается в настоящий объект, а не в набор строк, которые нужно парсить вручную:

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 в конечном счёте хранит маршрут как сериализованную строку и должен знать, как кодировать и декодировать в неё ваш тип. Это всего несколько строк, но не бесплатно — заложите на это время на первом же экране, которому понадобится непримитивный аргумент.

Мигрируйте от листьев к корню, а не наоборот. Если сначала конвертировать самые внутренние экраны, а уже потом графы, которые их размещают, можно держать строковые и типобезопасные маршруты бок о бок по ходу дела — вместо переписывания всего разом, когда ничего не компилируется, пока не готов каждый маршрут. Я переносил по одному флоу за раз — сначала онбординг, потом детали товара, потом настройки, — вручную проверяя навигацию после каждого, и никогда не имел больше горстки файлов в неконсистентном состоянии.

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

// По теме

Ещё из журнала

MFKAPPS 4 мин чтения

In-App Review API в Android: как просить оценку, не раздражая пользователя

Практическое руководство по In-App Review API от Google — как он на самом деле работает, когда его запускать и почему обычный попап «оцените нас» незаметно портит вашу оценку в Play Store.

#android #engineering #kotlin
MFKAPPS 5 мин чтения

Ярлыки приложений Android и плитка быстрых настроек: логирование воды без открытия приложения

Практическое руководство по динамическому ShortcutManager API и TileService в Android — как позволить действию в один тап полностью обойти приложение, и о подводных камнях, на которых спотыкается большинство реализаций.

#android #engineering #kotlin
MFKAPPS 4 мин чтения

Типы foreground-сервисов в Android в 2026 году: как выбрать тот, что реально подходит вашей функции

Практическое руководство по ограничениям типов foreground-сервисов Android — dataSync, mediaPlayback, specialUse, shortService — и как выбрать правильный, не получив ни принудительной остановки, ни отказа в проверке.

#android #engineering #kotlin