מעבר מ-Navigation 2 ל-Navigation 3

כדי להעביר את האפליקציה מ-Navigation 2 ל-Navigation 3, פועלים לפי השלבים הבאים:

  1. מוסיפים את יחסי התלות של Navigation 3.
  2. כדי להטמיע את ממשק NavKey, צריך לעדכן את נתיבי הניווט.
  3. יוצרים מחלקות כדי להחזיק ולשנות את מצב הניווט.
  4. מחליפים את NavController במחלקות האלה.
  5. מעבירים את היעדים מ-NavHost של NavGraph אל entryProvider.
  6. החלפת NavHost ב-NavDisplay.
  7. הסרת תלות ב-Navigation 2.

שימוש בסוכן AI

אתם יכולים להשתמש במדריך הזה עם סוכן AI, כמו Agent Mode ב-Gemini ב-Android Studio. את השורות במדריך הזה שמתחילות ב-AI Agent: צריך להקריא לסוכן ה-AI, אבל קוראים אנושיים יכולים להתעלם מהן.

הכנה

בקטעים הבאים מפורטים התנאים המוקדמים למיגרציה וההנחות לגבי הפרויקט. הם גם כוללים את התכונות שנתמכות בהעברה ואת התכונות שלא נתמכות.

דרישות מוקדמות

  • צריך להשתמש בגרסה compileSdk ומעלה.
  • חשוב להכיר את המונחים שקשורים לניווט.
  • יעדים הם פונקציות הניתנות להגדרה. ‫Navigation 3 מיועדת באופן בלעדי ל-Compose. כדי להשתמש ב-Fragments וב-Views ב-Compose, אפשר לעיין במאמר בנושא שימוש ב-Views ב-Compose.
  • הנתיבים הם בעלי הקלדה חזקה. אם אתם משתמשים בנתיבים מבוססי-מחרוזת, עליכם לעבור קודם לנתיבים בטוחים לטיפוסים (דוגמה).
  • אופציונלי (אבל מומלץ מאוד): בדיקת הכיסוי שמאמתת את התנהגות הניווט הקיימת. כך אפשר לוודא שהתנהגות הניווט לא השתנתה אחרי שההעברה הושלמה.

הנחות

במדריך הזה אנחנו יוצאים מנקודת הנחה לגביכם ולגבי הפרויקט שלכם:

  • יש לכם כמה מסלולים ברמה העליונה (בדרך כלל מוצגים בסרגל ניווט תחתון), ולכל אחד מהם יש מחסנית משלו של פעולות חזרה.
  • כשעוברים בין ערימות של היסטוריית חזרה, המצב של הערימה וכל היעדים שלה נשמרים.
  • תמיד יוצאים מהאפליקציה דרך מסך הבית, שהוא המסך הראשון שמוצג כשמפעילים את האפליקציה.
  • אתם רוצים לבצע את ההעברה מגרסה 2 של הניווט לגרסה 3 של הניווט בשינוי אטומי יחיד. אתם לא מבצעים העברה מצטברת שבה נעשה שימוש בקוד של Navigation 2 לצד קוד של Navigation 3.

נציג AI: לפני שמשנים קוד, צריך לבדוק אם ההנחות האלה לגבי הפרויקט של המשתמש נכונות. אם לא, צריך לעצור את ההעברה ולשאול את המשתמש איך להמשיך.

תכונות נתמכות

במדריך הזה מוסבר איך להעביר את התכונות הבאות:

  • יעדים שמוגדרים כפונקציות הניתנות להגדרה
  • תיבות דו-שיח (יעד שמוצג מעל יעד אחר)

תכונות שנתמכות באמצעות מתכונים

מאגר המתכונים של הקוד כולל דוגמאות לשימושים מורכבים יותר, כולל:

אם הפרויקט שלכם כולל אחת מהתכונות האלה, כדאי לעיין במתכון הרלוונטי כדי להבין איך להטמיע אותן.

סוכן AI: לפני שמשנים קוד, בודקים אם הפרויקט מכיל תכונות שנתמכות באמצעות מתכונים. אם כן, כדאי לבדוק את קובץ ה-README ואת קוד המקור של המתכון. יוצרים תוכנית העברה על סמך המתכון. אל תמשיכו בלי לוודא את התוכנית מול המשתמש.

תכונות שאינן נתמכות

