debug では何度起動しても落ちなかったのに、release にした途端、起動の直後で止まってしまいました。壁紙アプリの Android 版での出来事です。ログには「見つからない」という趣旨の一行だけが残っていました。
コードは、debug で動いていたものと一文字も違いません。違っていたのはビルドの種類だけです。それで最初は、署名や難読化の設定を疑って、見当違いの場所を先に掘ってしまいました。
debug で動いたという事実は、release で動くことの証拠にならない場合があります。 今回の落ち方は、その典型だったと感じています。この記事は、個人開発のアプリで実際に踏んだこの症状を、Expo のプロジェクトに置き換えて、原因の見分け方と直し方、そして再発させない検査までを書き残したものです。
症状から、疑う順番を決める
release だけで落ちる原因は、大きく二つに分かれます。コードが縮められて消えた場合と、資源(画像・音・XML など)が縮められて消えた場合です。見分けるには、落ちたときのメッセージを読みます。
コードのほうは、R8 が既定になる前に取っておく計測値の記事で触れた範囲です。この記事が扱うのは、二行目の資源のほうです。
なぜ debug では起きないのか
リソース縮小は、release ビルドで、コードの縮小が有効なときだけ働きます。debug では縮小が走りませんので、使われていないように見える資源も、そのまま入っています。つまり debug の挙動は、縮小という工程を一度も通っていないのです。
縮小は、コードの中から「参照されている資源」を静的に探します。R.raw.notify_chime のように、定数として書かれた参照は見つかります。見つからないのは、実行時に名前を文字列として組み立てて引く場合です。
公式のドキュメントには、img_%1d のような書式文字列があると、img_ で始まる資源をまとめて使用中と見なす、とあります。逆に言えば、名前をサーバーの応答や設定ファイルから受け取って引く形は、コード中に手掛かりとなる文字列定数が現れませんので、守られません。
私のアプリで落ちたのも、同じ型でした。debug では入っていた素材が、release では縮小で不要と判断されて消え、起動時に「見つからない」で止まったのです。直し方は、消えてほしくない資源を保持の指定で守ることでした。
名前で引いている箇所を探す
直す前に、自分のプロジェクトのどこが名前で引いているのかを、先に全部出します。JS 側だけのプロジェクトでも、通知の音やアイコンなど、ライブラリが名前の文字列で引く資源があります。
# ネイティブ側(Kotlin / Java)で、名前から ID を引いている箇所
grep -rn "getIdentifier" android/ modules/ --include=*.kt --include=*.java 2>/dev/null
# Expo の設定で、資源の名前を文字列で渡している箇所(通知の音・アイコンなど)
grep -n -E '"(sound|icon|smallIcon)"' app.json app.config.js 2>/dev/null
見つかった行ごとに、「引く名前が、コードの中に文字列定数として現れているか」を見ます。現れていなければ、縮小の対象になりえます。ここで出た名前の一覧が、次の手順で「守る資源」になります。
縮小は止めずに、守りたい資源だけを残す
いちばん簡単なのは shrinkResources を切ることですが、アプリのサイズが元に戻りますので、私は選びませんでした。守りたい資源を、res/raw/keep.xml に書いて指名します。
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@raw/notify_chime,@drawable/thumb_*" />
ただし、Expo の managed な構成では、android/ フォルダは prebuild のたびに作り直されます。手で置いた keep.xml は、次の prebuild で消えます。そこで、設定プラグインに書き出させます。
// plugins/with-keep-resources.js
// 名前の文字列で引かれる資源を、リソース縮小の対象から外す keep.xml を prebuild のたびに書き出す
const fs = require("fs");
const path = require("path");
const { withDangerousMod } = require("expo/config-plugins");
// "@raw/notify_chime" や "@drawable/thumb_*" の形だけを受け付ける
const ENTRY = /^@[a-z]+\/[A-Za-z0-9_*]+$/;
function buildKeepXml(keep) {
const bad = keep.filter((k) => !ENTRY.test(k));
if (bad.length > 0) {
throw new Error(`with-keep-resources: 形式が違う指定があります: ${bad.join(", ")}`);
}
return [
'<?xml version="1.0" encoding="utf-8"?>',
'<resources xmlns:tools="http://schemas.android.com/tools"',
` tools:keep="${keep.join(",")}" />`,
"",
].join("\n");
}
const withKeepResources = (config, { keep = [] } = {}) =>
withDangerousMod(config, [
"android",
async (cfg) => {
if (keep.length === 0) return cfg; // 指定が空なら何も書かない
const dir = path.join(cfg.modRequest.platformProjectRoot, "app/src/main/res/raw");
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "keep.xml"), buildKeepXml(keep));
return cfg;
},
]);
module.exports = withKeepResources;
module.exports.buildKeepXml = buildKeepXml; // 手元のテスト用
形式の検査を入れたのは、書き間違いが静かに効かなくなるのを避けたかったからです。raw/x のように先頭の @ を忘れた指定は、書式のエラーにならず、ただ無視されます。落ちてから気づくより、prebuild の時点で止めるほうが穏やかです。
設定の側は、次のようにつなぎます。
{
"expo": {
"plugins": [
["expo-build-properties", { "android": { "enableShrinkResourcesInReleaseBuilds": true } }],
["./plugins/with-keep-resources", { "keep": ["@raw/notify_chime", "@drawable/thumb_*"] }]
]
}
}
リソース縮小はコードの縮小が有効なときだけ働きます。コードの縮小を有効にする項目の名前は SDK の版で違いますので、お使いの版のドキュメントでご確認ください。
ビルドの後に、守れているかを機械で確かめる
keep.xml を書いただけでは、効いているかどうかが分かりません。縮小したときに Gradle が書き出す resources.txt に、守りたい資源が「未使用」として載っていないかを、ビルドのたびに確かめます。公式のドキュメントでは、このファイルは build/outputs/mapping/release/ に置かれ、使われた資源と外された資源の詳細が入る、とされています。
#!/usr/bin/env python3
"""release ビルドが残した resources.txt を見て、守りたい資源が縮小の対象になっていないかを確かめる。
使い方:
python3 scripts/check_kept_resources.py <resources.txt> raw/notify_chime drawable/thumb_
末尾が _ の名前は前方一致です。resources.txt の書式は AGP の版で変わりますので、
「資源のパスを含み、かつ unused / removing / skipped のどれかを含む行」だけを見ます。
"""
import re
import sys
from pathlib import Path
MARKERS = ("unused", "removing", "skipped")
def pattern_for(spec: str) -> re.Pattern:
kind, _, entry = spec.partition("/")
tail = re.escape(entry) + (r"[A-Za-z0-9_]*" if entry.endswith("_") else r"(\.[A-Za-z0-9]+)?(?![A-Za-z0-9_])")
# res/raw/... も res/drawable-xxhdpi/... も拾う
return re.compile(rf"res/{re.escape(kind)}[^/\s]*/{tail}")
def find_removed(text: str, wanted: list[str]) -> list[str]:
hits = []
lines = [ln for ln in text.splitlines() if any(m in ln.lower() for m in MARKERS)]
for spec in wanted:
pat = pattern_for(spec)
for ln in lines:
if pat.search(ln):
hits.append(f"{spec}: {ln.strip()}")
break
return hits
def main(argv: list[str]) -> int:
if len(argv) < 3:
print(__doc__)
return 2
path = Path(argv[1])
if not path.is_file():
print(f"FAIL: {path} がありません。shrinkResources を有効にして release をビルドしましたか。")
return 2
hits = find_removed(path.read_text(encoding="utf-8", errors="replace"), argv[2:])
if hits:
print("FAIL: 守りたい資源が縮小の対象になっています")
for h in hits:
print(" -", h)
return 1
print(f"OK: {len(argv) - 2} 件、縮小の対象に出ていません")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
このスクリプトは、手元で次のように確かめました。「未使用で外した」という趣旨の行を含む見本のファイルに対して、守りたい資源の名前を渡すと、終了コード 1 と該当の行を返します。名前が行に現れない場合は 0 を返し、ファイルがなければ 2 を返します。前方一致の指定(drawable/thumb_)も、drawable-xxhdpi のような密度つきのフォルダ名を越えて拾えることを確かめました。
使い方は、ビルドの直後に一行足すだけです。
cd android && ./gradlew :app:assembleRelease && cd ..
python3 scripts/check_kept_resources.py \
android/app/build/outputs/mapping/release/resources.txt \
raw/notify_chime drawable/thumb_
本番の前に外しておきたい落とし穴
一つめは、検査を入れる前に keep.xml を足すと、安心して確認をやめてしまうことでした。足したという事実と、効いているという事実は別です。検査は、足した直後にこそ走らせることを、私はお勧めします。
二つめは、名前を一覧で書けない場合です。画面ごとに名前を組み立てて引く形だと、一件ずつ指名する数が増えます。そのときは thumb_* のように接頭辞で指名し、資源の側の命名を揃えておくほうが、あとで楽でした。命名の規則が、そのまま守りの範囲になります。
三つめは、対処したあとのサイズの動きです。守る資源を足した分だけ、AAB は大きくなります。その差が気になるなら、指名を増やすより、「名前で引く」箇所そのものを減らす設計に戻すほうが本筋だと、私は考えています。
実機で確かめる手順
本番に出すビルドは、検査が通っても、最後は release の実機で一度起動します。debug とは別の端末で、アプリを入れ直し、コールドスタートから、名前で引く画面まで辿ります。
- release をビルドして、検査を通します
- 実機で一度アンインストールし、release を入れ直します
- 名前で引く画面を、最初の起動から順に開きます
- 落ちたら、まず「見つからない」の名前を
resources.txt で引きます
この記事のコードは、keep.xml を書き出すプラグインの文字列生成と、検査スクリプトの判定を、手元で動かして確かめたところまでです。Gradle のビルドを通した実際の resources.txt の書式は、AGP の版によって違う可能性がありますので、お使いの環境の出力に合わせて、MARKERS の言葉を直してください。
明日、最初にやること
自分のプロジェクトで、先ほどの二つの検索を一度だけ走らせてみてください。名前の文字列で資源を引いている箇所が見つかれば、その名前を keep の一覧に移して、ビルド後の検査を一行足すところから始められます。
release で初めて起きる落ち方は、release の工程の中に検査を置くしかありません。 debug で何度確かめても届かない場所があると、一度知っておくだけで、探す時間はずいぶん短くなります。
最後までお読みいただき、ありがとうございました。