AGP 8.4以降でアプリがクラッシュするようになった話
r4wxii
この記事ははてなエンジニア - Qiita Advent Calendar 2024 17日目の記事です。昨日の記事はbps_tomoyaさんによるAndroid Studio の Debug window ツールバーをカスタムするお話でした。
Android Gradle Plugin(AGP)のバージョンを今年の4月に公開された8.4.0以降に更新してreleaseビルドするとアプリでクラッシュするという不具合が発生した。releaseビルドでのみクラッシュが発生すると聞くとAndroidアプリ開発者は真っ先に圧縮・難読化・最適化を疑うはずで、
実際にAGP 8.4.0のリリースノートにはLibrary classes are shrunkとあるため、shrinkまわりの処理が変更された結果クラッシュするようになったと考えられる。
この記事ではどのような問題が発生したか、どのように解決したかを話していく。
クラッシュした直接の原因
アプリはマルチモジュールで構成されており、各モジュールで実装したプラグインをアプリモジュールで読み込むという機能が存在している。 この機能はプラグイン用のInterfaceを実装したClassをDaggerの@IntoSet
を用いてマルチバインディングする形をとっていて、DIされたプラグインに重複があれば例外をthrowする処理になっていた。
AGPを8.4.0に更新するとこのタイミングでクラッシュ、ようはプラグインが重複して読み込まれるようになってしまった。debugビルドではクラッシュしないのでプラグインの重複はなく、クラッシュするreleaseビルドでのみプラグインが重複してDIされていることになる。
なぜ重複して読み込まれるのか
releaseビルドの重複を確認するタイミングでログを出力してみると、
{class B.b=[B.b@218e2fc, B.b@7989594]} // Map<KClass<out T>, List<T>>
のように確かにプラグインが重複してDIされていた。本来であれば以下のように要素が1つのListを持つKeyがプラグインの数だけ存在する状態が期待される。
{class B.b=[B.b@218e2fc], class C.c=[C.c@7989594]} // 要素が1つのListを持つKeyがプラグインの数だけ存在する例
このままでは難読化されていて何が起きているか分からないので、定義したClassと難読化されたClassの対応をビルド時に生成されるmapping.txt
ファイルで確認する。
AGP 8.4.0更新後のmapping.txt
は以下となる。(文字列は例なので適当)
com.example.sample.app.PluginInterface -> A.a:
void invoke() -> d
com.example.sample.feature.b.BPlugin -> B.b:
void com.example.sample.feature.b.BPlugin.invoke() -> d
void com.example.sample.feature.c.CPlugin.invoke() -> d
どうやらCPlugin
の実装がBPlugin
Classにマージされているらしい? (参考)
試しにAGP 8.4.0更新前でmapping.txt
を生成してみるとこんな感じ。
com.example.sample.app.PluginInterface -> A.a:
void invoke() -> d
com.example.sample.feature.b.BPlugin -> B.b:
void com.example.sample.feature.b.BPlugin.invoke() -> d
com.example.sample.feature.c.CPlugin -> C.c:
com.example.sample.feature.c.CPlugin.invoke() -> d
更新前では確かにCPlugin
Class自体が難読化されているため、AGP 8.4.0のリリースノートにあるLibrary classes are shrunkの影響で過度に圧縮・最適化されてしまったと考えられる。
解決策
コードを保持したい場合の解決策としてkeepルールの指定がある。今回はプラグインのInterfaceを実装したClassの圧縮・最適化を防ぎたいのでProGuardルールファイルに以下の記述を追加することになる。
-keep public class * extends com.example.sample.app.PluginInterface { *; }
しかしこのkeepルールでは難読化が行われず元の名前が保持されてしまうため、ストアで公開するアプリで指定するのはできれば避けたい。そこでR8のFAQページで紹介されているAttributesを保持する最も弱いルールを適用する。
-keep,allowshrinking,allowoptimization,allowobfuscation public class * extends com.example.sample.app.PluginInterface { *; }
このようにkeepルールを指定することで難読化を有効にしたまま、圧縮・最適化を回避することができた。
どうして圧縮・最適化の対象になったのか考察
公式のCreate an Android libraryというドキュメントを読むと
By embedding a ProGuard file in your library module, you help ensure that app modules that depend on your library don't have to manually update their ProGuard files to use your library. When the Android Studio build system builds your app, it uses the directives from both the app module and the library. So there's no need to run a code shrinker on the library in a separate step.
とあり、アプリモジュールでライブラリモジュールのコードごとまとめて圧縮すればよいことが分かる。
そして今回のリリースノートには
Starting with Android Gradle Plugin 8.4, if an Android library project is minified, shrunk program classes will be published for inter-project publishing. This means that if an app depends on the shrunk version of the Android library subprojects, the APK will include shrunk Android library classes.
と書かれている。裏を返せばライブラリモジュールで圧縮されていなければ未圧縮のコードがアプリモジュールに公開されるということであり、未圧縮のライブラリモジュールのコードも含めてより効果的な圧縮が行われるように処理が変更されたのではと推測する。
実際の圧縮・最適化処理の変更までは確認していないが、プラグインの実装ClassはDaggerのバインディング以外から参照されていないために対象となる可能性は元から高い状態ではあった。
おまけ
デフォルトのProGuardルールファイルとして使われることの多いAGPが生成するproguard-android-optimize.txt
の記述が変更されていることに今回の調査中気づいた。ここで注目したいのは
-optimizations !code/simplification/arithmetic,!code/simplification/cast,!field/*,!class/merging/*
と書かれている行がAGP 8.3.0以降のproguard-android-optimize.txt
から削除されている点だ。
内容としては特定の最適化を有効化/無効化するもので、この行では最適化時のClass Mergingを無効化していることになる。つまりこの行が削除されているAGP 8.3.0以降の最適化でClass Mergingが行われるようになったために、今回のクラッシュが発生するようになったのだと考えた。
しかしながら公式の最適化に関するドキュメントを見てみると
R8 does not allow you to disable or enable discrete optimizations, or modify the behavior of an optimization. In fact, R8 ignores any ProGuard rules that attempt to modify default optimizations, such as -optimizations and -optimizationpasses. This restriction is important because, as R8 continues to improve, maintaining a standard behavior for optimizations helps the Android Studio team easily troubleshoot and resolve any issues that you might encounter.
とあり、R8で最適化を行っている場合-optimizations
は無視されるため、個別のProGuardルールファイルに記述してもClass Mergingを抑制することはできない。
おそらくR8がデフォルトとなってから数年経っていて無意味な記述をこのタイミングで消しただけなのだろう。そもそもAGP 8.3系で今回のクラッシュは発生していないので直接的な関係は皆無と思われる。
明日のはてなエンジニア - Qiita Advent Calendar 2024記事を担当するのはergofriendさんです。