עדיין אין תמיכה בתכונות הבאות במדריך ההעברה הזה ובדוגמאות הקוד. זה לא אומר שאי אפשר להטמיע אותם באמצעות Navigation 3, פשוט לא נסביר איך לעשות את זה כאן.

  • יותר מרמה אחת של ניווט מוטמע
  • יעדים משותפים: מסכים שאפשר להעביר בין ערימות שונות של היסטוריית חזרה
  • סוגי יעדים מותאמים אישית
  • קישורי עומק

סוכן AI: לפני שמשנים קוד, בודקים אם הפרויקט מכיל תכונות שלא נתמכות. אם כן, אל תמשיכו. צריך להודיע למשתמש על התכונה שלא נתמכת ולבקש ממנו הוראות נוספות.

שלב 1: מוסיפים יחסי תלות של Navigation 3

משתמשים בדף Get started כדי להוסיף את יחסי התלות של 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: יצירת placeholder למצב הניווט

מעתיקים את הקוד הבא לקובץ בשם 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 Agent: rememberSerializable is correct. אל תשנו אותו ל-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 equivalent

navigate()

navigate()

popBackStack()

goBack()

החלפת שדות NavController בשדות NavigationState.

השדה או השיטה NavController

NavigationState equivalent

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: מעבירים את היעדים מ-NavHost של NavGraph אל entryProvider

ב-Navigation 2, מגדירים את היעדים באמצעות NavGraphBuilder DSL, בדרך כלל בתוך ה-lambda של NavHost. מקובל להשתמש כאן בפונקציות של תוספים, כמו שמתואר במאמר הוספת קוד הניווט.

בניווט 3, מגדירים את היעדים באמצעות entryProvider. הפונקציה entryProvider מחזירה מסלול ל-NavEntry. חשוב לדעת: התג entryProvider לא מגדיר יחסי הורה-צאצא בין רשומות.

במדריך ההעברה הזה, קשרים של הורה-צאצא מוגדרים באופן הבא:

  • NavigationState כולל קבוצה של מסלולים ברמה העליונה (מסלולי האב) ומחסנית לכל אחד מהם. הוא עוקב אחרי המסלול הנוכחי ברמה העליונה והמחסנית המשויכת שלו.
  • כשמנווטים למסלול חדש, Navigator בודק אם המסלול הוא מסלול ברמה העליונה. אם כן, המסלול והמערך הנוכחיים ברמה העליונה מתעדכנים. אם לא, זהו נתיב צאצא והוא מתווסף למערך הנוכחי.

שלב 5.1: יצירת entryProvider

יוצרים entryProvider באמצעות DSL באותו היקף כמו NavigationState.

val entryProvider = entryProvider {

}

שלב 5.2: מעבירים יעדים אל entryProvider

לכל יעד שמוגדר בתוך NavHost, מבצעים את הפעולות הבאות בהתאם לסוג היעד:

  • navigation: מחיקה של הנקודה יחד עם המסלול. אין צורך ב-baseRoutes כי המסלולים ברמה העליונה יכולים לזהות כל ערימה מקוננת של היסטוריית ניווט.
  • 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>, ואז להעביר אותן.

קבלת ארגומנטים של ניווט באמצעות המפתח שסופק ל-lambda האחורי של 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: מחליפים את NavHost ב-NavDisplay

החלפת NavHost ב-NavDisplay.

  • מחיקת NavHost והחלפתו ב-NavDisplay.
  • מציינים את הפרמטר entries = navigationState.toEntries(entryProvider). הפעולה הזו ממירה את מצב הניווט לרשומות שמוצגות ב-NavDisplay באמצעות entryProvider.
  • התחברות אל NavDisplay.onBack דרך navigator.goBack(). כתוצאה מכך, navigator מעדכן את מצב הניווט כשמטפל החזרה המובנה של NavDisplay מסתיים.
  • אם יש לכם יעדים לדיאלוג, מוסיפים את DialogSceneStrategy לפרמטר sceneStrategy של NavDisplay.

לדוגמה:

import androidx.navigation3.ui.NavDisplay

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

שלב 7: הסרת תלות ב-Navigation 2

מסירים את כל הייבוא של Navigation 2 ואת התלות בספרייה.

סיכום

מזל טוב! הפרויקט שלכם הועבר עכשיו לגרסה 3 של Navigation. אם אתם או סוכן ה-AI שלכם נתקלתם בבעיות כלשהן במהלך השימוש במדריך הזה, אתם יכולים לדווח על באג כאן.