画像が主役のアプリを個人開発で何本か運用していると、ある時期から 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 がフレーム落ちを招きました。重い掃除は、ユーザーが画面を見ていない瞬間に寄せるのが安全です。