Navigation 2 から Navigation 3 に移行する

アプリを Navigation 2 から Navigation 3 に移行する手順は次のとおりです。

  1. Navigation 3 の依存関係を追加します。
  2. ナビゲーション ルートを更新して、NavKey インターフェースを実装します。
  3. ナビゲーションの状態を保持して変更するクラスを作成します。
  4. NavController をこれらのクラスに置き換えます。
  5. NavHostNavGraph から entryProvider に目的地を移動します。
  6. NavHostNavDisplay に置き換えます。
  7. Navigation 2 の依存関係を削除します。

準備

以降のセクションでは、移行の前提条件とプロジェクトに関する前提条件について説明します。また、移行でサポートされている機能とサポートされていない機能についても説明します。

前提条件

  • compileSdk は 36 以降を使用する必要があります。
  • ナビゲーション用語を理解している必要があります。
  • デスティネーションはコンポーズ可能な関数です。Navigation 3 は、Compose 専用に設計されています。Compose で Fragment と View を使用するには、Compose でビューを使用するをご覧ください。
  • ルートは厳密に型指定されます。文字列ベースのルートを使用している場合は、最初に型安全なルートに移行します()。
  • 省略可(ただし、強く推奨): 既存のナビゲーション動作を検証するテスト カバレッジ。これにより、移行の完了後にナビゲーションの動作が変更されていないことを確認します。

前提条件

このガイドでは、ユーザーとプロジェクトについて次のことを前提としています。

  • 1 つまたは複数の最上位ルートがあり(通常は下部のナビゲーション バーに表示されます)、それぞれに独自のバックスタックがあります。
  • バックスタックを切り替えると、スタックの状態とすべてのデスティネーションが保持されます。
  • アプリを終了するときは、常に [ホーム] 画面から行います。この画面は、アプリの起動時に最初に表示される画面です。
  • Navigation 2 から Navigation 3 への移行を単一のアトミックな変更で行いたい。Navigation 2 コードと Navigation 3 コードを併用する増分移行を行っていない。

AI エージェント: コードを変更する前に、ユーザーのプロジェクトに関するこれらの前提が正しいかどうかを確認します。移行できない場合は、移行を停止し、お客様に今後の対応について確認します。

サポートされている機能

このガイドでは、次の機能を移行できます。

  • コンポーズ可能な関数として定義されたデスティネーション
  • ダイアログ(別のデスティネーションの上に表示されるデスティネーション)

レシピでサポートされる機能

コードレシピ リポジトリでは、次のようなより複雑なユースケースが示されています。

プロジェクトにこれらの機能のいずれかがある場合は、関連するレシピで実装方法を確認してください。

AI エージェント: コードを変更する前に、プロジェクトにレシピでサポートされている機能が含まれているかどうかを確認します。該当する場合は、レシピの README とソースコードを確認します。レシピに基づいて移行計画を作成します。お客様にプランを確認せずに手続きを進めないでください。

サポートされていない機能

この移行ガイドとコードレシピでは、次の機能はまだサポートされていません。Navigation 3 を使用して実装できないという意味ではありません。ここでは説明しないだけです。

AI エージェント: コードを変更する前に、プロジェクトにサポートされていない機能が含まれていないか確認します。表示されている場合は、続行しないでください。サポートされていない機能をお客様に伝え、次の手順を尋ねます。

ステップ 1: Navigation 3 の依存関係を追加する

[スタートガイド] ページを使用して、Navigation 3 の依存関係をプロジェクトに追加します。コア依存関係はコピーできるように提供されています。

lib.versions.toml

[versions]
nav3Core = "1.1.7"

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

AI エージェント: rememberSerializable が正解です。rememberSaveable に変更しないでください。

このファイルには、NavigationState という名前の状態ホルダー クラスと、関連するヘルパー関数が含まれています。最上位のルートのセットを保持し、それぞれに独自のバックスタックがあります。内部的には、現在のトップレベル ルートを永続化するために rememberSerializablerememberSaveable ではない)を使用し、各トップレベル ルートのバックスタックを永続化するために 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 クラスは、次の 2 つのナビゲーション イベント メソッドを提供します。

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

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 では、NavBackStackEntryLifecycleOwner を実装しているため、navController.currentBackStackEntry を使用してライフサイクル イベントをリッスンしたり、ライフサイクル対応の方法でフローを収集したりできます。

