2026年5月、個人開発で運営している壁紙アプリの広告メディエーションを拡張し、Liftoff・InMobi・Unity Ads の3つのアダプタを追加しました。収益面の設定は狙いどおりに動いたのですが、内部テスト配信の後になって、端末あたりのダウンロードサイズが 8MB 以上増えていたことに気づきました。機能テストは通っていて、クラッシュもない。それでも「ダウンロードが重くなった」という逆行は、どのテストにも引っかからずに通り抜けていたのです。
サイズの逆行は、ビルドが壊れるわけでも例外が飛ぶわけでもないので、意識して測らない限り検知できません。私自身、audit のスクリプトや依存更新のゲートは整備してきたのに、サイズだけは「たまに Play Console を眺める」運用のままでした。以来、サイズにも予算(budget)を持たせて、Antigravity のエージェントに週次で見張らせる仕組みに切り替えています。本稿の設計はその実装記録です。
サイズはどこで静かに膨らむのか — 3つの典型経路
私のアプリで過去1年の増分を振り返ると、原因はほぼ次の3つに集約されました。
経路 典型例 1回あたりの増分 気づきにくさ 依存の追加・更新 広告アダプタ、解析SDK、UIライブラリ 0.5〜4MB 高い(lockfile の diff に MB 数は出ない) アセットの追加 内蔵壁紙、オンボーディング動画、フォント 0.2〜10MB 中(追加した本人は把握しているが台帳がない) 縮小設定の劣化 R8/ProGuard ルールの緩和、App Thinning の設定ミス 1〜8MB 最も高い(何も「追加」していないのに増える)
厄介なのは3つ目です。クラッシュ対応で -keep ルールを広めに書いた、ビルド設定を触った、といった変更は差分だけ見るとサイズと無関係に見えます。逆行検知を「変更の種類」ベースでやろうとすると必ず漏れるので、結果の数値そのもの を毎週測るしかない、というのが私の結論です。
「AAB のファイルサイズ」を測ってはいけません
最初の実装で私が間違えたのがここです。CI の成果物である .aab のファイルサイズを記録していたのですが、この数値はユーザー体験と一致しません。Android の場合、Play Store は AAB から端末構成ごとに分割 APK を生成して配信するため、ユーザーが実際にダウンロードするサイズは AAB より大幅に小さくなります。測るべきは bundletool が出す「端末あたりのダウンロードサイズ」です。
# AAB から端末あたりのダウンロードサイズを取得する
# (事前に bundletool build-apks で .apks を生成しておく)
bundletool build-apks \
--bundle=app/build/outputs/bundle/release/app-release.aab \
--output=/tmp/app.apks \
--ks= $KEYSTORE --ks-key-alias= $ALIAS \
--ks-pass=pass: $KS_PASS --key-pass=pass: $KEY_PASS
# MIN と MAX(構成による幅)が出る。ゲートには MAX を使う
bundletool get-size total --apks=/tmp/app.apks
# 出力例:
# MIN,MAX
# 21436512,24893440
MIN ではなく MAX を予算判定に使うのは、最も条件の悪い端末構成でも予算内に収めたいからです。iOS 側は Xcode の App Thinning Size Report が同じ役割を果たします。xcodebuild -exportArchive に thinning を指定すると、App Thinning Size Report.txt に variant ごとの compressed / uncompressed サイズが出力されるので、そこから最大の compressed 値を拾います。
# iOS: App Thinning Size Report から最大ダウンロードサイズを抽出
grep "compressed" "App Thinning Size Report.txt" \
| grep -oE '[0-9.]+ MB' | sort -rn | head -1
# 出力例: 31.2 MB
どちらも「ストアの管理画面で見る」のではなくローカル/CI で再現できるコマンドにしておくことが、後述の無人ラン化の前提になります。
サイズ台帳のスキーマ — 予算・実測・例外を1つの JSON で管理する
測定値は Git 管理下の台帳(size-ledger.json)に週次で追記します。設計のポイントは、予算・実測・承認済みの例外を1つのファイルにまとめることです。分けると必ずどれかが更新されなくなります。
{
"budgets" : {
"android_download_max_mb" : 26.0 ,
"ios_thinned_max_mb" : 34.0 ,
"warn_ratio" : 0.92
},
"exceptions" : [
{
"id" : "2026-05-mediation-adapters" ,
"delta_mb" : 3.1 ,
"reason" : "Liftoff/InMobi/Unity Ads アダプタ追加(収益要件)" ,
"approved" : "2026-05-21"
}
],
"history" : [
{ "date" : "2026-06-26" , "android_mb" : 23.7 , "ios_mb" : 31.2 , "commit" : "a1b2c3d" },
{ "date" : "2026-07-03" , "android_mb" : 23.9 , "ios_mb" : 31.2 , "commit" : "e4f5a6b" }
]
}
予算値そのものは各アプリの事情で決めるものですが、私は「直近の実測 + 直近1年の増分ペースの半年ぶん」を初期値にしました。きつすぎる予算は例外申請が常態化して形骸化します。warn_ratio は予算の 92% で警告を出すためのしきい値で、block される前に「近づいている」ことを知らせる緩衝帯です。
size-gate.mjs の実装 — 測定からゲート判定まで
測定・台帳追記・判定を1つの Node スクリプトにまとめます。依存なしの素の Node で動きます。
// size-gate.mjs — ダウンロードサイズの予算ゲート
// 使い方: node size-gate.mjs --android 23.9 --ios 31.2 --commit e4f5a6b
import { readFileSync, writeFileSync } from "node:fs" ;
const args = Object. fromEntries (
process.argv. slice ( 2 ). join ( " " ). split ( "--" ). filter (Boolean)
. map ( s => s. trim (). split ( / \s + / )). map (([ k , v ]) => [k, v])
);
const ledger = JSON . parse ( readFileSync ( "size-ledger.json" , "utf8" ));
const { budgets , history } = ledger;
const android = parseFloat (args.android);
const ios = parseFloat (args.ios);
const prev = history[history. length - 1 ];
const checks = [
{ name: "android" , value: android, budget: budgets.android_download_max_mb, prev: prev?.android_mb },
{ name: "ios" , value: ios, budget: budgets.ios_thinned_max_mb, prev: prev?.ios_mb },
];
let status = "PASS" ;
for ( const c of checks) {
const delta = c.prev ? (c.value - c.prev). toFixed ( 2 ) : "n/a" ;
if (c.value > c.budget) {
status = "BLOCK" ;
console. log ( `❌ ${ c . name }: ${ c . value }MB > budget ${ c . budget }MB (Δ ${ delta }MB)` );
} else if (c.value > c.budget * budgets.warn_ratio) {
if (status === "PASS" ) status = "WARN" ;
console. log ( `⚠️ ${ c . name }: ${ c . value }MB (budget比 ${ ( c . value / c . budget * 100 ). toFixed ( 0 ) }%, Δ ${ delta }MB)` );
} else {
console. log ( `✅ ${ c . name }: ${ c . value }MB (Δ ${ delta }MB)` );
}
}
// 台帳追記は判定に関わらず行う(履歴の欠けが一番困る)
ledger.history. push ({
date: new Date (). toISOString (). slice ( 0 , 10 ),
android_mb: android, ios_mb: ios, commit: args.commit ?? "unknown" ,
});
writeFileSync ( "size-ledger.json" , JSON . stringify (ledger, null , 2 ) + " \n " );
console. log (status);
process. exit (status === "BLOCK" ? 1 : 0 );
このスクリプト自体は判定と記録しかしません。exit 1 のとき何が原因かを調べる作業を、次のセクションでエージェントに渡します。私はこの「判定と調査を別レイヤーに分ける」構成を好みます。判定側を退屈なまま保つことが、無人ランの信頼性に直結するというのが実装してみての感触です。
増分の内訳をエージェントに調査させる — 幻覚を防ぐ2つの制約
BLOCK や WARN が出たとき、増分の内訳調査は Antigravity のエージェントに任せています。bundletool の出力だけでは「どのモジュール・どのアセットが増えたか」まで分からないので、apkanalyzer(Android SDK 付属)で前回コミットのビルドと比較させます。
# ベースラインと現在の APK をサイズ比較(diff は per-file で出る)
apkanalyzer apk compare --different-only baseline.apk current.apk
エージェントへの依頼はプロジェクト直下の指示ファイルに固定し、毎回同じ制約で走らせます。ここで効いたのは次の2つの制約でした。
出典の逐語引用を必須にする : 「増分の原因は◯◯です」と述べるとき、必ず apkanalyzer 出力の該当行か git log の該当コミットを逐語で引用させます。引用できない推測は「未確認」と明示させる。これを入れる前は、もっともらしいが実際には diff に存在しないライブラリ名を挙げてくることがありました。
原因候補は3件まで、確度順 : 網羅的に列挙させると読む側の負担が判定の速さを打ち消します。上位3件に絞らせ、それぞれに「次に人間が確認すべき1コマンド」を添えさせます。
この形にしてから、BLOCK 発生から原因コミット特定までの時間は、手作業でやっていた頃のおよそ半分(体感で40分→20分程度)になりました。調査そのものより「どこから見始めるか」の初動をエージェントが埋めてくれるのが大きいです。
週次の無人ランに組み込む — warn と block の2段階
測定は週1回、金曜朝のバックグラウンドランで走らせています。頻度を毎ビルドにしなかったのは、bundletool のフルビルドが1回あたり数分かかり、日々の開発ループに入れるとコストの割に検知の価値が薄いからです。サイズの逆行は「日単位で気づく必要がある」類の問題ではなく、週単位で十分間に合います。
運用ルールはシンプルに2段階です。
WARN(予算の92%超) : 台帳に記録し、週次レビューの議題に載せるだけ。作業は止めない
BLOCK(予算超過) : 次回のストア提出を止める。例外として通すなら exceptions に理由と承認日を書いてから予算を改定する
例外を「黙って予算を上げる」のではなく台帳に残すのは、半年後の自分のためです。冒頭のメディエーションアダプタ3件の +8.4MB は、R8 ルールの整理と未使用アダプタリソースの除外で +3.1MB まで圧縮した上で、例外として台帳に残しました。この「削れるだけ削ってから例外にする」順序を守ると、予算が実態から乖離しません。
スケジュール実行の組み方自体は Antigravity CLI 移行後、スケジュール実行をどこまで任せるかの棲み分け設計 で書いた構成をそのまま流用しています。また、サイズ増の主要因になりがちな依存更新側のゲートは 依存パッケージの更新を月イチの苦行にしない — エージェントに任せる週次アップデートのリスク分類と検証ゲート と対になる設計です。依存ゲートが「入口」、サイズゲートが「出口」の関係になります。
壁紙アプリでの実測 — 導入6週間の数字
導入から6週間の実測です。壁紙アプリはアセットが重い部類なので増分の絶対値は大きめですが、傾向は一般的なアプリでも変わらないはずです。
週 Android DL (MB) iOS thinned (MB) 判定 備考 W1 23.6 31.0 PASS ベースライン確定 W2 23.7 31.2 PASS — W3 25.1 31.2 WARN 内蔵壁紙4枚追加。WebP再圧縮で W4 に解消 W4 24.0 31.2 PASS アセット再圧縮反映 W5 24.0 33.8 WARN iOS のみ増。フォント2書体の重複埋め込みが原因 W6 24.0 31.4 PASS 重複フォント除去
W5 の重複フォントは、機能面では何の症状もない純粋なサイズ逆行で、この仕組みがなければ次の提出まで確実に見逃していたと思います。逆に言うと、6週間で「見逃していたはずの逆行」が2件見つかった、というのが導入効果の実感値です。
つまずきやすい点
bundletool のバージョン差 : get-size total の CSV 形式はバージョンで微妙に変わることがあります。無人ランでは bundletool の jar をリポジトリに固定バージョンで置き、暗黙の更新を避けるのが安全です
universal APK で測ってしまう : build-apks --mode=universal で作った APK は分割前の全部入りで、ダウンロードサイズの代理指標になりません。既定モードで生成した .apks に対して get-size を使います
iOS レポートの形式変化 : App Thinning Size Report は Xcode のメジャー更新でフォーマットが変わることがあります。パースを正規表現1本に依存させず、抽出結果が空だったらゲートを FAIL ではなく「測定不能」として通知する分岐を入れておくと、Xcode 26 系への更新時に無人ランが黙って壊れる事故を防げます
CDN 圧縮との混同 : ストア配信時の転送圧縮を見込んで予算を緩めるのは避けた方がよいです。圧縮率はコンテンツ依存で読めないため、bundletool / thinning report の数値をそのまま予算と比較する方が判断が安定します
なお、内蔵アセットが密度バケットの構成で消える問題は別の性質のトラブルなので、Play Store の密度分割で、特定の端末だけ内蔵壁紙が消えた — drawable と nodpi の境界設計 に分けて書いています。
まとめ — まず現在地を台帳に1行書くところから
仕組み全体を一度に組む必要はありません。まず bundletool get-size total を手元で1回走らせて、今日の数値を size-ledger.json の history に1行書く。予算もゲートもエージェントもその後で構いません。台帳に最初の1行がある状態と何もない状態では、次に「増えたかもしれない」と思ったときの初動がまったく違います。
サイズの逆行は派手な障害ではないぶん、後回しにしやすい問題です。私自身、8MB の増分に配信後まで気づかなかった経験がなければ、いまも「たまに眺める」運用のままだったと思います。同じ回り道をせずに済む方が一人でも増えれば幸いです。