R2 の請求を見て、AVIF に変えれば半分になると計算した
壁紙アプリを6本、並行で運用しております。画像はアプリに同梱せず、Cloudflare R2 から配る構成です。ある月の転送量が想定を上回っていて、内訳を見ると9割以上がサムネイルと原寸画像でした。
AVIF なら見た目を保ったまま半分になる、という話は方々で見かけます。手元でも変換してみました。原寸 2,796×1,290 の壁紙 400 枚を JPEG quality 82 から AVIF quality 50 に置き換えたところ、合計 1.41 GB が 0.68 GB になりました。52% の削減です。
数字としては期待どおりでした。ところが実機に配ってギャラリーをスクロールすると、速くなった感じがしません。手元の Pixel 4a に至っては、明らかに引っかかるようになっていました。
転送量というひとつの数字だけを見て、削れば速くなると思い込んでいた。それがこのときの正体でした。
体感は「転送」と「デコード」の合計で決まる
画像が画面に出るまでには、少なくとも三つの区間があります。ネットワークから落とす時間、圧縮を解いてビットマップに戻す時間、それを描く時間です。フォーマットを変えると、最初の区間は縮み、二つ目の区間は伸びます。
そこで expo-image の onLoadStart と onLoad だけでは足りず、区間を分けて測る必要がありました。R2 のログから転送時間を、端末側からデコード完了までを取り、差分をデコード費用として扱っています。
400枚のうち代表20枚を、Wi-Fi(実測 92 Mbps)で3回ずつ計測した中央値です。
フォーマット 1枚あたり平均サイズ 転送時間 デコード時間 合計
JPEG q82 3.6 MB 312 ms 48 ms 360 ms
WebP q80 2.4 MB 208 ms 71 ms 279 ms
AVIF q50 1.7 MB 147 ms 194 ms 341 ms
AVIF は転送を 165 ms 縮めた代わりに、デコードで 146 ms を返しています。差し引き 19 ms。誤差の範囲でした。転送量の請求は確かに半分になりましたが、読者の待ち時間はほとんど動いていません。
回線が速いほど、この構図は不利になります。同じ計測を 4G 相当(実測 14 Mbps)に絞ると、AVIF の合計は 1,340 ms、JPEG は 2,105 ms で、今度は AVIF が明確に勝ちました。フォーマットの優劣は端末と回線の組み合わせで反転します 。ひとつの平均値で決めると、どちらかの読者を犠牲にすることになります。
古い端末では、デコードが二重に効く
Pixel 4a での AVIF デコードは 1 枚あたり 380 ms 前後でした。iPhone 15 Pro の 194 ms に対して約 1.9 倍です。ハードウェアデコーダを持たない世代では、AVIF は CPU で解かれます。
スクロール中は複数枚が同時に解かれるため、ここが詰まると UI スレッドまで巻き込まれます。実際、Pixel 4a では AVIF 導入後にフレーム落ちが 3.1% から 11.7% へ増えました。転送を削った結果として、体感を悪くしていたわけです。
この現象はメモリ側にも波及します。デコード後のビットマップは、どのフォーマットでも同じ大きさになります。この点は壁紙アプリの画像キャッシュが静かに膨らんでメモリで落ちる話 で扱った内容と地続きで、フォーマットを変えてもメモリ予算は1バイトも減りません。
端末の対応状況が、選べる範囲の上限を決める
「AVIF に対応している」という記述と、「AVIF が実用的な速度で解ける」という状態は別物でした。手元の実機で整理した結果が次の表です。
環境 JPEG WebP AVIF 実務上の判断
iOS 17 以降 / A15 以降 ◯ ◯ ◯(HW) AVIF を第一候補に
iOS 16 / A12〜A14 ◯ ◯ ◯(SW) WebP を推奨
iOS 15 以前 ◯ ◯ × WebP へフォールバック
Android 13 以降 / 上位機 ◯ ◯ ◯ AVIF を第一候補に
Android 12 / 廉価機 ◯ ◯ △(遅い) WebP を推奨
Android 11 以前 ◯ ◯ × WebP へフォールバック
Android 12 の行が悩ましいところでした。ImageDecoder は AVIF を受け付けるので、機能としては通ります。けれど速度が伴わないため、通るからといって配るとフレーム落ちだけが残ります。対応表を「可否」で作ると、この段差を見落とします 。私は可否ではなく、実測のデコード時間で線を引くようにしました。
HEIC も検討しました。iOS では速いのですが、Android 側のデコーダ事情と特許周りの扱いが読みにくいところがあります。App Store と Google Play の両方に同じパックを配っている以上、片方でしか解けない資産を抱える構成とは噛み合いませんでした。片方のプラットフォームでしか使えない資産を持つと、変換パイプラインが二重になります。個人開発で回す規模を考えると、そこは避けたいところでした。
「1つのURLで交渉する」か「マニフェストにURLを並べる」か
配り分けの実装には、大きく二つの道があります。同じ URL に対して Accept ヘッダで内容を交渉する方式と、マニフェストにフォーマット別の URL を書いておき、端末が選ぶ方式です。
観点 Accept ネゴシエーション マニフェストにURL列挙
CDNキャッシュ Vary で分岐しヒット率が落ちる URLごとに独立して高い
端末側の実装 ヘッダを付けるだけ 選択ロジックが必要
切り戻し Worker の1行で全体に効く マニフェスト再生成が要る
デコード速度の考慮 ヘッダに情報が乗らない 端末の実測を反映できる
デバッグ URLから配信物が特定できない URLを見れば分かる
Accept ヘッダには「AVIF を解ける」という情報しか乗りません。「解けるが遅い」を表現できないため、先ほどの Android 12 の段差をサーバー側で扱えないわけです。この一点が決め手になりました。
最終的には両方を使っています。マニフェストで基本の配り分けを行い、Worker 側は保険として残す形です。Worker には、マニフェストが古い端末に届いてしまった場合の受け皿を任せています。
// worker/image-negotiate.js — マニフェストが追いつかない端末への保険
// 原則はマニフェスト側で決める。ここは「事故らせない」ための最終防衛線。
const FALLBACK_CHAIN = [ "avif" , "webp" , "jpeg" ];
function pickFormat ( accept , clientHint ) {
// clientHint: アプリが付ける X-Decode-Tier(fast / slow / legacy)
if (clientHint === "legacy" ) return "jpeg" ;
if (clientHint === "slow" ) return accept. includes ( "image/webp" ) ? "webp" : "jpeg" ;
for ( const fmt of FALLBACK_CHAIN ) {
if (accept. includes ( `image/${ fmt }` )) return fmt;
}
return "jpeg" ;
}
export default {
async fetch ( request , env ) {
const url = new URL (request.url);
const accept = request.headers. get ( "Accept" ) || "" ;
const tier = request.headers. get ( "X-Decode-Tier" ) || "fast" ;
const fmt = pickFormat (accept, tier);
const key = url.pathname. replace ( / \. \w +$ / , "" ) + "." + fmt;
const object = await env. WALLPAPERS . get (key. slice ( 1 ));
if ( ! object) return new Response ( "Not found" , { status: 404 });
return new Response (object.body, {
headers: {
"Content-Type" : `image/${ fmt }` ,
// Vary を絞らないとキャッシュが際限なく分岐する
Vary: "Accept, X-Decode-Tier" ,
"Cache-Control" : "public, max-age=31536000, immutable" ,
},
});
} ,
} ;
Vary の指定は慎重に扱っています。ここに端末固有のヘッダを足すと、エッジのキャッシュが端末数だけ分裂します。導入初週にヒット率が 94% から 61% へ落ちたのは、User-Agent を Vary に入れていたためでした。3段階の X-Decode-Tier に置き換えて、96% まで戻っています。
端末側で「解ける」ではなく「速く解ける」を判定する
アプリ側には、自分がどの層に属するかを一度だけ判定させています。毎回ベンチマークを走らせる作りにすると、起動が遅くなって本末転倒になります。
// src/media/decodeTier.ts
import * as Device from "expo-device" ;
import { Platform } from "react-native" ;
import AsyncStorage from "@react-native-async-storage/async-storage" ;
export type DecodeTier = "fast" | "slow" | "legacy" ;
const CACHE_KEY = "media:decodeTier:v2" ;
async function probe () : Promise < DecodeTier > {
const major = parseInt ( String (Device.osVersion ?? "0" ). split ( "." )[ 0 ], 10 );
if (Platform. OS === "ios" ) {
if (major >= 17 ) return "fast" ; // A15 以降が主流。HW デコーダ前提
if (major >= 16 ) return "slow" ; // 解けるが CPU 側に寄る
return "legacy" ;
}
// Android は API level ではなく実測に近い指標で切る
if (major >= 13 && (Device.totalMemory ?? 0 ) > 5.5e9 ) return "fast" ;
if (major >= 12 ) return "slow" ;
return "legacy" ;
}
export async function getDecodeTier () : Promise < DecodeTier > {
const cached = await AsyncStorage. getItem ( CACHE_KEY );
if (cached) return cached as DecodeTier ;
const tier = await probe ();
await AsyncStorage. setItem ( CACHE_KEY , tier);
return tier;
}
export function formatFor ( tier : DecodeTier ) : "avif" | "webp" | "jpeg" {
return tier === "fast" ? "avif" : tier === "slow" ? "webp" : "jpeg" ;
}
Device.totalMemory を条件に混ぜているのは、API level だけでは廉価機と上位機を分けられなかったためです。同じ Android 13 でも、メモリ 4 GB の端末では AVIF のデコードが 2.4 倍かかりました。厳密な指標ではありませんが、6本のアプリで観測した限り、実測との食い違いは 5% 程度に収まっています。
キーに v2 を入れてある点も、あとから効きました。判定式を直したときに、古い判定がキャッシュに残り続ける問題に一度ぶつかっております。バージョンを上げれば全端末が再判定に入ります。
マニフェスト側は URL を並べるだけにする
{
"packId" : "aurora-2026-07" ,
"items" : [
{
"id" : "aurora-014" ,
"w" : 2796 , "h" : 1290 ,
"src" : {
"avif" : "https://cdn.example.com/aurora-014.avif" ,
"webp" : "https://cdn.example.com/aurora-014.webp" ,
"jpeg" : "https://cdn.example.com/aurora-014.jpg"
},
"bytes" : { "avif" : 1782341 , "webp" : 2461902 , "jpeg" : 3612884 }
}
]
}
bytes を載せているのは、通信量に敏感な設定のときに、事前に判断させるためです。ここはマニフェストの配信そのものの設計と重なる部分があり、壁紙パックをアプリ審査なしで配る話 で整理した差分配信の枠組みに、src と bytes を足しただけの形になっています。既存の器を広げるほうが、新しい仕組みを増やすより運用が静かでした。
400枚を一度に変換しない
移行はまとめてやらないほうが安全でした。全部を変換してから配ると、問題が起きたときに切り分けができません。次の順序で進めています。
サムネイルだけを変換する。 一覧で最初に効き、失敗しても原寸の保存機能は壊れません。ここで転送量の 34% が動きます。
1本のアプリで2週間流す。 クラッシュ率とフレーム落ち率を、変換前の同期間と並べます。悪化がなければ次へ進みます。
原寸画像を、新規パックからのみ AVIF にする。 既存の 400 枚には触れません。新規だけなら、問題が出たときにマニフェストを1世代戻すだけで済みます。
残る5本へ広げる。 ここで初めて、変換パイプラインを CI に組み込みます。
既存資産の再変換は、必要になるまで行わない。 転送量の大半は新しいパックに集中しており、古い資産を変換しても請求はほとんど動きませんでした。
5番目は、当初の計画では最初にやるつもりでいた工程です。実際に内訳を見たところ、直近90日の転送量の 81% が過去30日に追加したパックに向いていました。古い資産の変換は、手間に対して見返りがほとんどありません。ここに時間を使わずに済んだのは、請求の内訳をパック単位で出すようにしていたおかげでした。
広告収益の側にも、わずかながら影響がありました。一覧の初回表示が縮んだ分だけセッション開始が早まり、AdMob の app open 広告の表示機会が Day 1 で 4% ほど増えています。因果としては弱い観測ですが、画像配信の設計が収益の入口と地続きになっている点は、頭の片隅に置くようにしております。
いま6本で落ち着いている構成
対象 fast 層 slow 層 legacy 層
サムネイル(560px) AVIF q45 WebP q78 JPEG q80
原寸(新規パック) AVIF q50 WebP q80 JPEG q82
原寸(既存資産) JPEG q82 JPEG q82 JPEG q82
この構成で、月間転送量は 38% 減りました。当初の試算だった 52% には届いていません。既存資産に手を付けていないためです。その代わり、Pixel 4a のフレーム落ち率は 3.1% のまま動いておらず、どの層の読者の体感も落としていません。
38% と 52% のどちらを取るか。私は前者を選びました。請求書の数字は自分にしか見えませんが、引っかかるスクロールは読者に見えてしまいます。
次に手を付けるとすれば、X-Decode-Tier の判定を実測ベースに寄せる部分です。いまは端末世代とメモリからの推定で、初回起動時に一度だけ小さな AVIF を実際に解いて時間を測る案を検討しております。起動時間の予算をどこから借りるかが決まっておらず、しばらく手元で寝かせる予定です。
もし同じようにアセット配信のコストを見ている方がいれば、フォーマットを変える前に、まず転送とデコードを分けて測ってみていただければと思います。片方だけを見ていた頃の私は、削った分がそのまま速さになると信じておりました。実装の判断材料になれば幸いです。