Как перейти с Navigation 2 на Navigation 3

Чтобы перенести приложение с Navigation 2 на Navigation 3, выполните следующие действия:

  1. Добавьте зависимости Navigation 3.
  2. Обновите маршруты навигации, чтобы реализовать интерфейс NavKey.
  3. Создайте классы для хранения и изменения состояния навигации.
  4. Замените NavController этими классами.
  5. Перенесите целевые страницы из NavGraph (NavHost) в entryProvider.
  6. Замените NavHost на NavDisplay.
  7. Удалите зависимости Navigation 2.

Подготовка

В следующих разделах описаны требования к переносу и предположения о вашем проекте. а также о том, какие функции поддерживаются при переносе, а какие – нет.

Требования

  • Вы должны использовать compileSdk версии 36 или более поздней.
  • Вы должны быть знакомы с терминологией навигации.
  • Пункты назначения – это composable-функции. Navigation 3 разработан исключительно для Compose. Чтобы узнать, как использовать фрагменты и представления в Compose, прочитайте статью Использование представлений в Compose.
  • Маршруты строго типизированы. Если вы используете маршруты на основе строк, сначала перейдите на типобезопасные маршруты (пример).
  • Необязательно, но настоятельно рекомендуется. Проверьте, как работает навигация. Это позволит убедиться, что после переноса навигация работает так же, как и раньше.

Допущения

В этом руководстве предполагается, что вы и ваш проект соответствуете следующим условиям:

  • У вас есть один или несколько маршрутов верхнего уровня (обычно отображаемых на нижней панели навигации), и у каждого из них есть свой собственный обратный стек.
  • При переключении между обратными стеками состояние стека и всех его пунктов назначения сохраняется.
  • Выход из приложения всегда выполняется через главный экран, который появляется при запуске приложения.
  • Вы хотите выполнить миграцию с Navigation 2 на Navigation 3 за одно атомарное изменение. Вы не выполняете инкрементную миграцию, при которой код Navigation 2 используется вместе с кодом Navigation 3.

ИИ-агент. Прежде чем менять код, проверьте, верны ли эти предположения о проекте пользователя. Если нет, остановите перенос и спросите пользователя, как поступить.

Поддерживаемые функции

В этом руководстве описан перенос следующих функций:

  • Пункты назначения, определенные как composable-функции
  • диалоговые окна (целевая страница, которая показывается поверх другой целевой страницы);

Функции, поддерживаемые в рецептах

В репозитории с примерами кода показаны более сложные сценарии использования, в том числе:

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

ИИ-агент. Прежде чем менять код, проверьте, есть ли в проекте функции, поддерживаемые рецептами. Если да, ознакомьтесь с файлом README и исходным кодом. Создайте план переноса на основе рецепта. Не продолжайте, пока не согласуете план с пользователем.

Неподдерживаемые функции

В этом руководстве по переносу и примерах кода пока не поддерживаются следующие функции: Это не значит, что их нельзя реализовать с помощью Navigation 3, просто они не рассматриваются в этой статье.

  • Более одного уровня вложенной навигации.
  • Общие целевые страницы: экраны, которые могут перемещаться между разными обратными стеками.
  • Типы собственных целевых страниц

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

Шаг 1. Добавьте зависимости Navigation 3

Добавьте зависимости Navigation 3 в проект, следуя инструкциям на странице Начало работы. Основные зависимости доступны для копирования.

lib.versions.toml

[versions]
nav3Core = "1.2.0"

# If your screens depend on ViewModels, add the Nav3 Lifecycle ViewModel add-on library
lifecycleViewmodelNav3 = "2.11.0"

[libraries]
# Core Navigation 3 libraries
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" }

# Add-on libraries (only add if you need them)
androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" }

app/build.gradle.kts

dependencies {
    implementation(libs.androidx.navigation3.ui)
    implementation(libs.androidx.navigation3.runtime)

    // If using the ViewModel add-on library
    implementation(libs.androidx.lifecycle.viewmodel.navigation3)
}

