Hello, Hackernoon!
My name is Artem, and I’m the author and host of the YouTube and Telegram channels Android Insights.
When developing modern Android applications, convenient screen navigation and effective dependency management play a crucial role. Jetpack Compose Navigation is a library from Google that provides a declarative approach to organizing navigation in apps built with Jetpack Compose. It enables you to work with navigation graphs and supports arguments, deeplinks, and state preservation.
Koin is a lightweight and user-friendly dependency injection (DI) framework for applications. It offers a declarative syntax for defining modules as well as support for scoping, which helps manage the lifecycle of dependencies.
In this article, I will discuss how to use Koin scopes in combination with Jetpack Compose Navigation to efficiently manage dependencies at different levels of the navigation graph.
Imagine we need to design the following sequence of screens:
For example, a simplified version of the code might look like this:
NavHost(
navController = rememberNavController(),
startDestination = Screen.EmailInput
) {
composable<Screen.EmailInput> {
/* omitted code */
}
composable<Screen.OTP> {
/* omitted code */
}
composable<Screen.PinCode> {
/* omitted code */
}
}
If you’re familiar with Jetpack Compose Navigation, everything looks quite standard:
NavHost
, passing it a NavController
and an entry point.composable
function.
In our project, we also use Koin, which is configured in the usual way.
Launching Koin within the Application class:
class App : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@App)
}
}
}
And calling the library function with basic settings:
KoinAndroidContext {
Content() // navigation code inside
}
Now that the basic setup is done, let’s get to the heart of the problem. Suppose we have an AuthManager
. The very same instance of AuthManager
should be available across all three screens within the authentication flow. How can we achieve this?
Using factory
is not an option here, because it would create a new instance of AuthManager
for each screen, which contradicts the requirements.
The simplest approach would be to declare AuthManager
as a singleton, for example:
val authModule = module {
singleOf(::AuthManager)
}
Yes, AuthManager
would be reused as needed, but this introduces another problem — the AuthManager
would remain in memory until the application is closed. Over the app’s lifetime, many such objects might accumulate, leading to higher resource usage. Moreover, let’s assume that after a logout and re-entry into the authentication flow, AuthManager
should be re-created for some reason.
How can we solve this? Before reading on, I invite you to try to solve this problem and share your solution in the comments.
In this specific case, scopes are an excellent fit. In short, a scope allows you to limit the lifetime of objects within the DI graph provided by Koin. If you’d like to learn more about scopes, I recommend reading the official documentation.
It’s also worth isolating the navigation flow into a separate subgraph. I’ll explain the reasons for this shortly. If you’re not familiar with nested navigation graphs, please refer to the documentation as a starting point.
To create a subgraph, we use the navigation
function. The navigation setup code then looks like this:
@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 */
}
}
}
}
There aren’t many changes: our entry point for the NavHost
is now Screen.AuthGraph
. Also, as mentioned, I’m using the navigation
function to create a nested navigation graph. Like NavHost
, the navigation
function requires a mandatory startDestination
parameter to set the entry point (in fact, you can navigate to any screen within the subgraph; startDestination
merely acts as a fallback).
Now let’s delve a bit deeper into how Compose Navigation works. If you look at the composable function, you’ll see that its lambda parameter content
receives a NavBackStackEntry. The NavBackStackEntry
implements interfaces such as LifecycleOwner
, ViewModelStoreOwner
, HasDefaultViewModelProviderFactory
, and SavedStateRegistryOwner
. In other words, each composable
has its own lifecycle, can save and restore its state, and—most importantly for us—can create ViewModels and clear them properly upon exit. If you continue exploring the library, you’ll notice that the navigation
function creates its own NavDestination
. This means that when navigating to one of the screens in the nested navigation graph, not one but two NavBackStackEntry
objects are created: one for the navigation
function (the root of the subgraph) and one for the specific screen within the subgraph. For us, this is great news!
But what does this mean for our task? Let’s break it down.
First, let’s step aside and talk about Koin scopes. As mentioned, a scope limits the lifetime of objects contained within it. Creating a scope is straightforward, but the challenge lies in closing it at the right moment — not too late to avoid memory leaks, and not too early so that dependencies remain available throughout the screen’s lifetime.
As we saw earlier, the root of the graph has its own NavBackStackEntry
, which can create and manage ViewModels. We can use this ViewModel as a container for the scope. An added benefit is that the ViewModel has an onCleared
method, which is an ideal place to clean up resources — in our case, to close the scope!
Phew, that covers most of the theory. Now let’s dive into the implementation.
At first glance, it might seem simple: take the NavBackStackEntry
of the subgraph’s root, create a ViewModel, and you’re done. But there’s one catch.
The NavBackStackEntry
is available once the composition is created. If you look at the signature of the navigation
function again, you’ll notice that it doesn’t have access to the composable context. So, what do we do?
A little trickery is required here.
The NavBackStackEntry
has a destination
property, which in turn has a parent
property that contains a route
. For example, for Screen.AuthGraph.EmailInput
, the parent is Screen.AuthGraph
. The catch is that route
is a string, but we need to obtain the corresponding NavBackStackEntry
. Fortunately, the NavController
provides a function getBackStackEntry
that allows us to get a NavBackStackEntry
by its route — which we have.
Here’s the code to obtain the parent NavBackStackEntry
:
fun NavBackStackEntry.requireParentBackStackEntry(
navController: NavController
): NavBackStackEntry {
val parentRoute = destination.parent?.route ?: error("current destination has no parent")
return navController.getBackStackEntry(parentRoute)
}
Nothing complicated.
So, once we have the required NavBackStackEntry
, what’s next?
Now, let’s create a container for the scope:
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")
}
}
To create the scope, we pass in a Qualifier
and a ScopeID
so that Koin can create a unique scope for us. The KoinComponent
interface is used to simplify access to Koin at runtime (it makes the getKoin()
function available).
I’ll also create a factory for the 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")
}
}
This factory is necessary because we need to pass parameters to ScopeViewModel
; otherwise, we could rely on the standard mechanism.
Let’s add a couple of helper functions that we’ll use later.
@Composable
fun rememberParentBackStackEntry(
navController: NavController,
backStackEntry: NavBackStackEntry,
): NavBackStackEntry {
return remember {
backStackEntry.requireParentBackStackEntry(navController)
}
}
The rememberParentBackStackEntry
function is used to cache the found parent NavBackStackEntry
. Without this, there might be a situation where the NavController
has already updated its state but the UI has not yet, leading to an exception.
fun NavBackStackEntry.getScopeViewModel(
qualifier: Qualifier,
scopeId: ScopeID,
): ScopeViewModel {
return ViewModelProvider(
this,
ScopeViewModelFactory(
qualifier = qualifier,
scopeID = scopeId,
)
)[ScopeViewModel::class]
}
The getScopeViewModel
function allows us to obtain a ScopeViewModel
for a given NavBackStackEntry
. This is possible because, as we discovered earlier, NavBackStackEntry
implements the ViewModelStoreOwner
interface.
Before moving on, let’s understand how Koin interacts with Jetpack Compose.
If you take a look at the koinViewModel
function:
@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
)
}
You can see that it accepts a scope
parameter, which by default is obtained by calling currentKoinScope()
. Let’s take a look at its code:
@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
}
}
}
Here we see that there is a LocalKoinScope
. If you check the KoinAndroidContext
function and then KoinContext
:
@Composable
fun KoinContext(
context: Koin = KoinPlatform.getKoin(),
content: @Composable () -> Unit
) {
CompositionLocalProvider(
LocalKoinApplication provides context,
LocalKoinScope provides context.scopeRegistry.rootScope,
content = content
)
}
By default, LocalKoinScope
holds the root scope. For us, this means we can use the Composition Local mechanism to override LocalKoinScope
when constructing our DI graph.
This is a schematic representation of our future graph. How do we build it? Let’s start by creating a feature scope. For this, I wrote another helper function:
/**
* 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()
}
}
The ParentScopeProvider
takes a parent NavBackStackEntry
, obtains its ScopeViewModel
, and overrides LocalKoinScope
. The key point here is that we are reassigning LocalKoinScope
.
Now, let’s modify the Content
function to use the helper we just created:
@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 */
}
}
}
}
}
Now that we’ve handled the parent scope, let’s move on to creating scopes for the individual screens.
/**
* 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()
}
}
For the screen scope to work correctly and have access to the parent dependencies, we need to link the current scope with the parent. Remember, the parent scope is available in LocalKoinScope
. We create the scope using getOrCreateScope
from the Koin instance, and then link the two scopes using the linkTo
function. The CompositionKoinScopeLoader
ensures that the scope is closed at the correct moment in the composition lifecycle.
@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
}
Let’s apply these changes to the Content
function:
@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 */
}
}
}
}
}
}
And let’s not forget about the authModule
:
val authModule = module {
scope<Screen.AuthGraph> {
scopedOf(::AuthManager)
}
}
In this article, we explored how to effectively organize dependency management in a Jetpack Compose application using Koin scopes. We examined how to leverage the features of Compose Navigation—particularly nested graphs and NavBackStackEntry
—to create a hierarchy of scopes and properly manage their lifecycles.
Yes, the proposed solution is not perfect, especially regarding some repetitive code, so I look forward to reading your alternative approaches or suggestions for improvement.
I hope you enjoyed this article.
If you’ve read all the way to the end, I invite you to join my YouTube and Telegram channels.
Thank you!