Gradle 強制覆寫 plugin 的 JVM target:Kotlin 與 Java 的切入點不對稱
問題情境
Android Flutter 專案升到 Kotlin 2.2 + AGP 8.12 後,build 時出現:
1Execution failed for task ':external_display:compileDebugKotlin'.
2> ⛔ Inconsistent JVM Target Compatibility Between Java and Kotlin Tasks
3 Inconsistent JVM-target compatibility detected for tasks
4 'compileDebugJavaWithJavac' (1.8) and 'compileDebugKotlin' (17).主專案 :app 已經設定 JVM 17,但第三方 plugin(例如 external_display)的 build.gradle 硬寫死 JVM 1.8。想從主專案這邊強制覆寫,卻發現 Kotlin 用一種寫法能贏、Java 用同樣的寫法卻會輸。
Kotlin 與 Java 的覆寫結果不一樣
Kotlin 端:task 級 configureEach 能贏
1subprojects {
2 tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
3 kotlinOptions {
4 jvmTarget = '17'
5 }
6 }
7}即使 plugin 的 build.gradle 寫了 kotlinOptions { jvmTarget = '1.8' },這段覆寫仍然會贏。
Java 端:task 級 configureEach 會被蓋回去
1subprojects {
2 tasks.withType(JavaCompile).configureEach {
3 sourceCompatibility = '17'
4 targetCompatibility = '17'
5 }
6}這段看起來跟 Kotlin 端對稱,但沒用 —— task 上的賦值會被 AGP 從 android.compileOptions 再同步回來,重新變成 1.8。
為什麼不對稱:兩個 plugin 的內部機制不同
Kotlin Plugin:extension → task 單向流動
Kotlin plugin 讀取 kotlin {} 或 kotlinOptions {} extension 的值,寫入對應的 KotlinCompile task。寫入一次,之後不再同步。
flowchart LR
E[kotlin extension] -->|一次性寫入| T[KotlinCompile task]
C[configureEach] -->|後寫的贏| T這就是為什麼 configureEach 能贏 —— 它註冊的 configuration action 在 task realization 時才套用,比 plugin 的 extension 寫入更晚。
AGP:extension ↔ task 雙向同步
AGP 把 android.compileOptions.sourceCompatibility 視為真相來源,每次 JavaCompile task 被 realize 或 configure 時,都會從 extension 重新同步過去。
flowchart LR
E[android.compileOptions] <-->|持續同步| T[JavaCompile task]
C[configureEach] -.->|被 AGP 蓋回去| T在 task 上直接賦值沒用 —— AGP 會用 extension 的值把你蓋掉。真正有效的治理點是 extension 本身。
正確解法:切入點依 plugin 機制決定
Kotlin:鎖 task
1tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
2 kotlinOptions {
3 jvmTarget = '17'
4 }
5}Java:鎖 extension,而且要在 afterEvaluate 時機
直接在 subprojects {} 最外層寫 plugins.withId("com.android.library") { android { compileOptions {...} } } 也沒用:這個 callback 在 plugin 被 apply 時立刻觸發,早於 plugin 自己的 build.gradle 執行,會被 plugin 後來的 android { compileOptions = 1.8 } 蓋回去。
必須等 plugin 自己的 android {} 執行完之後再改,也就是 afterEvaluate:
1subprojects {
2 if (project.name != 'app') {
3 afterEvaluate {
4 if (project.hasProperty('android')) {
5 android {
6 compileOptions {
7 sourceCompatibility = JavaVersion.VERSION_17
8 targetCompatibility = JavaVersion.VERSION_17
9 }
10 }
11 }
12 }
13 }
14}診斷流程
遇到 JVM target inconsistency 錯誤時,照以下步驟推論:
步驟 1:看錯誤訊息指的是哪個 task
1Inconsistent JVM-target compatibility detected for tasks
2'compileDebugJavaWithJavac' (1.8) and 'compileDebugKotlin' (17).compileDebugJavaWithJavac是 Java 端的 taskcompileDebugKotlin是 Kotlin 端的 task- 括號內的數字就是各自的 target
步驟 2:看哪一端低、哪一端高
- 低的那端被 plugin 硬寫死了
- 高的那端是主專案設定已經生效的
這一步決定要覆寫哪一端。
步驟 3:看是哪個 plugin 引起的
從錯誤訊息的 task 前綴 :external_display:compileDebugKotlin 找到是 external_display plugin。
查它的 build.gradle:
1find ~/.pub-cache/hosted/ -type d -name "external_display*"
2cat ~/.pub-cache/hosted/pub.dev/external_display-0.4.2+1/android/build.gradle通常會看到:
1compileOptions {
2 sourceCompatibility JavaVersion.VERSION_1_8
3 targetCompatibility JavaVersion.VERSION_1_8
4}
5kotlinOptions {
6 jvmTarget = '1.8'
7}步驟 4:依 Kotlin/Java 差異選擇覆寫方式
- Kotlin 寫死 → 用
KotlinCompile.configureEach - Java 寫死 → 用
afterEvaluate改android.compileOptions
完整的 root android/build.gradle 範例
1subprojects {
2 // Java 端:在 plugin 的 android {} 執行完後覆寫 compileOptions
3 if (project.name != 'app') {
4 afterEvaluate {
5 if (project.hasProperty('android')) {
6 android {
7 compileOptions {
8 sourceCompatibility = JavaVersion.VERSION_17
9 targetCompatibility = JavaVersion.VERSION_17
10 }
11 }
12 }
13 }
14 }
15 // Kotlin 端:task 級直接覆寫
16 tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
17 kotlinOptions {
18 jvmTarget = '17'
19 }
20 }
21}:app 跳過是因為它透過 kotlin { jvmToolchain(17) } 自己處理了(見下節)。
延伸:為什麼 :app 不能用同一套覆寫
若專案的 root build.gradle 裡有:
1subprojects {
2 project.evaluationDependsOn(":app")
3}這行強制 :app 比所有其他 subproject 先 evaluate。等到 subprojects { afterEvaluate {} } 想註冊到 :app 時,:app 已經 evaluate 完畢,Gradle 拋:
1Cannot run Project.afterEvaluate(Closure) when the project is already evaluated.所以要在呼叫 afterEvaluate 之前用 project.name != 'app' 跳過它。
:app 的 JVM 設定交給 :app/build.gradle 自己處理,例如:
1kotlin {
2 jvmToolchain(17)
3}