CMP for Mobile Native Developers: Dependency Injection

Santiago Mattiauda
9 min readSep 16, 2024

--

Dependency Injection (DI) is a crucial design pattern for improving modularity, testability, and maintainability of applications. In the Compose Multiplatform (CMP) environment, which allows sharing UI code between Android, iOS, and other platforms, implementing DI presents unique challenges due to the need to maximize code reuse and handle differences between platforms. This article explores the main DI frameworks available for CMP, as well as alternatives that can adapt to various development needs.

CMP for Mobile Native Developers: Series

In this series of articles, we will explore the following aspects of Compose Multiplatform:

Why use Dependency Injection in Compose Multiplatform?

Modularity and Code Reuse

DI allows for separation of concerns and the creation of modular components, essential in a cross-platform environment. By injecting dependencies, the same components can be reused across different platforms without modifying the code.

Testability

With DI, dependencies can be substituted with mock or test implementations, facilitating the creation of unit tests and improving test coverage.

Flexible Configuration

DI allows for centralized management of dependencies, simplifying the configuration and initialization of our applications.

Dependency Injection Frameworks in Compose Multiplatform

There are several frameworks for Dependency Injection, and some of them have support for Kotlin Multiplatform. However, as of today, given that Compose Multiplatform is still increasing its adoption, some of these frameworks have support for Compose Multiplatform.

The frameworks we saw in the previous series “KMP for Mobile Native Developer” have support for Compose Multiplatform

Next, we will see its application in Compose Multiplatform.

Koin

Koin is a lightweight and intuitive DI framework, widely used in the Kotlin ecosystem. Its simplicity and ease of configuration make it ideal for CMP projects.

Koin Compose Multiplatform vs Koin Android Jetpack Compose

Since mid-2024, Compose applications can be developed with the Koin Multiplatform API. All APIs are identical between Koin Jetpack Compose (koin-androidx-compose) and Koin Compose Multiplatform (koin-compose).

Which Koin package to use for Compose?

For a pure Android application that uses only the Android Jetpack Compose API, the following packages are used:

  • koin-androidx-compose to access the base Compose API and the Compose ViewModel API
  • koin-androidx-compose-navigation for the Compose ViewModel API with navigation API integration

For a Compose Multiplatform application, we will use the following packages:

  • koin-compose for the base Compose API
  • koin-compose-viewmodel for the Compose ViewModel API
  • koin-compose-viewmodel-navigation for the Compose ViewModel API with navigation API integration

Koin Configuration

In our TOML we will add the following dependencies

[versions]
koin = "4.0.0-RC2"

[libraries]
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" }
koin-composeViewModel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" }

And in the build.gradle.kts file we will add the corresponding dependencies in the different sourceSets

sourceSets {

androidMain.dependencies {
implementation(libs.koin.android)
}

commonMain.dependencies {
api(libs.koin.core)
api(libs.koin.compose)
api(libs.koin.composeViewModel)
}
}

Initializing Koin in a Compose Application with KoinApplication

As in a Compose Multiplatform project we aim to contain all our code in the common module, Koin support for Compose Multiplatform introduces a new composable to initialize Koin components.

fun koinConfiguration() = koinApplication {
// your configuration & modules here
modules(applicationModules())
}

@Composable
fun MainApplication() {
KoinApplication(application = ::koinConfiguration) {
RootScreen()
}
}

The KoinApplication function will handle the start and stop of the Koin context in relation to the Compose context lifecycle. This function initiates and terminates a new Koin application context.

In an Android application, KoinApplication will manage any need to stop or restart the Koin context in response to configuration changes or activity closures. This replaces the use of the classic startKoin function in the application.

Injecting in a Composable

While writing your composable function, you get access to the following Koin API: koinInject(), to inject an instance from the Koin container.

For a module that declares a CharacterRepository component:

val dataModule = module {
single { CharacterRepository() }
// or constructor DSL
singleOf(::CharacterRepository)
}

We can obtain its instance in the following way:

@Composable
fun App() {
val repository = koinInject<CharacterRepository>()
}

To align with Jetpack Compose’s functional approach, the best practice is to inject instances directly into function parameters. This allows for a default implementation with Koin while maintaining the flexibility to inject instances as needed.

@Composable
fun App(repository: CharacterRepository = koinInject()) {

}

ViewModels in Compose Multiplatform

