Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails以外の開発一般

AGP8とJDK17に対応した際の調査結果まとめ

最近は本業の作業時間に余裕がある(素晴らしいですね)というのもありますが、仕事で調査した結果がそのまま記事化出来るような内容であることが多く、投稿頻度が上がり気味になっています。

今回は Android プロジェクトの AGP8 系対応を行った際の話となります。

対応を行う過程で色々な問題や疑問点が出て調査を行ったので、そのまとめが主な内容です。
今回は AGP 7.4.2 から 8.2.2 に上げました。

この記事を書いている 2024/02 現在、特に JDK17 対応周りが公式ドキュメントの記載不足もあり確認に手間取ったのでその辺りを重点的に記載します。

AGP の Upgrade Assistant やエラーメッセージを検索して簡単に解決出来た問題については本記事ではあまり触れません。

🔗API level 34 対応

2024/02 現在、そろそろ API level 34 に対応したいわけですが、直近のツール類のリリース状況を見ると以下のような記載があります。

  • AGP 8.0.0
    • JDKの最小バージョンが17に更新された
  • Android API レベルをサポートするツールの最小バージョン
    • API level 34 をサポート(compileSdkに指定)する場合の Android Studio 最小バージョンは Hedgehog | 2023.1.1 で AGP の最小バージョンは 8.1.1 となる
    • 最小バージョン未満のツールでもビルドは可能な模様だが、「compileSdk で必要とされるバージョンよりも低いバージョンの Android Studio または AGP を使用すると、予期しない問題が発生する可能性があります」との記載がある

現状関わっているプロジェクトでは API level 34 で AGP 7.4.2 を利用しても特に問題は起きていませんが、API level 34 をサポートするなら AGP 8.1.1 以上の利用が推奨であり、そして 8.0.0 以上にするならJDK17にする必要がある、という状況になります。

ちなみに AGP 7.4.2 のままビルドすると以下のような警告が出ます。

We recommend using a newer Android Gradle plugin to use compileSdk = 34
This Android Gradle plugin (7.4.2) was tested up to compileSdk = 33

🔗JDK17対応

ひとまず AGP8 系に更新してビルドしようとしたところ、以下のようなエラーでビルドに失敗しました。
この時の開発環境は Android Studio Hedgehog | 2023.1.1 Patch 2 & Kotlin 1.8.10 です。

'compileDebugJavaWithJavac' task (current target is 1.8) and 'kaptGenerateStubsDebugKotlin' task (current target is 17) jvm target compatibility should be set to the same Java version.

このエラーに関してはkapt が jvmTarget を参照しなくなったという記事に詳細が載っていました。
kapt を利用していないプロジェクトであればこのエラーは発生しないはずです。

今回の実験で利用していたプロジェクトに関しては、以下のどちらかの対応でビルドが通るようになりました。

  • Kotlin のバージョンを 1.9 以上に更新 (今回は 1.9.10 まで上げた)
  • app モジュールの build.gradle.kts に kotlin { jvmToolchain(17) } の設定を追記し、既存の kotlinOptions { jvmTarget = "1.8" } などの設定を削除
    • Gradle Java toolchains support に記載の警告の通り、jvmToolchain を利用する場合、AGP 8.1.0 以上を利用する必要があります (targetCompatibility なども一緒に記載すれば 8.1.0 未満でも動く)

前者の方法では Kotlin 以外の変更は特にありませんでしたが、後者の解決方法では jvmToolchain(17) という記載が出てきました。

この 17 はJDK17を利用するということなのですが、今まで compileOptionskotlinOptions には主に 1.8 (JDK8) 以下を指定していたかと思います。

jvmToolchain で指定した値はこの kotlinOptions の jvmTarget などにも適用されてしまうわけで、いきなりこんなに上げちゃって大丈夫?と不安になったのですが、公式ドキュメントに分かりやすい説明があまり載っていませんでした。

🔗Android ビルドの Java バージョン

今回の調査を開始してから気付いたのですが、いつの間にかAndroid ビルドの Java バージョンという公式の説明ページが出来ていました。

