כישורים ב-Android
הצגה ב-GitHubJetpack Navigation 3
android skills add navigation-3כדי להעביר את האפליקציה מ-Navigation 2 ל-Navigation 3, פועלים לפי השלבים הבאים:
- מוסיפים את יחסי התלות של Navigation 3.
- כדי להטמיע את ממשק
NavKey, צריך לעדכן את נתיבי הניווט. - יוצרים מחלקות כדי להחזיק ולשנות את מצב הניווט.
- מחליפים את
NavControllerבמחלקות האלה. - מעבירים את היעדים מ-
NavHostשלNavGraphאלentryProvider. - מחליפים את
NavHostב-NavDisplay. - הסרת תלות ב-Navigation 2.
הכנה
בקטעים הבאים מתוארים התנאים המוקדמים למיגרציה וההנחות לגבי הפרויקט שלכם. הם גם כוללים את התכונות שנתמכות בהעברה ואת התכונות שלא נתמכות.
דרישות מוקדמות
- צריך להשתמש בגרסה
compileSdk36 ואילך. - חשוב להכיר את הטרמינולוגיה של הניווט.
- יעדים הם פונקציות הניתנות להגדרה. Navigation 3 מיועדת רק ל-Compose. מידע על שימוש במקטעים (fragments) וב-Views בפיתוח נייטיב זמין במאמר בנושא שימוש ב-Views בפיתוח נייטיב.
- הנתיבים הם בעלי הקלדה חזקה. אם אתם משתמשים בנתיבים מבוססי-מחרוזת, עליכם לעבור קודם לנתיבים בטוחים לטיפוסים (דוגמה).
- אופציונלי (אבל מומלץ מאוד): כיסוי בדיקות שמאמת את התנהגות הניווט הקיימת. כך אפשר לוודא שהתנהגות הניווט לא השתנתה אחרי שההעברה הושלמה.
הנחות
במדריך הזה אנחנו מניחים את ההנחות הבאות לגביכם והפרויקט שלכם:
- יש לכם נתיב אחד או כמה נתיבים ברמה העליונה (בדרך כלל מוצגים בסרגל ניווט בתחתית), ולכל אחד מהם יש מקבץ פעילויות קודמות (back stack) משלו.
- כשעוברים בין ערימות של היסטוריית חזרה, המצב של הערימה וכל היעדים שלה נשמרים.
- תמיד יוצאים מהאפליקציה דרך מסך הבית, שהוא המסך הראשון שמוצג כשמפעילים את האפליקציה.
- אתם רוצים לבצע את ההעברה מגרסה 2 של הניווט לגרסה 3 של הניווט בשינוי אטומי יחיד. אתם לא מבצעים העברה מצטברת שבה נעשה שימוש בקוד של Navigation 2 לצד קוד של Navigation 3.
סוכן AI: לפני שמבצעים שינויים בקוד, צריך לבדוק אם ההנחות האלה לגבי הפרויקט של המשתמש נכונות. אם לא, מפסיקים את ההעברה ושואלים את המשתמש איך להמשיך.
תכונות נתמכות
במדריך הזה נסביר איך להעביר את התכונות הבאות:
- יעדים שמוגדרים כפונקציות הניתנות להגדרה
- תיבות דו-שיח (יעד שמוצג מעל יעד אחר)
תכונות שנתמכות באמצעות מתכונים
מאגר המתכונים של הקוד כולל דוגמאות לשימושים מורכבים יותר, כולל:
- גיליונות תחתונים (הוראות מפורטות במדריך הזה)
- קוד ניווט מודולרי ויעדים מוחדרים
- שימוש בארגומנטים והעברתם אל
ViewModel - הצגת תוצאות ממסך
אם הפרויקט שלכם כולל אחת מהתכונות האלה, כדאי לעיין במתכון הרלוונטי כדי להבין איך להטמיע אותן.
סוכן AI: לפני שמשנים קוד, בודקים אם הפרויקט מכיל תכונות שנתמכות באמצעות מתכונים. אם כן, כדאי לעיין בקובץ ה-README ובקוד המקור של המתכון. יוצרים תוכנית העברה על סמך המתכון. אל תמשיכו בלי לוודא את התוכנית מול המשתמש.
תכונות שאינן נתמכות
עדיין אין תמיכה בתכונות הבאות במדריך הזה להעברה ובדוגמאות הקוד. זה לא אומר שאי אפשר להטמיע אותם באמצעות Navigation 3, פשוט לא נסביר איך לעשות את זה כאן.
- יותר מרמה אחת של ניווט מוטמע
- יעדים משותפים: מסכים שאפשר להעביר בין ערימות שונות של היסטוריית חזרה
- סוגים מותאמים אישית של יעדים
סוכן AI: לפני שמשנים קוד, בודקים אם הפרויקט מכיל תכונות שלא נתמכות. אם כן, אל תמשיכו. צריך להודיע למשתמש על התכונה שלא נתמכת ולבקש ממנו הוראות נוספות.
שלב 1: מוסיפים יחסי תלות של Navigation 3
משתמשים בדף Get started כדי להוסיף את יחסי התלות של 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 ופונקציות עזר משויכות. הוא מכיל קבוצה של מסלולים ברמה העליונה, שלכל אחד מהם יש מחסנית משלו. באופן פנימי, הוא משתמש ב-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 בשדות NavigationState.
שדה או שיטה של |
|
|---|---|
|
|
|
|
קבלת המסלול ברמה העליונה: עוברים בהיררכיה מהערך הנוכחי במקבץ פעילויות קודמות (back stack) כדי למצוא אותו. |
|
משתמשים ב-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 בהיקף של רכיב ה-Entry דרך LocalLifecycleOwner.current לכל תוכן שניתן להרכבה של היעד. מידע נוסף זמין במאמר בנושא מחזור החיים של היעד.
כדאי לבצע פעולות שמתחשבות במחזור החיים ישירות בתוך התוכן המורכב של היעד על ידי הפניה אל LocalLifecycleOwner.current.
לדוגמה, אם אוספים נתונים של Flow באופן שמודע למחזור החיים באמצעות הרשומה של מחסנית החזרה:
לפני:
// 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()
שלב 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<NavKey> { }
שלב 5.2: מעבירים יעדים אל entryProvider
לכל יעד שמוגדר בתוך NavHost, מבצעים את הפעולות הבאות בהתאם לסוג היעד:
-
navigation: מחיקה של הנקודה יחד עם המסלול. אין צורך ב-baseRoutes כי המסלולים ברמה העליונה יכולים לזהות כל מקבץ פעילויות קודמות (back stack) מקונן. -
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 @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 של יעדים.
בניווט 3, קישורי עומק מנוהלים בנפרד מממשק המשתמש של הניווט. אתם מגדירים את DeepLinkMatchers ומתאימים בקשות נכנסות ב-Activity שלכם כדי ליצור את מקבץ הפעילויות הקודמות הראשוני.
לפני:
בניווט 2, יכול להיות שהגדרתם קישור עומק כזה:
composable<RouteA>( deepLinks = listOf( navDeepLink { uriPattern = "www.example.com/user/{id}" } ) ) { // ... }
אחרי:
בניווט 3, מגדירים UriDeepLinkMatcher למסלול:
val userMatcher = UriDeepLinkMatcher( DeepLinkUri("www.example.com/user/{id}"), serializer<RouteA>() )
לאחר מכן, ב-Activity onCreate (וב-onNewIntent), מתאימים את ה-Intent הנכנס ומפעילים את מקבץ הפעילויות הקודמות (back stack):
val deepLinkMatchers: List<DeepLinkMatcher<*, *>> = listOf( userMatcher, ) class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val request = DeepLinkRequest(intent = intent) val matchResult = deepLinkMatchers .mapNotNull { it.match(request) } .maxOrNull() val backStack = when (matchResult) { null -> listOf(HomeKey) is BackStackMatchResult<*, *> -> { @Suppress("UNCHECKED_CAST") matchResult.backStack as List<NavKey> } else -> listOf(matchResult.key) } // Use backStack with NavDisplay } }
סוגים של ארגומנטים בהתאמה אישית
בניווט 2, טיפלתם בסוגים של ארגומנטים מותאמים אישית או של צד שלישי (כמו
LocalDateTime) באמצעות הטמעות מותאמות אישית של NavType ושל typeMap.
בניווט 3, מגדירים DeepLinkSerializer כדי לבצע דה-סריאליזציה של סוגים מותאמים אישית או של צד שלישי מפרמטרים של URI. פרטים נוספים זמינים במאמר בנושא סריאליזציה בהתאמה אישית באמצעות DeepLinkSerializer.
לתרחישי שימוש מתקדמים יותר, כולל ערימות חזרה סינתטיות וכלים מותאמים אישית להשוואה, אפשר לעיין במדריך תמיכה בקישורי עומק.
שלב 8: הסרת יחסי תלות של Navigation 2
מסירים את כל הייבוא של Navigation 2 ואת התלות בספרייה.
סיכום
מזל טוב! הפרויקט שלכם הועבר עכשיו לניווט 3. אם אתם או סוכן ה-AI שלכם נתקלתם בבעיות כלשהן בשימוש במדריך הזה, אתם יכולים לדווח על באג כאן.