引言
KMP(Kotlin Multiplatform)解决的是一类特定问题:Android 与 iOS 的业务逻辑(网络、数据模型、缓存、校验规则、状态机)重复实现了两遍,两边的 bug 与行为差异也随之翻倍。它的答案是把这部分下沉到一个共享模块,编译成 Android 的 AAR/JAR 与 iOS 的 framework,UI 仍然各写各的。
与 Flutter 这类「共享 UI」的方案相比,KMP 的取舍点完全不同:Flutter 用自绘引擎换取一致性,代价是与原生控件的融合、无障碍与平台新特性都要等框架适配;KMP 把共享边界放在逻辑层,UI 是原生控件,代价是「逻辑共享、UI 双写」,以及需要处理两个平台的语言互操作。
真正需要设计的是三件事:源集怎么切(哪些代码放 commonMain、哪些下沉到平台)、边界怎么定(expect/actual 用在什么地方)、产物怎么对齐(Kotlin 版本、Compose 版本、Gradle 元数据与 iOS 侧依赖)。本文按这个顺序展开。
目录
- 工程结构与源集划分
- 依赖与版本对齐
- expect/actual 机制
- 共享业务逻辑层
- 网络与序列化共享
- 平台差异处理策略
- 与 iOS 的 Swift 互操作
- Compose Multiplatform 共享 UI
- 构建产物发布
- 测试与调试
- 渐进式迁移策略
1. 工程结构与源集划分
一个典型的 KMP 工程有三类模块:共享模块(shared)、Android 应用模块(androidApp)与 iOS 工程(iosApp)。共享模块内部按源集(source set)分层。
// shared/build.gradle.kts
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary)
alias(libs.plugins.kotlinSerialization)
}
kotlin {
androidTarget { compilations.all { kotlinOptions.jvmTarget = "17" } }
listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach { target ->
target.binaries.framework { baseName = "Shared"; isStatic = true }
}
applyDefaultHierarchyTemplate() // Kotlin 1.9.20+ 会自动创建 iosMain
sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.ktor.client.core)
}
androidMain.dependencies { implementation(libs.ktor.client.okhttp) }
iosMain.dependencies { implementation(libs.ktor.client.darwin) }
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
源集的层级关系是「越往上越共享」:
| 源集 | 可见范围 | 放什么 |
|---|---|---|
commonMain | 所有目标 | 业务逻辑、数据模型、接口定义 |
androidMain | 仅 Android | Context 相关实现、OkHttp 引擎 |
iosMain | 仅 iOS | NSURLSession 实现、Darwin 引擎 |
nativeMain | 所有 Native 目标 | iOS/macOS 共用的 Native 代码 |
commonTest | 所有目标测试 | 纯逻辑测试 |
iosSimulatorArm64Test | 模拟器测试 | 需要真机 API 的测试 |
iosX64(Intel 模拟器)、iosArm64(真机)、iosSimulatorArm64(Apple Silicon 模拟器)三个目标缺一不可:只声明 iosArm64 会导致 M 系列芯片的 Mac 上无法跑模拟器测试。Kotlin 1.9.20 起 applyDefaultHierarchyTemplate() 会自动生成 iosMain 与 appleMain 中间层,不必手写 dependsOn。
一条实践规则:commonMain 里不允许出现任何平台类型(Context、UIViewController、NSObject)。一旦出现,说明这段逻辑本该下沉到平台源集,硬塞进共享层只会让两个平台都别扭。
2. 依赖与版本对齐
KMP 项目最容易出问题的不是代码,而是版本组合。三类版本必须相互兼容:
| 组件 | 约束 | 常见冲突 |
|---|---|---|
| Kotlin 插件 | 决定 Native 编译器与 klib 格式 | 与 AGP 版本不匹配 |
| Compose 编译器 | Kotlin 2.0 起随 Kotlin 版本走 | 老写法需手动指定 composeCompiler 版本 |
| Compose Multiplatform | 与 Kotlin 版本强绑定 | 升级 Kotlin 后 CMP 未跟进 |
| AGP | 与 Gradle 版本绑定 | androidTarget 要求 AGP 7.4+ |
统一用 Version Catalog 管理,并把「必须一起升级」的项放在一起:
# gradle/libs.versions.toml
[versions]
kotlin = "2.0.21"
agp = "8.7.3"
coroutines = "1.9.0"
serialization = "1.7.3"
ktor = "2.3.12"
composeMultiplatform = "1.7.3"
[plugins]
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
Kotlin 2.0 之后 Compose 编译器随 Kotlin 发布,因此 org.jetbrains.kotlin.plugin.compose 的版本必须与 kotlin 完全一致;而 Compose Multiplatform 的版本则需要单独选一个支持该 Kotlin 版本的发行版。这两个版本号不能凭感觉升,升级前先看 CMP 的发布说明。
另一个隐性依赖是 Gradle 元数据:共享模块发布到 Maven 后,Android 侧消费的是 -android 变体,iOS 侧消费的是 framework。如果发布时没开 maven-publish 的变体元数据,消费方会拿到错误的产物。
3. expect/actual 机制
expect/actual 是 KMP 的平台适配原语:在 commonMain 声明「有这么一个东西」,在平台源集给出实现。
// commonMain
expect class PlatformLogger() { fun log(tag: String, message: String) }
expect val platformName: String
expect fun currentTimeMillis(): Long
// androidMain
actual class PlatformLogger {
actual fun log(tag: String, message: String) = Log.d(tag, message)
}
actual val platformName: String = "Android ${Build.VERSION.SDK_INT}"
actual fun currentTimeMillis(): Long = System.currentTimeMillis()
// iosMain
actual class PlatformLogger {
actual fun log(tag: String, message: String) = NSLog("$tag: $message")
}
actual val platformName: String = UIDevice.currentDevice.systemName()
actual fun currentTimeMillis(): Long = (NSDate().timeIntervalSince1970 * 1000).toLong()
三条硬性规则:
expect声明不能有函数体,也不能是private;actual必须与声明在签名上完全一致。- 一个
expect必须在每个目标都有actual,漏一个就编译失败(这正是它的价值:平台适配不会被遗忘)。 expect class不能有actual之外的构造函数逻辑,构造参数会「传染」到所有平台。
3.1 什么时候不该用 expect/actual
expect/actual 是编译期契约,测试时无法替换实现。对于「行为有平台差异但可以注入」的场景,用接口 + 依赖注入更灵活:
// 更好:接口在 commonMain,实现按平台提供
interface Clock { fun nowMillis(): Long }
class FakeClock(private var now: Long = 0) : Clock { // 测试可用
override fun nowMillis() = now
}
判断标准很简单:如果这个能力在测试里需要被替换,就用接口;如果它只是一个不可替换的平台事实(如 platformName),就用 expect/actual。 混用两者是 KMP 代码可测性下降的主要原因。
4. 共享业务逻辑层
共享层最值得放的是「与 UI 无关、与平台无关」的部分,按收益从高到低排列:
| 内容 | 共享收益 | 注意事项 |
|---|---|---|
| 数据模型与 DTO | 高 | 加 @Serializable 即可 |
| 校验规则、格式化 | 高 | 纯函数,测试成本极低 |
| Repository / UseCase | 高 | 依赖抽象而非平台实现 |
| 网络客户端 | 中高 | Ktor 可直接共享 |
| 本地缓存 | 中 | SQLDelight 或 Room KMP |
| 状态机与 ViewModel | 中 | 注意与平台生命周期解耦 |
一个共享的 Repository 长这样,它只依赖 commonMain 里定义的接口:
class ArticleRepository(
private val api: ArticleApi,
private val dao: ArticleDao,
private val io: CoroutineDispatcher,
) {
fun observeFeed(): Flow<List<Article>> = dao.observeAll()
suspend fun refresh(): Result<Unit> = withContext(io) {
runCatching { dao.upsertAll(api.list().map(Article::toEntity)) }
}
}
协程在共享层是完全可用的(kotlinx-coroutines-core 有 Native 与 JVM 两套实现),但有一个跨平台差异要处理:Dispatchers.IO 是 JVM 侧的概念,Native 目标上的可用性随版本变化。稳妥做法是在 commonMain 里用 expect 暴露一个调度器:
// commonMain
expect val ioDispatcher: CoroutineDispatcher
// androidMain: actual val ioDispatcher = Dispatchers.IO
// iosMain: actual val ioDispatcher = Dispatchers.Default // 或自建基于 NSOperationQueue 的实现
并发模型方面,Kotlin 1.7.20 之后默认启用新的内存管理器,freeze 与 InvalidMutabilityException 已成为历史,但共享可变状态依然需要同步:跨线程共享的对象应设计成不可变,或者用 atomicfu 的原子类型与 Mutex 保护。协程的取消与异常传播规则在 Native 上与 JVM 一致,具体语义见 Kotlin 协程与结构化并发
。
5. 网络与序列化共享
网络层是 KMP 收益最直观的部分:一套 Ktor 客户端 + 一套序列化配置,两端复用。引擎按平台切换(Android 用 OkHttp,iOS 用 Darwin),上层代码完全一致。
// commonMain
class HttpClientFactory(private val engine: HttpClientEngine) {
fun create(): HttpClient = HttpClient(engine) {
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
install(HttpTimeout) { connectTimeoutMillis = 10_000; requestTimeoutMillis = 30_000 }
defaultRequest { url("https://api.example.com/") }
}
}
interface ArticleApi {
@GET("articles") suspend fun list(): List<ArticleDto>
}
@Serializable
data class ArticleDto(val id: Long, val title: String, val publishedAt: String)
序列化用 kotlinx-serialization,它在多平台上完全一致,没有 Gson 那类反射问题,因此不必为它编写额外的 keep 规则(混淆与收缩的取舍可参考 Android 性能优化与启动加速
)。与 Android 侧已有的 Retrofit 方案相比,迁移策略通常是「新增接口走 Ktor,存量接口保持 Retrofit」,避免一次性重写整个网络层;Retrofit 的拦截器与超时策略可对照 Android 网络层与 Retrofit 实践
逐项映射到 Ktor 的插件体系。
ArticleApi 这类接口在 Android 侧可以由 Retrofit 实现、在共享层由 Ktor 实现——这正是把接口放在 commonMain 的价值:共享的是契约,实现可以按平台挑最合适的工具。
6. 平台差异处理策略
平台差异有三种处理方式,选择取决于差异的「面积」:
| 策略 | 适用 | 代价 |
|---|---|---|
expect/actual | 单个函数或属性的差异 | 测试不可替换 |
| 接口 + 注入 | 行为差异、需要测试 | 需要一层抽象 |
| 平台源集独立实现 | 整块功能差异大 | 代码不共享 |
典型场景的推荐做法:
- 日志:用
expect/actual包一层,Android 走Log、iOS 走NSLog;但要限制在边界,业务代码只用共享层的Logger接口。 - 时间与 UUID:
kotlinx-datetime与kotlin.uuid.Uuid(Kotlin 2.0.20 起实验性提供)已能覆盖大部分需求,不必自己写expect。 - 文件路径:差异大,用接口注入,Android 传
Context.filesDir,iOS 传NSDocumentDirectory。 - 持久化:SQLDelight 提供跨平台 SQL 与生成代码;Room 自 2.7 起支持 KMP,若 Android 侧已用 Room,优先考虑统一到 Room 以减少心智负担。
一个反直觉但重要的原则:不要为了共享而共享。推送、权限、生物识别、后台任务这些与平台深度耦合的能力,强行抽象成共享接口往往得到一个「两个平台都不好用」的中间层。把它们留在平台侧,通过接口暴露给共享层调用即可。
7. 与 iOS 的 Swift 互操作
共享模块编译成 framework 后,Kotlin 代码在 Swift 里以 Objective-C 兼容的形式暴露。这带来一系列命名与类型的映射:
| Kotlin | Swift 侧 | 注意 |
|---|---|---|
suspend fun | completionHandler 回调 | 不能直接用 await,需 SKIE 或手写包装 |
Flow<T> | 不可直接使用 | 需要 callbackFlow 包装或 SKIE |
Int / Boolean | KotlinInt / KotlinBoolean | 可空基本类型会装箱 |
sealed class | 类层次 + is 判断 | Swift 无 when 穷尽检查 |
object | shared 单例访问器 | 命名可能冲突 |
| 泛型 | 受限 | 复杂泛型无法桥接 |
suspend 函数是最常被抱怨的一点。Kotlin 的挂起函数在 ObjC 头文件里变成带 completionHandler 的方法,Swift 侧要写回调;引入 Touchlab 的 SKIE 插件后,suspend 会变成原生的 async 函数、Flow 会变成 AsyncSequence、sealed class 会变成 Swift 的 enum,互操作体验接近原生。
// 未使用 SKIE:只能写回调
repository.refresh { result, error in if let result = result { /* ... */ } }
// 使用 SKIE:suspend 变成原生 async
let result = try await repository.refresh()
framework 的两个构建选项值得注意:isStatic = true(静态库,省去动态链接开销,启动更快)与 export(...)(把依赖的类型暴露给 Swift 侧,只在需要直接引用时才加)。isStatic = true 是推荐默认值:动态 framework 会增加启动时的动态链接开销,而 KMP 场景下并没有多个 framework 共享同一份 Kotlin 运行时的需求。export 只在 Swift 侧需要直接引用被依赖库的类型时才加,滥用会让头文件膨胀。
Swift 侧的接入方式有两种:CocoaPods(kotlin("native.cocoapods") 插件 + Podfile)或直接引用 framework/SPM。新项目推荐 SPM + XCFramework,构建链路更简单;存量 iOS 工程用 CocoaPods 更平滑。Swift 语言层面的细节(async/await、协议与泛型)可参考 Swift 协议与泛型
。
8. Compose Multiplatform 共享 UI
如果团队希望连 UI 也共享,Compose Multiplatform 提供了路径。它的 API 与 Android 的 Jetpack Compose 基本一致,额外提供多平台入口与资源系统。
// composeApp/build.gradle.kts
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
}
kotlin {
sourceSets {
commonMain.dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.components.resources)
}
}
}
// commonMain
@Composable
fun App() {
MaterialTheme {
var query by remember { mutableStateOf("") }
ArticleList(query = query, onQueryChange = { query = it })
}
}
// iosMain:暴露给 Swift
fun MainViewController(): UIViewController = ComposeUIViewController { App() }
iOS 侧在 SwiftUI 里用 UIViewControllerRepresentable 包一层即可。Compose Multiplatform 的 iOS 支持从 1.7 进入 Beta、1.8 起稳定,因此现在做共享 UI 已不再是实验性选择;但要评估两点:一是 iOS 上的滚动与手势手感与原生 UIScrollView 仍有细微差别,二是新系统特性(如新版导航手势、控件外观)需要等框架适配。
共享 UI 的收益与风险都集中在「一致性」上:一套 UI 省掉双写,但也意味着两端都只能使用 Compose 的能力边界。多数团队的折中做法是「共享设计系统与业务组件,平台特有的页面(相机、地图、支付)用原生实现」——这需要 Compose Multiplatform 与原生 View 混排的能力,复杂度不低,建议从单一页面开始试点。
9. 构建产物发布
共享模块的产物有三种分发方式:
| 方式 | 适用 | 说明 |
|---|---|---|
| 源码依赖(同一仓库) | 单体仓库 | 最简单,Gradle 直接 implementation(project(":shared")) |
| Maven 仓库 | 多仓库、需要版本化 | maven-publish + 私有 Nexus/Artifactory |
| XCFramework + SPM | iOS 团队独立仓库 | assembleXCFramework 产出二进制 |
发布到 Maven 时注意变体对齐:Android 侧消费 -android 变体,Kotlin/Native 侧消费 klib,发布配置需要开启 withSourcesJar() 并确保 Gradle Module Metadata 被上传,否则消费方会解析出错误的依赖。常用命令是 ./gradlew :shared:publishToMavenLocal(本地验证产物)、:shared:assembleXCFramework(产出 iOS 可用二进制)与 :shared:iosSimulatorArm64Test(模拟器跑共享测试)。
版本对齐是发布环节最容易出问题的地方:共享模块的 Kotlin 版本必须 ≥ 消费方的 Kotlin 版本(klib 格式向后兼容,向前不保证),因此升级顺序永远是「共享模块 → 消费方」。把共享模块纳入与主工程同一套 Version Catalog 管理,并在 CI 里对「Kotlin 版本不一致」做校验,能避免大部分「本地能编、CI 挂掉」的问题。模块拆分的整体思路与 Android 多模块架构与 Gradle 优化
中的依赖分层一致:共享模块应当只依赖 commonMain 允许的库,绝不能反向依赖 Android 侧的模块。
10. 测试与调试
commonTest 里的测试会在所有目标上执行一遍,这是 KMP 的隐藏收益:同一份测试同时验证 Android 与 iOS 的行为。
// commonTest
class ArticleRepositoryTest {
@Test
fun `refresh 失败时保留本地数据`() = runTest {
val dao = FakeArticleDao(listOf(Article(1, "本地")))
val repo = ArticleRepository(FailingApi, dao, UnconfinedTestDispatcher())
assertTrue(repo.refresh().isFailure)
assertEquals(1, dao.observeAll().first().size)
}
}
执行命令按目标区分:./gradlew :shared:allTests 跑全部目标(需要对应平台环境),./gradlew :shared:testDebugUnitTest 只跑 Android 侧,./gradlew :shared:iosSimulatorArm64Test 跑 iOS 模拟器。CI 上建议 Android 与 iOS 分两个 Job,避免 macOS 机器被 Android 任务占用。
调试共享代码的手段有限,几个实用技巧:
println仍然可用:Android 输出到 logcat、iOS 输出到 Xcode 控制台,跨平台调试最快的手段。- iOS 侧崩溃栈需要 dSYM:Kotlin/Native 的符号要配合 framework 的 dSYM 才能还原到 Kotlin 源码行,构建时保留。
-Xbinary=sourceInfoType=all(或 Gradle 属性kotlin.native.binary.sourceInfoType=all)能提供更完整的符号信息,代价是产物体积变大,只在调试构建开启。
11. 渐进式迁移策略
KMP 最大的风险不是技术,而是一次性重写。可行的路径是「先共享最没有争议的部分」:
- 第一步:只共享数据模型与纯函数。 把 DTO、枚举、校验规则、格式化函数搬到
commonMain,iOS 侧暂时不用,Android 侧先切换。这一步几乎零风险,收益是统一的模型定义。 - 第二步:共享网络与序列化层。 引入 Ktor,两端共用一套接口与序列化配置,Android 侧可先保留 Retrofit 实现作为对照。
- 第三步:共享 Repository 与业务状态机。 此时需要引入
expect/actual或接口注入处理平台差异,测试覆盖要跟上。 - 第四步:评估共享 UI。 只有在团队对 Compose 熟悉且 iOS 团队认可时再推进,从单一页面试点。
每一步都应该是「可独立发布的增量」,而不是一个持续数月的分支。判断是否继续推进的标准很实在:共享代码的测试覆盖率与两端行为一致性的收益,是否大于跨平台调试与版本对齐的成本。对大多数团队,前三步的收益最扎实,第四步需要更充分的理由。
权衡取舍
| 方案 | 共享范围 | UI 一致性 | 与原生融合 | 学习成本 |
|---|---|---|---|---|
| 纯原生双写 | 无 | 各自最优 | 完全原生 | 低 |
| KMP 共享逻辑 | 业务逻辑 | 双写,各自最优 | 完全原生 | 中 |
| KMP + Compose Multiplatform | 逻辑 + UI | 完全一致 | 部分场景受限 | 高 |
| Flutter | 逻辑 + UI | 完全一致 | 需平台通道 | 中高 |
选型的关键问题是「UI 一致性和原生融合,哪个更重要」。面向消费者的应用通常更看重原生融合(系统控件、手势、无障碍),因此 KMP 共享逻辑是更稳的起点;工具类、内部应用、跨端一致性要求高的产品可以考虑共享 UI。
常见坑清单
| 坑 | 现象 | 规避方式 |
|---|---|---|
只声明 iosArm64 | M 系列 Mac 无法跑模拟器测试 | 三个 iOS 目标都要声明 |
commonMain 里出现平台类型 | 编译失败或架构别扭 | 下沉到平台源集 |
用 expect/actual 做可替换依赖 | 测试无法注入 Fake | 改用接口 + 注入 |
expect 漏了某个目标的 actual | 编译失败 | 这正是机制的价值,按提示补齐 |
直接用 Dispatchers.IO | Native 目标上不可用 | expect 暴露 ioDispatcher |
| 跨线程共享可变状态 | 偶发数据竞争 | 不可变设计 + Mutex/atomicfu |
| Kotlin 版本低于消费方 | klib 不兼容 | 升级顺序:共享模块先行 |
| CMP 与 Kotlin 版本不匹配 | 编译报错或运行时异常 | 查 CMP 发布说明,别凭感觉升 |
suspend 直接暴露给 Swift | 只能写回调 | 引入 SKIE 或手写包装 |
| 动态 framework | 启动多一次动态链接 | 用 isStatic = true |
| 强行共享推送/权限/生物识别 | 两端都不好用 | 留在平台侧,接口暴露 |
| 一次性重写整个应用 | 长期分支、风险不可控 | 按四步渐进迁移 |
小结
KMP 的落地要点可以收成四句话:
- 源集分层决定可维护性:
commonMain只放平台无关逻辑,任何平台类型的出现都是分层失败的信号。 expect/actual与接口各司其职:不可替换的平台事实用前者,需要测试替换的行为用后者。- 版本对齐比代码更重要:Kotlin、CMP、AGP 三者强绑定,用 Version Catalog 统一管理,升级顺序永远是共享模块先行。
- 迁移要渐进:从模型到网络再到 Repository,每一步都可独立发布,共享 UI 放在最后评估。
判断一个 KMP 项目是否健康,有一个简单的问题:如果把 iOS 端整个删掉,共享模块还能否正常构建与测试? 能,说明分层是干净的;不能,说明平台细节已经渗进了 commonMain,需要尽早拆出来。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。