揭秘 KMP 编译魔法:同一份 Kotlin 代码是如何在 Android 和 iOS 上原生运行的?
Kotlin Multiplatform (KMP) 通过编译器工程实现跨平台原生运行:一份 Kotlin 代码被分别编译为 Android 的 JVM 字节码和 iOS 的机器码,无需运行时中间层。核心机制包括统一的编译器前端生成中间表示(IR),再由不同后端处理;通过 expect/actual 契约解决平台 API 差异;内存模型从严格线程隔离演进为更灵活的并发支持。KMP 实现了"

最近 Kotlin Multiplatform (KMP) 势头正劲,Google 官方背书,众多大厂纷纷入局(比如 Netflix、McDonald’s)。它宣称能让同一份 Kotlin 代码“原生”运行在 Android 和 iOS 上。这时候,那个熟悉的问题又来了,而且这次更让人困惑:
“等一下,Android 跑的是 JVM,用的是 Dalvik 字节码;iOS 根本没有 JVM,跑的是 ARM 架构的机器码。这俩体系截然不同,同一份 Kotlin 代码怎么可能同时在它俩上面‘原生’运行?这不科学!”
这背后并非什么“魔法”,而是堪称艺术的编译器工程。今天,就让我为你层层揭开这层神秘面纱,用一个清晰的心智模型,带你彻底搞懂 KMP 跨平台的底层逻辑。
核心心智模型:一份蓝图,各自施工
在深入技术细节前,我们先来建立一个核心心智模型。想象一下,你要在上海和北京各建一座一模一样的现代化办公楼。
你不会运送一整座建好的楼过去,对吗?正确的做法是:
- 制定一份通用蓝图 (Shared Blueprint):这份蓝图用一种标准化的语言(比如建筑信息模型 BIM)详细描述了办公楼的每一处设计、结构、材料规格。这就是你的 共享 Kotlin 代码 (
commonMain)。 - 组建两支本地施工队 (Platform-specific Teams):
- 上海施工队:他们熟悉上海本地的建筑规范、气候条件和材料供应渠道。他们会拿着通用蓝图,用上海本地的工艺和材料(比如“上海水泥”),把大楼建出来。这就是 Kotlin/JVM 编译器,它把你的 Kotlin 代码编译成 Android 平台认识的 JVM 字节码。
- 北京施工队:同样,他们也拿着那份通用蓝-图,但会根据北京的抗震要求、冬季施工特点,使用北京本地的建材(比如“京标建材”),建成一座外观功能相同但内在适应本地环境的大楼。这就是 Kotlin/Native 编译器,它把同一份 Kotlin 代码编译成 iOS 平台认识的 原生机器码。
关键点:产出的是两座功能一样,但“血肉”完全不同的建筑。它们都是用本地材料、按本地标准建造的“原生建筑”,而不是一座需要特殊适配器才能在北京使用的“上海预制楼”。
这就是 KMP 的核心思想:一次编写(Write Once),分别编译(Compile Natively)。 它与 React Native 或 Flutter 那种“带个翻译(Bridge/VM)去现场”的模式有着本质区别。KMP 在编译期就完成了所有“翻译”工作,运行时不存在任何中间层,因此能达到真正的原生性能。
理解了这个模型,接下来我们就可以深入到“施工现场”,看看这两支“编译施工队”具体是怎么工作的。
编译管线揭秘:从 Kotlin 源码到原生二进制
KMP 的“魔法”核心在于 Kotlin 编译器强大的架构:一个统一的前端(Frontend)和多个专职的后端(Backend)。
无论你的目标平台是什么,你的 Kotlin 源码首先都会被前端处理。前端负责:
- 词法分析、语法分析:把你的代码打散成一个个词元(tokens),再组成一棵抽象语法树(AST)。
- 语义分析、类型检查:检查你的代码有没有语法错误,变量类型是否匹配,函数调用是否正确等。
完成这些检查后,前端会生成一份独立于任何平台的“通用蓝图”——中间表示(Intermediate Representation, IR)。这份 IR 精确地描述了你的代码逻辑,但又不包含任何平台相关的细节。
接下来,这份 IR 会被分发给不同的后端“施工队”:
场景一:Android 施工队 (Kotlin/JVM Backend)
这是我们 Android 开发者最熟悉的路径。
- Kotlin/JVM 后端接收 IR:它读取通用的 IR。
- 生成 JVM 字节码:将 IR 转换成标准的
.class文件,也就是 Java 虚拟机认识的字节码。 - Android 构建工具接力:Android 的构建工具链(D8/R8)会接手这些
.class文件,把它们和其他库的字节码一起打包、优化,最终转换成 Android 运行时(ART)专用的 DEX 字节码。 - 打包成 APK/AAB:最终,DEX 文件和其它资源一起被打包成安装包。
结论:在 Android 平台上,KMP 的共享代码和你手写的原生 Android Kotlin 代码,经历的路径几乎完全一样。它们最终都变成了高效的 DEX 字节码,由 ART 直接执行。所以,在 Android 端,KMP 的性能就是原生 Kotlin 的性能,没有任何损耗。
场景二:iOS 施工队 (Kotlin/Native Backend)
这部分是 KMP 实现 iOS 原生运行的关键,过程要复杂一些:
- Kotlin/Native 后端接收 IR:同样,它也读取那份通用的 IR。
- 借助 LLVM 生成机器码:Kotlin/Native 自身并不直接生成机器码,而是将 IR 编译成另一种更底层的中间表示——LLVM IR。LLVM 是一个久经考验的、强大的编译器基础设施,被苹果(Swift/Clang)、Rust 等众多语言广泛使用。
- LLVM 编译和链接:LLVM 会接手 LLVM IR,进行一系列深度优化(这步是性能的关键),然后根据你的目标设备(比如 iPhone 15 Pro 的 A17 Pro 芯片),将其编译成对应架构的原生机器码(比如 ARM64)。
- 打包成 Framework:最后,这些机器码连同 Kotlin/Native 的运行时库(比如垃圾回收器、类型信息等)、以及一个自动生成的 Objective-C/Swift 交互头文件,一起被打包成一个标准的 iOS Framework(例如
shared.framework)。
结论:在 iOS 平台上,你的 Kotlin 代码最终变成了和 Swift/Objective-C 代码一样的原生二进制文件。它直接运行在 CPU 上,没有虚拟机,没有 JIT(Just-In-Time compilation),享受着和原生 iOS 代码同等的待遇。
现在,我们可以清晰地回答开篇的那个问题了:同一份 Kotlin 代码之所以能在 Android 和 iOS 上原生运行,是因为 Kotlin 编译器针对不同平台,走了两条完全不同的编译路径,产出了两种完全不同但都“原生”的最终产物。
理解了宏观的编译流程,魔鬼往往藏在细节中。接下来,我们聊聊那些让 KMP 真正可用的关键机制。
跨越平台差异:expect/actual 的契约精神
我们已经知道 KMP 可以将同一份代码编译到不同平台,但如果共享代码需要调用平台专属的 API 怎么办?比如获取设备信息、打印日志、读写文件等,这些操作在 Android 和 iOS 上的实现截然不同。
KMP 为此提供了一个优雅的解决方案:expect / actual 机制。
这个机制就像是定义一套“接口契约”:
-
expect:在共享模块(
commonMain)中,你声明一个类、函数或属性,但只定义它的“期望”形态,不提供任何具体实现。这相当于一份公开的契约,承诺“所有平台都将提供这个功能”。// In commonMain expect class PlatformLogger() { fun log(tag: String, message: String) }这个
expect声明本身不产生任何可执行代码,它只是一个占位符和一份对各平台的“硬性要求”。 -
actual:在特定平台的模块(如
androidMain和iosMain)中,你必须为这份契约提供“实际”的实现。Android 端的实现 (
androidMain):// In androidMain import android.util.Log actual class PlatformLogger actual constructor() { actual fun log(tag: String, message: String) { Log.d(tag, message) // 调用 Android 平台的 Logcat } }iOS 端的实现 (
iosMain):// In iosMain import platform.Foundation.NSLog actual class PlatformLogger actual constructor() { actual fun log(tag: String, message: String) { NSLog("%s: %s", tag, message) // 调用 iOS 平台的 NSLog } }
当编译器工作时,它会确保每一个 expect 声明在所有目标平台都有一个对应的 actual 实现。在链接最终产物时,它会巧妙地将共享模块中对 expect 的调用,替换为对应平台的 actual 实现。
这个机制的强大之处在于,它将“平台差异”这个棘手的问题,隔离在了 androidMain 和 iosMain 这两个模块中,而你的核心业务逻辑可以继续待在 commonMain 里,干净、纯粹,完全不知道底层的平台差异。
并发与内存管理:从“冰封王座”到“自由通行”
并发编程和内存管理是跨平台框架永远的痛点,也是 KMP 演进过程中的一个核心议题。尤其是在 Kotlin/Native 这边,它的内存模型经历了一次重大的变革。
旧时代的“冰封”模型:严格但痛苦的线程安全
在 Kotlin 1.7.20 之前,Kotlin/Native 采用了一套非常严格的内存模型,我们通常称之为“冰封(Freezing)”模型。它的核心规则是:
- 对象所有权:一个可变对象在任何时候只能被一个线程访问和修改。
- 线程间共享:如果你想在多个线程间共享一个对象,你必须先调用
.freeze()方法将其“冰封”。 - 冰封的代价:一旦对象被冰封,它就变成了彻头彻尾的不可变对象。任何尝试修改它的行为都会在运行时抛出
InvalidMutabilityException异常。
这套模型虽然从根本上杜绝了多线程数据竞争问题,但也给开发者带来了巨大的心智负担。尤其是在使用协程时,你必须时刻小心翼翼,确保传递给其他线程(Worker)的数据是已经“冰封”的,否则就会在不经意间遭遇运行时崩溃。这曾是劝退许多 KMP 尝鲜者的主要原因之一。
新时代的垃圾回收(GC):更自由、更直观的并发
幸运的是,随着 Kotlin 1.7.20 版本的发布,全新的 Kotlin/Native 内存管理器成为了默认选项。这次变革意义重大:
- 废除冰封:
freeze()机制被废弃。你不再需要在线程间共享对象时手动冰冻它们。 - 引入并发 GC:新的内存管理器实现了一个与 JVM 类似的、支持并发的垃圾回收器。现在,对象可以在不同线程之间自由传递和访问,就像你在写普通 JVM 程序一样。
- 并发更简单:
kotlinx.coroutines也随之升级。你不再需要使用特殊的native-mt版本,可以直接使用标准库。在不同调度器(Dispatchers)之间切换上下文变得和在 Android 上一样自然流畅,不再有InvalidMutabilityException的困扰。
这场变革极大地降低了在 KMP 中编写多线程代码的复杂度,使得 Kotlin 强大的协程能力在全平台(包括 iOS)得以完全释放。可以说,新内存模型的落地,是 KMP 走向成熟的关键里程碑,扫清了大规模应用的最核心障碍之一。
丝滑互操:Kotlin 如何与 Swift/Objective-C 对话
我们已经知道 Kotlin 代码被编译成了原生的 iOS Framework。那么,在 Xcode 项目中,Swift 或 Objective-C 代码是如何调用到这个 Framework 里的功能的呢?
答案是:Kotlin/Native 编译器贴心地为我们生成了 Objective-C 桥接头文件。
自动生成的“翻译官”:Objective-C 头文件
当你编译 KMP 模块时,编译器会分析所有公开(public)的类和函数,并自动生成一个 .h 头文件。这个头文件就是 Kotlin 世界与 Apple 世界沟通的桥梁。
比如,我们在 commonMain 中有这样一个简单的类:
// In commonMain
class Greeting {
fun greet(): String = "Hello from KMP!"
}
编译后,在生成的 Framework 中,你会找到一个类似这样的头文件:
// Generated by Kotlin/Native compiler
// In shared.framework/Headers/shared.h
NS_ASSUME_NONNULL_BEGIN
__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("Greeting")))
@interface SharedGreeting : NSObject
- (instancetype)init __attribute__((swift_name("init()")));
- (NSString *)greet __attribute__((swift_name("greet()")));
@end
NS_ASSUME_NONNULL_END
有了这个头文件,Swift 代码就可以像调用一个普通的 Objective-C 库一样,自然地使用你的 Kotlin 代码了:
import shared // 引入 KMP 模块
let greeting = Greeting() // 注意,类名可能被 Swift 友好地重命名了
print(greeting.greet()) // 输出: Hello from KMP!
这个过程是无缝的,Swift 编译器通过读取这个头文件,就能理解 Kotlin 模块暴露出的 API。数据类型(如 String 到 NSString)也会被自动转换。整个调用链路非常高效,几乎没有性能损失。
KMP 的“集装箱”:.klib 文件格式
在 KMP 的世界里,你还会经常听到一个词:.klib (Kotlin Library)。
.klib 文件是 Kotlin 用来分发多平台库的格式。你可以把它理解为一个“集装箱”。
- 对于 Kotlin/Native 目标,
.klib文件里装着序列化后的 Kotlin IR,以及编译好的 LLVM bitcode。当你的项目依赖一个 KMP 库时,编译器会从.klib中取出这些预编译好的内容,进行链接,而不需要重新从源码编译一遍,大大加快了构建速度。 - 对于 Kotlin/JVM 或 Kotlin/JS,
.klib则扮演着类似.jar或 JS 包的角色。
它是一种统一的、包含了平台相关产物的库格式,是 KMP 生态系统得以建立的基石。
工程实践:从开发到发布的完整链路
理论讲完了,我们来看看在实际项目中,这一切是如何串联起来的。一个典型的 KMP 项目,从代码共享到最终在 Android 和 iOS 上运行,会经历以下链路:
-
Gradle 构建:一切的起点。你的项目通过 Gradle 进行配置,指定不同的目标平台(
android,ios,jvm等)。 -
KMP Gradle 插件:这个插件是总指挥,它会根据你的配置,调用相应的 Kotlin 编译器(Kotlin/JVM, Kotlin/Native)来处理
commonMain和各平台专属的sourceSet。 -
Android 端产出:对于 Android,编译产物是一个 AAR 文件,它和普通的 Android 库文件没什么两样,会被主 App 模块依赖,最终打包进 APK/AAB。
-
iOS 端产出:对于 iOS,编译产物是一个 Framework。
-
集成到 Xcode:如何让 Xcode 项目方便地用上这个动态生成的 Framework 呢?最主流的方式是通过 CocoaPods。KMP 的 Gradle 插件可以自动为你生成一个
podspec文件。你只需要在 iOS 项目的Podfile中,通过:podspec指向这个本地文件即可。# In your iOS project's Podfile target 'YourIOSApp' do # ... other pods pod 'shared', :podspec => '../shared/shared.podspec' end之后,每次 Xcode 执行
pod install或pod update,CocoaPods 就会自动找到 KMP 编译好的 Framework,并配置好所有必要的链接和构建设置。
通过这套流程,KMP 丝滑地融入了 Android 和 iOS 各自成熟的构建生态中。
常见“坑”与实践建议
KMP 虽然强大,但在实践中也并非一帆风顺。这里分享一些来自社区和我们自身经验的提醒:
- 构建速度:Kotlin/Native 的编译速度(特别是首次编译或clean build)通常比 Kotlin/JVM 慢。这是因为它需要执行 LLVM 优化等更重的任务。善用 Gradle 的缓存机制,避免不必要的 clean build,可以有效提升日常开发效率。
- 调试:虽然 Android Studio 提供了越来越好的 KMP 调试支持,但调试 iOS 端的特定问题时,有时还是需要回到 Xcode。熟悉在 Xcode 中设置断点、查看原生调用栈,是 KMP 开发者的必备技能。
- 依赖库的选择:并非所有的 Java/Kotlin 库都能在 KMP 中使用。在为共享模块选择依赖时,要优先寻找明确支持 KMP 的库(例如 Ktor, SQLDelight, kotlinx.serialization)。对于那些还没有 KMP 支持的库,你可能需要通过
expect/actual机制自己动手封装一层。 - Swift/Objective-C 互操作的边界:虽然 KMP 提供了良好的互操作性,但某些 Kotlin 的高级特性(如泛型擦除、复杂的泛型签名)在暴露给 Swift 时可能表现不佳或难以使用。通常建议在 Kotlin 代码和 Swift/ObjC 的边界上,保持 API 的简洁和清晰,尽量使用基础数据类型和简单的类。
- 从哪里开始共享? 对于新项目,可以从一开始就规划好共享模块。对于现有项目,最佳的切入点是那些与 UI 无关、纯业务逻辑的部分,比如:
- 数据层:Repository、DataSource、网络请求(DTOs)、数据库实体(Models)。
- 领域层:UseCases、业务规则、数据校验逻辑。
- 工具类:日期处理、加解密、日志封装等。
先从一小块逻辑开始共享,逐步扩大范围,是风险最低、见效最快的策略。
总结:不是魔法,是更高维度的工程智慧
现在,让我们回到最初的起点。
KMP 并非变魔术般地让一份代码“分裂”成能在不同系统上运行的程序。它的核心是一种**“关注点分离”**的工程哲学,在编译阶段被发挥到了极致:
- 分离了“业务逻辑”与“平台实现”:你的核心思想(IR)被固化下来。
- 分离了“编译前端”与“编译后端”:让同一份 IR 可以被不同平台的“工匠”(后端)以最地道的方式去诠释和构建。
当你下一次在团队中推广或讨论 KMP 时,如果再有人抛出那个“这怎么可能”的经典疑问,你便可以自信地告诉他:
“这并非不可能,因为 KMP 玩的不是‘运行时翻译’的把戏,而是‘编译期生成’的硬核科技。同一份 Kotlin 设计蓝图,在 Android 上被编译成了 ART 能懂的 DEX 码,在 iOS 上则被 LLVM 锻造成了 CPU 直接执行的 ARM 机器码。它们生来就是各自平台上的‘原住民’,所以才能拥有不折不扣的原生性能。”
这,就是 KMP 背后真正的“编译魔法”——一种源于深刻理解不同平台本质、并以高超编译器技术将其统一的工程智慧。希望这篇文章,能帮你彻底看懂这背后的门道。
更多推荐

所有评论(0)