◉RORK LABEN
●EXPO — EAS Observe がネイティブクラッシュも記録(10/07)。SDK 57 は 57.0.21 以降で確認できます●SDK 58 — SDK 58 Beta は 9/15 公開。stable の日付はまだ未確認です●RN 0.88 — React Native 0.88.x の正式リリース予定は 10/12、残り3日●Q&A — expo-widgets が本番ビルドだけ真っ黒になる、という問いが出ています●RORK — 09-29 に GPT-6.1 Sol を追加。Pro・Max プランで利用できます●NEW — クライアントのアプリを作る前に決める、公開アカウントの持ち主●EXPO — EAS Observe がネイティブクラッシュも記録(10/07)。SDK 57 は 57.0.21 以降で確認できます●SDK 58 — SDK 58 Beta は 9/15 公開。stable の日付はまだ未確認です●RN 0.88 — React Native 0.88.x の正式リリース予定は 10/12、残り3日●Q&A — expo-widgets が本番ビルドだけ真っ黒になる、という問いが出ています●RORK — 09-29 に GPT-6.1 Sol を追加。Pro・Max プランで利用できます●NEW — クライアントのアプリを作る前に決める、公開アカウントの持ち主
記事一覧/開発ツール
⬡ 開発ツール/2026-06-25上級

壁紙アプリの画像キャッシュが静かに膨らんでメモリで落ちる — Rork運用で効いた計測と上限設計の運用メモ

画像が主役のRorkアプリで、ディスクキャッシュとメモリ常駐が少しずつ膨らみ、OOMクラッシュとして表面化する問題への対処。実測フック・キャッシュ上限・配信側リサイズまで、運用で効いた順に実装コード付きで整理します。

Rork577expo-image7メモリ管理3キャッシュ3React Native238パフォーマンス33

✦ プレミアム記事

画像が主役のアプリを個人開発で何本か運用していると、ある時期から Crashlytics に同じ顔ぶれのクラッシュが並び始めることがあります。再現手順は「アプリを開いて、ギャラリーを長くスクロールして、何枚か拡大表示する」だけ。例外メッセージは判で押したように Out of memory で、特定の画面に紐づくスタックトレースが出ないため、最初は原因が掴めませんでした。

私自身、壁紙系のアプリでこれにぶつかりました。コードは正しく動いているのに、長く使うほど落ちやすくなる。犯人は「少しずつ膨らむ画像キャッシュ」でした。expo-image は賢くキャッシュしてくれますが、上限を自分で設計しなければ、ディスクとメモリの両方が静かに伸びていきます。その膨張をどう計測し、どこに上限を置き、配信側で何を減らしたか。私が運用で効いた順に、実装コードとあわせて書き残します。

まず「膨らんでいる」ことを数字で確認する

メモリ問題で一番やってはいけないのは、勘で clearMemoryCache() を撒くことです。どこがどれだけ食っているのか分からないまま消すと、キャッシュヒット率まで一緒に下げてしまい、今度は読み込みが遅くなって別の離脱を生みます。最初にやるべきは計測です。

expo-image はディスクキャッシュのサイズを直接は返してくれないので、expo-file-system でキャッシュディレクトリを実測しつつ、画像の読み込み結果を onLoad で拾って指標化します。

// 画像の読み込み実績とディスクキャッシュ量を計測するフック
import { useCallback, useRef, useState } from 'react';
import * as FileSystem from 'expo-file-system';
 
type LoadMetrics = {
  loadCount: number;
  cacheHits: number;
  cacheHitRate: number;     // %
  avgLoadMs: number;
  diskCacheMB: number;
};
 
export const useImageMetrics = () => {
  const startTimes = useRef<Record<string, number>>({});
  const totals = useRef({ count: 0, ms: 0, hits: 0 });
  const [metrics, setMetrics] = useState<LoadMetrics>({
    loadCount: 0, cacheHits: 0, cacheHitRate: 0,
    avgLoadMs: 0, diskCacheMB: 0,
  });
 
  const markStart = useCallback((key: string) => {
    startTimes.current[key] = Date.now();
  }, []);
 
  // expo-image の onLoad は event.cacheType を返す('memory' | 'disk' | 'none')
  const markLoad = useCallback((key: string, cacheType?: string) => {
    const started = startTimes.current[key] ?? Date.now();
    const ms = Date.now() - started;
    const t = totals.current;
    t.count += 1; t.ms += ms;
    if (cacheType === 'memory' || cacheType === 'disk') t.hits += 1;
    setMetrics((prev) => ({
      ...prev,
      loadCount: t.count,
      cacheHits: t.hits,
      cacheHitRate: Math.round((t.hits / t.count) * 100),
      avgLoadMs: Math.round(t.ms / t.count),
    }));
  }, []);
 
  const refreshDiskSize = useCallback(async () => {
    const dir = (FileSystem.cacheDirectory ?? '') + 'expo-image/';
    const info = await FileSystem.getInfoAsync(dir, { size: true });
    const mb = (info.exists ? (info.size ?? 0) : 0) / (1024 * 1024);
    setMetrics((prev) => ({ ...prev, diskCacheMB: Math.round(mb * 10) / 10 }));
  }, []);
 
  return { metrics, markStart, markLoad, refreshDiskSize };
};
 
// 実測の一例(私の壁紙アプリ・iPhone 12 / 1,200枚スクロール後):
// { loadCount: 1200, cacheHits: 1044, cacheHitRate: 87,
//   avgLoadMs: 64, diskCacheMB: 612.4 }

この計測を入れて最初に見えたのは、cacheHitRate は 87% と良好なのに diskCacheMB が 600MB を超えていたことでした。つまりキャッシュは効いている、効きすぎて溜まり続けている、という状態です。問題はヒット率ではなく上限の不在だと、ここで初めて確信できました。

