Expo Modules 2.0 前瞻

Expo Modules 2.0 把原生模块变成一个带注解的 Swift 或 Kotlin 类。没有 DSL,没有样板代码,调用速度最高提升 5.6 倍。iOS 上可在 SDK 57 中试用。

中文
复制
An early look at Expo Modules 2.0

为 React Native 编写原生模块这件事,即将变得顺手得多,也快得多。在 Expo Modules 2.0 里,一个模块就是一个带注解的 Swift 或 Kotlin 类:你照常写方法和属性,把想暴露给 JavaScript 的那些标出来,就这么多。除了几个注解,没有新东西要学,没有样板代码要维护,运行时还比它所取代的 API 更快。

这是简短版。Expo Modules API 一直替你处理原生互操作中最麻烦的部分:在 JavaScript 值和原生类型之间转换数据,把你的函数放到正确的线程上执行,从而用上设备的多个核心而无需你自己写同步逻辑,以及把模块接入应用的生命周期。2.0 改变的是你实际要写的那部分。在 1.0 里,模块向 JavaScript 暴露什么,要用一套专门设计的 DSL 来描述。到了 2.0,这些直接来自原生代码,写模块就是按你为任何 iOS 或 Android 应用写 Swift 或 Kotlin 的方式去写。

Expo Modules 2.0 即将发布,在 iOS 上你已经可以试用:SDK 57 包含了支撑它的 Swift 宏,覆盖模块、函数、record、shared object 和事件。视图(View)尚未覆盖(详见下文),而 Android 会沿用同一套编写模型,目前仍在开发中。到 SDK 58,Expo Modules 2.0 将进入 beta:有文档、正式发布,并附带一个 agent skill,让你的编码助手学会这套新 API。由于 iOS 先行,下面的示例都用 Swift。它长这样。

如果你读过 Talking to JSI in Swift: what changed in SDK 56,这篇就是续篇,建立在文中所述的工作之上。在 iOS 上,SDK 56 解决的是模块底层的东西:我们去掉了 Objective-C++ 垫片,Swift 现在直接与 JSI 通信,调用速度大约快了一倍。Android 那边的铺垫则是另一种形式——一个 Kotlin 编译器插件,把一部分工作从运行时挪到构建时,详见 How a Kotlin compiler plugin cut Android time to first render by 30%。这篇文章讲的是你在这一基础之上要写的代码。

今天的 DSL

下面是一个用 Expo Modules 1.0 API 写的小模块:

public final class MyModule: Module {
  public func definition() -> ModuleDefinition {
    Name("MyModule")

    Function("add") { (a: Double, b: Double) in
      return a + b
    }

    AsyncFunction("fetchValue") { (key: String) in
      return try await store.read(key)
    }

    Property("ready") {
      return self.isReady
    }
  }
}

这段代码能跑,Expo 生态里很多原生代码就是这么写的。但要写出来,脑子里——或者你的编码 agent 的上下文里——得装不少东西。这套 DSL 本身就是一套小语法,建立在 Swift 的 result builders 之上(SwiftUI 的 body 背后也是同一套机制)。你得记住它的词汇:FunctionAsyncFunctionPropertyClassEvents,等等。你得把闭包参数类型一个个写出来,还得选同步还是异步的版本。这些跟你的模块做什么毫无关系,而 Expo Modules 2.0 把它们全部去掉,改用纯 Swift 语法。

同一个模块在 2.0 里

@ExpoModule
public final class MyModule {
  @JS
  func add(a: Double, b: Double) -> Double {
    return a + b
  }

  @JS
  func fetchValue(key: String) async throws -> String {
    return try await store.read(key)
  }

  @JS
  var ready: Bool {
    return isReady
  }
}

这就是整个模块。一个类,方法加属性注解,没别的:没有 definition(),没有 Name(...)(名字默认取类名,除非你通过宏参数传一个),也没有 result builder。每个 DSL 组件都归并成普通的 Swift 声明。

FunctionAsyncFunction 都变成带 @JS 标记的普通 Swift 方法。方法名和类型直接从声明里读出来。同步还是异步由 Swift 的 async 关键字决定:一个 async 方法在 JavaScript 里就是返回 Promise 的函数。

Expo Modules 1.0 里带 getter 和 setter 的 Property,现在就是一个 Swift var。可写的 Swift 属性(存储属性 var 和带 setter 的计算属性)在 JS 里可写。只读的 Swift 属性(let 常量和只有 getter 的 var 属性)在 JS 里只读。这些属性从 Swift 和 JS 都能访问,不需要额外配置;在 Expo Modules 2.0 里,定义属性的 API 就是 Swift 语法本身。

