同一份 Kotlin 代码如何在 Android 和 iOS 上运行?揭秘 KMP 背后的编译器魔法
深入探讨“一次编写,多端部署”在编译器层面究竟发生了什么

作为一名 Android 开发新手,当我第一次听说 Kotlin Multiplatform (KMP) 时,一个问题一直困扰着我:
“完全相同的 Kotlin 代码,怎么可能同时在 Android 和 iOS 上原生运行?”
我的意思是,Android 使用 JVM,iOS 不用。Android 运行 Dalvik 字节码,iOS 运行 ARM 机器码。它们是完全不同的架构。那么这到底是如何实现的呢?
答案不是“魔法”——而是卓越的编译器工程。一旦我理解了编译层面发生的事情,一切都豁然开朗了。
让我为你揭示幕后的真实运作机制。
根本区别:编译 vs 解释
这里有一个关键见解,可以回答我们的问题:
KMP 不会创建一个到处都能运行的通用二进制文件。相反,Kotlin 编译器会为每个平台创建不同的原生二进制文件。
React Native 和 Flutter 使用运行时桥接或虚拟机。KMP 在编译器层面做了根本不同的事情。
这些框架创建了一个在运行时解释你代码的“包装器”。KMP 的做法则截然不同:
- 你编写一次 Kotlin 代码(你的业务逻辑、网络请求、数据库操作)
- Kotlin 编译器为每个平台生成原生代码:
- Android → Kotlin/JVM 字节码(就是你已经在写的代码!)
- iOS → 通过 Kotlin/Native 生成的原生 ARM64 机器码
- JavaScript → 通过 Kotlin/JS 生成的实际 JS 代码
- 桌面端 → 原生二进制文件
输出不是一个包装器。它是针对每个平台的实际原生代码。
仔细想想。你的 Kotlin 代码变成了真正的原生 iOS 代码,而不是在桥接中运行的 JavaScript。这就是为什么 KMP 应用的性能与完全原生的应用一样好。
🧩 三层架构
KMP 遵循一个清晰的架构模式,分离关注点:
┌───────────────────────────────────────────────┐│ 平台特定代码 (UI 层) ││ Android: Jetpack Compose/XML ││ iOS: SwiftUI/UIKit │└───────────────────────────────────────────────┘│┌───────────────────────────────────────────────┐│ 共享业务逻辑 (Kotlin 共享模块) ││ 网络请求、数据库、业务规则 │└───────────────────────────────────────────────┘│┌───────────────────────────────────────────────┐│ 平台 API 适配器 (expect/actual) ││ 文件系统、网络、传感器等 │└───────────────────────────────────────────────┘
关键点:
- 你编写共享的 Kotlin 代码,放在一个共享模块中。
- Kotlin 编译器有多个后端——每个平台一个。
- 每个后端将你的代码编译为原生格式:
- Android → JVM 字节码 (
.class 文件 → DEX 字节码) - iOS → ARM64/x86_64 机器码 (原生二进制文件)
- JavaScript → JavaScript 源代码
- 桌面端 → 原生可执行文件 (通过 LLVM)
核心在于: 这些是相同输入产生的不同输出。
你的 Kotlin 源代码经过不同的编译流水线,每条流水线都为其目标平台生成真正的原生代码。没有解释,没有桥接,没有性能损失。
让我向你展示每条流水线中具体发生了什么。
🔧 编译过程:实际发生了什么
让我们写一些简单的 Kotlin 代码,并精确追踪它为 Android 和 iOS 编译时发生了什么。
你的共享 Kotlin 代码
// 在 commonMain/kotlin/UserRepository.kt
classUserRepository {
fun getUser(id: String): User {
return User(id, "John Doe")
}
}
data classUser(val id: String, val name: String)
很简单。现在看看当你构建项目时会发生什么。
📱 编译流水线 #1:Android (Kotlin/JVM)
逐步过程
- Kotlin 源代码 (
UserRepository.kt) - Kotlin/JVM 编译器 (
kotlinc) - JVM 字节码 (
.class 文件) - D8 编译器 (Android 构建工具)
- DEX 字节码 (
.dex 文件) - 打包进 APK/AAB
- 在 Android 运行时 (ART) 上运行
字节码的样子
编译后,如果你反编译 Android 字节码,你会看到类似这样的内容:
// 反编译的 JVM 字节码 (简化版)
public final classUserRepository {
public final User getUser(String id) {
return new User(id, "John Doe");
}
}
public final classUser {
private final String id;
private final String name;
public User(String id, String name) {
this.id =id;
this.name = name;
}
//... getters
}
这就是你的 Android 应用一直运行的代码! KMP 只是复用你已经拥有的编译流水线。
🍎 编译流水线 #2:iOS (Kotlin/Native)
逐步过程
- 相同的 Kotlin 源代码 (
UserRepository.kt) - Kotlin/Native 编译器 (使用 LLVM)
- ARM64 机器码 (原生二进制文件)
- 打包为
.framework 包 - Swift/Objective-C 可以直接导入和使用
- 直接在 iOS 硬件上运行——没有虚拟机!
这是关键见解:Kotlin 编译器不会创建一个通用二进制文件。它会创建针对每个目标平台优化的平台特定原生代码。
💡 expect/actual 模式
但是平台特定的功能怎么办?当相机或文件系统在每个平台上的工作方式不同时,你如何访问它们?
答案是:expect/actual 声明。
公共代码 (你需要什么)
// 在 commonMain - 共享代码
expect classPlatformLogger {
fun log(message: String)
}
// 你的共享代码现在可以使用它:classAuthService {
private val logger = PlatformLogger()
fun login(email: String) {
logger.log("User logging in: $email")
//... 其余的登录逻辑
}
}
平台实现 (每个平台如何实现)
// 在 androidMain
actual classPlatformLogger {
actual fun log(message: String) {
android.util.Log.d("MyApp", message)
}
}
// 在 iosMain
actual classPlatformLogger {
actual fun log(message: String) {
NSLog("MyApp", message)
}
}
编译时, 每个平台获得其自己的 PlatformLogger 实现。共享代码调用 log(),但实际执行的是平台特定的代码。
🤝 互操作性:iOS 如何调用你的 Kotlin 代码
编译后,你得到一个 .framework 包。但是 Swift 需要调用你的 Kotlin 代码。这是如何工作的?
Kotlin/Native 编译器生成 Objective-C 头文件
对于我们的 UserRepository 例子,编译器会自动生成:
// 自动生成的头文件 (UserRepository.h)
@interface SharedUserRepository : NSObject
- (instancetype)init;
- (SharedUser *)getUserWithId:(NSString *)id;
@end@interface SharedUser : NSObject
@property (readonly) NSString *id;
@property (readonly) NSString *name;
- (instancetype)initWithId:(NSString *)id name:(NSString *)name;
@end
在 Swift 中使用
Swift 现在可以自然地使用你的 Kotlin 代码:
importShared// 你的 KMP 模块
let repository = UserRepository()
let user = repository.getUser(id: "123")
print("User name: \(user.name)")
这里发生了什么?
- Swift 调用 Objective-C 互操作层
- Objective-C 桥接到你的 Kotlin/Native 代码
- 你的 Kotlin 代码作为原生 ARM64 机器码运行
- 结果通过互操作层传回
- Swift 接收到原生的 Objective-C/Swift 对象
互操作层具有接近零的开销——它只是函数调用转发,没有序列化或消息传递。
追踪一个函数在两个平台上的执行
让我们追踪一个真实的函数通过两个编译流水线,看看其中的差异:
共享的 Kotlin 代码
// 在 commonMain
classCalculator {
fun add(a: Int, b: Int): Int {
return a + b
}
}
Android 编译后 (JVM 字节码)
// 反编译的 JVM 字节码
public final classCalculator {
public final int add(int a, int b) {
return a + b;
}
}
当它在 Android 上运行时:
JVM 栈:
1. 将 'a' 压入栈
2. 将 'b' 压入栈
3. 执行 IADD 指令 (整数加法)
4. 从栈返回结果
iOS 编译后 (ARM64 汇编)
; ARM64 汇编 (简化版)
_Calculator_add:
add w0, w0, w1 ; w0 = w0 + w1 (32 位加法)
ret ; 结果返回到 w0
当它在 iOS 上运行时:
ARM64 寄存器:
1. 参数 'a' 在寄存器 w0 中
2. 参数 'b' 在寄存器 w1 中
3. 执行 ADD 指令 (直接 CPU 操作)
4. 结果保留在 w0 中 (返回寄存器)
注意区别:
- Android:使用基于栈的 JVM 指令
- iOS:使用基于寄存器的 ARM 指令
两者都是各自平台的原生代码——没有解释发生!
🧠 内存管理:平台间的差异
编译器层面的另一个关键区别是内存管理方式。
Android (JVM 垃圾回收)
// 你的代码
val user = User("123", "John")
//...// user 超出作用域
发生了什么:
- 对象在 JVM 堆上分配
- GC 跟踪引用
- 当没有引用存在时,GC 标记为待回收
- GC 定期运行以释放内存
iOS (自动引用计数)
相同的 Kotlin 代码编译为:
- 对象在原生堆上分配
- 引用计数器 = 1
- 当传递/存储时:计数器++
- 当引用丢弃时:计数器--
- 当计数器 = 0 时:立即释放
编译器生成不同的内存管理代码!
对于我们的 User 对象,iOS 编译包含:
// 生成的原生代码伪代码
User* user = User_create("123", "John"); // ref_count =1
retain(user); // ref_count =2//... 使用 user ...
release(user); // ref_count =1
release(user); // ref_count =0→ 释放
你编写相同的 Kotlin 代码,但编译器为每个平台以不同的方式处理内存。
❌ 常见误解澄清
一个常见问题:“如果相同的代码在不同的平台上运行,那不就意味着在运行时发生了一些翻译吗?”
不!原因如下:
React Native/Flutter (运行时桥接):
你的代码 → JavaScript → 桥接 → 原生 API (在运行时解释,有序列化开销)
KMP (编译时转换):
你的代码 → 平台特定的原生代码 (没有运行时翻译,直接执行)
项目结构
myapp/├── commonMain/kotlin/│└── Calculator.kt
├── androidMain/kotlin/│└── MainActivity.kt
└── iosMain/kotlin/└── iOS.kt
构建输出
运行 ./gradlew build 后:
build/├── android/│└── classes/│└── Calculator.class← JVM 字节码
│└── ios/└── MyApp.framework/├── MyApp ← ARM64 二进制文件 (原生机器码)
├── Headers/│└── MyApp.h ← Objective-C 互操作头文件
└── Info.plist
KMP 不是:
- ❌ 运行时桥接 (如 React Native)
- ❌ 虚拟机 (如桌面端的 Java)
- ❌ 解释器
- ❌ WebView 包装器
KMP 是:
- ✅ 一个具有多个后端的编译器
- ✅ 每个后端为其平台生成原生代码
- ✅ 零运行时开销——没有解释
- ✅ 编译时代码转换,而非运行时翻译
心智模型:
你的 Kotlin 源代码
││┌──────────────┼──────────────┐│││▼▼▼
JVM 后端 原生后端 JS 后端
│││▼▼▼
JVM 字节码 ARM64 代码 JavaScript
│││▼▼▼
Android ART iOS CPU 浏览器
相同的源代码 → 不同的编译路径 → 不同的原生输出。
这就是为什么它的性能像原生代码:它本来就是原生代码!
🎯 结论
Kotlin Multiplatform 的魔力其实不是魔法——而是卓越的编译器工程。
通过使用多个后端,每个后端都生成真正的原生代码,KMP 让你鱼与熊掌兼得:
- ✅ 代码共享 (一次编写)
- ✅ 原生性能 (无开销)
- ✅ 平台特定优化 (为每个目标量身定制)
当有人问你 “相同的 Kotlin 代码如何在 Android 和 iOS 上运行?” 时,你现在可以解释:
“它并没有。相同的 Kotlin 源代码被编译成针对每个平台的不同原生代码。在 Android 上,它变成 JVM 字节码。在 iOS 上,它通过 LLVM 变成 ARM64 机器码。编译器在构建时完成所有繁重的工作,因此运行时开销为零。”
这才是真正的答案。这也是 KMP 如此强大的原因。
原文链接:https://proandroiddev.com/how-does-the-same-kotlin-code-run-on-both-android-and-ios-the-compiler-magic-behind-kmp-b4450f87f7a8