Administrator
发布于 2026-08-23 / 201 阅读
0
0

Koin 在 KMP 中的依赖注入与模块化:让 ViewModel 跨端共用

系列文章《Kotlin Multiplatform 进阶实战》· 第 3 篇 / 共 6 篇
上篇解决了业务抽象如何跨端,本篇解决抽象如何被装配、被测试。

一、开篇:KMP 上的 DI 为什么首选 Koin

KMP 生态里 DI 框架不少,但能用、好用、不绑死注解处理器的,只剩 Koin 一家。

框架

KMP 友好度

注解处理开销

学习曲线

推荐度

Hilt

弱,依赖 AGP KSP,仅 Android

高

中

不推荐

Dagger

弱,无 KMP 支持

极高

高

不推荐

Koin

纯 Kotlin 运行时

零

低

强烈推荐

Kodein

支持

零

低

团队少

kotlin-inject

支持

中

中

较新

Koin 的核心是运行时解析,没有 APT/KSP 阶段。这对 KMP 极其重要:注解处理器在 iOS 端几乎都跑不通,编译期 DI 在 KMP 上一旦涉及 Android 专属注解就直接挂掉。

这一篇给出一套经过 6 个项目验证的 Koin 落地模板,把模块拆分、平台初始化、测试替换、ViewModel 跨端共用全部说清楚。

二、安装与依赖管理

版本目录里集中管理依赖:

[versions]
koin = "4.0.0"

[libraries]
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-test = { module = "io.insert-koin:koin-test", 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" }

每个公共业务模块的构建脚本只需要一行:

dependencies {
    implementation(libs.koin.core)
}

iOS 端用 Koin 4.0 之后可以用 koinCore 的 pod。Android 端可直接用 koin-android 启动。

三、模块拆分:3 层 Koin Module

Koin 的精髓是模块化。我们按工程层级建立 3 类 module:

core/common        -> appModule(业务无关的纯逻辑)
core/platform      -> platformModule(平台抽象接口的默认实现)
features/home      -> homeModule(特性内部用例)
features/detail    -> detailModule

3.1 appModule:跨端纯逻辑

放在 core/common 的 commonMain 里:

val appModule = module {
    single<Clock> { Clock.System }
    single<Json> {
        Json {
            ignoreUnknownKeys = true
            isLenient = true
        }
    }
    factory<DateFormatter> { DefaultDateFormatter(get()) }
}

注意 single 与 factory 的选择:纯函数对象用 factory、带缓存的用 single,不要为了性能全用 single。

3.2 platformModule:expect 与 actual 的注入点

val platformModule = module {
    single<AppLogger> { createAppLogger() }
    single<KeyValueStore> { createKvStore(name = "default") }
    single<HttpClient> { createHttpClient() }
}

expectfuncorePlatformModule(): Module

Android 端的 actual 实现:

actualfuncorePlatformModule(): Module = module {
    single<BluetoothProvider> { AndroidBluetoothProvider(get()) }
    singleOf(::AndroidDatabaseDriverFactory)
}

iOS 端的 actual 实现:

actualfuncorePlatformModule(): Module = module {
    single<BluetoothProvider> { IosBluetoothProvider() }
    singleOf(::IosDatabaseDriverFactory)
}

expect fun corePlatformModule 是关键——它让 common 层只用一个引用就把平台能力融进来,App 启动时不会再写 if/else。

3.3 特性模块:用 Koin 的命名空间

每个特性模块在自己的文件里定义,并按业务子域再切分:

val homeModule = module {
    viewModel { HomeViewModel(get(), get(), get()) }
    factory<HomeRepository> { HomeRepositoryImpl(get(), get(), get()) }
    factory { GetHomeUseCase(get()) }
}

val detailModule = module {
    viewModel { DetailViewModel(get()) }
    factory<DetailRepository> { DetailRepositoryImpl(get()) }
}

viewModel 大括号是 Koin 4.0 提供的特殊 DSL,会自动处理 ViewModel 生命周期,避免我们手写 ViewModelProvider.Factory 的样板。

四、App 端初始化:一次性把所有 Module 拼起来

4.1 Android 启动

classMyApplication : Application() {
overridefunonCreate() {
super.onCreate()
        startKoin {
            androidLogger(if (BuildConfig.DEBUG) Level.INFO else Level.NONE)
            androidContext(this@MyApplication)
            modules(
                appModule,
                platformModule,
                corePlatformModule(),
                homeModule,
                detailModule,
            )
        }
    }
}

AndroidManifest 里 android:name 指向 MyApplication 即可。

4.2 iOS 启动

iOS 不存在 Application class,需要在 SwiftUI App 的 init 里主动启动。我们在 iosMain 暴露一个 doInitKoin 函数,Swift 侧直接调用:

fundoInitKoin() {
    startKoin {
        modules(
            appModule,
            platformModule,
            corePlatformModule(),
            homeModule,
            detailModule,
        )
    }
}

Swift 侧 AppDelegate 或 App.init 里调用 doInitKoin 即可。注意 iOS 端 Koin 4.0 有 swift 友好写法,如果你的 App 是 SwiftUI,可以使用 KoinApplication.companion 的方式注入到 State 中。

五、ViewModel 共享:跨端的关键收益

KMP 上最大的价值不在跨端写业务,而在跨端写同一份 ViewModel。Koin 让这件事变得很轻。

classHomeViewModel(
privateval getHome: GetHomeUseCase,
privateval logger: AppLogger,
privateval analytics: Analytics,
) : ViewModel() {

val state: StateFlow<HomeState> = getHome()
        .map { HomeState.Loaded(it) as HomeState }
        .catch { emit(HomeState.Error(it.message ?: "unknown")) }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), HomeState.Loading)
}

5.1 Android 端:拿到 instance 就行

classHomeFragment : Fragment() {
privateval viewModel: HomeViewModel by viewModel()
}

5.2 iOS 端:用 ObservableObject 适配层接入

iOS 没有 ViewModel 这个概念。我们暴露一个 iOS 友好的 ObservableObject 适配层,内部订阅 Flow:

classHomeObservable(
privateval vm: HomeViewModel,
) : ObservableObject() {

var state: HomeState = HomeState.Loading
get() = field

init {
        vm.state
            .onEach { state = it }
            .launchIn(vm.viewModelScope)
    }
}

不管哪种方式,核心是同一份 HomeViewModel 在两端都被注入——这就是 Koin 给 KMP 带来的最大便利:DI 配置写一次,跨端共享。

六、测试中的 Koin:用 koin-test 做替换

KMP 的测试是必须先解决的难题。koin-test 提供 KoinTestRule:

classHomeViewModelTest : KoinTest {

@get:Rule
val koinRule = KoinTestRule.create {
        modules(
            module {
                factory<GetHomeUseCase> { FakeGetHomeUseCase() }
                single<AppLogger> { NoOpLogger() }
            },
            homeModule,
        )
    }

@Test
funloadHomeSuccess() = runTest {
val vm = koin.get<HomeViewModel>()
// 断言 state 最终变为 Loaded
    }
}

关键技术点:

  • FakeGetHomeUseCase 只在测试 module 里注册,不会污染生产 module。

  • NoOpLogger 覆盖 appModule 的默认实现,但只对当前测试生效。

  • koinRule.close 自动清理,全局共享 state 不会跨用例泄漏。

七、五个常见坑(必读)

下面五个坑每一个都能卡团队 1 到 3 天。

7.1 模块注册顺序:依赖方向要从下往上

modules(
    platformModule,
    corePlatformModule(),
    appModule,
    homeModule,
)

顺序反了,Koin 启动时找不到依赖会延迟到第一次解析才抛异常。这种 bug 在 CI 上极难定位。

7.2 Koin 启动早于 viewModels 被调用

在 Android 工程里,startKoin 必须在 super.onCreate 之后调用,但要在第一个 Activity 之前。我们统一塞到 Application.onCreate:

classMyApplication : Application() {
overridefunonCreate() {
super.onCreate()
        startKoin { /* modules... */ }
    }
}

iOS 端在 init 里调用,遇到 SwiftUI 异步初始化需要用 Task 包一层。

7.3 iOS 单元测试无法启动 Koin 完整模块

iOS 单元测试跑在 host 而不是模拟器,不要在 doInitKoin 里注册依赖 NSUserDefaults 的单例。可以做一个 testMode 标记:

fundoInitKoin(testMode: Boolean = false) {
    startKoin {
        modules(
if (testMode) listOf(testAppModule) else listOf(platformModule, homeModule)
        )
    }
}

7.4 频繁 factory 被误用为 single

factory 每次解析新建实例,single 全应用唯一。一个用错会引发极其隐晦的状态泄漏。规则:只有用 single 显式声明的依赖才是单例,否则一律 factory。

7.5 Koin 的 Kotlin 2.0 K2 兼容性

Kotlin 2.0 之前要加 allopen 插件。Koin 4.0 已经兼容 Kotlin 2.0,不需要额外配置。升级 Kotlin 2.0 时务必把 Koin 升到 4.0 以上,否则会有 KoinScopeComponent 找不到的诡异错误。

八、模块边界的黄金法则

把整套规则收成一句话:

期望只做接口定义,注入只做实现装配,业务只做使用抽象。

三者各司其职,严禁一个文件同时做三件事——这是 KMP 工程能撑两年的纪律底线。

九、下篇预告

到目前为止我们的 shared 已经能 hold 一个中等 App。但网络层、序列化、缓存、错误处理这些横切关注点怎么落地?下篇会拆解 KMP 上的网络与存储共享层——从 Ktor 配置到 SQLDelight,从多平台 Json 到重试策略,从身份令牌刷新到分布式日志。请持续关注。


参考阅读

  • Koin 官方 KMP 指南

  • Koin 4.0 release notes:Kotlin 2.0 与 SwiftUI 支持

  • 测试集成 koin-test 模块文档

  • Touchlab 的 KaMP Kit 项目结构参考


评论