このページには以下のような説明があります。

  • Android Studio を実行するJDK
  • Gradle ビルドを実行するJDK
  • Java または Kotlin のソースコードで使用可能な Java API
  • Java ソースコードをコンパイルするJDK
  • Java ソースコードで使用可能な Java 言語ソース機能
  • Kotlin または Java ソースをコンパイルするときに使用できる Java バイナリ機能

似たような話がいっぱい出てきて本当にこんがらがります。


Android ビルドの Java バージョンより

最初に載っているこの図を見ると、なんとなく雰囲気が掴めますが詳細を理解するのは難しいです。

正直なところ Android Studio やら Gradle ビルドが利用するJDKとかはどうでもよく(とまでは言わないが重要ではなく)、一番気になるところは以下の点です。

  • ソースコードで利用できるJava言語機能
  • 対応する minSdk のバージョン

かなり昔まで時代を遡ると minSdk を一定以上にしないと Java7 や Java8 言語機能が使えなかったのですが、最近ではビルド時の desugar (脱糖) という仕組みで minSdk を更新せずに新しい言語機能を利用出来るようになっています。

🔗desugar 周りの話

2024/02 現在、公式ページには以下の説明があります。

また、Android のリリース情報には以下の説明があります。

上記の説明にも記述が含まれていますが、Android 10 以降から OS の機能をモジュール化してモジュール単位の更新を可能にする Project Mainline が始まり、利用可能なモジュールを参照すると ART のモジュール化は Android 12 から導入されています。

そのため、Android 14 の OpenJDK 17 の更新に伴う Java17 言語サポートは ART14 に更新した Android 12 端末にも適用されます。

ここまでで Android 12 以降であれば Java17 も問題なさそうなことが分かりましたが、Android 11 以下の話が不明瞭です。
desugar に関する Java17 関連の記述は見当たりません。

多少関係ありそうなのは「Android ビルドの Java バージョン」ページ内の以下の文言辺りです。

Java または Kotlin のソースコードではどの Java API を使用できますか?

使用している compileSdk で利用可能な Java API が、指定された minSdk では利用できない場合、脱糖と呼ばれるプロセスにより、以前のバージョンの Android で API を使用できる場合があります。サポートされている API については、脱糖で使用可能な Java 11+ API をご覧ください。

Kotlin または Java ソースをコンパイルするときに使用できる Java バイナリ機能はどれですか?

targetCompatibility と jvmTarget の値を増やすと、追加の Java 機能を利用できますが、この機能を確実に利用するには Android SDK の最小バージョンの引き上げも必要になる場合があります。

「脱糖と呼ばれるプロセスにより ~ 使用できる場合があります」やら「確実に利用するには Android SDK の最小バージョンの引き上げも必要になる場合があります」とかなり曖昧な記述になっています。
前者のリンク先も Java11 のページで Java17 ではありません。

しかし、このページ内の記述例では jvmToolchain の設定は 17 が指定されています。よく分かりませんね・・・?

🔗実際のビルド結果について

仕方がないので実際にいろんな設定でビルドを試してみることにしました。

ここでは Kotlin 1.9.10 と AGP 8.2.2 を利用しています。
この環境であれば kapt を利用していても、新しい kotlin { jvmToolchain(17) } 、既存の kotlinOptions { jvmTarget = "1.8" } どちらの設定でもビルドが通ります。

また、minSdk は 21 で appモジュールがlibraryモジュールに依存しているプロジェクトを想定しています。

appモジュール libraryモジュール APKに含まれる
classファイルの形式
libraryのAARに含まれる
classファイルの形式
jvmTarget = "1.8" jvmTarget = "1.8" Java6 Java8
jvmToolchain(17) jvmTarget = "1.8" Java6 Java8
jvmToolchain(17) jvmToolchain(17) Java6 Java17
jvmTarget = "1.8" jvmToolchain(17) ビルドエラー Java17

この表は ./gradlew app:assemble./gradlew library:assemble コマンドで生成されたAPKやAARを解凍してclassファイルを取り出し、javap -vコマンドで major version を確認した結果となります。

