news 2026/9/10 0:08:09

expo-modules-jsi 变更全解:Swift JSI 绑定的 API 演进、崩溃修复与性能优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
expo-modules-jsi 变更全解:Swift JSI 绑定的 API 演进、崩溃修复与性能优化

expo-modules-jsi 变更全解:Swift JSI 绑定的 API 演进、崩溃修复与性能优化

【免费下载链接】expoAn open-source framework for making universal native apps with React. Expo runs on Android, iOS, and the web.项目地址: https://gitcode.com/GitHub_Trending/ex/expo

本文基于 Expo 仓库中 expo-modules-jsi 包的 CHANGELOG 展开,系统梳理该 Swift JSI 绑定库近期各版本(57.0.x / 56.0.x 及未发布版本)的破坏性变更、新 API、缺陷修复与底层性能优化。读完本文,你将理解 Expo 模块体系中「Swift 与 JavaScript 运行时(Hermes)互操作」这一基础层的能力边界、线程与生命周期模型,以及如何借助源码路径快速定位每项变更背后的实现证据。

1. 什么是 expo-modules-jsi:先建立背景

README 对该包的定义是:提供 React Native JSI(JavaScript Interface)C++ 库的类型安全 Swift 绑定,让原生 Swift 代码通过 Swift-first API 与 JavaScript 运行时(Hermes)交互,是新版expo-modules-core构建的底层基础。几个关键事实决定了如何阅读它的变更记录:

  • 三层架构:Swift 层(apple/Sources/ExpoModulesJSI/,公开 API,所有 JS 值类型均为~Copyable的非拷贝类型)→ C++ 工具层(apple/Sources/ExpoModulesJSI-Cxx/,桥接 Swift 与 JSI)→ JSI/Hermes 二进制 xcframework(Reacthermes-engineReactNativeDependencies)。
  • 不直接从源码消费:Swift/C++ interop 是逐 target 的编译器设置,源码分发会迫使所有依赖方也开启 interop。因此源码被编译为二进制ExpoModulesJSI.xcframework,模块作者只需import ExpoModulesCore(其重新导出 JSI 类型),无需直接依赖本包。
  • npm 包仅为自动链接占位:本包没有 JavaScript 运行时代码,package.json的存在只是让原生源码能被 autolink 进宿主 App。
  • 构建与测试入口(见 package.json 的scripts):pnpm build:xcframework重建 xcframework、pnpm test在 iOS 模拟器上跑 Swift Testing 套件、pnpm benchmark运行 Release 配置的基准测试(环境变量TEST_RUNNER_EXPO_BENCHMARK=1门控 Benchmarks target)。
  • 工程约束(Package.swift):Swift 6 语言模式、C++20 标准、.interoperabilityMode(.Cxx)、启用NonisolatedNonsendingByDefaultInferIsolatedConformances两个 upcoming feature,平台下限为 iOS 16.4 / tvOS 16.4 / macOS 13.4。

理解这些背景后,CHANGELOG 中几乎每一条目的动机都能对上号:绝大多数条目都是围绕「非拷贝值跨越异步/线程边界时的生命周期」「热路径上的每次调用开销」「与不同 React Native 版本编译兼容」这三条主线展开的。

2. 未发布(Unpublished)版本变更:本周期最重要的内容

2.1 破坏性变更:AsyncFunctionClosure的签名重构

未发布版本的首要变更是 [iOS]AsyncFunctionClosure的语义重写:它现在是一个同步闭包,接收 borrowed unowned 的this、消耗(consume)参数缓冲区,返回函数的异步主体(AsyncFunctionBody),与同步闭包的形态对齐。解码发生在宿主函数调用内部,因此不再有任何 JSI-owned 的值跨越异步边界——这消除了每次调用对参数缓冲区的拷贝,并修复了「运行时在异步宿主函数调用仍排队或挂起时被 reload」场景下的 use-after-free。

这个描述可以直接对照源码验证。JavaScriptRuntime.swift 中AsyncFunctionClosureAsyncFunctionBody的类型别名正是:

