KC Blog

Compose Multiplatform in Action: Using Koin for Dependency Injection in CMP

7 min read
CrossPlatform#CMP#Kotlin

Introduction

Compose Multiplatform (CMP)

Hi everyone,

Today we'll continue exploring CMP applications

by using Koin for dependency injection

to reduce coupling between code components

making them easier to maintain

Android developers might be familiar with Dagger2 or Hilt

but currently, CMP officially supports mainly Koin

Using other DI solutions might require your own workarounds

so today we'll focus on integrating Koin into CMP

What is Dependency Injection?

In software development,

high coupling refers to excessive dependencies between modules or components in code

making code difficult to maintain and test

To solve high coupling issues,

we can use Dependency Injection (DI) to reduce coupling between code components

Dependency Injection is a design pattern

that allows us to inject dependencies into objects during their lifecycle

rather than creating instances inside the objects

making code more flexible and testable

What it looks like without Dependency Injection

Let's look at an example from our StateFlow ViewModel from a few days ago

Here we need to manually create the SettingViewModel instance and initialize multiple classes inside

fun NavGraphBuilder.routeSettingScreen(
    navController: NavHostController,
) {

    composable(ElegantJapaneseScreen.Setting.name) {
        val viewModel = SettingViewModel()
        SettingScreen(navController, viewModel)
    }
}

class SettingViewModel() {
   lateinit var a :A
   lateinit var b :B
   lateinit var c :C
   lateinit var e :E
   lateinit var f :F
   
   init{
     a = A()
     b = B()
     ...
     ...
     ...
   }
}

What it looks like with Koin

With DI, you don't need to create instances yourself

fun NavGraphBuilder.routeSettingScreen(
    navController: NavHostController,
) {

    composable(ElegantJapaneseScreen.Setting.name) {
        val viewModel = koinViewModel<SettingViewModel>()
        SettingScreen(navController, viewModel)
    }
}

Does this really make a difference?

At first glance, it seems like we're just replacing instance creation with koinViewModel injection

But looking closer,

if your SettingViewModel constructor becomes complex,

you'd need to create each required instance one by one

For example:

val a = A()
val b = B()
val c = C()
val d = D()
val e = E()
val f = F()
...

SettingViewModel(a,b,c,d,e,f,...)

This is where DI's advantages become clear

It eliminates the need to create instances manually

making your code shorter and cleaner

Another advantage is

when you need to modify your code,

it becomes more flexible

You just need to change the injected module code

The original ViewModel code can remain unchanged

only modifying the logic in the module implementation

Additionally,

if you need to use a class in multiple places,

dependency injection reduces the steps to create instances.

class SettingViewModel(private val a: A, private val b: B,...) {
 ...
}

When using it, it's still simply:

val viewModel = koinViewModel<SettingViewModel>()

Implementing Koin in CMP

Add the corresponding libraries and versions to lib.versions.toml

First,

we need to add Koin dependencies and version numbers to the lib.versions.toml file

After completion,

remember to sync gradle

[versions]
koin = "3.5.0"
koinCompose = "1.2.0-Beta4"

