配信元を署名付きURLに切り替えた翌週、通信量のグラフだけが一段上に持ち上がりました。画像の点数も解像度も変えていません。変えたのは URL の形だけです。
犯人はキャッシュキーでした。署名付きURLは有効期限を過ぎるたびに X-Amz-Signature と X-Amz-Date が書き換わります。ライブラリから見れば、それは昨日と同じ画像ではなく、初めて見る別の画像です。
私自身、壁紙アプリでこの構造に気づくまで、ディスクキャッシュの容量設定ばかりを疑っておりました。上限を上げてもヒット率は戻りません。当たり前で、そもそも当たっていなかったからです。
2026-09-20 追記。 公開後にこの記事を読み返し、掲載コードを Expo の API ドキュメントと突き合わせ直しました。その結果、writeToCacheAsync の呼び出し方と readFromCacheAsync の返り値の説明に誤りがあり、さらに実測のべき乗指数を取り違えていたことが分かりました。該当箇所はすべて書き直し、何をどう間違えていたかも本文に残しております。
署名が変わるたびに、同じ画像が別物になる
署名付きURLは、オブジェクトのパスに対して「この時刻まで、この鍵で、この操作を許す」という証明をクエリとして付けたものです。証明部分は有効期限に紐づくので、期限が切れれば作り直しになります。
同じ壁紙 w042.webp が、セッションごとに次のように姿を変えます。
セッション1: https://cdn.example.net/wallpapers/w042.webp?X-Amz-Expires=3600&X-Amz-Date=20260810T000000Z&X-Amz-Signature=6f1c...
セッション2: https://cdn.example.net/wallpapers/w042.webp?X-Amz-Expires=3600&X-Amz-Date=20260811T000000Z&X-Amz-Signature=a93e...
パスは同一です。しかし文字列としては別物です。ここが分岐点になります。
要素 セッションをまたいだときの挙動 キーに使えるか
ホスト名 基本は不変。CDN を切り替えると変わる 単独では不十分
パス(/wallpapers/w042.webp) 不変。オブジェクトの同一性を表す 使える
X-Amz-Date / X-Amz-Signature期限ごとに必ず変わる 使ってはいけない
変換パラメータ(?w=1080&fm=webp) 要求内容が変われば変わる 使う。別バリアントは別キー
つまりキーに含めるべきは「どのオブジェクトを、どの形で欲しいか」であって、「いつ誰が許可したか」ではありません。
URL は取り寄せの伝票で、キーは棚の番号です。 伝票は毎回刷り直されますが、棚の番号まで一緒に振り直す理由はどこにもありません。
既定のキャッシュキーはどこで決まっているか
expo-image は source.uri の文字列からディスク上の格納先を決めます。cachePolicy は保存する層(メモリ/ディスク)を選ぶ設定であって、キーの作り方を変える設定ではありません。ここを混同していると、cachePolicy="memory-disk" を指定しているのに何も効いていない状態が起こります。
// これは「保存先」を指定しているだけで、キーの安定性には一切関与しません
< Image
source = { { uri: signedUrl } } // 毎セッション文字列が変わる
cachePolicy = "memory-disk" // 保存はされるが、次回は別キーとして扱われる
style = { styles.thumb }
/>
保存はされます。ただし翌日には参照されないエントリとして残り、ディスク上限を押し上げるだけの死蔵データになります。容量が膨らむ側の症状はRork 製アプリの「書類とデータ」が数 GB に膨らむときの原因と対処 で扱っていますが、署名付きURLが絡む場合は上限調整では止まりません。原因が容量ではなくキーだからです。
いちばん短い解 — source.cacheKey を渡す
最初にお伝えしたいのは、この問題には公式のつまみが一つ用意されている、という事実です。ImageSource には cacheKey というフィールドがあり、ドキュメントには「この画像を問い合わせ・保存するときに使うキャッシュキー。指定しない場合は uri がそのままキーになる」と書かれています(Expo の Image API リファレンス )。
つまり、キャッシュ API を自分で叩く前に、まずこれで足ります。
// URL は毎回変わってよい。キーだけを固定すれば、取得も保存も同じ棚に揃います
< Image
source = { { uri: signedUrl, cacheKey: "v1:/wallpapers/w042.webp?w=1080&fm=webp" } }
cachePolicy = "memory-disk"
style = { styles.thumb }
/>
初回公開時の私は、ここを読み落としたまま読み書きの API を手で組んでおりました。遠回りではありましたが、おかげで「どこまでがライブラリの仕事で、どこからが自分の仕事か」の線引きは、はっきりしました。まず宣言で足りないかを確かめ、足りない分だけを手で書く。 キャッシュに限らず、この順序を飛ばすと必ずコードが増えます。
キーの文字列は自分で決められるので、次の関数のように正規化だけを担当させます。
// lib/imageKey.ts
/** キーに含める変換パラメータ。ここに無いクエリは同一性に影響させません */
const VARIANT_PARAMS = [ "w" , "h" , "fm" , "q" , "dpr" ] as const ;
/** キー体系のバージョン。方式を変えたい日にここだけを上げます */
const KEY_VERSION = "v1" ;
/**
* 署名付きURLから、署名に依存しない安定キーを導きます。
* 同期関数です。同じオブジェクト・同じ変換なら、署名が変わっても同じ文字列を返します。
*/
export function stableImageKey ( url : string ) : string {
const u = new URL (url);
// 変換パラメータのみを、配列の順序に固定して取り出す
const variant = VARIANT_PARAMS
. map (( p ) => {
const v = u.searchParams. get (p);
return v === null ? null : `${ p }=${ v }` ;
})
. filter (( s ) : s is string => s !== null )
. join ( "&" );
// ホスト名は含めません。CDN 移行でキャッシュ全滅を起こさないためです
const path = u.pathname. replace ( / \/ +$ / , "" ). toLowerCase ();
return variant
? `${ KEY_VERSION }:${ path }?${ variant }`
: `${ KEY_VERSION }:${ path }` ;
}
// 期待する出力(署名だけが違う2つのURL)
// stableImageKey(".../w042.webp?w=1080&X-Amz-Signature=6f1c...")
// → "v1:/wallpapers/w042.webp?w=1080" 同じ
// stableImageKey(".../w042.webp?w=1080&X-Amz-Signature=a93e...")
// → "v1:/wallpapers/w042.webp?w=1080" 同じ
// stableImageKey(".../w042.webp?w=540&X-Amz-Signature=a93e...")
// → "v1:/wallpapers/w042.webp?w=540" 変換が違うので別キー
初回版ではこの関数を expo-crypto の digestStringAsync で SHA-256 にかけて返しておりました。いまは素の文字列のまま返しています。理由は計算量ではありません。digestStringAsync は Promise を返すため、リストの行ごとに非同期の往復が一つ挟まります。同期的に決められるものを非同期にすると、描画の前に await を待つ構造が生まれ、useEffect と useState の往復まで連れてくるのです。
ハッシュ自体は安いままでした。手元の Node v22.23.2 で同じ長さの正規化文字列を SHA-256 に10万回かけたところ 132.8 ms、1回あたり 1.33 マイクロ秒です。惜しむような値ではありません。それでも、惜しくないからといって挟む理由にはならない、というのが今回の結論です。
ホスト名を意図的に外している点は、後から効いてきます。配信元を切り替えたときに全キャッシュが無効化されると、移行当日に通信量が跳ねます。オブジェクトの同一性は保管場所ではなくパスが決めるはずなので、キーからも外しておきます。
VARIANT_PARAMS を許可リストにしているのも同じ理由です。除外リストにすると、CDN 側が計測用のクエリを一つ足しただけでキーが割れます。含めるものを明示する側が安全です。
writeToCacheAsync と readFromCacheAsync の実際の型
source.cacheKey で足りない場面もあります。端末に既にあるファイル——expo-image-picker が返したものや、expo-file-system で落としたもの——を、ネットワークを経由せずにキャッシュへ流し込みたいときです。ここで使うのが書き込み側の API になります。
初回公開時、私はこの2つの API の型を取り違えておりました。ドキュメントを読み直したうえで、正しい形を並べておきます。
API 引数 返り値 間違えやすい点
writeToCacheAsync(source, cacheKey)位置引数 2 つ。source はローカルファイル URI か ImageRef Promise<void>リモートURLは渡せない。オプションの物体 { cacheKey } でもない。返り値でパスは受け取れない
readFromCacheAsync(cacheKey)キー文字列のみ Promise<ImageRef | null>ローカルパスではない。{ uri } に包まず、source へそのまま渡す
getCachePathAsync(cacheKey)キー文字列のみ Promise<string | null>パスが欲しいときはこちら。存在確認も兼ねる
書き込み側については、ドキュメントに注意書きが一つあります。ImageRef から GIF や APNG のような動く画像を書き込むと、参照が保持しているのは復号済みの画像であるため 1 フレームに潰れます。元のバイト列のまま入れたい場合は、ローカルファイル URI を渡す必要があります。
正しい形に直した読み書きが、次のコードです。
// lib/imageCache.ts
import { Image, type ImageRef } from "expo-image" ;
import * as FileSystem from "expo-file-system" ;
import { stableImageKey } from "./imageKey" ;
type Resolver = ( objectPath : string ) => Promise < string >; // 署名付きURLを発行する関数
/**
* 安定キーで先にキャッシュを引き、無ければ署名を取り直して取得・保存します。
* 返り値は ImageRef で、<Image source={ref} /> にそのまま渡せます。
*/
export async function resolveCachedImage (
objectPath : string , // 例: "/wallpapers/w042.webp"
variantQuery : string , // 例: "w=1080&fm=webp"
sign : Resolver
) : Promise < ImageRef | null > {
// キー導出は署名を必要とせず、同期で終わります
const key = stableImageKey (
`https://placeholder.invalid${ objectPath }?${ variantQuery }`
);
const cached = await Image. readFromCacheAsync (key);
if (cached) {
return cached; // 署名を一度も発行せずに返せます
}
// ここで初めて署名を発行する。発行回数そのものが減ります
const signed = await sign ( `${ objectPath }?${ variantQuery }` );
// writeToCacheAsync はローカルファイルを取ります。いったん端末へ落としてから渡します
const tmp = `${ FileSystem . cacheDirectory }${ encodeURIComponent ( key ) }` ;
const { uri : localUri } = await FileSystem. downloadAsync (signed, tmp);
await Image. writeToCacheAsync (localUri, key); // 返り値は void
return Image. readFromCacheAsync (key);
}
順序が肝心です。署名を発行してからキャッシュを引くのではなく、キャッシュを引いてから必要な場合だけ署名を発行します。署名発行が API 呼び出しやエッジ関数の実行を伴う構成では、この順序だけで呼び出し回数が目に見えて減ります。
一方で、この経路をわざわざ通す必要があるのは「既にローカルにあるものを入れたい」場面だけです。素直にネットワークから取るのであれば、前節の source.cacheKey に任せたほうが、落としてから書き戻すという往復が丸ごと消えます。
呼び出し側はこうなります。
// components/WallpaperThumb.tsx
import { Image } from "expo-image" ;
import { stableImageKey } from "../lib/imageKey" ;
export function WallpaperThumb ({
objectPath ,
signedUrl ,
} : {
objectPath : string ;
signedUrl : string | undefined ;
}) {
const cacheKey = stableImageKey (
`https://placeholder.invalid${ objectPath }?w=1080&fm=webp`
);
return (
< Image
// uri が未取得でも undefined にしない(後述の落とし穴③)
source = { { uri: signedUrl ?? "" , cacheKey } }
cachePolicy = "memory-disk"
recyclingKey = { cacheKey } // FlashList で前の行の絵が残らないように
style = { { width: 120 , height: 213 } }
transition = { 120 }
/>
);
}
recyclingKey を添えているのは、リサイクルされるリストで前の行の画像が一瞬見える現象を止めるためです。プリフェッチとスクロール性能の兼ね合いについてはRork で作ったリストのスクロールが重い — 画像キャッシュとプリフェッチの設計 で整理しています。
手元で回した比較と、指数を取り違えていた話
どれくらい戻るのかは、アクセス分布に依存します。感覚で語れないので、自分のアプリに近い条件でシミュレータを書いて回しました。
初回公開時に載せた数字は、次のとおりでした。カタログ300点、1セッションあたり60点を閲覧、20セッション、1枚あたり平均1.8MB、ディスク上限256MB、LRU で退避。署名は毎セッション再発行されます。
キーの決め方 ヒット率 20セッションの転送量合計 LRU 退避回数
URL 全体をキーにする(既定) 17.7% 1.74 GB 846
パスと変換のみを正規化したキー 59.3% 0.86 GB 346
転送量の合計で 50.6% の削減でした。退避回数が 846 から 346 へ落ちている点も見どころです。無効なエントリが枠を食い潰していたため、本来残るべき人気の壁紙まで押し出されていたことになります。ヒット率の低下は二重に効いていたわけです。
ここまでは、いま回し直しても同じ結論になりました。問題は、この数字に添えていた「人気に偏る(指数2.2のべき乗)アクセス分布」という一文のほうです。
同じ条件で指数だけを振り直したところ、上の数字が再現できたのは 指数 0.6 付近 でした。2.2 は上位の数点に7割が集中するような極端な偏りで、そこまで偏ると URL キーのままでもヒット率は 86% を超えてしまいます。私は分布のパラメータを書き写す段階で取り違えていたのです。
シード 10 本の平均を、指数ごとに並べます。
べき乗指数 URLキー ヒット率 安定キー ヒット率 URLキー 転送量 安定キー 転送量 削減率
0.6 (初回掲載値の再現)17.1% 58.7% 1.75 GB 0.87 GB 50.2%
0.8 26.2% 68.1% 1.55 GB 0.67 GB 56.7%
1.0 38.7% 78.0% 1.29 GB 0.46 GB 64.1%
1.2 52.0% 85.1% 1.01 GB 0.31 GB 69.2%
1.5 68.7% 91.1% 0.66 GB 0.19 GB 71.7%
2.0 83.5% 96.3% 0.35 GB 0.08 GB 77.8%
2.2 86.6% 97.3% 0.28 GB 0.06 GB 80.4%
この表は、初回に書いた「指数を 1.5 に緩めると差は縮み、2.5 に強めると広がる」という説明も取り消します。まず 1.5 は 0.6 より緩くありません。そして偏りを強めると、ヒット率の絶対差はむしろ縮みます(0.6 で 41.6 ポイント、2.2 で 10.7 ポイント)。
一方で、転送量の削減率は偏りを強めるほど上がります(50.2% → 80.4%)。人気の数点がキャッシュに居座り続けるようになるので、無駄な再取得が消える割合は大きくなるためです。差が縮む指標と広がる指標が同居しているのに、私はそれを一つの文にまとめて逆向きに書いていました。
指数は書き写すものではなく、自分のログから当て直すものです。 ここを怠ると、数字は正しいまま、読み方だけが間違います。
再現できるように、測定台をそのまま置いておきます。
// cache_sim.mjs — node cache_sim.mjs
// 署名付きURL環境でのキャッシュキー方式の比較(LRU・ディスク上限あり)
const CATALOG = 300 , VIEWS = 60 , SESSIONS = 20 ;
const AVG = 1.8 * 1024 * 1024 , CAP = 256 * 1024 * 1024 ;
// 再現可能な擬似乱数(mulberry32)
function rng ( seed ) {
return function () {
seed |= 0 ; seed = (seed + 0x6D2B79F5 ) | 0 ;
let t = Math. imul (seed ^ (seed >>> 15 ), 1 | seed);
t = (t + Math. imul (t ^ (t >>> 7 ), 61 | t)) ^ t;
return ((t ^ (t >>> 14 )) >>> 0 ) / 4294967296 ;
};
}
// べき乗分布の累積テーブル
function zipf ( n , s ) {
const w = []; let sum = 0 ;
for ( let i = 1 ; i <= n; i ++ ) { const x = 1 / Math. pow (i, s); w. push (x); sum += x; }
const c = []; let a = 0 ;
for ( const x of w) { a += x / sum; c. push (a); }
return c;
}
function pick ( c , r ) {
let lo = 0 , hi = c. length - 1 ;
while (lo < hi) { const m = (lo + hi) >> 1 ; if (r <= c[m]) hi = m; else lo = m + 1 ; }
return lo;
}
function run ( mode , s , seed ) {
const c = zipf ( CATALOG , s), rand = rng (seed), lru = new Map ();
let used = 0 , hit = 0 , miss = 0 , dl = 0 , ev = 0 ;
for ( let ss = 0 ; ss < SESSIONS ; ss ++ ) {
const sig = `sig${ ss }` ; // セッションごとに署名が変わる
for ( let v = 0 ; v < VIEWS ; v ++ ) {
const id = pick (c, rand ());
const size = Math. round ( AVG * ( 0.6 + 0.8 * rand ()));
const key = mode === "url"
? `/img/${ id }.webp?w=1080&${ sig }` // 既定: URL 全体
: `/img/${ id }.webp?w=1080` ; // 安定キー
if (lru. has (key)) { // ヒット時は LRU の末尾へ
hit ++ ; const b = lru. get (key); lru. delete (key); lru. set (key, b); continue ;
}
miss ++ ; dl += size; lru. set (key, size); used += size;
while (used > CAP ) { // 上限超過分を古い順に退避
const o = lru. keys (). next ().value;
used -= lru. get (o); lru. delete (o); ev ++ ;
}
}
}
return { h: hit / (hit + miss), g: dl / 1073741824 , e: ev };
}
const seeds = [ 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 , 9 , 10 ];
for ( const s of [ 0.6 , 0.8 , 1.0 , 1.2 , 1.5 , 2.0 , 2.2 ]) {
const agg = ( m ) => {
let h = 0 , g = 0 , e = 0 ;
for ( const sd of seeds) { const r = run (m, s, sd); h += r.h; g += r.g; e += r.e; }
return { h: h / seeds. length , g: g / seeds. length , e: e / seeds. length };
};
const a = agg ( "url" ), b = agg ( "stable" );
console. log (
`s=${ s } \t URL ${ ( a . h * 100 ). toFixed ( 1 ) }% ${ a . g . toFixed ( 2 ) }GB \t 安定 ${ ( b . h * 100 ). toFixed ( 1 ) }% ${ b . g . toFixed ( 2 ) }GB \t 削減 ${ (( 1 - b . g / a . g ) * 100 ). toFixed ( 1 ) }%`
);
}
この数字はあくまで模擬環境のものです。自分のアプリの閲覧ログから指数を当てはめ、CATALOG と VIEWS と CAP を書き換えて回し直すと、投資に見合うかどうかの判断がつきます。指数の当て方は、上位 N 件が全閲覧に占める割合を実ログから取り、同じ割合になる s を上の表から逆引きするのがいちばん早い方法です。
署名が失効した瞬間の再取得経路
キャッシュを外した先で、失効した署名を掴む場面が必ず来ます。プリフェッチ済みの URL を数時間後に使う、という経路が典型です。
// lib/fetchWithResign.ts
const RESIGNABLE = new Set ([ 401 , 403 ]);
/**
* 署名切れ(401/403)のときだけ一度だけ署名を取り直して再試行します。
* 404 や 5xx で再署名しても意味がないので、対象を絞ります。
*/
export async function fetchWithResign (
objectPath : string ,
variantQuery : string ,
sign : ( p : string ) => Promise < string >
) : Promise < Response > {
const target = `${ objectPath }?${ variantQuery }` ;
let res = await fetch ( await sign (target));
if ( RESIGNABLE . has (res.status)) {
res = await fetch ( await sign (target)); // 再署名は1回まで
}
return res;
}
// 期待する挙動
// 200 → そのまま返る(署名発行1回)
// 403 → 再署名して200(署名発行2回)
// 404 → 再署名せずそのまま404(無駄な発行をしない)
再試行を1回に限っているのは、鍵側の設定ミスで恒久的に 403 が返る状態に入ると、無限にリトライして課金だけが伸びるからです。403 が連続する原因はバケットのポリシー側にあることが多く、こちらの切り分けはSupabase Storage に画像を保存したのに URL が 403 になる が参考になります。
実装中に踏んだ4つの落とし穴
落とし穴①・クエリの順序によるキーの割れ。 CDN やSDKによって ?w=1080&fm=webp と ?fm=webp&w=1080 が混在します。URLSearchParams の列挙順をそのまま連結すると、同じ画像に2つのキーができます。上のコードで VARIANT_PARAMS の配列順に固定しているのはこのためです。
落とし穴②・大文字小文字と末尾スラッシュ。 パスを人手で組み立てている箇所があると、/Wallpapers/ と /wallpapers/ が混ざります。キー導出の前に正規化するか、そもそもパスを一箇所からしか生成しない構造にします。私はこの手の揺れは規約で潰す方を好みます。パス生成はカタログ層の関数一つに寄せて、コンポーネント側では文字列連結をしないと決めました。
落とし穴③・uri が undefined のときの、キーの取りこぼし。 これは署名付きURL特有の踏み方です。署名の取得を useEffect で行っていると、取得前や取得失敗時の uri は undefined になります。cacheKey は正しく渡っているのだから、ディスクにある画像は出てきてよさそうに見えます。ところが出てきません。同じ構成を最小再現に落とした報告が expo/expo の Issue #40442 にあり、uri を空文字 "" にすると読まれる、という回避策まで書かれています。オフラインの初回描画だけを無言で落とす種類の挙動なので、前掲のコンポーネントでは signedUrl ?? "" としてあります。
落とし穴④・方式変更の初回に跳ねる通信量。 旧キーで貯めたキャッシュは全部ミスになります。アプリ更新の当日にリリースノート無しでこれをやると、通信量の増加として観測されます。段階公開の 5% 枠で1日置いて、ディスク使用量と転送量の両方を見てから広げるのが安全です。キー体系の先頭に v1: を置いているのは、この切り替えを意図して起こせるようにするためでもあります。
この設計を採るかどうかの判断基準
全員がやるべき変更ではありません。判断の分かれ目は4つあります。
条件 手を入れる価値 理由
署名の有効期限がセッション長より短い 高い 同一セッション内ですらキーが割れる
同じ画像を複数セッションで再表示する 高い ヒット率の回復幅がそのまま効く
閲覧の偏りが弱い(指数 1.0 未満) 高い 既定のキーではヒット率が 4 割に届かない
画像が1回しか見られない(都度生成など) 低い キャッシュ自体が仕事をしない
公開バケットで署名が不要 不要 既定のキーで十分に安定している
source.cacheKey を渡すだけなら、追加コードは正規化関数の 20 行ほどで済みます。読み書きまで自分で握るなら、キャッシュの削除・移行・破損時の復旧まで自分の責任になる、と考えたほうがよいかもしれません。どちらを選ぶにせよ、無効化の手段をキー体系に仕込んでおくと後から助かります。方式を変えたい日に v1: を v2: にするだけで全件を作り直せるからです。
フォーマットの選択とデコード費用も同じ層の話なので、あわせて転送量が半分になったのに、体感は速くならなかった も読み合わせると、どこに手を入れるべきかの優先順位が付けやすくなります。
まとめ
まず自分のアプリで、署名付きURLを2セッション分だけログに落として並べてみてください。パス以外のどこが変わっているかが1分で分かります。そこが分かれば、キーに何を含めるべきかは自ずと決まります。
キャッシュの設計は地味で、効いているときは誰にも気づかれません。それでも通信量のグラフが静かに下がるのを見ると、少し報われた気持ちになります。今回のように自分の記事を測り直して間違いが出てくると、そのたびに肝が冷えるのですが、読んでくださる方に誤った前提を持ち帰らせるよりはよほどましなのだと思います。お読みいただきありがとうございました。