Привет, Хабр! Меня зовут Артем и я автор и ведущий YouTube и Telegram каналов Android Insights.
Что такое Jetpack Navigation 3
Jetpack Navigation 3 — это новая версия библиотеки навигации от Google, которая кардинально отличается от предыдущих версий. Основная идея Nav3 проста: у вас есть NavBackStack
— обычный изменяемый список, где каждый элемент представляет экран в вашем приложении. Вы добавляете и удаляете элементы из этого списка, и UI автоматически обновляется. Каждый экран представлен как NavKey
— обычный Kotlin-класс.
Это даёт полный контроль над навигацией, но требует написания довольно много шаблонного кода для типовых операций.
Почему работать напрямую с NavBackStack неудобно
Давайте посмотрим, как выглядит код при прямой работе с NavBackStack
:
@Composable
fun MyApp() {
val backStack = rememberNavBackStack(Screen.Home)
// Добавить экран
backStack.add(Screen.Details("123"))
// Вернуться назад
backStack.removeLastOrNull()
// Заменить текущий экран
backStack.set(backStack.lastIndex, Screen.Success)
}
Проблемы начинаются, когда вам нужно вызвать навигацию из ViewModel. Придётся либо передавать NavBackStack
в ViewModel (что в моем понимании нарушает принципы архитектуры, так как я считаю, что ViewModel не должна знать о Compose-специфичных вещах), либо создавать промежуточные callback'и для каждого действия навигации.
Кроме того, при работе со стеком напрямую легко забыть обработать граничные случаи.
Как Nav3 Router упрощает работу
Nav3 Router — это тонкая обёртка над Navigation 3, которая предоставляет привычный API для навигации. Вместо того чтобы думать об индексах и операциях со списком, вы просто говорите "перейди на экран X" или "вернись назад".
Важный момент: Nav3 Router не создаёт свой собственный стек. Он работает с тем же NavBackStack
, который предоставляет Navigation 3, просто делает работу с ним удобнее. Когда вы вызываете router.push(Screen.Details)
, библиотека транслирует это в соответствующую операцию с оригинальным стеком.
О��новные преимущества:
Можно использовать из ViewModel
Команды навигации буферизируются, если UI временно недоступен (например, при повороте экрана)
Все операции со стеком происходят атомарно
Понятный API
Гибкость в модификации и добавлении собственного поведения
Подключение
Nav3 Router доступен в Maven Central. Добавьте зависимость в ваш build.gradle.kts
:
// Для shared модуля в KMP проекте
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.arttttt.nav3router:nav3router:1.0.0")
}
}
}
// Для Android-only проекта
dependencies {
implementation("io.github.arttttt.nav3router:nav3router:1.0.0")
}
Исходный код библиотеки доступен на GitHub: github.com/arttttt/Nav3Router
Как устроен Nav3 Router
Библиотека состоит из трёх основных частей, каждая решает свою задачу:
Router — интерфейс для разработчика
Router предоставляет методы вроде push()
, pop()
, replace()
. Когда вы вызываете эти методы, Router создаёт соответствующие команды и отправляет их дальше по цепочке. Сам Router не знает ничего о том, как именно будет выполнена навигация — это позволяет использовать его откуда угодно.
CommandQueue — буфер между командами и их выполнением
CommandQueue решает проблему таймингов. Представьте: пользователь нажал кнопку в момент поворота экрана. UI пересоздаётся, навигатор временно недоступен. CommandQueue сохранит команду и выполнит её, как только навигатор снова будет готов. Без этого команда просто потерялась бы.
// Упрощённая логика работы очереди
class CommandQueue {
private var navigator: Navigator? = null
private val pending = mutableListOf<Command>()
fun executeCommand(command: Command) {
if (navigator != null) {
navigator.apply(command) // Есть навигатор - выполняем сразу
} else {
pending.add(command) // Нет - сохраняем на потом
}
}
}
Navigator — тот, кто работает со стеком
Navigator берёт команды и применяет их к NavBackStack
. Важная деталь: он сначала создаёт копию текущего стека, применяет к ней все команды, и только потом атомарно заменяет оригинальный стек на модифицированную копию. Это гарантирует, что UI никогда не увидит промежуточные состояния стека.
// Упрощённая логика Navigator
fun applyCommands(commands: Array) {
val stackCopy = backStack.toMutableList() // Работаем с копией
for (command in commands) {
when (command) {
is Push -> stackCopy.add(command.screen)
is Pop -> stackCopy.removeLastOrNull()
// ... другие команды
}
}
backStack.swap(stackCopy) // Атомарно применяем изменения
}
Начинаем использовать Nav3 Router
Самый простой способ — даже не создавать Router вручную. Nav3Host сделает это за вас:
@Composable
fun App() {
val backStack = rememberNavBackStack(Screen.Home)
// Nav3Host создаст Router автоматически
Nav3Host(backStack = backStack) { backStack, onBack, router ->
NavDisplay(
backStack = backStack,
onBack = onBack,
entryProvider = entryProvider {
entry {
HomeScreen(
onOpenDetails = {
router.push(Screen.Details) // Используем router
}
)
}
entry {
DetailsScreen(
onBack = { router.pop() }
)
}
}
)
}
}
Для более сложных приложений имеет смысл создавать Router через DI и передавать его в ViewModel:
Определяем экраны
@Serializable
sealed interface Screen : NavKey {
@Serializable
data object Home : Screen
@Serializable
data class Product(val id: String) : Screen
@Serializable
data object Cart : Screen
}
Передаем router в Nav3Host
@Composable
fun App() {
val backStack = rememberNavBackStack(Screen.Home)
val router: Router = getSomehowUsingDI()
// Передаем Router в Nav3Host
Nav3Host(
backStack = backStack,
router = router,
) { backStack, onBack, _ ->
NavDisplay(
backStack = backStack,
onBack = onBack,
entryProvider = entryProvider {
entry<Screen.Home> { HomeScreen() }
entry<Screen.Details> { DetailsScreen() }
}
)
}
}
ViewModel получает Router через конструктор
class ProductViewModel(
private val router: Router,
private val cartRepository: CartRepository
) : ViewModel() {
fun addToCart(productId: String) {
viewModelScope.launch {
cartRepository.add(productId)
router.push(Screen.Cart) // Навигация из ViewModel
}
}
}
В UI просто используем ViewModel
@Composable
fun ProductScreen(viewModel: ProductViewModel = koinViewModel()) {
Button(onClick = { viewModel.addToCart(productId) }) {
Text("В корзину")
}
}
Примеры типовых сценариев
Простая навигация вперёд-назад
// Переход на новый экран
router.push(Screen.Details(productId))
// Возврат назад
router.pop()
// Переход с заменой текущего экрана (назад вернуться нельзя)
router.replaceCurrent(Screen.Success)
Работа с цепочками экранов
// Открыть сразу несколько экранов
router.push(
Screen.Category("electronics"),
Screen.Product("laptop-123"),
Screen.Reviews("laptop-123")
)
// Вернуться к конкретному экрану
// Удалит все экраны выше Product из стека
router.popTo(Screen.Product("laptop-123"))
Сценарий оформления заказа
@Composable
fun CheckoutScreen(router: Router) {
Button(
onClick = {
// После оформления заказа нужно:
// 1. Показать экран подтверждения
// 2. Не дать вернуться обратно в корзину
router.replaceStack(
Screen.Home,
Screen.OrderSuccess(orderId)
)
// Теперь в стеке только Home и OrderSuccess
}
) {
Text("Оформить заказ")
}
}
Выход из вложенной навигации
// Пользователь глубоко в настройках:
// Home -> Settings -> Account -> Privacy -> DataManagement
// Кнопка "Готово" должна вернуть на главную
Button(
onClick = {
// Оставит только root (Home)
router.clearStack()
}
) {
Text("Готово")
}
// Или если нужно закрыть приложение из любого места
Button(
onClick = {
// Оставит только текущий экран и вызовет системный back
router.dropStack()
}
) {
Text("Выйти")
}
Бонус: SceneStrategy и модальные окна
До сих пор мы говорили только о простой навигации между экранами. Но что если вам нужно показать диалог или bottom sheet? Здесь на помощь приходит концепция SceneStrategy из Navigation 3.
Что такое SceneStrategy
SceneStrategy — это механизм, который определяет, как именно будут отображаться экраны из вашего стека. По умолчанию Navigation 3 использует SinglePaneSceneStrategy
, который просто показывает последний экран из стека. Но вы можете создавать свои стратегии для более сложных сценариев.
Представьте SceneStrategy как режиссёра, который смотрит на ваш стек экранов и решает: "Так, эти три экрана показываем как обычно, а вот этот последний — как модальное окно поверх предыдущих". Это позволяет одним и тем же стеком представлять разные UI-паттерны.
Создаём стратегию для ModalBottomSheet
Давайте создадим стратегию, которая будет показывать определённые экраны как bottom sheet. Сначала определим, как мы будем помечать такие экраны:
@Serializable
sealed interface Screen : NavKey {
@Serializable
data object Home : Screen
@Serializable
data class Product(val id: String) : Screen
// Этот экран будем показывать как bottom sheet
@Serializable
data object Filters : Screen
}
Теперь создадим саму стратегию. Она будет проверять метаданные последнего экрана и, если находит специальный маркер, показывать его как bottom sheet:
class BottomSheetScene<T : Any>(
override val key: T,
override val previousEntries: List<NavEntry<T>>,
override val overlaidEntries: List<NavEntry<T>>,
private val entry: NavEntry<T>,
private val onBack: (count: Int) -> Unit,
) : OverlayScene<T> {
override val entries: List<NavEntry<T>> = listOf(entry)
override val content: @Composable (() -> Unit) = {
ModalBottomSheet(
onDismissRequest = { onBack(1) },
) {
entry.Content()
}
}
}
class BottomSheetSceneStrategy<T : Any> : SceneStrategy<T> {
companion object {
// Ключ для метаданных, по которому определяем bottom sheet
private const val BOTTOM_SHEET_KEY = "bottomsheet"
// Вспомогательная функция для создания метаданных
fun bottomSheet(): Map {
return mapOf(BOTTOM_SHEET_KEY to true)
}
}
@Composable
override fun calculateScene(
entries: List<NavEntry<T>>,
onBack: (Int) -> Unit
): Scene? {
val lastEntry = entries.lastOrNull() ?: return null
// Проверяем, есть ли у последнего экрана маркер bottom sheet
val isBottomSheet = lastEntry.metadata[BOTTOM_SHEET_KEY] as? Boolean
if (isBottomSheet == true) {
// Возвращаем специальную Scene для bottom sheet
return BottomSheetScene(
entry = lastEntry,
previousEntries = entries.dropLast(1),
onBack = onBack
)
}
// Это не bottom sheet, пусть другая стратегия обработает
return null
}
}
Комбинируем несколько стратегий
В реальном приложении вам может понадобиться и bottom sheet, и диалоги, и обычные экраны. Для этого можно создать стратегию-делегат, которая будет выбирать нужную стратегию для каждого экрана:
class DelegatedScreenStrategy<T : Any>(
private val strategyMap: Map<String, SceneStrategy<T>>,
private val fallbackStrategy: SceneStrategy<T>
) : SceneStrategy<T> {
@Composable
override fun calculateScene(
entries: List<NavEntry<T>>,
onBack: (Int) -> Unit
): Scene<T>? {
val lastEntry = entries.lastOrNull() ?: return null
// Проверяем все ключи в метаданных
for (key in lastEntry.metadata.keys) {
val strategy = strategyMap[key]
if (strategy != null) {
// Нашли подходящую стратегию
return strategy.calculateScene(entries, onBack)
}
}
// Используем стратегию по умолчанию
return fallbackStrategy.calculateScene(entries, onBack)
}
}
Используем в приложении
Теперь соберём всё вместе. Вот как будет выглядеть использование bottom sheet в реальном приложении:
@Composable
fun ShoppingApp() {
val backStack = rememberNavBackStack(Screen.Home)
val router = rememberRouter<Screen>()
Nav3Host(
backStack = backStack,
router = router
) { backStack, onBack, router ->
NavDisplay(
backStack = backStack,
onBack = onBack,
// Используем нашу комбинированную стратегию
sceneStrategy = DelegatedScreenStrategy(
strategyMap = mapOf(
"bottomsheet" to BottomSheetSceneStrategy(),
// Navigation 3 уже имеет эту стратегию
"dialog" to DialogSceneStrategy()
),
// Обычные экраны
fallbackStrategy = SinglePaneSceneStrategy()
),
entryProvider = entryProvider {
entry<Screen.Home> {
HomeScreen(
onOpenFilters = {
// Открываем фильтры как bottom sheet
router.push(Screen.Filters)
}
)
}
entry<Screen.Product> { screen ->
ProductScreen(productId = screen.id)
}
// Указываем, что Filters должен быть bottom sheet
entry<Screen.Filters>(
metadata = BottomSheetSceneStrategy.bottomSheet()
) {
FiltersContent(
onApply = { filters ->
// Применяем фильтры и закрываем bottom sheet
applyFilters(filters)
router.pop()
}
)
}
}
)
}
}
Что здесь происходит? Когда вы вызываете router.push(Screen.Filters)
, экран добавляется в стек как обычно. Но благодаря метаданным и нашей стратегии, UI понимает, что этот экран нужно показать как bottom sheet поверх предыдущего экрана, а не заменить его полностью.
При вызове router.pop()
bottom sheet закроется, и вы вернётесь к предыдущему экрану. С точки зрения Router'а это обычная навигация назад, но визуально это выглядит как закрытие модального окна.
Преимущества такого подхода
Использование SceneStrategy даёт несколько важных преимуществ. Во-первых, ваша навигационная логика остаётся простой — вы всё так же используете push
и pop
, не задумываясь о том, как именно будет показан экран. Во-вторых, состояние навигации остаётся консистентным — bottom sheet это просто ещё один экран в стеке, который правильно сохраняется при повороте экрана или процесс-килле. И наконец, это даёт большую гибкость — вы можете легко менять способ отображения экрана, просто изменив его метаданные, не трогая навигационную логику.
Такой подход особенно полезен, когда один и тот же экран может быть показан по-разному в зависимости от контекста. Например, экран входа может быть обычным экраном при первом запуске приложения и модальным диалогом при попытке выполнить действие, требующее авторизации.
Почему стоит использовать Nav3 Router
Nav3 Router не пытается заменить Navigation 3 или добавить новые фичи. Его задача — сделать работу с навигацией удобной и предсказуемой. Вы получаете простой API, который можно использовать из любого слоя приложения, автоматическую обработку проблем с таймингами, и возможность легко тестировать навигационную логику.
При этом под капотом всё равно работает обычный Navigation 3 со всеми его возможностями: сохранением состояния, поддержкой анимаций и правильной обработкой системной кнопки "Назад".
Если вы уже используете Navigation 3 или планируете мигрировать на неё, Nav3 Router поможет сделать этот опыт приятнее, не добавляя лишней сложности в проект.
Ссылки
GitHub репозиторий: github.com/arttttt/Nav3Router
Примеры использования: смотрите папку
sample
в репозитории
nihil-pro
На мой взгляд, у вас все еще сильная связь между разными экранами, условно Product знает о существовании Home.
Я бы смотрел в сторону path-navigation. Благодаря нормальному DI и AOP в котлине, вы могли бы сделать что-то такое, только гораздо чище с точки зрения архитектуры.
Сейчас у вас больше config-based-router
arttttt Автор
1) в чем по-вашему выражается сильная связь?
2) чем может помочь path навигация? если это выглядит так, как в примере ниже, то в android от этого очень долго пытались уйти в навигации от гугл и наконец ушли тк типизированная в контексте android подходит гораздо больше
3) чем плох config based router в контексте android разработки?
nihil-pro
Вы пишите:
То есть, ссылаетесь на какие-то принципы, но прямо не озвучиваете их. Напишите о каких принципах речь, и первый ваш вопрос отпадет.
Типизированная в любом контексте лучше, я с этом полностью согласен. Но path-based тоже может быть типизированным.
А как контекст андроид приложения меняет суть? Плох тем, что получается good-object + service-locator в одном.
Я честно не вижу, каким образом ваш роутер решил вами же описанные проблемы.
Было:
стало