🚀

React NativeのQAビルド時間を約80%改善した話 — Expoのfingerprint × repackでフルビルドをスキップ

に公開

こんにちは!テラーノベルでiOS / Android / Webとフロントエンド周りを担当している @kazutoyo です!

テラードラマのモバイルアプリでは、PRごとにiOS / AndroidのQAビルドが走り、Firebase App Distribution経由で実機確認用のビルドが配信されるようにしています。

このQAビルドが、これまでは1回あたり約17分かかっていました。

QAで不具合が見つかったら、修正してもう一度ビルドし、配信されるまでまた17分ほど待つことになります。
このビルド時間がそのまま、修正して確認するイテレーションの長さになっていました。
これをなんとか短くしたい、というのが今回のモチベーションです。

一方で、QAで確認したい変更の多くはJavaScript側の変更で、ネイティブ側は変わっていないことがほとんどです。

それなら、ネイティブ成果物は毎回作り直さずに使い回し、JSだけ差し替えればよいのでは?
というのが今回の出発点でした。

そこで、@expo/fingerprint でネイティブ入力の変化を検出し、ネイティブ成果物をキャッシュしておき、@expo/repack-app でJSだけ差し替えて再署名する仕組みを導入しました。

結果として、QAビルドは次のように短縮できました。

プラットフォーム フルビルド repack 短縮
iOS 17分33秒 4分06秒 約77%減
Android 17分17秒 2分37秒 約85%減

この記事では、その前提となる考え方、OTA(EAS Update)とrepackの使い分け、fingerprintを使ったキャッシュの仕組み、実装時にハマったポイントを紹介します。

なお、この記事は、React Native / ExpoアプリでPRごとのQAビルドを配信していて、iOS / Androidのネイティブビルド時間を短縮したい方向けの内容です。
EAS Buildではなく、GitHub Actionsなどで自前ビルドしている場合を前提にしています。

React NativeアプリにおけるJS層とネイティブ層

React Nativeアプリは、大きく分けるとJavaScriptのレイヤーと、iOS / Androidのネイティブのレイヤーで構成されています。

日々の機能追加やUIの修正、ロジックの変更といった作業の多くは、JS側だけで完結します。

一方で、ネイティブのビルドが必要になるのは、ネイティブ側に手が入るときです。

代表的には、次のようなケースです。

  • ネイティブコードを含むライブラリの追加やアップグレード
  • Expo SDKのバージョンアップ
  • app.config / Info.plist / AndroidManifest など、ネイティブ設定の変更
  • 独自のネイティブコードやconfig pluginの変更

React Nativeのリリースビルドでは、JSコードはバンドル化され、ipa / apk の中に含まれます。
そのため、ネイティブ部分が変わっていないのであれば、ipa / apk 全体を作り直さず、JSバンドルとアセットだけを差し替える、という考え方ができます。

実際の開発でも、新しいライブラリの追加やアップグレードをしていない期間は、ネイティブは変わらず、JSの変更だけで済むことがほとんどです。

つまりQAで確認したい差分の多くは、「JSだけ変わっていて、ネイティブバイナリは前回と同じ」という状態です。

それなら、ネイティブ成果物は作り直さずに使い回せるはずです。

図のように、日常的な変更の多くは上側のJSレイヤーに閉じています。
今回やりたいのは、下側のネイティブレイヤーが変わっていないときに、そこを作り直さないことです。

これまでのQAビルド

テラードラマのQAビルドは、ざっくり次のような流れで動いていました。

expo prebuild でネイティブプロジェクトを生成し、そこからネイティブのビルドとJSバンドルの生成を行って、最終的なipa / apkを作って配信する、という流れです。

この中で時間の大半を占めるのがネイティブビルドです。
iOSであればCocoaPodsやXcodeビルド、AndroidであればGradleの assembleRelease が支配的で、冒頭の約17分もほとんどがこの工程の時間でした。

ビルド環境はself-hosted GitHub Actions

ビルドはEAS Build / EAS Workflowsではなく、GitHub Actionsのself-hosted runnerで回しています。

iOSはmacOS runner、AndroidはLinux runner上でビルドし、Firebase App Distributionでテスターに配信する構成です。

fingerprint + repackを使ったネイティブビルドのスキップは、EAS Workflows向けには公式のrepackジョブも用意されています。

一方で、今回のQAビルドはすでにself-hosted GitHub Actions上で動いています。
そのため今回は、既存のGitHub Actions workflowを大きく変えずに、fingerprintでキャッシュキーを作り、GCSに保存したネイティブ成果物をrepackで使い回す形にしました。