public typealias AsyncFunctionClosure = @JavaScriptActor ( _ this: borrowing JavaScriptUnownedValue, _ arguments: consuming JavaScriptValuesBuffer ) throws -> AsyncFunctionBody public typealias AsyncFunctionBody = @JavaScriptActor () async throws -> JavaScriptValue

createAsyncFunction(_:_:)的实现展示了完整的「同步解码 → 切换异步上下文」流程:闭包同步阶段在宿主调用内解码this与参数并拿到AsyncFunctionBody,随后self.schedule { … }把异步主体调度到 JS 线程上执行,结果 resolve/reject 之前创建好的JavaScriptPromise;闭包自身抛错(第一挂起点之前)时按 JS async 函数语义直接 reject 返回的 promise。JavaScriptObject.swift 中同步的setProperty(_:function:)重载(传入AsyncFunctionClosure)也走同一条路径,等价于setProperty(name, runtime.createAsyncFunction(name) { … })

2.2 新特性

未发布版本新增的五个 API 都在 JavaScriptRuntime.swift 与 Coding 目录 中可查到对应实现:

  1. JavaScriptRuntime.collectGarbage(cause:):通过运行时的 JSI instrumentation 请求一次完整的垃圾回收。实现上只是一行 C++ 桥接——runtime.instrumentation().collectGarbage(cause)(见 JSIUtils.h 第 153-155 行)。注意 CHANGELOG 明确说明:在未实现 GC instrumentation 的引擎上是 no-op(JSI 默认实现什么都不做,Hermes 覆写为真实回收),这一点区别于测试中曾用过的 Hermes 专属gc()全局。源码注释也提示它面向测试与内存诊断,生产代码中引擎自身会回收,手动调用通常得不偿失。
  2. JavaScriptRuntime.runOrSchedule:在 JS 线程上被调用时同步执行闭包,否则异步调度。实现非常简洁——isOnJavaScriptThread()命中则JavaScriptActor.assumeIsolated(closure)就地执行,否则走schedule(priority:_:)。与schedule的区别在于它允许重入调用者,这在调用者已在 JS 线程、无需跨线程时避免了不必要的往返。
  3. JavaScriptPromise.resolveJavaScriptEncodable重载:接收JavaScriptEncodable值,在 JS 线程上完成编码,若编码或 resolver 调用抛错则 reject 该 promise。实现中标注了@_disfavoredOverload,保证同时符合JavaScriptRepresentableJavaScriptEncodable的类型继续走 representable 重载(保持既有表示,例如 64 位整数仍是 JSnumber)。
  4. TaskJavaScriptEncodable一致性:把 SwiftTask编码为一个随任务结果落定的 JSPromise,让原生代码可以把 promise 作为值传给 JavaScript(例如同步@JS函数返回Task)。这是 encode-only 的——promise 的解码路径是通过JavaScriptPromise.await()挂起等待,而不是重建Task(见 JavaScriptCodable+Task.swift)。
  5. JavaScriptCodable拆分为JavaScriptDecodable/JavaScriptEncodableArrayOptionalDictionaryJavaScriptCodable一致性被拆成独立的解码与编码两半,目的是让「只能编码、不能解码」的元素类型(如Task)也能穿过容器的encode路径。JavaScriptEncodable.swift 的注释说明了拆分原因:静态encode(_ value:in:)让宏对任意类型发出统一的调用形态,也使表示「缺省」的类型(如Optional)无需先有非 nil 的self就能产出 JS 值。

2.3 缺陷修复

