Всем привет! Я Android разработчик с 5 летним стажем и недавно я решил погрузиться в кроссплатформенную разработку с Compose Multiplatform. Как мне кажется, сейчас очень хорошее время для этого, т.к. Google и Jetbrains успели уже выкатить много различных библиотек для Compose Multiplatform и разработка на kmp уже мало чем отличается от нативной разработки.
В этой статье я бы хотел поделиться своими наработками по тому, как можно удобно совмещать библиотеку Navigation3 и Koin в Compose Multiplatform проекте и какие подводные камни есть на текущий момент.
За основу я взял архитектуру навигации из проекта nowinandroid от Google.
Копируем класс NavigationState.
@Composable fun rememberNavigationState( startKey: NavKey, topLevelKeys: Set<NavKey>, ): NavigationState { val topLevelStack = rememberNavBackStack(startKey) val subStacks = topLevelKeys.associateWith { key -> rememberNavBackStack(key) } return remember(startKey, topLevelKeys) { NavigationState( startKey = startKey, topLevelStack = topLevelStack, subStacks = subStacks, ) } } class NavigationState( val startKey: NavKey, val topLevelStack: NavBackStack<NavKey>, val subStacks: Map<NavKey, NavBackStack<NavKey>>, ) { val currentTopLevelKey: NavKey by derivedStateOf { topLevelStack.last() } val topLevelKeys get() = subStacks.keys @get:VisibleForTesting val currentSubStack: NavBackStack<NavKey> get() = subStacks[currentTopLevelKey] ?: error("Sub stack for $currentTopLevelKey does not exist") @get:VisibleForTesting val currentKey: NavKey by derivedStateOf { currentSubStack.last() } } @Composable fun NavigationState.toEntries( entryProvider: (NavKey) -> NavEntry<NavKey>, ): SnapshotStateList<NavEntry<NavKey>> { val decoratedEntries = subStacks.mapValues { (_, stack) -> val decorators = listOf( rememberSaveableStateHolderNavEntryDecorator<NavKey>(), rememberViewModelStoreNavEntryDecorator<NavKey>(), ) rememberDecoratedNavEntries( backStack = stack, entryDecorators = decorators, entryProvider = entryProvider, ) } return topLevelStack .flatMap { decoratedEntries[it] ?: emptyList() } .toMutableStateList() }
Он поддерживает работу с несколькими бэкстэками: 1 стэк для глобальной навигации (например через BottomNavBar) и отдельно по стэку для каждого раздела.
В репозитории nav3-recipes есть схожая реализация, но без стэка для глобальной навигации. Отличие в том, что если вы перейдёте Top1(начальный экран) -> Top2 -> Top3, то кнопка назад вернёт вас на начальный экран Top1, а не на Top2.
Обе реализации имеют место быть, выбирайте в зависимости от ваших потребностей.
Тут есть хороший разбор того, как это работает: https://youtu.be/hNzRWVr_Yvs?si=iPPcaBcgMJ51JUbO
И тут возникла первая проблема, функция rememberNavBackStack требует передать ей SavedStateConfiguration. Переходим в документацию функции и видим:
On Android, an overload of this function is available that does not require a SavedStateConfiguration. That version uses reflection internally and does not require subtypes to be registered, but it is not available on other platforms.
Оказывается, что на андроиде вся магия сериализации работает за счёт рефлексии, а в kmp нам придётся объявлять сериализаторы вручную. Выглядеть это будет следующим образом
@Composable public fun rememberNavigationState( startKey: NavKey, topLevelKeys: Set<NavKey>, ): NavigationState { val topLevelStack = rememberNavBackStack(configuration, startKey) val subStacks = topLevelKeys.associateWith { key -> rememberNavBackStack(configuration, key) } return remember(startKey, topLevelKeys) { NavigationState( startKey = startKey, topLevelStack = topLevelStack, subStacks = subStacks, ) } } private val configuration = SavedStateConfiguration { serializersModule = SerializersModule { polymorphic(NavKey::class) { subclass(HomeMainNavKey::class, HomeMainNavKey.serializer()) subclass(WorkoutMainNavKey::class, WorkoutMainNavKey.serializer()) subclass(WorkoutNavKey::class, WorkoutNavKey.serializer()) subclass(NutritionMainNavKey::class, NutritionMainNavKey.serializer()) subclass(AddMealNavKey::class, AddMealNavKey.serializer()) } } }
Выглядит уже не очень красиво. А представьте, что будет, когда количество экранов в приложении будет под сотню. Что ж, не переживайте, чуть позже я покажу как от этого можно избавиться :-)
Далее копируем класс навигатора. Это класс, который будет управлять стэйтом навигации.
class Navigator(val state: NavigationState) { fun navigate(key: NavKey) { when (key) { state.currentTopLevelKey -> clearSubStack() in state.topLevelKeys -> goToTopLevel(key) else -> goToKey(key) } } fun goBack() { when (state.currentKey) { state.startKey -> error("You cannot go back from the start route") state.currentTopLevelKey -> state.topLevelStack.removeLastOrNull() else -> state.currentSubStack.removeLastOrNull() } } private fun goToKey(key: NavKey) { state.currentSubStack.apply { remove(key) add(key) } } private fun goToTopLevel(key: NavKey) { state.topLevelStack.apply { if (key == state.startKey) { clear() } else { remove(key) } add(key) } } private fun clearSubStack() { state.currentSubStack.run { if (size > 1) subList(1, size).clear() } } }
Осталось только объеденить всё вместе и проверить
val navigationState = rememberNavigationState(HomeMainNavKey, TOP_LEVEL_NAV_ITEMS.keys) val navigator = remember { Navigator(navigationState) } val entryProvider = entryProvider { homeEntry(navigator) workoutEntry(navigator) nutritionEntry(navigator) } NavDisplay( entries = navigator.state.toEntries(entryProvider), onBack = navigator::goBack, )
Всё работает, но есть несколько проблем.
Мы хотим инжектить Navigator через DI, чтобы можно было управлять навигацией напрямую из вьюмоделей.
entryProvider будет сильно разрастаться при добавлении новых экранов.
Аналогично будет разрастаться и SavedStateConfiguration.
Перед тем как начинать этот pet проект, я уже изучил документацию по Navigation3. И там тоже подсвечивали проблему разрастания entryProvider. Решение - использовать DI.
В примере приводится реализация при помощи Dagger multibindngs. Но т.к. у нас мультиплатформа, я использую Koin. К сожалению в Koin нет аналога @IntoSet, но зато недавно появилась библиотека для интеграции с Navigation3. Подключаем её.
Объявляем Navigator в appModule:
internal val appModule = module { includes( homeMainModule, workoutMainModule, nutritionMainModule, workoutModule, ) singleOf(::Navigator) }
Код навигатора пришлось немного изменить, чтобы можно было за��ать стэйт позже.
public class Navigator() { // Вынесли state из конструктора public lateinit var state: NavigationState ... } // App.kt @Composable internal fun App( modifier: Modifier = Modifier, navigator: Navigator = koinInject(), ) { navigator.state = rememberNavigationState(HomeMainNavKey, TOP_LEVEL_NAV_ITEMS.keys) NavDisplay( entries = navigator.state.toEntries(), onBack = navigator::goBack, ) }
Модули фич будут выглядеть следующим образом:
public val homeMainModule: Module = module { viewModelOf(::HomeMainViewModel) navigation<HomeMainNavKey> { HomeMainScreen() } }
Инжектим entryProvider в функцию toEntries().
@Composable public fun NavigationState.toEntries( entryProvider: (Any) -> NavEntry<Any> = koinEntryProvider(), ): SnapshotStateList<NavEntry<Any>> {...}
Итак, первые 2 проблемы решены:
Теперь мы можем инжектить Navigator во ViewModel.
Каждая фича теперь сама добавляет свои entry в entryProvider при помощи navigation<NavKey>.
Но, к сожалению, на текущий момент библиотека io.insert-koin:koin-compose-navigation3:4.2.0-beta2 не предоставляет методов для провайдинга сериализаторов NavKey.
Я решил скропировать код, который они используют для NavEntry и адаптировал его для NavKey.
public typealias NavKeyProviderInstaller = PolymorphicModuleBuilder<NavKey>.() -> Unit @KoinDslMarker @OptIn(KoinInternalApi::class) public inline fun <reified T : NavKey> Module.navKey(serializer: KSerializer<T>): KoinDefinition<NavKeyProviderInstaller> { val def = _singleInstanceFactory<NavKeyProviderInstaller>(named<T>(), { { subclass(T::class, serializer) } }) indexPrimaryType(def) return KoinDefinition(this, def) } @OptIn(KoinInternalApi::class) @Composable public fun koinNavConfigProvider(scope : Scope = LocalKoinScopeContext.current.getValue()) : SavedStateConfiguration { return scope.getConfiguration() } private fun Scope.getConfiguration() : SavedStateConfiguration { val entries = getAll<NavKeyProviderInstaller>() val configuration = SavedStateConfiguration { serializersModule = SerializersModule { polymorphic(NavKey::class) { entries.forEach { builder -> this.builder() } } } } return configuration }
В каждом модуле добавляем определение сериализатора для NavKey.
public val homeMainModule: Module = module { viewModelOf(::HomeMainViewModel) navKey(HomeMainNavKey.serializer()) navigation<HomeMainNavKey> { HomeMainScreen() } }
Запускаем и получаем ошибку
java.lang.ClassCastException: kotlinx.serialization.modules.PolymorphicModuleBuilder cannot be cast to androidx.navigation3.runtime.EntryProviderScope
Проблема в том, что Koin не различает NavKeyProviderInstaller и EntryProviderInstaller, т.к. в обоих случаях это лямбды. Чтобы исправить эту проблему мы можем использовать SAM interface, для того чтобы наша лямбда имела определённый тип.
public fun interface NavKeyProviderInstaller<T : NavKey> { public fun build(builder: PolymorphicModuleBuilder<T>) } @KoinDslMarker @OptIn(KoinInternalApi::class) public inline fun <reified T : NavKey> Module.navKey(serializer: KSerializer<T>): KoinDefinition<NavKeyProviderInstaller<T>> { val def = _singleInstanceFactory<NavKeyProviderInstaller<T>>(named<T>(), { NavKeyProviderInstaller { it.subclass(T::class, serializer) } }) indexPrimaryType(def) return KoinDefinition(this, def) } @OptIn(KoinInternalApi::class) @Composable public fun koinNavConfigProvider(scope : Scope = LocalKoinScopeContext.current.getValue()) : SavedStateConfiguration { return scope.getConfiguration() } private fun Scope.getConfiguration() : SavedStateConfiguration { val entries = getAll<NavKeyProviderInstaller<out NavKey>>() val configuration = SavedStateConfiguration { serializersModule = SerializersModule { polymorphic(NavKey::class) { entries.forEach { builder -> builder.build(this) } } } } return configuration }
Ура, всё заработало.
Добавим обёртку над navigation<NavKey>, чтобы добавляла в граф не только NavEntry, но и сериализатор. Таким образом сократим количество кода и исключим случаи, когда мы забыли запровайдить сериализатор.
@OptIn(KoinInternalApi::class, KoinExperimentalAPI::class) public inline fun <reified T : NavKey> Module.navEntry( serializer: KSerializer<T>, metadata: Map<String, Any> = emptyMap(), noinline definition: @Composable Scope.(T) -> Unit, ) { navKey(serializer) navigation(metadata, definition) }
Теперь модули будут выглядеть следующим образом:
public val homeMainModule: Module = module { viewModelOf(::HomeMainViewModel) navEntry(HomeMainNavKey.serializer()) { HomeMainScreen () } }
Также добавим вспомогательную функцию rememberKoinNavBackStack, которая будет сама получать конфигурацию из Koin графа и применять её к стэку.
@Composable public fun rememberKoinNavBackStack(vararg elements: NavKey) : NavBackStack<NavKey> { return rememberNavBackStack( configuration = koinNavConfigProvider(), elements = elements, ) }
Таким образом получаем такое же api, как в нативном Android.
Итоговый код
import androidx.navigation3.runtime.NavKey import kotlinx.serialization.modules.PolymorphicModuleBuilder public fun interface NavKeyProviderInstaller<T : NavKey> { public fun build(builder: PolymorphicModuleBuilder<T>) }
import androidx.compose.runtime.Composable import androidx.navigation3.runtime.NavKey import kotlinx.serialization.KSerializer import kotlinx.serialization.modules.PolymorphicModuleBuilder import org.koin.compose.navigation3.EntryProviderInstaller import org.koin.core.annotation.KoinExperimentalAPI import org.koin.core.annotation.KoinInternalApi import org.koin.core.definition.KoinDefinition import org.koin.core.module.KoinDslMarker import org.koin.core.module.Module import org.koin.core.module._scopedInstanceFactory import org.koin.core.module._singleInstanceFactory import org.koin.core.qualifier.named import org.koin.core.scope.Scope import org.koin.dsl.ScopeDSL import org.koin.dsl.navigation3.navigation /** * Declares a scoped navigation entry with [NavKey] subclass serializer within a Koin scope DSL. * * This function registers a composable navigation destination and a [PolymorphicModuleBuilder] of [NavKey] subclass * which are scoped to a specific Koin scope, allowing access to scoped dependencies within the composable. * The route type T is used as both the navigation destination identifier * and a qualifier for the entry provider and [NavKeyProviderInstaller]. * * Example usage: * ```kotlin * activityScope { * navEntry(MyRoute.serializer()) { route -> * MyScreen(viewModel = koinViewModel()) * } * } * ``` * * @param T The type representing the navigation route/destination * @param serializer The [KSerializer] responsible for serializing instances of the specified [NavKey] subclass. * @param metadata Optional metadata map to associate with the navigation entry (default is empty) * @param definition A composable function that receives the [Scope] and route instance [T] to render the destination * @return A [KoinDefinition] for the created [EntryProviderInstaller] * * @see Module.navEntry for module-level navigation entries */ @OptIn(KoinInternalApi::class, KoinExperimentalAPI::class) public inline fun <reified T : NavKey> ScopeDSL.navEntry( serializer: KSerializer<T>, metadata: Map<String, Any> = emptyMap(), noinline definition: @Composable Scope.(T) -> Unit, ) { navKey(serializer) navigation(metadata, definition) } /** * Declares a singleton navigation entry with [NavKey] subclass serializer within a Koin module. * * This function registers a composable navigation destination and a [PolymorphicModuleBuilder] of [NavKey] subclass * as a singletons in the Koin module, allowing access to module-level dependencies within the composable. * The route type T is used as both the navigation destination identifier * and a qualifier for the entry provider and [NavKeyProviderInstaller]. * * Example usage: * ```kotlin * activityScope { * navEntry(MyRoute.serializer()) { route -> * MyScreen(viewModel = koinViewModel()) * } * } * ``` * * @param T The type representing the navigation route/destination * @param serializer The [KSerializer] responsible for serializing instances of the specified [NavKey] subclass. * @param metadata Optional metadata map to associate with the navigation entry (default is empty) * @param definition A composable function that receives the [Scope] and route instance [T] to render the destination * @return A [KoinDefinition] for the created [EntryProviderInstaller] * * @see ScopeDSL.navEntry for scope-level navigation entries */ @OptIn(KoinInternalApi::class, KoinExperimentalAPI::class) public inline fun <reified T : NavKey> Module.navEntry( serializer: KSerializer<T>, metadata: Map<String, Any> = emptyMap(), noinline definition: @Composable Scope.(T) -> Unit, ) { navKey(serializer) navigation(metadata, definition) } /** * Declares a scoped [NavKey] subclass serializer within a Koin scope DSL. * * This function registers a [PolymorphicModuleBuilder] of [NavKey] subclass that is scoped to a specific Koin scope. * The route type [T] is used as qualifier for the [NavKeyProviderInstaller]. * * Example usage: * ```kotlin * activityScope { * navKey(MyRoute.serializer()) * navigation<MyRoute> { route -> * MyScreen(viewModel = koinViewModel()) * } * } * ``` * * @param T The type representing the navigation route/destination * @param serializer The [KSerializer] responsible for serializing instances of the specified [NavKey] subclass. * @return A [KoinDefinition] for the created [NavKeyProviderInstaller] * * @see Module.navKey for module-level nav keys */ @KoinDslMarker @OptIn(KoinInternalApi::class) public inline fun <reified T : NavKey> ScopeDSL.navKey(serializer: KSerializer<T>): KoinDefinition<NavKeyProviderInstaller<T>> { val def = _scopedInstanceFactory<NavKeyProviderInstaller<T>>(named<T>(), { NavKeyProviderInstaller { it.subclass(T::class, serializer) } }, scopeQualifier) module.indexPrimaryType(def) return KoinDefinition(module, def) } /** * Declares a singleton [NavKey] subclass serializer within a Koin module. * * This function registers a [PolymorphicModuleBuilder] of [NavKey] subclass as a singleton in the Koin module. * The route type [T] is used as qualifier for the [NavKeyProviderInstaller]. * * Example usage: * ```kotlin * module { * navKey(MyRoute.serializer()) * navigation<HomeRoute> { route -> * HomeScreen(myViewModel = koinViewModel()) * } * } * ``` * * @param T The type representing the navigation route/destination * @param serializer The [KSerializer] responsible for serializing instances of the specified [NavKey] subclass. * @return A [KoinDefinition] for the created [NavKeyProviderInstaller] * * @see ScopeDSL.navKey for scope-level nav keys */ @KoinDslMarker @OptIn(KoinInternalApi::class) public inline fun <reified T : NavKey> Module.navKey(serializer: KSerializer<T>): KoinDefinition<NavKeyProviderInstaller<T>> { val def = _singleInstanceFactory<NavKeyProviderInstaller<T>>(named<T>(), { NavKeyProviderInstaller { it.subclass(T::class, serializer) } }) indexPrimaryType(def) return KoinDefinition(this, def) }
import androidx.compose.runtime.Composable import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.rememberNavBackStack import androidx.savedstate.serialization.SavedStateConfiguration import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.polymorphic import org.koin.compose.LocalKoinScopeContext import org.koin.core.annotation.KoinInternalApi import org.koin.core.scope.Scope /** * Provides a [NavBackStack] that is automatically remembered in the Compose hierarchy across * process death and configuration changes. * * This function uses [koinNavConfigProvider] to retrieve [SavedStateConfiguration] from the current Koin scope. * * ### Serialization requirements * - All destination keys must be `@Serializable` and implement the [NavKey] interface. * - You **must** register all keys serializers in Koin using [navKey] or [navEntry]. * * @param elements The initial [NavKey] elements of this back stack. * @return A [NavBackStack] that survives process death and configuration changes. * @see koinNavConfigProvider * @see NavKey */ @Composable public fun rememberKoinNavBackStack(vararg elements: NavKey) : NavBackStack<NavKey> { return rememberNavBackStack( configuration = koinNavConfigProvider(), elements = elements, ) } /** * Composable function that retrieves an [SavedStateConfiguration] from the current or specified Koin scope. * * This function collects all registered [NavKeyProviderInstaller] instances from the Koin scope * and aggregates them into a single [SavedStateConfiguration] that can be used with Navigation 3 in Kotlin Multiplatform. * By default, it uses the scope from [LocalKoinScopeContext], but a custom scope can be provided. * * Example usage: * ```kotlin * @Composable * fun MyApp() { * val backStack = rememberNavBackStack( * configuration = koinNavBackStackConfigurationProvider(), * MyRoute, * ) * NavDisplay( * entries = backStack, * ... * ) * } * ``` * * @param scope The Koin scope to retrieve nav keys from. Defaults to [LocalKoinScopeContext.current]. * @return An [SavedStateConfiguration] that combines all registered nav keys from the scope * * @see NavKeyProviderInstaller for defining nav keys in Koin modules */ @OptIn(KoinInternalApi::class) @Composable public fun koinNavConfigProvider(scope : Scope = LocalKoinScopeContext.current.getValue()) : SavedStateConfiguration { return scope.getConfiguration() } private fun Scope.getConfiguration() : SavedStateConfiguration { val entries = getAll<NavKeyProviderInstaller<out NavKey>>() val configuration = SavedStateConfiguration { serializersModule = SerializersModule { polymorphic(NavKey::class) { entries.forEach { builder -> builder.build(this) } } } } return configuration }
На этом у меня всё! Полный код из статьи вы можете найти в моём репозитории.
Буду рад конструктивной критике и предложениям по улучшению :-)
P.S. Надеюсь в будущих релизах в Koin добавят поддержку сериализаторов для NavKey. А лучше поддержку Multibindings, тогда и отдельная библиотека не понадобилась бы.