この記事では、EAS Workflowsではなく、self-hosted GitHub Actions上の既存ワークフローにrepackを組み込んだ例として紹介します。

開発者のPRから、GitHub Actions(self-hosted runner)でビルドして、Firebase App Distributionでテスターに届くまでの流れです。

OTA(EAS Update)とrepackの使い分け

「JSしか変わっていない」ときの一般的な選択肢として、OTAがあります。

Expoでいうと、expo-updates / EAS Updateを使って、インストール済みのアプリに新しいJSバンドルを配信する方法です。

OTAの大きなメリットは、ネイティブ側に変更がない限り、テスターがアプリを入れ直さなくてよいことです。
すでに入っているアプリに対してJSだけを更新できるので、テスター側のインストール作業を減らせます。

一方で、PRごとのQA配信で使う場合は、少し運用上の複雑さがあります。

OTAでPRごとのプレビューを配る場合、テスターはOTAを読み込めるアプリを入れておき、確認したいPRのJS更新をそのつど読み込む形になります。
JSだけの変更であればそのまま確認できますが、ネイティブ側に変更があるPRでは、JSの更新だけでは確認できず、対応するネイティブビルドを入れ直す必要があります。

そのため開発者側は、PRごとに「これはJSだけなのでOTAで確認できる」「これはネイティブ変更があるので入れ直しが必要」を判断し、必要に応じてテスターに伝える必要があります。

一方でrepackは、すでにビルド済みのipa / apkはそのまま使い、中のJSバンドルとアセットだけを差し替えて、新しいインストール可能なビルドを作り直す方法です。
@expo/repack-app というCLIで、フルネイティブビルドをせずにこの差し替えを行います。
ネイティブコンパイルがないぶん、フルビルドよりもかなり速く済みます。

これをPRごとのQA配信に当てはめると、次のようになります。

ネイティブが変わっていないPRでは、キャッシュしておいたネイティブ成果物にJSを差し替えて配信します。
ネイティブが変わっているPRでは、従来どおりフルビルドして配信します。

どちらの場合でも、テスターから見るとやることは同じです。
Firebase App Distributionから、そのPRに対応するビルドをインストールして確認するだけです。

つまりrepackでは、OTAのようにインストールを省略できるわけではなく、テスターは毎回ビルドをインストールする必要があります。
その代わり、「このPRはOTAで確認できるのか、それともアプリを入れ直す必要があるのか」を、テスターや開発者が意識する必要がありません。

今のフローは「1つのPR = Firebase App Distributionに配信される1つのインストール可能なビルド」という形です。
repackであれば、このフローを変えずに、ネイティブビルドだけを必要なときに省略できます。
そのため、今回のQA運用ではOTAよりもrepackの方が素直に収まると考えました。

整理すると、今回の用途では次のような違いがあります。

観点 OTA repack
配り方 インストール済みアプリにJSを配信 ipa / apk として配信
テスターのインストール ネイティブ変更がなければ不要 PRごとに必要
ネイティブ変更がある場合 アプリの入れ直しが必要 フルビルドに切り替えて配信
運用上の注意 入れ直しが必要なPRかどうかを判断して共有する必要がある テスターは毎回同じようにインストールして確認できる
今回の運用との相性 インストール回数は減らせるが、案内が少し複雑 既存のApp Distributionフローに乗せやすい

ただし、OTAもrepackも「ネイティブは変えられない」という制約は共通です。
ネイティブが変わるPRでは、従来どおりフルビルドに切り替える必要があります。

また、iOSの再署名はad-hocとdevelopment署名のみ対応で、ストア配布には使えません。
今回のようなQA配信用途にはちょうど合っていますが、本番ストア配信用の仕組みではありません。

このあたりはExpoのドキュメントでも整理されていて、repackは社内向けのテスト(QA端末やテスター、CIのスモークテストなど)に、本番ユーザーへのJS配信にはEAS Updateを、という使い分けが示されています。
今回のQA配信はまさに前者にあたるので、repackが素直に当てはまりました。

https://docs.expo.dev/build-reference/repack/

fingerprintでネイティブの変化を検出する

次に問題になるのが、「ネイティブが変わっていないことをどう判定するのか」です。

ここで使うのが @expo/fingerprint です。

fingerprintは、プロジェクトのネイティブ入力からハッシュ、つまり指紋を生成し、ネイティブ層とJS層の互換性を判断するための仕組みです。

依存パッケージ、独自のネイティブコード、ネイティブプロジェクトのファイル、各種設定などから計算されます。
expoexpo-updates に同梱されているので、追加インストールなしで使えることが多いです。