Similar to how you have access to classic single/factory instances, you get access to the following Koin API for ViewModel:

  • koinViewModel() to inject a ViewModel instance
  • koinNavViewModel() to inject a ViewModel instance with navigation argument data (if you're using the Navigation API)

For a module that declares a HomeViewModel component:

module {
viewModel {
HomeViewModel(
getAllCharacters = get(),
refreshCharacters = get(),
addToFavorite = get(),
removeFromFavorite = get()
)
}
// or constructor DSL
viewModelOf(::HomeViewModel)
}

We can obtain its instance in the following way, using koinViewModel

@Composable
fun HomeScreenRoute() {
val viewModel = koinViewModel<HomeViewModel>()
HomeScreenContent(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 16.dp),
viewModel = viewModel,
onClick = {},
onFavorite = viewModel::addToFavorites,
)
}

We can inject the instance directly into the parameters of the composable function:

@Composable
fun HomeScreenContent(
modifier: Modifier = Modifier,
viewModel: HomeViewModel = koinViewModel(),
onClick: (Character) -> Unit = {},
onFavorite: (Character) -> Unit = {},
){
// composable implementation....
}

For more detailed information on using Koin in Compose Multiplatform, it is recommended to consult the official documentation, which provides additional examples and in-depth explanations of features and implementation best practices.

You can find the complete example of the article using Koin in the following link

Let’s look at the next dependency injection framework, Kodein.

Kodein-DI

Kodein-DI is a multiplatform dependency injection framework that stands out for its flexibility and wide range of configuration options. It is ideal for projects that require more advanced dependency management.

While Kodein-DI integrates effectively with Compose Multiplatform and Kotlin Multiplatform projects, its structured approach makes it particularly useful in larger-scale and more complex applications. However, it’s important to consider that it has a steeper learning curve and requires a more elaborate initial configuration compared to Koin.

Let’s look at the same examples we used with Koin.

Kodein Configuration

Before starting to use Kodein, it’s necessary to configure its dependencies

[versions]
kodeinDiFrameworkCompose = "7.22.0"

[libraries]
kodein-di = { module = "org.kodein.di:kodein-di", version.ref = "kodeinDiFrameworkCompose" }
kodein-di-framework-compose = { module = "org.kodein.di:kodein-di-framework-compose", version.ref = "kodeinDiFrameworkCompose" }

and declare these in our build.gradle.kts file

sourceSets {   
commonMain.dependencies {
implementation(libs.kodein.di)
implementation(libs.kodein.di.framework.compose)
}
}

Once we have the dependencies configured, we’ll need to initialize the dependency provider.

Compose provides a way to expose objects and instances to the hierarchy of Composable functions without passing arguments through each composable function. This method is called CompositionLocal. This is what Kodein-DI uses under the hood to help you access your DI containers transparently.

Sharing a DI reference within a Composable tree

You can easily use Kodein-DI to expose a DI container within a composable tree by using the withDI function in other functions. These functions accept a DI constructor, a DI reference, or DI modules.

val appModule = DI.Module("AppModule"){
//dependency definitions
}

@Composable
fun MainApplication() = withDI(appModule) {
RootScreen()
}

As we saw in the previous example, for a module that declares a CharacterRepository component with Kodein, we will use the following form

val appModule = DI.Module("AppModule") {
bindSingleton { CharacterRepository() }
}

We can obtain its instance within a composable in the following way

@Composable
fun App() {
val di = localDI()
val repository: CharacterRepository by di.instance()
}

ViewModels with Kodein

Kodein does not provide support for ViewModels in Compose Multiplatform. According to its documentation, it only supports Android, which is not ideal for managing the lifecycle of ViewModels in Composables across platforms.

If we are using ViewModel, we can implement it as follows

val appModule = DI.Module("AppModule") {
bindFactory<Unit, FavoritesViewModel> {
FavoritesViewModel(characterRepository = instance())
}
}

Define a factory for creating our ViewModel and then use localDI to obtain instances,

@Composable
fun FavoriteRoute() {
val di = localDI()
val viewModel: FavoritesViewModel by di.instance()

FavoritesScreen(
viewModel = viewModel,
onFavoriteClick = viewModel::addToFavorites,
)
}

As you can see, this instance does not survive recomposition, so we could use remember.

