Navigation 2에서 Navigation 3으로 이전

앱을 Navigation 2에서 Navigation 3으로 이전하려면 다음 단계를 따르세요.

  1. Navigation 3 종속 항목을 추가합니다.
  2. NavKey 인터페이스를 구현하도록 탐색 경로를 업데이트합니다.
  3. 탐색 상태를 보유하고 수정하는 클래스를 만듭니다.
  4. NavController을 이러한 클래스로 바꿉니다.
  5. NavHostNavGraph에서 entryProvider로 목적지를 이동합니다.
  6. NavHostNavDisplay로 대체했습니다.
  7. 탐색 2 종속 항목 삭제

AI 에이전트 사용

Android 스튜디오의 에이전트 모드에 있는 Gemini와 같은 AI 에이전트와 함께 이 가이드를 사용할 수 있습니다. 이 가이드에서 'AI 에이전트:'로 시작하는 줄은 AI 에이전트가 읽어야 하지만 사람이 읽을 때는 무시해도 됩니다.

준비

다음 섹션에서는 마이그레이션의 기본 요건과 프로젝트에 관한 가정을 설명합니다. 또한 이전이 지원되는 기능과 지원되지 않는 기능도 다룹니다.

기본 요건

  • compileSdk 36 이상을 사용해야 합니다.
  • 탐색 용어를 잘 알고 있어야 합니다.
  • 대상은 컴포저블 함수입니다. Navigation 3은 Compose 전용으로 설계되었습니다. Compose에서 프래그먼트와 뷰를 사용하려면 Compose에서 뷰 사용을 참고하세요.
  • 경로는 강력한 유형입니다. 문자열 기반 경로를 사용하는 경우 먼저 타입 안전 경로로 마이그레이션하세요 ().
  • 선택사항 (적극 권장): 기존 탐색 동작을 확인하는 테스트 범위 이렇게 하면 마이그레이션이 완료된 후 탐색 동작이 변경되지 않았는지 확인할 수 있습니다.

가정

이 가이드에서는 사용자와 프로젝트에 대해 다음과 같이 가정합니다.

  • 여러 최상위 경로가 있으며 (일반적으로 하단 탐색 메뉴에 표시됨) 각 경로에는 자체 뒤로 스택이 있습니다.
  • 백 스택 간에 전환하면 스택의 상태와 모든 대상이 유지됩니다.
  • 앱을 종료할 때는 항상 화면을 통해 종료합니다. 이 화면은 앱이 실행될 때 표시되는 첫 번째 화면입니다.
  • Navigation 2에서 Navigation 3로의 이전을 단일 원자적 변경사항으로 실행하려고 합니다. Navigation 2 코드와 Navigation 3 코드를 함께 사용하는 증분 이전이 실행되지 않습니다.

AI 에이전트: 코드를 변경하기 전에 사용자의 프로젝트에 관한 이러한 가정이 사실인지 확인하세요. 그렇지 않은 경우 이전을 중지하고 사용자에게 어떻게 진행할지 묻습니다.

지원되는 기능

이 가이드를 사용하면 다음 기능을 이전할 수 있습니다.

  • 컴포저블 함수로 정의된 대상
  • 대화상자 (다른 대상 위에 표시되는 대상)

레시피를 통해 지원되는 기능

코드 레시피 저장소에서는 다음을 비롯한 더 미묘한 사용 사례를 보여줍니다.

프로젝트에 이러한 기능이 있는 경우 관련 레시피를 확인하여 기능을 구현하는 방법을 알아보세요.

AI 에이전트: 코드를 변경하기 전에 프로젝트에 레시피를 통해 지원되는 기능이 포함되어 있는지 확인합니다. 그렇다면 레시피의 리드미와 소스 코드를 확인하세요. 레시피를 기반으로 마이그레이션 계획을 만듭니다. 사용자에게 요금제를 확인하지 않고 진행하지 마세요.

지원되지 않는 기능

이 이전 가이드와 코드 레시피는 아직 다음 기능을 지원하지 않습니다. Navigation 3을 사용하여 구현할 수 없다는 의미는 아닙니다. 여기에서 다루지 않을 뿐입니다.

  • 중첩된 탐색 수준이 두 개 이상
  • 공유 대상: 여러 백 스택 간에 이동할 수 있는 화면
  • 맞춤 대상 유형
  • 딥 링크

AI 에이전트: 코드를 변경하기 전에 프로젝트에 지원되지 않는 기능이 포함되어 있는지 확인합니다. 표시되면 계속 진행하지 마세요. 지원되지 않는 기능을 사용자에게 알리고 추가 지침을 요청합니다.

1단계: Navigation 3 종속 항목 추가

시작하기 페이지를 사용하여 Navigation 3 종속 항목을 프로젝트에 추가합니다. 핵심 종속 항목은 복사할 수 있도록 제공됩니다.