今回はこのハッシュを、ネイティブ成果物キャッシュのキーとして使いました。

ネイティブ入力が同じならハッシュも同じになります。
つまり、同じfingerprintの成果物がキャッシュにあれば、ネイティブは変わっていないので前回のバイナリを使い回せる、と判断できます。

動的な値がfingerprintに含まれるとキャッシュが死ぬ

ここで1つハマりました。

prebuild時などに決まる動的な値がfingerprintの計算対象に含まれていると、その値が変わるたびにハッシュも変わってしまいます。

たとえば、ビルドごとに変わるバージョン番号などです。

これがfingerprintに含まれていると、ネイティブの互換性としては変わっていないのに、ハッシュだけが毎回変わってしまいます。
その結果、キャッシュが一度もHITしません。

実際、最初はまさにこれにハマりました。

ビルドごとに変化するバージョン番号がfingerprintの入力に含まれていたためです。

対策として、そうした動的な値をfingerprintの計算対象から外しました。

@expo/fingerprint では、sourceSkips で特定のソースを除外できます。
たとえば ExpoConfigVersions を指定すると、app.jsonのバージョン、Androidの versionCode、iOSの buildNumber が計算対象から外れます。

// fingerprint.config.js
/** @type {import('@expo/fingerprint').Config} */
const config = {
  sourceSkips: ['ExpoConfigVersions'],
}

module.exports = config

設定後は、fingerprint:generate の出力に含まれる sources から、該当する値が消えることを確認しました。

versionCode / buildNumber は配布上のバージョン管理には関係しますが、今回の用途ではJSバンドルとネイティブバイナリの互換性を変える入力ではないため、fingerprintの判定対象から外しました。

ただし、除外する対象は慎重に選ぶ必要があります。
ネイティブバイナリの互換性に影響する値まで除外してしまうと、本来フルビルドすべき変更を見逃す可能性があります。

キャッシュキーは決定的でなければ意味がありません。
毎回変わる値が1つでも紛れ込むと、キャッシュは死にます。

ここが今回いちばんのハマりどころでした。

https://docs.expo.dev/versions/latest/sdk/fingerprint/

ネイティブ成果物をキャッシュしてJSだけ差し替える仕組み

fingerprintとrepackを組み合わせてネイティブビルドをスキップする、という考え方自体はExpoが公式に提唱しているものです。

EAS Workflowsを使っていれば、署名やビルド管理まで含めたrepackジョブが用意されています。

今回は前述のとおりself-hosted GitHub Actions上なので、この考え方を自分たちのworkflowとストレージに実装しました。

https://expo.dev/blog/accelerating-continuous-integration-with-fingerprint-repack-in-eas-workflows

全体の流れは次のようになります。

  1. @expo/fingerprint でネイティブ入力のハッシュを計算する
  2. そのハッシュをキーに、オブジェクトストレージへネイティブ成果物をキャッシュする
  3. PRビルド時にキャッシュの有無を判定して経路を分ける

今回はオブジェクトストレージとしてGoogle Cloud Storageを使いました。

base.ipa / base.apk は、あるfingerprintに対して一度フルビルドしたネイティブ成果物です。
同じfingerprintのPRでは、このbaseを共通で使い回します。

判定後の経路は次の2つです。

  • MISS: 同じfingerprintのbaseがないため、従来どおりフルネイティブビルドする。その後、成果物をキャッシュに保存して配信する
  • HIT: 同じfingerprintのbaseがあるため、キャッシュしたbaseを取得し、@expo/repack-app でJSを差し替えて再署名して配信する

図にするとこんなイメージです。

ポイントは、HIT時に expo prebuild、CocoaPods、Xcodeビルド、Gradleビルドといった重い工程を避けることです。
キャッシュしたネイティブ成果物を使うので、フルビルドの工程はスキップし、JSの差し替えと再署名だけを行います。

キャッシュのHIT / MISS判定

HIT / MISSの判定は、GCS上に対象の成果物が存在するかどうかで分けています。

たとえばiOSでは、fingerprintのハッシュを使って base.ipa のパスを決めます。

HASH=$(pnpm exec fingerprint fingerprint:generate --platform ios \
  | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.parse(s).hash||""))')

BASE_URI="gs://app-build-cache/base/ios-device/preview/staging/$HASH/base.ipa"

set +e
OUT=$(gcloud storage objects describe "$BASE_URI" 2>&1)
rc=$?
set -e

if [ $rc -eq 0 ]; then
  hit=true
