Привет, Хабр! Меня зовут Артем и я автор и ведущий YouTube и Telegram каналов Android Insights.
При разработке современных Android-приложений важную роль играет удобная навигация между экранами и управление зависимостями. Jetpack Compose Navigation — это библиотека от Google, предоставляющая декларативный способ организации навигации в приложениях, построенных на Jetpack Compose. Она позволяет работать с графами навигации, поддерживает аргументы, deeplink’и и сохранение состояния.
Koin — это лёгкий и удобный фреймворк для внедрения зависимостей (DI) в приложениях. Он предлагает декларативный синтаксис для определения модулей, а также поддержку scoping’а, который помогает управлять жизненным циклом зависимостей.
В этой статье я рассмотрю, как использовать Koin scopes в связке с Jetpack Compose Navigation, чтобы эффективно управлять зависимостями на разных уровнях навигационного графа.
Проблема
Представим, что нам нужно спроектировать следующую последовательность экранов
Auth flow

Например, упрощенный вариант кода может выглядеть следующим образом
Скрытый текст
NavHost(
navController = rememberNavController(),
startDestination = Screen.EmailInput
) {
composable<Screen.EmailInput> {
/* omitted code */
}
composable<Screen.OTP> {
/* omitted code */
}
composable<Screen.PinCode> {
/* omitted code */
}
}
Если вы знакомы с Jetpack Compose Navigation, то все выглядит довольно привычно:
создаем NavHost, передаем в него NavController и точку входа
декларируем экраны, используя функцию composable
Также в проекте используется Koin, который настроен самым обычным образом
Запуск внутри Application класса
class App : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@App)
}
}
}
Вызов библиотечной функции с базовыми настройками
KoinAndroidContext {
Content() // внутри код навигации
}
Теперь, когда базовые настройки завершены, можно перейти ближе к сути задачи.
Представим, что у нас есть некий AuthManager. Тот же самый экземпляр AuthManager должен быть доступен для каждого из трех экранов внутри флоу авторизации. Как можно добиться данного поведения?
Вариант с использованием factory я не рассматриваю, потому что для каждого экрана будет создан свой экземпляр класса AuthManager, что противоречит требованиям.
Самый просто способ - объявить AuthManager синглтоном, например, таким образом:
val authModule = module {
singleOf(::AuthManager)
}
Да, AuthManager будет переиспользован, что и требовалось, но здесь возникает другая проблема - AuthManager останется в памяти до закрытия приложения. За время жизни приложения, может скопиться довольно много таких объектов, что приведет к повышенному использованию ресурсов. Плюс, будем считать, что, после логаута и повторного перехода на флоу авторизации, AuthManager должен быть создан заново по тем или иным причинам.
Как можно решить эту задачу? Прежде чем продолжить чтение, предлагаю попробовать решить эту проблему и предложить свое решение в комментариях.
Решение
В данном конкретном случае прекрасно подходят скоупы. Вкратце, скоуп позволяет ограничивать время жизни объектов в рамках di графа, который предоставляет Koin, если хотите подробнее узнать о них, то предлагаю прочитать официальную документацию
Также стоит выделить флоу навигации в отдельный подграф. Для чего это нужно, я расскажу чуть позже. Если не знакомы со вложенными графами навигации, то советую ознакомиться с документацией, для начала этого будет достаточно.
Чтобы создать подграф, воспользуемся функцией navigation. Код настройки навигации примет следующий вид:
Обновленный граф навигации
@Composable
private fun Content() {
NavHost(
navController = rememberNavController(),
startDestination = Screen.AuthGraph
) {
navigation<Screen.AuthGraph>(
startDestination = Screen.AuthGraph.EmailInput
) {
composable<Screen.AuthGraph.EmailInput> {
/* omitted code */
}
composable<Screen.AuthGraph.OTP> {
/* omitted code */
}
composable<Screen.AuthGraph.PinCode> {
/* omitted code */
}
}
}
}
Изменений не так много: у нас поменялась точка входа для функции NavHost, теперь это Screen.AuthGraph. Также, как было сказано выше, я использую функцию navigation, чтобы создать вложенный граф навигации.
navigation, как и NavHost, требует обязательный параметр startDestination, чтобы задать начальную точку входа(на самом деле, можно перейти на любой экран в подграфе, startDestination скорее выполняет роль fallback'а).
Теперь давайте немного углубимся в особенности работы compose навигации. Если посмотреть на функцию composable, то видно, что в лямбду content параметром передается некий NavBackStackEntry. NavBackStackEntry реализует интерфесы LifecycleOwner, ViewModelStoreOwner, HasDefaultViewModelProviderFactory и SavedStateRegistryOwner. Другими словами, каждый composable имеет свой жизненный цикл, может сохранять и восстанавливать свое состояние, но самое важное для нас то, что он может создавать ViewModel и корректно очищать их в момент закрытия. Если продолжить изучать библиотеку, то видно, что для функции navigation создается свой NavDestination. Это значит что, в момент перехода на один из экранов вложенного графа навигации, будет создан не один NavBackStackEntry, а два: первый для функции navigation и корня подграфа, а второй для конкретного экрана в подграфе. Для нас это прекрасные новости!
Но что значит для поставленной задачи тот вывод, который был сделан выше? Давайте разберемся!
Для начала немного отойдем в сторону и поговорим про скоупы в koin. Как я упомянул выше, скоуп позволяет ограничить время жизни объектов внутри себя. Скоуп легко создать, но самая сложная задача - закрыть его в правильный момент, не слишком поздно, чтобы не образовалась утечка и не слишком рано, чтобы зависимости были доступны во всем времени жизни экрана.
Как мы выяснили выше, корень графа имеет свой NavBackStackEntry, который в свою очередь может создавать и управлять ViewModel. Мы можем использовать эту ViewModel в качестве контейнера для скоупа. Еще это хорошо тем, что во ViewModel доступна функция onCleared, которая является идеальным местом для очистки ресурсов, а в нашем случае - для закрытия скоупа!
Фух, с большей частью теории мы закончили, теперь самое время приступить к реализации.
Реализация
Казалось бы, что все довольно просто: нужно взять NavBackStackEntry корня подграфа, создать ViewModel и все, только есть одно НО
NavBackStackEntry доступен, когда создана композиция, если еще раз посмотреть на сигнатуру функции navigation, то можно заметить, что в ней нет доступа к composable контексту. Что же делать?
Здесь стоит применить немного хитрости.
У NavBackStackEntry есть свойство destination, у которого есть свойство parent, уже у которого, в свою очередь, есть свойство route. Например, для Screen.AuthGraph.EmailInput родителем будет Screen.AuthGraph. Загвоздка в том, что route - строка, а нам необходимо найти NavBackStackEntry, но это не проблема, потому что у NavController есть функция getBackStackEntry, которая позволяет получить NavBackStackEntry, зная его route, а он у нас как раз есть.
Теперь напишем код поиска NavBackStackEntry, он выглядит следующим образом
fun NavBackStackEntry.requireParentBackStackEntry(
navController: NavController
): NavBackStackEntry {
val parentRoute = destination.parent?.route ?: error("current destination has no parent")
return navController.getBackStackEntry(parentRoute)
}
Ничего сложного
Итак, требуемый NavBackStackEntry получен, что дальше?
Теперь создадим контейнер для скоупа
class ScopeViewModel(
qualifier: Qualifier,
scopeID: ScopeID,
) : ViewModel(), KoinComponent {
val scope: Scope = getKoin().getOrCreateScope(scopeID, qualifier)
override fun onCleared() {
super.onCleared()
scope.close()
Log.d("ScopeViewModel", "ScopeViewModel closed, scope ${scope.id} closed")
}
}
Для создания самого скоупа, требуется передать Qualifier и ScopeID, чтобы koin мог создать уникальный скоуп для нас. Интерфейс KoinComponent необходим для более простого доступа к koin в рантайме(становится доступна функция getKoin()).
Также я создам фабрику для ScopeViewModel
class ScopeViewModelFactory(
private val qualifier: Qualifier,
private val scopeID: ScopeID,
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(ScopeViewModel::class.java)) {
@Suppress("UNCHECKED_CAST")
return ScopeViewModel(qualifier, scopeID) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
Фабрика необходима, потому что нам требуется передавать параметры в ScopeViewModel, иначе можно было бы обойтись стандартным механизмом.
Давайте напишем еще парочку вспомогательных функций, которые нам пригодятся дальше.
@Composable
fun rememberParentBackStackEntry(
navController: NavController,
backStackEntry: NavBackStackEntry,
): NavBackStackEntry {
return remember {
backStackEntry.requireParentBackStackEntry(navController)
}
}
rememberParentBackStackEntry нужен для сохранения найденного родительского NavBackStackEntry, иначе может случиться ситуация, когда NavController уже обновил свое состояние, но UI еще нет. Это может привести к исключению.
fun NavBackStackEntry.getScopeViewModel(
qualifier: Qualifier,
scopeId: ScopeID,
): ScopeViewModel {
return ViewModelProvider(
this,
ScopeViewModelFactory(
qualifier = qualifier,
scopeID = scopeId,
)
)[ScopeViewModel::class]
}
getScopeViewModel позволяет получить ScopeViewModel для NavBackStackEntry. Это возможно, потому что, как мы выяснили раньше, NavBackStackEntry реализует интерфейс ViewModelStoreOwner.
Прежде чем двигаться дальше, необходимо разобраться, как koin взаимодействует с Jetpack Compose.
Если посмотреть на функцию koinViewModel
@Composable
inline fun <reified T : ViewModel> koinViewModel(
qualifier: Qualifier? = null,
viewModelStoreOwner: ViewModelStoreOwner = LocalViewModelStoreOwner.current ?: error("No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"),
key: String? = null,
extras: CreationExtras = defaultExtras(viewModelStoreOwner),
scope: Scope = currentKoinScope(),
noinline parameters: ParametersDefinition? = null,
): T {
return resolveViewModel(
T::class, viewModelStoreOwner.viewModelStore, key, extras, qualifier, scope, parameters
)
}
То видно, что она принимает в себя параметр scope, который по умолчанию получается путем вызова функции currentKoinScope(). Давайте посмотрим на ее код.
@Composable
@ReadOnlyComposable
//fun currentKoinScope(): Scope = LocalKoinScope.current
fun currentKoinScope(): Scope = currentComposer.run {
try {
consume(LocalKoinScope)
} catch (_: UnknownKoinContext) {
getDefaultKoinContext().let {
warningNoContext(it)
it.scopeRegistry.rootScope
}
} catch (e: ClosedScopeException) {
getDefaultKoinContext().let {
it.logger.debug("Try to refresh scope - fallback on default context from - $e")
it.scopeRegistry.rootScope
}
}
}
Тут мы видим, что есть некий LocalKoinScope, если заглянуть в функцию KoinAndroidContext, а потом в KoinContext
@Composable
fun KoinContext(
context: Koin = KoinPlatform.getKoin(),
content: @Composable () -> Unit
) {
CompositionLocalProvider(
LocalKoinApplication provides context,
LocalKoinScope provides context.scopeRegistry.rootScope,
content = content
)
}
То видно, что по умолчанию в LocalKoinScope находится корневой скоуп. Для нас это значит, что мы можем использовать механизм Composition Local и переопределять LocalKoinScope для построения di графа.
Граф зависимостей

Вот так схематично выглядит наш будущий граф. Как его построить? Начнем с создания скоупа фичи. Для этого я написал еще одну вспомогательную функцию
/**
* this function gets or creates a [ScopeViewModel] which holds a [Scope] within the [parentBackStackEntry]
*/
@Composable
inline fun <reified T : Any> ParentScopeProvider(
parentBackStackEntry: NavBackStackEntry,
crossinline content: @Composable () -> Unit
) {
val parentScope = parentBackStackEntry
.getScopeViewModel(
qualifier = qualifier<T>(),
scopeId = T::class.java.name,
)
.scope
CompositionLocalProvider(
LocalKoinScope provides parentScope
) {
content()
}
}
ParentScopeProvider принимает на вход родительский NavBackStackEntry, получает для него ScopeViewModel и переопределяет LocalKoinScope. Самый важный момент здесь, что происходит переопределение LocalKoinScope.
Теперь модифицируем функцию Content и используем то, что написали выше.
Обновленный код навигации
@Composable
private fun Content() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = Screen.AuthGraph
) {
navigation<Screen.AuthGraph>(
startDestination = Screen.AuthGraph.EmailInput
) {
composable<Screen.AuthGraph.EmailInput> { navBackStackEntry ->
val parentNavBackStackEntry = rememberParentBackStackEntry(navController, navBackStackEntry)
ParentScopeProvider<Screen.AuthGraph>(parentNavBackStackEntry) {
/* omitted code */
}
}
composable<Screen.AuthGraph.OTP> { navBackStackEntry ->
val parentNavBackStackEntry = rememberParentBackStackEntry(navController, navBackStackEntry)
ParentScopeProvider<Screen.AuthGraph>(parentNavBackStackEntry) {
/* omitted code */
}
}
composable<Screen.AuthGraph.PinCode> { navBackStackEntry ->
val parentNavBackStackEntry = rememberParentBackStackEntry(navController, navBackStackEntry)
ParentScopeProvider<Screen.AuthGraph>(parentNavBackStackEntry) {
/* omitted code */
}
}
}
}
}
Так, с родительским скоупом разобрались, теперь переходим к созданию скоупов для самих экранов.
/**
* this function creates a [Scope] for a screen */@OptIn(KoinExperimentalAPI::class)
@Composable
inline fun <reified T : Any> ComposeScreenScopeProvider(
crossinline content: @Composable () -> Unit
) {
val parentScope = LocalKoinScope.current
val koin = getKoin()
val scope = koin.getOrCreateScope(
qualifier = qualifier<T>(),
scopeId = T::class.java.name,
)
rememberKoinScope(
parentScope = parentScope,
scope = scope,
)
CompositionLocalProvider(
LocalKoinScope provides scope
) {
content()
}
}
Для того, чтобы скоуп экрана работал правильно и имел доступ к зависимостями предка, необходимо связать текущий скоуп с предыдущим. Как вы помните, родительский скоуп находится в LocalKoinScope.
Создать скоуп можно посредством вызова функции getOrCreateScope у объекта Koin. Затем необходимо связать оба скоупа. Для этого существует функция linkTo.
CompositionKoinScopeLoader позволяет следить за композицией закрывать скоуп в нужный момент.
@OptIn(KoinInternalApi::class)
@KoinExperimentalAPI
@Composable
inline fun rememberKoinScope(
parentScope: Scope?,
scope: Scope,
): Scope {
val wrapper = remember(scope) {
parentScope?.let {
scope.linkTo(parentScope)
}
CompositionKoinScopeLoader(scope)
}
return wrapper.scope
}
Применим изменения к функции Content
Обновленный код навигации
@Composable
private fun Content() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = Screen.AuthGraph
) {
navigation<Screen.AuthGraph>(
startDestination = Screen.AuthGraph.EmailInput
) {
composable<Screen.AuthGraph.EmailInput> { navBackStackEntry ->
val parentNavBackStackEntry = rememberParentBackStackEntry(navController, navBackStackEntry)
ParentScopeProvider<Screen.AuthGraph>(parentNavBackStackEntry) {
ComposeScreenScopeProvider<Screen.AuthGraph.EmailInput> {
/* omitted code */
}
}
}
composable<Screen.AuthGraph.OTP> { navBackStackEntry ->
val parentNavBackStackEntry = rememberParentBackStackEntry(navController, navBackStackEntry)
ParentScopeProvider<Screen.AuthGraph>(parentNavBackStackEntry) {
ComposeScreenScopeProvider<Screen.AuthGraph.OTP> {
/* omitted code */
}
}
}
composable<Screen.AuthGraph.PinCode> { navBackStackEntry ->
val parentNavBackStackEntry = rememberParentBackStackEntry(navController, navBackStackEntry)
ParentScopeProvider<Screen.AuthGraph>(parentNavBackStackEntry) {
ComposeScreenScopeProvider<Screen.AuthGraph.PinCode> {
/* omitted code */
}
}
}
}
}
}
Ну и не забываем про authModule
val authModule = module {
scope<Screen.AuthGraph> {
scopedOf(::AuthManager)
}
}
Заключение
В этой статье мы рассмотрели, как можно эффективно организовать управление зависимостями в Jetpack Compose приложении с помощью скоупов Koin. Мы разобрали, как использовать особенности навигации Compose (в частности, вложенные графы и NavBackStackEntry) для создания иерархии скоупов, и как правильно управлять их жизненным циклом.
Да, предложенное решение не является идеальным, особенно с точки зрения повторяющегося кода, поэтому буду раз ознакомиться с вашими вариантами, либо с предложениями по улучшению.
Надеюсь, что данная статья вам понравилась.
Если вы дочитали до конца, то хочу пригласить вас на свои YouTube и Telegram каналы.
Спасибо!