Crashlytics の画面を朝いちばんに開いたとき、見慣れないスタックトレースが一行だけ増えていました。NoClassDefFoundError: java.util.function.Supplier。手元のエミュレータでは再現せず、発生している端末を絞ると、Android 6.0.1 のユーザーにほぼ限られていました。
私は個人開発で Android アプリを長く運用しておりますが、画像ライブラリを更新した直後にこの種のクラッシュが出ると、まず「自分のコードのどこかか」と身構えてしまいます。実際には、自分のコードは一行も触っていませんでした。
最初にお伝えしたいのは、原因は新しい端末で動く環境の側にあり、直し方は設定の 1 行だった、ということです。ここでは、そこへ辿り着くまでの切り分けの順番と、エージェントに調査を頼むときに私が先に揃えるようにしている手がかりを書き残します。
症状の見分け方 — 「古い OS だけ」「起動直後ではない」「自分のクラスではない」
エラー文言だけでも、見る場所はかなり絞れます。
| 手がかり | この件での値 | 何を意味するか |
|---|---|---|
| 例外の種類 | NoClassDefFoundError | コンパイル時には存在したクラスが、実行する端末の OS に無い |
| 欠けているクラス | java.util.function.Supplier | Java 8 で入った標準 API。Android では API 24(7.0)以降の OS にしか入っていない |
| 発生する端末 | Android 6.0.1(API 23) | API 24 未満の端末だけが落ちる |
| スタックの先頭 | 依存ライブラリ側のパッケージ | 自分のコードが直接呼んだのではなく、ライブラリ内部で使われている |
この 4 行がそろうと、「ライブラリが Java 8 の API を使うようになったのに、アプリ側がそれを古い OS 向けに置き換えていない」という仮説が最有力になります。逆に、ClassNotFoundException や自分のパッケージ名が先頭に出る場合は、別の原因を疑います。
原因の切り分けは、この順番で
私は次の順で確かめています。上から順に、安いものを先にです。
- minSdk を確認する。24 未満なら、Java 8 の標準 API を OS 任せにできません
- 脱糖(desugaring)の設定が入っているか確認する。
coreLibraryDesugaringEnabledが無ければ、ここが欠けています - どの依存ライブラリが Supplier を参照しているか確認する。更新したライブラリが犯人かどうかを、バイトコードで確かめます
- 直したあと、古い OS の実機かエミュレータで再現を潰す
3 番目は、次のような小さなスクリプトで確かめられます。クラスファイルの定数プールには文字列がそのまま入っているため、jar を展開せずに grep できます。
#!/usr/bin/env bash
# 何を解決するか: 依存ライブラリの jar のうち、java.util.function.Supplier を参照しているものを洗い出す
# 使い方: ./find-supplier-users.sh path/to/jars/*.jar
set -euo pipefail
for jar in "$@"; do
# クラスファイルの中身を連結して、参照文字列の出現数を数える
hits=$(unzip -p "$jar" '*.class' 2>/dev/null | grep -a -c 'java/util/function/Supplier' || true)
if [ "${hits:-0}" -gt 0 ]; then
echo "${hits} ${jar}"
fi
done | sort -rnここで出てきた jar が、更新したライブラリと一致すれば、仮説はほぼ確定です。なぜ jar を直接見るのかというと、Gradle の依存ツリーだけでは「どのライブラリがどの API を使っているか」までは分からないからです。
直し方 — coreLibraryDesugaring を有効にする
minSdk が 24 未満のアプリでは、java.util.function のような Java 8 の標準ライブラリを、ビルド時にアプリ側へ取り込む設定があります。Groovy の build.gradle では次のとおりです。
android {
compileOptions {
// 古い OS に無い Java 8+ の標準 API を、ビルド時に取り込む
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
dependencies {
// バージョンは公式ドキュメントで現行版を確認してから決める
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5'
}Kotlin DSL の場合は isCoreLibraryDesugaringEnabled = true と書きます。この設定は、アプリのコードだけでなく、依存ライブラリのバイトコード内の参照も書き換えてくれます。だから、ライブラリ側を直せなくても、アプリ側の 1 行で穴が塞がるのです。
私の場合は、この設定を足してビルドし直すだけで、Android 6.0.1 のクラッシュが止まりました。リリースは段階公開にして、Crashlytics で同じ例外が消えていくのを数日かけて見届けております。
エージェントに調査を頼むとき、先に揃える 4 つの手がかり
ここからが、この記事で一番お伝えしたかった部分です。
この件を最初に見たとき、私はスタックトレースを丸ごとエージェントに貼り、「原因を調べて」とだけ頼みかけました。ただ、それだと、エージェントは新しい端末での挙動を前提に推測を始めてしまいます。結果は芳しくなく、依存ライブラリの変更履歴を延々と読む方向へ流れてしまいました。いま思えば、足りなかったのはエージェントの力ではなく、私の側の下ごしらえでした。
いまは、依頼の前に次の 4 つを私の側で揃えるようにしています。
- 端末の OS バージョンと API レベル。「Android 6.0.1(API 23)のみ」と明記します
- minSdk と、desugaring 設定の有無。
build.gradleの該当箇所をそのまま貼ります - 直前に更新した依存ライブラリと、更新前後のバージョン。差分が小さいほど、調べる範囲が狭まります
- 再現できた環境と、再現できなかった環境。「エミュレータの API 34 では再現せず」も立派な手がかりです
こうして渡すと、エージェントの最初の返答が「minSdk が 24 未満なのに脱糖が無い」という方向から始まるようになりました。原因を探すより、何が欠けているかの見取り図を先に渡すほうが、はるかに短く済みます。
エージェントに渡すのは、症状ではなく、欠けているものの輪郭。 この線引きは、体調の悪い日でも崩さないようにしています。
直したあとの確かめ方
直したつもりで終わらせないために、次の 2 点だけは確かめています。
- 古い OS の環境で、同じ画面遷移を一度通す。API 23 のエミュレータで十分です
- リリースビルドで確認する。デバッグビルドでは通っても、R8 が絡むリリースビルドで挙動が変わることがあります
どちらも地味な作業ですが、ここを省くと、次の更新でまた同じ朝を迎えることになります。
次にやること
お手元のアプリで、まず minSdk の値を見てみてください。24 未満であれば、coreLibraryDesugaringEnabled が入っているかを確認するところから始めていただければと思います。私も、ライブラリを更新するたびに、この 2 か所を見る習慣にしております。