アプリストアの審査なしにモバイルアプリの JavaScript だけの修正を配れることが、OTA アップデートの魅力です。ただし、その JavaScript は、以前にビルドされて変更できないバイナリの中で動きます。新しい JavaScript が、古いバイナリに含まれないネイティブモジュールを呼ぶと、アプリは壊れます。

システムプログラミングの世界では、これは別の名前で知られています。バージョン1の共有ライブラリに対してコンパイルされたプログラムを、構造体のレイアウトが変わったバージョンに対してロードすると、思わぬ場所でクラッシュします。そこでの対策が ABI バージョンです。同じ番号の実行ファイルとライブラリは組み合わせてよい、という番号です。アップデートのランタイムバージョンが、その番号にあたります。

Expo が fingerprint ポリシーでこれを自動計算する仕組みを調べ、番号が何に反応するかを確かめました。

TL;DR

  • Expo のランタイムバージョンは、「ビルドのネイティブコードとアップデートの互換性を保証する属性」です。アップデートは、ランタイムバージョンの文字列が等しいビルドにだけ提供されます。仕組みはそれだけです。
  • 手で管理する文字列は、誰かが変更を忘れるまでは機能します。文書自身の例は、ビルドにないネイティブモジュールを使うアップデートです。expo-updates は「エラーを検出して、ロールバックを試みることがある」とされています。
  • fingerprint ポリシーは、@expo/fingerprint で依存関係、ネイティブのファイル、設定をハッシュして文字列を導きます。バージョン 0.20.13 での僕のテストでは、JavaScript の編集と純 JS の依存は無視され、新しいネイティブモジュールは意図どおり変化を起こしました。
  • JavaScript に近いと思われる編集でも変わりました。extra、version、ios.buildNumber、android.versionCode です。ハッシュが変わると、新しいバイナリが出るまで、既存のインストールはアップデートを受け取れなくなります。
  • sourceSkips を設定すると、既定値は置き換えられます。僕のテストでは、version 系のフィールドと extra だけをスキップする設定にしたところ、package.json のスクリプトの編集でハッシュが再び変わりました。そのスクリプトに対する既定のスキップが外れたためです。
  • これらはすべて、1つのパッケージバージョンの動作をローカルで測ったものです。EAS、実機、アップデートサーバーは動かしていません。

モデル

アップデートのプロトコルも、自分の言葉で同じことを述べています。クライアントは expo-runtime-version を送り、それは「クライアントが動かしているネイティブコードの構成を定める」ものです。マニフェストの runtimeVersion は、そのアップデートを動かすのに必要なネイティブの構成を示します。サーバーは、制約を満たす最新のアップデートを選びます。

このやりとりのどこでもコードは検査されません。文字列は、設定した人が立てる約束です。下のシミュレーションは、Expo も実機も使っていないので模擬と明記しますが、重要な3つの結果を示します。

== hand-maintained runtime string, native module added, string not bumped
  served: u2
  device result: FAILS: native module(s) missing: expo-camera

== derived runtime string: same change
  runtime strings: installed=fp-727073d0 u2=fp-47831473
  served: u1  (u2 is held back until a new binary exists)
  device result: ok

== a JS-only value hashed into the runtime string
  before=fp-39a097af after=fp-28bb4ee8  equal=false
  -> every existing install stops receiving updates, though nothing native changed

導出した文字列は1つ目の失敗を防ぎ、3つ目の可能性を生みます。3つ目がどの程度起きるかは、ハッシュに何が入るかで決まります。

fingerprint は何をハッシュするか

最小のプロジェクト(Expo 57.0.26、React Native 0.87.1、@expo/fingerprint 0.20.13)を作り、runtimeVersion: { policy: "fingerprint" }、extra.apiUrl、version、ビルド番号、バージョンコードを設定しました。fingerprint を生成し、1か所を変えてもう一度生成し、比べました。コマンドはパッケージ自身の CLI(npx @expo/fingerprint fingerprint:generate)です。Expo の文書では fingerprint ポリシーがこのパッケージを使うと説明されていますが、僕は CLI を直接呼んだだけで、ポリシー側が同じ既定の設定を適用するかは確認していません。表は、パッケージの挙動として読んでください。

