個人でアプリ開発を 2014 年から続けている廣川政樹です。Rork が吐き出してくれる React Native プロジェクトは便利な反面、本番運用に持っていくときに「いつ React Native の New Architecture (Fabric + TurboModules + Bridgeless) に乗せ替えるか」という判断を毎回迫られます。今回は壁紙系の自分のアプリ群を題材に、Rork の叩き台に New Architecture を段階的に流し込んだ検証手順をまとめます。累計 5,000 万 DL を超えた壁紙アプリ群を 6 本並列で運用してきた身として、いきなり全部を切り替える勇気は出ませんでした。リスクを分割しながら数値で追える形に持ち込んだ流れを順に書きます。
なぜ New Architecture を「移行」ではなく「段階導入」と呼ぶか
React Native の New Architecture は、ブリッジ廃止・Fabric レンダラ・TurboModules を一括で有効化する派手な変更ですが、現場視点ではフラグ 1 個で覚悟を決める作業ではありません。私の場合、Rork で出力した雛形に AdMob・Firebase・Skia・Reanimated・MMKV・AppLovin MAX を載せた構成を 6 アプリに横展開しています。その状態で newArchEnabled = true を一発でひっくり返すと、Fabric 非対応の古いネイティブモジュールが起動直後に静かに死に、TestFlight 配布まで気付けないことが過去にありました。
そこで現在は「移行」ではなく「段階導入」という言葉を社内ドキュメントでも使うようにしています。具体的には次の 4 段階に分けています。
ベースライン計測 — Old Architecture のまま冷起動・JS スレッド負荷・クラッシュ率を 2 週間取る
フラグ実装 — expo-build-properties 経由で newArchEnabled を端末別に切り替えられるようにする
カナリア配布 — 6 アプリのうち最も DAU が小さい 1 本を社内 + TestFlight で 1 週間まわす
横展開 — KPI が同等以上ならアプリを 1 本ずつ昇格、悪化が出たら直前のステップに戻す
「いきなり Production の全アプリを切り替えない」というだけのルールですが、運用上はこの順序が一番効きます。Rork が出した雛形は New Architecture 対応として綺麗に組まれていることが多いので、ぶつかるのはほぼ自分が後から足したネイティブ依存だけ、という認識でも実態に近いです。
Step 1: ベースライン計測の取り方
ベースライン段階では Old Architecture のままで構いません。むしろ「移行前の値が手元にない」状態で先に進むと、後段で「速くなった気がするが説明できない」という曖昧な意思決定になります。私は最低 14 日の連続稼働を確保するようにしています。
計測項目は次の 5 つに固定しています。
冷起動: Firebase Performance Monitoring の _app_start を p50/p95 で
JS スレッド負荷: Hermes プロファイラで主要画面遷移を 30 サンプル取る
ネイティブメモリ: Xcode Instruments と Android Profiler で同一シナリオを 3 回
クラッシュ率: Crashlytics の Crash-free users を直近 14 日
ANR (Android): Play Console の Android Vitals から 7 日中央値
この 5 項目を Notion の検証表に貼り、後段で同じ列を 2 週間ごとに追記していきます。記録のフォーマットを最初に固めておくと、社内で比較するときに「数字の意味のすり合わせ」に時間を使わずに済みます。
Step 2: newArchEnabled を端末別に切り替えるためのフラグ実装
Rork が吐いた app.config.ts を起点に、expo-build-properties で New Architecture フラグを差し込みます。私の構成では次のように Plugin 設定だけで完結させています。実体験として、ここに enableProguardInReleaseBuilds などビルド最適化系を混ぜると切り戻しの単位がぼやけるので、New Architecture 専用の Plugin ブロックは独立させています。
// app.config.ts
import type { ExpoConfig } from "expo/config" ;
const enableNewArch = process.env. NEW_ARCH === "1" ;
const config : ExpoConfig = {
name: "WallpaperApp" ,
slug: "wallpaper-app" ,
plugins: [
[
"expo-build-properties" ,
{
ios: {
newArchEnabled: enableNewArch,
deploymentTarget: "15.1" ,
},
android: {
newArchEnabled: enableNewArch,
minSdkVersion: 24 ,
compileSdkVersion: 35 ,
targetSdkVersion: 35 ,
},
},
],
],
ios: { bundleIdentifier: "com.dolice.wallpaperapp" },
android: { package: "com.dolice.wallpaperapp" },
};
export default config;
CI 側では NEW_ARCH=1 をブランチ別に渡せるようにします。私は EAS Build を使っているので、eas.json のプロファイルを 2 つ用意し、production と production-newarch を並走させています。1 アプリにつき AAB / IPA が 2 系統生まれますが、ロールアウト中だけ容認しています。
// eas.json (抜粋)
{
"build" : {
"production" : {
"channel" : "production" ,
"env" : { "NEW_ARCH" : "0" }
},
"production-newarch" : {
"channel" : "production-newarch" ,
"env" : { "NEW_ARCH" : "1" }
}
}
}
このとき気をつけたいのは、Hermes バイトコードキャッシュは Old/New で互換性が無いことです。チャンネルを分けておかないと、EAS Update で配信した JS バンドルが「想定外の Architecture で起動」してしまい、起動直後にホワイトスクリーンになります。チャンネルの分離は手間に見えますが、ここを揃えるとロールバック作業がチャンネル切替 1 回で完結します。
Step 3: Fabric 非対応モジュールを Interop Layer 側で包み込む判定フロー
New Architecture の段階導入で最も時間が溶けるのが、自前ネイティブモジュールおよびサードパーティ製ライブラリの Fabric/TurboModule 対応状況の見極めです。React Native 0.74 系以降に同梱されている Interop Layer のおかげで、Old な Native Module / Old な UIManager 由来コンポーネントもしばらくは動きますが、互換性レイヤーを経由する分だけ起動コストが乗ります。
私の判定フローは次の 3 段階です。
公式または OSS 側のリポジトリで RCT_NEW_ARCH_ENABLED の対応コミットが入っているかを grep で 1 分以内に確認
入っていれば npm の最新版に更新し、pod install 後に RCT_NEW_ARCH_ENABLED=1 pod install を再実行
入っていなければ Interop Layer 経由で温存しつつ、置き換え候補をスプレッドシートに記録
3 のスプレッドシートは Notion の DB として持っていて、「ライブラリ名 / 互換状況 / Interop 経由のフレーム落ちの有無 / 置き換え候補 / 期限」の 5 列にしています。期限は最大でも 90 日で切るようにしていて、これを超えたら DAU の小さいアプリから本格的に剥がし始めます。
実装例として、自前で書いた小さな TurboModule を Codegen で生成する流れだけ抜粋します。Spec ファイルを置くだけで雛形が走るので、独自ロジックは requireNativeModule 越しに呼び出すだけにしておくと、Old/New 切替時の影響を最小化できます。
// specs/NativeWallpaperGallery.ts
import type { TurboModule } from "react-native" ;
import { TurboModuleRegistry } from "react-native" ;
export interface Spec extends TurboModule {
pickWallpaper ( category : string ) : Promise <{ uri : string ; width : number ; height : number }>;
scheduleDailyRotation ( hourJst : number ) : Promise < boolean >;
}
export default TurboModuleRegistry . getEnforcing < Spec >( "WallpaperGallery" ) ;
// src/lib/wallpaperGallery.ts
import NativeWallpaperGallery from "../../specs/NativeWallpaperGallery" ;
export async function pickTodayWallpaper ( category : string ) {
return NativeWallpaperGallery. pickWallpaper (category);
}
Codegen で生成された型を共通化しておくと、Old Architecture でフォールバック実装する際もインターフェースが揃った状態で書けます。私の運用では __DEV__ ではなく Platform.constants.reactNativeVersion?.minor を見て分岐するより、起動時に global?.RN$Bridgeless を確認する方が確実でした。Bridgeless かどうかが判別できれば、後段の UI 実装で Animated の代わりに Reanimated に強制したり、SectionList の代わりに Fabric 最適化が効いた FlashList にしたりといった分岐がやりやすくなります。
// src/lib/runtime.ts
export const isBridgeless : boolean =
// @ts-expect-error: New Architecture only
typeof globalThis.RN$Bridgeless === "boolean" ? globalThis.RN$Bridgeless : false ;
export function selectListComponent < T >() {
// Bridgeless 環境では FlashList の方が初回スクロール時の jank が安定して低い
return isBridgeless ? "FlashList" : "FlatList" ;
}
Step 4: 壁紙系 6 アプリで 2 週間サイクルの KPI 比較を回す
カナリアアプリの 1 週間が完走したら、4 軸で旧構成と並べて評価します。実測値の傾向としては、Rork が出した雛形に Skia と Reanimated を載せたアプリは、Bridgeless ON の方が起動直後の 1 秒目に発生する JS タスクの集中が解消され、p95 冷起動が 12〜18% ほど短縮される傾向にありました。一方、AppLovin MAX を AdMob メディエーション経由で併走させているアプリは、Bridgeless 切替直後にメインスレッドのバナー初期化が前倒しされ、起動直後 3 秒間の CPU 使用率が一時的に 6〜9 ポイント上がることがありました。
評価は次の表形式で残しています。p95 や Crash-free を「同等」と判定する閾値はあらかじめ決めておくのが大事で、私の場合は「冷起動 p95 が 5% 以上悪化したら不合格」「Crash-free が 0.3 ポイント以上悪化したら不合格」というしきい値を固定しています。
観測項目 Old Architecture (平均) New Architecture (平均) 判定基準
冷起動 p50 (Android Pixel 6) 約 820 ms 約 740 ms 5% 改善で合格
冷起動 p95 (iPhone 12) 約 1,420 ms 約 1,190 ms 5% 悪化以内で許容
Crash-free users (14 日) 99.62% 99.71% 0.3 pt 以内維持
ANR rate (Android) 0.18% 0.14% 0.05 pt 以内維持
AdMob impression / DAU 6.2 6.1 5% 以内維持
数字は私の壁紙系 6 アプリのうちの代表 1 本(Beautiful HD Wallpapers 系列)で観測した平均値で、当然アプリ特性により振れます。重要なのは絶対値ではなく、Old/New を「同じ評価軸で並べた状態にしてから判断する」という構造の方です。
切り戻しの設計と「カナリアの止め方」
段階導入で一番怖いのは「カナリアの停止判断が遅れて、横展開を待っているアプリ全部に影響が出る」事態です。私の運用では、停止判断を CI 側ではなく Slack 通知ベースで人間が引き受けるようにしています。具体的には Crashlytics の Velocity Alert と Play Console の ANR 通知を Slack の #alerts-newarch に流し、しきい値超過から 30 分以内にカナリアアプリのチャンネルを production に巻き戻す手順を Runbook 化しています。
#!/usr/bin/env bash
# scripts/rollback-newarch.sh
set -euo pipefail
APP_SLUG = " $1 " # 例: wallpaper-app-relax
eas channel:edit production-newarch \
--branch "production" \
--json | tee "logs/rollback-${ APP_SLUG }-$( date +%Y%m%d_%H%M%S).json"
echo "[rollback] ${ APP_SLUG } を production チャンネルに戻しました"
このスクリプトはチャンネルを書き換えるだけのワンライナーですが、EAS Update のチャンネルを切替えるだけで Old Architecture バンドルが配信されるよう CI を組んでおくと、原因究明より先に止血ができます。バイナリ再ビルドが必要な深い問題のときは、App Store / Play Console の段階リリースを 1% まで絞ったまま放置し、新規ダウンロード経路だけ完全に閉じる運用にしています。
後段に残った課題と、次の四半期で潰したいこと
ここまで 3 アプリの昇格を終え、残り 3 本のうち 2 本は依存ライブラリの Fabric 対応待ち(Skia 系の特殊シェーダを使っている方)、最後の 1 本は AdMob メディエーション経由の旧アダプタが Bridgeless 環境で読み込みエラーを返す問題が残っています。これは AppLovin 側の SDK 13.x 系で解消する見込みなので、6 月に SDK アップデート → 再カナリア → 横展開、という流れを組む予定です。
長期運用の側面では、6 アプリ全てが New Architecture に揃った後で、Reanimated 4 や FlashList v2 など Fabric 前提の新機能を改めて検討する順序にしています。Old/New 混在の期間に新機能まで足してしまうと、原因特定が一気に難しくなるためです。「アーキ移行」と「機能追加」を同じスプリントに乗せない、というのは個人開発であっても守ったほうが結果的に楽でした。
Rork は React Native プロジェクトを素早く立ち上げてくれますが、長く運用するアプリほど New Architecture を「いつ・どの順で入れるか」を考える時間が必要になります。今回の段階導入の流れが、同じく複数アプリを抱えながら New Architecture を検討している方の参考になれば嬉しいです。お読みいただきありがとうございました。