Kotlin Multiplatform 这几年在移动端圈子里的热度一直没降过,但真正把它用明白的人其实不多。我大概从 2021 年开始在几个中型项目里落地 KMP,从最开始的“试着共享一套逻辑层”到后来整个业务模块下沉到 shared 里,踩了不少坑,也总结了一套自己的方法论。这篇东西不打算讲那些官方文档里已经写烂的基础概念,而是想从“这套东西到底怎么运转的”这个角度切入,再配合我实际项目里的工程配置和设计取舍,给准备上手 KMP 或者已经在用但总觉得别扭的同行一些参考。
不管你是 Android 出身想了解 iOS 侧怎么接,还是 iOS 出身对 KMP 的编译产物心存疑虑,或者是技术负责人正在做跨平台方案选型,这篇文章应该都能给你一些不一样的视角。我会尽量把原理讲得直白,把实践讲得具体,每一个配置和代码片段都来自真实项目,不是从 Demo 里抄出来的。
1. KMP 到底在解决什么问题
1.1 跨平台方案的演进和定位
跨平台开发这个事,业内走了好几条路。最早是 WebView 套壳,后来是 React Native 这种 JavaScript 桥接方案,再后来 Flutter 用自己的渲染引擎把 UI 层也接管了。这些方案有一个共同点:UI 是跨平台的,业务逻辑跟着 UI 框架走,语言的运行时也在各自框架的掌控之中。它们解决了“写一套界面到处跑”的问题,但代价是性能、包体积、原生能力调用链路,以及和系统版本迭代之间的时差。
Kotlin Multiplatform 的定位明显不一样。它不做 UI,不碰渲染。KMP 只负责把业务逻辑层共享出来,UI 还是各自原生实现。也就是说,你在 Android 上依然用 Compose 或者 View 体系画界面,在 iOS 上依然用 SwiftUI 或者 UIKit,但底下的状态管理、网络请求、数据持久化、业务规则这些通通可以下沉到共享模块里。
我个人的感觉是,经过了这几年大厂和中厂的实际验证,KMP 最靠谱的使用方式从来不是“全链路跨平台”,而是“逻辑层跨平台,表现层全原生”。这个定位使得它和 Flutter 这类方案并不是直接竞争关系,而是一个可以共存的互补方案。对于团队里已经有成熟原生工程师、又不想维护两套业务代码的项目来说,KMP 的性价比非常高。
1.2 从源码到二进制:KMP 的编译原理
很多人在接触 KMP 之前会有个疑问:Kotlin 代码怎么在 iOS 上跑的?答案不在 Kotlin 语言本身,而在编译器的目标平台支持。Kotlin 编译器背后是 Kotlin/Native 工具链,它通过 LLVM 将 Kotlin 源码编译成原生机器码,最终以静态库或者 framework 的形式集成进 iOS 工程。
具体到一次构建过程,KMP 会做这样几件事。Android 目标平台走的是 Kotlin/JVM 那套,编译成 .class 再打包进 APK/AAB;iOS 目标平台走的是 Kotlin/Native 那套,编译成 Klib 中间产物,再链接成最终的 framework。Kotlin/Native 之所以能跨平台输出不同架构的产物,靠的是 Kotlin/Native 自带的平台库(Platform Libraries),它把 Objective-C 运行时、Foundation、UIKit 这些 iOS 系统框架的基本接口都映射到了 Kotlin 层,让你在 commonMain 里可以统一调用 expect 声明,再由各平台的 actual 实现去对接系统 API。
还有一个容易被忽视的东西叫 Kotlin/Native 并发模型。KMP 的共享代码在 iOS 端运行时,内存管理走的是引用计数而不是 JVM 的 GC,这意味着你在 commonMain 里写并发代码时必须考虑到 iOS 端的限制。最初的版本是彻底的线程隔离,数据跨线程需要 freeze;后来引入了新内存管理器,默认开启,在并发模型上和 JVM 更接近了,但在一些边界场景下仍然不能完全照搬 Android 端写法。这块后面我会单独拎出来细说。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 工程结构怎么搭才对
2.1 source set 的层级关系
KMP 的工程结构初看会有点懵,因为它的源码目录不是 Android 里的单一 app/src/main 那种平铺,而是按 source set 来组织的。一个典型的 KMP 模块会包含 commonMain、androidMain、iosMain 这样几个 source set,如果你还需要支持 watchOS、macOS 之类的目标,还会有对应的独立目录。
层级关系是这样的:commonMain 是所有平台共享的代码,在它下面不能直接调用任何平台 API,只能调用 Kotlin 标准库和第三方库提供的跨平台接口;androidMain 里放 Android 专属实现,可以使用 Android SDK 和 Java/Kotlin JVM 生态的库;iosMain 里放 iOS 专属实现,可以直接调用 Foundation、UIKit,甚至可以通过 cinterop 对接 Objective-C 库。
实际工程里,source set 与 source set 之间还存在中间层。比如你如果开了 iosArm64、iosSimulatorArm64、iosX64 这三个目标,Kotlin 会帮你抽象出一个 iosMain 中间层,把三个架构共用的代码放到这里,而每个架构特有的配置放在各自的 source set 里。这一点很关键,因为大部分 iOS 平台代码其实不存在架构差异,你没必要在三个目录里各写一遍。
我从工程上给个建议:source set 结构的核心原则是“能上提的上提,能下沉的下沉”。上提是指尽量把逻辑代码写到 commonMain,下沉是指实在避免不了平台差异时,把差异封装到最小的 actual 实现里。如果 commonMain 和两端的 actual 代码比例能达到 8:2 左右,这个项目的共享收益率就已经非常舒服了。
2.2 Kotlin Multiplatform Wizard 创建项目
现在创建一个 KMP 工程,最推荐的方式是直接使用 JetBrains 官方的 Kotlin Multiplatform Wizard 向导,地址是 kmp.jetbrains.com。这个向导会生成一个包含 androidApp、iosApp 和 shared 三部分的标准工程。androidApp 是一个正常的 Android 应用模块,iosApp 是一个 Xcode 工程,而 shared 就是我们要写的 KMP 模块。
用向导生成的好处在于,它的模板已经处理好了 Android 和 iOS 两端的接入配置,包括 Gradle 脚本、Xcode 构建脚本、embedAndSign 逻辑等。你要是从零开始手写这些配置,大概率会被各种版本兼容问题折磨一遍。我个人建议是第一次上手用向导生成的模板跑通整个链路,然后再逐步改造适合自己的结构。
生成完成后,第一步不要急着改代码,先在 Android 端跑一次,再在 iOS 端跑一次,确认两端都能正常调起模板里默认的“Hello World”函数。这步走通说明基础环境没问题,后续的改动都是增量式的,排查起来也更容易。不少新手第一步就把工程结构大改一通,最后分不清是环境问题还是代码问题,这个节奏非常不推荐。
2.3 build.gradle.kts 里那些关键配置
KMP 模块的 Gradle 脚本是整个工程的核心配置,我来逐段拆解一下关键含义。首先是 kotlin 块里的 targets 配置:
kotlin复制kotlin {
androidTarget()
listOf(
iosArm64(),
iosSimulatorArm64(),
iosX64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "Shared"
isStatic = true
}
}
sourceSets {
commonMain.dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
}
androidMain.dependencies {
implementation("com.squareup.okhttp3:okhttp:4.12.0")
}
iosMain.dependencies {
// iOS 平台依赖一般不需要额外添加,基础库已经映射
}
}
}
androidTarget 负责 Android 的编译输出。iosArm64 是真机架构,iosSimulatorArm64 是 Apple Silicon 模拟器架构,iosX64 是 Intel 模拟器架构。三个目标打出来的 framework 是不同架构的,最后通过 embedAndSign 或 Xcode 构建脚本,把对应架构的产物链接进 App。
framework 的 isStatic 参数我强烈建议设为 true。动态 framework 在启动时需要做动态链接,会直接影响 App 的启动时间;静态库则会把代码直接链接进主二进制,启动开销更小,集成也更简单。除非工程有特殊的动态下发或者模块化需求,否则不要用 dynamic。
再说一下 Android target 的配置。KMP 的 androidTarget 在 AGP 版本上有一些使用细节,Kotlin 2.0 之后推荐搭配 AGP 8.x 来用。如果你的工程还在用旧版本 AGP,建议先升级而不是反过来迁就 KMP,因为 KMP 工具链对 AGP 也是有版本下界的,配不上会直接报编译错,没有太多回旋空间。
3. 核心细节与共享代码设计
3.1 expect/actual 机制和它的边界
KMP 真正意义上的“共享”不是代码完全一致,而是“声明共享,实现分平台”。核心机制就是 expect/actual。你可以在 commonMain 里定义一个 expect 声明,比如一个获取当前网络状态的方法,然后分别在 Android 和 iOS 端写一个 actual 实现,真正去调用各平台的系统 API。
expect/actual 能修饰的成员包括函数、属性、类、对象、注解等。它的语法很直白,commonMain 里写声明,各平台 source set 里写实现,编译器会在构建时检查两边的匹配关系。实际开发中一个很重要的原则是:expect 声明要足够通用,不要把一个平台特有的行为暴露在公共接口里,否则另一端的 actual 会非常别扭,甚至无法实现。
举个例子,你要实现一个日志模块。Android 端你很自然地想用 Log.i 输出到 logcat,iOS 端想用 OSLog 或者直接 NSLog,那么封装一个 expect 函数 logMessage(tag: String, message: String) 就很合适。但如果你想把 Android 的 CrashHandler 也塞进来,expect 声明就会很尴尬,因为 iOS 端没有对应概念,哪怕有 NSSetUncaughtExceptionHandler,行为语义也完全不同,强行封装只会让代码难以理解。
所以我的核心设计原则是:expect/actual 只做能力声明,不做策略抽象。真正的业务策略放在 commonMain 的普通类里,通过接口注入的方式去依赖 expect 的能力。这样 commonMain 的代码可以保持完全是平台无关的,也方便做单元测试。
3.2 共享代码的分层策略
当你在 commonMain 里写业务代码时,最容易犯的错是把所有东西都塞进一层里。KMP 代码和普通 App 代码一样,需要有清晰的分层。我惯用的结构是三段式:数据层、领域层、应用层,UI 层留在各端原生实现。
数据层包含网络请求、数据解析、数据库操作、本地缓存这些内容。这一层可以大量依赖第三方跨平台库,比如 Ktor client、kotlinx.serialization、SQLDelight 或者 Realm。领域层则是业务规则和用例,它是纯 Kotlin 代码,不依赖任何平台 API,这样才方便在 commonTest 里直接写单元测试。应用层负责把数据以状态的形式暴露给各端 UI,这里通常会用 ViewModel 或者状态流。
我见过一个反面案例,某个项目在 commonMain 里直接把 Ktor 的 Response 对象传到了 UI 层,Android 端用着还行,iOS 端拿到的对象体验就很奇怪。原因在于 Ktor 在 iOS 端的实现里有不少 NSDictionary、NSData 这类底层类型映射,如果传到 UI 层,Swift 代码里会出现一堆类型转换的样板代码,共享代码的收益就被抹掉了。正确的做法是在应用层做一次模型转换,把网络层的 DTO 转成 UI 层的不可变状态对象。
3.3 commonMain 里的依赖注入
commonMain 的代码不能使用 Spring、Dagger/Hilt 这类 JVM 生态的依赖注入框架,所以你需要一个轻量级的方案。这里我推荐两种做法:手动构造器注入,或者写一个极简的服务定位器。
手动构造器注入在 KMP 里其实够用。因为共享模块的规模通常不会大到需要一个完整的 DI 框架,使用构造器把依赖传进来,代码可读性和可测试性都很好。比如一个 Repository 需要 ApiClient,一个 UseCase 需要 Repository,这种层级关系在 App 启动时手工组装一次即可。
服务定位器方案我一般用在跨模块访问比较频繁的场景。在 commonMain 里定义一个简单的 ServiceContainer,维护一个 MutableMap<Class, Any>,提供 register 和 get 方法,在两端的 App 启动时注册所有服务。这个方案实现成本很低,但用起来非常顺手,特别适合中小型项目的初期阶段。
这里还是要强调一句:不要在 commonMain 里尝试用反射、ClassLoader 这类 JVM 特性,Kotlin/Native 下这些能力是受限的。依赖注入设计得越简单,跨平台兼容性就越好。
4. 依赖管理和生态现状
4.1 分层依赖:怎么判断一个库能不能用
KMP 的依赖管理和普通 Kotlin 工程很像,也是用 Gradle 坐标声明,但多了一个重要的维度:每个库都可能有多个变体,分别对应不同的平台。在 commonMain 里声明的依赖,Gradle 在构建时会自动选择对应平台的那个变体。
判断一个库能不能在 KMP 里用,核心看它有没有发布 KMP 的元数据变体。以 Ktor client 为例,它在 Maven Central 上发布的坐标 org.jetbrains.kotlinx:ktor-client-core 携带了 Gradle Module Metadata,里面声明了 JVM、Android、iOS、macOS、JS 等多个变体。因此在 commonMain 里能直接依赖它,编译器会为 Android 选择 JVM 变体,为 iOS 选择 Native 变体。
实践里我最常碰到的依赖问题有两类。一类是库只有 JVM 版本,只能在 androidMain 里用,不能用代码在 iosMain 里引用;另一类是两个库的 Kotlin 版本、Ktor 版本、serialization 版本互相冲突,导致构建时出现“Duplicate JVM class”或者“Metadata version mismatch”。
针对依赖冲突,我给一个比较有效的排查思路:先用 gradle dependencies 命令看完整依赖树,然后定位是哪个库传递引入了旧版本,最后通过 exclude 或者直接提升某个库的版本来解决。KMP 模块里处理版本冲突比纯 Android 工程敏感得多,因为 Kotlin/Native 的编译对 metadata 版本非常严格,版本不匹配时不会像 JVM 那样有兼容回退。
4.2 Kotlinx 系列库的实际体验
Kotlin 官方维护的 kotlinx 系列已经覆盖了大部分基础能力,这也是我推荐在 commonMain 里优先使用它们的原因。kotlinx.coroutines 提供了跨平台的协程支持,kotlinx.serialization 提供了 JSON 和各类格式的序列化能力,kotlinx-datetime 解决了日期时间处理的跨平台问题。
kotlinx.coroutines 在 iOS 端的使用要特别小心,因为它和 Swift 的 async/await 对接有自己的桥接方式。当你把一个挂起函数暴露给 Swift 时,最终会以 completion handler 的形式暴露。Swift 里调用是异步的,需要处理回调。Kotlin 2.0 之后提供了更友好的 Swift 导出,可以自动生成 async 版本,但前提是函数签名必须满足一定的约束,比如不能在参数里带非 Sendable 类型的复杂对象。
kotlinx.serialization 我几乎在每一个 KMP 项目里都用。它能在编译期生成序列化器,避免反射,性能和稳定性都很不错。定义数据模型时用 @Serializable 注解,然后通过 Json 对象进行编码解码。这里有个小坑:Json 配置的 ignoreUnknownKeys 在 Android 和 iOS 端的表现是一致的,但如果数据模型里用了默认值参数,某些字段缺失时的行为在两端可能略有差异,需要在测试阶段重点验证。
4.3 第三方生态:Ktor、SQLDelight、Firebase
除了 kotlinx 系列,最值得关注的两个第三方库是 Ktor client 和 SQLDelight。Ktor client 在这几年的 KMP 实践中已经非常成熟了,网络请求、拦截器、超时控制、WebSocket 都能在跨平台模块里正常工作。它的引擎在 Android 端默认用的是 OkHttp,在 iOS 端默认用的是 Darwin 引擎,直接对接 NSURLSession,性能和稳定性没有问题。
SQLDelight 是另一款我很喜欢的 KMP 数据库方案。它让你用 SQL 语句定义表结构和查询,编译时生成类型安全的 Kotlin 代码,并在各平台生成对应的数据库驱动。Android 端可以理解为 SQLite 的封装,iOS 端也是用 SQLite。这个方案的好处是 SQL 是共享的,数据层的逻辑不会两端漂移,而且编译期就把 SQL 语法正确性检查了,比手写数据库操作省心很多。
Firebase 在 KMP 里的支持这两年也有进步,官方的 Firebase Kotlin SDK 已经支持了不少模块,包括 Analytics、Auth、Firestore 等。但需要留意的是,这个库实际上是 Firebase iOS SDK 和 Android SDK 的封装,不是所有 Firebase 服务都覆盖,而且在 iOS 端还需要手动配置 GoogleService-Info.plist。如果你的项目重度依赖某一 Firebase 子模块,建议先确认它是否支持 KMP,否则还是用原生实现再通过接口暴露给 commonMain 更稳。
5. iOS 端接入的实践细节
5.1 framework 产物与 embedAndSignFramework
KMP 模块在 iOS 端构建后,输出的是一个 framework 包。这个 framework 需要被 Xcode 工程引用,并且打进 App 的最终产物。在过去,很多人用的是 CocoaPods 方式,也就是 KMP 模块发布为一个 podspec,然后通过 pod install 接入;现在官方推荐的则是 embedAndSignFramework 任务。
embedAndSignFramework 的思路是在 Xcode 的 Build Phase 里加一个脚本步骤,当 Xcode 构建时,这个脚本会调用 Gradle 编译对应架构的 framework,并自动嵌入到 App 包、完成签名。这样做的好处是不依赖 CocoaPods,两边工程各自独立,KMP 版本的升级只需要改 Gradle 脚本,不需要去维护 pod 仓库。
我目前的工程里,KMP 的构建脚本大致是这样:
bash复制cd "$SRC_ROOT/shared"
./gradlew :shared:embedAndSignAppleFrameworkForXcode
这个任务名会根据 Xcode 当前的环境变量自动选择架构。真机构建时,Xcode 会设置 ARCHS=arm64 和 SDK_NAME=iphoneos,Gradle 识别到之后只编译 iosArm64 这个 target;模拟器构建时,会自动选择模拟器架构。这套机制非常顺滑,省掉了手动拼接 xcodebuild 参数的麻烦。
5.2 Swift 侧调用 Kotlin 代码的桥接规范
KMP 编译出的 framework 会暴露给 Swift 一个 Objective-C 兼容的接口,所以 Swift 调用 KMP 代码的体验在大部分场景下是顺畅的。但有些细节需要遵守,否则接口会变得很难用。最典型的是可空性标注,Kotlin 的 String? 在 Swift 里是 String?,非空 String 就是 String,这个映射是自动的,但如果你用了 Java 注解库试图手动控制,反而会弄巧成拙。
另一个重点是挂起函数在 Swift 里的形态。默认情况下,一个 suspend fun fetchUser() 暴露给 Swift 后会变成 fetchUser(completionHandler:),使用起来略繁琐。Kotlin 2.0 的导出增强可以在某些条件下把 suspend 函数映射成 async 函数,让 Swift 侧调用可以写成 await fetchUser()。但要注意这个特性是实验性的,开启方式是在 Gradle 里配置:
kotlin复制iosTarget.binaries.framework {
export = true
compilerOptions {
freeCompilerArgs.add("-Xexport-kdoc")
// 或者配置 asyncexport 相关参数
}
}
实际使用中,我建议 Swift 侧再包一层薄薄的适配层,把 KMP 暴露出来的接口转换成更 Swift 风格的 API。这个适配层不用厚,主要是处理回调改 async、错误类型转换、数据模型映射这几件事。有了适配层,SwiftUI 或者 UIKit 调用起来会自然很多。
5.3 二进制发布:怎么把 KMP 产物交给其他团队
如果你在做一个基础组件或者 SDK 公司,可能会需要把 KMP 产物发布给不具备 Kotlin 环境的 iOS 团队。目前有两种主流方案:一是二进制 framework 直接分发,二是通过 Swift Package Manager 集成。
二进制 framework 分发最简单,把编译好的 framework 压缩包放到内部文件服务器,iOS 团队下载后拖到 Xcode 工程里即可。这种方式比较原始,但步骤少、排错容易。
SPM 集成是更好的方案。在 KMP 的构建脚本里配置一个 Zip 任务,把多个架构的 framework 合并成一个 XCFramework,再写一个 Package.swift 描述产物信息,上传到 Git 仓库或者内部托管平台。iOS 团队就能像依赖普通 Swift Package 一样,通过 URL 和版本号直接引入。这个模式在工程化和版本管理上都很干净,也是我目前最推荐的发布方式。
6. 实操案例:一个跨平台任务调度模块
6.1 功能设计和分层拆解
纸上谈兵说了这么多,我放一个真实做过的模块来演示整个 KMP 开发流程。需求背景是这样的:App 有一个后台任务调度系统,需要支持延迟执行、周期任务、失败重试、任务去重,并且这个系统要同时在 Android 和 iOS 端工作。
我接到这个需求时,第一反应是绝对不能两端各自实现一套,否则规则维护成本很高。用 KMP 把调度核心写在 commonMain,两端只做最底层的能力提供,是性价比最高的方案。
功能拆解后,commonMain 里的调度核心包括任务模型定义、调度队列、重试策略、任务去重逻辑。Android 端需要提供的底层能力是 WorkManager 的封装;iOS 端需要提供的是 BGTaskScheduler 的封装。这样就非常匹配 KMP 的定位:规则在共享层,能力由两端提供。
6.2 日志、协程和任务队列的处理
调度模块离不开日志。我选择了 Kermit 这个跨平台日志库,它支持在 Android 端输出到 logcat,在 iOS 端输出到 os_log,而且可以配置多个 logger 实例。在调度执行的关键节点处打日志,比如入队时打任务 ID、延迟时间和重试次数,执行前后各打一条状态,这样在线上遇到调度异常时,两端用各自原生的日志工具就能定位问题。
还有一个细节是队列的并发控制。调度任务经常需要限制最大并发数,比如同时只允许 3 个网络密集型任务执行,这属于典型的数据流处理场景。我直接用 kotlinx.coroutines 的 Flow 和 Mutex 在 commonMain 里实现了一个简单的并行调度器。测试下来,这套逻辑在 Android 和 iOS 端的行为完全一致,这让我对“共享逻辑层”的价值有了更直观的感受:同样的并发问题,只需要写一遍、测一遍。
6.3 实际跑通过程中遇到的坑
这个模块有几个让记忆深刻的坑,也在这里记录下来,希望能帮你省些时间。
第一个是 iOS 后台执行的实际限制。我用 BGTaskScheduler 封装了 iOS 端的任务触发,但 iOS 系统对后台任务的时间窗口有严格限制,不是你设置了时间就一定能按时执行。如果调度系统设计时假设“时间到了任务就必然执行”,在 iOS 上就会出现大量任务未按时执行的假异常。我的解决办法是把“到期尝试执行”和“真正执行成功”两个状态分开,执行失败时由 commonMain 的调度器决定是重试还是丢弃,系统层面的时间窗口只是触发因素,不是执行保证。
第二个是协程在 iOS 端的内存生命周期问题。KMP 新内存管理器虽然解决了大部分冻结问题,但如果调度器持有的协程 Job 没有在合适的时机取消,还是会引起内存泄漏。后来我在每一层都显式传入协程作用域,把生命周期绑定到具体业务使用方,而不是让调度器自己全局持有作用域,这个问题才算彻底解决。
第三个是 Swift 侧调用 KMP 挂起函数时,对错误处理的预期不一致。Kotlin 的异常在 Swift 里会映射成 NSError,如果你在 commonMain 里抛出的是一个自定义异常,Swift 端拿到的可能都是同一个 error code,排查问题时信息量不够。我的做法是在共享模块的公开方法里不抛业务异常,而是返回一个 Result 类型,将错误码和错误信息打包好,Swift 端直接 switch 处理即可。这种设计在那个调度模块里效果很好,错误处理路径清晰了很多。
7. 常见问题与排查技巧实录
7.1 构建和编译阶段的典型报错
KMP 的报错信息有时候比较晦涩,很多问题其实有固定的套路可以处理。我把高频出现的几类列出来,对照着排查效率会高不少。
| 报错特点 | 常见原因 | 处理办法 |
|---|---|---|
| “Could not find org.jetbrains.kotlin:kotlin-stdlib:...” | 依赖仓库没配全 | 检查 repositories,补上 google() 和 mavenCentral() |
| Metadata version mismatch | Kotlin 版本不统一 | 统一所有模块的 Kotlin 版本,清理构建产物后重新编译 |
| Unresolved reference (iOS source set 里引用 JVM 库) | 在 iosMain 里用了只有 JVM 变体的库 | 改用跨平台库,或把依赖下沉到 androidMain |
| “Command PhaseScriptExecution failed” | embedAndSign 脚本执行失败 | 查看 Xcode build log 中 Gradle 输出的实际错误,通常是 Gradle 任务编译失败 |
| Framework not found Shared | Xcode 找不到 framework 路径 | 检查 Build Settings 中 FRAMEWORK_SEARCH_PATHS 是否指向正确目录 |
这里特别提一下清理构建产物的必要性。KMP 的构建缓存比纯 Android 工程更复杂,Kotlin/Native 的编译缓存失效规则有时会让你在升级依赖后遇到奇怪的报错,我一般先跑 ./gradlew clean,再删掉 ~/.konan 里对应的缓存目录,最后重新编译。这套流程解决了我至少七八成“莫名其妙”的问题。
7.2 运行时性能和包体积的优化
KMP 在 iOS 端的启动性能之前有过一些争议,但在我测试下来,如果采用静态 framework,并且代码本身没有做太过分的初始化操作,性能差异其实非常小。包体积方面,KMP 基础框架大约会增加 5-8MB 的二进制体积,具体取决于你引入了多少跨平台依赖。这个体积在今天的 App 生态里算是可以接受的,但如果你的 App 对体积极度敏感,可以通过以下方式优化。
一个能明显减小包体积的手段是 minified 混淆。Android 端可以开启 R8 对共享模块进行代码裁剪,iOS 端则需要在编译 framework 时加上 -Xbinary 的链接参数来剔除未使用的代码路径。另外,依赖库的选择也很关键,有的跨平台库体积本身就大,比如包含了对 WebSocket 的大体量实现,如果业务用不到,可以考虑换成轻量级方案。我会在每次发布前检查一次 framework 的体积和符号表,避免“库越加越多,包体积直线上升”的失控局面。
7.3 新内存管理器下的并发编写注意点
我在前面的章节里多次提到了 Kotlin/Native 并发模型,这里把它做实。KMP 的默认内存管理方式已经切换到新内存管理器,它可以支持对象跨线程自由共享,不再强制冻结,这是个大进步。但在 iOS 端,如果多个协程并行访问同一个可变对象,仍然需要保证线程安全。
我的经验是:commonMain 里的共享可变状态尽量用原子类或者 Channel 来管理,不要依赖锁。这既是为了跨平台一致性,也是因为 iOS 端的锁语义和 JVM 不完全相同,锁在 Kotlin/Native 端依然有性能开销和使用限制。在实现任务调度系统时,我用状态流的形式对外暴露任务列表,用 MutableStateFlow 保存当前队列状态,状态更新都在单线程上下文中完成,既保证了线程安全,又让 UI 端可以通过 collect 订阅到任务变化。
如果业务场景确实需要真实的并发控制,官方推荐的方案是使用 kotlinx.coroutines 提供的 Mutex 和 Semaphore,它们在 commonMain 里就能用,而且实现经过了各个平台的优化。不要自己去写同步代码,在跨平台环境里,这几乎一定会踩坑。
8. 从工程化视角看 KMP 的团队落地
8.1 什么样的项目适合引入 KMP
做了几个 KMP 项目后,我对“什么样的项目适合用 KMP”有了比较清晰的判断标准。首先是业务逻辑的复杂度,如果 App 的核心竞争力体现在流程、规则或者算法上,那么这部分逻辑非常值得通过 KMP 共享;如果业务逻辑薄得像一层纸,主要工作是渲染列表和展示图片,KMP 的收益就不明显。
其次是团队构成。如果团队内同时有 Android 和 iOS 工程师,而且两个平台都有一定代码量,那么 KMP 的价值会体现在减少重复开发和避免需求实现漂移上。共享代码意味着在需求评审和技术方案阶段,两个端的同学可以更多地对齐逻辑,而不是各自理解、各自实现。
最后是技术栈匹配度。如果团队已经有一定比例的 Kotlin 使用经验,上手 KMP 几乎没有额外语言成本;如果团队基本都是 Swift 背景,虽然也可以做 KMP,但需要团队的 Kotlin 学习意愿比较强,否则共享代码很容易变成“两边都会,但都不是很熟”的灰色地带。
8.2 团队协作和代码评审的调整
KMP 对团队协作方式也有影响。最重要的一条是:共享模块的代码评审必须同时拉上 Android 和 iOS 的同学,因为一段 commonMain 代码可能会影响两端的运行时行为。刚开始我们吃过亏,一段只考虑 Android 语境的代码被合入后,iOS 端的日志和异常上报路径都不对,排查了半天才发现是共享层的问题。
还有一个实际操作上的建议是:把 commonMain 里所有 expect 声明的变更当作一个重点审查项。因为 expect 一旦改动,另一端的所有 actual 实现都需要同步变更,容易遗漏。我通常会在 PR 描述里明确标注“本变更涉及实际 API 变更”,并附带一份变更影响清单,提醒两端同学重点回归。
另外一个值得投入的方向是 KMP 模块的单元测试。commonMain 的代码可以在 JVM 上跑测试,这意味着你可以把大部分业务逻辑的测试写在 commonTest 里,两端一起受益。iOS 端真正需要测试的只是 actual 实现那一小层,这一层通常比较薄,测试量不大。把测试重心放到 commonMain,能有效提升两端代码质量的一致性。
8.3 逐步迁移还是从零开始
如果你的目标是改造一个已经上线很久的 App,我不建议“推倒重来”式的引入 KMP。更稳妥的路径是选择一个新的模块,或者是稳定迭代的存量模块,先在共享模块里实现核心逻辑,再让两端接入。通过这种方式,项目组可以在不阻断主线迭代的前提下,逐步验证 KMP 的实际效果。
我做过的一个典型案例是先把用户协议、隐私政策和“关于我们”这类静态页面逻辑抽出到 shared 模块,业务简单但涉及文本渲染、版本比较、远程配置这些跨平台能力。跑通之后,再迁移登录注册、push 配置、埋点上报这些更核心的模块。每一步都有明确的验证指标,比如“Android 端功能无回归”“iOS 端包体积增量在预期内”“两端日志输出一致”。这种渐进式的引入方式在团队内的阻力最小,也更容易赢得其他成员的支持。
如果是从零开始的新项目,情况就简单很多。直接以 KMP 作为基础架构,业务代码从一开始就写在 commonMain,两端的 UI 层各自实现。新项目的技术债少,依赖冲突少,团队也能从一开始就用正确的姿势写跨平台代码。
9. 从原理到实践的一些体会
对于一个熟悉原生开发但刚开始接触 KMP 的团队,我的建议是:先不要追求“所有代码统统共享”,而是从数据层和领域层入手,把网络的封装、数据模型、状态管理等逻辑抽出来。UI 层保留原生实现,一方面可以减少 KMP 对 UI 技术栈的冲击,另一方面也能让大家把注意力集中在 KMP 真正擅长的领域——业务逻辑共享。
从原理层面理解了 KMP 的编译和目标平台对接机制之后,你会发现很多看起来“很魔法”的行为其实都有清晰的解释。它之所以能跨平台,是因为编译器为每个平台生成了对应的二进制产物,而不是用某种运行时解释执行;它之所以能在 commonMain 里调用协程和序列化,是因为这些库已经为每个平台发布了对应的变体;它之所以在 iOS 端能对接 SwiftUI,是因为 framework 经过桥接后就是一个标准的原生库,和你用 Objective-C 写一个 framework 是完全一样的工作方式。
我个人在实际操作中的体会是,KMP 最大的价值不是帮你省掉两套代码的字面量,而是通过一个共享的逻辑层,强迫两端工程师在需求和设计阶段就对齐思路。很多需求在 Android 和 iOS 上是“差不多”的,但“差不多”这个词恰恰是 bug 的温床。有了 KMP,逻辑实现不再是各写各的,而是在同一个代码仓库里,同一段代码里,讨论如何表达和执行同一个业务规则。
最后再分享一个小技巧:不管你的共享模块多简单,都建议在一开始就配置好基础的三件套——严格的依赖版本管理、统一格式的日志库、从 commonMain 暴露的 Result 风格接口。这三样东西会在后续每一个功能迭代中帮你节省大量时间,属于花十几分钟配置、后面天天受益的投入。希望这篇从原理到实践的指南能让你少走一些弯路。