変更ハッシュ
JavaScript ファイルを編集同じ
純 JS の依存(lodash)を追加同じ
ネイティブモジュール(expo-camera)を追加変化
どちらかをまた削除元のハッシュに戻る
extra.apiUrl変化
version 1.0.0 → 1.0.1変化
ios.buildNumber 1 → 2変化
android.versionCode 1 → 2変化
アプリの name変化
ios.infoPlist に利用目的の文字列を追加変化
package.json の android スクリプトを編集(“run” を含まない)同じ

ネイティブの互換性のためのハッシュとして、パターンは理にかなっています。ネイティブプロジェクトに入るもの(表示名、権限の文言、ビルド番号)は、ハッシュを変えます。新しいネイティブモジュールは、autolinking の入力に追加するファイルを通じてハッシュを変えます。extra は違います。JavaScript が読む値の入れ物ですが、アプリ設定の一部であり、既定の構成ではハッシュに含まれます。文書の SourceSkips の表には、これを外すための別の項目 ExpoConfigExtraSection があります。

ハッシュ対象を変える方法として、文書は .fingerprintignore、fileHookTransform、extraSources、sourceSkips を挙げています。知っておく価値のある制約も書かれています。生の関数として書かれた config plugin では、関数の名前だけが fingerprint の対象になるので、無名のプラグインの中身を編集してもハッシュは変わりません。

既定値を落とすスキップリスト

sourceSkips はビットマスクか名前の配列を受け取り、文書の SourceSkips の表には ExpoConfigVersions と ExpoConfigExtraSection があります。extra の変更とバージョンの更新でハッシュが変わらないようにしたかったので、次のように書きました。

// fingerprint.config.js
module.exports = { sourceSkips: ['ExpoConfigVersions', 'ExpoConfigExtraSection'] };

望んだ通りになりました。version、ios.buildNumber、android.versionCode、extra.apiUrl は、ハッシュを変えなくなりました。アプリの name と infoPlist の文字列は、これまでどおり変えました。それで正しいのです。ところが別の編集でもハッシュが変わりました。

package.json scripts.android edited (no "run")     default config: same     with my sourceSkips: CHANGED

既定の構成は、この種のスクリプトをスキップします。sourceSkips を渡すと、既定のリストに足されるのではなく、置き換えられました。既定のスキップ('PackageJsonAndroidAndIosScriptsIfNotContainRun')を明示的に書き戻すと、編集はまた無視されるようになりました。文書の fingerprint.config.js の例も、この名前を ExpoConfigVersions の隣に挙げていて、今はそれがヒントだったと読めます。

教訓は一般的です。リストを上書きする設定は、知らずに入っていた項目を消すことがあります。スキップリストを変えたら、上の表をもう一度確かめてください。

実用的な運用

  1. どの編集が新しいネイティブビルドを始めるべきで、どれがそうでないかを決め、書き出します。fingerprint はそれを決める手段ではなく、強制する手段です。
  2. CI で、プルリクエストごとに fingerprint を計算し、最後に出荷したバイナリに記録されたものと比べます。fingerprint:diff は異なるソースを列挙するので、レビュー担当者に理由が伝わります。ハッシュが変わったことは「この変更には新しいストア向けビルドが要る」という意味で、レビューでそう言えます。
  3. extra は意識して扱います。頻繁に変える環境値が入っているなら、変更のたびに新しいバイナリが要ると受け入れるか、アプリ設定の外へ出す(起動時に取得するリモート設定など)か、ExpoConfigExtraSection をスキップに加えます。スキップする前に、アップデート後にアプリがそれらの値をどう読むかを確かめてください。僕は試していません。
  4. sourceSkips を上書きするときは、既定値を意図して書き写します。
  5. 文字列を自分で決めたい場合(たとえば appVersion を上げる方式)は、そのままで構いません。それでも CI の確認は足してください。前回のリリースと fingerprint が違うのにランタイムバージョンの文字列が変わっていない状態は、ユーザーを壊す状況そのものです。

自分のプロジェクトの fingerprint を取る

