RORK LABEN
ACQUISITION — Rorkが初の買収を実施。macOSでネイティブSwiftアプリを生成するPaperlineを取得しましたFUNDING — Left Lane Capital主導の$15Mシードは、AI時代のモバイルアプリの作り方と収益化の再定義に充てられますGROWTH — Rork Maxはローンチから3日でARR $1.5Mに到達し、2週間で年間売上を倍増させたとされますENGINE — Rork MaxはClaude Code+Claude Opus 4.6駆動。Web初のSwiftビルダーとしてXcodeを置き換えますSPLIT — 通常RorkはReact Native(Expo)、Rork MaxはネイティブSwiftでAppleエコシステム全域が対象ですPRICING — 無料で開始でき、有料は月25ドル〜、Rork Maxは月200ドルですACQUISITION — Rorkが初の買収を実施。macOSでネイティブSwiftアプリを生成するPaperlineを取得しましたFUNDING — Left Lane Capital主導の$15Mシードは、AI時代のモバイルアプリの作り方と収益化の再定義に充てられますGROWTH — Rork Maxはローンチから3日でARR $1.5Mに到達し、2週間で年間売上を倍増させたとされますENGINE — Rork MaxはClaude Code+Claude Opus 4.6駆動。Web初のSwiftビルダーとしてXcodeを置き換えますSPLIT — 通常RorkはReact Native(Expo)、Rork MaxはネイティブSwiftでAppleエコシステム全域が対象ですPRICING — 無料で開始でき、有料は月25ドル〜、Rork Maxは月200ドルです
記事一覧/開発ツール
開発ツール/2026-06-25上級

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

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

Rork446expo-image5メモリ管理3キャッシュ3React Native181パフォーマンス26

プレミアム記事

画像が主役のアプリを個人開発で何本か運用していると、ある時期から 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/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

関連記事

開発ツール2026-06-19
Rork で作ったリストのスクロールが重い — 画像キャッシュとプリフェッチの設計
Rork が生成した FlatList は画像が増えるとスクロールがカクつきます。expo-image のキャッシュ、recyclingKey、プリフェッチ、FlashList への移行を実機の数値とともに整理し、滑らかさを取り戻すまでの設計を残しました。
開発ツール2026-06-12
Rork 製アプリの『書類とデータ』が数 GB に膨らむときの原因と対処 — expo-image のディスクキャッシュ管理
アプリ本体は小さいのに『書類とデータ』だけが数 GB に膨らむ——壁紙アプリの運用で実際に踏んだ expo-image のディスクキャッシュ肥大を、cachePolicy の使い分け・縮小版配信・世代別クリアの三段構えで解決した記録です。
開発ツール2026-05-25
Rork iOSアプリのメモリプレッシャー対策 — 壁紙アプリ運用で磨いた5段階解放アーキテクチャ
Rork経由でビルドしたiOSアプリのメモリプレッシャー対策を、5段階解放という考え方で整理しました。壁紙アプリ群を12年運用してきた中で見えた優先順位と、Instrumentsでの計測手順を実装込みで紹介します。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →