リリース直後の壁紙アプリの IPA は 16MB ほどでした。ところが季節ごとに新しい壁紙を追加していくと、半年後にはバンドルが 90MB を超えていました。App Store のセルラー回線ダウンロード上限(当時 200MB、現在はさらに緩和されていますが)に達するずっと手前で、私は別の問題に気づきました。初回起動が目に見えて遅くなり、ストアの「容量」表示を見たユーザーから「重い」というレビューが付き始めたのです。
アーティスト・クリエイターの廣川政樹です。2014年から個人で iOS / Android アプリを作り続け、壁紙・癒し・引き寄せ系を中心に累計 5,000 万ダウンロードほどの規模になりました。現在は壁紙系だけで 6 本のアプリを並行運用していますが、そのどれもが「画像を増やしたい」という欲求と「バイナリを軽く保ちたい」という制約の綱引きの上に成り立っています。この記事は、その綱引きをどう設計で解いたか、実際に動かしているコードと前後の数値で共有します。
なぜ壁紙アプリだけサイズが急に問題になるのか
一般的な業務アプリなら、画像はアイコンと数枚のイラスト程度です。ところが壁紙アプリは画像そのものが商品です。1 枚の高解像度壁紙(iPhone 15 Pro Max 相当の 1290×2796、Display P3)は、無圧縮なら 10MB を超えることも珍しくありません。これを 100 枚同梱すれば、それだけでバンドルは 1GB に近づきます。
サイズが膨らむと、具体的に三つの場所で痛みが出ます。
ひとつ目はストアのコンバージョンです。App Store の製品ページには容量が表示され、Wi-Fi がない場面ではダウンロードをためらう要因になります。私の計測では、同一アプリでバイナリを 90MB から 28MB に落としたバージョンのインストール完了率(ストア表示 → 完了)が約 14% 改善しました。表示の段階で離脱していた層が一定数いたことになります。
ふたつ目は初回起動です。同梱画像が多いと、アプリの初回展開とメモリ常駐の負荷が上がります。とくに iOS では、アセットカタログに大量の画像を入れるとビルド時間も実行時のメモリも増えます。
三つ目は更新の俊敏さです。壁紙を 5 枚足すたびに審査を通して全ユーザーに数十 MB を再ダウンロードさせるのは、運用として重すぎます。壁紙はコンテンツであって、コードのリリースとは更新のリズムが違うのです。
同梱とリモートの境界をどこに引くか
「全部リモートにすればいい」と言い切れれば楽なのですが、それでは初回起動でグリッドが真っ白になり、体験が崩れます。私は次の 2 軸で境界を引いています。
ひとつは起動体験への寄与度です。初回起動の最初の画面に映るもの(オンボーディング背景、最初のグリッドに見える数枚のサムネイル)は同梱します。ここがネットワーク待ちになると離脱に直結するからです。
もうひとつは更新頻度です。頻繁に差し替える季節コレクションや新作はリモート、ロゴやプレースホルダなど変わらないものは同梱、と分けます。
整理すると、私のアプリでは次の配分にしています。
- アプリアイコン・スプラッシュ・UI 用アイコン → 同梱(数百 KB)
- 初回グリッドに見える先頭 6〜8 枚のサムネイルのみ(フル解像度ではない)→ 同梱
- フル解像度の壁紙、追加コレクション、季節もの → すべてリモート
この方針だけで、同梱されるのはサムネイルと UI 素材に限られ、バイナリは劇的に小さくなります。実アプリでは IPA を 90MB から 28MB へ、約 69% 削減できました。
リモート画像カタログの設計
リモート配信の中心は、画像本体ではなく「目録(manifest)」です。アプリは起動後にこの JSON を取得し、どの壁紙がどの URL にあるかを知ります。バージョン番号を持たせ、差分だけ取り直せるようにしておきます。
// manifest の型。CDN 上の wallpapers/manifest.v3.json として配信する
type WallpaperManifest = {
version: number; // 整数。増えたら端末側キャッシュを再検証
updatedAt: string; // ISO8601
items: WallpaperItem[];
};
type WallpaperItem = {
id: string;
// CDN ベース URL は環境変数で差し替えられるようにする
thumb: string; // 例: "wallpapers/autumn/leaf-01_thumb.webp"
full: string; // 例: "wallpapers/autumn/leaf-01_2796.heic"
width: number;
height: number;
bytes: number; // 事前計測した転送量。プレフェッチ判断に使う
collection: string; // "autumn" など
premium: boolean;
};
取得側は、まずローカルに保存した manifest の version と比較し、上がっていたときだけ更新します。
import * as FileSystem from "expo-file-system";
const MANIFEST_URL = `${process.env.EXPO_PUBLIC_CDN_BASE}/wallpapers/manifest.v3.json`;
const CACHE = `${FileSystem.documentDirectory}manifest.json`;
async function loadManifest(): Promise<WallpaperManifest> {
// 1. まずローカルを即座に返す(オフラインでも起動できる)
let local: WallpaperManifest | null = null;
const info = await FileSystem.getInfoAsync(CACHE);
if (info.exists) {
local = JSON.parse(await FileSystem.readAsStringAsync(CACHE));
}
// 2. バックグラウンドで最新を取りに行く
try {
const res = await fetch(MANIFEST_URL, { cache: "no-cache" });
const remote: WallpaperManifest = await res.json();
if (!local || remote.version > local.version) {
await FileSystem.writeAsStringAsync(CACHE, JSON.stringify(remote));
return remote;
}
} catch {
// ネットワーク不通時はローカルで継続。ここで throw しないのが運用上の肝
}
return local ?? { version: 0, updatedAt: "", items: [] };
}
ここで大事なのは、ネットワーク取得が失敗しても起動を止めないことです。私は最初、manifest 取得を await でブロックしてしまい、地下鉄など電波の弱い場所で起動が固まるという報告を受けました。ローカルを先に返してから裏で更新する、という順序に変えたところ、その種のレビューはほぼ消えました。本番に出してから気づく典型的な落とし穴です。
初回表示を遅くしないためのプレフェッチ
リモート化の代償は「画像が出るまでの一瞬の空白」です。これを消すために、サムネイルのプレフェッチとプレースホルダを組み合わせます。同梱した先頭サムネイルで即座にグリッドを埋め、見えている範囲のフル解像度だけを優先的に取りに行きます。
最初に書いた素朴な実装はこうでした。
// Before: グリッド全件のフル解像度を一気に読み込む
function Grid({ items }: { items: WallpaperItem[] }) {
return (
<FlatList
data={items}
numColumns={2}
renderItem={({ item }) => (
<Image source={{ uri: cdn(item.full) }} style={styles.cell} />
)}
/>
);
}
これだと、画面外の数十枚まで一斉にフル解像度を取りに行き、初回のデータ転送が膨らんで結局「重いアプリ」に逆戻りします。改善版では、グリッドにはサムネイルだけを出し、フル解像度は詳細画面に入ったときに取得します。さらに expo-image のメモリ/ディスクキャッシュとプレースホルダを使い、空白を「ぼかしたサムネイル」で埋めます。
// After: グリッドは軽いサムネイル、フルは必要になってから
import { Image } from "expo-image";
function Grid({ items }: { items: WallpaperItem[] }) {
return (
<FlatList
data={items}
numColumns={2}
// 画面に近い行だけ描画負荷をかける
windowSize={5}
maxToRenderPerBatch={8}
renderItem={({ item }) => (
<Image
source={{ uri: cdn(item.thumb) }}
placeholder={{ blurhash: item.blurhash }}
contentFit="cover"
transition={180}
cachePolicy="memory-disk"
style={styles.cell}
/>
)}
/>
);
}
詳細画面に入ったときだけ、item.full を Image.prefetch で先読みしておくと、保存ボタンを押す頃にはほぼ確実にキャッシュに乗っています。私の体感では、この分離だけで初回グリッド表示の体感速度がリモート化前と遜色なくなりました。サムネイルは 1 枚あたり数十 KB なので、6〜8 枚同梱しても誤差です。
On-Demand Resources や Play Asset Delivery を使うべきか
iOS には On-Demand Resources、Android には Play Asset Delivery という、OS が用意した「後から取りに行く」仕組みがあります。バイナリを軽く保ちながら必要時にアセットを配る、という目的は同じです。
私はしばらく Android で Play Asset Delivery の install-time / fast-follow を検討しました。結論から言うと、壁紙アプリでは自前の CDN + manifest 方式を選んでいます。理由は三つあります。第一に、6 本のアプリで配信の仕組みを共通化したかったこと。OS の仕組みはプラットフォームごとに API も運用も別物で、共通の manifest を一本持つほうが保守が軽くなります。第二に、季節差し替えのたびにアセットパックを再ビルド・再審査するより、CDN に画像を置くだけで反映できるほうが圧倒的に速いこと。第三に、誰がどの壁紙を見たか・保存したかという計測を、自前配信なら自然に取れることです。
逆に、課金で守りたい大容量の限定コンテンツや、どうしてもオフライン同梱を保証したい初期パックには OS の仕組みが向きます。要は「コードのリリースと同じリズムで更新するもの」は OS の仕組み、「コンテンツのリズムで更新するもの」は CDN、という切り分けです。
フォーマットと解像度で転送量をさらに削る
リモート化しても、1 枚あたりの転送量が大きいままでは通信量とキャッシュを圧迫します。ここは地味ですが効きます。
フォーマットは、iOS 配信には HEIC、Android と Web には WebP を基本にしています。同じ見た目で JPEG より 30〜50% 軽くなります。サムネイルは品質を落としても気づかれないので、WebP で長辺 600px・品質 70 程度に固定しています。
解像度は、端末より大きな画像を配らないことが肝心です。壁紙の見栄えのために Display P3 の広色域を保ちたい一方、無闇に大きいと無駄です。私は端末の論理解像度とピクセル密度から必要サイズを決め、CDN 側で何段階かのバリアントを用意しています。
import { PixelRatio, Dimensions } from "react-native";
// 端末に必要な実ピクセル長辺を求め、用意済みバリアントから最小を選ぶ
function pickVariant(item: WallpaperItem): string {
const { height } = Dimensions.get("window");
const needed = Math.ceil(height * PixelRatio.get()); // 例: 932 * 3 = 2796
const variants = [1290, 1668, 2208, 2796, 3120]; // CDN に用意した長辺
const target = variants.find((v) => v >= needed) ?? variants[variants.length - 1];
return item.full.replace(/_\d+\.(heic|webp)$/, `_${target}.$1`);
}
実測では、フル解像度 1 枚あたりの平均転送量を 4.8MB から 1.6MB へ、ほぼ 1/3 にできました。これは保存ボタンを押してから書き出しが終わるまでの待ち時間にも効きますし、データ通信量を気にするユーザーのレビュー改善にもつながりました。ダウンサンプリングをサーバー側で済ませておくと、端末のメモリピークも下がり、古い端末でのクラッシュ率が目に見えて落ちます。
計測してみて分かった、サイズ以外への波及
サイズ削減は単独の指標ではなく、いくつかの数字に連鎖しました。私が前後で追ったのは、ストアのインストール完了率、初回起動から最初の壁紙保存までの到達率、そして広告収益の指標である eCPM とセッションあたりの広告表示回数です。
バイナリを 69% 削った版では、前述のとおりインストール完了率が約 14% 改善しました。それ以上に効いたのは、初回起動が軽くなったことで「最初のセッションで 1 枚保存する」ところまで到達するユーザーの割合が上がったことです。最初の体験が軽いとオンボーディング離脱が減り、結果として Day 1 リテンションが数ポイント改善しました。広告アプリでは滞在の質が AdMob の eCPM にも跳ね返るので、サイズという一見地味な指標が収益の末端まで効いてくる、というのが実運用で得た実感です。
逆に注意したいのは、リモート化でネットワーク依存が増える点です。電波の弱い環境での初回体験が悪化しないよう、同梱サムネイルとオフライン継続(manifest のローカルフォールバック)は必ずセットで入れてください。ここを省くと、サイズは下がったのに低評価が増える、という本末転倒が起きます。
CDN とキャッシュの運用で気をつけていること
リモート配信に切り替えると、今度は「キャッシュをどう効かせ、どう失効させるか」が運用の中心になります。画像本体はファイル名にバージョンや解像度を含めて不変にし、長期キャッシュ(Cache-Control: public, max-age=31536000, immutable)を効かせます。一度配ったファイルは中身を差し替えない、という規律を守れば、端末も CDN エッジも安心してキャッシュできます。
差し替えたいときは、ファイルを上書きするのではなく新しい名前で置き、manifest の該当エントリを書き換えます。つまり可変なのは manifest だけです。その manifest には短いキャッシュ(max-age=0 に近い値)を与え、起動のたびに再検証させます。私の 6 本では、この「画像は不変・目録は可変」という分担を徹底してから、古い画像が残るとか新作が出てこないといった問い合わせがほぼゼロになりました。
# CDN 側のキャッシュ方針(擬似設定)
# 画像本体: 名前が一意なので恒久キャッシュ
/wallpapers/*.heic Cache-Control: public, max-age=31536000, immutable
/wallpapers/*.webp Cache-Control: public, max-age=31536000, immutable
# 目録: 起動ごとに再検証
/wallpapers/manifest.v3.json Cache-Control: public, max-age=0, must-revalidate
落とし穴は、manifest のバージョンを上げ忘れたまま画像を追加してしまうことです。version が据え置きだと、起動時の比較ロジックが「更新なし」と判断してローカルを使い続け、新作が表示されません。私は manifest を書き出すスクリプトの最後で version を必ずインクリメントし、items 件数の差分をログに出すようにしています。人手で JSON を編集しないこと、これも本番で痛い目を見てから固めた運用です。
次の一歩
もし手元の壁紙アプリのバイナリが数十 MB を超えているなら、まず最初にやるべきことはひとつだけです。現在のバンドルに入っている画像を一覧にして、「初回起動の最初の画面に本当に必要か」を一枚ずつ問い直すことです。ほとんどの画像はリモートに出せます。そこを起点に manifest を一本立て、同梱はサムネイルと UI 素材だけに絞っていく——この順番で進めれば、体験を落とさずにバイナリを大きく削れます。
着手する順番として、私は次の三手を推奨します。
- 現在のバンドルに含まれる画像を棚卸しし、初回画面で必要なものとそうでないものに仕分けます。
- リモートに出せる画像を CDN へ移し、manifest を一本立てて参照を切り替えます。
- サムネイル同梱とオフライン継続を入れてから、フォーマットと解像度のバリアントを整えます。
この順で進めると、体験を落とす前に効果の大きい削減から先に取れます。
私自身、6 本のアプリでこの設計に統一してから、新しい壁紙の追加が「CDN に置くだけ」で済むようになり、審査を挟まずにコンテンツを更新できる身軽さを手に入れました。同じように画像とサイズの綱引きに悩んでいる方の参考になれば嬉しいです。お読みいただきありがとうございました。