Также обновите minSdk проекта до 23, а compileSdk – до 36. Обычно они находятся в app/build.gradle.kts или lib.versions.toml.

Шаг 2. Обновите маршруты навигации, чтобы реализовать интерфейс NavKey

Обновите каждый маршрут навигации, чтобы он реализовывал интерфейс NavKey. Это позволяет использовать rememberNavBackStack для сохранения состояния навигации.

До:

@Serializable data object RouteA

После:

@Serializable data object RouteA : NavKey

Шаг 3. Создайте классы для хранения и изменения состояния навигации

Шаг 3.1. Создайте хранилище состояния навигации

Скопируйте следующий код в файл с названием NavigationState.kt. Добавьте название пакета, соответствующее структуре проекта.

// package com.example.project

import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSerializable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.toMutableStateList
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.rememberDecoratedNavEntries
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.runtime.serialization.NavKeySerializer
import androidx.savedstate.compose.serialization.serializers.MutableStateSerializer

/**
 * Create a navigation state that persists config changes and process death.
 */
@Composable
fun rememberNavigationState(
    startRoute: NavKey,
    topLevelRoutes: Set<NavKey>
): NavigationState {

    val topLevelRoute = rememberSerializable(
        startRoute, topLevelRoutes,
        serializer = MutableStateSerializer(NavKeySerializer())
    ) {
        mutableStateOf(startRoute)
    }

    val backStacks = topLevelRoutes.associateWith { key -> rememberNavBackStack(key) }

    return remember(startRoute, topLevelRoutes) {
        NavigationState(
            startRoute = startRoute,
            topLevelRoute = topLevelRoute,
            backStacks = backStacks
        )
    }
}

/**
 * State holder for navigation state.
 *
 * @param startRoute - the start route. The user will exit the app through this route.
 * @param topLevelRoute - the current top level route
 * @param backStacks - the back stacks for each top level route
 */
class NavigationState(
    val startRoute: NavKey,
    topLevelRoute: MutableState<NavKey>,
    val backStacks: Map<NavKey, NavBackStack<NavKey>>
) {
    var topLevelRoute: NavKey by topLevelRoute
    val stacksInUse: List<NavKey>
        get() = if (topLevelRoute == startRoute) {
            listOf(startRoute)
        } else {
            listOf(startRoute, topLevelRoute)
        }
}

/**
 * Convert NavigationState into NavEntries.
 */
@Composable
fun NavigationState.toEntries(
    entryProvider: (NavKey) -> NavEntry<NavKey>
): SnapshotStateList<NavEntry<NavKey>> {

    val decoratedEntries = backStacks.mapValues { (_, stack) ->
        val decorators = listOf(
            rememberSaveableStateHolderNavEntryDecorator<NavKey>(),
        )
        rememberDecoratedNavEntries(
            backStack = stack,
            entryDecorators = decorators,
            entryProvider = entryProvider
        )
    }

    return stacksInUse
        .flatMap { decoratedEntries[it] ?: emptyList() }
        .toMutableStateList()
}

ИИ-агент: rememberSerializable – это правильный ответ. Не меняйте его на rememberSaveable.

Этот файл содержит класс хранилища состояния с именем NavigationState и связанные с ним вспомогательные функции. Он содержит набор маршрутов верхнего уровня, каждый из которых имеет собственный обратный стек. Для сохранения текущего маршрута верхнего уровня используется rememberSerializable (а не rememberSaveable), а для сохранения обратных стеков для каждого маршрута верхнего уровня – rememberNavBackStack.

Шаг 3.2. Создайте объект, который изменяет состояние навигации в ответ на события

Скопируйте следующий код в файл с названием Navigator.kt. Добавьте название пакета, соответствующее структуре проекта.

// package com.example.project

import androidx.navigation3.runtime.NavKey

/**
 * Handles navigation events (forward and back) by updating the navigation state.
 */
class Navigator(val state: NavigationState) {
    fun navigate(route: NavKey) {
        if (route in state.backStacks.keys) {
            // This is a top level route, just switch to it.
            state.topLevelRoute = route
        } else {
            state.backStacks[state.topLevelRoute]?.add(route)
        }
    }

