模組化導覽方案 (Hilt)
本食譜示範如何使用 Navigation 3 和 Dagger/Hilt 進行依附元件插入,建構多模組應用程式。目標是建立解耦架構,在不同的功能模組中定義及實作導覽功能。
運作方式
應用程式分為多個模組:
-
app模組:這是主要應用程式模組。這個類別提供HiltDeepLinkActivity(示範一般和深層連結意圖啟動的進入點)、HiltModularActivity(用於代管NavDisplay,並將深層連結意圖委派給Navigator.handleDeepLink(intent))、起始目的地繫結,以及建構最終entryProvider。 -
common模組:這個模組包含核心導覽邏輯,包括:Navigator類別,可管理返回堆疊、解析傳入的深層連結 Intent,並透過handleDeepLink(intent)建構合成返回堆疊。- 用於初始化返回堆疊的
startDestination: NavKey。 EntryProviderInstaller型別,這是功能模組用來將導覽項目提供給應用程式entryProvider的函式。BackStackMatcher<*, NavKey>,功能模組會使用這個類別,透過合成返回堆疊提供深層連結比對器。
-
功能模組 (例如
conversation、profile):每項功能都會分割成兩個子模組:api模組:定義功能的公開 API,包括導覽路徑和深層連結 URI 模式。這樣一來,其他模組就能導覽至這項功能,而不必瞭解實作詳細資料。impl模組:提供功能實作,包括可組合項、將功能路徑對應至可組合項的EntryProviderInstaller,以及設定.withBackStack的BackStackMatcher<*, NavKey>,以建立適當的階層式返回堆疊 (例如[ConversationList, ConversationDetail(id)])。這兩者都是使用 Dagger/Hilt 多重繫結 (@IntoSet) 提供。
這種模組化方法可明確區隔問題,讓程式碼集更具擴充性和可維護性。每項功能都負責自己的導覽邏輯,而 app 模組只會將這些部分結合在一起。
package com.example.nav3recipes.modular.hilt 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 dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.components.ActivityRetainedComponent import dagger.multibindings.IntoSet import kotlinx.serialization.Serializable // API @Serializable object Profile : NavKey const val PROFILE_URI = "https://www.nav3recipes.com/profile" // IMPLEMENTATION @Module @InstallIn(ActivityRetainedComponent::class) object ProfileModule { @IntoSet @Provides fun provideEntryProviderInstaller() : EntryProviderInstaller = { entry<Profile>{ ProfileScreen() } } @IntoSet @Provides fun provideDeepLinkMatcher(): BackStackMatcher<*, NavKey> = 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.hilt 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.runtime.entryProvider import androidx.navigation3.ui.NavDisplay import com.example.nav3recipes.ui.setEdgeToEdgeConfig import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @AndroidEntryPoint class HiltModularActivity : ComponentActivity() { @Inject lateinit var navigator: Navigator @Inject lateinit var entryProviderScopes: Set<@JvmSuppressWildcards EntryProviderInstaller> 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 = entryProvider { entryProviderScopes.forEach { builder -> this.builder() } } ) } } } }
package com.example.nav3recipes.modular.hilt 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 dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.components.ActivityRetainedComponent import dagger.multibindings.IntoSet import kotlinx.serialization.Serializable // 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 @Module @InstallIn(ActivityRetainedComponent::class) object ConversationModule { @IntoSet @Provides fun provideEntryProviderInstaller(navigator: Navigator): EntryProviderInstaller = { entry<ConversationList> { ConversationListScreen( onConversationClicked = { conversationDetail -> navigator.goTo(conversationDetail) } ) } entry<ConversationDetail> { key -> ConversationDetailScreen(key) { navigator.goTo(Profile) } } } @IntoSet @Provides fun provideDeepLinkMatcher(): BackStackMatcher<*, NavKey> = 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.hilt import android.content.Intent import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.deeplink.BackStackMatcher import androidx.navigation3.runtime.deeplink.DeepLinkRequest import androidx.navigation3.runtime.deeplink.invoke import dagger.hilt.android.scopes.ActivityRetainedScoped import javax.inject.Inject typealias EntryProviderInstaller = EntryProviderScope<NavKey>.() -> Unit @ActivityRetainedScoped class Navigator @Inject constructor( startDestination: NavKey, private val deepLinkMatchers: Set<@JvmSuppressWildcards 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.hilt import androidx.navigation3.runtime.NavKey import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.components.ActivityRetainedComponent @Module @InstallIn(ActivityRetainedComponent::class) object AppModule { @Provides fun provideStartDestination(): NavKey = ConversationList }