段階公開を 5% で回していたとき、レビューに「サンプルの壁紙が灰色のまま表示されない」という報告が数件だけ並びました。手元の Pixel でも、社用の中位機でも再現しません。Crashlytics にも何も出ていません。共通していたのは、報告者の端末がいずれも画面密度の低い廉価機だったことでした。クラッシュではなく、画像という静的リソースが「特定の端末群からだけ存在しないことになっている」という状態です。
原因にたどり着くまで遠回りをしました。最初はサーバー側の配信を疑い、次に難読化のリソース除去を疑い、最後に App Bundle の密度分割(density split)に行き当たりました。個人開発で壁紙アプリを長く運用してきて、密度分割の理屈は知っているつもりでしたが、「分けてはいけない画像まで分けてしまう」という落とし穴には、このとき初めて正面からぶつかりました。ここでは再現手順から修正、そして Antigravity のエージェントにどこまで任せたかまでを、判断の根拠ごと残しておきます。
なぜ「特定の端末だけ」画像が消えるのか
Android App Bundle は、1つの .aab から端末構成ごとに最適化した APK(split APK)を生成します。分割の軸は ABI(CPU)・言語・そして画面密度の3つです。端末は Play から、自分の構成に合う split だけをダウンロードします。低密度端末は低密度の split を、高密度端末は高密度の split を受け取り、不要な解像度のリソースは届きません。これがインストールサイズを小さく保つ仕組みです。
問題は、res/drawable/ に無修飾で置いた画像の扱いです。無修飾の drawable/ は内部的に mdpi 相当として扱われ、密度分割が有効だと「中密度の split」に振り分けられます。すると、その split を受け取らない密度バケットの端末では、R.drawable.sample_wallpaper の実体が手元に無い、という状態が起こり得ます。getDrawable() は端末によっては最も近い密度から代替を引いてくれますが、対象リソースがどの密度 split にも入っていない構成では取得に失敗します。私のケースでは、原寸のサンプル壁紙を drawable/ に1枚だけ置いていたことが引き金でした。
| リソースの置き場所 | 密度分割での扱い | 全端末へ届くか |
| drawable/(無修飾) | mdpi split に同梱 | ×(中密度系の端末中心) |
| drawable-xxhdpi/ 等 | 各密度 split に同梱 | ×(その密度の端末のみ) |
| drawable-nodpi/ | 密度分割の対象外(base split) | ○(全端末) |
| drawable-anydpi/(ベクター等) | 密度分割の対象外 | ○(全端末) |
まず bundletool で再現する
推測のまま直すと、別の端末で再発します。先に「どの密度 split に何が入っているか」を自分の目で確かめます。bundletool で端末仕様を指定し、その端末がダウンロードするはずの APK セットを取り出します。
# 1) リリースと同じ .aab を用意し、端末仕様(JSON)を書く
# 低密度端末を再現するため screenDensity を低めに設定する
cat > device-ldpi.json << 'JSON'
{
"supportedAbis": ["arm64-v8a"],
"supportedLocales": ["ja-JP"],
"screenDensity": 240,
"sdkVersion": 26
}
JSON
# 2) その端末が受け取る APK セットだけを生成する
bundletool build-apks \
--bundle=app-release.aab \
--output=ldpi.apks \
--device-spec=device-ldpi.json
# 3) APK セットを展開し、問題の画像が含まれているか確認する
unzip -o ldpi.apks -d ldpi_out
for apk in ldpi_out/splits/*.apk; do
echo "== $apk =="
unzip -l "$apk" | grep -i "sample_wallpaper" || echo " (含まれていません)"
done
screenDensity を 240 / 320 / 480 と変えて 3 回回すと、ある密度のときだけ sample_wallpaper がどの split にも現れない、という形で再現できます。「特定の端末でだけ消える」という曖昧な症状が、「この密度 split に入っていない」という具体的な事実に変わった瞬間に、直し方は自ずと決まりました。
分けるべき画像と、分けてはいけない画像
ここがこの問題の本質だと考えています。密度分割そのものは正しい仕組みで、アイコンやボタン背景のように「画面密度に応じて最適な解像度を出し分けたい」画像には密度別フォルダが合っています。問題は、密度で出し分ける意味がない画像まで密度の軸に乗せてしまうことです。
サンプル壁紙は原寸のラスタ画像で、端末密度がいくつであろうと同じ1枚を全員に届けたいリソースです。これを drawable/ に置くと、本来不要な密度分割の対象になり、結果として一部の端末から欠落します。宮大工だった祖父が、寸法の合わない材を無理に使わずその場所のために木取りをしていたように、画像も「その分割軸に乗せる意味があるか」で置き場所を決めるべきでした。密度で分ける意味のない画像を密度フォルダに置いたことが、そもそもの設計ミスだったのです。
修正1: drawable-nodpi へ移す
数枚の原寸画像が対象なら、いちばん副作用が小さいのは drawable-nodpi/ への移動です。nodpi は「密度に依存しない」という宣言で、密度分割の対象から外れ、全端末が受け取る base split に同梱されます。
# Before: res/drawable/sample_wallpaper.webp (mdpi split に入ってしまう)
# After : res/drawable-nodpi/sample_wallpaper.webp (全端末へ届く)
mkdir -p app/src/main/res/drawable-nodpi
git mv app/src/main/res/drawable/sample_wallpaper.webp \
app/src/main/res/drawable-nodpi/sample_wallpaper.webp
リソース ID は R.drawable.sample_wallpaper のまま変わらないため、参照側のコードは1行も触る必要がありません。フォルダ修飾子が変わってもリソース名は同じだからです。移動後にもう一度 bundletool を回し、240 / 320 / 480 のいずれの端末仕様でも当該画像が base split に含まれることを確認します。
// 取得失敗を握りつぶさず、自前ログに残しておくと段階公開で気づける
val drawable = runCatching { ContextCompat.getDrawable(context, R.drawable.sample_wallpaper) }
.onFailure { Log.w("AssetGuard", "sample_wallpaper を解決できません: density=${resources.displayMetrics.densityDpi}", it) }
.getOrNull()
// drawable が null の端末が段階公開のログに出るかどうかで、修正の効きを判定する
修正2: 該当ケースだけ密度分割を無効化する
原寸ラスタが多数あり、フォルダ移動では追いつかない場合は、密度分割そのものを無効化する手もあります。Gradle の bundle ブロックで切り替えます。
// build.gradle.kts (app)
android {
bundle {
density {
// すべての密度リソースを base split に同梱する。
// 全端末へ確実に届く代わり、ダウンロードサイズは増える。
enableSplit = false
}
}
}
ただしこれはアプリ全体に効くため、アイコン類まで全密度を全端末に配ることになり、インストールサイズが膨らみます。私は壁紙アプリでは「該当画像だけ nodpi、密度分割は有効のまま」を選びました。判断の目安を表にまとめます。
| 状況 | 推奨 | トレードオフ |
| 全端末へ届けたい原寸画像が数枚 | drawable-nodpi へ移動 | ほぼ無し(最小の変更) |
| 密度非依存の大判画像が多数 | 該当群を nodpi に集約 | base split がやや増える |
| 密度別アセットが少なく管理を単純化したい | enableSplit = false | ダウンロードサイズ増 |
| アイコン・9-patch など密度別が正しい資産 | 密度フォルダのまま維持 | 変更不要 |
Antigravity エージェントに任せた点と、握り続けた点
修正そのものは単純でも、res/ 配下に散らばったラスタ資産のどれが「密度非依存にすべき原寸画像」なのかを目視で洗い出すのは骨が折れます。ここを Antigravity のエージェントに任せました。具体的には、drawable* 配下の画像を走査し、ベクター(.xml)・9-patch(.9.png)・状態リスト以外の原寸ラスタを列挙させ、drawable-nodpi/ への移動差分を提案させました。リソース名が変わらないため参照箇所の書き換えが不要であることの確認も、エージェントに横断検索させると速く済みます。
一方で、握り続けたのは「この画像を密度非依存にしてよいか」という意味判断です。アプリアイコンやツールバーのアイコンは密度別であることが正しく、これを nodpi に寄せると逆に粗く見えます。そこでエージェントには、移動候補から密度別が必要なカテゴリ(アイコン・小サイズ UI 部品)を除外する「やってはいけないことの仕様」を先に渡しました。エージェントは機械的な走査と差分生成に強く、意味の境界を引くのは人間が担う——この線引きを最初に決めておくと、提案をそのまま信用せずに済みます。負のスペックの考え方はエージェントに禁止事項を明示する設計で詳しく扱っています。
検証と段階公開
直したら、症状が出ていた密度を含む複数の端末仕様で bundletool を回し、base split に当該画像が含まれることを機械的に確かめます。
for d in 240 320 480 560; do
sed "s/\"screenDensity\": [0-9]*/\"screenDensity\": $d/" device-ldpi.json > /tmp/dev.json
bundletool build-apks --bundle=app-release.aab --output=/tmp/out_$d.apks \
--device-spec=/tmp/dev.json >/dev/null 2>&1
unzip -o /tmp/out_$d.apks -d /tmp/o_$d >/dev/null
if unzip -l /tmp/o_$d/splits/base-master.apk | grep -qi "sample_wallpaper"; then
echo "density=$d : OK (base に同梱)"
else
echo "density=$d : NG (まだ分割されています)"
fi
done
そのうえで、いきなり 100% には上げず段階公開で確かめます。私は壁紙アプリの更新では 5% → 25% → 50% → 100% と上げ、Crash-free users を 99.7% 以上、加えて先ほどの自前ログで「画像を解決できなかった端末」がゼロであることを各段で見てから次へ進めます。クラッシュとして出ない不具合は、自分で観測点を置かないと気づけないからです。この観測点を増やす考え方は、ライブラリが古い端末でだけ落ちる coreLibraryDesugaring の盲点でも同じ姿勢で書いています。
まずは手元のリリース .aab に対して bundletool build-apks --device-spec を一度だけ回し、base-master.apk に「全端末へ届けたいはずの画像」が入っているかを覗いてみてください。入っていなければ、それが密度分割に巻き込まれている証拠です。
同じ落とし穴で時間を溶かす方が一人でも減れば嬉しいです。お読みいただきありがとうございました。