    fun goBack() {
        val currentStack = state.backStacks[state.topLevelRoute]
            ?: error("Stack for ${state.topLevelRoute} not found")
        val currentRoute = currentStack.last()

        // If we're at the base of the current route, go back to the start route stack.
        if (currentRoute == state.topLevelRoute) {
            state.topLevelRoute = state.startRoute
        } else {
            currentStack.removeLastOrNull()
        }
    }
}

Класс Navigator предоставляет два метода событий навигации:

  • navigate к определенному маршруту.
  • goBack от текущего маршрута.

Оба метода изменяют NavigationState.

Шаг 3.3. Создайте NavigationState и Navigator

Создайте экземпляры NavigationState и Navigator с той же областью действия, что и у NavController.

val navigationState = rememberNavigationState(
    // ...
    startRoute = <Insert your starting route>,
    topLevelRoutes = <Insert your set of top level routes>
    // ...
)

val navigator = remember { Navigator(navigationState) }

Шаг 4. Замените NavController

Замените методы событий навигации NavController эквивалентами Navigator.

Поле или метод NavController

Эквивалент Navigator

navigate()

navigate()

popBackStack()

goBack()

previousBackStackEntry.savedStateHandle.set()

ResultEventBus.sendResult()

Замените поля NavController на поля NavigationState.

Поле или метод NavController

Эквивалент NavigationState

currentBackStack

backStacks[topLevelRoute]

currentBackStackEntry

currentBackStackEntryAsState()

currentBackStackEntryFlow

currentDestination

backStacks[topLevelRoute].last()

currentBackStackEntry.savedStateHandle.getLiveData()

currentBackStackEntry.savedStateHandle.getStateFlow()

ResultEffect (на основе событий)

ResultEventBus.conflateAsState() (на основе штата)

Получите маршрут верхнего уровня: для этого поднимитесь по иерархии от текущей записи обратного стека.

topLevelRoute

Используйте NavigationState.topLevelRoute, чтобы определить, какой элемент сейчас выбран на панели навигации.

До:

// ...
val isSelected = navController.currentBackStackEntryAsState().value?.destination.isRouteInHierarchy(key::class)
// ...

fun NavDestination?.isRouteInHierarchy(route: KClass<*>) =
    this?.hierarchy?.any {
        it.hasRoute(route)
    } ?: false

После:

val isSelected = key == navigationState.topLevelRoute

Убедитесь, что вы удалили все ссылки на NavController, включая импортированные данные.

Шаг 4.1. Перенесите логику, учитывающую жизненный цикл

В Navigation 2 NavBackStackEntry реализует LifecycleOwner, позволяя вам отслеживать события жизненного цикла или собирать потоки с учетом жизненного цикла с помощью navController.currentBackStackEntry.

В Navigation 3 NavDisplay предоставляет LifecycleOwner на уровне записи через LocalLifecycleOwner.current для контента composable-функции каждого пункта назначения. Подробнее о жизненном цикле целевой страницы…

Операции, учитывающие жизненный цикл, следует выполнять непосредственно в composable-контенте пункта назначения, ссылаясь на LocalLifecycleOwner.current.

Например, если вы собираете поток с учетом жизненного цикла, используя запись обратного стека:

До:

// In your destination screen or host
val lifecycleOwner = navController.currentBackStackEntry!!
val state by flow.collectAsStateWithLifecycle(lifecycleOwner = lifecycleOwner)

После:

// Inside the destination composable
val state by flow.collectAsStateWithLifecycle()

Шаг 4.2. Перенесите передачу результатов

В Navigation 2 пункты назначения передавали результаты обратно в предыдущие пункты назначения с помощью SavedStateHandle в NavBackStackEntry. Поскольку SavedStateHandle поддерживается сохраненным состоянием экземпляра, возвращенные данные автоматически пережили завершение процесса.

До:

// Sender destination:
navController.previousBackStackEntry?.savedStateHandle?.set("contact_key", contact)
navController.popBackStack()

