Dan Tech Academy

Koin trong Kotlin Multiplatform: inject dependency gọn, không cần sinh code

Viết module Koin 4.2 cho commonMain, khởi động trên Android và iOS, thay dependency khi test và bắt lỗi thiếu định nghĩa bằng verify(). Code đã build trên Kotlin 2.4.

Koin làm gì trong một app KMP

Dependency injection (DI) nghĩa là một class nhận những thứ nó cần qua constructor, thay vì tự tạo ra. ScoreRepository nhận một ScoreStore, ScoreViewModel nhận một ScoreRepository. Ai đó vẫn phải tạo các object này và nối chúng với nhau. Koin là thư viện làm phần đó, và nó chạy được trong commonMain của Kotlin Multiplatform (KMP).

Lúc viết bài, bản Koin mới nhất trên Maven Central là 4.2.2. Code trong bài mình build và test trên Koin 4.2.2 với Kotlin 2.4.10.

Khai báo dependency

Thêm Koin vào commonMain:

// shared/build.gradle.kts
kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation("io.insert-koin:koin-core:4.2.2")
            implementation("io.insert-koin:koin-compose-viewmodel:4.2.2")
        }
    }
}

koin-compose-viewmodel chỉ cần khi bạn lấy ViewModel trong Compose bằng koinViewModel().

Viết module

Một module Koin là danh sách "cần type này thì tạo thế nào". Ví dụ nhỏ, một repository điểm số và một ViewModel:

// commonMain
package com.example.koin

import androidx.lifecycle.ViewModel
import org.koin.core.context.startKoin
import org.koin.core.module.Module
import org.koin.core.module.dsl.singleOf
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.KoinAppDeclaration
import org.koin.dsl.module

interface ScoreStore {
    fun save(score: Int)
    fun best(): Int
}

class InMemoryScoreStore : ScoreStore {
    private var bestScore = 0

    override fun save(score: Int) {
        if (score > bestScore) bestScore = score
    }

    override fun best(): Int = bestScore
}

class ScoreRepository(private val store: ScoreStore) {
    fun submit(score: Int) = store.save(score)
    fun best(): Int = store.best()
}

class ScoreViewModel(private val repository: ScoreRepository) : ViewModel() {
    fun best(): Int = repository.best()
}

expect fun platformModule(): Module

val appModule = module {
    includes(platformModule())
    singleOf(::ScoreRepository)
    viewModelOf(::ScoreViewModel)
}

fun initKoin(config: KoinAppDeclaration? = null) {
    startKoin {
        config?.invoke(this)
        modules(appModule)
    }
}

Vài thứ đáng để ý:

  • single tạo một instance dùng chung cho cả app. factory (không có trong ví dụ) tạo instance mới mỗi lần được hỏi. viewModel/viewModelOf tạo ViewModel gắn với vòng đời của màn hình.
  • singleOf(::ScoreRepository) đọc constructor và tự gọi get() cho từng tham số. Viết tay thì là single { ScoreRepository(get()) }. Cả hai đều là Kotlin thường, không cần annotation processor hay bước sinh code.
  • includes(platformModule()) gộp phần riêng của từng nền tảng vào. Mỗi nền tảng viết actual fun platformModule() trả về module của nó, ví dụ bản Android đọc SharedPreferences, bản iOS đọc NSUserDefaults. Trong ví dụ, cả hai đều dùng InMemoryScoreStore cho gọn:
// androidMain và iosMain
actual fun platformModule(): Module = module {
    single<ScoreStore> { InMemoryScoreStore() }
}

Khởi động Koin trên từng nền tảng

Gọi initKoin() một lần, ở chỗ app khởi động. Trên Android là Application.onCreate():

class ScoreApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        initKoin()
    }
}

Trên iOS, bạn gọi hàm đó từ Swift khi app khởi động. Hàm top-level của Kotlin hiện ra trong Swift dưới một class mang tên file, nên nếu initKoin nằm trong Koin.kt thì Swift gọi KoinKt.doInitKoin(). Tiền tố do là do Kotlin/Native tự thêm: Objective-C dành tên bắt đầu bằng init cho initializer.

Trong Compose, lấy ViewModel bằng koinViewModel():

@Composable
fun ScoreScreen(viewModel: ScoreViewModel = koinViewModel()) {
    Text("Best: ${viewModel.best()}")
}

Thay dependency khi test

Vì ScoreRepository chỉ biết interface ScoreStore, test có thể đưa vào một bản giả. Với Koin, bạn nạp module thật rồi ghi đè đúng một định nghĩa:

// commonTest
class FakeScoreStore(private val fixedBest: Int) : ScoreStore {
    override fun save(score: Int) = Unit
    override fun best(): Int = fixedBest
}

class ScoreRepositoryKoinTest {
    @AfterTest
    fun tearDown() {
        stopKoin()
    }

    @Test
    fun repositoryReadsTheStoreKoinGaveIt() {
        val koin = startKoin {
            allowOverride(true)
            modules(appModule, module { single<ScoreStore> { FakeScoreStore(fixedBest = 42) } })
        }.koin

        assertEquals(42, koin.get<ScoreRepository>().best())
    }
}

stopKoin() sau mỗi test là bắt buộc. startKoin dùng một instance toàn cục, gọi lần thứ hai khi lần đầu chưa dừng sẽ báo lỗi.

Bắt lỗi thiếu định nghĩa trước khi chạy app

Với DSL như trên, nếu bạn quên khai báo ScoreStore, app vẫn build được và chỉ crash khi Koin cần tạo ScoreRepository. Koin có verify() trong koin-test để bắt lỗi này bằng một unit test. Theo tài liệu Koin, verify() chỉ chạy trên JVM, nên trong KMP bạn đặt test này ở source set host test của Android:

// androidHostTest, cần io.insert-koin:koin-test
@OptIn(KoinExperimentalAPI::class)
class AppModuleCheck {
    @Test
    fun appModuleIsComplete() {
        appModule.verify()
    }
}

Mình thử xóa dòng single<ScoreStore> khỏi module Android, test này fail với MissingKoinDefinitionException. Trên Koin 4.2.2, verify() vẫn được đánh dấu @KoinExperimentalAPI, nên cần dòng @OptIn ở trên.

Koin 4.2 còn có một Compiler Plugin kiểm tra toàn bộ đồ thị dependency lúc build, và tài liệu Koin khuyên dùng nó cho project mới trên Kotlin 2.3.20 trở lên. Plugin này là tùy chọn. DSL ở trên chạy được mà không cần nó, và bạn có thể thêm plugin sau mà không phải viết lại module. Nếu bạn từng dùng annotation của Koin qua KSP: bộ xử lý KSP (koin-ksp-compiler) đã bị deprecated, còn bản thân các annotation thì không.

Học tiếp

Khóa Kotlin Mobile 101 dạy inject dependency bằng Koin trên một app Kotlin Multiplatform hoàn chỉnh, kèm unit test chạy trong vài giây.

Đọc blog thì vui. Đi theo lộ trình thì đến đích.

Kotlin Android Roadmap sắp xếp các bài Android Mastery, OOP và Design Patterns thành 5 cấp độ - từ dòng Kotlin đầu tiên đến app lên store.

Kèm bài viết mới mỗi tuần. Huỷ bất cứ lúc nào.