Skip to content
Published on

Kotlin Swift export 进入 Alpha — 正在拆掉 Objective-C 这座桥,但还不到过桥的时候

分享
Authors

引言 — KMP 在 iOS 上缴的税

Kotlin Multiplatform 的卖点一直很干净:业务逻辑只写一次,Android 和 iOS 共用。在 Android 这一侧,这句话确实成立——毕竟是同一种语言、同一套工具链。

问题一直出在 iOS 这一侧的边界上。因为 Kotlin/Native 产出的东西,并不是 Swift 能直接读的东西。Kotlin 吐出的是一个 Objective-C 框架,而 Swift 要把那份 Objective-C 头文件再翻译成自己能看见的 API 才能用。也就是说,Kotlin 和 Swift 之间夹着一门谁都没要求过的第三种语言。两边明明都是把空安全写进类型系统的现代语言,中间的翻译却是一门 1984 年出生的语言。

这份翻译成本不是抽象的。正如下文将看到的,类型会被压扁,名字会被搞乱,协程会退化回回调。Swift export 就是要把这座桥彻底拆掉的一次尝试,它在 Kotlin 2.4.0(2026 年 6 月 3 日发布)中进入了 Alpha。

进入 Alpha 本身是好消息。可一旦你去核实「Alpha」在 JetBrains 词典里到底精确指什么,立刻开工单迁移的冲动大概率会消退。本文把这两面都摆出来。

Objective-C 这座桥到底破坏了什么

先得看清楚这个功能为什么存在——这些是 Kotlin 自己的 Objective-C interop 文档承认的损失。

基础类型装箱。 照文档原话,kotlin.Int 的装箱值在 Swift 里表现为一个 KotlinInt 类的实例,这些类都派生自 NSNumber。反方向不会自动发生——文档明确写道,当 NSNumber 被用作 Swift/Objective-C 的参数或返回类型时,不会自动转换为 Kotlin 的基础类型。原因是 NSNumber 并不在静态层面携带「被包裹的值到底是 ByteBoolean 还是 Double」这类信息,所以必须手动转换。传一个可空的整数,就会多出一个堆分配的包装器,Swift 那侧还得多写手动转换代码。

泛型损失。 Objective-C 的「lightweight generics」窄到装不下 Kotlin 的泛型。文档钉死的约束有两条。第一,泛型只能定义在类上,不能定义在接口(即 Objective-C/Swift 里的 protocol)或函数上。第二,可空性会被压扁。照搬文档的例子,这样一段 Kotlin 代码

class Sample<T>() {
    fun myVal(): T
}

在 Swift 里看起来是这样:

class Sample<T>() {
    fun myVal(): T?
}

T 变成了 T?,因为生成出来的 Objective-C 头文件不得不把 myVal 声明为可空。文档给出的规避办法是直接给 T 加上 T : Any 的上界,让头文件能够标注成 non-null——换句话说,就是把 Kotlin 一侧的 API 设计,硬掰去迁就 Objective-C 头文件的处境。

协程变成回调。 Kotlin 的 suspend 函数,在生成出来的 Objective-C 头文件里,会变成一个接收回调的函数,用 Swift 的说法就是 completion handler。从 Swift 5.5 起,虽然多了一条可以把它当 async 函数来调用的路,但文档写明这条路「highly experimental」并带有特定限制,细节要看 KT-47610。截至本文写作的 2026 年 7 月 16 日,这个 issue 依然是 Open 状态,归在 Language Design 和 Native 两个子系统下,打着 ObjC Export 的标签。这个开了好几年的 issue,浓缩地说明了为什么需要 Swift export 这样一条单独的路径。

名字被搅乱。 这正是 Swift export 文档自己给出的动机——消除 Objective-C 那些让人费解的下划线和被搅乱的名字(mangled names)。如果你见过顶层函数挂在某个类似 SharedKt 的类下面冒出来,就会明白这是在说什么。

归纳一下,Objective-C 这座桥没法把 Kotlin 的 API 原样搬过去,只能投影到 Objective-C 能表达的那个子集上。这份损失在设计上就是不可避免的。

