モジュール式 Navigation のレシピ(Hilt)
このレシピでは、Navigation 3 と依存関係注入用の Dagger/Hilt を使用してマルチモジュール アプリケーションを構造化する方法について説明します。目標は、ナビゲーションが個別の機能モジュールで定義および実装される、疎結合のアーキテクチャを作成することです。
仕組み
アプリケーションは複数のモジュールに分かれています。
-
appモジュール: メインのアプリケーション モジュールです。HiltDeepLinkActivity(通常のインテントとディープリンク インテントの起動を示すエントリ ポイント)、HiltModularActivity(NavDisplayをホストし、ディープリンク インテントをNavigator.handleDeepLink(intent)に委任する)、開始デスティネーション バインディングを提供し、最終的なentryProviderをビルドします。 -
commonモジュール: このモジュールには、次のようなコア ナビゲーション ロジックが含まれています。- バックスタックを管理し、受信したディープリンク インテントを解決し、
handleDeepLink(intent)を介して合成バックスタックを構築するNavigatorクラス。 - バックスタックの初期化に使用される
startDestination: NavKey。 EntryProviderInstaller型。これは、機能モジュールがナビゲーション エントリをアプリケーションのentryProviderに提供するために使用する関数です。BackStackMatcher<*, NavKey>。機能モジュールが合成バックスタックを使用してディープリンク マッチャーを提供するために使用します。
- バックスタックを管理し、受信したディープリンク インテントを解決し、
-
機能モジュール(
conversation、profileなど): 各機能は次の 2 つのサブモジュールに分割されます。apiモジュール: ナビゲーション ルートやディープリンク URI パターンなど、機能の公開 API を定義します。これにより、他のモジュールは実装の詳細を知らなくてもこの機能に移動できます。implモジュール: コンポーザブル、機能のルートをコンポーザブルにマッピングするEntryProviderInstaller、適切な階層型バックスタック([ConversationList, ConversationDetail(id)]など)を確立するために.withBackStackで構成されたBackStackMatcher<*, NavKey>など、機能の実装を提供します。どちらも 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 }