Administrator
发布于 2026-08-30 / 106 阅读
0
0

Gradle 与 KMP 协同构建:Kotlin/Native 编译缓存、XCFramework 任务链与多平台 Convention Plugin

系列文章《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 输出的产物不一样:

target

产物

iOS/Android 使用方式

androidTarget

aar / jar

和普通 Android 库一样依赖

iosArm64

framework

Xcode 引入

iosX64

framework

Xcode 引入模拟器架构

iosSimulatorArm64

framework

Apple Silicon 模拟器

iOS 侧如果只引入单个 framework,上架时会因为缺少真机架构被拒。所以正式发布必须输出 XCFramework。而日常开发如果每次都打 XCFramework,M 系列 Mac 上 SimulatorArm64 其实就够了,没必要把 arm64/x64 都跑一遍。

二、Kotlin/Native 编译缓存

2.1 Native 编译缓存是什么

Kotlin/Native 支持把编译好的库缓存成 *.klib 和 *.cache 文件,后续编译可以直接复用。这和 Gradle Build Cache 是两个层面的机制:

缓存层级

产物

控制方式

Gradle Build Cache

Task 输出(klib、aar、jar)

org.gradle.caching=true

Kotlin/Native Cache

编译后的 bitcode/cache

kotlin.native.cacheKind

Xcode DerivedData

iOS 产物缓存

Xcode 自身管理

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 里按场景输出不同产物:

场景

输出

命令

日常开发

仅模拟器 framework

./gradlew linkDebugFrameworkIosSimulatorArm64

PR 验证

完整 XCFramework

./gradlew assembleSharedXCFramework

发版

Release XCFramework

./gradlew assembleSharedReleaseXCFramework

模拟器-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 最佳实践


评论