Swift export 在做什么

Swift export 的想法很简单:把中间语言拿掉。

官方文档开篇第一句就说得很准——Swift export 直接导出 Kotlin 源码,让 Swift 能以惯用方式调用 Kotlin 代码,并消除 Objective-C 头文件。编译器会直接生成 swiftmodule 文件、静态 .a 库、头文件、modulemap 文件,再拷贝进应用的构建目录。

配置只是 Gradle 一侧的一个代码块。

// build.gradle.kts
kotlin {
    iosArm64()
    iosSimulatorArm64()

    swiftExport {
        // 根模块名
        moduleName = "Shared"

        // 从生成的 Swift 代码中去掉包名前缀
        flattenPackage = "com.example.sandbox"

        // 外部模块的 export 配置
        export(project(":subproject")) {
            moduleName = "Subproject"
            flattenPackage = "com.subproject.library"
        }
    }
}

然后在 Xcode 的 Build Phases 里,把 Run Script 的任务从 embedAndSignAppleFrameworkForXcode 换成 embedSwiftExportForXcode。文档给出的脚本形式是 ./gradlew :<模块名>:embedSwiftExportForXcode

上一节看到的损失,有几项确实消失了。以下是文档列出的内容。

  • 基础类型可空性得到改善。 不同于必须把 Int? 这类类型装箱进 KotlinInt 包装器的 Objective-C interop,Swift export 会直接转换可空性信息。
  • 支持多模块。 一个 Kotlin 模块对应导出为一个 Swift 模块。
  • 保留包结构。 Kotlin 的包会被显式保留,避免命名冲突(会被翻译成嵌套的 Swift enum)。
  • 重载。 Kotlin 里重载的函数,在 Swift 中可以无歧义地调用。
  • 类型别名。 typealias 会原样保留在 Swift 里。

而且在 Kotlin 2.3.0(2025 年 12 月 16 日)里又加了两项。Kotlin 的 enum class 不再像以前那样导出为普通的 Swift 类,而是开始映射为真正的原生 Swift enumvararg 函数也开始直接映射为 Swift 的可变参数。

2.4.0 改变了什么 — 升级,以及协程

Kotlin 2.4.0 发行说明把这次变化的核心归纳成两点。

第一,结构化并发。 Kotlin 的 suspend 函数和 suspend 函数类型,现在会导出为 Swift 惯用的 async,而不再是 completion handler。

// Kotlin
suspend fun hello(): String {
    delay(1000)
    return "Hello Swift! This is Kotlin."
}
// Swift
let msg = try await hello()

第二,Flow 导出。 这一项在实务上分量更重。正如文档精确指出的那样,在此之前,要把 kotlinx.coroutines.flowFlow 接口暴露给 Swift,唯一的办法是靠第三方方案。现在 Flow 会默认导出为 Swift 的 AsyncSequence,且类型信息也会保留。

// Kotlin
fun flowOfStrings(): Flow<String> = flowOf("hello", "any", "world")
// Swift
var actual: [String] = []

// String 类型能从 Kotlin 正确推断出来
for try await element in flowOfStrings().asAsyncSequence() {
    actual.append(element)
}

真正在生产环境里跑过 KMP 的人,都清楚这一行代码的分量。「Kotlin 一侧的状态流该怎么接进 SwiftUI」,一直是采用 KMP 的团队必然会撞上的问题,而至今为止的答案,一直都是第三方包装器。

值得一提的是,这是默认开启的。Swift export 本身是 opt-in 的,但在它内部,Flow export 不需要额外单独打开。

这次升级的门槛究竟是什么,也很值得玩味。Kotlin 路线图上挂着「Swift Export: Alpha release」标签的那个条目,实际是 KT-80305,而工单的真实标题是「Support coroutines in Swift Export」。它在 2026 年 3 月 24 日被标记为 Fixed 并关闭,并入了 2.4.0-Beta2。也就是说,Alpha 的门槛实际上就是「协程能不能跑起来」。 反过来说,这也意味着,在协程还跑不起来的那段时间里,JetBrains 自己就判断这个功能撑不起 iOS 应用真正的 API 表面。

