nashcft's blog

時々何か書く。

Android project で Gradle 8.0 に更新するために Java toolchain の設定をする

ある日 Android project の Gradle を 7.6 から 8.0 (この記事を書いている時点での最新は 8.0.1) に更新したところ以下のようなエラーと共にビルドに失敗した:

'compile{Variant}JavaWithJavac' task (current target is 1.8) and 'kaptGenerateStubs{Variant}Kotlin' task (current target is 17) jvm target compatibility should be set to the same Java version.
Consider using JVM toolchain:https://kotl.in/gradle/jvm/toolchain

この時の target JVM まわりの設定は以下のようになっていた:

android {
  // ...
  compileOptions {
    sourceCompatibility = JavaVersion.VERSION_1_8
    targetCompatibility = JavaVersion.VERSION_1_8
  }

  kotlinOptions {
    jvmTarget = "1.8"
  }
}

つまり target JVM を 1.8 に設定しているのに KaptGenerateStubs task は 17 を target として実行されてしまっているという状況のようだ。このビルドを実行した時の JDK のバージョンは 17 だったので build.gradle の設定を無視して実行環境上の JVM のバージョンを参照しているように思われる。ついでに JVM toolchain なるものを使いなよという提案もされている。

これをどうにかしないことには Gradle を 8.0.x には上げられないので対処したのだが、その過程で JVM toolchain (Gradle の docs では "Java toolchain" と呼ばれていたので以後 Java toolchain とする) を設定することになったのでそのまわりについて調べたことをざっと記録していこうと思う。

kapt が jvmTarget を参照しなくなった

Gradle 8.0 の release note と Kotlin 1.8 の what's new を読んだ感じ以下のような状況らしい:

  • Gradle 8.0 から Kotlin DSLAPI level が 1.8 になった
  • Kotlin 1.8 から Java と Kotlin の compile task で target JVM が一致していない場合の反応をエラーとするようにした
  • kapt が jvmTarget の設定を見なくなったので、これと合わせるように設定していた compileJavaWithJavac task の target compatibility とずれてビルドエラーが発生した

docs.gradle.org

kotlinlang.org

kapt の挙動変更については Kotlin の what's new に記載がなかったのでこのエラーについて何か情報がないか検索したところ、 YouTrack にそれそのものな issue があった:

現時点のやりとりにおける JetBrains の中の人の見解は以下のような感じ:

  • Kotlin 1.8.0 から kapt が jvmTarget, というか kotlinOptionscompilerOptions を参照しなくなったのは意図的な変更で、バグという認識をしていない
  • jvmTarget による指定をやめて Java toolchain を使ってほしそう

報告者も言っているように意図的な、しかも後方互換性を破壊するような挙動の変更ならちゃんと what's new で言及してよと思うが、document を読んだ感じ Gradle としても target JVM まわりの設定は Java toolchains に寄せてほしそうだし、そういう気持ちなら Java toolchain を設定する方向でいいか、となった。

JavaVersion sourceCompatibility
Java version compatibility to use when compiling Java source. Default value: language version of the toolchain from this extension.
Note that using a toolchain is preferred to using a compatibility setting for most cases.

docs.gradle.org

ところで報告者は Android Studio に同梱された JDK を使ってビルドしたいが JVM target は固定したい (≒ ビルドに使う JDK のバージョンと異なる target を指定したい)と言っており、この場合 Java toolchains による暗黙的な設定に置き換えると指定した toolchain のバージョンに target JVM が揃えられてしまうので要求を満たせない。このような要求に対する現状の workaround は報告者のコメントなどを見るに KotlinCompile 系の task に対して configureEach で target JVM の指定を適用するという方法くらいのようだ。ただし報告者によるとパフォーマンスの問題があったり build script の構成によっては上手くいかないこともあるらしい (未確認):

tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
    kotlinOptions {
        jvmTarget = "<指定したい jvm version>"
    }
}

KSP も同様

KSP だとどうだろうと思って、以下の repository がちょうどいい構成だったのでこれで Gradle を 8.0.1 にして確認したところ同様のエラーが発生した:

github.com

'compileDebugJavaWithJavac' task (current target is 1.8) and 'kspDebugKotlin' task (current target is 17) jvm target compatibility should be set to the same Java version.
Consider using JVM toolchain: https://kotl.in/gradle/jvm/toolchain

ということで ksp しか使っていない project でも Java toolchain の設定をするか上記の workaround を入れるかして対処する必要がある。

Java toolchain の設定をする

Java toolchain とは

Java toolchain とは大雑把に言うと JVM 系の project のビルドや実行に使われるツール群 (javac とか java とか) のことで、 Gradle 6.7 から Gradle を実行しているものとは別の toolchain を project や task の単位で指定できるようになったとのこと。異なるマシン上でも実行環境を揃えたり、 project 内で異なる Java version での task 実行を行ったり、 Gradle がサポートしていないバージョンの Java で project / task の実行をしたりといったことを目的としているようだ。

Document はこれ:

docs.gradle.org

6.7 時点の話なので 8.0 での挙動等と異なる点はあるけどこの記事も読むといいかも:

