TL;DR
SDK 56では、Appleプラットフォーム上のネイティブモジュール基盤を全面的に書き直し、Swiftが直接JSIとやり取りするようになりました。以前コールパスに入っていたObjective-C++レイヤーは無くなりました。呼び出しは高速化され、スタックは単純になり、以前は扱いにくかったいくつかのことが素直になります。本稿はApple側の変更についてです。AndroidのSDK 56における変更(Kotlinコンパイラプラグイン)は別稿で扱います。
古いスタックと、なぜ変える必要があったのか
SDK 56以前、AppleプラットフォームでJavaScriptからExpoネイティブモジュールへ入る呼び出しは3言語を経由していました。SwiftのモジュールコードはObjective-C++のバインディング層(EXJavaScriptRuntime, EXJavaScriptValue, EXJavaScriptObject など)の上に位置し、その下にC++のJSIがありました。Objective-C++レイヤーが存在した唯一の理由は、SwiftがC++を直接呼べなかったためで、Objective-C++が実用的な接着剤だったからです。
コストはあらゆる所に現れました。各呼び出しは入るときに2つの言語境界、出るときにさらに2つを支払い、各値は往復で二度形を変えられました: std::string ↔ NSString ↔ Swift String、std::vector ↔ NSArray ↔ Swift Array、など。各ホップで割り当てとコピーが発生しました。ホットパスに3言語を維持する必要があり、何かがうまくいかないときは3言語すべてを考慮しなければなりませんでした。スタックトレース、型、所有権は呼び出し中途で形を変え、シーム越しのデバッグを困難にしました。これがAPIの速度と設計の上限を設定していて、書き換えの狙いはまさにその上限を上げることでした。
Swift/C++ interop の登場
これまでSwiftとC++を同一プロジェクトで混ぜるには、Objective-CとC++の混在(いわゆるObjective-C++)を経由する必要がありました。SwiftはObjective-Cと話せて、Objective-C++のファイルはObjective-CとC++を自由に混在させられます。したがってSwiftで使いたいC++型はまずObjective-Cクラスでラップする必要があり、古いスタックはそれを毎回やっていました。
Swift/C++ interopはSwift 5.9で導入され、中間ホップを取り除きます。SwiftはC++ヘッダを直接importできるようになり、コンパイラはC++型をSwift型にマッピングします: クラスやstructはSwiftで構築可能な型になり、メソッドはSwiftのメソッドになります。Objective-Cクラスは不要になり、NSString/NSArrayのような再形成も不要です。
カバレッジは完全ではありません。テンプレートはSwiftのジェネリクスとしてそのまま来るわけではありません。しかし、実用的なC++ APIの大部分は扱え、C++の所有権モデルも保たれます。ムーブ専用のものには~Copyableを組み合わせることで、JSIの単一オーナー方針をSwift APIまで維持できます。
その成果として、JSI呼び出しは3言語のリレーから1つのSwift式に減り、それが直接C++呼び出しに下されます。1回あたりのコストは最初からC++で書いた場合と同等です。コストは別のところに移ります: コンパイル時間と境界周りのいくつかの尖った問題です。
私たちがこれをReact Native界隈で最初に試したわけではありません。Nitro Modulesはより早く到達していて、そのときはSwift/C++ interopが今より成熟していない時期でした。
ExpoModulesJSIの設計
ExpoModulesJSIは、JSIを慣用的なSwift型でラップする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 はランタイム資源を所有しており、所有権が明確です。ある時点で正確に1箇所だけが値を保持し、2つ目が欲しいなら明示的に行う必要があります。Swift 5.9はこれを表現するツールを与えてくれました: ~Copyable。私たちのラッパーはこれに準拠しており、SwiftコンパイラはJSIが想定する単一オーナー規律を強制します。誤ったコピーや参照カウントの思わぬ増殖、値が黙って二つになるようなAPIはありません。
パッケージ自体はSwiftPMパッケージで、C++ interopを有効にする場所でもあり、xcframeworkを生成する場所でもあります。ビルドスクリプトはプラットフォームごとにスライス(iOS device, iOS simulator, tvOS, tvOS simulator, macOS)をコンパイルし、各スライスでinteropを有効にし、ヘッダのエスケープハッチを配線してから、それらを単一のxcframeworkにバンドルします。
多くのReact NativeプロジェクトはCocoaPods経由でネイティブ依存を取り込むため、事前ビルド済みxcframeworkをラップするpodspecも配布しています。podspecはpod install時にスタブのxcframeworkを作ってCocoaPodsに正しいビルドフェーズを生成させ、スクリプトフェーズが実際のSwiftPMビルドを呼び(content-hashベースでキャッシュして毎回再ビルドしないようにします)ます。結果としてExpoModulesJSIはPodfileから使え、ビルドはSwiftPMに閉じ、下流のモジュール作者はこれらの詳細を目にしません。
2つの並行性モデルの橋渡し
React NativeのスレッドモデルはSwift Concurrencyより何年も前に設計されました。JSの作業はランループで駆動される専用のJSスレッドで動き、ネイティブ作業はdispatch_queue_tやNSRunLoopでホップします。actorもawaitポイントも構造化されたキャンセルもなく、キューとブロック、そして正しいスレッドでコールバックするという契約があるだけです。
公開するSwift APIはモダンなSwiftに見えるようにしたかった: async/await、構造化並行性、適切な箇所ではactor隔離。Swift Concurrencyはモダンな言語で広がっているムーブの一部で、KotlinやC#などもコールバックチェーンからの移行を進めています。つまりSwift ConcurrencyとReact Nativeのrun-loop/dispatchの世界が、互いの不変条件を損なわずに共存できる層を設計する必要がありました。
境界部分に仕事の大半がありました。ここでは詳細は省きます(別稿が必要になるほど長くなること、また負荷下で設計がまだ固まりつつあるためです)。
トレードオフと落とし穴
Swift/C++ interopはまだ実験的で、推移的で、コンパイルが遅いです。これは単一のAPIの癖よりも設計に大きく影響しました。開始前に別チームに知っておいてほしい点を列挙します。
-
Swift/C++ interopはまだ実験的です。
- Swift 5.9が出てから数年経っても、この機能はオプトインのまま(.interoperabilityMode(.Cxx) in Package.swift)で、進化中と公式に扱われており、Swiftのバージョン間でAPIや挙動が変わる可能性があります。ただし、我々にとってはブロッカーではありません。
-
できないこと、決してできないことがある。
- SwiftとC++はメモリと所有権モデルが非常に異なります。ARCと値意味論対マニュアルライフタイム、RAII、裸ポインタ。複雑なテンプレートメタプログラミングや特定の継承パターン、非自明なムーブ/コピー意味論に依存するものはきれいなSwift投影を持てないことがあります。一部はツールのギャップで時間とともに解消されますが、概念的に橋渡しできないものもあります。我々はそれを考慮して設計しました。
-
ビルドが遅くなり、interopはモジュールグラフに伝播するので、事前ビルド済みxcframeworkを配布しています。
- 2つの問題がこれを後押ししました。C++ interopを有効にするとファイルごとのコンパイル時間が目に見えて増え、それがモジュールグラフ全体で重なります。また、Swiftモジュールでinteropを有効にすると、そのソースをimportする下流のモジュールもinteropを有効にしなければなりません。これらのコストをすべてのExpoアプリに広げたくなかったので、ExpoModulesJSIは事前ビルドしてxcframeworkとして出荷しています。アプリのビルドはバイナリにリンクし、下流のモジュールは通常のSwiftライブラリとしてインポートし、interopの境界はExpoModulesJSIの内部に留まります。モジュール作者はビルド設定に触れる必要がありません。
-
生成されるC++ヘッダが巨大で、ときどき間違っていることがある。
- SwiftはC++に公開する全てのpublicシンボルをエクスポートするC++ヘッダを吐きます。非自明なSwift公開面ではこれが急速に大きくなり、コンパイル遅延に寄与している可能性があります。また、宣言が誤った順序で出力され、Swift APIの形を少し変えないとコンパイルできないC++が生成されることもありました。対処可能ですが発生しうることは知っておいてください。
- 非公式ながら回避策があります: -clang-header-expose-decls=has-expose-attr はC++ヘッダをエクスポート注釈のある宣言に制限します。このフラグは公式ドキュメントには見当たらず、SwiftコンパイラのFrontendOptions.tdの数行にのみ言及がありました。これを使うと生成ヘッダが大幅に小さくなり、順序問題の一部を回避できます。
-
自分で所有していないC++型を注釈する: APINotes。
- デフォルトではSwiftはC++のクラスやstructを値型としてインポートします。これは仮想メソッドを持つものには問題です。Swiftでは仮想(動的)ディスパッチは参照型(class)でしか動かないため、virtualな jsi::Runtime::evaluateJavaScript が値型としてインポートされると呼べません。Swift側に参照としてインポートするよう伝える必要があります。
- 自分で管理するコードならヘッダ内にマクロ(SWIFT_SHARED_REFERENCE など)を置けますが、jsi::Runtime はReact Nativeにあり、JSIヘッダをフォークやパッチしたくありませんでした。ClangのAPINotesが解決策です。APINotesはサイドカーのYAMLで、サードパーティヘッダに対してSwiftのimport属性を追加できます。我々はこれで jsi::Runtime を参照型としてマークし、その仮想メソッドがSwiftに渡るようにしました。ほとんどのJSI呼び出しがここを通るため重要です。
-
C++例外はSwift境界を越えない。
- Swiftではthrowする関数はthrowsでマーキングされ、コンパイラが呼び出し箇所で検査します。C++に相当する仕組みはありません。C++の関数はnoexceptでない限り例外を投げる可能性があり、SwiftがC++関数をimportするときにそれを区別する手段がないため、非投げないものとして扱われます。もし実際に例外が投げられるとアプリはクラッシュします。
- また、SwiftにimportされたC++シグネチャからはその関数が例外を投げるかどうか分かりません。C++ソースを読むか推測するか、あるいはすべてのimport呼び出しを致命的と扱うしかありません。
- JSIではいくつかのコアメソッド(evaluateJavaScript、投げられたJS値のプロパティアクセスなど)が実際に jsi::JSError を投げます。例外がSwiftに抜けると、スタックアンワインダがそれを期待していないフレームを通り抜けてアプリがクラッシュします。
- 我々の対処は、C++側で例外をキャッチしてスレッドローカルに退避し、各呼び出し後にそれをSwiftのエラーとして再投げする小さな橋渡しコードを書いたことです。ホストコールバックから上がってきたSwiftエラーについても逆方向で同じ経路を通します。throwsの配線はコンパイラがやってくれないので自分で作る必要があります。
Expo Moduleのパフォーマンス
この書き換えの目標は明確でした: より良いSwift APIのためにパフォーマンス税を払わないこと。Turbo ModulesはReact Nativeがモダンなネイティブモジュールアーキテクチャに対して設定する基準であり、我々はその基準を満たしたかった。Swift Concurrencyサポート、~Copyable所有権モデル、モダンな型システムは提供したいもので、Turbo Moduleと同等のパフォーマンスを満たすことが実装上の制約でした。
以下の数値がそれを満たしたと確認するためのものです。
ベンチマーク方法論
- 4つのマイクロベンチマーク、3つのネイティブモジュールアーキテクチャ、2つのSDKバージョン
- ベンチマークはJavaScriptからネイティブに呼び出し、各ベンチ100,000回のイテレーション、トライアルは3回の平均
- アーキテクチャ:
- Expo Module経路(本稿の主題)
- React NativeのTurbo Modules(コアのJSIベースのモダン経路)
- レガシーブリッジ(現行React Nativeでは実質的にインタロップ層を加えたTurbo Module)
- ベンチマーク:
- 同期のno-op関数
- 0 + 1 の加算
- 'hello' と 'world' の連結
- 非同期のno-op
Trivialな入力は意図的です(本文はここで切れています)。