廣川政樹です。2014年からの個人開発で累計5,000万ダウンロードのアプリ群を運用してきましたが、Rork で新しいアプリを試作するようになってから一番神経を使うのが「メモリの面倒見」です。AdMob 月収100万円超を出していた壁紙アプリで一度メモリリークを見落とし、D7 リテンションが 18% 落ち、結果として eCPM まで 22% 下がった経験があります。広告ネットワークから見れば「すぐ落ちるアプリ」は単価を下げてよい対象だからです。
Rork は React Native + Expo の構成でかなり良いコードを出してくれるのですが、「動くコード」と「メモリを適切に解放するコード」は別物です。このページでは Flipper と Xcode Instruments の使い分け、5つのリークパターンの Before/After、そして本番リリースを止める判断基準まで一通り扱っていきます。Rork Max を使って自分の Xcode プロジェクトを直接触れるようになった人ほど、ここで紹介する Instruments の手順は重宝するはずです。
なぜ Rork のコードでメモリリークが起きやすいのか
Rork の生成は、React のフックを使った宣言的な書き方は得意ですが、ライフサイクル周りの細かい後始末は省略されがちです。具体的には以下の4つの傾向が頻繁に観察できます。
useEffect でイベントリスナーを登録するがクリーンアップ関数を返さない
setInterval や setTimeout を使うがコンポーネントアンマウント時にクリアしない
- 非同期処理の結果をアンマウント後もステートに書き込もうとする
- Supabase / Firebase のリアルタイム購読や RevenueCat のリスナーを解除しない
これらはコードとして「動く」のですが、長時間使うと確実にメモリが積み上がり、最後にはバックグラウンド復帰時の OS キル → 「アプリが落ちた」という体験につながります。私の運用経験では、壁紙アプリのように一回のセッションが短くてもアプリ内で頻繁に画面遷移するタイプは特に被害が大きく、画面遷移 10 回でヒープが 5MB 以上増えるとレビュー欄に「重い」「落ちる」が即時に増えました。
運用視点でのリリース判断基準
技術的な話の前に、私が運用上どこで線引きしているかを共有しておきます。AdMob で月収100万円超を出していた時期に決めた数字で、現在もそのまま使っています。
- Flipper で 10 往復後のヒープ増分が 3MB 未満 → リリース可
- 増分 3〜5MB → デバッグして原因特定 → 修正可能なら直してリリース
- 増分 5MB 超 → 本番リリース停止。原因特定するまで App Store / Google Play の Submit を止める
- 増分 10MB 超 → 既にリリース済みなら緊急アップデート(48 時間以内)
この数字は端末の RAM 容量と OS のメモリ警告タイミングから逆算しています。iOS の場合、4GB RAM の端末では約 1.4GB を超えるとアプリが OS キル候補に上がるため、起動直後 100MB のアプリだと残り 1.3GB を 10 往復ごとに食い潰す速度を見るのが現実的です。「絶対値」ではなく「増え方」を監視することが大切です。
Flipper でメモリを可視化する
最初のステップは「実際にリークしているのか」を確認することです。感覚だけで修正してもキリがありません。
Step 1: Flipper の準備(Mac が必要)
# Flipper は Expo 開発者向けの公式デバッグツール
# https://fbflipper.com/ からダウンロードしてインストール
# EAS ビルドではなく、開発用ビルドで接続します
npx expo start --dev-client
# Flipper Desktop アプリを起動した状態で、シミュレーターまたは実機を接続
# 自動検出されない場合は「+ Add Device」から手動で追加
Flipper を起動して端末またはシミュレーターと接続したら、左メニューの「React DevTools」→「Profiler」タブと、「Memory」タブを両方開いておきます。
Step 2: 測定プロトコルの実行
毎回同じ操作を行うことで、施策の効果を比較可能にします。私は以下のプロトコルを「リーク回帰テスト」として固定しています。
- アプリを起動して、ホーム画面で 10 秒待機
- Flipper Memory タブで「Force GC」をクリックしてベースライン取得
- 計測対象画面を開く → 戻る、を 10 回繰り返す(各往復は約 3〜5 秒間隔)
- 最後にもう一度「Force GC」をクリック
- ベースラインと現在値の差分を記録
Force GC を挟むのが重要です。これがないとガベージコレクション待ちのオブジェクトまでリークと誤判定し、対応コストが2倍になります。
Step 3: 数値の読み方
Flipper の Memory タブで以下を確認してください。
- Heap Size: 上の判定基準で線引き。10 往復後の増分が 5MB 超なら本番停止
- Retained Size: 特定のコンポーネントが突出して大きければそこが犯人
- GC Roots の数: 増え続けていればリスナー登録解除漏れの典型サイン
数値の見方に迷ったら「同じ画面を 10 回開いて閉じた後のヒープサイズ」を最初の値と比べてみてください。リークがなければほぼ同じ値に戻るはずです。戻らなければリークしています。
よくある 5 パターンと修正コード
パターン 1:useEffect のクリーンアップ忘れ
Rork が生成するコードで最も多いのがこれです。私の経験では、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 の警告が出ることがあります。React 18 以降は警告が出なくなりましたが、リソースが無駄に消費される問題は残ります。
// ✅ isMounted フラグで制御する方法
useEffect(() => {
let isMounted = true;
const fetchData = async () => {
const result = await api.getItems();
if (isMounted) { // マウント中のときだけ更新
setItems(result);
}
};
fetchData();
return () => {
isMounted = false; // アンマウント時にフラグを切る
};
}, []);
AbortController を使う方法もありますが、isMounted フラグのほうがコードがシンプルで読みやすく、Rork のコードに追記しやすいです。私の場合、Rork に「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ヶ月放置すると Heap Size が 4MB 単調増加していました。
// ✅ RevenueCat のリスナー解除パターン
useEffect(() => {
const handler = (info: CustomerInfo) => {
setIsPro(info.entitlements.active['pro'] !== undefined);
};
Purchases.addCustomerInfoUpdateListener(handler);
return () => {
Purchases.removeCustomerInfoUpdateListener(handler);
};
}, []);
パターン 5:FlatList を FlashList に切り替えた直後のキー重複
これは厳密にはメモリリークではないのですが、FlatList から FlashList に切り替えた直後に Heap が急増する症状が出ることがあります。FlashList は内部でアイテムをリサイクルする仕組みなので、keyExtractor で一意でないキーを返していると同じセルが複数保持されてメモリが積み上がります。
// ❌ 同じ ID が複数あると Heap が積み上がる
<FlashList
data={items}
keyExtractor={(item) => item.userId} // ユーザーごとに複数アイテムがあると重複
estimatedItemSize={80}
renderItem={renderItem}
/>
// ✅ 一意な ID を保証する
<FlashList
data={items}
keyExtractor={(item) => `${item.userId}-${item.timestamp}`}
estimatedItemSize={80}
renderItem={renderItem}
/>
estimatedItemSize を実測値に近づけることも重要で、推奨は中央値±5pt 以内です。Rork が初期値で 100 を返してくることが多いですが、実際の見た目に合わせて調整すると Heap の安定度が一段上がります。
Xcode Instruments でさらに深掘りする(iOS)
Flipper での確認が完了したら、iOS の場合は Xcode Instruments の「Leaks」テンプレートでも検証してみましょう。Flipper よりも詳細なスタックトレースが取れます。
Step 1: Instruments の起動と設定
# Rork Max の場合、Xcode プロジェクトが生成されているので直接 Instruments を起動できます
# Xcode で .xcworkspace を開く
# Product → Profile(Cmd + I)→ Leaks テンプレートを選択
# 起動後、左下の「+」から「Allocations」テンプレートを追加で読み込む
Allocations を併用することで、リークだけでなく「解放されていないが参照は残っている」オブジェクトも追跡できます。これがいわゆる「滞留メモリ」で、リーク判定はされないが実害がある厄介な存在です。
Step 2: 操作プロトコル
Flipper と同じ「10 往復テスト」を Instruments でも実行します。Instruments の場合は、各往復の後に「Mark Generation」ボタン(旗のアイコン)を押すことが推奨されます。これにより、各往復で新規に確保されたメモリを世代別に追跡できます。
各 Generation で残るオブジェクト数を見て、特定のクラス名が世代をまたいで増え続けていれば、そのクラスがリーク源です。私のアプリでは UIView のサブクラスが各往復で 3〜5 個ずつ増え続けていた時期があり、原因は removeFromSuperview を呼んでいない箇所でした。
Step 3: 結果の解釈と次のアクション
Instruments は学習コストが少し高いですが、「どのオブジェクトが解放されていないか」「どこから参照されているか」がひと目でわかります。Flipper でリークの存在は確認できたが原因が特定できないときに活用してみてください。
修正後の確認方法
修正を加えたら、必ず以下の手順で改善を確認してください。
- Flipper を接続した状態でアプリを起動
- 「リーク回帰テスト」プロトコル(Force GC → 10 往復 → Force GC)を実行
- Memory タブの Heap Size 増分が 3MB 未満であることを確認
- 余裕があれば iOS では Instruments の Generations 検査も実施
- ストア提出前の最終確認として実機での 30 分連続使用テスト
戻っていれば修正成功です。まだ増え続けているようであれば、別の箇所に同様のパターンが残っている可能性があります。Rork に「すべての useEffect にクリーンアップ関数が設定されているか確認して修正してください。確認のみで未修正の箇所は理由とともに教えてください」と依頼すると、ひととおりチェックしてくれます。
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%)
- 月収: 修正前 90万円台 → 修正後 110万円台(+22%)
AdMob は「短時間で離脱するユーザー」が多いアプリの eCPM を下げる傾向があります。これはアドネットワーク側のアルゴリズムが「広告を表示してもクリックや次のセッションに繋がらない」と判断するからで、リテンション低下と eCPM 低下は連動して起きます。メモリリークの修正は、技術的な品質改善であると同時に、立派な売上改善施策です。
予防のための習慣
メモリリークは後から修正するより、最初から防ぐほうが楽です。Rork でコードを生成したあとに毎回確認するべき点をまとめました。
useEffect を見つけたら、必ず「クリーンアップ関数が必要か」を考える
- タイマー系の処理(
setInterval, setTimeout, requestAnimationFrame)には自動的にクリア / キャンセルを追加する習慣をつける
- 外部サービス(Supabase, Firebase, WebSocket, RevenueCat)の購読はすべてクリーンアップで解除する
- FlashList の
keyExtractor は必ず複合キーで一意性を担保する
- 重要な画面を変更したあとは Flipper で「10 往復 + Force GC」テストをする
- ストア Submit 前に Instruments の Generations 検査を 1 回入れる
私自身、5,000万ダウンロード分のアプリ運用の中で「メモリの面倒見を最初からきちんとやれば、後の運用工数が半分以下になる」と何度も実感しています。アプリが育ってくると「なぜか重くなってきた」という問題が必ず出てきます。その時に「診断の手順がわかっている」だけで、対処のスピードがまったく変わってきます。ぜひ今のうちに Flipper と Instruments の使い方に慣れておいてください。
メモリリークの修正に取り組む過程で、あわせてアプリ全体のパフォーマンスも見直しておくと効果的です。Rork アプリの動作が重くなったときのトラブルシューティングや、React Native のビルドエラーを解決するガイドも参考にしてみてください。
まずは自分のアプリで useEffect を全文検索し、クリーンアップ関数が返されていない箇所がないか確認してみましょう。おそらく 1〜2 箇所は見つかるはずです。同じ課題に取り組んでいる方の参考になれば幸いです。