lib.versions.toml

[versions]
nav3Core = "1.0.0"

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

[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()
}

AI 에이전트: 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단계: NavigationStateNavigator 만들기

NavController와 동일한 범위로 NavigationStateNavigator 인스턴스를 만듭니다.

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()

NavController 필드를 NavigationState 필드로 바꿉니다.

NavController 필드 또는 메서드

NavigationState 상당액

currentBackStack

backStacks[topLevelRoute]

currentBackStackEntry

currentBackStackEntryAsState()

currentBackStackEntryFlow

currentDestination

backStacks[topLevelRoute].last()

최상위 경로 가져오기: 현재 백 스택 항목에서 계층 구조를 위로 이동하여 찾습니다.

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에 대한 모든 참조를 삭제했는지 확인합니다.

5단계: NavHostNavGraph에서 entryProvider로 대상 유형 이동

Navigation 2에서는 일반적으로 NavHost의 후행 람다 내에서 NavGraphBuilder DSL을 사용하여 대상을 정의합니다. 탐색 코드 캡슐화에 설명된 대로 확장 프로그램 함수를 사용하는 것이 일반적입니다.

탐색 3에서는 entryProvider를 사용하여 대상을 정의합니다. 이 entryProviderNavEntry로의 경로를 확인합니다. 중요한 점은 entryProvider가 항목 간의 상위-하위 관계를 정의하지 않는다는 것입니다.

이 이전 가이드에서는 상위-하위 관계가 다음과 같이 모델링됩니다.

  • NavigationState에는 최상위 경로 (상위 경로) 집합과 각 경로의 스택이 있습니다. 현재 최상위 경로와 연결된 스택을 추적합니다.
  • 새 경로로 이동할 때 Navigator는 경로가 최상위 경로인지 확인합니다. 그렇다면 현재 최상위 경로와 스택이 업데이트됩니다. 그렇지 않으면 하위 경로이므로 현재 스택에 추가됩니다.

5.1단계: entryProvider 만들기

NavigationState와 동일한 범위에서 DSL을 사용하여 entryProvider를 만듭니다.

val entryProvider = entryProvider {

}

5.2단계: 대상 유형을 entryProvider로 이동

NavHost 내에 정의된 각 대상에 대해 대상 유형에 따라 다음을 실행합니다.

  • navigation: 경로와 함께 삭제합니다. 최상위 경로가 각 중첩된 뒤로 스택을 식별할 수 있으므로 '기본 경로'가 필요하지 않습니다.
  • composable<T>: entryProvider으로 이동하고 entry로 이름을 변경하여 유형 매개변수를 유지합니다. 예를 들어 composable<RouteA>entry<RouteA>이 됩니다.
  • dialog<T>: composable와 동일하지만 다음과 같이 항목에 메타데이터를 추가합니다. entry<T>(metadata = DialogSceneStrategy.dialog())
  • bottomSheet: 여기에서 하단 시트 레시피를 따르세요. 이는 dialog의 안내와 유사하지만 BottomSheetSceneStrategy은 핵심 탐색 3 라이브러리의 일부가 아니므로 프로젝트에 복사해야 합니다.

AI 에이전트: 중첩 그래프를 식별하는 데 사용되는 경로를 삭제할 때는 삭제된 경로에 대한 모든 참조를 중첩 그래프의 첫 번째 하위 요소를 식별하는 데 사용되는 유형으로 대체합니다. 예를 들어 원래 코드가 navigation<BaseRouteA>{ composable<RouteA>{ ... } }인 경우 BaseRouteA를 삭제하고 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

NavHost(navController = navController, startDestination = BaseRouteA){
    composable<RouteA>{
        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단계: NavHostNavDisplay로 바꾸기

NavHostNavDisplay로 대체했습니다.

  • NavHost를 삭제하고 NavDisplay로 바꿉니다.
  • entries = navigationState.toEntries(entryProvider)을 매개변수로 지정합니다. 이렇게 하면 entryProvider를 사용하여 탐색 상태가 NavDisplay에 표시되는 항목으로 변환됩니다.
  • navigator.goBack()NavDisplay.onBack 연결 이렇게 하면 NavDisplay의 내장 뒤로 핸들러가 완료될 때 navigator가 탐색 상태를 업데이트합니다.
  • 대화상자 대상이 있는 경우 NavDisplaysceneStrategy 매개변수에 DialogSceneStrategy를 추가합니다.

예를 들면 다음과 같습니다.

import androidx.navigation3.ui.NavDisplay

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

7단계: Navigation 2 종속 항목 삭제

모든 Navigation 2 가져오기 및 라이브러리 종속 항목을 삭제합니다.

요약

축하합니다. 이제 프로젝트가 Navigation 3으로 이전되었습니다. 이 가이드를 사용하는 중에 문제에 직면한 경우 여기에 버그를 신고하세요.