我最早接触Kotlin Multiplatform(以下简称KMP)是在2022年底,当时第一个反应是:这不就是Flutter换个皮嘛,学它干吗。真正把一个共享模块同时跑进Android和iOS两个工程之后,我才意识到这个判断错得离谱——KMP要解决的根本不是UI复用,而是业务逻辑层的多端一致问题。如果你正在评估跨平台方案,或者已经在用KMP但没完全搞清楚代码该怎么切分,这篇文章把我的理解、实操过程和踩过的坑一次说清楚。内容覆盖框架定位、编译原理、共享边界、项目创建、存量改造和版本配套,适合想认真评估KMP或准备动手落地的同学。
1. KMP到底解决什么问题:先把这个框架的错误预期扭过来
1.1 KMP和Flutter、RN的本质区别在"共享层"
很多人一听到"跨平台"就默认等于"一套代码到处跑",但KMP的目标不是这个。Flutter和React Native走的是"UI和业务逻辑一起复用"的路线,你写一套Dart或者JavaScript代码,渲染层由各自引擎接管。KMP的思路完全反过来:UI层不共享,Android端用Compose或原生View,iOS端用SwiftUI或UIKit,只把业务逻辑、数据层、领域模型这些"无界面代码"放进共享模块。
这个区别在选型上非常重要。如果你的产品是强UI型的,比如工具类App、信息流产品,Flutter那种"一套UI全部复用"的优势很明显。但如果你的产品两个端已经有大量原生交互、系统能力接入、自定义视觉效果,硬套Flutter可能比原生开发更痛苦。KMP不碰你的UI层,它只是在数据层和逻辑层介入,这种侵入性极低的跨平台方案,对大中型原生团队其实友好得多。
还有一点很多人没意识到:KMP是JetBrains和Google共同背书的技术路线,Android的官方文档已经直接支持KMP共享模块嵌入到原生App中。这一点对Android团队来说意义很大,因为这意味着它是"原生开发的延伸",而不是"另一套生态"。
1.2 别把KMP当KMP算法:一个高频搜索误区
搜索KMP相关资料的时候,你会看到大量"KMP算法""KMP算法next数组计算""KMP算法属于动态规划吗"的帖子。这里的KMP是Knuth-Morris-Pratt字符串匹配算法,和Kotlin Multiplatform没有任何关系,只是恰好缩写撞了。
我记得有次在技术群里有人问"KMP项目创建"的步骤,底下有人回答用next数组推导复杂度……场面一度很混乱。所以你要先确认自己搜到的是不是"Kotlin Multiplatform"的社区资料,一般可以加"Kotlin"或"Gradle"关键词锁定范围,比如搜"KMP Gradle配置""KMP expect actual"这类带上下文的关键词。
1.3 KMP适合谁,不适合谁
按我这个阶段的观察,适合上KMP的场景有这几个:
- 团队同时维护Android和iOS,两端有不少重复的领域逻辑和网络层代码
- 业务规则复杂且对一致性要求高,比如支付计算、权限判断、埋点逻辑
- 想做逻辑复用但不想被UI框架绑死,希望保留原生体验
- 团队里至少有一方熟悉Kotlin,因为共享代码用Kotlin写,Android端几乎零学习成本
不适合的情况也很明显:一个纯iOS团队要上KMP就得先补Kotlin;小型项目或原型验证类项目,KMP的前期工程复杂度不划算;如果你连稳定的数据层都还没定下来,也别急着共享。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. shared模块的一次编译多层落地:理解编译目标和expect/actual
2.1 三个sourceSet与双端编译产物
KMP工程的核心是一个共享模块,通常叫shared,它通过Gradle配置多个编译目标。我第一次打开KMP模板的build.gradle.kts时,看到androidTarget() iosArm64() iosSimulatorArm64()这些配置,第一反应是"这不是一个模块吗?怎么像生成了好几个项目"。
实际上KMP确实会把共享代码编译成多个产物:Android端编译成JVM字节码,变成AAR依赖;iOS端通过Kotlin/Native编译成LLVM中间表示,再打包成framework导入Xcode。同一份Kotlin代码,在不同平台上走的编译链路完全不同,这是KMP最核心的底层机制。
代码层级上,KMP把所有源码分成三个sourceSet:
commonMain:放两端共享的代码,是代码量最多的地方androidMain:放Android平台专属实现,比如OkHttp引擎、Android权限APIiosMain:放iOS平台专属实现,比如Darwin网络引擎、UIKit调用
在Android Studio里,你看到的依赖关系和源码目录是这样的:
kotlin复制kotlin {
androidTarget()
iosX64()
iosArm64()
iosSimulatorArm64()
sourceSets {
commonMain.dependencies {
implementation("io.ktor:ktor-client-core:2.3.7")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")
}
androidMain.dependencies {
implementation("io.ktor:ktor-client-okhttp:2.3.7")
}
iosMain.dependencies {
implementation("io.ktor:ktor-client-darwin:2.3.7")
}
}
}
注意iosX64是Intel模拟器、iosArm64是真机、iosSimulatorArm64是Apple Silicon模拟器,三者要同时保留,否则你同事换台M系列Mac的模拟器就编不过去。这块我第一次没配置全,结果同事拉代码在模拟器上直接编译失败,排查了半天才发现少了一个target。
2.2 expect/actual不是接口,是"代码占位+平台补全"
KMP最容易被误解的机制就是expect/actual。它看起来像接口和实现,实际上是一种编译期解析机制:commonMain里的expect声明一个函数或类的存在,每个平台sourceSet里必须提供对应的actual实现。编译时编译器直接选择对应平台的actual,不是运行时动态派发。
举个例子,获取当前系统名称:
kotlin复制// commonMain/kotlin/.../Platform.kt
expect fun getPlatformName(): String
kotlin复制// androidMain/kotlin/.../Platform.android.kt
actual fun getPlatformName(): String = "Android ${Build.VERSION.SDK_INT}"
kotlin复制// iosMain/kotlin/.../Platform.ios.kt
import platform.UIKit.UIDevice
actual fun getPlatformName(): String = UIDevice.currentDevice.systemName
注意iOS端的actual里用的是Kotlin直接调用UIKit API,不是在写Swift,这是Kotlin/Native的Objective-C互操作能力。这种机制最舒服的地方是:你的业务代码只面对getPlatformName()这一个抽象,具体差异被隔离在平台目录里。
用expect/actual的时候有个原则:不要把大块逻辑写进actual,actual里尽量只做平台适配,复杂的业务校验仍然放commonMain。有人喜欢把整个Repository做成actual,结果平台实现里堆了大量业务逻辑,共享就失去意义了。
2.3 哪些官方库帮你省掉大量actual工作
如果你什么都自己写expect/actual,KMP的开发成本会非常高。好在生态里已经有几个跨平台库把底层差异封装好了,我们团队现在基本固定用这几个:
- Ktor client:网络请求,Android底层走OkHttp,iOS底层走Darwin,上层API完全一致
- kotlinx.serialization:JSON序列化,不依赖反射,各平台表现一致
- kotlinx.coroutines:协程框架,本身在每个平台都有调度器实现
- kotlinx-datetime:跨平台日期时间处理
- SQLDelight或Room KMP扩展:本地数据库
网络层是KMP生态最成熟的一块。Ktor的API设计允许你在commonMain里直接写完整的网络请求逻辑,比如统一加Header、统一解析错误码、统一做超时重试,这些代码在两端的行为完全一致。用Ktor的时候,依赖要区分引擎:commonMain只引ktor-client-core,Android的androidMain引ktor-client-okhttp,iOS的iosMain引ktor-client-darwin。漏掉任何一个平台依赖,运行时才会报找不到引擎,编译期反而是过的。
3. 代码共享的边界设计:数据层能共享,UI层要看情况
3.1 分层架构里真正该放进commonMain的东西
KMP共享代码的边界问题,决定了你用起来是"真香"还是"到处是坑"。我目前比较推荐的分层思路是:最底层的数据模型、网络接口、本地存储、依赖注入容器放共享层;领域层放业务逻辑和状态管理;UI层完全不共享。
举一个典型的共享内容清单:
| 层 | 放不放共享模块 | 说明 |
|---|---|---|
| 网络层 | 放 | Ktor + 序列化,协议实现两端共用 |
| 数据模型 | 放 | DTO和领域对象,避免两端各一套模型导致转换地狱 |
| 本地存储 | 放 | SQLDelight、DataStore KMP版本 |
| 业务逻辑/状态机 | 放 | 登录状态、订单状态、权限判断 |
| 导航/路由 | 看情况 | 可以共享状态机的路由状态,但平台实现需要各自写 |
| UI组件 | 基本不放 | 除非用Compose Multiplatform |
一个特别值得放的场景是登录状态机。Android端和iOS端各自维护一套登录状态切换逻辑,往往会出现"iOS已经支持静默续期,Android还在跳登录页"的差异。把登录状态机和Token刷新逻辑挪到共享层后,逻辑完全一致,两端只负责UI上的渲染和交互。
3.2 千万别把平台API直接塞进共享代码
新手最容易踩的坑是:在commonMain里写Android的SharedPreferences或者iOS的UserDefaults。KMP源代码在commonMain里只能调用Kotlin标准库和跨平台库的API,平台专属的类在编译期就不存在。碰到这种情况有两条路:
- 用expect/actual封装平台能力,比如偏好存储统一封装一个接口
- 直接引入已经做好跨平台的库,比如Multiplatform Settings
我见过一些项目在commonMain里写满了expect声明,每个都很薄,比如取设备ID、读系统版本、获取推送Token,这种做法没问题,但要注意粒度。expect声明得越碎片化,平台端actual的维护负担越重。建议把粒度控制在"业务需要的语义边界"上,比如interface DeviceInfoProvider,而不是expect fun getDeviceId()这种一个函数一个函数地散着来。
3.3 Compose Multiplatform能共享UI了,但别急着全面上
Compose Multiplatform(CMP)是KMP生态里最让大家兴奋的部分,它让共享代码的范围从逻辑层扩展到了UI层。你可以在Android和iOS上跑同一套Compose代码,iOS端底层用Skia渲染。
但我的建议是:如果你是做生产项目,别被这个概念冲昏头。CMP在iOS端虽然已经有了不少成功案例,但整个渲染性能、键盘处理、手势细节和大量原生控件的对接,仍然需要踩非常多的坑。如果你有一个体验要求很高的原生App,现阶段更适合的做法是:先共享数据和逻辑,UI层保持Android用Compose、iOS用SwiftUI,等CMP真正成熟了再局部引入。
反过来说,如果你是从零启动一个工具类或中后台类App,团队又都是Kotlin背景,CMP完全可以考虑。它至少能让你少维护一套UI代码,代价是接受"一套UI两端表现略有差异"这个现实。
4. 从Wizard到双端运行的新项目搭建全过程
4.1 30分钟跑通一个空KMP工程的完整步骤
如果你对KMP完全没概念,最快的上手方式是直接用JetBrains的Kotlin Multiplatform Wizard,在浏览器里选好项目名、包名、需要支持的目标平台(Android/iOS),它会在线生成一个完整的工程压缩包,比你自己在IDE里手工建工程快得多。网上搜"KMP项目创建"基本都会指向这个工具。
下载解压后,用Android Studio打开工程,工程结构大概是:一个composeApp或shared模块加androidApp模块,iOS工程由iosApp里导入了共享framework的Xcode项目承担。接下来按顺序做这几件事:
- 在Android Studio里先构建
androidApp,确认Android端能跑起来 - 打开仓库根目录的
iosApp.xcodeproj(Mac上操作) - Xcode第一次构建时,需要先执行Gradle脚本生成iOS framework,模板工程一般在Build Phase里配置好了
- 选择任意iOS模拟器,运行一次,看到"Hello Kotlin"就说明双端跑通了
我第一次跑iOS端时卡在Xcode提示"framework not found",后来发现是因为模板工程默认用的embedAndSignAppleFrameworkForXcode脚本,必须在Xcode的Build Settings里把Framework Search Paths指向build目录,否则链接器找不到产物。这个在最新模板里已经不再需要手动配了,但如果你用老版本模板或自己建的工程,十有八九会遇到。
4.2 Gradle配置核心点解读
KMP工程的Gradle配置比普通Android工程多一层。核心块是kotlin {},里面定义target和各种sourceSet的依赖。最让我头疼的一块是iOS相关target的声明。
如果你只在Intel Mac上开发,iosX64足够;但Apple Silicon的Mac运行iOS模拟器时需要iosSimulatorArm64,真机需要iosArm64。最稳妥的写法是三个都声明,Kotlin版本在1.9.20以后还支持iosArm64()自动推断。系统会把三种二进制合并成一个fat framework,但那样体积会大不少。
还有一个绕不开的配置是compose插件的启用。如果是用Wizard生成的模板,不需要手动改;自己手动创建KMP模块的话,需要在plugins块里加:
kotlin复制plugins {
kotlin("multiplatform")
id("com.android.library")
id("org.jetbrains.compose")
}
这里的com.android.library是给Android端打包成库用的,没有它androidTarget()会直接报错。
4.3 第一次构建iOS端时最磨人的三件事
第一次构建iOS端,你会明显感觉到和纯Android开发完全不同的节奏。
第一,Kotlin/Native的首次编译很慢。它要把共享代码编译成LLVM位码再组装framework,还得下载一堆预编译依赖。我公司内网环境下第一次构建跑了将近十分钟,一度以为卡死了。第二次之后就走了缓存,快很多。这个阶段别着急,多构建几次就稳定了。
第二,模拟器和真机的架构匹配。模拟器编一次用的是一套指令集,真机又是另一套。你在模拟器上调试得好好的,要装真机时发现framework不兼容,得重新跑一次linkDebugFrameworkIosArm64。这个流程如果没做成脚本,每次来回切换非常浪费时间。
第三,CocoaPods和Xcode的集成方式选择。老教程里普遍建议用CocoaPods管理KMP共享库,需要往Podfile里加pod 'shared', :path => '../shared'。但新版KMP模板已经不强制CocoaPods了,直接用Xcode的Build Phase调用embedAndSignAppleFrameworkForXcode,更加干净。如果你团队本来就没用CocoaPods,别为了KMP单独引入它,直接用嵌入脚本方案。
5. 存量App改造的正确顺序:先抽网络层,不碰UI
5.1 为什么要先动数据层
很多团队评估KMP时最关心的是"怎么把现有项目改造成KMP",而不是从零新建。我的建议很明确:不要一上来就想着把整个项目翻成共享模块,先抽数据层。
原因很简单:数据层通常是两端代码最相似的部分,而且对UI的依赖最低。网络请求、JSON解析、数据库读写这些模块,Android端和iOS端的实现原理几乎是一样的,迁移到shared模块的技术风险最小,收益却非常直观——以后再改接口或调整模型字段,只改一遍就够了。相比之下,把UI层强行共享,牵扯的东西太多,很容易陷入"要么性能不达标、要么维护成本更高"的窘境。
从业务价值角度讲,数据层共享之后,后续的领域逻辑迁移就有了天然基础。当你把Repository都放到了共享层,领域服务调用Repository,自然也只依赖共享层了,这时再推动业务逻辑迁移,阻力就小很多。
5.2 一个网络层迁移到shared模块的实操案例
假设你有一块登录+用户信息获取的逻辑,Android端用Retrofit、iOS端用Alamofire,现在要迁到KMP共享。我推荐的顺序是:
- 新建shared模块,先定义数据模型类,把登录接口的请求参数和响应体从两端各复制一份并调整成kotlinx.serialization格式
- 引入Ktor,把网络请求写成统一的Repository,替代两端原先的Retrofit接口和Alamofire请求
- 用expect/actual封装依赖注入方式,Android端在App启动时注入Ktor引擎,iOS端在AppDelegate里初始化共享模块
- 先在Android端验证:UI层暂时保留原生代码,只把数据源替换成shared模块的Repository
- 验证没问题后再切iOS端,把原先Alamofire的调用点替换成共享Repository的调用
整个迁移过程可以把shared模块当成一个独立库来维护,两端的UI代码顺序调整,不会出现"切换方案瞬间全崩"的状态。
这个案例里最容易出问题的是JSON字段的命名策略。Android端的Gson或者Moshi一般不强制改字段名,但kotlinx.serialization默认要求用字段名做key。如果服务端返回的是下划线风格字段,你需要在每个DTO上标注@SerialName("user_id"),或者全局开启JsonNamingStrategy.SnakeCase(Kotlin 2.0+支持)。这种细节如果没对齐,迁移后的数据解析全是空的,排查起来很费劲。
5.3 Android和iOS接入共享产物的两种方式
共享模块编出来的产物,Android端很好办:在settings.gradle.kts里include :shared,然后app模块依赖它就行,本质上就是依赖一个AAR。
iOS端接入方式就稍微绕一点,目前主要有两种:
| 方式 | 优点 | 缺点 |
|---|---|---|
| Xcode Build Phase脚本调用Gradle | 不用引入额外依赖管理工具,版本由KMP模板管理 | 首次构建慢,脚本报错信息不友好 |
| CocoaPods集成 | 依赖管理统一,CI处理方便 | 团队没用CocoaPods的话多一套工具 |
我们团队用的是脚本方式,把./gradlew :shared:embedAndSignAppleFrameworkForXcode挂在Xcode的Build Phase里,一步生成framework并嵌入。这样只要共享模块代码有变化,Xcode构建时会自动先跑Gradle,省去手动拷贝framework的步骤。缺点是你得容忍那个缓慢的首次构建过程,以及偶尔出现的"脚本执行超时"报错。
6. 版本配套、调试体验与生态现状:入坑前的最后忠告
6.1 版本矩阵是个隐形杀手
KMP的版本配套敏感程度比普通Gradle项目高一个量级。Kotlin版本、Ktor版本、AGP版本、Compose插件版本之间存在隐性的强制绑定,升级一个,另外几个可能都得跟着动。
以我实际踩过的版本组合来说:
| Kotlin版本 | Ktor建议版本 | AGP建议版本 | 备注 |
|---|---|---|---|
| 1.9.24 | 2.3.10 | 8.2~8.5 | 比较稳定的一组 |
| 2.0.21 | 2.3.12 | 8.5~8.7 | K2编译器默认启用 |
| 2.1.0 | 3.0.3 | 8.7+ | 新特性多,生态跟进需要时间 |
我最早用Kotlin 1.9.10配Ktor 2.3.4,网络请求正常,但一升级Kotlin到2.0.x,Ktor客户端Crash了一个诡异的问题,倒查半天是Ktor版本太老,不支持K2编译器生成的新ABI。升级Ktor之后才恢复正常。这让我学到一个教训:KMP相关的依赖升级不要零散改,最好成套升级,升级前先看官方兼容表。
6.2 KMP的真实调试成本
社区里经常有人说"KMP一套代码两处调试",但真实情况是:Android端的调试体验和原生完全一样,Android Studio断点、日志、布局检查都很顺畅。iOS端的调试就割裂一些——Kotlin代码的断点,可以跨语言映射到Xcode的调试器里,但体验不如Android Studio那么自然;想看协程调度的细节,或者Compose渲染链路,Android Studio和Xcode的联动工具链还没完全成熟。
还有一个非常影响体验的是:在Android Studio里改了共享代码,Xcode工程里的framework不会自动更新。你需要在Xcode里重新构建一次,或者手动触发Gradle任务。我习惯的做法是写一个脚本,修改了shared代码后自动跑./gradlew :shared:embedAndSignAppleFrameworkForXcode,然后Xcode增量编译。不然每次切换平台都在重复"改代码—构建—等编译器"的循环,非常耗神。
6.3 我踩过之后沉淀下的几条判断标准
经过几个项目的实践,我给KMP总结了几条判断标准,不一定全面,但对选型很有参考价值:
- 如果你的核心痛点是两端UI不一致,KMP帮不了你,那是设计系统或Flutter该解决的事
- 如果你的核心痛点是逻辑不一致、重复开发、两端Bug行为不同,KMP是性价比很高的方案
- KMP是"渐进式"的,你可以只在一个模块上试点,不必全项目押注
- 团队改造过程中,Android端和iOS端的人要建立共同维护共享代码的流程,否则共享代码会变成"谁都不想碰的公共区",比不共享更难受
我个人在实际操作中的体会是:KMP最大的价值不是省代码量,而是强制两端定义同一个业务语义,把"这端改了那端没改"这类问题从源头上消灭掉。这个价值在代码量上未必能体现出来,但长期维护时你会明显地感觉到安心。
