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 になる。ドキュメントが整い、正式リリースとなり、コーディングアシスタントに新しい API を覚えさせる agent skill も付属する。iOS が先行するので、以下の例はすべて Swift で書く。こんな感じだ。

Talking to JSI in Swift: what changed in SDK 56 を読んでいれば、これはその続編であり、そこで説明した作業の上に成り立っている。iOS では、SDK 56 がモジュールの土台を解決した。Objective-C++ のシムを廃止し、Swift が JSI と直接やり取りするようになり、呼び出しはおよそ2倍速くなった。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 エコシステムのネイティブコードの多くはこう書かれている。だが書くには、頭の中に——あるいはコーディングエージェントのコンテキストに——かなりの量を詰め込んでおく必要がある。この 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 の構文そのものなのだ。

移行は一度に終わらせる必要はない。2つの API は同じモジュール内で共存できる。既存の definition() を残し、@ExpoModule を追加し、関数とプロパティをひとつずつ @JS へ移していけばいい。まだ 2.0 で書けないものは定義に残しておく。両者は統合されるので、モジュール全体を書き直すことなく段階的に採用できる。

変換すら自分でやらなくていい。expo-migrate-module skill を使えば、コーディングエージェントがモジュールの Swift 部分を 1.0 から 2.0 へ、JavaScript API を変えずに移行してくれる。まだ移行できない部分は 1.0 の definition() に残し、何が残っているか分かるようにレポートに列挙する。実行するだけだ。

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

これは、その skill を読み込んだインタラクティブセッションを起動する。claude-codecodexcursor、あるいは使っている別の agent に置き換えればいい。この skill は expo-experiments プラグインにあり、API とともに更新されるので、use は毎回最新版を取得する。

record、shared object、event も同じ扱い

変わるのは関数だけではない。モジュールを構成する他の要素も、ネイティブの構文を受け入れるという同じ方針に従っている。

record は Swift の struct で、privatestaticlazy 以外の格納プロパティがそれぞれフィールドになる。フィールドが必須か、オプショナルか、nullable かは宣言からそのまま読み取れる。追加で書くものは何もない。

@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 のようなプラットフォーム型がそのまま動く。他のネイティブ型に対応させたい場合は、Swift の Codable に準拠するのと同じ感覚で、JavaScriptEncodableJavaScriptDecodable プロトコルに準拠すればいい。送れない payload 型があれば、Swift コンパイラがビルド時に明確なエラーを出す。ユーザーがあなたの app を手にする前に気づける。

なぜ「純ネイティブ」が以前より重要になったか

「以前より重要」なのは、コードを書く側が変わったからだ。ネイティブモジュールの作成、レビュー、移行に coding agent が関わる場面が増えており、その agent に渡す API が作業の進めやすさを決める。2.0 で楽になる点(覚えることが少ない、型が一箇所に集まる、エラーが出るべき場所で出る)は、そのまま coding model が必要としている点でもある。

モデルは 10 年以上の 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] 配列に詰め、タプルを組み立ててから関数を呼ぶ。この動的パスが、今の 1 回の呼び出しで最大のコストだ。関数シグネチャが事前に分かっていれば、生成されたバインディングは JS ランタイムが引数を置く位置から直接読み取り、対応する Swift の引数に変換する。途中で何も確保しない。1.0 が呼び出しのたびに繰り返していた型変換は、2.0 ではビルド時に移り、実行時の呼び出しごとの管理もなくなった。

3 つの 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 ビルドで4つのベンチマークを実行し、10万回呼び出しの合計時間を棒グラフにした。同期 no-op は 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。非同期 no-op は 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 のバグになる前に捕まえられる。

いつ使えるのか

🚀 今日から 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← ホームへ戻る