[libraries]
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-compose-viewmodel= { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koinCompose" }
koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koinCompose" }
Import the libraries into build.gradle.kts
  • As usual, these are shared, so add the following to commonMain:
    sourceSets {
        commonMain.dependencies {
            implementation(libs.koin.core)
            implementation(libs.koin.compose.viewmodel)
            implementation(libs.koin.compose)
        }
    }
Configuring Koin for multiple platforms in CMP

(Remember our discussion on entry points for different platforms?

If you've forgotten, go back and review it)

  • First, let's add an expect platformModule in commonMain

    Since target platforms might have different implementations

    If CMP doesn't support something yet

    We can use platformModule to implement and inject separately

    allowing different target platform content to be injected into commonMain

    For example: persistent storage dataStore, localized storage RoomDatabase Builder, etc.

First, in commonMain, declare an expect for platformModule & appModule() which helps us place or extend more modules

// in ../commonMain
expect val platformModule: Module

fun appModule() =
    listOf(platformModule,...)

Then implement platformModule for androidMain & iosMain

androidMain:

// in ../androidMain

actual val platformModule: Module = module {
    /** Add some target class that you would like to get it instance*/
    // for example : single { dataStore(get<Context>()) }

}

iosMain:

// in ../iosMain

actual val platformModule: Module = module {
    /** Add some target class that you would like to get it instance*/
    // for example : single { dataStore() }

}
  • Now we need to configure Koin in CMP target platforms

    First, let's add Koin to androidMain

// in ../androidMain/../MainActivity.kt

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val androidModule = module {
            single<Context> { this@MainActivity.applicationContext }
        }

        startKoin {
            modules(appModule() + androidModule)
        }

        setContent {
            App()
        }
    }
}

Key code explanation:

  1. androidModule: Because both Android and iOS platforms might have their own rules

    For example: Android has Context but iOS doesn't

    We can create an androidModule to configure a Context instance in Koin

  2. startKoin: Then we can bring in the androidModule and the previously created appModule()

    Based on the previous code

    Our previously declared platformModule will also be included

  • Now let's configure Koin for iosMain
// in ../iosMain/../MainViewController.kt

fun MainViewController() = ComposeUIViewController {

    val uiViewController = LocalUIViewController.current
    val iosModule = module {
        single<UIViewControllerWrapper> { UIViewControllerWrapperImpl(uiViewController) }
    }

    KoinApplication(application = {
        modules(appModule() + iosModule)
    }) {
        App()
    }
}

interface UIViewControllerWrapper {
    fun getViewController(): UIViewController
}

class UIViewControllerWrapperImpl(private val viewController: UIViewController) : UIViewControllerWrapper {
    override fun getViewController() = viewController
}

Key code explanation:

  1. iosModule: Similar to android, iOS also has unique elements like UIViewController

    If needed, we can create an iosModule for it

  2. KoinApplication: Then we can bring in the iosModule and our previously created appModule()

    Based on the previous code

    Our previously declared platformModule will also be included

Implementing shared modules with Koin in CMP

The previous section mainly covered development methods across multiple target platforms

Now, we can finally start developing shared modules

Creating modules in Koin is also relatively intuitive

First, let's see how to define Koin modules in commonMain

// in ../commonMain

expect val platformModule: Module

fun appModule() =
    listOf(platformModule, utilModule, viewModelModule ...)

val utilModule = module {
    single { A() }
    single { B() }
    single { C() }
}

val viewModelModule = module {
    single { SettingViewModel(get(), get(), get()) }
}

Key code explanation:

  1. single { A() }: Defines a singleton instance of A

    Whenever A is injected, Koin will return the same instance

    single { B() } and single { C() }: Similarly defines singleton instances of B and C

  2. single { SettingViewModel(get(), get(), get()) }: Defines a singleton instance of SettingViewModel, and injects instances of A, B, C from the Koin container using the get() method.

  3. appModule(): We defined this earlier, now we just add the new modules.

  4. This module primarily uses the module{} provided in the Koin library

    The core concept is to create the instances you want

    Once startKoin is configured

    You can have them injected for you through Koin

Actually using Koin injection in CMP

Now we can happily free ourselves from complex manual instance creation

fun NavGraphBuilder.routeSettingScreen(
    navController: NavHostController,
) {

    composable(ElegantJapaneseScreen.Setting.name) {
        val viewModel = koinViewModel<SettingViewModel>()
        SettingScreen(navController, viewModel)
    }
}

Summary

  • Koin can be used in Compose Multiplatform
  • With proper configuration, Koin can be used flexibly on different platforms
  • Using Koin can greatly simplify dependency management in cross-platform projects
  • Choose the appropriate initialization method based on project size and complexity