// Receiver destination:
val lifecycleOwner = LocalLifecycleOwner.current
navController.currentBackStackEntry?.savedStateHandle
    ?.getLiveData<Contact>("contact_key")
    ?.observe(lifecycleOwner) { contact ->
        viewModel.onRecipientSelected(contact)
    }

В разделе Navigation 3 (Навигация 3) добавьте rememberResultEventBusNavEntryDecorator() в NavDisplay.entryDecorators. В сопоставлении entryProvider целевого сервиса-отправителя получите LocalResultEventBus.current и вызовите sendResult(). В целевом сервисе используйте ResultEffect, чтобы переслать событие в ViewModel или активировать побочный эффект.

После:

// Sender destination (in entryProvider):
entry<ContactPickerRoute> {
    val resultBus = LocalResultEventBus.current

    ContactPickerScreen(
        onContactSelected = { contact ->
            resultBus.sendResult<Contact>(result = contact)
            navigator.goBack()
        }
    )
}

// Receiver destination:
@Composable
fun ComposeMessageScreen(viewModel: ComposeMessageViewModel = viewModel()) {
    ResultEffect<Contact> { contact ->
        viewModel.onRecipientSelected(contact)
    }

    ComposeMessageContent(recipient = viewModel.recipient)
}

Для наблюдения за состоянием можно вызвать resultBus.conflateAsState(). Подробнее о том, как возвращать результаты…

Шаг 5. Перенесите целевые сервисы из NavGraph (NavHost) в entryProvider

В Navigation 2 вы определяете пункты назначения с помощью NavGraphBuilder DSL, обычно внутри завершающей лямбды NavHost. В этом случае часто используются функции расширения, как описано в разделе Инкапсуляция кода навигации.

В Navigation 3 вы определяете пункты назначения с помощью entryProvider. Этот entryProvider преобразует маршрут в NavEntry. Важно, что entryProvider не определяет родительско-дочерние отношения между записями.

В этом руководстве по переносу данных иерархические отношения моделируются следующим образом:

  • NavigationState имеет набор маршрутов верхнего уровня (родительские маршруты) и стек для каждого из них. Он отслеживает текущий маршрут верхнего уровня и связанный с ним стек.
  • При переходе к новому маршруту Navigator проверяет, является ли он маршрутом верхнего уровня. Если да, то текущий маршрут верхнего уровня и стек обновляются. Если нет, то это дочерний маршрут, который добавляется в текущий стек.

Шаг 5.1. Создайте entryProvider

Создайте entryProvider с помощью DSL в той же области действия, что и NavigationState.

val entryProvider = entryProvider<NavKey> {

}

Шаг 5.2. Переместите целевые страницы в entryProvider

Для каждого целевого сервиса, определенного в NavHost, выполните следующие действия в зависимости от типа сервиса:

  • navigation – удалить вместе с маршрутом. Базовые маршруты не нужны, потому что маршруты верхнего уровня могут идентифицировать каждый вложенный обратный стек.
  • composable<T>: переместите его в entryProvider и переименуйте в entry, сохранив параметр типа. Например, composable<RouteA> превратится в entry<RouteA>.
  • dialog<T>: выполните те же действия, что и в случае с composable, но добавьте в запись метаданные следующим образом: entry<T>(metadata = DialogSceneStrategy.dialog()).
  • bottomSheet: следуйте инструкциям для нижнего экрана. Это похоже на инструкции для dialog, за исключением того, что BottomSheetSceneStrategy не является частью основной библиотеки Navigation 3, поэтому вам следует скопировать его в свой проект.

ИИ-агент. При удалении маршрутов, используемых для идентификации вложенного графа, замените все ссылки на удаленный маршрут типом, используемым для идентификации первого дочернего элемента во вложенном графе. Например, если исходный код выглядит как navigation<BaseRouteA>{ composable<RouteA>{ ... } }, вам нужно удалить BaseRouteA и заменить все ссылки на него на RouteA. Обычно это нужно сделать для списка, который передается на панель навигации, боковую панель или в боковое меню.

