季節の壁紙を10枚追加するだけのために、アプリのビルドを上げ、スクリーンショットを差し替え、App Store の審査に2日待つ。リリース直後はこれで回っていました。けれど個人開発で壁紙系を6本並行で運用するようになると、この手順が完全にボトルネックになりました。1本の審査待ちのあいだに別の3本でも追加したい画像が積み上がり、結局「今週はまとめて来週」と先送りする悪循環に入っていたのです。
コンテンツの追加は、本来アプリのコードとは無関係です。画像が増えても挙動は変わりません。それならアプリ審査の外に出してしまおう、と決めて作り直したのが、ここで紹介するリモートアセットパックの仕組みです。動かしているコードと、切り替え前後で測った転送量を添えて共有します。
なぜ「バンドル同梱」をやめたのか
最初の設計では画像をアプリにバンドルしていました。オフラインでも確実に表示でき、初回起動が速いという利点があります。一方で、画像を1枚増やすたびにアプリ全体を再申請する必要があり、IPA も膨らみ続けます。半年で 90MB を超えたあたりで限界を感じました。
判断の軸は2つだけに絞りました。ひとつは更新頻度です。週に何度も差し替えるコンテンツはリモート、リリース時に固定されるUI素材は同梱。もうひとつは初回体験です。起動直後に必ず見える数枚(デフォルトの表紙やオンボーディング画像)は同梱し、それ以外はリモートから取りに行く。この線引きで、同梱アセットを20枚程度に固定できました。
マニフェスト方式 — 何を配って何を消すか
リモート配信の中心になるのは、画像そのものではなくマニフェストです。アプリは起動時にまずマニフェストを取りに行き、そこに書かれた指示にしたがって画像を取得します。スキーマはこうしています。
{
"packVersion" : 142 ,
"minAppVersion" : "3.2.0" ,
"generatedAt" : "2026-06-13T02:00:00Z" ,
"categories" : [
{
"id" : "seasonal-rainy" ,
"title" : { "ja" : "雨の季節" , "en" : "Rainy Season" },
"items" : [
{
"id" : "rainy-0142" ,
"hash" : "b7f3c1a9" ,
"w" : 1290 , "h" : 2796 ,
"url" : "https://cdn.example.com/packs/rainy-0142.heic" ,
"thumb" : "https://cdn.example.com/thumbs/rainy-0142.webp" ,
"addedIn" : 142
}
]
}
]
}
ポイントは hash と addedIn です。hash は画像の内容から生成した短い指紋で、これが変わったときだけ再ダウンロードします。addedIn はその画像が初めて登場したパックバージョンで、差分計算に使います。minAppVersion は、新しいフォーマットの画像(たとえば後から導入した動く壁紙)を古いアプリに配らないための安全弁です。
マニフェスト自体は CDN のエッジに置き、Cache-Control: max-age=300 で5分だけキャッシュします。短くしているのは、緊急で1枚消したいときに最大5分で全端末へ伝播させたいからです。画像本体は内容が変わらない前提なので max-age=31536000, immutable で1年キャッシュします。
差分ダウンロード — 転送量を 88% 削った実装
ここが運用コストに最も効いた部分です。素朴に作ると、マニフェストが更新されるたびにアプリは全カテゴリの全画像を確認しに行きます。6本×数千枚では、これだけで無視できない通信が発生します。
端末側に「最後に同期したパックバージョン」を保存しておき、サーバーには差分だけを問い合わせます。
import * as FileSystem from 'expo-file-system' ;
import AsyncStorage from '@react-native-async-storage/async-storage' ;
const MANIFEST_URL = 'https://cdn.example.com/packs/manifest.json' ;
const CACHE_DIR = `${ FileSystem . cacheDirectory }packs/` ;
type Item = { id : string ; hash : string ; url : string ; addedIn : number };
async function syncPacks () : Promise < void > {
const lastVersion = Number ( await AsyncStorage. getItem ( 'packVersion' ) ?? '0' );
const res = await fetch ( MANIFEST_URL );
const manifest = await res. json ();
if (manifest.packVersion === lastVersion) return ; // 変化なし
await FileSystem. makeDirectoryAsync ( CACHE_DIR , { intermediates: true })
. catch (() => {}); // 既存なら無視
const allItems : Item [] = manifest.categories. flatMap (( c : any ) => c.items);
// 新規 or ハッシュが変わったものだけを対象にする
const targets : Item [] = [];
for ( const item of allItems) {
const local = `${ CACHE_DIR }${ item . id }-${ item . hash }.heic` ;
const info = await FileSystem. getInfoAsync (local);
if ( ! info.exists) targets. push (item);
}
// 同時実行は4本までに制限し、低速回線でも詰まらせない
await runWithConcurrency (targets, 4 , async ( item ) => {
const dest = `${ CACHE_DIR }${ item . id }-${ item . hash }.heic` ;
await FileSystem. downloadAsync (item.url, dest);
});
await AsyncStorage. setItem ( 'packVersion' , String (manifest.packVersion));
await pruneStale (allItems); // 不要になった旧ハッシュのファイルを削除
}
ファイル名にハッシュを含めているのが要点です。画像を差し替えるとファイル名ごと変わるため、古いキャッシュと衝突せず、CDN 側の immutable キャッシュとも整合します。pruneStale は、現行マニフェストに存在しないファイルをキャッシュから消す処理で、これを忘れると端末のストレージが単調増加します。
切り替え前は、週次のコンテンツ更新で1端末あたり平均 14MB を再取得していました。差分方式にしてからは平均 1.7MB まで下がり、削減率はおよそ 88% です。CDN の転送費用も、6本合計で月あたり半分以下になりました。
壊れたパックを巻き戻す — ロールバックの実際
リモート配信の怖さは、間違ったマニフェストを配ると全端末が同時に影響を受ける点です。実際に一度、サムネイルのURLにタイプミスがあるパックを出してしまい、一覧の一部が灰色のプレースホルダーになりました。
このときに効いたのが、マニフェストのバージョン管理を「上書き」ではなく「追記」にしておいた設計です。生成したマニフェストは manifest-142.json のように番号付きで保存し、manifest.json はそれへのポインタとして別に置いています。ロールバックは、ポインタを1つ前の番号に戻すだけです。
# 配信中のポインタを1つ前のバージョンに戻す
aws s3 cp s3://packs/manifest-141.json s3://packs/manifest.json \
--content-type application/json \
--cache-control "max-age=300"
エッジキャッシュが最大5分なので、実測では30秒から3分ほどで全端末が健全なバージョンに戻ります。アプリの再申請もアップデート配信も不要です。この「ポインタを戻すだけ」という単純さが、深夜に問題が起きたときの精神的な余裕につながりました。
6本を横断して運用する場合、私はマニフェスト生成を1つのスクリプトに集約し、アプリごとに appId でフィルタした内容を出力しています。共通の画像プールから各アプリの個性に合うカテゴリを選ぶだけなので、新しい季節パックを6本に展開する作業が数分で終わるようになりました。
運用に効いた、ちいさな取り決め
しばらく回してみて、運用を楽にしたのは派手な仕組みよりも、いくつかの地味な取り決めでした。
ひとつは、マニフェストの生成を毎日決まった時刻に1回だけ走らせることです。思いついたときに手動で配ると、どのバージョンに何が入っているかを後から追えなくなります。1日1回に固定し、generatedAt と差分の一覧をログに残すようにしてから、不具合の切り分けが一気に楽になりました。
もうひとつは、画像の取得失敗を「無音で握りつぶさない」ことです。低速回線やストレージ不足でダウンロードがこける端末は一定数あります。失敗したアイテムIDを集計してダッシュボードに出すようにしておくと、特定のCDNリージョンだけ失敗率が高い、といった偏りに気づけます。私の場合、AdMob の収益ログと同じ集計基盤にこのアセット同期の成否を流し込み、1日1回まとめて確認しています。
地味ですが、こうした観測の足場があると、コンテンツ配信を止めずに改善を回し続けられます。
どこから手をつけるか
すでにバンドル同梱で運用しているなら、いきなり全部をリモート化する必要はありません。まず更新頻度の高い1カテゴリだけをマニフェスト方式に切り出し、差分ダウンロードとロールバックの導線を本番で確かめるところから始めるのをお勧めします。そこで運用の手触りを得てから、残りのカテゴリを段階的に移すのが安全です。
私自身、最初の1本で2週間ほど運用を観察してから残り5本に展開しました。コンテンツ追加がコードのリリースから切り離されると、運用のテンポが目に見えて変わります。同じように複数アプリの更新に追われている方の設計の足がかりになれば幸いです。