ディスクキャッシュに上限を置き、古い順に削る

expo-image の clearDiskCache() は全消しなので、運用には粗すぎます。欲しいのは「上限を超えたぶんだけ、古いものから消す」挙動です。これは expo-file-system でキャッシュファイルの更新時刻とサイズを集め、合計が閾値を超えたら古い順に削除する、LRU 風のエビクションで実装できます。

// ディスクキャッシュを上限 200MB に保つ手動エビクション
import * as FileSystem from 'expo-file-system';
 
const CACHE_DIR = (FileSystem.cacheDirectory ?? '') + 'images/';
const MAX_BYTES = 200 * 1024 * 1024; // 200MB
 
export const evictDiskCache = async () => {
  const dir = await FileSystem.getInfoAsync(CACHE_DIR);
  if (!dir.exists) return { freedMB: 0, keptMB: 0 };
 
  const names = await FileSystem.readDirectoryAsync(CACHE_DIR);
  const entries = await Promise.all(
    names.map(async (name) => {
      const info = await FileSystem.getInfoAsync(CACHE_DIR + name, { size: true });
      return {
        uri: CACHE_DIR + name,
        size: info.exists ? (info.size ?? 0) : 0,
        // modificationTime は秒。新しいものほど大きい
        mtime: info.exists ? (info.modificationTime ?? 0) : 0,
      };
    })
  );
 
  const total = entries.reduce((sum, e) => sum + e.size, 0);
  if (total <= MAX_BYTES) return { freedMB: 0, keptMB: Math.round(total / 1048576) };
 
  // 古い順(mtime 昇順)に削って閾値の 80% まで落とす
  entries.sort((a, b) => a.mtime - b.mtime);
  let running = total;
  let freed = 0;
  const target = MAX_BYTES * 0.8;
  for (const e of entries) {
    if (running <= target) break;
    await FileSystem.deleteAsync(e.uri, { idempotent: true });
    running -= e.size; freed += e.size;
  }
  return { freedMB: Math.round(freed / 1048576), keptMB: Math.round(running / 1048576) };
};
 
// 呼び出しタイミング: アプリ起動時とバックグラウンド復帰時に一度ずつ。
// 出力例: { freedMB: 430, keptMB: 160 }

ここで効いた工夫が二つあります。ひとつは閾値ちょうどではなく 80% まで落とすこと。境界ぴったりまで削ると、次に数枚読み込んだだけで再び上限を超え、エビクションが毎回走ってしまいます。少し余白を残すと削除の頻度が下がります。もうひとつは、削除を起動時とバックグラウンド復帰時に限定したことです。スクロール中に走らせると、その間のディスク I/O がフレーム落ちを招きました。重い掃除は、ユーザーが画面を見ていない瞬間に寄せるのが安全です。

✦

ここまでお読みいただきありがとうございます。

この記事の続きを読む

この先には、実装コードやベンチマーク結果など、実務でお役に立てる内容をご用意しています。このサイトは広告を掲載しておらず、サーバーや開発にかかる費用はメンバーの皆様のご支援で成り立っています。もしお役に立てていましたら、ご支援いただけますと大変ありがたいです。

この記事で得られること
✦expo-image のキャッシュサイズと画像メモリを実測する onLoad 計測フック(cacheHitRate と平均読み込み時間を取得)
✦ディスクキャッシュに 200MB の上限を設け、LRU 風に古い画像から削除する手動エビクションの実装
✦Crashlytics の OOM を減らした recyclingKey・cachePolicy・プリフェッチ抑制の使い分けと、p95 メモリの目標値
Stripe による安全な決済 · いつでもキャンセル可能
✦

この記事を購入する

この先の内容をすべてお読みいただけます。一度のご購入で、いつでも何度でもアクセスできます。このサイトは広告を掲載しておらず、皆さまのご支援がサーバー費用などの運営を支えています。

または
メンバーシップなら全記事が読み放題 →
シェア

お読みいただきありがとうございます

Rork Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • ✦コピー&ペーストで使える実装コード付き
  • ✦毎日新しい上級ガイドを追加
  • ✦¥580/月 または ¥2,480 の永久アクセス
メンバーシップを見る →

関連記事

⬡ 開発ツール2026-08-10
署名付きURLに変えたらキャッシュが効かなくなった — expo-image のキーを URL から切り離す
署名付きURLは毎回クエリが変わるため、URL をそのままキーにしている限りキャッシュは当たりません。expo-image の cacheKey でキーを自分で握り、転送量を半分に落とすまでの設計と、指数を振り直した再測定の記録です。
⬡ 開発ツール2026-07-24
同じ通信が同時に何本も飛ぶのを1本に束ねる — 参照カウント付きシングルフライト層の設計
起動直後に同じ API へ何本ものリクエストが同時に飛ぶ多重フェッチを、進行中の Promise を共有して1本に束ねる設計です。束ねる範囲を決めるキーの組み立て方、失敗した Promise を配り続けてしまう罠、呼び出し側の中断が全体を巻き込む罠、そして束ね率を数えて回帰を止めるテストまで、参照カウント付きシングルフライト層の実装を実測の通信ログとともに残します。
⬡ 開発ツール2026-05-02
FlatList でカクつき始めたら読む — Rork アプリを FlashList v2 で快適にする実装手順
Rork で生成したアプリの長いリストが重くなったとき、FlashList v2 へ移行することでスクロールが大幅に滑らかになります。estimatedItemSize が不要になった v2 の思想を踏まえた、現実的な移行手順を解説します。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます