系列文章《Kotlin Multiplatform 进阶实战》· 第 3 篇 / 共 6 篇
上篇解决了业务抽象如何跨端,本篇解决抽象如何被装配、被测试。
一、开篇:KMP 上的 DI 为什么首选 Koin
KMP 生态里 DI 框架不少,但能用、好用、不绑死注解处理器的,只剩 Koin 一家。
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 项目结构参考