progret.hatenadiary.com

設定の記述やルール

今回は基本的には project level での設定をすれば十分なのでその方法のみ紹介する。 Task level での切り替え設定をしたい場合は document を読んでほしい。

Java toolchain の設定は java extension block で以下のように記述する:

java {
  toolchain {
    languageVersion.set(JavaLanguageVersion.of(17))
    vendor.set(JvmVendorSpec.ADOPTIUM)
  }
}

設定できる項目は以下の3つ:

  • バージョン (languageVersion): toolchain に指定する Java のバージョン
  • 配布元 (vendor): 使用する JDK の配布元
  • VM の実装 (implementation): 主に OpenJ9 を使用したい時に設定する
    • Android project だとまあまあ関係ない話かも

これらの設定には有効性の概念があり、下記の2パターンの内のどちらかに該当するものが有効とされる。そうでない設定は無効と判定されビルドエラーとなる:

  1. 何も指定がない
  2. languageVersion の指定が含まれている

Android project では基本的に languageVersion の設定だけで十分だと思っていて、もし distribution にこだわりがある or そこまで実行環境を揃えておきたいという場合には vendor も書くという風になるだろう。

また、toolchain の設定は Kotlin Gradle plugin からも行うことができ、 distribution の指定が必要ない場合はより短い記述で設定可能になっている:

kotlinlang.org

kotlin {
  jvmToolchain {
    // この block の中の this は java.toolchain と同じ JavaToolchainSpec なので書くものは同じ
    languageVersion.set(JavaLanguageVersion.of(17))
    vendor.set(JvmVendorSpec.ADOPTIUM)
  }

  // バージョン指定だけで良い場合は以下のように書ける
  jvmToolchain(17)
}

このようにして toolchain (のバージョン) の指定を行うと、 Java における sourceCompatibilitytargetCompatibility, Kotlin における jvmTarget を設定しなかった場合に指定された toolchain のバージョンが target JVM として使用されるようになる。

Toolchain に使用する JDK/JRE の選択と自動ダウンロード

Java toolchain の指定がなされた project で build 等を実行すると、ローカルにインストールされている toolchain たちの中から指定に該当するものを自動で選択し、それを用いて各 task を実行する。ローカルにインストールされている toolchain の検知対象については document に以下のように書かれている:

Package manager でインストールしたものが使えるので、普段 JDK の管理でそれらを使用している人ならそのまま使いまわせて便利。

ところで、自動検知の結果ローカルに該当する toolchain が見つからなかった場合、これまで紹介した設定だけだと該当なしということでエラーになってしまう。実は元々は該当なしだった場合 AdoptOpenJDK (現: Adoptium) が自動でダウンロードされるようになっていたのだが、 8.0 でその機能が削除されてしまっている。

docs.gradle.org

ただし、「ローカルに指定に該当する toolchain が無かった場合にはダウンロードして取ってくる」という機能自体は 8.0 以降も存在しており、 download repository の設定を記述することで有効になる。 Download repository の設定は基本的には以下のような感じで settings.gradle に resolver について書くことになる。 Toolchain resolver は plugin として自分で作ることもできるが、既に Foojay Toolchains Plugin というものが公開されており、一般的な JDK distribution を使うならこの plugin を使用すれば事足りる。

github.com

pluginManagement {
  // ...
}

plugins {
  id("org.gradle.toolchains.foojay-resolver") version("0.4.0")
}

toolchainManagement {
  jvm {
    javaRepositories {
      repository("foojay") {
        resolverClass.set(org.gradle.toolchains.foojay.FoojayToolchainResolver::class.java)
      }
    }
  }
}

Download repository に foojay しか使わない場合は foojay-resolver-convention plugin を使うことで toolchainManagement block の記述を省略できる:

// foojay しか使わないならこれを追加するだけで OK
plugin {
  id("org.gradle.toolchains.foojay-resolver-convention") version("0.4.0")
}

自動ダウンロードでの toolchain の取得を当てにする場合、あらゆる種類の JDK を取得できるわけではないので注意。例えば foojay は JavaVendorSpec で定義されているすべての vendor をサポートしているわけではないし、それぞれの vendor でダウンロード可能な JDK のバージョンも違いがある。欲しい toolchain が取得可能かは使用する resolver plugin の対応状況や、 asdf など package manager がそれぞれの vendor からどのバージョンを取得可能かのリストなどを確認するとよい。とはいえ最新の Java に追従しているわけではない Android では基本的には LTS のバージョンを使うことになるだろうし、 LTS のものなら入手できないことはまずないので、気にするとしたら使いたい vendor で JDK 8 が配布されてるかどうかくらいか。

参考 (asdf-java が取得可能な JDK の一覧):

github.com

Android project 向けの workaround

Kotlin の document の Configure a Gradle project のページを見たらわかるが、 toolchains support の冒頭に Android project に関する注意が書かれていて、 Java toolchains のサポートは AGP 7.4.0 以上だということの他に toolchain を設定しても Java の compile task の target JVM に反映されないという現状の不具合について issuetracker へのリンクと対処法が示されている。

