系列文章《Kotlin Multiplatform 进阶实战》· 第 1 篇 / 共 6 篇
一、开篇:为什么你的 KMP 工程半年后就改不动了
过去一年我在多个团队的 KMP 项目里观察到同一个现象:项目伊始都信心满满,半年后再没人愿意新增 commonMain 模块。追根溯源,几乎所有"烂尾"项目的共同病灶,不是 Kotlin 语言,不是个别库,而是工程脚手架——具体说就是 Gradle 配置混乱、目录约定缺失、模块边界模糊、commonMain 与平台源码相互侵蚀。
KMP 不是"装上插件就能用"的银弹。它把"代码如何组织"这件事比纯 Android 项目提早两个抽象层级摆在你面前:模块、源集(source set)、目标(target)、变体(variant)、中间表示(KLib)。如果脚手架不先把这些问题钉死,业务代码就会反噬构建配置,二三个月后 build.gradle.kts 变成粪山。
这一篇把过去几年沉淀下来的"能撑两年"的工程骨架完整给出,所有规则都基于真实改造经验,不是 Hello World 演示。
二、整体目录结构:一个被验证的 7 层骨架
下面这套结构在我们 6 个 KMP 项目里沿用并迭代过,是经过压力测试的形态:
project-root/
├── build-logic/ // ① 约定插件仓库(convention plugins)
│ └── src/main/kotlin/
│ ├── KmpAndroidLibraryConventionPlugin.kt
│ ├── KmpIosLibraryConventionPlugin.kt
│ └── KmpFeatureConventionPlugin.kt
├── gradle/
│ └── libs.versions.toml// ② 版本目录(version catalog)
├── core/
│ ├── common/ // ③ 纯 commonMain 核心库(无平台依赖)
│ ├── platform/ // ④ 平台抽象层(expect 在这里)
│ └── network/ // ⑤ 共享网络/存储/序列化层
├── features/
│ ├── home/
│ │ ├── src/commonMain/
│ │ ├── src/androidMain/
│ │ └── src/iosMain/
│ └── detail/ ...
├── iosApp/ // ⑥ iOS 应用壳工程(Xcode workspace)
├── androidApp/ // ⑦ Android 应用壳工程
└── settings.gradle.kts
这 7 层各自承担唯一职责,下文逐一说清楚为什么这样分。
2.1 为什么把 common 与 platform 拆开
很多教程喜欢把"业务域核心 + 平台抽象"塞在同一个 commonMain 模块。初期爽,业务一来就崩——core-common 会被反复穿插 expect/actual,导致纯逻辑代码与平台桥混在一起,最后变成谁都不敢动的雷区。
我们强制这样切分:
这套规则让 core/common 保持 100% 可在 JVM 上单测,无需起 Android/iOS 模拟器,单元测试覆盖率能稳定在 85%+。
2.2 features 下放业务模块
每个特性都是独立 Gradle 子项目,至少包含 commonMain + 当前需要的平台源集。这样 CI 矩阵中"只跑变化的特性"才能真正做到。一行配置决定你能上多大的工程团队:
// features/home/build.gradle.kts
plugins {
id("kmp.feature.convention") // 由 build-logic 暴露
}
kmp.feature.convention 插件帮我们做了 7 件事:应用 KMP 插件、引入 kotlinx-coroutines、kotlinx-serialization、core-platform 依赖、注册 commonTest、打开 withJava()、显式指定 jvmTarget=17。这个约定先行于配置的设计,是后面所有 build 速度优化的前提。
三、版本目录(libs.versions.toml):消灭版本漂移
跨平台项目依赖来源分散:Compose Multiplatform、Compose Android、Compose iOS、kotlinx-coroutines、kotlinx-serialization、Ktor,每条都可能有自己的版本兼容范围。我们把所有版本集中到 libs.versions.toml:
[versions]
kotlin = "2.0.21"
agp = "8.7.2"
coroutines = "1.9.0"
serialization = "1.7.3"
ktor = "3.0.1"
compose-multiplatform = "1.7.0"
sqlDelight = "2.0.2"
[plugins]
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinCocoapods = { id = "org.jetbrains.kotlin.cocoapods", version.ref = "kotlin" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
sqlDelight = { id = "app.cash.sqldelight", version.ref = "sqlDelight" }
[libraries]
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
注意 ktor-client-okhttp 与 ktor-client-darwin 引用同一个 version,因为 Ktor 引擎是版本强绑定的——不锁定就会出现"OKHttp 引擎 3.0、Darwin 引擎 2.3"这种诡异组合。
代码里一律这样使用:
// 禁止:implementation("io.ktor:ktor-client-core:3.0.1")
// 推荐:
implementation(libs.ktor.client.core)
libs 由 Gradle 自动生成,任何直接写版本字符串的 PR 都会被 CI 拒绝——这条规则写进了 CODEOWNERS 强制。
四、build-logic:约定插件,把样板代码集中到一处
每个子模块原本要写 100~200 行 build.gradle.kts。一旦改一个 Gradle 任务(比如全项目切换到 JVM 17),你得改 50 个文件。约定插件(convention plugin)是 KMP 大型工程唯一的出路。
build-logic/ 是一个独立的 Gradle 复合构建(composite build),它提供三套插件,对应三种子工程类型:
// KmpAndroidLibraryConventionPlugin.kt
classKmpAndroidLibraryConventionPlugin : Plugin<Project> {
overridefunapply(target: Project) = with(target) {
with(pluginManager) {
apply("com.android.library")
apply("org.jetbrains.kotlin.multiplatform")
}
extensions.configure<KotlinMultiplatformExtension> {
targetHierarchy.default()
jvmToolchain(17)
applyDefaultHierarchyTemplate()
}
dependencies {
add("commonMainImplementation", libs.findLibrary("kotlinx-coroutines-core").get())
}
}
}
// KmpFeatureConventionPlugin.kt:业务特性模块专用
classKmpFeatureConventionPlugin : Plugin<Project> {
overridefunapply(target: Project) = with(target) {
with(pluginManager) {
apply("kmp.android.library.convention")
}
extensions.configure<KotlinMultiplatformExtension> {
applies("compose-multiplatform") // 暴露 UI 共享能力
targets.named("androidLibrary") {
compilations.named("main") {
// 业务模块默认 enable 严格空安全
kotlinOptions.freeCompilerArgs += "-Xexplicit-api=strict"
}
}
}
}
}
applies("compose-multiplatform") 不是手误——它来自 Compose Multiplatform 1.6+,仅作用于应用工程。我们故意写在 feature 层而非 platform 层,避免 platform 误带 UI 依赖。
这种约定的威力在迁移时完全显现:2024 年我们把 Kotlin 1.9.25 升到 2.0.21 全项目一共改 3 个文件,AGP 8.5 升 8.7.2 一共改 2 个文件,全部在 build-logic 完成。
五、源集(Source Set)分层:让"边界"长在工程结构里
commonMain 仍然只是一个目录。真正决定编译行为的是源集层级。我们固定用 4 层源集:
层级之间不允许互相跨层引用。platform 层在 KMP 官方语义里其实没有这种概念,这是我们项目里人为添加的隐喻,目的是让新成员一眼能看清"这个文件属于跨平台、属于平台桥、还是属于平台原生"。
我们还用一个自动检测的 Lint 任务守住规则:
// 在 build-logic 加一个 checkTask,禁止 platform/ 目录被 features/ 直接引用
tasks.register("checkModuleBoundary") {
dependsOn("dependencies")
doLast {
val violations = mutableListOf<String>()
rootProject.subprojects.forEach { sub ->
sub.configurations.findByName("commonMainImplementation")?.dependencies?.forEach { dep ->
if (dep.name.contains("platform") && sub.path.startsWith(":features:")) {
violations += "${sub.path} 非法依赖 ${dep.name}"
}
}
}
if (violations.isNotEmpty()) throw GradleException(violations.joinToString("\n"))
}
}
六、iOS 工程集成:用 CocoaPods 而非手动 framework
iOS 侧集成是新手最容易踩坑的地方。两种主流方式:
我们强制走 CocoaPods。关键配置:
// core/platform/build.gradle.kts
plugins {
id("kmp.android.library.convention")
kotlin("plugin.cocoapods")
}
kotlin {
cocoapods {
summary = "Shared KMP module"
version = "1.0.0"
ios.deploymentTarget = "15.0"
framework {
baseName = "SharedPlatform"
isStatic = true// 静态库,避免与宿主 App 重复符号
}
pod("SQLite", linkOnly = true) // 系统库不打包
}
}
isStatic = true 是血泪经验。动态库会让宿主 App 的 Launch Time 多 80~150ms,并且会和宿主已有的 dynamic framework 冲突。
iOS 侧 Podfile:
target 'iOSApp'do
pod 'SharedPlatform', :path => '../core/platform'
pod 'SharedNetwork', :path => '../core/network'
pod 'HomeFeature', :path => '../features/home'
end
三个 pod 各自嵌入,保证 Pod 缓存粒度到模块。
七、可观测性:从脚手架期就埋点
脚手架阶段最容易遗漏的一步:构建可观测性。半年后没人知道 core-network 编译花了 1 分 20 秒,没人知道 iosArm64 编译耗时占总时长 38%。
我们在根 build.gradle.kts 加一个 Build Analyzer:
plugins {
id("build-time-tracker")
}
gradle.projectsLoaded {
rootProject.allprojects {
tasks.configureEach {
if (name.startsWith("compile") || name.contains("Test")) {
// 给关键任务加计时器
}
}
}
}
然后把数据上报到公司 build-scan。两年后,这个埋点至少节省了 2 个工程师的"为什么构建慢了"事故。
八、避坑清单(实战总结)
下面这些坑每一个都至少让我们烧过半天以上时间,新项目务必绕开:
commonMain别用System.currentTimeMillis():它跑在 JVM target 上时返回 0 epoch,要用
kotlin.time.Clock.System.now().toEpochMilliseconds()。iOS 端别暴露 suspend fun 给 OC 调用
:Objective-C 不认识协程,必须用 Flow + 回调桥接。
SQLDelight 的
.sq文件要放在commonMain/sqldelight:放在
androidMain会导致 iOS 编译失败。Compose Multiplatform 1.6 前不要共享 UI
:1.6 之前 iOS 侧稳定性极差,1.7+ 才算能用。
别在 iOS 单元测试里引用 Ktor engine
:单元测试跑在 host 而不是模拟器,Darwin 引擎找不到。统一在
iOSTest源集注入 mock 引擎。
九、下篇预告
脚手架搭好,最关键的问题来了:业务逻辑写到哪一层为止、平台代码从哪一层介入。下篇会系统讲透 expect/actual——这一被吹爆又被滥用、被低估又被误解的跨端抽象机制。我们会给出 5 类真实平台差异的标准答案:时间、文件、网络、并发、原生能力,请关注。
参考阅读
Kotlin 官方 KMP 工程结构指引:https://kotlinlang.org/docs/multiplatform-discover-project.html
JetBrains
applyDefaultHierarchyTemplate文档Gradle Version Catalogs 规范(1.0+ 稳定)
实战项目参考:nowinandroid、KaMP Kit、Touchlab KMP Starter