迁移不必一次做完。两套 API 可以在同一个模块里共存:保留现有的 definition(),加上 @ExpoModule,再把函数和属性逐个挪到 @JS。暂时还没有 2.0 写法的东西,留在定义里就行。两者会合并,所以你可以逐步采用,不用重写整个模块。

连转换都不用自己动手。expo-migrate-module skill 能让你的编码 agent 把模块的 Swift 部分从 1.0 迁移到 2.0,同时保持 JavaScript API 不变。暂时迁不了的部分,它会留在 1.0 的 definition() 里,并在报告中列出,让你清楚还剩什么。只需运行:

npx skills@latest use expo/skills@expo-migrate-module --agent claude-code

这会启动一个加载了该 skill 的交互式会话;把 claude-code 换成 codexcursor,或者你用的其他 agent。这个 skill 位于 expo-experiments 插件中,随 API 一同演进,use 每次都会拉取最新版本。

record、shared object 和 event 也是同样的处理方式

会变的可不只是函数。模块的其他构建单元同样遵循拥抱原生语法的思路。

record 就是一个 Swift struct,每个不是 privatestaticlazy 的存储属性都会成为一个字段。字段是必填、可选还是可空,直接从声明中读出,不需要额外写任何东西:

@Record
struct Options {
  var name: String     // required
  var count: Int = 0   // optional (has a default)
  var note: String?    // nullable and optional
}

shared object 是一个 class,它的实例同时存在于 Swift 和 JavaScript 中:JS 持有一个由原生实例支撑的对象。暴露方式与模块相同,用 @JS 方法和属性,其中包括一个会成为 JS 构造函数的 @JS init()

@SharedObject
final class MediaPlayer: SharedObject {
  private let player: AVPlayer

  @JS
  init(src: String) {
    player = AVPlayer(url: URL(fileURLWithPath: src))
  }

  @JS
  func play() {
    player.play()
  }

  @JS
  var currentTime: Double {
    get {
      return player.currentTime().seconds
    }
    set {
      player.seek(to: CMTime(seconds: newValue, preferredTimescale: 600))
    }
  }

  @JS
  var muted: Bool {
    get {
      return player.isMuted
    }
    set {
      player.isMuted = newValue
    }
  }
}

JS 侧拿到的是一个带 Web 风格的 API,currentTime 几秒内就能像 HTML 媒体元素一样使用,而底层这个 class 把它映射到 AVFoundation 的类型上。JavaScript 看到的结构由你设计,注解只负责把它传递过去。

在 1.0 中,event 是一个注册的字符串名称加上 sendEvent(...) 调用:

public final class DownloadModule: Module {
  public func definition() -> ModuleDefinition {
    Name("DownloadModule")
    Events("progress")
  }

  func tick() {
    sendEvent("progress", ["percent": 50])
  }
}

在 Expo Modules 2.0 中,event 是一个带类型的可调用属性。你只需声明一次 payload 类型,调用该属性就会派发事件:

@ExpoModule
public final class DownloadModule {
  @Event
  var onProgress: (ProgressEvent) -> Void  // listened to as "progress" in JS

  func tick() {
    onProgress(ProgressEvent(percent: 50))
  }
}

@Record
struct ProgressEvent {
  var percent: Int
}

payload 可以是任何能发送到 JavaScript 的类型:基本类型、数组、字典、record、shared object、typed array 和 array buffer,以及开箱即用的平台类型,比如 DataDate。要支持其他原生类型,只需遵循 JavaScriptEncodableJavaScriptDecodable 协议,就像遵循 Swift 自己的 Codable 一样。如果某个 payload 类型无法发送,Swift 编译器会在构建时给出明确的错误,让你尽早发现,而不是等用户拿到你的 app 之后。

为什么“纯原生”比过去更重要

“比过去更重要”,说的是现在写代码的人变了。原生模块的编写、审查和迁移,越来越多地有 coding agent 参与其中,而你交给这个 agent 的 API,决定了这套流程能跑多顺。2.0 让你觉得舒服的那些点(要学的东西更少、类型集中在一处、错误落在出错的地方),恰恰也是 coding model 需要的。

模型是在十多年的 Swift 和 Kotlin 代码上训练出来的,而 1.0 的 DSL 可供模型学习的样本相对少得多,所以今天 agent 还得把 API 完整写进上下文窗口里。到了 2.0,API 大部分就是语言本身,要学的东西少了很多。而且 2.0 让“生成—报错—修复”这个循环更短,因为编译器能在模块声明里发现类型错误,并给出对 agent 友好的报错。一个会写 iOS 应用的 agent,就会写 Expo 模块。