jvmToolchain(17) を指定しても最終的なアプリ(APK)には desugar により Java6 バイナリが含まれていることが分かります。
この結果を見れば Android 11 以下でも問題ないであろうことが分かりますね。

ちなみに minSdk を 24 にすると Java8 バイナリが含まれるAPKが生成されますが、34 にしても Java8 のままでした。
この辺りは今後のAGPのバージョンアップで変更される可能性がありそうでしょうか?

その他で注意するポイントがあるとするとlibraryをAARとしてよそに提供する場合です。

表ではlibraryを利用する側のプロジェクトの方が低いバージョンを指定しているとビルドエラーになっていますが、libs ディレクトリなどに配置した AAR ファイルを取り込む場合は状況が異なりました。

試しに jvmToolchain(17) でビルドしたAARを jvmTarget = "1.8" のプロジェクトに取り込んだところ、ビルドに成功して Java6 バイナリが含まれるAPKが生成されました。
desugar の詳細動作は把握出来ていないのですが、少なくとも AGP 7.4.2 ~ 8.2.2 に関してはこの挙動になるようです。

ただし、上記はJava17的な要素を使っていない場合のみであり、libraryにJavaのレコードクラスを作ってみたところ以下のエラーが出るようになりました。

Invalid build configuration. Attempt to create a global synthetic for 'Record desugaring' without a global-synthetics consumer.

このAARも利用側のプロジェクトを jvmToolchain(17) に設定すればビルドに成功し、Java6 バイナリが含まれるAPKが生成されます。

利用側の環境次第でエラーが発生する可能性を考えると、念のため AGP8 以上と JDK17 の利用が一般的になるまではライブラリのバージョンは 1.8 以下のままにした方が良さそうです。

🔗Gradle タスクエラー

AGP7 対応時も苦労した覚えがあるのですが、AGP8 への更新でまたもや既存の自作 Gradle タスクが動かなくなりました。
しかも無くなったり非推奨になったりしたAPIの代替APIは存在しなかったりします。

Gradle のメジャーバージョン更新が多いのはそろそろどうにかして欲しいところです。

今回該当プロジェクトで問題が起きたのは tasks.whenTaskAdded を利用していた箇所です。

Old vs New API overview を見ると tasks.configureEach に置き換えが出来るかと思ったのですが、 whenTaskAdded の中で tasks.create していた部分がエラーになってしまいました。
configureEach のタイミングではタスクの作成は出来なくなっているようです。

具体的に何をしていたかと言うと、出力されるAPKやAABファイルの名称変更です。

既存の Gradle 設定や Android の buildTypesproductFlavors 辺りの設定でもある程度は変更出来ますが、以下のような細かい設定は出来ません。

  • versionName の後ろに .<ビルド番号> をデバッグ版のみ追記したい
    • ただし、APKファイル名などは debug/release 問わずビルド番号も付与
  • productFlavor、buildType などをAPKファイル名などに対していい感じに名称に設定

例えば AppName-1.0.0.0-dev-debug.apk のような名称で出力を行いたいのです。

🔗AGP 7.4.2 時点での記述

随分昔は android.applicationVariants.all 内で outputFileName を直接書き換えられたのですが、AGP 7.4.2 の時点では使用できなくなっており、仕方なく build ディレクトリに出力されたAPKなどを別のディレクトリにリネームしつつコピーする形で対応していました。

tasks.whenTaskAdded {
    // Project.android から flavorDimensionList、productFlavors、buildTypes にアクセス出来るので this.name (タスク名) 辺りと組み合わせて色々頑張る
    // (buildSrc で拡張関数を作る場合は fun Task.android() = project.extensions.findByName("android") as? BaseExtension で Task からアクセス可能)

    // name が assemble<flavorName><buildType> などの名称か判定する自作メソッド呼び出し
    if (isBuildTask()) {
        // ${project.buildDir.path}/outputs/apk/${flavorName}/${buildType} などの値
        val outputDir = ...
        // ${project.rootDir.path}/artifacts/apk/${appName}-${versionName}.${buildNum}${flavorName}-${buildType}.apk などの値
        val artifactFile = ...
        val deleteTask = tasks.create<Delete>("delete${name.capitalize()}") {
            // clean まではしないが apk, aab は毎回作り直す
            delete(outputDir, artifactFile)
        }
        dependsOn(deleteTask)
        val copyTask = tasks.create<Copy>("copy${name.capitalize()}") {
            from(outputDir) { include("*.apk", "*.aab") }
            into(artifactFile.parent)
            rename { artifactFile.name }
        }
        finalizedBy(copyTask)
    }
}