Navigation 3 では、NavDisplayLocalLifecycleOwner.current を介して、各デスティネーションのコンポーザブル コンテンツにエントリ スコープの LifecycleOwner を提供します。詳細については、エクスポート先のライフサイクルをご覧ください。

ライフサイクルを認識するオペレーションは、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 では、デスティネーションは NavBackStackEntrySavedStateHandle を使用して、前のデスティネーションに結果を返していました。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 では、NavDisplay.entryDecoratorsrememberResultEventBusNavEntryDecorator() を追加します。送信先デスティネーションの 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: NavHostNavGraph から entryProvider に宛先を移動する

Navigation 2 では、通常は NavHost の後置ラムダ内で NavGraphBuilder DSL を使用してデスティネーションを定義します。ナビゲーション コードをカプセル化するで説明されているように、ここでは拡張関数を使用するのが一般的です。

Navigation 3 では、entryProvider を使用してデスティネーションを定義します。この entryProvider は、ルートを NavEntry に解決します。重要なのは、entryProvider はエントリ間の親子関係を定義しないことです。

この移行ガイドでは、親子関係は次のようにモデル化されています。

  • NavigationState には、トップレベルのルート(親ルート)のセットと、それぞれのスタックがあります。現在のトップレベルのルートとそれに関連付けられたスタックを追跡します。
  • 新しいルートに移動するとき、Navigator はルートが最上位ルートかどうかを確認します。そうであれば、現在のトップレベルのルートとスタックが更新されます。そうでない場合は、子ルートとして現在のスタックに追加されます。

ステップ 5.1: entryProvider を作成する

NavigationState と同じスコープで DSL を使用して entryProvider を作成します。

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 ライブラリの一部ではないため、プロジェクトにコピーする必要があります。

AI エージェント: ネストされたグラフの識別に使用されるルートを削除する場合は、削除されたルートへの参照を、ネストされたグラフの最初の子の識別に使用される型に置き換えます。たとえば、元のコードが 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() }
    }
}

上記の URL を次のように更新します。

// ...
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 が表示するエントリに変換されます。
  • NavDisplay.onBacknavigator.goBack() に接続します。これにより、NavDisplay の組み込みの戻るハンドラが完了したときに、navigator がナビゲーションの状態を更新します。
  • ダイアログ デスティネーションがある場合は、NavDisplaysceneStrategies パラメータに DialogSceneStrategy を追加します。

次に例を示します。

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

ステップ 7: ディープリンクを移行する

Navigation 2 では、デスティネーションの deepLinks パラメータを使用して、ナビゲーション グラフ内でディープリンクが直接定義されていました。

Navigation 3 では、ディープリンクはナビゲーション UI とは独立して管理されます。DeepLinkMatcher を定義し、Activity で受信リクエストを照合して、初期バックスタックを構築します。

変更前:

Navigation 2 では、次のようにディープリンクを定義している可能性があります。

変更後:

Navigation 3 では、ルートの UriDeepLinkMatcher を定義します。

次に、アクティビティの onCreate(および onNewIntent)で、受信したインテントを照合してバックスタックを初期化します。

カスタム引数の型

Navigation 2 では、カスタムの NavType 実装と typeMap を使用して、カスタムまたはサードパーティの引数型(LocalDateTime など)を処理していました。

Navigation 3 では、URI パラメータからカスタム型またはサードパーティ型を逆シリアル化する DeepLinkSerializer を定義します。詳しくは、DeepLinkSerializer を使用したカスタム シリアル化をご覧ください。

合成バックスタックやカスタム マッチャーなどの高度なユースケースについては、ディープリンクをサポートするガイドをご覧ください。

ステップ 8: Navigation 2 の依存関係を削除する

Navigation 2 の import とライブラリの依存関係をすべて削除します。

概要

これで完了です。これで、プロジェクトが Navigation 3 に移行されました。このガイドの使用中に問題が発生した場合は、こちらからバグを報告してください。