在 Swift 里跟 JSI 对话:SDK 56 改了什么
在 SDK 56 中,Expo 的原生模块在 Apple 平台上直接调用 JSI。Objective-C++ 这一层被去掉了,调用速度提升到原来的 1.6–2.3 倍。
中文
复制

太长不看
在 SDK 56 中,我们重写了 Apple 平台上的原生模块基础设施,让 Swift 直接与 JSI 通信。过去挡在调用路径中间的 Objective-C++ 层已经删除。调用更快,调用栈更简单,一些以前很别扭的事情现在变得直接。这篇文章讲的是 Apple 这一侧。SDK 56 中 Android 的改动(一个 Kotlin 编译器插件)会单独写一篇。
旧架构,以及为什么必须改
在 SDK 56 之前,Apple 平台上从 JavaScript 调用 Expo 原生模块要穿过三种语言。Swift 模块代码下面垫着一层 Objective-C++ 绑定(EXJavaScriptRuntime、EXJavaScriptValue、EXJavaScriptObject 等等),再下面是 C++ 写的 JSI。Objective-C++ 层存在的理由只有一个:Swift 没法直接调用 C++,而 Objective-C++ 是唯一实用的胶水。
代价无处不在。每次调用进去要跨两道语言边界,出来还要再跨两道;每个值在每个方向上都要被重塑两次:std::string ↔ NSString ↔ Swift String,std::vector ↔ NSArray ↔ Swift Array,如此往复。每一跳都要分配和拷贝。热路径上要维护三种语言,出了问题也要在三种语言里推理。调用过程中,栈回溯、类型和所有权都会变形,跨接缝调试因此格外痛苦。
这给 API 的速度和整洁程度设了一个上限,而这次重写要解决的就是这个上限。
Swift/C++ 互操作登场
直到不久之前,在同一个项目里混用 Swift 和 C++ 都意味着要经过 Objective-C 和 C++ 的混合体,通常叫 Objective-C++。Swift 能和 Objective-C 对话,Objective-C++ 文件可以自由地交错书写 Objective-C 和 C++。所以任何想在 Swift 里用的 C++ 类型,都得先用一个 Objective-C 类包起来。旧架构每次调用都在做这件事。
Swift 5.9 引入的 Swift/C++ 互操作去掉了中间这一跳。Swift 可以直接 import C++ 头文件。编译器把 C++ 类型映射到 Swift 类型:类和结构体变成可以构造的 Swift 类型,方法变成 Swift 方法。中间没有 Objective-C 类,穿过时也不需要 NSString/NSArray 重塑。覆盖面并非完整。比如模板就不会以 Swift 泛型的形式传过来。但一个地道 C++ API 表面的大部分都能过来,而且 C++ 类型到达时所有权模型是完整的。再配合 ~Copyable 处理只能移动的类型,这就足以把 JSI 的单所有者纪律一直保留到 Swift API。
代价是:一次 JSI 调用从三种语言的接力,变成单个 Swift 表达式,再降为一次直接的 C++ 调用。单次调用的开销,和你一开始就用 C++ 写出来是一样的。开销只是转移到了别处:编译时间,以及边界上一些尖锐的坑,下面会讲。
在 React Native 这个圈子里,我们不是第一个尝试的。Nitro Modules 更早做到了,那时 Swift/C++ 互操作比现在还不成熟。
设计 ExpoModulesJSI
ExpoModulesJSI 是一个 Swift package,把 JSI 包装成地道的 Swift 类型。尽管名字如此,它对 Expo Modules 一无所知;它纯粹是 JSI 之上的 Swift 封装,原则上我们可以单独发布。我们没有这么做,因为它依赖 React Native(JSI 就在那里),而 JSI 在 React Native 之外基本没有用户。所以这个名字是刻意保守的。
核心类型系统与 JSI 一一对应:JavaScriptRuntime、JavaScriptValue、JavaScriptObject、JavaScriptArray、JavaScriptFunction、JavaScriptArrayBuffer、JavaScriptTypedArray、JavaScriptBigInt、JavaScriptNativeState。每一个都映射到一个 JSI 类型,但以现代、类型安全的 Swift API 暴露出来。
我们用不可复制类型来对应 JSI 的所有权语义。JSI 的许多值类型在 C++ 里按设计就是可移动、不可复制的。以 jsi::Value 或 jsi::Object 为例:它们持有运行时资源,所以所有权归属很明确。任何时刻,有且只有一个地方持有该值,想要第二份就必须显式说明。Swift 5.9 给了我们对应的工具:~Copyable。我们的封装类型遵循它,于是 Swift 编译器会强制执行 JSI 在底层假定的同一套单一所有者规则。不会有意外的拷贝,不会有意外的引用计数意外,也不存在某个值悄悄变成两份的 API。
这个包本身是一个 SwiftPM package,C++ 互操作在这里启用,xcframework 也在这里产出。构建脚本为每个平台(iOS 真机、iOS 模拟器、tvOS、tvOS 模拟器、macOS)各编译一个 slice,每个都开启互操作并接上头文件的逃生通道,然后打包成单个 xcframework。
大多数 React Native 项目通过 CocoaPods 引入原生依赖,所以我们也提供了一个包装预编译 xcframework 的 podspec。podspec 在 pod install 时创建一个占位 xcframework,好让 CocoaPods 生成正确的构建阶段,随后一个脚本阶段调用真正的 SwiftPM 构建(带基于内容哈希的缓存,所以不会每次 pod install 都重新构建)。于是 ExpoModulesJSI 可以直接从 Podfile 使用,构建本身留在 SwiftPM 里——Swift/C++ 互操作在那里有一等支持——而下游的模块作者完全看不到这些。
桥接两种并发模型
React Native 的线程模型比 Swift Concurrency 早了好几年。JS 任务跑在一条专用的 JS 线程上,由 run loop 驱动;原生任务则通过 dispatch_queue_t 和 NSRunLoop 来回切换。没有 actor,没有 await 点,没有结构化取消;只有队列、block,以及那条约定——你得在正确的线程上回调。
我们希望公开的 Swift API 看起来像现代 Swift:async/await、结构化并发、在合适的地方使用 actor 隔离。Swift Concurrency 是现代语言整体演进的一部分;Kotlin、C# 等语言也经历过类似的转变,告别回调链。这意味着我们要设计一层东西,让 Swift Concurrency 和 React Native 那套 run loop 加 dispatch 的世界共存,同时不让任何一方破坏另一方的不变量。大部分工作都在边界上。具体细节这里跳过,一来它们值得单独写一篇,二来这套设计在负载下还在调整。
取舍与坑
Swift/C++ 互操作仍处于实验阶段,具有传递性,编译也慢。它对设计的影响比任何单个 API 的怪癖都大。以下是我们在动手之前希望另一个团队知道的事。
Swift/C++ 互操作仍是实验性的。 Swift 5.9 发布多年之后,这个特性依然需要手动开启(Package.swift 里的 .interoperabilityMode(.Cxx)),官方文档也写明它仍在演进,也就是说 API 和行为可能随 Swift 版本变化。值得留意,但对我们不构成阻碍。
有些事做不到,有些则永远做不到。 Swift 和 C++ 的内存与所有权模型差异极大。一边是 ARC 和值语义;另一边是手动生命周期、RAII 和裸指针。很多 C++ 惯用法没有干净的 Swift 映射:复杂的模板元编程、某些继承模式、任何依赖非平凡 move/copy 语义的东西。其中一部分是工具链的缺口,会随时间补上;另一部分在概念上就无法桥接。你只能绕开它们设计,我们就是这么做的。
构建慢,而且互操作会沿着模块图扩散,所以我们直接发布预编译的 xcframework。 两个相关问题把我们推向了二进制分发。第一,打开 C++ 互操作会让每个文件的编译时间明显增加,而且这种开销会沿着模块图累积。第二,在一个 Swift 模块里启用互操作,会迫使所有导入其源码的下游模块也启用互操作。这两种代价我们都不想让它扩散到每一个 Expo 应用里。于是我们把 ExpoModulesJSI 预编译成 xcframework,作为公开产物发布。应用构建时链接这个二进制,而不是重新编译启用了互操作的源码;下游模块把它当作普通 Swift 库导入;互操作边界始终留在 ExpoModulesJSI 内部。模块作者完全不需要碰任何构建设置。
生成的 C++ 头文件很大,而且有时是错的。 Swift 会生成一个 C++ 头文件,把每个公开符号都暴露给 C++。Swift 接口一旦有点规模,这个头文件就会迅速膨胀,很可能也是构建变慢的原因之一。我们还见过它把声明按错误的顺序输出,生成的 C++ 根本编译不过,除非你重新调整 Swift API 的形状。能用,只是要知道它会发生。
有一个没写进文档的逃生口:-clang-header-expose-decls=has-expose-attr 可以把 C++ 头文件限制为只包含显式标注为导出的声明。这个标志在我们能找到的任何官方文档里都没有;唯一的公开提及是 Swift 编译器源码里 FrontendOptions.td 中的几行。用上它之后,生成的头文件会明显变小,也能绕开一部分顺序问题。
给不属于我们的 C++ 类型加标注:APINotes。 默认情况下,Swift 会把每个 C++ 类和结构体都当作值类型导入,就像 Swift 的 struct。这对任何带虚方法的类型都是问题,因为在 Swift 里虚分派只对引用类型(class)有效。一个以值类型导入的虚 jsi::Runtime::evaluateJavaScript 是没法调用的。你必须告诉 Swift 把这个类型按引用导入。对于自己掌控的代码,Swift 提供了可以塞进 C++ 头文件的宏(SWIFT_SHARED_REFERENCE 之类)。但 jsi::Runtime 在 React Native 里,我们不想 fork 或打补丁改 JSI 头文件。Clang 的 APINotes 是出路。它是一个旁挂的 YAML 文件,能把 Swift 导入属性叠加到第三方头文件上,而不改动这些头文件。我们用它把 jsi::Runtime 标为引用类型,正是这一点让它的虚方法能够传到 Swift。几乎每次 JSI 调用都要经过其中之一,所以这是关键依赖。
C++ 异常不会跨越 Swift 边界。 在 Swift 里,会抛错的函数要标上 throws,编译器会在调用点强制检查。C++ 没有对应的机制。除非标了 noexcept,否则任何函数都可能抛异常。Swift 导入 C++ 函数时拿不到任何信号来区分这两种情况,于是默认它不抛。一旦抛了,应用就崩。读导入后的 API 时同样是这个问题:Swift 里导入的 C++ 签名看不出一个函数会不会抛。你只能去翻 C++ 源码,或者猜,或者把每一次导入调用都当成可能致命的。具体到 JSI:好几个核心方法(evaluateJavaScript、对抛出的 JS 值做属性访问,等等)确实会抛 jsi::JSError。异常一旦逃进 Swift,栈展开会穿过那些并非按此编译的栈帧,应用随即崩溃。我们的对策是一座小桥:在 C++ 侧捕获异常,存进线程局部存储,每次调用结束后再作为 Swift error 重新抛出。宿主回调里抛出的 Swift error 走的是同一条路,方向相反。这套 throws 的管道得你自己搭,编译器不会帮你。
Expo Module 的性能
这次重写的目标很明确:不能为了更好的 Swift API 付出性能代价。Turbo Module 是 React Native 为现代原生模块架构立下的标杆,我们要达到这个标杆,而不是拿速度换开发体验。Swift Concurrency 支持、~Copyable 的所有权模型、现代类型系统,这些是我们想交付的东西;对齐 Turbo Module 的性能是让它们真正落地的约束。下面的数字说明我们做到了。
基准测试方法
四个微基准,三种原生模块架构,两个 SDK 版本。基准从 JavaScript 调用原生,每个跑 100,000 次迭代,三轮取平均。三种架构分别是 Expo Module 路径(本文的主题)、React Native 的 Turbo Module(core 里基于 JSI 的现代路径),以及旧的 Bridge——在当前的 React Native 里,它本质上就是一个带互操作层的 Turbo Module。四个基准:同步空函数、0 + 1 相加、拼接 'hello' 和 'world'、异步空函数。输入故意做得毫无意义;测的是跨越 JS 与原生边界的开销,不是算术或字符串处理的开销。以下所有数字来自一台 iPhone 16 Pro 的 release 构建。
关于异步基准测试,有一点需要说明。Expo Module 这条路径用的是 Swift Concurrency(async/await),每次调用比回调式异步要做更多的事:一个 Task、一个 continuation、调度器交互。Turbo Modules 和 Bridge 用的是回调式。所以这并不是同一套机制做了三遍,而是同一个逻辑操作在各自的世界里按各自的习惯写法实现。我们认为这才是诚实的比较方式;你完全可以在模块的 Swift 代码里用 async func 写一个异步函数,这里展示的开销就是这种写法要付出的代价(在我们这里则是不需要付出的)。
基准测试结果
我们在 SDK 55(这次重写之前的最后一个版本)和 SDK 56 上跑了同一套测试。Turbo Modules 和 Bridge 一并列出,作为参照。
| 基准测试 | SDK 55 Expo | SDK 56 Expo | 提升倍数 | SDK 56 Turbo | SDK 56 Bridge(interop) |
|---|---|---|---|---|---|
| 同步空操作 | 135 ms | 68 ms | 2.0x | 70 ms | 214 ms |
| 两个 double 相加 | 212 ms | 92 ms | 2.3x | 93 ms | 379 ms |
| 字符串拼接 | 220 ms | 137 ms | 1.6x | 150 ms | 430 ms |
| 异步空操作 | 1219 ms | 706 ms | 1.7x | 1091 ms | 1158 ms |
Expo Module 这条路径整体快了 1.6–2.3 倍。每一项基准测试都有大幅提升,而且提升幅度正好对应我们认为会起作用的那些架构改动:空操作的开销主要来自边界,字符串的开销主要来自数据编组,异步路径的绝对收益最大,因为它原本要消除的开销最多。
在 SDK 55 上,Expo Module 这条路径每一项都落后于 Turbo Modules:异步落后 11%,同步空操作和字符串落后约 17%,double 相加落后 66%。重写之后局面反转了。最简单的同步路径上我们与 Turbo Modules 持平(在舍入误差范围内),字符串编组上领先 9%,异步上领先 1.55 倍。异步这个差距是我们最想先拿出来说的。真实应用里开销真正堆积的地方就在这里——promise 跨模块边界串联,任务被调度、被恢复。
关于这个对比,还有一点要老实交代。Turbo Modules 和 Bridge 在 SDK 55 到 SDK 56 之间也有小幅变化,主要来自 React Native 上游的改进(Turbo 在 addNumbers 上明显变快了,其余几项两者都有几个百分点的浮动)。也就是说,我们追赶的并不是一个静止的目标,而是一个还在持续改进的目标,现在我们略微超过了它。
Bridge 这一列反映的是更早的情况。在同步调用上,它比两条基于 JSI 的路径都慢 3–4 倍,这正好符合每次调用都要做 JSON 序列化所应有的差距。到了异步场景,差距缩小到 1.6 倍,因为异步的开销主要来自 Promise 分配和调度,而这三种架构在这上面的付出大致相当。
注意事项
微基准测的是微观的东西。把一件什么都不做的事重复 100,000 次,只能告诉你边界成本,并不能告诉你真实应用用起来是什么感觉——在真实应用里,起主导作用的通常是调用频率、负载大小,以及实际工作本身的成本。不同的设备、OS 版本和 Hermes 构建都会让绝对数字上下浮动;比例才是这部分里稳定的东西。
这解锁了什么
-
通往那些用 Objective-C++ 做起来很痛苦、甚至根本做不到的功能,现在有了更顺的路。
-
后续的性能优化变得可做,因为调用路径只剩一种语言。
这不是终点。在这个基础之上还会有更多东西,本文不一一展开;这次重写是下一轮 API 工作的平台。
开始使用 Expo SDK 56 原生模块
SDK 56 在 iOS、tvOS 和 macOS 上提供了新的原生模块路径。这个版本里其余的内容见 SDK 56 发布说明;如果你想提 bug、建议修改设计或参与贡献,expo-modules-jsi 包在 GitHub 上。
Android 的情况不一样。SDK 56 在 Android 上的主要收获是 Kotlin 编译器插件(另有专文介绍),它把更多工作移到了编译期,带来的收益比用 JSI 重写更大。我们之后也打算研究一个 Kotlin 优先的 JSI 封装,但 Android 的 JSI 现状比 iOS 当初健康,所以从这个方向能拿到的收益大概有限。
还有一点:AI 在这次重写中帮了大忙。它用 Swift 覆盖了几乎整个 JSI C++ 接口,并把测试覆盖率推到了接近 90%。纯手工做的话,耗时会长得多。