壁紙アプリのプレビュー画面を 20 回ほど行き来したあと、端末が明らかに熱を持っていました。落ちるわけではない。ただ、指の下でスクロールがわずかに引っかかる。個人開発でこの「わずかに引っかかる」を放置した結果、D7 リテンションが 18% 落ち、連動して AdMob の eCPM が 22% 下がるところまで見届けたことがあります。広告ネットワークから見れば、すぐ離脱するアプリは単価を下げてよい対象なのだと、数字で思い知らされました。
Rork は React Native + Expo の構成でよく動くコードを出してくれます。ただ「動くコード」と「メモリを適切に解放するコード」は別物です。生成されたコードは宣言的な部分が整っている一方で、後始末のコードだけがすっぽり抜けていることが珍しくありません。
そしてもう一つ。この記事は当初 Flipper を前提に書いていましたが、その前提はすでに崩れています。React Native の Flipper 統合は 0.73 で非推奨となり、0.74 で新規テンプレートから外れ、0.76 以降は React Native DevTools が既定のデバッガになりました。Expo も SDK 50 の時点で Flipper のサポートを外しています。ネット上に残る「Flipper でメモリを見る」手順の多くが、今の環境では最初の接続すら通りません。以下は、その置き換えを済ませたあとの計測手順です。
なぜ Rork のコードでメモリリークが起きやすいのか
Rork の生成は、React のフックを使った宣言的な書き方は得意ですが、ライフサイクル周りの細かい後始末は省略されがちです。具体的には以下の4つの傾向が繰り返し観察できます。
useEffect でイベントリスナーを登録するがクリーンアップ関数を返さない
setInterval や setTimeout を使うがコンポーネントアンマウント時にクリアしない
- 非同期処理の結果をアンマウント後もステートに書き込もうとする
- Supabase / Firebase のリアルタイム購読や RevenueCat のリスナーを解除しない
いずれもコードとしては「動く」のが厄介なところです。長時間使うと確実にメモリが積み上がり、最後にはバックグラウンド復帰時の OS キル、つまり「アプリが落ちた」という体験につながります。壁紙アプリのように一回のセッションが短くてもアプリ内で頻繁に画面遷移するタイプは特に被害が大きく、画面遷移 10 回でヒープが 5MB 以上増えるようになった時期は、レビュー欄に「重い」「落ちる」が目に見えて増えました。
運用視点でのリリース判断基準
計測手順の前に、どこで線を引いているかを先に共有しておきます。ツールが Flipper から React Native DevTools に変わっても、この数値そのものは変える必要がありませんでした。見ているのはツールではなく、JS ヒープの増え方だからです。
- 10 往復後のヒープ増分が 3MB 未満 → リリース可
- 増分 3〜5MB → デバッグして原因特定 → 修正できたらリリース
- 増分 5MB 超 → 本番リリース停止。原因が特定できるまで App Store / Google Play の Submit を止める
- 増分 10MB 超 → 既にリリース済みなら緊急アップデート(48 時間以内)
この数字は端末の RAM 容量と OS のメモリ警告タイミングから逆算しています。4GB RAM の iOS 端末では、フォアグラウンドのアプリが概ね 1.3〜1.4GB を超えたあたりから OS キルの候補に上がります。起動直後 100MB のアプリが 10 往復ごとに 5MB ずつ積み上げると、単純計算で 2,600 往復。一見遠く思えますが、実際には画像キャッシュやネイティブ側の確保がここに乗るため、体感の劣化は桁ひとつ手前で始まります。
絶対値ではなく増え方を見る。 これが一番言いたいことです。リークのないアプリは、同じ操作を何度繰り返してもヒープが元の値付近に戻ります。戻らないものだけがリークです。
React Native DevTools のメモリパネルで可視化する
最初のステップは「本当にリークしているのか」を確認することです。感覚だけで修正に入るとキリがありません。
Step 1: DevTools を開く
Hermes で動いている React Native アプリであれば、追加インストールは不要です。
# Expo プロジェクトの場合
npx expo start
# 端末を接続した状態でターミナルに戻り、j キーを押すと DevTools が開きます
# (素の React Native プロジェクトなら npx react-native start → j)
# Dev Client を使っている場合も手順は同じ
npx expo start --dev-client
開くのは Chrome DevTools のフロントエンドです。Flipper のような独立したデスクトップアプリではなくなったぶん、セットアップで詰まる箇所がほぼ無くなりました。Flipper 時代に「プラグインのバージョンが合わない」で半日溶かした経験がある方ほど、この変化はありがたく感じるはずです。
タブのうち Memory と Performance を使います。メモリリークの診断で主に触るのは Memory タブの「Heap snapshot」です。
Step 2: 測定プロトコルを固定する
毎回同じ操作を行うことで、施策の効果を比較できるようにします。以下を「リーク回帰テスト」として固定しています。
- アプリを起動して、ホーム画面で 10 秒待機する
- Memory タブでスナップショットを 1 枚取得する(これがベースライン)
- 計測対象の画面を開く → 戻る、を 10 回繰り返す(各往復は 3〜5 秒間隔)
- もう一度スナップショットを取得する
- 2 枚のスナップショットを比較する
スナップショット取得時には自動的に GC が走るため、Flipper 時代のように Force GC を別途押す必要はありません。ここは手順が一つ減った箇所です。
Step 3: スナップショットの読み方
2 枚目のスナップショットを開いたら、上部のプルダウンで Comparison を選び、比較対象に 1 枚目を指定します。ここからが本番です。
- Delta 列で降順ソート する。往復のたびに増え続けているコンストラクタが上に来ます
# Delta が往復回数(10)の倍数に近いクラス を疑う。1 往復で 1 つ残っていれば 10、2 つなら 20 という具合に、きれいな数字が出ます
- 該当行を展開して Retainers(保持者) を見る。誰がそのオブジェクトを掴んだままなのかがここに出ます
この「往復回数の倍数」という当たりの付け方が、実務では一番効きました。ヒープ全体のサイズを眺めていても犯人は見えませんが、# Delta がちょうど 10 や 20 で並んでいるクラスは、ほぼ確実に往復ごとに 1 つずつ捨て損ねています。Retainers を辿ると、たいてい解除し忘れたリスナーかタイマーのクロージャに行き着きます。
Performance タブの JS heap 推移グラフも併用すると、往復の回数とヒープの階段状の増加が視覚的に一致するのが見えて、原因箇所の見当が付けやすくなります。
よくある 5 パターンと修正コード
パターン 1:useEffect のクリーンアップ忘れ
Rork が生成するコードで最も多いのがこれです。生成された5画面ぶんのコードを精査すると、3画面でこのパターンが見つかるという肌感です。
// ❌ リークするコード(クリーンアップなし)
useEffect(() => {
const subscription = someEventEmitter.addListener('update', handleUpdate);
// コンポーネントがアンマウントされても subscription が残り続ける
}, []);
// ✅ 修正版(クリーンアップ関数を返す)
useEffect(() => {
const subscription = someEventEmitter.addListener('update', handleUpdate);
return () => {
subscription.remove(); // アンマウント時に必ず解除
};
}, []);
Rork に修正を依頼する場合は「useEffect 内のイベントリスナーにクリーンアップ関数を追加してください。ただし、依存配列の中身は変更しないでください」と指示するのを好んでいます。依存配列を勝手に変更されると、別のレンダリングバグを誘発することがあるためです。
パターン 2:タイマーのクリア忘れ
定期更新や遅延処理を実装するときによく現れます。
// ❌ リークするコード
useEffect(() => {
const timer = setInterval(() => {
fetchLatestData(); // 画面を離れた後も延々と実行される
}, 5000);
// タイマーをクリアしていない
}, []);
// ✅ 修正版
useEffect(() => {
const timer = setInterval(() => {
fetchLatestData();
}, 5000);
return () => clearInterval(timer); // これだけで解決する
}, []);
setTimeout も同様に clearTimeout(timer) をクリーンアップ関数へ。requestAnimationFrame をループで回している箇所は cancelAnimationFrame(frameId) の解除が抜けがちで、壁紙アプリのプレビューアニメーションで何度か踏みました。アニメーションの継続時間が短いと症状が出にくく、発見が遅れます。
パターン 3:アンマウント後のステート更新
API コールが完了する前にユーザーが画面を離れると、コールバックがアンマウント済みのコンポーネントにステートを書き込もうとします。React 18 以降は警告が出なくなりましたが、警告が消えただけでリソースの無駄遣いは残ったままです。
// ✅ isMounted フラグで制御する方法
useEffect(() => {
let isMounted = true;
const fetchData = async () => {
const result = await api.getItems();
if (isMounted) { // マウント中のときだけ更新
setItems(result);
}
};
fetchData();
return () => {
isMounted = false; // アンマウント時にフラグを切る
};
}, []);
AbortController で通信そのものを中断する方法もあり、ネットワーク帯域まで考えるならそちらが本筋です。ただ Rork に追記を依頼する用途では、isMounted フラグのほうが差分が小さく、生成コードの構造を壊さずに入ります。「isMounted パターンで非同期処理を保護してください」と頼むほうが、AbortController を指示するより一度で通る確率が高いと感じています。
パターン 4:Supabase / Firebase / RevenueCat の購読解除忘れ
リアルタイム機能や課金機能でよく起きます。Rork のテンプレートで Supabase をつないだ瞬間に発生しやすいので、接続直後に必ず確認してください。
// ❌ リークするコード(Supabase realtime)
useEffect(() => {
const channel = supabase
.channel('messages')
.on('postgres_changes', { event: '*', schema: 'public', table: 'messages' }, handleChange)
.subscribe();
// channel.unsubscribe() がない
}, []);
// ✅ 修正版
useEffect(() => {
const channel = supabase
.channel('messages')
.on('postgres_changes', { event: '*', schema: 'public', table: 'messages' }, handleChange)
.subscribe();
return () => {
supabase.removeChannel(channel); // これで完全に購読を解除できる
};
}, []);
RevenueCat の購読状態リスナーも同様で、Purchases.addCustomerInfoUpdateListener で登録したら Purchases.removeCustomerInfoUpdateListener を必ず呼びます。この解除を忘れたアプリでは、1ヶ月放置してヒープが 4MB 単調増加していました。課金画面は開かれる回数が少ないぶん、往復テストでも見落としやすい箇所です。
// ✅ RevenueCat のリスナー解除パターン
useEffect(() => {
const handler = (info: CustomerInfo) => {
setIsPro(info.entitlements.active['pro'] !== undefined);
};
Purchases.addCustomerInfoUpdateListener(handler);
return () => {
Purchases.removeCustomerInfoUpdateListener(handler);
};
}, []);
パターン 5:FlashList のキー重複と v2 での変更点
これは厳密にはメモリリークではないのですが、リスト画面でヒープが急増する症状として現れます。FlashList は内部でセルをリサイクルするため、keyExtractor が一意でない値を返していると同じセルが複数保持されます。
// ❌ 同じ ID が複数あるとヒープが積み上がる
<FlashList
data={items}
keyExtractor={(item) => item.userId} // ユーザーごとに複数アイテムがあると重複
renderItem={renderItem}
/>
// ✅ 一意な ID を保証する
<FlashList
data={items}
keyExtractor={(item) => `${item.userId}-${item.timestamp}`}
renderItem={renderItem}
/>
ここで一点、古い記事を参考にする際の注意があります。FlashList は v2 で New Architecture 前提のリライトが入り、estimatedItemSize / estimatedListSize / estimatedFirstItemOffset は不要になりました。同期的にレイアウトを計測できるようになったため、推定値を人間が与える必要がなくなったからです。v1 の記事を見ながら estimatedItemSize を書き足しても、v2 では非推奨プロップを増やすだけになります。Rork の生成コードにこれらが残っていたら、v2 に上げたタイミングで外してください。
| プロップ | v1 | v2 |
estimatedItemSize | 実測に近い値が必須 | 不要(非推奨) |
keyExtractor | 一意性が必須 | 一意性が必須(変わらず) |
| New Architecture | 任意 | 前提 |
Xcode Instruments でさらに深掘りする(iOS)
DevTools で見えるのは JS ヒープです。ネイティブ側の確保、たとえば UIView や画像バッファの解放漏れはここには出てきません。iOS で「JS ヒープは戻っているのにメモリ全体が増える」ときは、Instruments の出番です。
Step 1: Instruments の起動と設定
# Rork Max で Xcode プロジェクトが生成されている場合は直接プロファイルできます
# Xcode で .xcworkspace を開く
# Product → Profile(Cmd + I)→ Leaks テンプレートを選択
# 起動後、左下の「+」から Allocations テンプレートを追加で読み込む
Allocations を併用すると、リークだけでなく「解放されていないが参照は残っている」オブジェクトも追跡できます。これがいわゆる滞留メモリで、リーク判定はされないのに実害があるという厄介な存在です。Leaks だけを見て「問題なし」と判断すると、この層をまるごと見落とします。
Step 2: 操作プロトコル
DevTools と同じ「10 往復テスト」を Instruments でも実行します。各往復のあとに「Mark Generation」ボタン(旗のアイコン)を押してください。各往復で新規に確保されたメモリを世代別に追跡できます。
各 Generation に残るオブジェクト数を見て、特定のクラス名が世代をまたいで増え続けていれば、そのクラスがリーク源です。UIView のサブクラスが各往復で 3〜5 個ずつ増え続けていた時期があり、原因は removeFromSuperview を呼んでいない箇所でした。JS 側のコードをいくら眺めても見つからない類のもので、Instruments を開いて初めて姿が見えました。
Step 3: 結果の解釈と次のアクション
Instruments は学習コストが少し高いぶん、「どのオブジェクトが解放されていないか」「どこから参照されているか」が一望できます。DevTools でリークの存在は確認できたのに原因が特定できない、というときの次の一手として置いておくのがちょうどよい距離感です。
修正後の確認方法
修正を加えたら、必ず以下の手順で改善を確認してください。
- DevTools を接続した状態でアプリを起動する
- 「リーク回帰テスト」プロトコル(スナップショット → 10 往復 → スナップショット)を実行する
- Comparison ビューで、疑っていたクラスの
# Delta が 0 付近に戻っていることを確認する
- ヒープ全体の増分が 3MB 未満であることを確認する
- iOS では Instruments の Generations 検査も実施する
- ストア提出前の最終確認として、実機での 30 分連続使用テストを行う
3 番を飛ばさないでください。全体のヒープ増分だけを見ていると、あるリークを直した裏で別のリークが育っていても数字が相殺されて気付けません。犯人だったクラスの Delta が個別に落ちていることまで確認して、はじめて「直った」と言えます。
Rork への修正依頼プロンプトの書き方
自分でコードを修正するのが難しい場合は、Rork に直接依頼できます。運用で使っているプロンプトを共有します。
以下のファイルを確認して、メモリリークの原因となっている箇所をすべて修正してください:
- useEffect 内のイベントリスナーにクリーンアップ関数が設定されているか
- setInterval / setTimeout のクリアが漏れていないか
- requestAnimationFrame のキャンセルが漏れていないか
- Supabase / Firebase のリアルタイム購読を解除しているか
- RevenueCat の addCustomerInfoUpdateListener を解除しているか
- FlashList の keyExtractor が一意な値を返しているか
- 非同期処理の結果をアンマウント後に書き込もうとしていないか
修正方針:
- 既存の依存配列([]内)は変更しないでください
- isMounted フラグパターンを優先してください
- 修正後、変更箇所とその理由を箇条書きで教えてください
このプロンプトを src/screens/YourScreen.tsx のような特定のファイルとセットで渡すと、的確に直してくれます。一度に複数ファイルを渡すと修正漏れが増えるため、1ファイルずつ依頼するのを好んでいます。
eCPM とリテンションへの影響
メモリリークの放置は、技術的な健全性だけでなく収益にも直結します。壁紙アプリでメモリリークを修正した前後のデータは以下のとおりです。
| 指標 | 修正前 | 修正後 | 差分 |
| D1 リテンション | 38% | 41% | +3pt |
| D7 リテンション | 11% | 13% | +2pt |
| 平均セッション時間 | 2分10秒 | 2分45秒 | +27% |
| AdMob eCPM | $4.20 | $5.15 | +22% |
数字の因果を厳密に切り分けたわけではありません。同時期に他の改善も入っていますし、季節変動もあります。それでも、セッション時間が伸びた分だけ広告表示機会が増え、離脱率が下がった分だけ入札が上向く、という方向は一貫していました。AdMob は「広告を表示しても次のセッションに繋がらない」アプリの単価を下げる傾向があるため、リテンション低下と eCPM 低下は連動して起きます。メモリリークの修正は、品質改善であると同時に売上施策でもある。そう捉え直してから、優先度の付け方が変わりました。
予防のための習慣
メモリリークは後から修正するより、最初から防ぐほうが楽です。Rork でコードを生成したあとに毎回確認している点をまとめます。
useEffect を見つけたら、必ず「クリーンアップ関数が必要か」を考える
- タイマー系(
setInterval, setTimeout, requestAnimationFrame)にはクリア / キャンセルを自動的に添える
- 外部サービス(Supabase, Firebase, WebSocket, RevenueCat)の購読はすべてクリーンアップで解除する
- FlashList の
keyExtractor は複合キーで一意性を担保する
- 重要な画面を変更したあとは DevTools で「スナップショット 2 枚比較」を回す
- ストア Submit 前に Instruments の Generations 検査を 1 回入れる
診断手順を持っていることの価値は、リークを見つけられることそのものより、「重くなってきた気がする」という曖昧な不安に数字で答えを出せることにあると思っています。答えが出れば、直すか見送るかを落ち着いて選べます。
あわせてアプリ全体のパフォーマンスも見直しておくと効果的です。Rork アプリの動作が重くなったときのトラブルシューティングや、React Native のビルドエラーを解決する手順も参考にしてみてください。
まずは自分のアプリで useEffect を全文検索し、クリーンアップ関数が返されていない箇所がないか確認してみましょう。おそらく 1〜2 箇所は見つかるはずです。同じ課題に取り組んでいる方の参考になれば幸いです。