在这里插入图片描述

最近 Kotlin Multiplatform (KMP) 势头正劲,Google 官方背书,众多大厂纷纷入局(比如 Netflix、McDonald’s)。它宣称能让同一份 Kotlin 代码“原生”运行在 Android 和 iOS 上。这时候,那个熟悉的问题又来了,而且这次更让人困惑:

“等一下,Android 跑的是 JVM,用的是 Dalvik 字节码;iOS 根本没有 JVM,跑的是 ARM 架构的机器码。这俩体系截然不同,同一份 Kotlin 代码怎么可能同时在它俩上面‘原生’运行?这不科学!”

这背后并非什么“魔法”,而是堪称艺术的编译器工程。今天,就让我为你层层揭开这层神秘面纱,用一个清晰的心智模型,带你彻底搞懂 KMP 跨平台的底层逻辑。

核心心智模型:一份蓝图,各自施工

在深入技术细节前,我们先来建立一个核心心智模型。想象一下,你要在上海和北京各建一座一模一样的现代化办公楼。

你不会运送一整座建好的楼过去,对吗?正确的做法是:

  1. 制定一份通用蓝图 (Shared Blueprint):这份蓝图用一种标准化的语言(比如建筑信息模型 BIM)详细描述了办公楼的每一处设计、结构、材料规格。这就是你的 共享 Kotlin 代码 (commonMain)。
  2. 组建两支本地施工队 (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 开发者最熟悉的路径。

  1. Kotlin/JVM 后端接收 IR:它读取通用的 IR。
  2. 生成 JVM 字节码:将 IR 转换成标准的 .class 文件,也就是 Java 虚拟机认识的字节码。
  3. Android 构建工具接力:Android 的构建工具链(D8/R8)会接手这些 .class 文件,把它们和其他库的字节码一起打包、优化,最终转换成 Android 运行时(ART)专用的 DEX 字节码
  4. 打包成 APK/AAB:最终,DEX 文件和其它资源一起被打包成安装包。

结论:在 Android 平台上,KMP 的共享代码和你手写的原生 Android Kotlin 代码,经历的路径几乎完全一样。它们最终都变成了高效的 DEX 字节码,由 ART 直接执行。所以,在 Android 端,KMP 的性能就是原生 Kotlin 的性能,没有任何损耗。

场景二:iOS 施工队 (Kotlin/Native Backend)

这部分是 KMP 实现 iOS 原生运行的关键,过程要复杂一些:

  1. Kotlin/Native 后端接收 IR:同样,它也读取那份通用的 IR。
  2. 借助 LLVM 生成机器码:Kotlin/Native 自身并不直接生成机器码,而是将 IR 编译成另一种更底层的中间表示——LLVM IR。LLVM 是一个久经考验的、强大的编译器基础设施,被苹果(Swift/Clang)、Rust 等众多语言广泛使用。
  3. LLVM 编译和链接:LLVM 会接手 LLVM IR,进行一系列深度优化(这步是性能的关键),然后根据你的目标设备(比如 iPhone 15 Pro 的 A17 Pro 芯片),将其编译成对应架构的原生机器码(比如 ARM64)。
  4. 打包成 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:在特定平台的模块(如 androidMainiosMain)中,你必须为这份契约提供“实际”的实现。

    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 实现。

这个机制的强大之处在于,它将“平台差异”这个棘手的问题,隔离在了 androidMainiosMain 这两个模块中,而你的核心业务逻辑可以继续待在 commonMain 里,干净、纯粹,完全不知道底层的平台差异。

并发与内存管理:从“冰封王座”到“自由通行”

并发编程和内存管理是跨平台框架永远的痛点,也是 KMP 演进过程中的一个核心议题。尤其是在 Kotlin/Native 这边,它的内存模型经历了一次重大的变革。

旧时代的“冰封”模型:严格但痛苦的线程安全

在 Kotlin 1.7.20 之前,Kotlin/Native 采用了一套非常严格的内存模型,我们通常称之为“冰封(Freezing)”模型。它的核心规则是:

  1. 对象所有权:一个可变对象在任何时候只能被一个线程访问和修改。
  2. 线程间共享:如果你想在多个线程间共享一个对象,你必须先调用 .freeze() 方法将其“冰封”。
  3. 冰封的代价:一旦对象被冰封,它就变成了彻头彻尾的不可变对象。任何尝试修改它的行为都会在运行时抛出 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。数据类型(如 StringNSString)也会被自动转换。整个调用链路非常高效,几乎没有性能损失。

KMP 的“集装箱”:.klib 文件格式

在 KMP 的世界里,你还会经常听到一个词:.klib (Kotlin Library)。

.klib 文件是 Kotlin 用来分发多平台库的格式。你可以把它理解为一个“集装箱”。

  • 对于 Kotlin/Native 目标,.klib 文件里装着序列化后的 Kotlin IR,以及编译好的 LLVM bitcode。当你的项目依赖一个 KMP 库时,编译器会从 .klib 中取出这些预编译好的内容,进行链接,而不需要重新从源码编译一遍,大大加快了构建速度。
  • 对于 Kotlin/JVMKotlin/JS.klib 则扮演着类似 .jar 或 JS 包的角色。

它是一种统一的、包含了平台相关产物的库格式,是 KMP 生态系统得以建立的基石。

工程实践:从开发到发布的完整链路

理论讲完了,我们来看看在实际项目中,这一切是如何串联起来的。一个典型的 KMP 项目,从代码共享到最终在 Android 和 iOS 上运行,会经历以下链路:

  1. Gradle 构建:一切的起点。你的项目通过 Gradle 进行配置,指定不同的目标平台(android, ios, jvm 等)。

  2. KMP Gradle 插件:这个插件是总指挥,它会根据你的配置,调用相应的 Kotlin 编译器(Kotlin/JVM, Kotlin/Native)来处理 commonMain 和各平台专属的 sourceSet

  3. Android 端产出:对于 Android,编译产物是一个 AAR 文件,它和普通的 Android 库文件没什么两样,会被主 App 模块依赖,最终打包进 APK/AAB。

  4. iOS 端产出:对于 iOS,编译产物是一个 Framework

  5. 集成到 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 installpod 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 背后真正的“编译魔法”——一种源于深刻理解不同平台本质、并以高超编译器技术将其统一的工程智慧。希望这篇文章,能帮你彻底看懂这背后的门道。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