大体こんな感じの処理で、assembleFlavorNameDebug などのタスクに対して dependsOn の前処理で削除処理、finalizedBy の後処理で artifacts ディレクトリにAPKなどのコピーをしていました。

🔗AGP 8.2.2 時点での記述

AGP 8.2.2 で whenTaskAdded から configureEach に変更すると前述の通り tasks.create でエラーになります。

そして今回の処理を行うには以下のような条件が必要になります。

  • flavor や buildType にアクセス出来る必要がある
    • androidComponents.onVariants などが動くまで取得出来ない
  • assembleXXX や bundleXXX のタスクにアクセス出来る必要がある
    • tasks.configureEach などが動くまで取得出来ない

というわけで色々試行錯誤した結果、 androidComponents.onVariants で前処理/後処理タスクを登録し、 tasks.configureEach では dependsOn や finalizedBy のみ行うようにしました。

修正後の関連箇所を記載すると以下のような感じです。

android {
    flavorDimensions += listOf("env", "flipper")
    productFlavors {
        create("dev") { dimension = "env" }
        create("prod") { dimension = "env" }
        create("disableFlipper") { dimension = "flipper" }
        create("enableFlipper") { dimension = "flipper" }
    }
}
androidComponents {
    beforeVariants { variantBuilder ->
        // 不要な Build Variants を無効化
        if (variantBuilder.buildType == "release") {
            if (variantBuilder.name.contains("EnableFlipper")) {
                variantBuilder.enable = false
            }
        }
    }
    onVariants { variant ->
        val flavorSuffix = variant.productFlavors.joinToString(separator = "") {
            when (it.first) {
                "env" -> "-${it.second}"
                // disable の場合は名称を付与しない
                "flipper" -> if (it.second == "enableFlipper") "-flipper" else ""
                else -> throw IllegalStateException()
            }
        }
        val flavorNameCapitalized = variant.productFlavors.joinToString(separator = "") {
            it.second.replaceFirstChar(Char::uppercase)
        }
        val flavorName = flavorNameCapitalized.replaceFirstChar(Char::lowercase)

        val filePrefix = "${appName}-${versionName}.${buildNum}"
        val buildType = variant.buildType ?: throw IllegalStateException()
        val buildTypeCapitalized = buildType.replaceFirstChar(Char::uppercase)
        val srcDirBase = "${project.buildDir.path}/outputs"
        val artifactsDir = "${project.rootDir.path}/artifacts"
        val srcApkDir = "$srcDirBase/apk/$flavorName/$buildType"
        val srcAabDir = "$srcDirBase/bundle/$flavorName$buildTypeCapitalized"
        val fileName = "$filePrefix$flavorSuffix-$buildType"
        val artifactApkFile = File("$artifactsDir/apk/$fileName.apk")
        val artifactAabFile = File("$artifactsDir/bundle/$fileName.aab")

        val taskNameBase = "$flavorNameCapitalized$buildTypeCapitalized"
        tasks.register<Delete>("deleteAssemble$taskNameBase") {
            delete(srcApkDir, artifactApkFile)
        }
        tasks.register<Delete>("deleteBundle$taskNameBase") {
            delete(srcAabDir, artifactAabFile)
        }
        tasks.register<Copy>("copyAssemble$taskNameBase") {
            from(srcApkDir) { include("*.apk") }
            into(artifactApkFile.parent)
            rename { artifactApkFile.name }
        }
        tasks.register<Copy>("copyBundle$taskNameBase") {
            from(srcAabDir) { include("*.aab") }
            into(artifactAabFile.parent)
            rename { artifactAabFile.name }
        }
    }
}
tasks.configureEach {
    // 既存の自作判定メソッド
    if (isBuildTask()) {
        val taskNameBase = name.replaceFirstChar(Char::uppercase)
        dependsOn("delete$taskNameBase")
        finalizedBy("copy$taskNameBase")
    }
}