For more detailed information on using Kodein-DI in Compose Multiplatform, it is recommended to consult the official documentation, which provides additional examples and in-depth explanations of features and implementation best practices.

You can find the complete example in the following link where Kodein is used for Dependency Injection.

Alternatives to DI Frameworks

There are alternatives to DI frameworks that can be useful in certain scenarios:

  • Manual Injection: This involves explicitly passing dependencies to constructors or methods. It’s simple and offers total control, ideal for small applications or prototypes. However, it can become difficult to maintain in large projects.
  • Service Locator: A centralized component provides the necessary dependencies. It’s easy to implement and use, but can lead to strong coupling between components, reducing modularity and making unit testing more difficult.
  • Factory Pattern: Allows creating object instances through specialized factories. It’s useful for creating objects with specific dependencies, but offers less modularity and flexibility than a complete DI framework.

Taking into account the points mentioned above and adding Inversion of Control, we could manage dependencies in our project in a similar way.

In our example, we needed to instantiate FavoriteViewModel, which required a CharacterRepository. The key point for good dependency management is to separate the components that are agnostic to the framework—in this case, Compose Multiplatform—and manage them separately. On the other hand, for components that require closer interaction with the framework, such as ViewModels (due to their lifecycle), it's recommended to use the mechanisms suggested by the framework itself.

Let’s look at the agnostic components. For this, I’m going to create an object to manage the instances.

@ThreadLocal
@OptIn(InternalCoroutinesApi::class)
object SharedModule : SynchronizedObject() {

private var db: CharactersDatabase? = null
private var remoteDataSource: CharacterNetworkDataSource? = null
private var localDataSource: CharacterLocalDataSource? = null


private var repository: CharacterRepository? = null

/*------ data layer -----*/
private fun remoteDataSource(): CharacterNetworkDataSource {
synchronized(this) {
return remoteDataSource
?: KtorCharacterNetworkDataSource(CoreModule.httpClient()).also {
remoteDataSource = it
}
}
}

private fun localDataSource(): CharacterLocalDataSource {
synchronized(this) {
return localDataSource ?: SQLDelightCharacterLocalDataSource(db()).also {
localDataSource = it
}
}
}

fun db(): CharactersDatabase {
synchronized(this) {
return db ?: createDatabase(sqlDriver).also { db = it }
}
}

fun repository(): CharacterRepository {
synchronized(this) {
return repository ?: CharacterRepository(localDataSource(), remoteDataSource()).also {
repository = it
}
}
}
}

The idea is to have a reference to the object in case we need singleton-like behavior.

Then, to create our ViewModels, we’ll do something similar

object AppModule {
fun createFavoritesViewModel(): FavoritesViewModel {
return FavoritesViewModel(
characterRepository = SharedModule.repository()
)
}
}

Once the components are defined, in our case the ViewModel provided through a factory, we will create a DiContainer using CompositionLocal. This will provide us with a centralized entry point for our dependencies.

val LocalAppModule = staticCompositionLocalOf<AppModule> {
error("LocalAppModule not provided")
}

@Composable
fun DiContainer(content: @Composable () -> Unit) {
CompositionLocalProvider(LocalAppModule provides AppModule, content)
}

Once we have registered our AppModule in the composables tree, we can use the factory for our ViewModels

import androidx.lifecycle.viewmodel.compose.viewModel
import com.santimattius.kmp.skeleton.di.LocalAppModule

@Composable
fun FavoriteRoute() {
val appModule = LocalAppModule.current
val viewModel: FavoritesViewModel = viewModel { appModule.createFavoritesViewModel() }
FavoritesScreen(
viewModel = viewModel,
onFavoriteClick = viewModel::addToFavorites,
)
}

where to create the ViewModel we will use the viewModel function specific to Compose Multiplatform.

Each approach has its advantages and disadvantages, and the choice will depend on the specific needs and complexity of the project.

Conclusion

Implementing Dependency Injection (DI) in Compose Multiplatform is essential for achieving a clean, modular, and easily testable architecture. Frameworks like Koin or Kodein-DI provide robust and flexible solutions for complex projects, while approaches such as manual injection or the Service Locator pattern may be more appropriate for simple projects or prototypes. The choice of the ideal DI method will depend on several factors, including project complexity, team experience with the tools, and target platforms. A proper implementation of DI can significantly improve the quality, maintainability, and scalability of an application, whether it’s multiplatform or not.

--

--