開発機では出ない不具合に半日溶かした話
ある壁紙アプリを Expo SDK 52 系へ上げ、New Architecture を有効にしたまま数週間、特に問題なく運用していました。ところが、ある日 TestFlight に配ったビルドで「一覧をスクロールすると一瞬引っかかる」という報告が届きます。手元の開発ビルドでは何度試しても再現しません。Expo Go でも、expo run:ios のデバッグビルドでも、滑らかに動くのです。
最終的に原因は、特定のサードパーティ製ネイティブモジュールが New Architecture の TurboModule として動かず、interop 層(後方互換のためのブリッジ)を経由して静かに動いていたことでした。interop 層は「動かなくする」のではなく「遅いまま動かす」ので、機能テストはすべて通ります。だからこそ release ビルドの実機で、しかも特定画面でだけ顔を出すのです。
このメモは、同じ「dev では出ないが release でだけ出る」種類の回帰に当たったときに、当て推量ではなく計測で切り分け、全体を旧アーキへ巻き戻さずに直すための実務記録です。私自身、2014年から個人開発を続けていますが、こうした「環境差で出たり消えたりする」不具合ほど、まず観測点を置くことが近道だと感じています。
なぜ dev と release で挙動が割れるのか
New Architecture では、JavaScript とネイティブの間が JSI(直接参照)に置き換わり、ネイティブモジュールは TurboModule として遅延初期化されます。しかし旧式の NativeModules / NativeEventEmitter に依存したライブラリは、いきなり動かなくなるわけではありません。React Native は interop 層を用意していて、旧 API を新ランタイム上で取り持ってくれます。
問題は、この取り持ちにコストがかかる点です。旧経路では呼び出しごとに JSON シリアライズとスレッド跨ぎが発生し、JSI のゼロコピー同期呼び出しの恩恵が消えます。一覧のセルがマウントのたびに旧式モジュール(例えば画像のメタ情報取得やデバイス情報の同期取得)を叩いていると、その差がスクロールのフレーム落ちとして表面化します。
dev ビルドでこれが見えにくいのは、Metro のリロード前提で最適化が浅く、そもそも体感のばらつきが大きいからです。逆に release ビルドは Hermes のバイトコード化や最適化が効くぶん、相対的に「旧経路だけが遅い」という差が際立ちます。つまり release でだけ出るのは偶然ではなく、構造的に起きやすい現象です。
まず観測点を置く — interop に落ちたモジュールを列挙する
犯人を勘で探す前に、「どのネイティブモジュールが TurboModule になっていて、どれが legacy 経路か」を起動時に一覧化します。アプリ起動直後に一度だけ走らせる軽量な点検です。
// src/diagnostics/archProbe.ts
// 起動時に一度だけ呼び、TurboModule 化されているモジュールと
// legacy interop に落ちているモジュールを切り分けて記録する。
import { TurboModuleRegistry } from 'react-native';
type ProbeResult = {
name: string;
isTurbo: boolean;
};
// 自分のアプリが実際に使っているネイティブモジュール名を列挙する。
// (依存ライブラリの README やネイティブ実装の MODULE_NAME を確認して埋める)
const SUSPECTS = [
'RNDeviceInfo',
'RNCImageMetadata',
'RNFSManager',
'RNCAsyncStorage',
];
export function probeArchitecture(): ProbeResult[] {
return SUSPECTS.map((name) => {
// TurboModuleRegistry.get は TurboModule として登録されていれば
// 非 null を返す。null の場合は legacy 側で解決されている可能性が高い。
const turbo = TurboModuleRegistry.get(name);
return { name, isTurbo: turbo != null };
});
}
// 期待する出力例:
// [{ name: 'RNDeviceInfo', isTurbo: false }, ...] ← false が要注意
isTurbo: false のモジュールが、interop 層を通っている候補です。ここで大事なのは、この結果を本番ビルドのテレメトリに乗せて、実機の母集団で見ることです。手元の1台では再現しない問題を相手にしているので、観測も実機側に置きます。
// App.tsx の初期化部
import { probeArchitecture } from './src/diagnostics/archProbe';
import { track } from './src/telemetry';
const probes = probeArchitecture();
const legacy = probes.filter((p) => !p.isTurbo).map((p) => p.name);
// 個人情報を載せず、モジュール名と件数だけを送る。
track('arch_probe', { legacyCount: legacy.length, legacyModules: legacy });
数日分のイベントが溜まると、「release 機の大半で RNCImageMetadata が legacy 経路」といった事実が、推測ではなくデータとして見えてきます。
ホットパスでの呼び出し回数を測る
legacy モジュールが存在するだけでは、必ずしも体感に響きません。効いてくるのは、それがスクロールのようなホットパスから高頻度で呼ばれているときです。そこで、疑わしいモジュールの呼び出しを薄く包んで頻度と所要時間を測ります。
// src/diagnostics/wrapNativeCall.ts
// 疑わしいネイティブ呼び出しを包み、1フレーム予算(約16.6ms)を
// 超えた同期呼び出しだけを記録する。常時計測ではなく閾値超えのみ。
import { track } from './src/telemetry';
const FRAME_BUDGET_MS = 16.6;
export function timed<T>(label: string, fn: () => T): T {
const start = performance.now();
const result = fn();
const elapsed = performance.now() - start;
if (elapsed > FRAME_BUDGET_MS) {
track('native_call_slow', { label, ms: Math.round(elapsed) });
}
return result;
}
// 一覧セル内での使用例
// 旧式モジュールの同期取得をセルのレンダリングごとに呼んでいた箇所
const meta = timed('imageMeta', () => ImageMetadata.getSync(uri));
この計測を入れて初めて、「imageMeta がセル表示のたびに走り、release 機では 20〜30ms かかってフレームを落としている」という具体像がつかめました。dev で見えなかったのは、開発機の方が CPU に余裕があり、20ms 台でもフレーム落ちに至りにくかったからです。原因の所在が数値で確定したので、ここからは直し方の問題になります。
直す — 全体を旧アーキへ戻さない
いちばん手早い「対処」は newArchEnabled: false で全体を旧アーキへ戻すことです。しかしこれは、TurboModule 化できている他のモジュールの恩恵まで捨てることになり、しかも将来的に旧アーキ自体が廃止されるため、借金を先送りするだけです。問題は1モジュールに局在しているのですから、そこだけを隔離します。
手当て1: ホットパスから外す
最も効果が大きいのは、そもそもホットパスから旧式呼び出しを抜くことです。画像メタ情報のように一覧の各セルで都度取得していたものは、取得結果をキャッシュし、表示時には同期呼び出しをしないように組み替えます。
// src/lib/imageMetaCache.ts
// uri 単位でメタ情報をキャッシュし、セル描画時の同期呼び出しを無くす。
// 取得はリスト表示の外(プリフェッチ時)で非同期に済ませる。
const cache = new Map<string, { width: number; height: number }>();
export async function prefetchMeta(uri: string): Promise<void> {
if (cache.has(uri)) return;
const meta = await ImageMetadata.getAsync(uri); // 非同期版を使う
cache.set(uri, meta);
}
export function readMeta(uri: string) {
return cache.get(uri); // セル内ではキャッシュ参照のみ。ネイティブを叩かない。
}
セルはキャッシュを読むだけになり、ネイティブ往復がレンダリングから消えます。多くの場合、これだけでスクロールは元の滑らかさに戻ります。
手当て2: TurboModule 対応版へ寄せる、なければ薄い自作 TurboModule
ライブラリ側が New Architecture 対応の新バージョンを出していれば、まずそこへ上げます。directory での対応状況を確認しておきます。
# 依存が New Architecture 対応かを確認する
npx @react-native-community/cli config 2>/dev/null | grep -i "newArch"
# 対応表は React Native Directory の "New Architecture" バッジでも確認できる
対応版が無く、かつ機能が小さい場合は、必要な1〜2メソッドだけを TurboModule として自作するほうが、巨大ライブラリの interop コストを払い続けるより安く付くことがあります。Codegen の Spec を1枚書けば、型定義からネイティブのスタブが生成されます。
// specs/NativeImageMeta.ts
// 必要最小限の同期メソッドだけを TurboModule として宣言する。
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
export interface Spec extends TurboModule {
getSize(uri: string): { width: number; height: number };
}
export default TurboModuleRegistry.getEnforcing<Spec>('NativeImageMeta');
「全部を内製しろ」という話ではありません。ホットパスに居座る1つだけを、薄く置き換える判断です。
手当て3: 切り戻しのための機能ゲート
修正を一度に全ユーザーへ当てるのは怖いものです。新経路(キャッシュ+TurboModule)と旧経路を、リモートで切り替えられるゲートの背後に置き、段階的に開きます。
// src/lib/featureGate.ts
// 新しい画像メタ経路を割合配信で開く。問題が出たら即座に 0% に戻せる。
import remoteConfig from './remoteConfig';
export function useTurboImageMeta(): boolean {
// 例: 0〜100 の数値を返すリモート値。まず 10、安定したら 50、100 と上げる。
return remoteConfig.getNumber('turbo_image_meta_rollout') >= bucketOf(userId);
}
arch_probe と native_call_slow のテレメトリを見ながら割合を上げ、native_call_slow の発生率が下がっていることを確認してから全開にします。万一新経路で別の問題が出ても、ストア審査を待たずにリモートで 0% に戻せます。OTA(EAS Update)と組み合わせれば、JS 側の修正は審査なしで配れます。
再発させないための運用
一度直しても、依存を増やすたびに同じ穴に落ちる可能性があります。次の3つを習慣にしておくと、静かなフォールバックを早期に捕まえられます。
第一に、arch_probe を本番に残し続けることです。新しいライブラリを足したリリースで legacyCount が増えたら、その時点で気づけます。第二に、native_call_slow の発生率をリリースごとのダッシュボードに置き、回帰の有無を毎回確認することです。第三に、依存追加のレビュー時に「これは New Architecture 対応か」を明示的に確認する項目を1行入れておくことです。
| 観測点 | 何を見るか | 異常のサイン |
| arch_probe | legacy 経路のモジュール名と件数 | リリースで件数が増える |
| native_call_slow | フレーム予算超えの同期呼び出し | 特定 label の発生率上昇 |
| 依存追加レビュー | New Architecture 対応バッジ | 未対応のまま merge される |
切り分けの型として残しておく
今回の件で効いたのは、特別な知識よりも「dev で再現しないなら、観測も判断も release 実機側に置く」という単純な原則でした。interop 層は親切設計ゆえに失敗を隠します。隠れたコストを表に出すには、TurboModule 化の有無とホットパスでの所要時間という2つの数値を、本番の母集団で見るのがいちばん速い、というのが私の結論です。
同じように「特定環境でだけ出る」不具合に向き合っている方の、切り分けの型として残しておきます。お読みいただきありがとうございました。