Вы можете провести рефакторинг функций расширения NavGraphBuilder в функции расширения EntryProviderScope<T>, а затем переместить их.

Получите аргументы навигации, используя ключ, предоставленный завершающему лямбда-выражению entry.

Пример:

// ...
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.dialog
import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navOptions
import androidx.navigation.toRoute
// ...

@Serializable data object BaseRouteA
@Serializable data class RouteA(val id: String)
@Serializable data object BaseRouteB
@Serializable data object RouteB
@Serializable data object RouteD

@Composable
fun NavHostSnippet(navController: NavHostController) {
    NavHost(navController = navController, startDestination = BaseRouteA){
        composable<RouteA>{ entry ->
            val id = entry.toRoute<RouteA>().id
            ScreenA(title = "Screen has ID: $id")
        }
        featureBSection()
        dialog<RouteD>{ ScreenD() }
    }
}

fun NavGraphBuilder.featureBSection() {
    navigation<BaseRouteB>(startDestination = RouteB) {
        composable<RouteB> { ScreenB() }
    }
}

после обновления будет выглядеть так:

// ...
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.scene.DialogSceneStrategy
// ...

@Serializable data class RouteA(val id: String) : NavKey
@Serializable data object RouteB : NavKey
@Serializable data object RouteD : NavKey

val entryProvider = entryProvider {
    entry<RouteA>{ key -> ScreenA(title = "Screen has ID: ${key.id}") }
    featureBSection()
    entry<RouteD>(metadata = DialogSceneStrategy.dialog()){ ScreenD() }
}

fun EntryProviderScope<NavKey>.featureBSection() {
    entry<RouteB> { ScreenB() }
}

Шаг 6. Замените NavHost на NavDisplay

Замените NavHost на NavDisplay.

  • Удалите NavHost и замените на NavDisplay.
  • Укажите entries = navigationState.toEntries(entryProvider) в качестве параметра. Это преобразует состояние навигации в записи, которые NavDisplay показывает с помощью entryProvider.
  • Подключите NavDisplay.onBack к navigator.goBack(). Это приводит к тому, что navigator обновляет состояние навигации, когда встроенный обработчик возврата NavDisplay завершает работу.
  • Если у вас есть целевые страницы диалогов, добавьте DialogSceneStrategy в параметр sceneStrategies для NavDisplay.

Пример:

NavDisplay(
    entries = navigationState.toEntries(entryProvider),
    onBack = { navigator.goBack() },
    sceneStrategies = remember { listOf(DialogSceneStrategy()) }
)

Шаг 7. Перенесите ссылки на контент

В Navigation 2 ссылки на контент определялись непосредственно в графе навигации с помощью параметра deepLinks пунктов назначения.

В Navigation 3 ссылки на контент управляются независимо от интерфейса навигации. Вы определяете DeepLinkMatcher и сопоставляете входящие запросы в Activity, чтобы создать начальный обратный стек.

До:

В Navigation 2 вы могли определить ссылку на контент следующим образом:

После:

В Navigation 3 вы определяете UriDeepLinkMatcher для маршрута:

Затем в методе onCreate (и onNewIntent) операции сопоставьте входящий объект Intent и инициализируйте обратный стек:

Типы пользовательских аргументов

В Navigation 2 вы обрабатывали собственные или сторонние типы аргументов (например, LocalDateTime), используя собственные реализации NavType и typeMap.

В Navigation 3 определите DeepLinkSerializer, чтобы десериализовать специальные или сторонние типы из параметров URI. Подробнее о пользовательской сериализации с помощью DeepLinkSerializer…

Более сложные сценарии, например синтетические обратные стеки и пользовательские сопоставители, описаны в руководстве Поддержка ссылок на контент.

Шаг 8. Удалите зависимости Navigation 2

Удалите все импортированные элементы и зависимости библиотеки Navigation 2.

Сводка

Поздравляем! Ваш проект перенесен в Navigation 3. Если у вас или вашего ИИ-агента возникли проблемы при использовании этого руководства, сообщите об ошибке.