「Alpha」在 JetBrains 词典里意味着什么

这里得诚实一点。在很多项目里,Alpha 差不多是「快了」这种营销用语,但 Kotlin 把这个等级写进了文档里明确定义。照搬 components-stability 文档的定义:

Alpha 的意思是「we are testing whether this should become production-ready」——我们正在测试这个功能是否应该走向生产就绪。紧接着的说明才是关键:有意做成产品,正在验证用户价值和市场契合度以确定最终形态,功能集尚不完整,预计会有破坏性变更,而且「如果假设不成立,我们可能会大幅改动甚至放弃这个功能」(「we may significantly change or discontinue the feature」)。

拿它和紧挨着上面一级的 Beta 定义一比,差距就很清楚了。Beta 是「you can use it, we'll do our best to minimize migration issues for you」——里面带着一个承诺。Alpha 没有这个承诺。Kotlin 文档把 Experimental、Alpha、Beta 归成一类,统称 pre-stable。

文档补的另外一句话也值得原样引用——稳定性等级并不能说明这个组件多快会发布为 Stable。也就是说,进入 Alpha 完全不代表 Beta 或 Stable 已经近在眼前。

Swift export 文档正文的语气也是一致的:"currently in Alpha and still incomplete, so breaking changes are expected."

目前公开的 Kotlin 路线图写着最后一次更新是 2026 年 2 月,下一次预计更新在 2026 年 8 月。路线图上挂着的 Swift export 条目只到 Alpha release 为止,没有列出瞄准 Beta 或 Stable 的条目。2.4.0 之后的下一个 EAP——2.4.20-Beta1(2026 年 6 月 24 日)的发行说明里,也没有提到 Swift export 相关的变化。在下一次路线图更新之前,仅凭公开材料,没有依据去推测 Beta 的时间点。与其编造一个不存在的日期,不如老实说没有。

还有一点——JetBrains 自己在这次发布里,也没有大张旗鼓地宣传 Swift export。2.4.0 发布博客里 Kotlin/Native 部分的总结那一整句是「Support for Swift packages as dependencies, updates on Swift export, and the CMS GC enabled by default」,仅此而已。「updates on Swift export」——没有把 Alpha 升级单独拎出来当标题,这件事本身就是一个信号。

还做不到的事

把文档 "Current limitations" 一节和正文里散落的注释收集起来,就是下面这份清单。每一条后面都附上它在真实项目里具体是怎么疼的。

仅限 direct integration。 这是最大的一堵墙。Swift export 只在使用 direct integration 把 iOS 框架接入 Xcode 项目的项目里才能工作。参照 iOS 集成方式文档,集成路径分五条——direct integration、本地 Swift 包、本地 podspec CocoaPods(以上三种属于 local),以及 SwiftPM + XCFramework、CocoaPods + XCFramework(属于 remote)。Swift export 只支持其中一条。而且,direct integration 本身就是用在「KMP 项目导入 CocoaPods 依赖」这种情况下的方式。归纳一下:

  • 把共享模块打包成 XCFramework 丢进独立 iOS 仓库的组织 — 不适用。
  • 在 KMP 一侧拉取 CocoaPods 依赖的项目 — 不适用。
  • 用 IntelliJ IDEA 的 KMP 插件或网页向导搭建的单体仓库(monorepo) — 适用。

这条约束也不是新出现的。在 Swift export 第一次默认随 Kotlin 2.2.20 提供时,发行说明里就已经写着同一句话,而在它进入 Alpha 的 2.4.0 版本文档里,这句话原封不动地留着。跨越三个版本,这堵墙没有挪动过。

泛型。 文档的注释又短又硬——"Generic types are generally not supported."。导出到 Swift 时,Kotlin 的泛型类型参数会被类型擦除为其上界(upper bound)。这里出现了一个讽刺:就泛型而言,Swift export 还没有比 Objective-C 这座桥更好。 Objective-C 至少在类上能承载 lightweight generics,而 Swift export 却直接把它擦除到上界。前面 Sample<T> 例子里的可空性问题解决了,代价是类型参数本身消失了。

