מתכון לניווט מודולרי (Koin)
המתכון הזה מדגים איך לבנות אפליקציה עם כמה מודולים באמצעות Navigation 3 ו-Koin להזרקת תלות. המטרה היא ליצור ארכיטקטורה מנותקת שבה הניווט מוגדר ומוטמע במודולים נפרדים של תכונות. הוא מסתמך על פריט המידע שנוצר koin-compose-navigation3.
איך זה עובד
האפליקציה מחולקת לכמה מודולים של Android:
-
מודול
app: זהו המודול הראשי של האפליקציה. הוא מספק אתKoinDeepLinkActivity(נקודת כניסה שמדגימה הפעלות של אובייקטים מסוג Intent של קישורים רגילים וקישורי עומק), אתKoinModularActivity(שמארח אתNavDisplayומקצה אובייקטים מסוג Intent של קישורי עומק ל-Navigator.handleDeepLink(intent)), ומאתחלNavigatorנפוץ (שפותר את כל ה-BackStackMatcher<*, NavKey>הרשומים באמצעותgetAll()). -
מודול
common: המודול הזה מכיל את הלוגיקה העיקרית של הניווט שמשמשת גם את מודול האפליקציה וגם את המודולים של התכונות, כולל:- מחלקה מסוג
Navigatorשמנהלת את מקבץ הפעילויות הקודמות, פותרת כוונות נכנסות של קישורי עומק ויוצרת מקבצי פעילויות קודמות סינתטיים באמצעותhandleDeepLink(intent). -
startDestination: NavKeyשמשמש לאתחול של מקבץ הפעילויות הקודמות (back stack). -
BackStackMatcher<*, NavKey>, שמשמשת מודולים של תכונות כדי לתרום את ההתאמות של קישורי העומק שלהם עם מקבצי פעילויות קודמות (back stack) סינתטיים.
- מחלקה מסוג
-
מודולים של תכונות (למשל,
conversation, profile): כל תכונה מחולקת לשני מודולי משנה:- מודול
api: מגדיר את ה-API הציבורי של התכונה, כולל נתיבי הניווט שלה ותבניות ה-URI של קישורי העומק. כך מודולים אחרים יכולים לנווט לתכונה הזו בלי לדעת את פרטי ההטמעה שלה. - מודול
impl: מספק את ההטמעה של התכונה, כולל הקומפוזיציות שלה ו-KoinModule. מודול Koin משתמש ב-DSLnavigationכדי להגדיר את מתקיני ספקי הכניסה ואתsingle<BackStackMatcher<*, NavKey>>(named<...>())שהוגדר באמצעות.withBackStackכדי ליצור את היררכיית מקבץ הפעילויות הקודמות המתאימה (לדוגמה,[ConversationList, ConversationDetail(id)]).
- מודול
הגישה המודולרית הזו מאפשרת הפרדה נקייה של תחומי האחריות, וכך בסיס הקוד קל יותר להרחבה ולתחזוקה. כל תכונה אחראית ללוגיקת הניווט שלה, ומודול app רק משלב את החלקים האלה.
package com.example.nav3recipes.modular.koin import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.deeplink.BackStackMatcher import androidx.navigation3.runtime.deeplink.DeepLinkUri import androidx.navigation3.runtime.deeplink.UriDeepLinkMatcher import androidx.navigation3.runtime.deeplink.withBackStack import kotlinx.serialization.Serializable import org.koin.androidx.scope.dsl.activityRetainedScope import org.koin.core.annotation.KoinExperimentalAPI import org.koin.core.qualifier.named import org.koin.dsl.module import org.koin.dsl.navigation3.navigation // API @Serializable object Profile : NavKey const val PROFILE_URI = "https://www.nav3recipes.com/profile" // IMPL @OptIn(KoinExperimentalAPI::class) val profileModule = module { activityRetainedScope { navigation<Profile> { ProfileScreen() } } single<BackStackMatcher<*, NavKey>>(named<Profile>()) { UriDeepLinkMatcher( uriPattern = DeepLinkUri(PROFILE_URI), serializer = Profile.serializer(), ).withBackStack { matchResult -> listOf(ConversationList, matchResult.key) } } } @Composable private fun ProfileScreen() { val profileColor = MaterialTheme.colorScheme.surfaceVariant Column( modifier = Modifier .fillMaxSize() .background(profileColor) .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { Text( text = "Profile Screen", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onSurface ) } }
package com.example.nav3recipes.modular.koin import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.Button import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.dropUnlessResumed import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.deeplink.BackStackMatcher import androidx.navigation3.runtime.deeplink.DeepLinkUri import androidx.navigation3.runtime.deeplink.UriDeepLinkMatcher import androidx.navigation3.runtime.deeplink.withBackStack import com.example.nav3recipes.ui.theme.colors import kotlinx.serialization.Serializable import org.koin.androidx.scope.dsl.activityRetainedScope import org.koin.core.annotation.KoinExperimentalAPI import org.koin.core.qualifier.named import org.koin.dsl.module import org.koin.dsl.navigation3.navigation // API object ConversationList : NavKey @Serializable data class ConversationDetail(val id: Int) : NavKey { val color: Color get() = colors[id % colors.size] } const val CONVERSATION_URI = "https://www.nav3recipes.com/conversation" // IMPL @OptIn(KoinExperimentalAPI::class) val conversationModule = module { activityRetainedScope { navigation<ConversationList> { ConversationListScreen( onConversationClicked = { conversationDetail -> get<Navigator>().goTo(conversationDetail) } ) } navigation<ConversationDetail> { key -> ConversationDetailScreen(key) { get<Navigator>().goTo(Profile) } } } single<BackStackMatcher<*, NavKey>>(named<ConversationDetail>()) { UriDeepLinkMatcher( uriPattern = DeepLinkUri("$CONVERSATION_URI/{id}"), serializer = ConversationDetail.serializer(), ).withBackStack { matchResult -> listOf(ConversationList, matchResult.key) } } } @Composable private fun ConversationListScreen( onConversationClicked: (ConversationDetail) -> Unit ) { LazyColumn( modifier = Modifier.fillMaxSize(), ) { items(10) { index -> val conversationId = index + 1 val conversationDetail = ConversationDetail(conversationId) val backgroundColor = conversationDetail.color ListItem( modifier = Modifier .fillMaxWidth() .clickable(onClick = dropUnlessResumed { onConversationClicked(conversationDetail) }), headlineContent = { Text( text = "Conversation $conversationId", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface ) }, colors = ListItemDefaults.colors( containerColor = backgroundColor // Set container color directly ) ) } } } @Composable private fun ConversationDetailScreen( conversationDetail: ConversationDetail, onProfileClicked: () -> Unit ) { Column( modifier = Modifier .fillMaxSize() .background(conversationDetail.color) .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { Text( text = "Conversation Detail Screen: ${conversationDetail.id}", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onSurface ) Spacer(modifier = Modifier.height(16.dp)) Button(onClick = dropUnlessResumed(block = onProfileClicked)) { Text("View Profile") } } }
package com.example.nav3recipes.modular.koin import android.content.Intent import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.deeplink.BackStackMatcher import androidx.navigation3.runtime.deeplink.DeepLinkRequest import androidx.navigation3.runtime.deeplink.invoke class Navigator( startDestination: NavKey, private val deepLinkMatchers: List<BackStackMatcher<*, NavKey>>, ) { val backStack: SnapshotStateList<NavKey> = mutableStateListOf(startDestination) fun goTo(destination: NavKey) { backStack.add(destination) } fun handleDeepLink(intent: Intent) { if (intent.data == null && intent.extras == null) return val request = DeepLinkRequest(intent) val matchResult = deepLinkMatchers.firstNotNullOfOrNull { matcher -> matcher.match(request) } ?: return backStack.clear() backStack.addAll(matchResult.backStack) } fun goBack() { backStack.removeLastOrNull() } }
package com.example.nav3recipes.modular.koin import org.koin.androidx.scope.dsl.activityRetainedScope import org.koin.dsl.module val appModule = module { includes(profileModule, conversationModule) activityRetainedScope { scoped { Navigator( startDestination = ConversationList, deepLinkMatchers = getAll(), ) } } }
package com.example.nav3recipes.modular.koin import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.ui.Modifier import androidx.navigation3.ui.NavDisplay import com.example.nav3recipes.ui.setEdgeToEdgeConfig import org.koin.android.ext.android.inject import org.koin.android.scope.AndroidScopeComponent import org.koin.androidx.compose.navigation3.getEntryProvider import org.koin.androidx.scope.activityRetainedScope import org.koin.core.Koin import org.koin.core.annotation.KoinExperimentalAPI import org.koin.core.component.KoinComponent import org.koin.core.scope.Scope import org.koin.dsl.koinApplication /** * This recipe demonstrates how to use a modular approach with Navigation 3, * where different parts of the application are defined in separate modules and injected * into the main app using Koin. * * Features (Conversation and Profile) are split into two modules: * - api: defines the public facing routes for this feature * - impl: defines the entryProviders for this feature, these are injected into the app's main activity * The common module defines: * - a common navigator class that exposes a back stack and methods to modify that back stack * - a type that should be used by feature modules to inject entryProviders into the app's main activity * The app module creates the navigator by supplying a start destination and provides this navigator * to the rest of the app module (i.e. MainActivity) and the feature modules. */ @OptIn(KoinExperimentalAPI::class) class KoinModularActivity : ComponentActivity(), AndroidScopeComponent, KoinComponent { // Local Koin Context Instance companion object { private val localKoin = koinApplication { modules(appModule) }.koin } // Override default Koin context to use the local one override fun getKoin(): Koin = localKoin override val scope : Scope by activityRetainedScope() val navigator: Navigator by inject() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setEdgeToEdgeConfig() navigator.handleDeepLink(intent) setContent { Scaffold { paddingValues -> NavDisplay( backStack = navigator.backStack, modifier = Modifier.padding(paddingValues), onBack = { navigator.goBack() }, entryProvider = getEntryProvider() ) } } } }