Привет, Хабр! Меня зовут Артем и я автор и ведущий 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 в репозитории

Комментарии (0)


  1. nihil-pro
    14.09.2025 19:24

    На мой взгляд, у вас все еще сильная связь между разными экранами, условно Product знает о существовании Home.
    Я бы смотрел в сторону path-navigation. Благодаря нормальному DI и AOP в котлине, вы могли бы сделать что-то такое, только гораздо чище с точки зрения архитектуры.
    Сейчас у вас больше config-based-router

    Nav3Host(
    	    backStack = backStack,
    	    router = router,
    	) { backStack, onBack, _ ->
            NavDisplay(
                backStack = backStack,
                onBack = onBack,
                entryProvider = entryProvider {
                    entry<Screen.Home> { HomeScreen() } // !
                    entry<Screen.Details> { DetailsScreen() } // !
                }
            )
        }


    1. arttttt Автор
      14.09.2025 19:24

      1) в чем по-вашему выражается сильная связь?
      2) чем может помочь path навигация? если это выглядит так, как в примере ниже, то в android от этого очень долго пытались уйти в навигации от гугл и наконец ушли тк типизированная в контексте android подходит гораздо больше

      entry("/path/to/screen")

      3) чем плох config based router в контексте android разработки?


      1. nihil-pro
        14.09.2025 19:24

        1) в чем по-вашему выражается сильная связь?

        Вы пишите:

        ...передавать NavBackStack в ViewModel (что в моем понимании нарушает принципы архитектуры, так как я считаю...

        То есть, ссылаетесь на какие-то принципы, но прямо не озвучиваете их. Напишите о каких принципах речь, и первый ваш вопрос отпадет.

        2) чем может помочь path навигация? ...
        ...типизированная в контексте android подходит гораздо больше..

        Типизированная в любом контексте лучше, я с этом полностью согласен. Но path-based тоже может быть типизированным.

        3) чем плох config based router в контексте android разработки?

        А как контекст андроид приложения меняет суть? Плох тем, что получается good-object + service-locator в одном.

        Я честно не вижу, каким образом ваш роутер решил вами же описанные проблемы.

        Было:

        // Добавить экран
        backStack.add(Screen.Details("123"))
            
        // Вернуться назад
        backStack.removeLastOrNull()
        
        // Заменить текущий экран
        backStack.set(backStack.lastIndex, Screen.Success)

        стало

        // Переход на новый экран
        router.push(Screen.Details("123"))
        
        // Возврат назад
        router.pop()
        
        // Переход с заменой текущего экрана (назад вернуться нельзя)
        router.replaceCurrent(Screen.Success)