类的约束。 Swift export 只支持直接继承 Any 的 final 类,也就是 class Foo() 这类东西。这意味着带继承层级的领域模型、或者 sealed 层级都无法原样导出。被导出的类,在 Swift 那侧会变成一个继承自 KotlinRuntime.KotlinBase 的类。

不支持跨语言继承。 Swift 类不能直接子类化从 Kotlin 导出的类或接口。如果你一直用的模式是在 Kotlin 一侧定义接口、在 Swift 一侧实现后传回去,这个模式没法原样搬过来。

继承集合的类型。 继承 ListSetMap 的类型,在导出过程中会被忽略(KT-80416),那些继承类型也无法在 Swift 一侧实例化(KT-80417)。截至 2026 年 7 月 16 日,两个 issue 都还未解决,状态都是 Backlog。Backlog 的意思是「没有排进任何版本」,而不是「排进了下一个版本」。顺带一提,这两张工单的实际标题比文档摘要更具体——分别是「function with receiver of MutableList is not translated」和「ByteArray is impossible to instantiate」。从后者能看出,连 ByteArray 这种常见类型都会撞上这个问题。

没有 IDE 支持。 文档写得很直白——"No IDE migration tips or automation are available."。没有工具帮你从 Objective-C 路径迁移过来,得靠手工。

opt-in 的麻烦。 如果用到需要 opt-in 的声明(比如 kotlinx.datetime),就得在模块层级另加一个代码块,显式声明 optIn 编译器选项。

operator 的限制。 文档写明,目前对带 operator 修饰符的函数的支持是有限的。

Int 变成 Int32 的问题

这一条没写进限制清单里,但真去读映射表的话,有个地方很扎眼。

KotlinSwift
IntInt32
LongInt64
CharUnicode.UTF16.CodeUnit
AnyKotlinBase
UnitVoid
NothingNever

Kotlin 的 Int 并没有映射到 Swift 的 Int,而是映射到了 Int32。从类型正确性的角度看这没问题——Kotlin 的 Int 确实是 32 位,而 Swift 的 Int 在 64 位平台上是 64 位,如果把它映射成 Int 就是在撒谎。文档的示例给出的结果是这样的。

// Kotlin
class MyClass {
    val property: Int = 0
}
// Swift
public class MyClass : KotlinRuntime.KotlinBase {
    public var property: Swift.Int32 { get }
}

正确,但站在 Swift 调用方的角度看并不惯用。Swift 代码里处理整数的默认类型是 Int,数组下标是 Int,SwiftUI 的 API 接收的也是 Int。要用从 Kotlin 传来的 Int32,每跨一次边界就得转换一次。KotlinInt 装箱消失的地方,换成了 Int32 转换——这无疑是一笔便宜得多的交易,但也是「从 Swift 惯用地调用」这个目标尚未完全达成的一个标记。Char 变成 Unicode.UTF16.CodeUnit,也是同一类诚实的不便。

2.4.0 中其余的 Kotlin/Native 变化

既然已经在看 Swift export 了,顺带把同一版本里 Kotlin/Native 的其他变化也简单整理一下。这部分反而会更快地影响到你现在的构建。

CMS GC 成为默认值。 在 Kotlin 2.0.20 中作为实验性功能引入的并发标记清除(CMS)GC,从 2.4.0 起成为默认值。此前默认使用的 PMCS(parallel mark concurrent sweep)在标记堆上对象时必须暂停应用线程,而 CMS 会让标记阶段与应用线程并发运行。发行说明称这会显著改善 GC 停顿时间和应用响应性,并以 Compose Multiplatform UI 应用的基准测试作为依据——这是 JetBrains 自己的测量结果,发行说明正文并没有给出具体数字。如果出现问题,可以在 gradle.properties 里设置 kotlin.native.binary.gc=pmcs 改回旧值。

Apple 目标最低版本上调。 这是一次悄悄的破坏性变更。iOS 和 tvOS 从 14.0 提到 15.0,macOS 从 11.0 提到 12.0,watchOS 从 7.0 提到 8.0。如果需要支持更低的版本,得在 freeCompilerArgs 里加上类似 -Xoverride-konan-properties=minVersion.ios=14.0 的选项。

