問題情境

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 端的 task
  • compileDebugKotlin 是 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 寫死 → 用 afterEvaluateandroid.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}