表面更干净,底层更快

SDK 56 的工作也在这里体现出价值,这也是 2.0 不只是语法更顺手的原因。

Expo Modules 2.0 用构建期宏(@JS)读取你的 Swift 函数签名,因此提前就知道每个参数和返回值的准确类型。1.0 要到运行时才知道,所以它的工作方式跟反射一样:每次原生调用都把传入参数包进一个分配好的、带引用计数的容器,逐个经过动态类型转换器塞进 [Any] 数组,再拼出一个元组,然后才能调用你的函数。这条动态路径是如今一次调用里最大的开销。函数签名提前已知之后,生成的绑定直接在 JS 运行时放置参数的位置读取,并转换成对应的 Swift 参数,中间不再分配任何东西。1.0 每次调用都要重复的类型转换工作,在 2.0 里改到了构建期完成,运行时的每次调用记账也没了。

得益于横跨三个 SDK 的优化,速度提升相当可观。SDK 56 去掉了 Objective-C++ 层,Expo Module 调用比 SDK 55 快 1.5 到 2 倍,与 React Native 的 Turbo Modules 持平。SDK 57 再次改进了共享运行时,这部分对所有模块都有好处,包括仍留在 1.0 上的模块。如图所示,它们一行代码没改就变快了。除此之外,@JS 宏还消除了剩余的那部分动态调用开销。

这是 100,000 次调用的总耗时,越低越好:

iPhone 16 Pro、iOS 27、Release 构建下四项基准测试的柱状图,统计 100,000 次调用的总耗时。同步空操作:SDK 55 135 ms,SDK 56 80 ms,SDK 57 1.0 52 ms,SDK 57 2.0 9 ms,TurboModule 113 ms。两数相加:212、107、97、19、136 ms。字符串拼接:220、143、121、48、190 ms。异步空操作:1219、747、610、556、1080 ms。

所有数字都在同一套环境下测得:一台运行 iOS 27 的 iPhone 16 Pro,Release 构建,预编译框架。

在同一个 SDK 上,同步调用走 @JS 路径比 1.0 API 快 2.5 到 5.6 倍。异步调用两者相差不大,因为底层共用同一套 promise 机制。异步调用更大的提升要等到 SDK 58。现在最干净的 API 同时也是调用模块最快的路径。

2.0 接下来做什么

下一步是视图。它会沿用模块的同一套模型:一个视图就是一个标记了 @ExpoView 的类,props 和事件回调只在带类型的 @ViewProps struct 里声明一次,而不是一半写在原生视图里、一半写在手写的 JS prop 类型里。

我们还在做的另一件事是生成 TypeScript。每个 @JS 成员、@Record 和视图 prop 的类型本来就写在原生签名里,因此工具可以直接从这份来源生成模块的 TypeScript 声明。今天你要分别写原生类型和对应的 TypeScript 声明;目标是只写一次。

这和其他一些 React Native 模块的 codegen 思路正好相反——那边你先写一份 TypeScript spec,生成器再搭出原生代码的骨架让你去填。用 Expo Modules 2.0,你像任何原生应用一样直接使用平台的原生 API,应用其余部分仍然留在 React 里,TypeScript 类型则从原生代码中产生。模块的 TypeScript 声明变成一件生成的构建产物,CI 可以检测它是否和原生代码不同步。

这套生成 TypeScript 声明的工具链,第一步是把 Swift 源码转成一份机器可读的摘要,涵盖模块导出的所有内容:每个函数、属性、record 和事件,以及它们的类型。有了这份摘要,spec-first 方式的好处同样成立:等 Android 也能从 Kotlin 源码产出同样的摘要,工具链就能对比两者,在平台差异变成 JS 里的 bug 之前把它抓出来。

什么时候能用?

🚀 从今天起,在 iOS 上,SDK 57 中,核心宏(@ExpoModule@JS@Record@SharedObject@Event)已随 SDK 一同发布。这些宏目前还没有文档,因此请将其视为实验性功能:API 仍可能发生变化,相关指南和参考文档将随 SDK 58 beta 一起上线。Android 支持正在开发中。如果你想现在就用这种方式编写 iOS 模块,可以开始动手了。

从更长远来看,Expo Modules 2.0 将成为编写 Expo 模块的默认方式,而 1.0 对现有项目仍然可用。如果你在编写原生模块,可以在 SDK 57 中试用 2.0,然后到 Expo Developers Discord#creating-expo-modules 频道告诉我们哪些地方好用、哪些地方不好用,以及接下来还希望有哪些功能。正式 beta 版将随 SDK 58 发布。

来源: Expo Blog← 返回首页