LLVM 21。 Kotlin/Native 用的 LLVM 从 19 升到了 21。发行说明写道,这应该不会影响到代码本身。

降低了 devirtualization 的内存占用。 链接发布任务(link release task)在特别大的项目里内存占用过高的问题得到了改善。发行说明的措辞需要谨慎地转述——「根据一位 EAP 用户的基准测试」,链接发布任务的内存消耗减半,至少省下了 13GB。也就是说,这是 JetBrains 转述的单个用户的自测数据,项目规模和环境条件都没有公开。完全不能保证你的项目会得到同样幅度的改善。

Swift 包导入(Experimental)。 现在可以在 Gradle 配置里,把一个 Swift 包声明为 iOS 应用的依赖。写法是在 swiftPMDependencies 代码块里,用 swiftPackage(url = ..., version = ..., products = ...) 的形式。路线图条目(KT-53877)仍是 Open 状态,而且这个功能的等级是 Experimental,不是 Alpha。

那么,现在该不该用

判断可以这样区分。

现在值得打开的情况

  • 正在新起一个 KMP 项目,采用 direct integration 的单体仓库结构,没有 CocoaPods 依赖。
  • 共享模块的公开 API 很简单——顶层函数、final 的 data class、enum、suspend 函数、Flow。没有把泛型容器或继承层级导出给 Swift。
  • 能承受 Swift 边界持续变化。破坏性变更不是「可能发生」,而是已经被预告了。
  • 愿意留反馈。在 Alpha 阶段打开它,相当于给 JetBrains 正在验证的那个「假设」投了一票。

不该打开的情况

  • 把共享模块作为 XCFramework 分发,或者在用 CocoaPods——这压根就不可能。这不是一个选择,而是一个事实。
  • 公开 API 里有泛型。它们会被擦除到上界,而这正是文档明确写着「一般不支持」的领域。
  • 用的是在 Swift 一侧实现 Kotlin 接口的模式——没有跨语言继承。
  • Objective-C 路径已经在跑,而且有产品时间表。现在迁移能拿到的,是语法上的优雅和 AsyncSequence;会失去的,是稳定性保证和 IDE 支持。没有理由把生产环境的边界,迁到一个连 Beta 承诺都还没有的等级上。

现实的姿态是这样的:Swift export 的方向是对的,而且随着 2.4.0 补上协程和 Flow 支持,它第一次有了「能撑起真实 API 表面」的样子。所以,切一个分支打开试试,数一数你的共享模块公开 API 撞上上面那份限制清单多少次。 那个数字,就是你团队的迁移准备度,也是你下次读发行说明时该重点找什么的清单。迁移生产分支是后面的事,至少也得等到 Beta 之后。

结语

Kotlin Multiplatform 在 iOS 上长期背负的问题是:语言不是两种,而是三种。Objective-C 这座桥把 Kotlin 的类型投影到了 Objective-C 能表达的那个子集上,这份损失表现为 KotlinInt 装箱、泛型擦除、completion handler。Swift export 正在拆掉这座桥。它在 2.2.20 中以 Experimental 姿态登场,在 2.3.0 中补上了 enum 和 vararg,又在 2.4.0 中拿到协程和 Flow 之后进入了 Alpha。

与此同时,也有一些必须诚实面对的地方。按 JetBrains 自己的定义,Alpha 仍是「正在测试是否应该走向生产就绪」的阶段,也是假设一旦不成立就可能被放弃的阶段。仅限 direct integration 这条约束,跨越三个版本几乎一字未改;泛型甚至比旧桥还差;也没有 IDE 支持。公开路线图上,没有 Beta 相关的条目。

所以结论可以这样收尾:Swift export 不是在宣告 KMP 的 iOS 故事已经结束,而是在宣告它才真正开始。 现在该做的不是迁移,而是测量——数清楚你的 API 会撞上那堵墙多少次,然后等 2026 年 8 月的路线图更新。

参考资料