모듈식 탐색 레시피 (Koin)
이 레시피에서는 종속 항목 삽입을 위해 탐색 3 및 Koin을 사용하여 다중 모듈 애플리케이션을 구성하는 방법을 보여줍니다. 목표는 탐색이 별도의 기능 모듈에서 정의되고 구현되는 분리된 아키텍처를 만드는 것입니다. koin-compose-navigation3 아티팩트를 사용합니다.
작동 방식
애플리케이션은 여러 Android 모듈로 나뉩니다.
-
app모듈: 기본 애플리케이션 모듈입니다. 일반 및 딥 링크 인텐트 실행을 보여주는 진입점인KoinDeepLinkActivity,NavDisplay를 호스팅하고 딥 링크 인텐트를Navigator.handleDeepLink(intent)에 위임하는KoinModularActivity를 제공하고 일반Navigator를 초기화합니다(getAll()을 통해 등록된 모든BackStackMatcher<*, NavKey>해결). -
common모듈: 이 모듈에는 다음을 포함하여 애플리케이션 모듈과 기능 모듈 모두에서 사용되는 핵심 탐색 로직이 포함되어 있습니다.- 백 스택을 관리하고, 수신 딥 링크 인텐트를 해결하고,
handleDeepLink(intent)를 통해 합성 백 스택을 빌드하는Navigator클래스 - 백 스택을 초기화하는 데 사용되는
startDestination: NavKey BackStackMatcher<*, NavKey>: 기능 모듈에서 합성 백 스택으로 딥 링크 일치자를 제공하는 데 사용합니다.
- 백 스택을 관리하고, 수신 딥 링크 인텐트를 해결하고,
-
기능 모듈 (예:
conversation,profile): 각 기능은 두 개의 하위 모듈로 분할됩니다.api모듈: 탐색 경로 및 딥 링크 URI 패턴을 비롯한 기능의 공개 API를 정의합니다. 이를 통해 다른 모듈은 구현 세부정보를 알 필요 없이 이 기능으로 이동할 수 있습니다.impl모듈: 컴포저블 및 KoinModule을 비롯한 기능의 구현을 제공합니다. Koin 모듈은navigationDSL을 사용하여 진입점 제공업체 설치 프로그램을 정의하고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() ) } } } }