系列文章《Android Gradle 构建体系深度实践(续)》第 3 篇
推荐阅读时间:10 分钟
前言
前面两篇聊了远程构建缓存和 CI 分片。这一篇进入最后一个场景:Gradle 与 Kotlin Multiplatform(KMP)的协同构建。
KMP 工程不是“一个 Android 项目多挂了一个 iOS target”那么简单。它的编译模型、缓存机制、产物形态都和纯 Android 工程差别不小。团队如果同时维护 Android 和 KMP 模块,构建时间很容易被 Kotlin/Native 编译链路拖垮。
这篇围绕四个问题展开:
KMP 的编译模型与 source set 组织方式
Kotlin/Native 编译缓存怎么配
XCFramework 打包的任务链和常见坑
怎么写一套同时服务 Android 和 KMP 的 Convention Plugin
一、KMP 的编译模型:为什么它比纯 Android 慢
1.1 多 target 意味着多轮编译
一个典型的 KMP 共享模块会声明多个 target:
kotlin {
androidTarget()
iosX64()
iosArm64()
iosSimulatorArm64()
}
每个 target 都要跑一轮完整的编译、链接、打包。Android 模块通常只有 debug/release 两个 variant,而 KMP 模块可能同时有 androidDebug、androidRelease、iosArm64、iosX64、iosSimulatorArm64 等多个编译目标。
这意味着:
同样的 Kotlin 代码要编译多次
iOS target 需要 Kotlin/Native 编译器,启动和编译开销都比 JVM 大
链接阶段依赖 Xcode 工具链,受 macOS 环境限制
1.2 Source set 的层级关系
KMP 用 source set 来组织跨平台代码:
commonMain
├── androidMain
├── iosMain
│ ├── iosArm64Main
│ ├── iosX64Main
│ └── iosSimulatorArm64Main
commonMain 放平台无关代码,androidMain 和 iosMain 放平台实现,iosArm64Main 等放架构特定代码。Gradle 在编译时会按层级合并源码目录。
如果层级设计混乱,比如把大量代码放到 iosArm64Main 而不是 iosMain,会导致每个架构都重复实现,编译时间也会上涨。
1.3 产物形态决定打包方式
KMP 不同 target 输出的产物不一样:
iOS 侧如果只引入单个 framework,上架时会因为缺少真机架构被拒。所以正式发布必须输出 XCFramework。而日常开发如果每次都打 XCFramework,M 系列 Mac 上 SimulatorArm64 其实就够了,没必要把 arm64/x64 都跑一遍。
二、Kotlin/Native 编译缓存
2.1 Native 编译缓存是什么
Kotlin/Native 支持把编译好的库缓存成 *.klib 和 *.cache 文件,后续编译可以直接复用。这和 Gradle Build Cache 是两个层面的机制:
Gradle 缓存解决“Task 要不要重新跑”;Kotlin/Native 缓存解决“编译器内部能不能复用中间结果”。
2.2 配置缓存策略
在 gradle.properties 里可以控制 Kotlin/Native 缓存行为:
# 开启静态缓存,提升 Native 编译速度
kotlin.native.cacheKind=static
# 启用增量编译
kotlin.incremental.native=true
# 控制 Native 编译并行度
kotlin.native.enableParallelExecution=true
# 避免每次构建都重新下载 Kotlin/Native 工具链
kotlin.native.distribution.downloadFromMaven=true
kotlin.native.cacheKind 有几个选项:
none:不启用缓存,每次重新编译dynamic:动态缓存,链接时开销小但运行时稍慢static:静态缓存,编译耗时更短,是推荐的默认选项
2.3 和 Gradle 远程缓存叠加
KMP 模块的 compileKotlinIosArm64、linkPodDebugFrameworkIosArm64、assembleXCFramework 等 Task 同样支持 Gradle Build Cache。只要这些 Task 的输入输出声明规范,CI 上编译完的 klib 和 framework 就能共享给开发机。
实际配置时,建议在 settings.gradle.kts 里同时开启本地和远程缓存:
buildCache {
local { isEnabled = true }
remote(HttpBuildCache::class.java) {
url = uri("https://gradle-cache.example.com/cache/")
isEnabled = true
isPush = providers.environmentVariable("CI").isPresent
}
}
注意:KMP 的某些 Task(比如调用 Xcode 的 podInstall)可能依赖本地环境,默认不会被缓存。需要检查 Build Scan 里的缓存命中情况。
2.4 Kotlin/Native 工具链缓存
Kotlin/Native 编译器和相关依赖默认下载到 ~/.konan。CI 里如果每次都在新机器上跑,下载工具链可能占 5-10 分钟。建议把它缓存到 CI 的依赖缓存里:
cache:
key: ${CI_COMMIT_REF_SLUG}-konan
paths:
- ~/.konan
另外,如果团队使用固定版本的 Kotlin,可以把 ~/.konan/dependencies 做成一个基础 Docker 镜像或 macOS runner 快照,进一步减少准备时间。
三、XCFramework 打包的任务链
3.1 从 KMP 到 iOS 产物
KMP iOS target 默认输出 *.framework。如果要同时支持真机和模拟器,需要把它们合并成 XCFramework。AGP/KMP 插件提供了 assembleXCFramework Task,但背后的任务链很长:
compileKotlinIosArm64
linkDebugFrameworkIosArm64
compileKotlinIosX64
linkDebugFrameworkIosX64
compileKotlinIosSimulatorArm64
linkDebugFrameworkIosSimulatorArm64
assembleXCFramework
每一步都依赖前一步的输出。只要 common 代码没变,但某个架构的 expect/actual 实现变了,就只有那个架构需要重编。
3.2 一个常见的坑:签名导致缓存失效
iOS framework 在打包时如果嵌入了签名信息,每次构建的签名结果可能不同(时间戳、证书序列号),导致 Gradle 认为输出变了,无法命中缓存。
解决方法是在 build.gradle.kts 里关闭 framework 的自动签名,把签名放到 iOS 主工程里做:
kotlin {
iosX64 {
binaries.framework {
baseName = "Shared"
isStatic = true
// 避免在 KMP 侧签名
embedBitcode(BitcodeEmbeddingMode.DISABLE)
}
}
// iosArm64、iosSimulatorArm64 同理
}
3.3 产物体积控制
XCFramework 会同时打包多个架构,体积容易膨胀。可以在 CI 里按场景输出不同产物:
模拟器-only 的构建能省掉真机架构的编译,开发调试时快很多。
四、多平台 Convention Plugin
4.1 统一 Android 和 KMP 模块的约定
如果项目里既有 Android 模块,也有 KMP 共享模块,维护两套 build 脚本会很乱。可以用 Convention Plugin 把公共约定收敛起来。
目录结构:
build-logic/
└── convention/
└── src/main/kotlin/
├── AndroidLibraryConventionPlugin.kt
└── KotlinMultiplatformConventionPlugin.kt
KotlinMultiplatformConventionPlugin.kt:
class KotlinMultiplatformConventionPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
with(pluginManager) {
apply("org.jetbrains.kotlin.multiplatform")
}
extensions.configure<KotlinMultiplatformExtension> {
androidTarget()
iosX64()
iosArm64()
iosSimulatorArm64()
applyDefaultHierarchyTemplate()
sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
}
}
}
}
}
这样每个 KMP 模块只需要一句话:
plugins {
id("myapp.kotlin.multiplatform")
}
4.2 统一版本号和编译参数
把 compileSdk、minSdk、Kotlin/JVM target 等集中到 Convention Plugin 里,避免每个模块各自写:
androidTarget {
compilations.all {
compileTaskProvider.configure {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
}
}
对于 iOS target,可以统一 baseName、isStatic、embedBitcode 等配置,减少复制粘贴。
4.3 跨平台的 dependency catalog
KMP 模块经常需要同时给 Android 和 iOS source set 加依赖。用 libs.versions.toml 统一管理版本号,再通过 Convention Plugin 自动注入公共依赖,能大幅降低版本冲突概率。
[versions]
kotlin = "2.0.21"
coroutines = "1.9.0"
ktor = "2.3.12"
[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" }
4.4 KMP 测试 source set 也建议统一
KMP 的测试同样分层。commonTest 放平台无关断言,androidUnitTest 和 iosTest 放平台相关测试。Convention Plugin 可以默认注入 kotlin-test 和 coroutines-test:
sourceSets {
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
}
androidUnitTest.dependencies {
implementation(libs.junit)
implementation(libs.androidx.core.testing)
}
}
这样业务模块只需要写测试代码,不用再纠结该引哪个依赖。
五、实战:一个 KMP 混合工程的 CI 配置
5.1 分阶段编译
不要把 Android 和 iOS 所有 target 放在一个 job 里跑。建议拆分:
stages:
- kmp-android
- kmp-ios
- kmp-publish
kmp-android:
script:
- ./gradlew :shared:assembleRelease :shared:testReleaseUnitTest --build-cache
kmp-ios:
script:
- ./gradlew :shared:assembleSharedReleaseXCFramework --build-cache
kmp-publish:
needs: [kmp-android, kmp-ios]
script:
- ./gradlew :shared:publishToMavenLocal
kmp-android 跑在 Linux 构建机上,kmp-ios 跑在 macOS 构建机上,各自利用远程缓存。
5.2 macOS 构建机的特殊注意
iOS 编译绕不开 macOS,而 macOS CI 机器通常比 Linux 贵。几条省钱经验:
只在必要时跑完整 XCFramework,日常 PR 只跑
compileKotlinIos*做语法检查用
iosSimulatorArm64替代iosX64(M 系列芯片上更快)固定 Xcode 版本,避免每次升级导致缓存全部失效
在 macOS 机器上同样配置 Gradle Build Cache,减少重复编译
5.4 多仓库场景下的缓存策略
如果 KMP 共享库以 Maven 产物形式发布,Android 主仓库通过依赖引用,那么 Android 仓库的 CI 只需要拉取已发布的 aar。这时缓存策略可以分层:
KMP 仓库:重点缓存 Kotlin/Native 编译和 XCFramework 打包
Android 仓库:重点缓存 aar 依赖解析和主工程打包
两个仓库共用同一个 Gradle Build Cache Node 时,要注意 namespace 隔离。可以在 URL 后加路径前缀,或者在 Node 上为不同仓库创建不同的用户凭证。
5.3 一个缓存未命中的排查案例
某天团队发现 assembleSharedReleaseXCFramework 在开发机上总是未命中缓存。查 Build Scan 发现 linkReleaseFrameworkIosArm64 的输入里包含一个绝对路径:
/Users/ci-bot/.konan/dependencies/clang-llvm-.../bin/clang
这个路径在 CI 机器和开发机上不同,导致 key 不一致。修复方式是在 gradle.properties 里固定 konan 缓存目录:
kotlin.native.home=${user.home}/.konan
并在 Task 声明里把相关路径标记为 @Classpath 而不是 @InputFile,让 Gradle 只关心内容而不是路径。
六、系列总结
这三篇文章从缓存、CI 分片聊到 KMP 协同构建,覆盖了大型 Android/KMP 工程里最常见的构建性能问题:
远程构建缓存让“编译一次、处处复用”成为可能
CI 分片把单点长任务拆成并行小任务
测试分层与 Flaky Test 治理让 CI 结果可信
KMP 协同构建把上述能力延伸到跨平台场景
构建优化没有银弹,核心思路始终是:先测量,再拆分,再缓存,最后固化到工程规范里。
参考阅读
Kotlin Multiplatform 官方构建指南
Kotlin/Native 编译缓存配置
Gradle Convention Plugin 最佳实践