Build Variants の処理時点でタスクを登録する関係上、APKとAAB用にタスクを個別に生成しなければ行けなかった辺りが若干冗長ですが、これで何とか対応出来ました。

※dependsOn の前処理が不要であれば動的なタスク生成などはせずに、 tasks.configureEach 内で doLast などを設定して直接 File API などでコピーすることも可能です。

🔗ビルドオプションのデフォルト値変更

AGP 8.0.0 ではビルドパフォーマンスの改善を目的にビルドオプションのデフォルト値が変更されています。

ここでは対応プロジェクトで問題が起きたもののみ記載します。

🔗R8 fullMode の有効化

android.enableR8.fullMode のデフォルト値が true に変更されました。

これにより、リリースビルドした際にエラーが出るようになってしまいました。
具体的には GsonRetrofit 利用箇所で問題が起きていました。

一応 false に戻せば問題なく動いたのですが、できれば true の方が良いだろうということで調査を行いました。

検索したところ該当する issue は既に存在しました。

共に最新のコードには該当の proguard 記述が含まれているようなのですが、現行の最新リリースである gson 2.10.1retrofit 2.9.0 には含まれていなかったため、以下のファイルから必要そうなところをコピペしてきました。

ただ、Gson の方はこれだけだとうまく行かず、最終的に @SerializedName を使っているクラスをまとめて -keep するようにしたところ正常に動作するようになりました。

🔗BuildConfig の生成停止

android.defaults.buildfeatures.buildconfig のデフォルト値が false に変更されました。
(現状は build.gradle の android.buildFeatures.buildConfig でも設定可能)

これにより、BuildConfig.DEBUG などを利用していた箇所がエラーになってしまいました。

これに関しては対応箇所がある程度多めだった app モジュールは true に戻し、簡単に解決出来そうな library モジュールは debug/release でソースを分けるなどの対応を行いました。

🔗Rクラスの名前空間の有効化

android.nonTransitiveRClass のデフォルト値が true に変更されました。

これにより、Rクラスには各モジュールで宣言されたリソースのみが含まれるようになり、app モジュール内で library のリソースを参照していた箇所がエラーになってしまいました。

これの対応は library のリソースを参照しないようにするか、パッケージ名を含む完全修飾名を利用してRクラスを参照する必要があります。

が、参照しないようにするのは難しいし、完全修飾名は冗長過ぎてつらいので false に戻して対応しました。

🔗まとめ

主に AGP 8.1.0 ~ 8.2.2 辺りのJDK17対応の疑問点についてまとめると以下のような感じになります。

  • アプリモジュールのソースコードのコンパイル設定は jvmToolchain(17) にまとめてしまって問題がない
    • minSdk に応じてAPKなどに含まれる class ファイルは Java6 または Java8 バイナリとなる
  • jvmToolchain でまとめて指定する方が分かりやすいが、特にエラーの発生していないプロジェクトであれば AGP8 以降利用時も jvmTarget = "1.8" などを維持することは問題がない
    • jvmTarget = "1.8" などの設定は AGP8 の最小JDKバージョンとは関係がない
  • 他者へ配布予定のあるライブラリモジュールは今のところは jvmTarget = "1.8" などを維持した方が安全
    • 今のところは Kotlin 1.9 以上を利用すれば問題なさそう
  • アプリモジュールの方がバージョンが高ければ、各モジュールでコンパイル設定のバージョンが異なっていても問題がない

あとは Gradle が早く安定化(メジャーバージョンの更新頻度の低下)して欲しいところですね・・・。

Gradle は頑張ると色々出来るのですが、勉強コストが結構高いくせに勉強した内容の陳腐化が早くて辛いです。

AGPのロードマップを確認すると、AGP9 や AGP10 で古い Variant API の非推奨化/削除が行われたり、非公開の内部 AGP クラスへのアクセス権が削除されたりする可能性があるようなのであまり特殊な処理は書かないように気をつけた方が良さそうです。


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。