未发布版本的 bug fix 条目信息量很大,每一条都指向 JSI 生命周期或字符串处理的一个具体坑:

  • RN < 0.86 的构建失败jsi::Runtime::getStringData在旧版本(如 react-native-macos 0.81)中是 protected 成员,Swift 无法直接调用。修复是让字符串解码走 C++ 包装器,在这些版本上改用公开的jsi::String::getStringData辅助函数。这与 String+JSI.swift 的注释完全一致:「走expo.getStringData包装器而非 runtime 方法」,并在 JSIUtils.h 中按版本分派。配合 IRuntimeCompat.h 对IRuntime的别名(RN 0.86 把 JSI API 拆分为抽象基类IRuntime与具体Runtime,旧版本则把IRuntime别名为Runtime),同一套源码得以同时编译于 RN 0.85 / 0.86+ / react-native-tvos。
  • dateFromMilliseconds编译歧义:新工具链下未限定的abs(_:)因 C++ interop 把 C 语言abs重载带入作用域而报 "type of expression is ambiguous",修复是在Double溢出保护中改用Double.magnitude
  • 非 ASCII 属性名被截断JavaScriptPropNameID(_:string:)与数组字符串下标曾把String.count(grapheme 簇数量)当作PropNameID::forUtf8的 UTF-8 字节长度传入,导致"café""🎉"这类键从被篡改的字节构建、无法匹配到目标属性。现在 String+JSI.swift 中的toJSIPropNameID(in:)withUTF8拿到真正的字节计数——注释里特别点出"café"String.count报 4 而实际 UTF-8 是 5 字节。后续还有一项修复(#49679)把按名访问路径(getProperty/setProperty/hasProperty、数组字符串下标、字典转换)中同样被篡改的非 ASCII 属性名一并修掉。
  • 非持有JavaScriptRuntime包装器的 use-after-free:当非持有包装器比运行时活得久(例如被 reload 时遗弃的 task 捕获),其缓存的jsi::PropNameID会在运行时释放后被销毁。修复机制在 JavaScriptRuntime.swift 的installLongLivedObjectsTeardown()中:把一个带 deallocator 的原生状态对象钉在global上(属性名以包装器地址区分,多个包装器互不覆盖),运行时拆除时在 JS 线程上 flushpropNameIdsRegistry与延迟 promise 工厂;持有型包装器则在deinit中先清缓存再销毁运行时(JSI 规定运行时关联的对象必须先于运行时本身销毁)。
  • JavaScriptPromise不再在 resolve/reject 调用抛错时 trap:这实际上只会在运行时拆除过程中发生;现在 resolver 调用失败改为 reject promise,rejecter 调用失败则直接丢弃(JS 世界正要消亡,丢弃无害)。这段逻辑就写在 JavaScriptPromise.swift 的resolve路径的catch分支里。
  • xcframework 预构建在 Xcode 27 下失败RuntimeScheduler构造函数触发新的外部引用所有权警告导致构建失败,已修复。
  • 预构建 xcframework 混入覆盖率插桩:通过自动生成的 SwiftPM scheme 构建时,Xcode 对普通 Releasebuild也传了-profile-generate -profile-coverage-mapping,给宿主函数调用路径上每个函数加了计数器增量、并使二进制体积增加约 40%。已修复,benchmark target 也一并去掉了插桩。

2.4 内部优化(Others):热路径上的逐条减法

这一节是整份 CHANGELOG 中最体现「源码级深度」的部分——所有收益都在热路径上实测得出。逐项对照源码:

  • CppError::tryCatch从 Objective-C block 改为 C++ callable:所有调用方本来就传纯 C++ 主体,block 形式只增加一次不可内联的间接调用与错误处理路径上的 ObjC 运行时依赖。实现见 CppError.h(tryCatch(jsi::IRuntime&, Fn&&)),JSIUtils.h 中 eval/call 的错误捕获均已改用tryCatch(runtime, [&] { … })形式。
  • JavaScriptActor.assumeIsolated不再每次调用堆分配闭包盒:保持operation非逃逸,同步宿主调用约快 1.6 倍。assumeIsolated是同步、就地、不跨线程执行的,JavaScriptRuntime.swift 底部createFunctionClosure的注释正是围绕它展开的:参数缓冲区在同步assumeIsolated闭包内部从裸指针 + 计数就地构建,避免把~CopyableJavaScriptValuesBuffer盒装跨越闭包边界。
  • JavaScriptValue.undefined/.null改为共享永生实例:消除每次 void 返回宿主调用的一次分配。
  • 新增 opt-in benchmark target:测量值访问、宿主函数调用、JS 函数调用,pnpm benchmark运行。五个套件文件位于 apple/Benchmarks(HostFunctionBenchmarksValueAccessBenchmarksPromiseBenchmarksStringConversionBenchmarksFunctionCallBenchmarks),由EXPO_BENCHMARK环境变量门控,普通测试构建会编译但跳过。
  • 移除调用路径上的 weak/unowned 运行时引用流量:同步宿主函数调用与宿主对象属性访问器的原生开销下降。
  • 字符串传递提速:长字符串最快约 3.8 倍。实现即 String+JSI.swift 中通过getStringData分块读取引擎内部表示(Hermes 存 ASCII 字节或 UTF-16 code unit,哪份有就给哪份),ASCII 直接按 UTF-8 拷贝免校验,UTF-16 在 Swift 侧转码——源码注释给出的实测:4 KB ASCII 比utf8()快 3.7 倍、4 KB 非 ASCII 快 2.4 倍。
  • 值转移(take over)而非经引擎克隆:属性读、数组读、函数调用返回给 Swift 的值直接接管,toJavaScriptValue(in:)约快 1.16 倍。
  • 非 ASCII 字符串解码提速:≤512 个 UTF-16 code unit 的字符串约快 1.5 倍——对应appendEngineStringChunk中的长度分派:短串直接用unsafeUninitializedCapacity预分配 3 倍最坏容量原地转码,长串则交给标准库 bulk decoder(两者同速,但后者精确分配容量,避免 KB 级字符串时 3 倍冗余容量)。
  • 延迟JavaScriptPromise创建提速约 1.2 倍:改为从缓存的 JS 闭包构建(runtime.deferredPromiseFactory()一次 eval 出 promise + resolve + reject 三件套数组),替代每次new Promise(executor)挂宿主函数——源码注释说明后者每次要付出宿主函数 + Swift context 对象 + 构造期间引擎回调 + 两个持有型拷贝的代价。
  • then回调延迟安装JavaScriptPromise现在在第一次await()才安装then回调而非构造时(见 JavaScriptPromise.swift 中LongLivedState.deferredPromise注释:只交给 JS、由 Swift 落定的 promise 根本不需要它)。异步函数返回的 promise 创建便宜约 2.5 倍、落定便宜约 3.9 倍。
  • 原语返回值的直接写槽:同步宿主调用返回undefined/null/布尔/数字时,结果直接写入引擎槽位、不发引擎调用;错误从「每次调用都检查」改为「仅在真的抛出时上报」(见writeJSIValue(to:)resultPtr的调用约定,结果由 C++ 调用方所有,Swift 不返回非拷贝值)。
  • thisJavaScriptValue的同步宿主调用:改为由调用方模块直接销毁参数缓冲区,省去额外交接。

3. 已发布版本回顾:57.0.x 与 56.0.x 的关键变更

3.1 57.0.0(2026-06-25):错误模型的破坏性变更与性能基建

  • JavaScriptError从非拷贝 struct 改为可拷贝的Error,且JavaScriptValue不再符合Error。新 APIinit(_:value:)允许包装并抛出任意 JS 值(不限于Error实例),保持被抛值到达 JavaScript 时的同一性。
  • UnownedThisSyncFunctionClosure重载createFunction/setProperty接收把this作为 borrowedJavaScriptUnownedValue的闭包,跳过每次调用的持有型值分配与 weak 运行时流量,供忽略this的宿主函数(典型是模块级@JS函数)使用。源码中该重载带@_disfavoredOverload,未显式标注this类型的闭包仍解析到持有型重载,保证向后兼容(JavaScriptRuntime.swift 第 381-406 行)。
  • JavaScriptRuntime符合Identifiableid基于底层运行时指针地址——JSI 运行时不可移动(每个jsi::ValueRuntime&并假设它永不搬移),故地址在其生命周期内是可靠身份;但源码注释同时警告地址在运行时销毁后可能被复用,不要当作持久 id。
  • 其他重要优化:同步宿主函数不再每次调用分配JavaScriptRef(no-op@JS宿主调用基线实测约快 10%);JavaScriptNativeState支持经void *工厂支撑任意jsi::NativeState子类型,无 Swift/C++ interop 的消费方(如expo-modules-core)可自带 pointee,expo::NativeState以公开 C++ 头文件形式随 xcframework 发布(NativeState.h);NativeArrayBuffer参数在已是原生支撑时不再拷贝缓冲区。

3.2 57.0.2 / 57.0.3 / 57.0.4:引用语义与长生命周期对象

  • JavaScriptRef.withValue:非消耗式借用访问器,重复读取长期引用的值而不接管它(JavaScriptRef.swift 第 63 行)。
  • JavaScriptRuntime.longLivedObjectsLongLivedObjectCollection让在途 promise 等LongLivedObject跨越异步边界存活,运行时拆除时释放剩余项。这是 2.3 节所述 use-after-free 修复体系的基座。
  • DateJavaScriptCodable一致性:编码为 JSDate,解码可接受 JSDate、epoch 毫秒数或可由 JSDate构造器解析的字符串(JavaScriptCodable+Date.swift)。
  • 修复项包括:promise 长于运行时时的 use-after-free、async 函数以JavaScriptThrowablereject 时保留错误code(对齐同步 throw 路径)、独立JavaScriptRuntimedeinit中销毁自建 Hermes 运行时(从 React Native 采纳的运行时不受影响,对应 JavaScriptRuntime.swift 的ownsRuntime标志)。
  • 57.0.4(2026-07-22)新增 clear-caches.sh 脚本清理本地构建缓存与产物,即pnpm clean

3.3 56.0.x:从零拷贝值到 xcframework 构建加固

  • JavaScriptUnownedValue(56.0.9):非持有、非拷贝的值,借用jsi::Value实现零拷贝参数解码快路径;JavaScriptValuesBuffer随之把运行时缓存为永生(immortal)的facebook.jsi.IRuntime而非 ARC 管理的包装器,参数解码热路径去掉每次调用 retain/release(实测addNumbers快约 16%、addStrings快约 24%)。
  • JavaScriptValuesBuffer.copying(in:values:)rawBaseAddress(56.0.0):为跨 Swift/ObjC++ 边界转发预转换的 JS 值。
  • 闭包版setProperty(_:function:)(56.0.9):从闭包直接创建同步/异步宿主函数。
  • 大量 xcframework 构建修复:Xcode 26 下嵌套 SwiftPM 构建忽略-derivedDataPath、GNUsed环境(如 Nix shell)下的sed错误、Swift 工具链版本进入缓存键(升级 Xcode 强制重建切片而非复用旧编译器产物)、重建前清理过期中间产物、React-jsi 头变更时缓存不失效、缺失切片导致No such module 'ExpoModulesJSI'、source-built RN +useFrameworks: "static"的 header 搜索路径缺失,以及 56.0.4 中对 RN 0.86+facebook::jsi::IRuntime的支持(保持与 0.85 及 react-native-tvos 兼容)。

4. 如何在仓库中验证这些变更

CHANGELOG 的每条记录都能在仓库中找到落点,推荐的核查路径:

  • 公开 API 语义:apple/Sources/ExpoModulesJSI 下的 Swift 源码,重点文件是 JavaScriptRuntime.swift、JavaScriptPromise.swift、JavaScriptRef.swift、String+JSI.swift;
  • C++ 桥接与跨版本兼容:apple/Sources/ExpoModulesJSI-Cxx/include 下的CppError.hIRuntimeCompat.hHostFunctionClosure.hRuntimeScheduler.h等;
  • 行为验证:apple/Tests 中按类型组织的 Swift Testing 套件(JavaScriptPromiseTestsLongLivedObjectCollectionTestsJavaScriptPropNameIDTestsJavaScriptCodableTaskTests等 20+ 个测试文件);
  • 构建管线:apple/Package.swift(target 配置、interop 标志、APINotes 路径 apple/APINotes/jsi.apinotes)与 apple/ExpoModulesJSI.podspec(xcframework 的script_phase/prepare_command/vendored_frameworks接线);
  • 本地运行:在仓库根下执行pnpm build:xcframeworkpnpm test(可设PODS_ROOT指向其他宿主 App 的 Pods,参数转发给xcodebuild)、pnpm benchmark(Mac Catalyst、Release 配置)。

5. 小结

expo-modules-jsi 的 CHANGELOG 呈现出清晰的主线:

  1. 生命周期正确性——非拷贝 JSI 值在「异步边界、运行时 reload、包装器长于运行时」三种场景下的 use-after-free 被系统性消除,核心手段是LongLivedObjectCollection、JS 线程上的拆除清扫与ownsRuntime语义;
  2. 热路径性能——通过去堆分配、免 retain/release、免引擎调用、值接管与分块字符串转码,宿主函数调用、属性访问、字符串传递、promise 创建/落定逐环节提速,且全部有 benchmark target 支撑可复现验证;
  3. 多版本编译兼容——IRuntimeCompat.h与 C++ 包装层让同一套源码横跨 RN 0.85、0.86+ 与 react-native-tvos。

对于 Expo 模块作者而言,该包的日常使用方式保持不变(import ExpoModulesCore即可),但了解上述 API 演进——尤其是AsyncFunctionClosure的新签名、TaskJavaScriptEncodable一致性、以及runOrSchedule/collectGarbage等新能力——将直接影响新模块在异步桥接设计上的选择。

【免费下载链接】expoAn open-source framework for making universal native apps with React. Expo runs on Android, iOS, and the web.项目地址: https://gitcode.com/GitHub_Trending/ex/expo

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 0:07:28

统计了3名卸载者的代码习惯,我补齐智能体配置后采纳率从47%翻到了89%

统计了3名卸载者的代码习惯,我补齐智能体配置后采纳率从47%翻到了89% 推广 CodeWhisperer 第二个月,周一例会我投屏后端服务看板时,底下一声冷笑:「组长,这玩意儿我早上刚卸了。」跟着又是两声附和。那天看板上明晃晃标着:团队整体采纳率 47%,12 人有 3 个直接卸载,剩下的多数…

作者头像 李华
网站建设 2026/9/10 0:07:05

Windsurf连接服务器实战:从SSH握手到AI索引的远程开发排错指南

用 Windsurf 连接服务器&#xff0c;我第一周就把能踩的坑基本都踩了一遍。这不是夸张——从 SSH 握手失败、known_hosts 冲突&#xff0c;到连上之后扩展全部消失、AI 索引失效&#xff0c;断断续续折腾了快两个周末。这篇文章不打算复述官方文档&#xff0c;我把实际遇到过、…

作者头像 李华
网站建设 2026/9/9 23:58:57

AI写代码实战指南:从工具选型到提示词工程的完整提效路径

不会用AI写代码这件事&#xff0c;放在前两年还不算什么问题&#xff0c;顶多是被调侃一句"老顽固"。但放到现在这个节点&#xff0c;我越来越觉得&#xff0c;这不是个人偏好问题&#xff0c;而是实实在在的生产力差距问题。同样是接手一段老系统代码&#xff0c;有…

作者头像 李华
网站建设 2026/9/9 23:55:21

C11 _Generic 宏:编译期类型选择实战指南

在C语言项目里&#xff0c;凡是遇到“同一种操作&#xff0c;不同类型不同实现”的需求&#xff0c;最尴尬的事就是——你明明只有一个宏&#xff0c;却要写出好几个分支&#xff0c;或者干脆忍受编译器那一声不痛不痒的警告。比如早期我用printf打印变量时&#xff0c;%d配dou…

作者头像 李华