在 Swift 里跟 JSI 对话:SDK 56 改了什么

在 SDK 56 中,Expo 的原生模块在 Apple 平台上直接调用 JSI。Objective-C++ 这一层被去掉了,调用速度提升到原来的 1.6–2.3 倍。

中文
复制
Talking to JSI in Swift: what changed in SDK 56

太长不看

SDK 56 中,我们重写了 Apple 平台上的原生模块基础设施,让 Swift 直接与 JSI 通信。过去挡在调用路径中间的 Objective-C++ 层已经删除。调用更快,调用栈更简单,一些以前很别扭的事情现在变得直接。这篇文章讲的是 Apple 这一侧。SDK 56 中 Android 的改动(一个 Kotlin 编译器插件)会单独写一篇。

旧架构,以及为什么必须改

在 SDK 56 之前,Apple 平台上从 JavaScript 调用 Expo 原生模块要穿过三种语言。Swift 模块代码下面垫着一层 Objective-C++ 绑定(EXJavaScriptRuntimeEXJavaScriptValueEXJavaScriptObject 等等),再下面是 C++ 写的 JSI。Objective-C++ 层存在的理由只有一个:Swift 没法直接调用 C++,而 Objective-C++ 是唯一实用的胶水。

代价无处不在。每次调用进去要跨两道语言边界,出来还要再跨两道;每个值在每个方向上都要被重塑两次:std::stringNSString ↔ Swift Stringstd::vectorNSArray ↔ 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 一一对应:JavaScriptRuntimeJavaScriptValueJavaScriptObjectJavaScriptArrayJavaScriptFunctionJavaScriptArrayBufferJavaScriptTypedArrayJavaScriptBigIntJavaScriptNativeState。每一个都映射到一个 JSI 类型,但以现代、类型安全的 Swift API 暴露出来。

我们用不可复制类型来对应 JSI 的所有权语义。JSI 的许多值类型在 C++ 里按设计就是可移动、不可复制的。以 jsi::Valuejsi::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_tNSRunLoop 来回切换。没有 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 ExpoSDK 56 Expo提升倍数SDK 56 TurboSDK 56 Bridge(interop)
同步空操作135 ms68 ms2.0x70 ms214 ms
两个 double 相加212 ms92 ms2.3x93 ms379 ms
字符串拼接220 ms137 ms1.6x150 ms430 ms
异步空操作1219 ms706 ms1.7x1091 ms1158 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%。纯手工做的话,耗时会长得多。

来源: Expo Blog← 返回首页