KMP 跨平台共享代码,现在能共享多少
KMP 跨平台共享代码,现在能共享多少
从一条 Gradle 配置说起
去年秋天我在一个老项目里尝试接入 Kotlin Multiplatform,起因很实际:Android 端有一套复杂的离线同步逻辑,大概八千行 Kotlin 代码,iOS 团队用 Swift 重写了一遍,bug 数量是 Android 版本的 1.5 倍,维护成本直接翻倍。当时想的很简单,把这套逻辑抽成 KMP 模块,两边共用,问题解决。
实际操作下来,第一个卡点不是 Kotlin/Native 的编译速度,也不是 iOS 的内存模型,而是 kotlin("multiplatform") 插件和现有 Android 项目的 Gradle 配置打架。我们项目用了 com.android.application 7.4.2,KMP Gradle 插件 1.9.10,一apply就报 KotlinSourceSet 命名冲突,原因是某个第三方库(具体是 Room 的 KSP 处理流程)在 commonMain 和 androidMain 之间重复注册 source set。搜了一圈 GitHub issue,jetbrains/kotlin 仓库里 #KT-55778 这个 ticket 从 2022 年挂到 2024 年初才 close,close 的理由是"已在 2.0.0 修复",但 2.0.0 的 migration guide 里又提到 source set 布局的 breaking change。
这就是 KMP 生态的现状:你以为的问题和实际的问题,中间隔着三层 Gradle 的抽象。最后我的解决方案是降级 KMP 插件到 1.8.22,手动 exclude 冲突的 source set 注册,在 build.gradle.kts 里写了大概四十行 workaround。代码共享还没开始,先共享了一肚子火。
"100% 共享业务逻辑" 是个什么级别的谎言
JetBrains 官方文档和各路技术大会上的说法,KMP 的核心卖点是 "share code between Android and iOS, from business logic to UI"。前半句我认,后半句得打个大折扣。
先说业务逻辑。真正能无痛共享的,是纯 Kotlin 写的、不依赖平台特性的代码。比如数据结构的定义、简单的算法、状态机的流转逻辑。但但凡业务逻辑稍微真实一点,立刻会撞上平台边界。我们的离线同步模块需要读取本地 SQLite 数据库,KMP 提供了 expect/actual 机制,你在 commonMain 里写 expect class DatabaseDriver,然后在 androidMain 里 actual 成 androidx.sqlite.db.SupportSQLiteDatabase,在 iosMain 里 actual 成 SQLite3 的 C 指针封装。
这听起来合理,实际写起来 iosMain 那部分的 C interop 代码让我怀疑人生。Kotlin/Native 的 cinterop 工具要把 SQLite 的头文件生成 Kotlin 绑定,生成出来的 API 风格像是 2005 年的 JNI:函数指针要手动管理生命周期,字符串要在 CString 和 Kotlin String 之间来回转换,错误码用 Int 返回而不是异常。更麻烦的是,Kotlin/Native 的内存模型在 1.7.20 之前是严格的单线程所有权模型,你的数据库连接对象如果要在后台线程和主线程之间传递,得用 Worker 或者 Freeze 那一套 API,代码写得像在做分布式系统而不是本地数据库访问。
JetBrains 在 2022 年宣布要迁移到新的内存模型(New Memory Model, NMM),1.7.20 开始 preview,1.9.0 默认启用。这个迁移本身说明了一个问题:早期的 Kotlin/Native 设计假设 iOS 开发者会接受一套完全不同的并发编程模型,结果发现没人买账。现在 NMM 确实让跨线程共享对象变简单了,但代价是性能开销和更复杂的调试体验。我在 iOS 真机上遇到过 InvalidMutabilityException 变成 FreezingException 再变成某种死锁,日志里只有 Kotlin/Native 的堆栈,和 Xcode 的 Instruments 完全对不上号。
所以业务逻辑的共享,不是 "写一次跑两边",是 "写一次,再写两遍平台适配层,然后调试三遍"。
Compose Multiplatform 的 UI 共享,劝退实录
2023 年 JetBrains 把 Compose Multiplatform 推到了前台,Google I/O 上也有演示,看起来像是 Flutter 的 Kotlin 版替代方案。我实际跟了一个 Compose Multiplatform 的 iOS 项目,版本是 1.5.0-dev,用的是 Compose 的 iOS 目标平台支持。
第一个震撼是包体积。一个几乎空白的 "Hello World" 应用,Android APK 大概 3MB,iOS 的 .ipa 直接冲到 18MB。原因是 Kotlin/Native 的静态链接把整个 Compose runtime、Skia 的 iOS 构建、以及 Kotlin 标准库都打包进去了。Skia 在 iOS 上不是系统库,Flutter 也带 Skia,但 Flutter 的引擎优化了多年,Compose Multiplatform 的 iOS 后端是 2023 年才 serious 起来的,二进制体积和启动性能都明显落后。我测过冷启动时间,iPhone 12 上 Compose Multiplatform 的空应用大概 1.2 秒,同设备的 Flutter 是 0.6 秒,纯 SwiftUI 是 0.3 秒。
第二个震撼是平台 API 的缺失。Compose 的 Modifier 体系在 Android 上很完整,到了 iOS 上,手势处理、安全区域(Safe Area)、动态字体(Dynamic Type)、深色模式切换这些基础能力,要么没有,要么要通过 UIKitView 嵌套原生视图来做。UIKitView 的嵌套又带来合成层(compositing layer)的性能问题,滚动时掉帧是常态。我在 GitHub 上提过一个 issue,jetbrains/compose-multiplatform #3578,关于 LazyColumn 在 iOS 上快速滑动时的内存暴涨,回复是 "known issue, will be addressed in future releases",六个月过去了,1.6.0 的 release note 里没提。
第三个震撼是预览和调试。Android 上的 Compose Preview 是标配,iOS 上的 Compose Multiplatform 没有等价物。你得先编译 Kotlin/Native(编译一次在我的 M2 MacBook Pro 上要 3-5 分钟),然后跑 Xcode 模拟器,修改 UI 代码再重新编译。JetBrains 搞了一个 compose-hot-reload 实验性项目,但只支持 JVM 目标,iOS 用不了。这和 Flutter 的 Hot Reload 比,体验差距是代际的。
所以 UI 共享这件事,Compose Multiplatform 目前的状态是:能跑 demo,不能跑生产。JetBrains 的路线图里 2024 年要 "stabilize iOS support",但什么叫 stabilize,没有具体标准。我看过一些俄罗斯外包团队用 Compose Multiplatform 接活,交付的 iOS 应用质量一言难尽,客户反馈 "感觉不像原生",这评价对跨平台框架是致命的。
那些真正在共享的代码,到底长什么样
抛开官方宣传,看看实际在用 KMP 的团队在共享什么。
Square 的 Okio 和 KotlinPoet 是早期 KMP 化的库,它们共享的是纯算法和数据结构,没有平台依赖。Okio 的 Buffer 类在 JVM 和 Native 上行为一致,因为它自己管理字节数组,不依赖系统的 I/O API。这类库的 KMP 化相对干净,也是 KMP 生态里质量最高的部分。
Cash App(也是 Square 的)的 multiplatform-paging 项目更有代表性。他们把 Android 的 Paging 库做了 KMP 移植,但仔细看代码结构,commonMain 里只有 PagingSource 的接口定义和 Pager 的配置逻辑,真正的数据加载、缓存、线程调度都在 androidMain 和 iosMain 里各自实现。共享的比例大概是 30% 的接口 + 70% 的平台实现,而不是反过来。
我自己的项目最后采用的方案是:用 KMP 共享数据模型(data class)和 API 接口定义(Kotlin Serialization 的 @Serializable 类),业务逻辑还是各平台自己写。这样共享的代码量大概占总代码的 15%,但维护成本确实降低了——至少 API 变更时编译器会两边同时报错,不会出现在 Android 改了字段、iOS 没同步导致线上崩溃的情况。
这 15% 的共享,是 KMP 目前最务实的 sweet spot。想要更多,得付出不成比例的代价。
KSP、Room、和那个永远不来的 "稳定版"
KMP 的编译工具链是另一个隐形坑。Kotlin 的编译器插件体系(KCP)和符号处理工具(KSP)在 JVM 上已经很成熟,但 KSP for Kotlin/Native 是 2023 年底才进入 beta,KSP for Kotlin/JS 更晚。这意味着大量依赖注解处理的库——Room、Dagger/Hilt、Moshi、甚至 Kotlin Serialization 的某些高级特性——在 KMP 项目里的支持是滞后的。
Room 的 KMP 支持是 Google 2023 年 I/O 宣布的,实际可用要到 Room 2.7.0-alpha(2024 年初)。我们项目等不了,用了 SQLDelight 作为替代。SQLDelight 的 KMP 支持确实更早,但它的 Gradle 配置和 KMP 的 source set 模型有冲突,具体是 sqldelight 插件生成的代码目录和 commonMain 的 Kotlin 编译任务不自动关联,需要手动 sourceSets { commonMain.kotlin.srcDir(...) }。这个配置在 SQLDelight 的文档里只提了一句,我花了两个下午才搞对。
更底层的问题是 Kotlin/Native 的编译产物和 LLVM 的兼容。Kotlin/Native 把自己的 runtime 静态链接进最终二进制,但 iOS 的 App Store 审核要求某些架构的 bitcode 格式(虽然 2022 年后 bitcode 强制要求取消了,但调试符号的格式要求还在)。Kotlin/Native 的 embedBitcode 选项在 1.9.x 之前有几个版本生成的是不完整的 bitcode,导致上传 App Store Connect 时失败,错误信息是 ITMS-90502: Invalid Bundle,没有任何提到 Kotlin 的提示。我在 YouTrack 上找到 KT-57891, workaround 是关闭 bitcode,但这对需要 watchOS 兼容的项目又有影响。
这些工具链的细碎问题,单独看都能解决,堆在一起就是时间黑洞。一个三人的小团队,如果预估 KMP 接入需要两周,实际很可能要两个月,而且这两个月里产品需求不会停。
对比 Flutter 和 React Native,KMP 的差异化到底成不成立
跨平台框架的比较是个老话题,但 KMP 的立场比较特殊。Flutter 和 React Native 是 "一个框架覆盖多平台",KMP 是 "一个语言共享部分代码,UI 还是原生"。
JetBrains 的宣传口径是 KMP 让你 "use native UI on each platform",所以体验比 Flutter 好。这个逻辑有问题。用原生 UI 的前提是你有原生 UI 的开发能力,如果团队本来就有 iOS 开发者写 SwiftUI,那 KMP 共享的是业务逻辑,UI 本来就是原生的;如果团队没有 iOS 开发者,KMP 不会帮你变出一个来。Flutter 至少让 Android 开发者能独立完成 iOS 应用,KMP 不能。
反过来,对于已经有双端原生团队的组织,KMP 的共享价值又受限于前面说的平台适配成本。共享 30% 的业务逻辑,需要两边都理解 KMP 的 expect/actual 机制和编译流程,这增加了认知负担。我见过一个团队的做法是:Android 开发者写 KMP 的 commonMain 和 androidMain,iOS 开发者只写 iosMain 的 actual 实现,但 commonMain 的接口设计如果偏向 Android 的 API 风格(比如用 Flow 而不是 iOS 的 Combine),iOS 开发者会很痛苦。Flow 在 Kotlin/Native 上的实现基于自己的 coroutine dispatcher,和 iOS 的 DispatchQueue 混用时,线程切换的调试难度倍增。
React Native 的新架构(TurboModules、Fabric)在 2023 年逐渐稳定,它的共享模型是 JS 共享业务逻辑,原生模块写平台桥接。这和 KMP 的 expect/actual 本质上是一个思路,但 JS 的生态系统大得多,npm 上能直接用的跨平台库比 KMP 的 Maven Central 丰富一个数量级。KMP 的库生态目前还在早期,很多 Android 常用的库(比如 Retrofit/OkHttp 的 KMP 替代 Ktor Client)功能完整度差不少,Ktor 的 iOS 引擎直到 2.3.0 才支持 HTTP/2 的 multiplexing。
所以 KMP 的差异化——"原生 UI + 共享逻辑"——在理论上有吸引力,实践中卡在生态成熟度和团队能力的中间地带。它最适合的场景可能是:大型组织,有专门的 Kotlin 基础设施团队,愿意投入人力维护 KMP 的编译工具链和内部库适配。对于中小团队,Flutter 的 "全包" 或者干脆双端原生,可能更省总成本。
版本政治:Kotlin 2.0 和 K2 编译器的影响
2024 年 Kotlin 2.0 发布,最大的变化是 K2 编译器成为默认。K2 对 KMP 的影响是深远的,因为 KMP 的跨平台编译逻辑重度依赖编译器插件和 FIR(Frontend Intermediate Representation)的扩展点。
好消息是 K2 的编译速度确实有提升,我们的项目从 Kotlin 1.9.22 升级到 2.0.0 后,clean build 时间从 4 分 30 秒降到 3 分 10 秒(M2 MacBook Pro,16GB)。坏消息是大量 KMP 相关的编译器插件还没跟上 K2 的 API 变化。Compose Compiler 在 Kotlin 2.0 发布的同时出了 2.0.0 适配版,但 KSP 2.0 的支持是延迟的,Room 的 KSP 生成在 K2 上有几个已知 bug(Google 的 issue tracker 上 #342618778)。
更隐蔽的问题是 Kotlin/Native 的编译后端。K2 的 FIR 前端是共享的,但 Native 后端(LLVM IR 生成)和 JVM 后端是独立的代码路径。这意味着某些在 JVM 上编译通过的 Kotlin 代码,在 Native 上会报不同的错误,或者更常见的是,编译通过了但运行时行为不一致。我遇到过一个 case:sealed class 的 when 表达式在 JVM 上如果覆盖了所有子类,不需要 else 分支;在 Native 上同样的代码,因为编译器的 exhaustiveness check 实现不同,报 warning 而不是 error,结果运行时真的漏了一个分支,crash 在 iOS 上。
这种 "编译器一致性" 的问题,KMP 的宣传里不会提,但它是跨平台共享的最大敌人。你共享代码的前提是 "相同代码相同行为",任何平台相关的编译器差异都会破坏这个前提。
一个具体的成本核算
回到我最初那个离线同步模块的项目。最后我们没把完整的业务逻辑搬上 KMP,只共享了数据模型和 API 定义。核算一下成本:
expect/actual 和 Kotlin/Native 调试的时间(约每人一周)这个账算下来,对于我们的项目规模,KMP 是微赚的。但如果当初试图共享那八千行的完整业务逻辑,加上 SQLite 访问、文件系统操作、网络重试策略这些平台相关代码,投入产出比会倒挂。那个 SQLite 的 expect/actual 适配层,我写了三天,后来 iOS 端的 Swift 开发者又花了两天理解它,加起来五天,而直接写 Swift 的 SQLite 访问层大概也是两天。
现在能共享多少:我的判断
KMP 目前能稳定共享的代码比例,对于典型移动应用,我给的数字是 10%-20%。这个比例随项目类型变化:
expect/actual 的平台适配JetBrains 的路线图里,2024 年的重点是 Compose Multiplatform 的 iOS 稳定化和 K2 编译器的生态迁移。但路线图不等于交付质量,Kotlin/Native 的内存模型迁移花了两年,Compose iOS 的 "稳定化" 没有明确的功能完整度定义。
对于考虑 KMP 的团队,我的建议是:先从一个极小的模块试水,比如共享数据模型,跑通完整的 CI/CD 和双端集成流程,再评估扩大共享范围。不要从 "我们要共享 50% 代码" 的目标倒推,而是从 "这个模块共享后是否减少了总维护成本" 的正向验证出发。
KMP 的技术方向是对的,Kotlin 作为语言的跨平台能力在逐步兑现,但生态成熟度和工具链的打磨还需要至少两年时间。现在入场,是早期采用者的位置,要接受当小白鼠的成本。JetBrains 的商业模型(IDE 工具销售)决定了 KMP 本身不会直接收费,但你的团队时间是要收费的。
最后留个问题:如果 Google 哪天把 Jetpack Compose 的 iOS 后端也认真做起来(而不是现在这种实验性态度),KMP 的 "原生 UI" 差异化还成立吗?还是说,跨平台框架的终局都是某种形式的自绘 UI,而平台原生只是过渡阶段的妥协?