mkdir fp-lab && cd fp-lab
# ラボの package.json、app.json、index.js、experiment.mjs をここに置いてから:
npm install --ignore-scripts --legacy-peer-deps
node experiment.mjs       # 3つの設定 × 上のシナリオ、続いて依存のシナリオ
node ../update-compat/sim.mjs     # モデルの3つの結果(シミュレーション)

実際のプロジェクトでは、npx @expo/fingerprint fingerprint:generate > a.json を実行し、変更を加えて b.json を生成し、npx @expo/fingerprint fingerprint:diff a.json b.json を実行します。

ハッシュに驚かされる場面

  • 誰も上げないランタイムバージョン。 症状: ネイティブモジュールを足すアップデートのあと、古いビルドのユーザーがクラッシュするかフォールバックします。対処: fingerprint ポリシーか、CI の確認を使います。
  • 無害に見える設定の編集。 症状: extra.apiUrl やアプリのバージョンを変えるとランタイムバージョンが変わり、既存のインストールはエラーなしでアップデートを受け取れなくなります。対処: CI の diff と、それらの値をハッシュに入れるかどうかの判断が要ります。
  • 既定値を置き換えるスキップリスト。 症状: スキップを足したあと、無関係な編集でハッシュが変わり始めます。対処: 既定値を明示的に含め、実験をやり直します。
  • 無名の config plugin。 症状: プラグインの挙動を変えても、fingerprint が変わりません。文書がこの制約を説明しています。対処: 生のプラグイン関数に名前を付けるか、追加のソースでそのソースをハッシュします。
  • 追跡対象のファイルの外でのネイティブの変更。 症状: ハッシュには見えない形でバイナリが違います。試しておらず、具体例は挙げられません。導出されたバージョン全般の弱点です。対処: 自動の仕組みと並べて、人が行う手順(ネイティブ変更のチェックリスト)も残します。
  • プラットフォームの違い。 症状: Android だけのネイティブ変更で iOS のユーザーも新しいビルドが必要になる、またはその逆が起きます。文書では、プラットフォーム別の runtimeVersion が最上位のものを上書きするとされています。対処: 両プラットフォームが実際に分かれるなら、プラットフォーム別の値を使います。これは試していません。

導出か、手書きか

依存や設定を何人かが変えるなら、導出したバージョンを使います。忘れることが支配的な失敗だからです。ネイティブの変更が少なく、いつ境界を越えるかを自分で制御したいなら、手で管理する方式を使い、安全網として CI の確認を足します。

アプリが JavaScript と Expo 自身のモジュールだけで、依存を変えるたびに必ず新しいビルドを出すなら、ランタイムバージョンはほとんど形式的なものです。どちらにしてもコストは同じです。

CLI で分かったことと、動かしていないこと

確認したことです。Node.js 22.23.3、Expo 57.0.26、React Native 0.87.1、@expo/fingerprint 0.20.13 で、上の表のすべての行(最小のプロジェクトで CLI を使い、編集ごとに前後の fingerprint を生成)、2つの形での sourceSkips の効果、既定のスキップを書き戻すと動作が戻ること。シミュレーションのモデルは僕自身のもので、Expo のコードは含みません。Expo の文書のランタイムバージョンのページ、アップデートプロトコルのページ、fingerprint のページを執筆当日に読みました。

確認していないことです。本物のアップデートサーバー、EAS、実機、そして expo-updates のポリシー自体(それが使うパッケージだけを確認しました)。アップデートとビルドが食い違ったときに実機で何が起きるかは確認していません。「ロールバックすることがある」は文書の表現です。ほかのバージョンの fingerprint パッケージは動かしていません。確認した時点で next タグのプレリリースが公開されており、挙動が違うかもしれません。.fingerprintignore、fileHookTransform、プラットフォーム別の runtimeVersion の上書きも試していません。

ハッシュに頼る前に読む

ランタイムバージョンを手で設定すれば、2つの成果物を組み合わせてよいかどうかの判断は人が持ちます。導出すれば、その判断はハッシュに移りますが、ハッシュは緩すぎるのと同じくらい簡単に厳しすぎることがあります。fingerprint を生成し、1か所ずつ変えて、何に反応するかを確かめてから頼ってください。