Всем привет! Я 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,
)

Всё работает, но есть несколько проблем.

  1. Мы хотим инжектить Navigator через DI, чтобы можно было управлять навигацией напрямую из вьюмоделей.

  2. entryProvider будет сильно разрастаться при добавлении новых экранов.

  3. Аналогично будет разрастаться и 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 проблемы решены:

  1. Теперь мы можем инжектить Navigator во ViewModel.

  2. Каждая фича теперь сама добавляет свои 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, тогда и отдельная библиотека не понадобилась бы.

Комментарии (0)