Kotlin Multiplatform(简称KMP)这两年热度确实高,我在自己的团队里从最早“只敢拿一个非核心模块试水”,到后来把账号、网络层、数据存储全部下沉到共享代码,整个过程踩了不少坑,也搞清楚了很多文档里没写透的细节。这篇内容我尽量把KMP从原理到落地讲完整:它到底帮你解决了什么问题、项目结构应该怎么搭、iOS端怎么接入、哪些环节容易翻车,以及我实际使用一年后的真实感受。适合正在做双端开发的Android/iOS工程师,或者正打算把重复业务逻辑抽出来的团队参考。
1. 技术选型背后的真实诉求
1.1 KMP最核心的定位:共享逻辑,而非共享一切
很多人第一次听Kotlin Multiplatform,会误以为它和Flutter、React Native一样,是一套代码同时搞定两个平台的全部开发。这个理解从一开始就有偏差。KMP的核心目标是只共享业务逻辑层,也就是网络请求、数据解析、状态管理、数据库操作、权限判断这些与UI无关的部分,而UI仍然由各端原生实现。
这样设计的好处很直接:UI层是移动应用中变化最频繁、平台耦合最深的层次,强行共享UI往往意味着引入一套抽象渲染层,一旦遇到复杂交互或平台特殊行为,调试成本会成倍上升。KMP把共享边界划在“逻辑”而不是“界面”,本质上是选择了最稳妥、收益最可控的那一部分做复用。
我在团队里经常用一个比喻:Flutter是“连装修一起包了”,KMP是“只把承重墙和管道做好,装修各自来”。这两种思路没有绝对优劣,但如果你手里已经有一套成熟的原生UI组件库,或者产品对交互体验要求极高,KMP这种渐进式改造的路径会平滑得多。你可以只抽一个工具类模块、再抽网络层、再抽数据层,每一步都有明确的验收标准,不必经历“全量重写”的阵痛。
1.2 为什么我没有选Flutter或React Native
这个问题几乎每个决定用KMP的团队都会被问。我自己的判断逻辑是这样的:如果团队从零起步、缺少原生开发积累、产品以简单页面为主,Flutter确实能带来极高的交付效率。但如果你维护的是一个已经运行多年的原生应用,里面沉淀了大量自定义控件、系统能力调用、甚至部分C++底层库,那么“UI全部重做”的风险就非常大了。React Native则受限于桥接层性能,以及依赖生态版本管理的复杂度,在大型项目里同样会面临不少隐性成本。
KMP最大的吸引力在于保护原生投资:现有Android代码可以继续使用,现有iOS Swift/Objective-C代码也可以继续使用,新增能力或重构业务时再用KMP编写共享逻辑。这种共存能力让团队不需要做“二选一”的决定,而是可以逐步扩大共享范围。而且共享代码最终编译成Android的字节码和iOS的Native framework,调用方式对两端都是“原生”的,不会有解释执行或桥接带来的明显性能损耗。
当然我也要承认KMP的短板:它在UI层面的共享方案Compose Multiplatform成熟度还在爬坡,如果你期待“一套UI代码两端跑”,KMP目前的体验不如Flutter顺畅。所以KMP真正的应用场景是“业务逻辑重、UI差异大”的项目,用它的收益才最明显。
1.3 不适合上KMP的情况
不是所有项目都应该引入KMP。我总结了几类不太适合的场景,如果你遇到类似情况,建议先缓一缓。
- 纯Demo、原型、短期外包项目:KMP的学习成本、工程配置成本不算低,如果项目生命周期很短,省下来的逻辑复用可能覆盖不了成本。
- 团队没有Kotlin基础:KMP的共享代码是用Kotlin写的,虽然Kotlin和Java/Swift都有相似性,但如果整个团队要从零学语言同时又要学跨平台框架,学习曲线会比较陡。
- 业务逻辑极简单:如果应用只是几个展示页面、几乎没有复杂状态和网络层,KMP带来的抽象反而显得笨重。
- 高度依赖系统私有API:部分应用和系统能力深度耦合,比如频繁使用CoreBluetooth配合特定的iOS私有框架、或者依赖Android的AIDL绑定系统服务,这类代码很难抽成共享层。
判断标准我认为就一条:你能不能在两个平台里圈出一个足够大、且相对稳定的纯逻辑领域。 如果能,KMP的价值就很大;如果逻辑本身很少,它就是个负担。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. KMP核心原理详解
2.1 一套代码,多端产物
KMP能实现跨平台共享,底层靠的是Kotlin编译器针对不同目标平台生成不同产物。我们平时写的Kotlin代码在Android上会被编译成JVM字节码,在iOS上则通过Kotlin/Native编译器编译成原生机器码,最终封装成一个Apple Framework,由Xcode工程链接使用。在这两种情况下,源代码是同一份,编译器却完全不同。
这个设计有一个非常大的好处:共享代码里可以调用Kotlin标准库、协程、序列化等跨平台库,这些库在不同平台上都有对应的实现。你不需要像C++那样自己处理平台差异,也不需要在运行时搞一个巨大的解释器。Kotlin/Native还提供了一套底层的平台互操作机制(cinterop),允许你直接调用Objective-C和C的系统API,这意味着共享层虽然以Kotlin为主,但并没有被隔离在“小程序沙箱”里,需要时仍然能触及系统能力。
我刚开始接触KMP时有个明显的困惑:既然Shared模块能编译成iOS Framework,那它是不是只能在iOS上用?其实KMP的编译目标远不止这两端,它还能编译成JavaScript(用于Web前端)和桌面平台的原生二进制。也就是说你写一份业务逻辑,理论上可以覆盖Android、iOS、Web、Desktop四个方向,只是UI仍然各自来。这个潜力在后续扩展产品端时很有价值,我们团队目前已经在评估把共享逻辑复用到桌面端的可能性了。
2.2 expect/actual:跨平台API的占位机制
KMP里最容易让人绕晕的概念就是expect/actual。简单说,当你在共享代码里需要某个平台相关的能力(比如读取当前设备时区、获取系统语言、访问Keychain),你不能直接写实现,而是先用expect声明一个“这个能力存在”,再在Android和iOS各自的源集里用actual给出真正的实现。
code复制// commonMain
expect fun getPlatformName(): String
// androidMain
actual fun getPlatformName(): String = "Android"
// iosMain
actual fun getPlatformName(): String = "iOS"
这个机制的妙处在于编译期强制检查:如果哪个平台源集忘了实现对应的actual,编译直接报错,不会拖到运行时才崩溃。我实际使用的体会是,expect/actual非常适合作“平台能力抽象层”,比如设备信息、密钥存储、生物识别等,但你也要克制使用它的频率。如果一个类里塞了几十个expect声明,维护成本会很高,更好的做法是把平台差异收敛到少量几个接口里,共享层尽量通过接口编程访问平台能力,而不是到处写expect。
expect/actual还有一个容易被忽略的坑:它不仅能声明函数,还能声明类、属性、注解,甚至object单例。但实现时要注意,两个平台上的actual实现签名必须完全一致。如果我们因为某个平台的API特殊,悄悄加了一个额外参数,编译器会直接拒绝通过,这在多人协作时有点烦,但也从机制上避免了“隐性平台偏差”。
2.3 源集结构:每个平台都有自己的“地盘”
KMP工程默认会生成多个源集(source set),理解这些源集的边界是整个工程有清晰划分的关键。最基础的结构是:commonMain放共享代码,androidMain放Android平台实现,iosMain放iOS平台实现。但iOS部分在实际项目里会进一步拆分成iosArm64(真机)和iosSimulatorArm64(模拟器),这是因为Kotlin/Native编译产物和架构强相关,需要针对不同CPU架构生成不同二进制。
实际配置Gradle时,iOS framework的构建和合并逻辑往往最复杂。你需要指定binaries.framework的配置,选择是生成静态库还是动态库,以及是否需要isStatic = true来减少App体积。我建议入门阶段先把这些配置交给项目模板生成,不要手工写,等基础工程能跑通了再深入定制。早期如果非要想把每个配置细节都搞明白,反而会因为构建链路的复杂性影响进度。
源集依赖也是一个值得注意的点。commonMain里只能依赖Kotlin官方或多平台库,不能直接依赖Android的Gson或iOS的Alamofire。第三方库必须选择提供多平台支持的版本,比如网络层用Ktor client、JSON序列化用kotlinx.serialization、数据库用SQLDelight、依赖注入用Koin。这些库生态现在已经比较成熟,覆盖了绝大多数日常需求,但只要涉及极冷门的第三方SDK,大概率还是要自己用expect/actual包一层。
2.4 Kotlin/Native内存模型与并发差异
Kotlin/Native和传统JVM的内存模型有很大不同。早期版本的Kotlin/Native使用严格的“线程隔离”模型,对象默认不允许跨线程共享,需要在调用时冻结状态,这对很多从JVM过来的开发者是很大的心理门槛。从Kotlin 1.7.20开始,新的内存管理器逐步开放,到Kotlin 1.9.20成为默认选项,不再强制冻结对象,跨线程共享状态变得更加自然,整体体验和JVM上的Kotlin已经很接近了。
不过我还是遇到了不少与并发相关的坑。在共享代码中如果用了CoroutineScope,默认情况下它跑在哪个线程调度器上是需要明确指定的。JVM上有Dispatchers.Main依赖Android主线程,但iOS上并没有这个调度器,所以多平台代码里建议使用Dispatchers.Default或Dispatchers.Main配合相应的Main dispatcher实现,或者干脆在共享层不启动线程,只提供挂起函数,由UI层调用时自行决定线程切换。这种“共享层无调度”的策略能省掉大量线上崩溃问题。
另外iOS端调用Kotlin挂起函数时,编译器会生成带completionHandler的方法,Swift侧需要用回调或者async/await来衔接。这里有个小细节:Swift的async/await直接调用Kotlin挂起函数是可行的,但闭包里的调用链要特别小心循环引用。我一般在Swift侧封装一层ViewModel,把Kotlin暴露的对象统一管理,尽量避免直接在SwiftUI的View里持有Kotlin对象。
3. 从零搭建KMP项目:实操记录
3.1 环境准备
KMP开发的必要工具比普通Android开发多了一个Xcode,因为编译iOS Framework和最终的App打包都离不开Xcode。我建议按顺序安装:JDK 17+、Android Studio(最新的稳定版即可)、Xcode(包含Command Line Tools)、以及Kotlin插件。Android Studio自带的Kotlin插件在新建KMP项目时会起作用,不需要额外手动安装。Xcode则要确保允许安装额外的工具链,否则Kotlin/Native编译时会找不到匹配的SDK。
环境装完后第一件事不是写代码,而是跑一遍Kdoctor工具。Kdoctor专门检测KMP开发环境是否配置完整,比如JDK版本、Xcode路径、SDK版本、CocoaPods等是否就绪。别嫌这一步多余,我遇到多次编译报错都是因为环境问题,比如Xcode升级后命令行工具路径变了、JDK版本过新导致Gradle不支持等。花两分钟跑一次Kdoctor,能省掉接下来好几个小时的排查时间。
我个人的环境版本参考:Mac mini M2、macOS Sonoma、Xcode 15.x、JDK 17、Android Studio Giraffe、Kotlin 1.9.20+。这个组合目前比较稳定。如果你的电脑是Apple Silicon芯片,构建速度会比Intel版本快很多,尤其是Kotlin/Native的链接过程,差距非常明显。
3.2 新建项目与Gradle配置
创建KMP项目最稳的方式是直接用IntelliJ IDEA或Android Studio里的Kotlin Multiplatform Wizard模板。你可以在JetBrains的在线向导中生成一个基础工程,里面包含了shared模块、androidApp、iosApp三个部分。生成的模板目录结构大致如下:
code复制MyApplication/
├── shared/
│ ├── build.gradle.kts
│ └── src/
│ ├── commonMain/kotlin/
│ ├── androidMain/kotlin/
│ └── iosMain/kotlin/
├── androidApp/
├── iosApp/
├── build.gradle.kts
├── settings.gradle.kts
└── gradle.properties
打开shared/build.gradle.kts,核心是kotlin闭包里的sourceSets和listOf(iosX64(), iosArm64(), iosSimulatorArm64())。IOS平台三个目标分别对应Intel模拟器、真机和Apple Silicon模拟器,通常三个都配置,实际打包时按需选择。
code复制kotlin {
androidTarget()
iosX64()
iosArm64()
iosSimulatorArm64()
sourceSets {
commonMain.dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
implementation("io.ktor:ktor-client-core:2.3.7")
implementation("io.ktor:ktor-client-content-negotiation:2.3.7")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.7")
implementation("com.squareup.sqldelight:runtime:2.0.1")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.1")
}
androidMain.dependencies {
implementation("io.ktor:ktor-client-okhttp:2.3.7")
implementation("com.squareup.sqldelight:android-driver:2.0.1")
}
iosMain.dependencies {
implementation("io.ktor:ktor-client-darwin:2.3.7")
implementation("com.squareup.sqldelight:native-driver:2.0.1")
}
}
}
同时要配置binaries.framework,基础配置只需要指定baseName和isStatic。
code复制listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach {
it.binaries.framework {
baseName = "Shared"
isStatic = true
}
}
iOS工程集成共有三种方式:直接使用Xcode编译产生的Framework、通过CocoaPods集成、通过Swift Package Manager集成。模板默认用的是“直接Framework + Embed Script”的方式,也是我最推荐的方式,因为无需额外依赖安装器,CI环境配置也简单。
3.3 把Shared框架嵌入iOS工程
iOS端接入KMP共享代码的流程稍微繁琐,核心逻辑是:用Gradle任务把KMP模块编译成动态或静态Framework,然后让Xcode工程链接这个Framework,并在编译阶段自动调用Gradle任务保持Framework是最新的。
具体来说,Xcode的Build Phase里会加入一个Run Script,脚本内容通常是一行:
bash复制cd "$SRCROOT/.."
./gradlew :shared:embedAndSignAppleFrameworkForXcode
这个任务名看起来有点长,但它是JetBrains官方提供的统一入口,会自动根据当前Xcode的配置(真机、模拟器、架构)选择编译对应的Framework,并嵌入到App包中。脚本中用到"-configuration"和"-sdk"等Xcode环境变量,并不需要手动传参。
我一开始手动操作时因为framework的搜索路径没配好,编译一直报“找不到Shared”的错。后来参照模板工程,在Build Settings里配置FRAMEWORK_SEARCH_PATHS指向$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME),就解决了。如果你不想折腾这些路径,也可以直接把framework拖进Xcode的Frameworks分组里,但这样每次改动共享代码后都要手工替换,非常不利于开发节奏。建议还是花点时间把build phase脚本配置好,后面就一劳永逸了。
3.4 Swift侧如何调用共享代码
Framework集成完毕,Swift就可以直接import Shared然后调用Kotlin暴露的类和方法。
swift复制import Shared
import SwiftUI
struct ContentView: View {
let greeting = Greeting().greet()
var body: some View {
Text(greeting)
}
}
如果共享代码里的方法定义为挂起函数,Swift侧的签名会自动变成回调加async两种形式,使用体验和Swift原生异步函数没有本质区别。这也是Kotlin协程与Swift并发模型兼容性的一种体现。
有一个关键细节:Kotlin的object单例在Swift里会映射为shared属性。比如Kotlin里定义object ApiClient,Swift中调用方式是ApiClient.shared.request()。如果你在Kotlin里定义了带命名参数的构造函数,Swift侧生成的方法名会自动去掉参数名,改成withName:age:这类风格。这些映射规则初期会觉得不习惯,但只要统一封装在一个“桥接层”里,Swift侧就能保持自己惯用的风格。
3.5 共享网络层与数据层的实践
KMP项目里最典型的共享代码就是网络层。以我用Ktor Client实现的例子来说,commonMain里定义好数据类和API服务,Android端使用OkHttp引擎,iOS端使用Darwin引擎,上层调用方式完全一致。数据解析用kotlinx.serialization,配合ContentNegotiation插件自动完成JSON转换。
数据存储方面,SQLDelight是KMP生态中非常成熟的选择。它用SQL语句定义表结构,自动生成类型安全的查询代码,并且在Android和iOS上都使用原生数据库驱动。使用体验接近Room,但跨平台能力是它的核心优势。如果只是存轻量键值对,也可以用multiplatform-settings库,它在Android上底层是SharedPreferences,iOS上是NSUserDefaults,API完全统一。
我实践中会刻意在共享模块里再分几层:data层负责所有外部数据来源(网络、数据库、偏好设置),domain层负责业务规则和状态管理,presentation层只导出可观察状态,不暴露Kotlin具体类型给UI。这样的分层能让Swift侧代码变得很薄,同时也方便以后在共享模块里做单元测试。Kotlin代码跑单元测试比Swift方便太多,这也是KMP带来的隐形红利。
4. 实战中踩过的坑
4.1 Kotlin/Native编译慢是常态
Kotlin/Native的编译速度明显慢于JVM编译,尤其是第一次编译或者清理后全量编译,耗时几分钟甚至十几分钟都很常见。Apple Silicon比Intel芯片快不少,但也不算“快”。我的缓解方案主要有几个:
- 尽量使用
isStatic = true生成静态库,能明显降低Link耗时; - 开发阶段只编译需要用到的目标,比如在模拟器上调试只跑
iosSimulatorArm64,不要每次都三端全编译; - 使用Gradle构建缓存和Kotlin编译缓存,避免重复编译相同代码;
- 把Xcode的
Run Script里的Gradle任务放在需要的时候手动触发,而不是每次Build都跑全量。
如果团队有CI,建议专门准备一台高配Mac mini做iOS的KMP编译,本地开发以Android和逻辑调试为主,iOS侧验证放晚些再做,节奏能舒服很多。
4.2 第三方库缺少平台实现
KMP生态虽然已经成长了很多,但和纯Android或纯iOS的第三方库数量完全不在一个量级。常见的情况是:你找到一个功能合适的库,发现它只支持JVM,不支持Native,或者反过来。这时候如果这个库很重要,你只能自己用expect/actual包一层,把平台实现分别放到androidMain和iosMain里。
遇到这类问题我的排查顺序是:先看库的文档和源码里有没有commonMain目录,再看仓库的Releases有没有标注KMP支持,最后去Kotlin Slack或GitHub Issues里搜一下。如果实在不行,换个思路:是否真的需要在共享层用这个库?如果只有某一端需要,完全可以不放进共享层,而是通过接口抽象,让各端自己实现。
4.3 并发与状态共享问题
在旧版Kotlin/Native内存模型下,跨线程传对象经常遇到“对象已冻结”的运行时异常。更新到新内存模型后这个问题大幅减少,但并没有完全消失。我在项目里遇到过类似问题:在后台协程里更新了数据库对象,然后在UI线程读取时发现数据不是最新的,原因是SQLDelight的查询没有正确使用协程或Flow。
现在新内存模型下,仍有一些类型是不能跨线程共享的,比如某些涉及平台句柄的对象。我的建议是:在共享层尽量避免自行启动线程和在多个线程之间共享可变状态,尽量用Flow或StateFlow作为状态输出,让UI层订阅。这种响应式编程模型天然规避了大部分并发问题,也让Swift侧的状态绑定变得更简单。
4.4 Kotlin版本升级带来的DSL变动
KMP最大的痛点之一是版本兼容。Kotlin版本更新时,Gradle DSL经常会有breaking changes,比如androidTarget()的配置方式、sourceSets的写法、framework的配置位置,每个大版本都可能微调。我经历过从Kotlin 1.8到1.9的升级,改了不少构建脚本。
我的建议是:不要把KMP的Kotlin版本放在太激进的位置,最好等新版本发布一两个patch版本后再升级。升级前先去官方迁移文档里搜相关关键词,确认没有影响自己用到的API。同时关注Compose Multiplatform等配套库的版本,因为它们往往要求特定的最低Kotlin版本。
4.5 常见问题速查表
| 问题现象 | 可能原因 | 解决思路 |
|---|---|---|
| iOS编译报“Framework not found Shared” | Framework搜索路径未配置 | 在Build Settings里添加FRAMEWORK_SEARCH_PATHS指向Gradle输出的目录 |
| Kotlin/Native编译内存溢出 | 默认内存不足 | Gradle配置kotlin.native.jvmArgs = ["-Xmx4g"]或加大CI内存 |
| Swift无法访问Kotlin的object属性 | 映射规则不熟 | Kotlin的object在Swift里通过.shared访问 |
| 真机编译通过,模拟器编译失败 | 模拟器架构目标未配置 | 确认target包含iosSimulatorArm64或iosX64 |
| 网络请求在Android正常,iOS超时 | Transport引擎未配置正确 | iOS源集依赖ktor-client-darwin而非OkHttp |
| SQLDelight编译提示找不到表 | 驱动未正确注册 | Android和iOS源集分别添加对应Driver依赖 |
5. 从共享逻辑到共享UI:Compose Multiplatform值不值得追
聊完KMP本身,必须提一下Compose Multiplatform(CMP)。它的定位是把Jetpack Compose的UI框架扩展到iOS平台,让共享范围从逻辑层延伸到UI层。我最近在一个内部工具项目里做了试点,体验是:开发效率确实高,但工程复杂度也比传统KMP要高一些。
CMP在iOS上的渲染并不是把Compose绘制成原生控件,而是通过Skia在画布上直接绘制UI。这意味着两端的UI组件完全遵循Compose的渲染逻辑,视觉一致性很好,但一些iOS原生交互习惯(比如侧滑返回手势、TextField的特定输入交互)仍然需要额外适配。如果你做的是内容展示型应用,CMP是个不错的选择;如果涉及大量系统控件联动、复杂原生交互,建议还是等生态更成熟一点再上。
我的建议是:KMP和CMP不要绑在一起选型。共享逻辑是KMP的核心价值,即使完全不用CMP,KMP也值得引入。CMP则更像一个未来的扩展选项,在合适的时候把部分UI层纳入共享,平时还是以原生UI为主,这样项目风险最小。
6. 关于KMP,我最后想说的几点经验
回到标题“从原理到实践”,我最大的感受是KMP不是一个“装完就能跑”的框架,而是一套需要你理解编译链路、工程组织方式和平台差异的开发范式。它给你带来跨平台复用的收益,但前提是你愿意接受构建配置的复杂度和生态层面的掣肘。
如果让我给刚接触KMP的团队三个建议,我会说:第一,从小模块开始,不要一上来就打算把整个应用业务逻辑抽完;第二,保持共享层纯粹,不要在共享代码里偷偷放进平台相关假设,那会让后续维护变成灾难;第三,重视构建脚本和CI配置,把它们当作工程资产去管理和维护,而不是“能跑就行”。
分享一个我从实际项目里学到的具体经验:建议在共享代码的commonTest里为所有核心业务逻辑都补上单元测试,尤其是涉及日期计算、网络数据解析、状态分支的代码。因为共享代码的平台无关性,让它在JVM上跑单元测试的成本非常低,而一旦测试覆盖到位,后续重构和版本升级时的信心会大得多。这是KMP相比传统双端独立开发隐藏最大的优势,也是很多团队容易忽视的地方。
