Как перейти с 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-функции контента каждого пункта назначения. Подробнее о жизненном цикле целевой страницы…

Операции, учитывающие жизненный цикл, следует выполнять непосредственно в составном контенте целевого приложения, ссылаясь на 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)
    }

В разделе "Навигация 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. Перенесите целевые страницы из NavHost в NavGraph в 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 – удалить вместе с маршрутом. Нет необходимости в "base routes", поскольку маршруты верхнего уровня могут идентифицировать каждый вложенный стек возврата.
  • 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 ссылки на контент управляются независимо от интерфейса навигации. Вы определяете DeepLinkMatchers и сопоставляете входящие запросы в своем действии, чтобы создать начальный стек возврата.

До:

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

После:

В спецификации GTFS версии 3 для маршрута задается поле UriDeepLinkMatcher:

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

Собственные типы аргументов

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

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

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

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

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

Сводка

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