elif echo "$OUT" | grep -qiE "not found|404|does not exist|matched no objects"; then
  hit=false
else
  exit "$rc"
fi

ここでも1つ注意点がありました。

「オブジェクトが存在しない」ときのメッセージは、gcloudのバージョンによって 404 / not found / matched no objects など表記が揺れます。

そのため、not-foundのときだけをMISSとして扱い、それ以外は本物のエラーとして失敗させるようにしました。

雑に「失敗したらMISS扱い」にすると、権限不足やバケット不在のような、本当は直すべきエラーをフルビルドで隠してしまいます。

このあたりは地味ですが、CIに組み込むうえでは大事なポイントでした。

HIT経路での再署名

HIT経路では、フルビルドの代わりに、キャッシュしたバイナリへ最新のJSを差し替えて再署名します。

ipa / apk の中身を差し替えるだけでは、そのまま端末にインストールできません。
差し替え後の成果物を、各プラットフォームの方式で再署名する必要があります。

ここはiOSとAndroidで少し事情が違いました。

iOS

iOSは、ad-hoc用のprovisioning profileと署名証明書を渡して再署名します。

# repack + 再署名(HIT, iOS)
npx --yes @expo/repack-app@0.6.2 \
  --platform ios \
  --source-app base.ipa \
  --output repacked.ipa \
  --embed-bundle-assets \
  --js-bundle-only \
  --signing-identity "Apple Distribution: Your Company (TEAMID)" \
  --provisioning-profile path/to/adhoc.mobileprovision

Widgetやnotification serviceのようなApp拡張を複数持っている場合は、--provisioning-profile{ bundleId: profilePath } 形式のJSONを渡すことで、まとめて再署名することもできます。

Android

Androidは、repack時に再署名用のkeystoreを渡します。
キャッシュした base.apk にJSバンドルとアセットを差し替えたうえで、QA配信用のkeystoreで署名します。

# repack + 再署名(HIT, Android)
BT=$(ls -d "$ANDROID_HOME"/build-tools/* | sort -V | tail -1)

npx --yes @expo/repack-app@0.6.2 \
  --platform android \
  --source-app base.apk \
  --output repacked.apk \
  --embed-bundle-assets \
  --js-bundle-only \
  --android-build-tools-dir "$BT" \
  --ks path/to/qa.keystore \
  --ks-pass pass:"$ANDROID_KEYSTORE_PASSWORD" \
  --ks-key-alias "$ANDROID_KEY_ALIAS" \
  --ks-key-pass pass:"$ANDROID_KEY_PASSWORD"

どのkeystoreで署名するかはプロジェクトの構成によって変わります。
ここはお使いの署名方式に合わせて読み替えてください。

結果: QAビルドを17分台から数分台に短縮できた

GitHub Actions / self-hosted runner上で、同じPRビルドの流れをMISS時とHIT時でそれぞれ1回ずつ計測しました。
時間はジョブ全体の実行時間で、いずれもsuccessしたビルドの結果です。

プラットフォーム MISS(フルビルド) HIT(repack) 短縮 速度比
iOS 17分33秒 4分06秒 −13分27秒(約77%減) 4.3倍
Android 17分17秒 2分37秒 −14分40秒(約85%減) 6.6倍

iOSのHITは、再署名や各種ツールのセットアップなどに時間がかかり、4分ほどになりました。
Androidの2分台と比べると差はありますが、もともとの17分台から見れば、どちらも大幅に短縮できています。

特にPRごとのQAビルドでは、17分待つのと数分で配信されるのとでは、体感がかなり違います。

ただし、今回の数値は各パターン1回ずつの実測値です。
runnerの状態、依存関係のキャッシュ、Firebase App Distributionへのアップロード時間などによって変動します。

そのため、この表の数値はベンチマークというより、今回の環境での一例として見てもらうのがよさそうです。
一方で、ネイティブが変わっていないときにフルネイティブビルドを避けることで、QAビルド時間を大きく短縮できることは確認できました。

まとめ

@expo/fingerprint でネイティブの変化を検出し、ネイティブ成果物をキャッシュして @expo/repack-app でJSだけ差し替えることで、QAビルドをiOSで約77%、Androidで約85%短縮できました。

ビルドが速くなったことで、テスターへの配布も早くなり、QAの回転、ひいては開発サイクルの改善にもつながりそうです。

React Nativeはビルド時間の長さがデメリットになりがちですが、こうしたキャッシュの仕組みでも改善できました。

それでは、よいReact Nativeライフを!

参考リンク

テラーノベル テックブログ

Discussion