こういう状況なので現時点では toolchain の設定で指定した JVM のバージョンに対応する JavaVersion の値を compileOptions で指定する必要がある:

kotlin {
  // 例えば Java toolchain の設定が 11 なら
  jvmToolchain(11)
}

android {
  // ...

  compileOptions {
    // これらの compatibility options も 11 に合わせる
    sourceCompatibility = JavaVersion.VERSION_11
    targetCompatibility = JavaVersion.VERSION_11
  }
}

Robolectric を使っている場合について

Robolectric は Android の target SDK 29 以上で実行する場合実行環境に Java 9 以上を要求する:

github.com

このため Java toolchain の設定に乗り換える際に toolchain のバージョンを単純に 8 に指定するだけだと Robolectric を使用したテストの実行でエラーになってしまう。この問題を対処するには

  1. toolchain のバージョン指定を 9 以上にする (= target JVM を 9 以上に上げる)
  2. robolectric.properties で実行時の target SDK を 28 以下にする

の2通りの方法が考えられる。

Target JVM のバージョンを上げる方法に関して、 application project の場合はあまり問題にならないと思うので軽率に上げてしまって良さそうだが、 library project の場合は利用者の target JVM より高くなると使えなくなってしまうので、移行期間を設けたり test の toolchain だけ 9 以上になるように設定したりするなど上手くやる必要がある。個人的には普通の Android アプリ開発で target JVM 1.8 にこだわる理由は特にないのではと思っているのでみんな軽率に 11 に上げてしまっていいんじゃないかと思っている。17 は R8 がまだ対応しきれていないっぽいのでまだいいかなという感触。

sdk property で対処する方法は簡単だし外への影響がないのだが、 minimum SDK が 29 以下の間しか使えないので一時凌ぎとして捉えた方が良い。

CI について

(2023-07-19 内容を変更、元の記述は間違った内容を含むので削除)

CI 環境のセットアップについては自動ダウンロードを有効にしている場合でもそうでない場合でもまず Gradle を動かすための JDK を task 実行前に用意しておく必要がある。自動ダウンロードを無効にしている場合はさらに project で指定した version の Java tool chain をダウンロードするステップが追加されるが、これに関しては CI が複数の JDK を配置させられるような機能を持っていれば*1それを使うなり package manager をインストールしてそれ経由でダウンロードするなりで実現できるだろう。 Gradle による Java toolchain の自動検知対象となる場所以外に配置しても、下記の property に配置場所の path を指す環境変数を指定して gradle.properties に追記することで Gradle に認識させることができる:

org.gradle.java.installations.fromEnv=<JDK の path を指す環境変数 (,区切り)>

できる限り手元の開発環境でも CI 環境でも使う toolchain を揃えたいという場合は自動ダウンロードに任せてしまった方が設定の手間もなくポータブルだが、そこまで厳密な運用を必要としないなら好みの範囲かと思う。

おわりに

kapt / ksp の挙動変更については疑問が残るが、 Java toolchain の設定自体は便利機能だし、当初思っていたより設定の手間がずっと少なかったので、ビルド環境に関する制約がなければ設定の移行をしてしまって良いと思う。今は Java toolchain の設定を導入したくないとしても、これまで通りの target JVM の指定方法を維持するための workaround も一応存在するので、 workaround を入れてとりあえず Gradle 8.0 に上げてから最終的にどうするか考えるという手段を取ることもできそう。なんにせよ Android Gradle Plugin 8.0 から要求される Gradle の最低バージョンが 8.0 になるので、今のうちから Gradle のバージョンを 8.0 以上に上げられるようにしておきたい。

developer.android.com

それで Java toolchain の設定をするのに結局どういう変更をしたらいいのかというのが散らかってしまっていると思うので、私が行った build script の変更の抜粋を以下に置いておく。今回対象となった project は target JVM を 11 にしても問題なかったのでそのように修正しているが、移行したいが少なくとも build の target JVM は 1.8 でないと問題がある + Robolectric を使っているという場合は task level での toolchain の設定で乗り切れるはずなので調べて試してみてほしい。

build.gradle

+ kotlin {
+   jvmToolchain(11)
+ }

  android {
    ...
    compileOptions {
-     sourceCompatibility = JavaVersion.VERSION_1_8
-     targetCompatibility = JavaVersion.VERSION_1_8
+     // https://issuetracker.google.com/issues/260059413 が修正されたら削除
+     sourceCompatibility = JavaVersion.VERSION_11
+     targetCompatibility = JavaVersion.VERSION_11
    }

-   kotlinOptions {
-     jvmTarget = "1.8"
-   }
    ...
  }

settings.gradle

  ...
+ plugin {
+   id("org.gradle.toolchains.foojay-resolver-convention") version("0.4.0")
+ }
  ...

調査メモ:

scrapbox.io

*1:例えば GitHub Actions の setup-java では複数の JDK のインストールを指定できる: https://github.com/actions/setup-java/blob/4fb397523b853fa75ed64fd1d10a967e1eb3148a/README.md#install-multiple-jdks