<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Gradle on Tarragon</title><link>https://tarrragon.github.io/blog/tags/gradle/</link><description>Recent content in Gradle on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 17 Apr 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/gradle/index.xml" rel="self" type="application/rss+xml"/><item><title>Gradle Configuration 時序陷阱：afterEvaluate、evaluationDependsOn、finalized properties</title><link>https://tarrragon.github.io/blog/work-log/gradle-configuration-%E6%99%82%E5%BA%8F%E9%99%B7%E9%98%B1afterevaluateevaluationdependsonfinalized-properties/</link><pubDate>Fri, 17 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/gradle-configuration-%E6%99%82%E5%BA%8F%E9%99%B7%E9%98%B1afterevaluateevaluationdependsonfinalized-properties/</guid><description>&lt;h2 id="三種典型錯誤都源自同一個問題">三種典型錯誤都源自同一個問題&lt;/h2>
&lt;p>這些錯誤表面訊息不同，但根本原因都是「callback 註冊得太晚，或屬性被賦值得太晚」：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>錯誤訊息&lt;/th>
 &lt;th>實際含義&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>Cannot run Project.afterEvaluate(Closure) when the project is already evaluated&lt;/code>&lt;/td>
 &lt;td>對象已 evaluate 完，註冊 callback 失敗&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>The value for property 'languageVersion' is final and cannot be changed any further&lt;/code>&lt;/td>
 &lt;td>屬性已被 finalize，後續賦值失敗&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>覆寫了 plugin 設定但沒生效&lt;/td>
 &lt;td>覆寫時機早於 plugin，被 plugin 蓋回去&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>想正確治理這些情境，必須先理解 Gradle configuration 的時序模型。&lt;/p>
&lt;hr>
&lt;h2 id="gradle-configuration-階段的時序">Gradle Configuration 階段的時序&lt;/h2>
&lt;h3 id="單一-project-的-evaluate-流程">單一 project 的 evaluate 流程&lt;/h3>





&lt;pre tabindex="0">&lt;code class="language-mermaid" data-lang="mermaid">sequenceDiagram
 participant Root as Root build.gradle
 participant Sub as Subproject build.gradle
 participant Plugin as Plugin (apply)
 participant After as afterEvaluate

 Root-&amp;gt;&amp;gt;Sub: 開始 evaluate subproject
 Sub-&amp;gt;&amp;gt;Plugin: apply plugin &amp;#39;com.android.library&amp;#39;
 Note over Plugin: plugins.withId callback 觸發
 Plugin--&amp;gt;&amp;gt;Sub: 回到 subproject 腳本
 Sub-&amp;gt;&amp;gt;Sub: android { compileOptions = 1.8 }
 Note over Sub: plugin 自己的設定套用
 Sub-&amp;gt;&amp;gt;After: subproject evaluate 完成
 Note over After: afterEvaluate callback 觸發&lt;/code>&lt;/pre>&lt;p>&lt;strong>關鍵時機&lt;/strong>：&lt;/p>
&lt;ol>
&lt;li>&lt;code>subprojects {}&lt;/code> block 的內容：最早執行&lt;/li>
&lt;li>&lt;code>plugins.withId(&amp;quot;...&amp;quot;) { ... }&lt;/code> callback：plugin apply 那一刻觸發&lt;/li>
&lt;li>plugin 自己 build.gradle 內的設定（例如 &lt;code>android { ... }&lt;/code>）：在 3 之後&lt;/li>
&lt;li>&lt;code>afterEvaluate { ... }&lt;/code> callback：subproject evaluate 完畢後觸發&lt;/li>
&lt;li>&lt;code>tasks.withType(...).configureEach { ... }&lt;/code>：task realize/configure 時才套用&lt;/li>
&lt;/ol>
&lt;blockquote>
&lt;p>要覆寫某個設定，必須讓覆寫的時機晚於那個設定的寫入時機。&lt;/p>&lt;/blockquote>
&lt;h3 id="多-project-的順序">多 project 的順序&lt;/h3>
&lt;p>預設情況 Gradle 自己決定 subproject 的 evaluation 順序（通常按字典序）。但若有：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-groovy" data-lang="groovy">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="n">subprojects&lt;/span> &lt;span class="o">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="n">project&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="na">evaluationDependsOn&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="s2">&amp;#34;:app&amp;#34;&lt;/span>&lt;span class="o">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="o">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>:app&lt;/code> 被強制&lt;strong>最先&lt;/strong>完成 evaluate。這是為了讓其他 subproject 能看到 &lt;code>:app&lt;/code> 的 extension 值，但副作用是：&lt;strong>對 &lt;code>:app&lt;/code> 來說，後面所有 &lt;code>subprojects {}&lt;/code> 內的 hook（尤其是 &lt;code>afterEvaluate&lt;/code>）註冊時機都太晚了&lt;/strong>。&lt;/p>
&lt;hr>
&lt;h2 id="錯誤-1cannot-run-projectafterevaluate-when-already-evaluated">錯誤 1：&lt;code>Cannot run Project.afterEvaluate when already evaluated&lt;/code>&lt;/h2>
&lt;h3 id="症狀">症狀&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-groovy" data-lang="groovy">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="n">subprojects&lt;/span> &lt;span class="o">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="n">afterEvaluate&lt;/span> &lt;span class="o">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="o">(&lt;/span>&lt;span class="n">project&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="na">name&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="s1">&amp;#39;app&amp;#39;&lt;/span>&lt;span class="o">)&lt;/span> &lt;span class="o">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 想對非 app 的 subproject 做事
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="o">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="o">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="o">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>build 時拋錯，指著 &lt;code>afterEvaluate&lt;/code> 那一行。&lt;/p></description><content:encoded><![CDATA[<h2 id="三種典型錯誤都源自同一個問題">三種典型錯誤都源自同一個問題</h2>
<p>這些錯誤表面訊息不同，但根本原因都是「callback 註冊得太晚，或屬性被賦值得太晚」：</p>
<table>
  <thead>
      <tr>
          <th>錯誤訊息</th>
          <th>實際含義</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>Cannot run Project.afterEvaluate(Closure) when the project is already evaluated</code></td>
          <td>對象已 evaluate 完，註冊 callback 失敗</td>
      </tr>
      <tr>
          <td><code>The value for property 'languageVersion' is final and cannot be changed any further</code></td>
          <td>屬性已被 finalize，後續賦值失敗</td>
      </tr>
      <tr>
          <td>覆寫了 plugin 設定但沒生效</td>
          <td>覆寫時機早於 plugin，被 plugin 蓋回去</td>
      </tr>
  </tbody>
</table>
<p>想正確治理這些情境，必須先理解 Gradle configuration 的時序模型。</p>
<hr>
<h2 id="gradle-configuration-階段的時序">Gradle Configuration 階段的時序</h2>
<h3 id="單一-project-的-evaluate-流程">單一 project 的 evaluate 流程</h3>





<pre tabindex="0"><code class="language-mermaid" data-lang="mermaid">sequenceDiagram
    participant Root as Root build.gradle
    participant Sub as Subproject build.gradle
    participant Plugin as Plugin (apply)
    participant After as afterEvaluate

    Root-&gt;&gt;Sub: 開始 evaluate subproject
    Sub-&gt;&gt;Plugin: apply plugin &#39;com.android.library&#39;
    Note over Plugin: plugins.withId callback 觸發
    Plugin--&gt;&gt;Sub: 回到 subproject 腳本
    Sub-&gt;&gt;Sub: android { compileOptions = 1.8 }
    Note over Sub: plugin 自己的設定套用
    Sub-&gt;&gt;After: subproject evaluate 完成
    Note over After: afterEvaluate callback 觸發</code></pre><p><strong>關鍵時機</strong>：</p>
<ol>
<li><code>subprojects {}</code> block 的內容：最早執行</li>
<li><code>plugins.withId(&quot;...&quot;) { ... }</code> callback：plugin apply 那一刻觸發</li>
<li>plugin 自己 build.gradle 內的設定（例如 <code>android { ... }</code>）：在 3 之後</li>
<li><code>afterEvaluate { ... }</code> callback：subproject evaluate 完畢後觸發</li>
<li><code>tasks.withType(...).configureEach { ... }</code>：task realize/configure 時才套用</li>
</ol>
<blockquote>
<p>要覆寫某個設定，必須讓覆寫的時機晚於那個設定的寫入時機。</p></blockquote>
<h3 id="多-project-的順序">多 project 的順序</h3>
<p>預設情況 Gradle 自己決定 subproject 的 evaluation 順序（通常按字典序）。但若有：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-groovy" data-lang="groovy"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">subprojects</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="n">project</span><span class="o">.</span><span class="na">evaluationDependsOn</span><span class="o">(</span><span class="s2">&#34;:app&#34;</span><span class="o">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="o">}</span></span></span></code></pre></div><p><code>:app</code> 被強制<strong>最先</strong>完成 evaluate。這是為了讓其他 subproject 能看到 <code>:app</code> 的 extension 值，但副作用是：<strong>對 <code>:app</code> 來說，後面所有 <code>subprojects {}</code> 內的 hook（尤其是 <code>afterEvaluate</code>）註冊時機都太晚了</strong>。</p>
<hr>
<h2 id="錯誤-1cannot-run-projectafterevaluate-when-already-evaluated">錯誤 1：<code>Cannot run Project.afterEvaluate when already evaluated</code></h2>
<h3 id="症狀">症狀</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-groovy" data-lang="groovy"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">subprojects</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="n">afterEvaluate</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="k">if</span> <span class="o">(</span><span class="n">project</span><span class="o">.</span><span class="na">name</span> <span class="o">!=</span> <span class="s1">&#39;app&#39;</span><span class="o">)</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">            <span class="c1">// 想對非 app 的 subproject 做事
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span>        <span class="o">}</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="o">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="o">}</span></span></span></code></pre></div><p>build 時拋錯，指著 <code>afterEvaluate</code> 那一行。</p>
<h3 id="邏輯推論">邏輯推論</h3>
<ul>
<li><code>afterEvaluate(Closure)</code> 是<strong>註冊動作</strong>，註冊當下就執行</li>
<li><code>subprojects {}</code> 對每個 subproject 都執行一次，包括 <code>:app</code></li>
<li>當處理到 <code>:app</code> 時，它已經 evaluate 完畢（因為 <code>evaluationDependsOn</code>）</li>
<li>對已 evaluate 的 project 註冊 <code>afterEvaluate</code> → 註冊失敗 → 拋錯</li>
</ul>
<p>把 <code>project.name != 'app'</code> 放在 closure <strong>內</strong>救不了——<code>afterEvaluate</code> 方法本身已經先炸。</p>
<h3 id="解法">解法</h3>
<p>判斷必須提前到註冊動作之外：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-groovy" data-lang="groovy"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">subprojects</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">if</span> <span class="o">(</span><span class="n">project</span><span class="o">.</span><span class="na">name</span> <span class="o">!=</span> <span class="s1">&#39;app&#39;</span><span class="o">)</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="n">afterEvaluate</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">            <span class="c1">// 此時 :app 根本不會進到這裡
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span>        <span class="o">}</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="o">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="o">}</span></span></span></code></pre></div><h3 id="更穩健的通用寫法">更穩健的通用寫法</h3>
<p>若不想 hardcode 名字：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-groovy" data-lang="groovy"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">subprojects</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">if</span> <span class="o">(!</span><span class="n">project</span><span class="o">.</span><span class="na">state</span><span class="o">.</span><span class="na">executed</span><span class="o">)</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="n">afterEvaluate</span> <span class="o">{</span> <span class="o">...</span> <span class="o">}</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="o">}</span> <span class="k">else</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="c1">// 對已 evaluate 的 project 立即執行（如果適用）
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span>    <span class="o">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="o">}</span></span></span></code></pre></div><hr>
<h2 id="錯誤-2languageversion-is-final-and-cannot-be-changed-any-further">錯誤 2：<code>languageVersion is final and cannot be changed any further</code></h2>
<h3 id="症狀-1">症狀</h3>
<p>在 <code>subprojects {}</code> 內嘗試為所有 Kotlin Android 子專案套用 JVM Toolchain：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-groovy" data-lang="groovy"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">subprojects</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="n">plugins</span><span class="o">.</span><span class="na">withId</span><span class="o">(</span><span class="s2">&#34;org.jetbrains.kotlin.android&#34;</span><span class="o">)</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="n">kotlin</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">            <span class="n">jvmToolchain</span><span class="o">(</span><span class="mi">17</span><span class="o">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="o">}</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="o">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="o">}</span></span></span></code></pre></div><p>某個 subproject evaluate 時拋錯。</p>
<h3 id="邏輯推論-1">邏輯推論</h3>
<ul>
<li><code>plugins.withId</code> callback 在 plugin apply 那一刻觸發</li>
<li>但 Kotlin plugin 的部分屬性在<strong>另一個更早的時機</strong>就被 finalize（例如 plugin 自己內部的 lazy property initialization）</li>
<li><code>jvmToolchain(17)</code> 想寫入 <code>languageVersion</code> 這類屬性，發現已 finalize</li>
<li>Gradle 的 Provider API 對已 finalize 的屬性再賦值會直接 throw</li>
</ul>
<h3 id="診斷">診斷</h3>
<p>看錯誤訊息最後幾個字：<code>is final and cannot be changed any further</code>。這是 Gradle Provider API 的通用訊息，指向「lazy property 被 finalize 後無法修改」。</p>
<p><strong>不要</strong>去找「誰把它 finalize 了」——這通常是 plugin 內部實作細節，追不到根因。</p>
<p><strong>要</strong>找：「有沒有更早的時機點可以設定這個？」</p>
<h3 id="解法-1">解法</h3>
<p>把 toolchain 設定往前搬到 <code>:app/build.gradle</code> 的頂層（而不是在 root 的 subprojects 內延遲套用）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-groovy" data-lang="groovy"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// :app/build.gradle
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="n">kotlin</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="n">jvmToolchain</span><span class="o">(</span><span class="mi">17</span><span class="o">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="o">}</span></span></span></code></pre></div><p>Flutter 專案的 <code>:app</code> 是 root configuration 最早執行的 subproject，這個時機點還沒人會 finalize Kotlin plugin 的屬性。</p>
<p>Gradle 會用 <code>:app</code> 的 toolchain 決定整個 daemon 用哪個 JDK，其他 subproject 繼承這個 JDK 環境，不需要自己再宣告 toolchain。</p>
<hr>
<h2 id="錯誤-3覆寫了-plugin-設定卻沒生效">錯誤 3：覆寫了 plugin 設定卻沒生效</h2>
<h3 id="症狀-2">症狀</h3>
<p>在 root <code>build.gradle</code> 寫了：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-groovy" data-lang="groovy"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">subprojects</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="n">plugins</span><span class="o">.</span><span class="na">withId</span><span class="o">(</span><span class="s2">&#34;com.android.library&#34;</span><span class="o">)</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="n">android</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">            <span class="n">compileOptions</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">                <span class="n">sourceCompatibility</span> <span class="o">=</span> <span class="n">JavaVersion</span><span class="o">.</span><span class="na">VERSION_17</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">                <span class="n">targetCompatibility</span> <span class="o">=</span> <span class="n">JavaVersion</span><span class="o">.</span><span class="na">VERSION_17</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="o">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="o">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="o">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="o">}</span></span></span></code></pre></div><p>但第三方 plugin（例如 <code>external_display</code>）仍然用 JVM 1.8 編譯。</p>
<h3 id="邏輯推論-2">邏輯推論</h3>
<p>回到時序圖：</p>
<table>
  <thead>
      <tr>
          <th>順序</th>
          <th>執行內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td><code>subprojects {}</code> 內的 <code>plugins.withId</code> callback 註冊</td>
      </tr>
      <tr>
          <td>2</td>
          <td>subproject build.gradle 開始執行</td>
      </tr>
      <tr>
          <td>3</td>
          <td>plugin 被 apply → <code>plugins.withId</code> callback 觸發（這裡設 17）</td>
      </tr>
      <tr>
          <td>4</td>
          <td>plugin build.gradle 繼續執行 → <code>android { compileOptions = 1.8 }</code></td>
      </tr>
  </tbody>
</table>
<p>第 4 步晚於第 3 步，覆蓋了我們的 17。</p>
<h3 id="解法-2">解法</h3>
<p>把覆寫時機搬到第 4 步<strong>之後</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-groovy" data-lang="groovy"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">afterEvaluate</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="n">android</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="n">compileOptions</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">            <span class="n">sourceCompatibility</span> <span class="o">=</span> <span class="n">JavaVersion</span><span class="o">.</span><span class="na">VERSION_17</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">            <span class="n">targetCompatibility</span> <span class="o">=</span> <span class="n">JavaVersion</span><span class="o">.</span><span class="na">VERSION_17</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">        <span class="o">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="o">}</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="o">}</span></span></span></code></pre></div><p><code>afterEvaluate</code> 的 callback 在 subproject 的 build.gradle 整個執行完畢後觸發，此時 plugin 已經寫完 <code>compileOptions = 1.8</code>，我們再蓋回 17 就贏了。</p>
<hr>
<h2 id="除錯決策樹">除錯決策樹</h2>
<p>遇到 configuration 階段的時序錯誤時：</p>





<pre tabindex="0"><code class="language-mermaid" data-lang="mermaid">flowchart TD
    A[出現錯誤] --&gt; B{訊息關鍵字}
    B --&gt;|already evaluated| C[註冊 callback 太晚]
    B --&gt;|is final| D[賦值屬性太晚]
    B --&gt;|覆寫沒生效| E[覆寫時機太早]
    C --&gt; F[在註冊前跳過已 evaluate 的 project]
    D --&gt; G[把賦值搬到更早的時機點]
    E --&gt; H[把覆寫搬到 afterEvaluate]</code></pre><p>三個解法方向完全相反：</p>
<ul>
<li><strong>太晚</strong> → 提前</li>
<li><strong>太早</strong> → 延後</li>
</ul>
<p>所以看到錯誤時第一件事是判斷<strong>時機太早還是太晚</strong>，而不是試圖繞過屬性狀態。</p>
<hr>
<h2 id="判斷時機太早還是太晚的速查">判斷「時機太早還是太晚」的速查</h2>
<table>
  <thead>
      <tr>
          <th>現象</th>
          <th>時機狀態</th>
          <th>該往哪搬</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>callback 註冊失敗（already evaluated）</td>
          <td>太晚</td>
          <td>提前，或跳過已 evaluate 的對象</td>
      </tr>
      <tr>
          <td>屬性賦值失敗（is final）</td>
          <td>太晚</td>
          <td>提前到屬性 finalize 之前的 hook</td>
      </tr>
      <tr>
          <td>我設的值被蓋掉</td>
          <td>太早</td>
          <td>延後到對方設定之後（通常是 afterEvaluate）</td>
      </tr>
      <tr>
          <td>task 上設了值但沒生效</td>
          <td>取決於 plugin</td>
          <td>看 plugin 有沒有從 extension 同步的機制</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>Gradle JVM target 除錯復盤：七個節點的策略權衡</title><link>https://tarrragon.github.io/blog/work-log/gradle-jvm-target-%E9%99%A4%E9%8C%AF%E5%BE%A9%E7%9B%A4%E4%B8%83%E5%80%8B%E7%AF%80%E9%BB%9E%E7%9A%84%E7%AD%96%E7%95%A5%E6%AC%8A%E8%A1%A1/</link><pubDate>Fri, 17 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/gradle-jvm-target-%E9%99%A4%E9%8C%AF%E5%BE%A9%E7%9B%A4%E4%B8%83%E5%80%8B%E7%AF%80%E9%BB%9E%E7%9A%84%E7%AD%96%E7%95%A5%E6%AC%8A%E8%A1%A1/</guid><description>&lt;h2 id="為什麼寫這篇">為什麼寫這篇&lt;/h2>
&lt;p>排查 Gradle JVM target inconsistency 時走了七個節點才收斂。這篇復盤每個節點的完整決策流：&lt;/p>
&lt;hr>
&lt;h2 id="節點-a第一次錯誤出現">節點 A：第一次錯誤出現&lt;/h2>
&lt;h3 id="當下看到">當下看到&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">Execution failed for task &amp;#39;:flutter_broadcasts_4m:compileDebugKotlin&amp;#39;.
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&amp;gt; ⛔ Inconsistent JVM Target Compatibility Between Java and Kotlin Tasks
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> detected for tasks &amp;#39;compileDebugJavaWithJavac&amp;#39; (17)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> and &amp;#39;compileDebugKotlin&amp;#39; (1.8).&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="判讀">判讀&lt;/h3>
&lt;p>&lt;strong>這類錯誤在系統中代表什麼&lt;/strong>（商業邏輯）：&lt;/p>
&lt;p>Android 專案的每個 module（主 app 或第三方 plugin）會分別編譯 Java 跟 Kotlin 原始碼，各自產出 JVM bytecode。每個 bytecode 檔案有一個「target version」，決定它能在多舊的 JVM runtime 上執行，以及可以使用哪些語言特性。&lt;/p>
&lt;p>同一個 module 內的 Java 跟 Kotlin 若產出不同 target 的 bytecode，執行時可能觸發 API 相容性問題（例如 Java 17 的 class 呼叫到 Kotlin 1.8 runtime 不存在的方法）。Kotlin 2.2 把這個原本只是 warning 的情境提升為 strict error，直接中止 build。&lt;/p>
&lt;p>所以 &lt;code>Inconsistent JVM Target Compatibility&lt;/code> 這類錯誤的本質是：&lt;strong>某個 module 裡面 Java 跟 Kotlin 編譯產出的 bytecode 不是同一個版本&lt;/strong>。&lt;/p>
&lt;p>&lt;strong>這次訊息具體說了什麼&lt;/strong>（CASE）：&lt;/p>
&lt;ul>
&lt;li>錯誤 task 前綴 &lt;code>:flutter_broadcasts_4m&lt;/code> → 出問題的 module 是這個第三方 plugin&lt;/li>
&lt;li>&lt;code>compileDebugJavaWithJavac (17)&lt;/code> → 這個 module 的 Java 編譯產出 bytecode target = 17&lt;/li>
&lt;li>&lt;code>compileDebugKotlin (1.8)&lt;/code> → 這個 module 的 Kotlin 編譯產出 bytecode target = 1.8&lt;/li>
&lt;li>17 跟 1.8 不同 → 符合上面「module 內不一致」的 pattern&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>從 CASE 推論的事&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>主專案 &lt;code>:app&lt;/code> 已設定 JVM 17，這個 plugin 的 Java 繼承到 17；但 Kotlin 被某處明確設成 1.8&lt;/li>
&lt;li>Kotlin plugin 的預設值會跟 Java 對齊，所以 1.8 是「有人明確寫了」，不是預設&lt;/li>
&lt;li>最有可能的「有人」是 plugin 自己的 &lt;code>build.gradle&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>需要進一步確認才能完整判讀的&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>Kotlin 1.8 具體寫在哪？&lt;code>cat ~/.pub-cache/hosted/pub.dev/flutter_broadcasts_4m-*/android/build.gradle&lt;/code> 可以驗證&lt;/li>
&lt;li>其他 plugin 有沒有同類寫死？這不影響當前這個錯誤的修復，但影響&lt;strong>修復範圍&lt;/strong>的完整性&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>判讀後的問題類別&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>類別：第三方 plugin 內部寫死 JVM target&lt;/li>
&lt;li>主專案的 override 機制沒能覆蓋到 plugin 的內部設定&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>這次判讀的完整度&lt;/strong>：驗證了 plugin 內部寫死（確認過 &lt;code>kotlinOptions { jvmTarget = '1.8' }&lt;/code>），但&lt;strong>沒有擴大搜尋其他 plugin&lt;/strong>。這個不完整後來在節點 D 付出代價。&lt;/p>
&lt;h3 id="可選策略">可選策略&lt;/h3>
&lt;h4 id="a1-等-plugin-升級">A1. 等 plugin 升級&lt;/h4>
&lt;ul>
&lt;li>優點：零維護；無需理解 Gradle 機制&lt;/li>
&lt;li>缺點：決策權不在自己；無法保證 plugin 作者會修&lt;/li>
&lt;/ul>
&lt;h4 id="a2-從-root-專案強制覆寫">A2. 從 root 專案強制覆寫&lt;/h4>
&lt;ul>
&lt;li>優點：決策權自主；影響範圍可控；不需 fork&lt;/li>
&lt;li>缺點：需要理解 Gradle 生命週期&lt;/li>
&lt;/ul>
&lt;h4 id="a3-fork-plugin-修改">A3. Fork plugin 修改&lt;/h4>
&lt;ul>
&lt;li>優點：覆蓋完整；可修改任何細節&lt;/li>
&lt;li>缺點：持續維護成本；升級需 merge;增加依賴來源複雜度&lt;/li>
&lt;/ul>
&lt;h4 id="a4-降-app-回-jvm-18">A4. 降 &lt;code>:app&lt;/code> 回 JVM 1.8&lt;/h4>
&lt;ul>
&lt;li>優點：不需額外配置&lt;/li>
&lt;li>缺點：放棄 Java 17 語言特性；跟 AGP 方向相反&lt;/li>
&lt;/ul>
&lt;h3 id="選擇與理由">選擇與理由&lt;/h3>
&lt;p>&lt;strong>A2&lt;/strong>。A1 放棄決策權；A3 維護成本跟 plugin 重要性不成比例；A4 機會成本太高。&lt;/p></description><content:encoded><![CDATA[<h2 id="為什麼寫這篇">為什麼寫這篇</h2>
<p>排查 Gradle JVM target inconsistency 時走了七個節點才收斂。這篇復盤每個節點的完整決策流：</p>
<hr>
<h2 id="節點-a第一次錯誤出現">節點 A：第一次錯誤出現</h2>
<h3 id="當下看到">當下看到</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Execution failed for task &#39;:flutter_broadcasts_4m:compileDebugKotlin&#39;.
</span></span><span class="line"><span class="ln">2</span><span class="cl">&gt; ⛔ Inconsistent JVM Target Compatibility Between Java and Kotlin Tasks
</span></span><span class="line"><span class="ln">3</span><span class="cl">  detected for tasks &#39;compileDebugJavaWithJavac&#39; (17)
</span></span><span class="line"><span class="ln">4</span><span class="cl">  and &#39;compileDebugKotlin&#39; (1.8).</span></span></code></pre></div><h3 id="判讀">判讀</h3>
<p><strong>這類錯誤在系統中代表什麼</strong>（商業邏輯）：</p>
<p>Android 專案的每個 module（主 app 或第三方 plugin）會分別編譯 Java 跟 Kotlin 原始碼，各自產出 JVM bytecode。每個 bytecode 檔案有一個「target version」，決定它能在多舊的 JVM runtime 上執行，以及可以使用哪些語言特性。</p>
<p>同一個 module 內的 Java 跟 Kotlin 若產出不同 target 的 bytecode，執行時可能觸發 API 相容性問題（例如 Java 17 的 class 呼叫到 Kotlin 1.8 runtime 不存在的方法）。Kotlin 2.2 把這個原本只是 warning 的情境提升為 strict error，直接中止 build。</p>
<p>所以 <code>Inconsistent JVM Target Compatibility</code> 這類錯誤的本質是：<strong>某個 module 裡面 Java 跟 Kotlin 編譯產出的 bytecode 不是同一個版本</strong>。</p>
<p><strong>這次訊息具體說了什麼</strong>（CASE）：</p>
<ul>
<li>錯誤 task 前綴 <code>:flutter_broadcasts_4m</code> → 出問題的 module 是這個第三方 plugin</li>
<li><code>compileDebugJavaWithJavac (17)</code> → 這個 module 的 Java 編譯產出 bytecode target = 17</li>
<li><code>compileDebugKotlin (1.8)</code> → 這個 module 的 Kotlin 編譯產出 bytecode target = 1.8</li>
<li>17 跟 1.8 不同 → 符合上面「module 內不一致」的 pattern</li>
</ul>
<p><strong>從 CASE 推論的事</strong>：</p>
<ul>
<li>主專案 <code>:app</code> 已設定 JVM 17，這個 plugin 的 Java 繼承到 17；但 Kotlin 被某處明確設成 1.8</li>
<li>Kotlin plugin 的預設值會跟 Java 對齊，所以 1.8 是「有人明確寫了」，不是預設</li>
<li>最有可能的「有人」是 plugin 自己的 <code>build.gradle</code></li>
</ul>
<p><strong>需要進一步確認才能完整判讀的</strong>：</p>
<ul>
<li>Kotlin 1.8 具體寫在哪？<code>cat ~/.pub-cache/hosted/pub.dev/flutter_broadcasts_4m-*/android/build.gradle</code> 可以驗證</li>
<li>其他 plugin 有沒有同類寫死？這不影響當前這個錯誤的修復，但影響<strong>修復範圍</strong>的完整性</li>
</ul>
<p><strong>判讀後的問題類別</strong>：</p>
<ul>
<li>類別：第三方 plugin 內部寫死 JVM target</li>
<li>主專案的 override 機制沒能覆蓋到 plugin 的內部設定</li>
</ul>
<p><strong>這次判讀的完整度</strong>：驗證了 plugin 內部寫死（確認過 <code>kotlinOptions { jvmTarget = '1.8' }</code>），但<strong>沒有擴大搜尋其他 plugin</strong>。這個不完整後來在節點 D 付出代價。</p>
<h3 id="可選策略">可選策略</h3>
<h4 id="a1-等-plugin-升級">A1. 等 plugin 升級</h4>
<ul>
<li>優點：零維護；無需理解 Gradle 機制</li>
<li>缺點：決策權不在自己；無法保證 plugin 作者會修</li>
</ul>
<h4 id="a2-從-root-專案強制覆寫">A2. 從 root 專案強制覆寫</h4>
<ul>
<li>優點：決策權自主；影響範圍可控；不需 fork</li>
<li>缺點：需要理解 Gradle 生命週期</li>
</ul>
<h4 id="a3-fork-plugin-修改">A3. Fork plugin 修改</h4>
<ul>
<li>優點：覆蓋完整；可修改任何細節</li>
<li>缺點：持續維護成本；升級需 merge;增加依賴來源複雜度</li>
</ul>
<h4 id="a4-降-app-回-jvm-18">A4. 降 <code>:app</code> 回 JVM 1.8</h4>
<ul>
<li>優點：不需額外配置</li>
<li>缺點：放棄 Java 17 語言特性；跟 AGP 方向相反</li>
</ul>
<h3 id="選擇與理由">選擇與理由</h3>
<p><strong>A2</strong>。A1 放棄決策權；A3 維護成本跟 plugin 重要性不成比例；A4 機會成本太高。</p>
<h3 id="修正動作">修正動作</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-groovy" data-lang="groovy"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">subprojects</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="n">plugins</span><span class="o">.</span><span class="na">withId</span><span class="o">(</span><span class="s2">&#34;com.android.library&#34;</span><span class="o">)</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="n">android</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">            <span class="n">compileOptions</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">                <span class="n">sourceCompatibility</span> <span class="o">=</span> <span class="n">JavaVersion</span><span class="o">.</span><span class="na">VERSION_17</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">                <span class="n">targetCompatibility</span> <span class="o">=</span> <span class="n">JavaVersion</span><span class="o">.</span><span class="na">VERSION_17</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="o">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="o">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="o">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="n">tasks</span><span class="o">.</span><span class="na">withType</span><span class="o">(</span><span class="n">org</span><span class="o">.</span><span class="na">jetbrains</span><span class="o">.</span><span class="na">kotlin</span><span class="o">.</span><span class="na">gradle</span><span class="o">.</span><span class="na">tasks</span><span class="o">.</span><span class="na">KotlinCompile</span><span class="o">).</span><span class="na">configureEach</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="n">kotlinOptions</span> <span class="o">{</span> <span class="n">jvmTarget</span> <span class="o">=</span> <span class="s1">&#39;17&#39;</span> <span class="o">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="o">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="o">}</span></span></span></code></pre></div><h3 id="結果">結果</h3>
<p><code>flutter_broadcasts_4m</code> 過了。</p>
<h3 id="事後檢視">事後檢視</h3>
<p>判讀階段明確知道「需要進一步確認其他 plugin 是否有同類問題」，但沒做。當下沒做的理由是「目前錯誤訊息只指向這一個 plugin」，這個理由把判讀完整性降到最低——<strong>修復只要能讓當前這次 build 過就好</strong>。</p>
<p>若判讀時把「範圍完整性」當成跟「修復正確性」同等的維度：</p>
<ul>
<li>會額外做一次 <code>grep -r &quot;jvmTarget&quot; ~/.pub-cache/hosted/pub.dev/*/android/build.gradle | grep &quot;1.8&quot;</code></li>
<li>會得到一份完整的有同類問題的 plugin 清單</li>
<li>修復策略 A2 就會涵蓋整份清單，不只當前一個</li>
</ul>
<p>這裡不是選錯了策略，是<strong>判讀時把範圍當成「訊息指定的」而非「應該主動探索的」</strong>。</p>
<hr>
<h2 id="節點-b使用者問要不要換-jvm-toolchain">節點 B：使用者問「要不要換 JVM Toolchain」</h2>
<h3 id="當下看到-1">當下看到</h3>
<p>節點 A 修復成功。使用者提出：「既然官方推薦 JVM Toolchain，A2 的 task 級 configureEach 是不是次佳解？」</p>
<h3 id="判讀-1">判讀</h3>
<p>這不是錯誤訊息，是<strong>當前方案跟官方推薦方向的差距</strong>。</p>
<p><strong>這類判斷的商業邏輯</strong>：</p>
<p>Gradle 有兩種層次不同的 JVM 治理機制，判斷「要不要換」之前要先理解它們處理的是不同問題：</p>
<ul>
<li><strong>編譯輸出控制</strong>：決定「編譯出來的 bytecode target 是多少」。影響產出的 <code>.class</code> 檔能在哪個 JVM runtime 上跑，但不管 Gradle 自己用什麼 JDK 執行。</li>
<li><strong>JDK 工具鏈管理</strong>：決定「Gradle 執行編譯器時用哪一版 JDK」。不同 JDK 會影響編譯行為、支援的語言特性、以及一些 bytecode 預設目標。</li>
</ul>
<p>這兩件事可以獨立設定。一個專案可以用 JDK 21 執行 Gradle，但編譯產出 JVM 17 bytecode（為了向下相容）。</p>
<p>所以「要不要換 toolchain」這個問題的本質是：<strong>這兩層治理機制現在各自的解決方式是否對當前需求最佳？</strong></p>
<p><strong>這次的具體選擇空間</strong>（CASE）：</p>
<p>當前方案：<code>tasks.withType(KotlinCompile).configureEach { jvmTarget = '17' }</code> task 級 configureEach</p>
<ul>
<li>處理的問題：編譯輸出控制（bytecode target = 17）</li>
<li>不處理的問題：JDK 工具鏈管理（開發者本機裝什麼 JDK、版本是否一致未控管）</li>
</ul>
<p>Toolchain 方案：<code>kotlin { jvmToolchain(17) }</code> extension 級</p>
<ul>
<li>處理的問題：JDK 工具鏈管理（Gradle 自動下載 JDK 17 執行）</li>
<li>附帶處理：對守規矩的 plugin 也會影響 bytecode target</li>
<li>不處理的問題：硬寫死 <code>jvmTarget = '1.8'</code> 的 plugin（extension 會被 plugin 的 task 設定蓋掉）</li>
</ul>
<p><strong>從 CASE 推論的事</strong>：</p>
<p>這兩個方案<strong>不是替代關係，是不同層次的治理</strong>。task 級覆寫處理「產出」，toolchain 處理「JDK 環境」。兩者可以並存，甚至應該並存。</p>
<p><strong>需要進一步確認</strong>：</p>
<ul>
<li>Toolchain 的 extension 設定是否真會被硬寫死的 plugin 蓋掉？（答案是：會被蓋掉，但節點 B 當下沒驗證）</li>
<li>Toolchain 能在哪些時機點設定？（答案：某些屬性在 plugin apply 的 lazy initializer 時 finalize，此時再設會炸——但這也是節點 B 當下沒驗證）</li>
</ul>
<h3 id="可選策略-1">可選策略</h3>
<h4 id="b1-保持現狀task-級-configureeach">B1. 保持現狀（task 級 configureEach）</h4>
<ul>
<li>優點：已經 work</li>
<li>缺點：偏離官方方向；每位開發者本機 JDK 需自行管理</li>
</ul>
<h4 id="b2-完全換成-toolchain">B2. 完全換成 toolchain</h4>
<ul>
<li>優點：符合官方方向；JDK 自動下載</li>
<li>缺點：無法覆蓋硬寫死 plugin（extension 會被 plugin 的 task 設定蓋）</li>
</ul>
<h4 id="b3-混合toolchain--task-級覆寫">B3. 混合（toolchain + task 級覆寫）</h4>
<ul>
<li>優點：同時享有 toolchain 的 JDK 管理跟 task 級的強制力</li>
<li>缺點：配置面向增加</li>
</ul>
<h3 id="選擇與理由-1">選擇與理由</h3>
<p><strong>B3</strong>。B2 單獨不完整，B1 忽略長期適應性，B3 是功能完整的組合。</p>
<h3 id="結果-1">結果</h3>
<p>Build 炸：<code>languageVersion is final</code>。</p>
<h3 id="事後檢視-1">事後檢視</h3>
<p>判讀階段明確列出了「toolchain 能在哪些時機點設定」這個需要確認的問題，但沒確認就進入策略。<strong>判讀的未完成部分就是節點 C 的失敗來源</strong>。</p>
<p>這次判讀告訴了我們「還缺什麼資訊」，但沒有把「缺的資訊」當成進入下一階段的阻擋條件。若判讀的標準是「所有標示為『需要確認』的事實都要先解答」，節點 C 不會發生。</p>
<p>這一步的本質問題是<strong>把判讀中的不確定性帶入執行階段</strong>。</p>
<hr>
<h2 id="節點-clanguageversion-is-final-錯誤">節點 C：<code>languageVersion is final</code> 錯誤</h2>
<h3 id="當下看到-2">當下看到</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">* Where:
</span></span><span class="line"><span class="ln">2</span><span class="cl">Build file &#39;/Users/mac-eric/project/unipos/android/build.gradle&#39; line: 37
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl">* What went wrong:
</span></span><span class="line"><span class="ln">5</span><span class="cl">&gt; The value for property &#39;languageVersion&#39; is final and cannot be changed any further.</span></span></code></pre></div><h3 id="判讀-2">判讀</h3>
<p><strong>這類錯誤在系統中代表什麼</strong>（商業邏輯）：</p>
<p>Gradle 的許多 configuration 屬性有「生命週期狀態」的概念。一個屬性從建立時可以自由讀寫，但到了某個時機點後會被 <strong>finalize</strong> — 意思是「值從此鎖定，任何後續賦值都會被拒絕」。</p>
<p>Finalize 不是錯誤，是 Gradle 保證 build 可預測性的機制：若某個值已經被使用（被其他 task 讀取、被其他設定依賴），再讓它改變會造成「同一次 build 的上下文裡不同地方看到不同值」的不一致。</p>
<p>觸發 finalize 的時機有很多種，最常見的：</p>
<ul>
<li>其他程式碼讀取了這個屬性</li>
<li>plugin 內部的 lazy initializer 把值固定下來</li>
<li>project evaluation 進入某個階段</li>
</ul>
<p>所以 <code>is final and cannot be changed any further</code> 這類錯誤的本質是：<strong>你現在嘗試賦值的屬性，已經在更早的時機被鎖定了</strong>。問題不在「值本身」，在「賦值的時機」。</p>
<p><strong>這次訊息具體說了什麼</strong>（CASE）：</p>
<ul>
<li>錯誤位置：root <code>build.gradle</code> line 37</li>
<li>line 37 是 <code>kotlin { jvmToolchain(17) }</code> 那行</li>
<li>被鎖定的屬性：<code>languageVersion</code></li>
<li>狀態：已 final，拒絕修改</li>
</ul>
<p><strong>從 CASE 推論的事</strong>：</p>
<ul>
<li><code>jvmToolchain(17)</code> 內部試圖設定多個屬性，其中 <code>languageVersion</code> 已 final</li>
<li>「已 final」表示有更早的動作完成了它的 finalize。可能來源：
<ul>
<li>(a) 某個 plugin 在 apply 階段透過 lazy initializer 把值固定下來</li>
<li>(b) 某個先前的配置（<code>kotlinOptions { }</code> 或類似）把值鎖定</li>
</ul>
</li>
<li>這段在 <code>subprojects {}</code> 內，會對每個 subproject 執行；<strong>可能不是每個 subproject 都觸發</strong>，是某個特定的</li>
</ul>
<p><strong>錯誤訊息沒說但需要推論的</strong>：</p>
<ul>
<li>是<strong>哪個</strong> subproject 觸發？訊息沒指名</li>
<li>為什麼 <code>:app</code> 先前 <code>kotlin { jvmToolchain(17) }</code> 成功，subprojects 內就失敗？</li>
</ul>
<p><strong>判讀後的問題類別</strong>：</p>
<ul>
<li>類別：<strong>時機問題</strong> — 設定 <code>jvmToolchain</code> 的時機晚於某個 plugin 的 <code>languageVersion</code> finalize 時機</li>
<li>對照已 work 的 <code>:app</code>：<code>:app</code> 是在自己的 <code>build.gradle</code> 頂層設 toolchain，時機最早</li>
<li>差異：subprojects 內的 <code>plugins.withId</code> 或 <code>kotlin {}</code> 區塊是 callback，執行時機比 <code>:app</code> 頂層晚</li>
</ul>
<h3 id="可選策略-2">可選策略</h3>
<h4 id="c1-拿掉-subprojects-的-toolchain只留-app">C1. 拿掉 subprojects 的 toolchain，只留 <code>:app</code></h4>
<ul>
<li>優點：<code>:app</code> 的 toolchain 驅動整個 Gradle daemon 的 JDK 環境，子專案繼承；避開 finalize 衝突</li>
<li>缺點：依賴「Gradle daemon 用 global JDK」這個前提</li>
</ul>
<h4 id="c2-改用-afterevaluate-延遲-toolchain-設定">C2. 改用 <code>afterEvaluate</code> 延遲 toolchain 設定</h4>
<ul>
<li>優點：可能繞過 finalize</li>
<li>缺點：afterEvaluate 的時機本身可能更晚，屬性可能更 finalized；且 <code>:app</code> 已 evaluate 的情境會引入另一個問題（未預見）</li>
</ul>
<h4 id="c3-回滾-toolchain完全用-task-級覆寫">C3. 回滾 toolchain，完全用 task 級覆寫</h4>
<ul>
<li>優點：最保守；已驗證 work</li>
<li>缺點：放棄 toolchain 的 JDK 管理；違反節點 B 的初衷</li>
</ul>
<h3 id="選擇與理由-2">選擇與理由</h3>
<p><strong>C1</strong>。判讀中指出「<code>:app</code> 頂層時機最早所以 work」，對應的治理是「只在最早時機點設定」。C1 直接反映這個判讀。</p>
<h3 id="結果-2">結果</h3>
<p><code>flutter_broadcasts_4m</code> 繼續通過，但會遇到下一個 plugin。</p>
<h3 id="事後檢視-2">事後檢視</h3>
<p>C1 選擇正確，但<strong>支持 C1 的關鍵事實（Gradle daemon 使用 global JDK）是節點 C 當下才被建立的</strong>。若節點 B 判讀階段就補上這個事實，B 階段的「B3 設定方式」會直接選「toolchain 只設在 :app」，節點 C 不會發生。</p>
<p>這一步的決策品質問題不在節點 C，在節點 B 的判讀不完整。</p>
<hr>
<h2 id="節點-d第二個-plugin-爆了">節點 D：第二個 plugin 爆了</h2>
<h3 id="當下看到-3">當下看到</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Execution failed for task &#39;:external_display:compileDebugKotlin&#39;.
</span></span><span class="line"><span class="ln">2</span><span class="cl">&gt; detected for tasks &#39;compileDebugJavaWithJavac&#39; (1.8)
</span></span><span class="line"><span class="ln">3</span><span class="cl">  and &#39;compileDebugKotlin&#39; (17).</span></span></code></pre></div><h3 id="判讀-3">判讀</h3>
<p><strong>這類錯誤在系統中代表什麼</strong>（商業邏輯）：</p>
<p>跟節點 A 是同一類錯誤（JVM target 不一致），但要注意<strong>不一致的方向</strong>：「哪一邊高、哪一邊低」決定治理策略。</p>
<p>在覆寫第三方 plugin 的 JVM target 時，每一個 module 有兩個編譯端（Java、Kotlin），每一端都可能被 plugin 寫死或被主專案覆寫。可能的失敗組合是：</p>
<ul>
<li>Java 端被 plugin 拉低，Kotlin 端被主專案拉高 → 要覆寫 Java</li>
<li>Kotlin 端被 plugin 拉低，Java 端被主專案拉高 → 要覆寫 Kotlin</li>
<li>兩端都被 plugin 拉低 → 兩端都要覆寫</li>
</ul>
<p>訊息裡的「低的那端」就是還沒被主專案成功覆寫的那一端，也就是下一步要處理的目標。</p>
<p><strong>這次訊息具體說了什麼</strong>（CASE）：</p>
<ul>
<li>出問題的 module 換了：是 <code>:external_display</code>（不是節點 A 的 <code>:flutter_broadcasts_4m</code>）</li>
<li>方向跟節點 A <strong>相反</strong>：
<ul>
<li>節點 A：Java 17 / Kotlin 1.8（Kotlin 低）</li>
<li>現在：Java 1.8 / Kotlin 17（Java 低）</li>
</ul>
</li>
</ul>
<p><strong>從 CASE 推論的事</strong>：</p>
<ul>
<li>Kotlin 17 表示節點 A 的 <code>KotlinCompile.configureEach { jvmTarget = '17' }</code> 對 <code>:external_display</code> 也生效了 —— 這條 task 級覆寫不限於單一 plugin</li>
<li>Java 1.8 表示節點 A 的 <code>plugins.withId(&quot;com.android.library&quot;) { android { compileOptions = 17 } }</code> <strong>沒對 <code>:external_display</code> 生效</strong></li>
<li>這段覆寫對 <code>:flutter_broadcasts_4m</code> 可能生效（否則 Java 也會是 1.8），也可能是 <code>:flutter_broadcasts_4m</code> 的 Java 本來就是 17 沒被寫死</li>
<li>需要進一步確認 <code>:external_display</code> 的 <code>build.gradle</code>：是不是它自己硬寫了 <code>compileOptions = 1.8</code></li>
</ul>
<p><strong>驗證判讀（實際做了）</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">cat ~/.pub-cache/hosted/pub.dev/external_display-0.4.2+1/android/build.gradle</span></span></code></pre></div><p>確認這個 plugin <strong>兩邊都寫死 1.8</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-groovy" data-lang="groovy"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">compileOptions</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="n">sourceCompatibility</span> <span class="n">JavaVersion</span><span class="o">.</span><span class="na">VERSION_1_8</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="n">targetCompatibility</span> <span class="n">JavaVersion</span><span class="o">.</span><span class="na">VERSION_1_8</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="n">kotlinOptions</span> <span class="o">{</span> <span class="n">jvmTarget</span> <span class="o">=</span> <span class="s1">&#39;1.8&#39;</span> <span class="o">}</span></span></span></code></pre></div><p><strong>需要進一步推論的</strong>：</p>
<ul>
<li>為什麼節點 A 的 <code>plugins.withId { android { compileOptions } }</code> 沒贏過 plugin 的 <code>android { compileOptions = 1.8 }</code>？</li>
<li>猜測：<code>plugins.withId</code> 的 callback 早於 plugin 自己的 <code>android {}</code> 區塊，plugin 後寫所以蓋掉</li>
<li>但這只是猜測，還沒驗證 AGP 的同步機制</li>
</ul>
<p><strong>判讀後的問題類別</strong>：</p>
<ul>
<li>類別：跟節點 A 類似（plugin 寫死），但<strong>覆寫的方向不同</strong>——這次是 Java 端要覆寫</li>
<li>節點 A 的 Kotlin 端有 task 級工具（configureEach）可用</li>
<li>Java 端有沒有對稱的工具？這個判讀<strong>沒有完成</strong></li>
</ul>
<h3 id="可選策略-3">可選策略</h3>
<h4 id="d1-在-taskswithtypejavacompileconfigureeach-設-sourcetarget">D1. 在 <code>tasks.withType(JavaCompile).configureEach</code> 設 source/target</h4>
<ul>
<li>優點：跟節點 A 的 Kotlin 做法結構一致</li>
<li>缺點：假設 AGP 的 JavaCompile 跟 Kotlin plugin 的 KotlinCompile 機制對稱，這個假設沒驗證</li>
</ul>
<h4 id="d2-在-pluginswithid--android--compileoptions---覆寫">D2. 在 <code>plugins.withId { android { compileOptions } }</code> 覆寫</h4>
<ul>
<li>優點：用 extension 而非 task</li>
<li>缺點：這段已經在檔案內且顯然沒生效（plugin 後來的 <code>android {}</code> 蓋掉）</li>
</ul>
<h4 id="d3-用-afterevaluate-改-androidcompileoptions">D3. 用 <code>afterEvaluate</code> 改 <code>android.compileOptions</code></h4>
<ul>
<li>優點：時機晚於 plugin 自己的 <code>android {}</code>，能確實覆蓋</li>
<li>缺點：引入 afterEvaluate 的時序複雜度</li>
</ul>
<h4 id="d4-先查-agp-文件確認-javacompile-是否能用-task-級覆寫">D4. 先查 AGP 文件，確認 JavaCompile 是否能用 task 級覆寫</h4>
<ul>
<li>優點：判讀階段缺失的「Java 端機制」補完，選擇有依據</li>
<li>缺點：查證過程有不確定性</li>
</ul>
<h3 id="選擇與理由-3">選擇與理由</h3>
<p><strong>D1</strong>。理由：跟節點 A 的 Kotlin 做法對稱。</p>
<p><strong>這個選擇的本質問題在判讀階段</strong>。判讀結束時已經留下「Java 端機制未驗證」這個未完成的問題，但策略階段沒把 D4 當成補完判讀的選項，直接用「結構對稱」作為依據跳到 D1。</p>
<h3 id="結果-3">結果</h3>
<p>Build 再爆，<strong>完全一樣的錯誤</strong>。</p>
<h3 id="事後檢視-3">事後檢視</h3>
<p>D1 的失敗根源是<strong>判讀不完整時就進入策略</strong>。這跟節點 B → C 的失敗模式相同：判讀列出了需要確認的事，但沒確認就決定策略。</p>
<p>對稱假設之所以危險，是因為它<strong>用「結構相似」取代了「機制驗證」</strong>。結構相似是判讀層次的現象（訊息結構類似），機制是底層層次的事實（實作者如何設計）。用前者取代後者，判讀就沒有真正進到底層。</p>
<p>當下若把 D4 視為跟 D1 平行的選項，而且讓判讀的未完成問題成為「必須先解」的前提，會直接跳到 D4 → D3 路徑。</p>
<hr>
<h2 id="節點-e決定改用-afterevaluate--extension">節點 E：決定改用 afterEvaluate + extension</h2>
<h3 id="當下看到-4">當下看到</h3>
<p>D1 失敗，確認 AGP 會從 <code>android.compileOptions</code> 同步到 JavaCompile task。要把 Java 端的覆寫改成 extension 級，且要晚於 plugin 自己的 <code>android {}</code>。</p>
<h3 id="判讀-4">判讀</h3>
<p><strong>這類選擇在系統中代表什麼</strong>（商業邏輯）：</p>
<p>Gradle 的 <code>method(Closure)</code> 形式 API（像 <code>afterEvaluate</code>、<code>configure</code>、<code>doLast</code>）都是<strong>兩階段模型</strong>：</p>
<ol>
<li><strong>註冊階段</strong>：呼叫 <code>method(Closure)</code> 時，Gradle 把 closure 記起來，決定「什麼時候執行這個 closure」。這個註冊動作本身會立即執行，若註冊條件不滿足（例如目標物件狀態不對），註冊會直接失敗。</li>
<li><strong>執行階段</strong>：條件觸發時（例如 project evaluate 完成），Gradle 從註冊列表拿出 closure 執行。</li>
</ol>
<p>這兩個階段的失敗模式不同：註冊失敗是呼叫 <code>method</code> 本身拋錯，closure 根本不會執行；執行失敗是 closure 內部拋錯。</p>
<p>所以當我們要對 <code>method(Closure)</code> 形式 API 套用<strong>過濾條件</strong>時，要先問：過濾的對象是誰？</p>
<ul>
<li>若要過濾「延遲執行的內容」 → 條件放 closure 內</li>
<li>若要過濾「註冊動作本身是否該發生」 → 條件放 <code>method</code> 呼叫之前</li>
</ul>
<p>這不是風格偏好，是「過濾發生在不同階段」。</p>
<p><strong>這次的具體選擇空間</strong>（CASE）：</p>
<p>寫法 1：<code>afterEvaluate { if (project.name != 'app') { android { compileOptions } } }</code>
寫法 2：<code>if (project.name != 'app') { afterEvaluate { android { compileOptions } } }</code></p>
<p>表面上兩者「看起來都跳過 <code>:app</code>」。</p>
<p><strong>把商業邏輯套回 CASE 推論</strong>：</p>
<ul>
<li>寫法 1：過濾在 closure 內 → <code>afterEvaluate</code> 本身會對<strong>所有</strong> subproject 呼叫（包括 <code>:app</code>）。若 <code>:app</code> 狀態不滿足註冊條件，註冊階段就失敗</li>
<li>寫法 2：過濾在 <code>afterEvaluate</code> 外 → <code>:app</code> 根本不會觸發註冊呼叫</li>
</ul>
<p>哪種寫法正確，取決於**「註冊階段對 <code>:app</code> 會不會失敗」**。</p>
<p><strong>判讀需要問的關鍵問題</strong>：</p>
<ul>
<li><code>afterEvaluate</code> 的註冊動作會不會失敗？</li>
<li>什麼情況下會失敗？</li>
<li>「project 已 evaluate」是不是其中一種？</li>
<li><code>:app</code> 在當前專案結構下會不會是已 evaluate 狀態？</li>
</ul>
<p><strong>這些問題當下沒問</strong>。判讀停留在「兩種寫法看起來一樣」的表面層次，沒有展開到兩階段模型。</p>
<h3 id="可選策略-4">可選策略</h3>
<h4 id="e1-過濾放-closure-內">E1. 過濾放 closure 內</h4>
<ul>
<li>優點：過濾邏輯跟 closure 放一起；讀起來連貫</li>
<li>缺點：假設 afterEvaluate 方法呼叫不會失敗</li>
</ul>
<h4 id="e2-過濾放-afterevaluate-外">E2. 過濾放 afterEvaluate 外</h4>
<ul>
<li>優點：阻止 afterEvaluate 方法呼叫本身對有問題的 project 觸發</li>
<li>缺點：兩層 if 需要額外理解</li>
</ul>
<h4 id="e3-用-projectstateexecuted-判斷">E3. 用 <code>project.state.executed</code> 判斷</h4>
<ul>
<li>優點：通用解法，不 hardcode 名字</li>
<li>缺點：對這個情境過度設計</li>
</ul>
<h3 id="選擇與理由-4">選擇與理由</h3>
<p><strong>E1</strong>。理由：讀起來連貫。</p>
<p><strong>這個選擇的本質問題</strong>：判讀沒展開「方法呼叫 vs closure 執行」的兩階段，所以權衡時用「可讀性」這個表面維度決定，沒有觸及「哪個寫法能阻止失敗」這個底層維度。</p>
<h3 id="結果-4">結果</h3>
<p>Build 炸：<code>Cannot run Project.afterEvaluate(Closure) when the project is already evaluated.</code></p>
<h3 id="事後檢視-4">事後檢視</h3>
<p>E1 vs E2 的真正差異不是「哪個好讀」，是<strong>過濾哪一個執行階段</strong>：</p>
<ul>
<li>E1 過濾延遲執行的 closure 內容</li>
<li>E2 過濾方法呼叫本身</li>
</ul>
<p>判讀若展開到這個層次，權衡就會變成：「我要過濾的是哪一個階段？」——而這題有明確答案（<code>:app</code> 的失敗發生在方法呼叫階段），所以 E2 是唯一正確選項。</p>
<p>判讀不到這個層次 → 兩個選項在決策者眼中「等價」→ 用次要維度（可讀性）決定。</p>
<hr>
<h2 id="節點-fcannot-run-afterevaluate-when-already-evaluated">節點 F：<code>Cannot run afterEvaluate when already evaluated</code></h2>
<h3 id="當下看到-5">當下看到</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Cannot run Project.afterEvaluate(Closure) when the project is already evaluated.</span></span></code></pre></div><h3 id="判讀-5">判讀</h3>
<p><strong>這類錯誤在系統中代表什麼</strong>（商業邏輯）：</p>
<p>Gradle 的 project 有生命週期：建立 → 配置中 → <strong>evaluate 完成</strong> → 執行 task。一旦 project 走到「evaluate 完成」狀態，有些動作就再也做不了，因為它們的意義依賴於「evaluate 還沒結束」這個前提。</p>
<p><code>afterEvaluate</code> 是一種「訂閱 evaluate 完成事件」的 API：註冊一個 closure，Gradle 承諾在該 project evaluate 完成時呼叫它。</p>
<p>但如果 project <strong>已經</strong> evaluate 完成，這個承諾無法兌現 — 「evaluate 完成」這個事件已經發生過了，不會再發生第二次。此時再註冊訂閱沒有意義，Gradle 直接拋錯。</p>
<p>所以 <code>Cannot run Project.afterEvaluate(Closure) when the project is already evaluated</code> 這類錯誤的本質是：<strong>想訂閱一個已經發生過的事件</strong>。</p>
<p><strong>這次訊息具體說了什麼</strong>（CASE）：</p>
<ul>
<li><code>afterEvaluate(Closure)</code> 這個方法呼叫失敗</li>
<li>失敗原因：目標 project 已經 evaluate 完</li>
<li>位置：root <code>build.gradle</code> line 52（<code>afterEvaluate</code> 那行）</li>
</ul>
<p><strong>從 CASE 推論的事</strong>：</p>
<ul>
<li>「已 evaluate 完的 project」具體是哪個？訊息沒指名，但從上下文推論：</li>
<li>回頭看 root <code>build.gradle</code> 上半部有 <code>subprojects { project.evaluationDependsOn(&quot;:app&quot;) }</code></li>
<li>這行強制 <code>:app</code> 比其他 subproject 先 evaluate</li>
<li>當 <code>subprojects {}</code> 的區塊處理到 <code>:app</code> 時，<code>:app</code> 的 evaluate 已完成 → 對它呼叫 <code>afterEvaluate</code> 失敗</li>
</ul>
<p><strong>完整推論鏈</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">subprojects {} 執行 → 對 :app 呼叫 afterEvaluate(Closure)
</span></span><span class="line"><span class="ln">2</span><span class="cl">→ :app 已 evaluate（因 evaluationDependsOn）→ 訂閱失敗</span></span></code></pre></div><p><strong>判讀後的問題類別</strong>：</p>
<ul>
<li>類別：訂閱了一個已發生的事件（註冊時機晚於事件觸發）</li>
<li>解決方向：阻止註冊動作對該對象觸發</li>
</ul>
<h3 id="可選策略-5">可選策略</h3>
<h4 id="f1-把-projectname--app-提前到-afterevaluate-外">F1. 把 <code>project.name != 'app'</code> 提前到 afterEvaluate 外</h4>
<ul>
<li>優點：直接阻止方法呼叫對 <code>:app</code> 觸發</li>
<li>缺點：hardcode 名字；若 <code>:app</code> 改名需修</li>
</ul>
<h4 id="f2-用-projectstateexecuted-條件">F2. 用 <code>project.state.executed</code> 條件</h4>
<ul>
<li>優點：通用，不依賴名字</li>
<li>缺點：過度設計；<code>:app</code> 本來就不需要 subprojects 邏輯管</li>
</ul>
<h4 id="f3-trycatch-吞掉註冊失敗">F3. <code>try/catch</code> 吞掉註冊失敗</h4>
<ul>
<li>優點：程式碼最少</li>
<li>缺點：anti-pattern，隱藏失敗</li>
</ul>
<h3 id="選擇與理由-5">選擇與理由</h3>
<p><strong>F1</strong>。F3 是反模式；F2 的通用性在此情境無實際收益。</p>
<h3 id="結果-5">結果</h3>
<p>Build 成功。</p>
<h3 id="事後檢視-5">事後檢視</h3>
<p>F1 選擇正確。但這個節點若在 E 階段判讀「方法呼叫 vs closure 執行」兩階段時就識別出來，<strong>F 節點本來不會存在</strong>。F 是 E 判讀不完整的延伸結果。</p>
<hr>
<h2 id="節點-g最終修復">節點 G：最終修復</h2>
<ul>
<li><code>:app/build.gradle</code>：<code>kotlin { jvmToolchain(17) }</code></li>
<li><code>android/settings.gradle</code>：Foojay plugin</li>
<li><code>android/build.gradle</code> subprojects：
<ul>
<li>Java 端 <code>afterEvaluate</code> 改 <code>android.compileOptions</code>（跳過 <code>:app</code>）</li>
<li>Kotlin 端 <code>KotlinCompile.configureEach</code></li>
</ul>
</li>
</ul>
<hr>
<h2 id="把判讀當成獨立階段的意義">把「判讀」當成獨立階段的意義</h2>
<p>回看七個節點中四個失敗節點的<strong>失敗來源</strong>：</p>
<table>
  <thead>
      <tr>
          <th>節點</th>
          <th>失敗類別</th>
          <th>根本來源</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>節點 C</td>
          <td>需要新資訊（toolchain 時機）</td>
          <td>節點 B 判讀留下「需要確認」但沒補</td>
      </tr>
      <tr>
          <td>節點 D1</td>
          <td>對稱假設</td>
          <td>節點 D 判讀用「結構對稱」取代「機制驗證」</td>
      </tr>
      <tr>
          <td>節點 F</td>
          <td>方法呼叫時機</td>
          <td>節點 E 判讀沒展開 API 的兩階段行為</td>
      </tr>
  </tbody>
</table>
<p><strong>三個失敗都源自判讀未完成</strong>。不是策略選錯，是策略階段進入時，判讀本身還帶著未解決的問題。</p>
<p>如果把判讀當成獨立階段，並且<strong>要求判讀階段的所有「需確認」項目在進入策略前都被解答</strong>，這三個失敗都可以避免。</p>
<h3 id="判讀完成的標準">判讀完成的標準</h3>
<p>一個合理的判讀完成標準：</p>
<ol>
<li><strong>字面事實都列出來</strong>：訊息裡出現的 task、file、line、屬性名都提取</li>
<li><strong>推論標示</strong>：哪些是從字面事實推論出來的（而非訊息直接寫的）</li>
<li><strong>未確認的問題列清單</strong>：判讀過程中發現「需要進一步確認」的問題，不迴避</li>
<li><strong>未確認的問題在進入策略前解答</strong>：或明確決定「這個問題可以先忽略，理由是&hellip;」</li>
</ol>
<p>多數失敗不是在策略階段「選錯」，是在判讀跟策略之間<strong>帶著未解問題跨界</strong>。</p>
<hr>
<h2 id="整個過程的決策品質檢視">整個過程的決策品質檢視</h2>
<h3 id="七個節點四次失敗的分類">七個節點四次失敗的分類</h3>
<p><strong>判讀未完成延伸類（三個）</strong>：</p>
<ul>
<li>節點 C（來自 B 的判讀）</li>
<li>節點 D1（來自 D 的判讀）</li>
<li>節點 F（來自 E 的判讀）</li>
</ul>
<p><strong>策略階段發現需要新資訊類（零個）</strong>：</p>
<ul>
<li>所有失敗都可追溯到判讀階段已知的未解問題</li>
</ul>
<p><strong>偶然類（零個）</strong>：</p>
<ul>
<li>本次沒有真正「不可預見」的失敗</li>
</ul>
<h3 id="可複用的三個原則">可複用的三個原則</h3>
<h4 id="原則-1觀察--判讀--策略--執行-是四個獨立階段">原則 1：觀察 → 判讀 → 策略 → 執行 是四個獨立階段</h4>
<p>每個階段的目的不同：</p>
<ul>
<li>觀察：把訊息讀清楚</li>
<li>判讀：從訊息推出問題本質，列出所有已知、已推論、未確認的事實</li>
<li>策略：基於判讀推導選項並權衡</li>
<li>執行：實際動作</li>
</ul>
<p>跳過判讀 → 策略基於不完整資訊；跳過策略 → 執行是直覺反應。</p>
<h4 id="原則-2判讀階段的未解問題是進入策略的阻擋條件">原則 2：判讀階段的未解問題是進入策略的阻擋條件</h4>
<p>判讀中標示「需要確認」的問題，要麼在進入策略前補完，要麼明確決定「可以忽略，理由是&hellip;」。不能帶著未解問題進策略。</p>
<h4 id="原則-3單點成功後擴大觀察範圍">原則 3：單點成功後擴大觀察範圍</h4>
<p>每個節點結束後，判讀應擴展：「還有哪些地方可能有同類問題？」當前修復是否涵蓋全部，還是只涵蓋當前這一個？</p>
<hr>
<h2 id="整體節點地圖">整體節點地圖</h2>





<pre tabindex="0"><code class="language-mermaid" data-lang="mermaid">flowchart TD
    A[節點 A: flutter_broadcasts_4m 1.8] --&gt;|task 級覆寫| B[節點 B: 換 toolchain?]
    B --&gt;|subprojects 套 toolchain| C[節點 C: languageVersion final]
    C --&gt;|只 :app toolchain| D[節點 D: external_display Java 1.8]
    D --&gt;|對稱 task 級 JavaCompile| D1[仍失敗]
    D1 --&gt;|換 afterEvaluate extension| E[節點 E: closure 內過濾 :app]
    E --&gt;|afterEvaluate 炸 :app| F[節點 F: already evaluated]
    F --&gt;|把過濾提前| G[節點 G: 成功]

    style A fill:#e0f0ff
    style G fill:#d0ffd0
    style C fill:#ffe0e0
    style D1 fill:#ffe0e0
    style F fill:#ffe0e0</code></pre><p>三個紅色失敗節點的共同特徵：<strong>前一節點的判讀留下「需要確認」但沒確認就進策略</strong>。決策品質的提升點不在策略選擇，在判讀的完整度與「未解問題不跨界進策略」的紀律。</p>
]]></content:encoded></item><item><title>Gradle 強制覆寫 plugin 的 JVM target：Kotlin 與 Java 的切入點不對稱</title><link>https://tarrragon.github.io/blog/work-log/gradle-%E5%BC%B7%E5%88%B6%E8%A6%86%E5%AF%AB-plugin-%E7%9A%84-jvm-targetkotlin-%E8%88%87-java-%E7%9A%84%E5%88%87%E5%85%A5%E9%BB%9E%E4%B8%8D%E5%B0%8D%E7%A8%B1/</link><pubDate>Fri, 17 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/gradle-%E5%BC%B7%E5%88%B6%E8%A6%86%E5%AF%AB-plugin-%E7%9A%84-jvm-targetkotlin-%E8%88%87-java-%E7%9A%84%E5%88%87%E5%85%A5%E9%BB%9E%E4%B8%8D%E5%B0%8D%E7%A8%B1/</guid><description>&lt;h2 id="問題情境">問題情境&lt;/h2>
&lt;p>Android Flutter 專案升到 Kotlin 2.2 + AGP 8.12 後，build 時出現：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">Execution failed for task &amp;#39;:external_display:compileDebugKotlin&amp;#39;.
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&amp;gt; ⛔ Inconsistent JVM Target Compatibility Between Java and Kotlin Tasks
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> Inconsistent JVM-target compatibility detected for tasks
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &amp;#39;compileDebugJavaWithJavac&amp;#39; (1.8) and &amp;#39;compileDebugKotlin&amp;#39; (17).&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>主專案 &lt;code>:app&lt;/code> 已經設定 JVM 17，但第三方 plugin（例如 &lt;code>external_display&lt;/code>）的 &lt;code>build.gradle&lt;/code> 硬寫死 JVM 1.8。想從主專案這邊強制覆寫，卻發現 Kotlin 用一種寫法能贏、Java 用同樣的寫法卻會輸。&lt;/p>
&lt;hr>
&lt;h2 id="kotlin-與-java-的覆寫結果不一樣">Kotlin 與 Java 的覆寫結果不一樣&lt;/h2>
&lt;h3 id="kotlin-端task-級-configureeach-能贏">Kotlin 端：task 級 configureEach 能贏&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-groovy" data-lang="groovy">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="n">subprojects&lt;/span> &lt;span class="o">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="n">tasks&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="na">withType&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="n">org&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="na">jetbrains&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="na">kotlin&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="na">gradle&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="na">tasks&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="na">KotlinCompile&lt;/span>&lt;span class="o">).&lt;/span>&lt;span class="na">configureEach&lt;/span> &lt;span class="o">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="n">kotlinOptions&lt;/span> &lt;span class="o">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="n">jvmTarget&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s1">&amp;#39;17&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="o">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="o">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="o">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>即使 plugin 的 build.gradle 寫了 &lt;code>kotlinOptions { jvmTarget = '1.8' }&lt;/code>，這段覆寫仍然會贏。&lt;/p>
&lt;h3 id="java-端task-級-configureeach-會被蓋回去">Java 端：task 級 configureEach 會被蓋回去&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-groovy" data-lang="groovy">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="n">subprojects&lt;/span> &lt;span class="o">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="n">tasks&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="na">withType&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="n">JavaCompile&lt;/span>&lt;span class="o">).&lt;/span>&lt;span class="na">configureEach&lt;/span> &lt;span class="o">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="n">sourceCompatibility&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s1">&amp;#39;17&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="n">targetCompatibility&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s1">&amp;#39;17&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="o">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="o">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段看起來跟 Kotlin 端對稱，但沒用 —— task 上的賦值會被 AGP 從 &lt;code>android.compileOptions&lt;/code> 再同步回來，重新變成 1.8。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼不對稱兩個-plugin-的內部機制不同">為什麼不對稱：兩個 plugin 的內部機制不同&lt;/h2>
&lt;h3 id="kotlin-pluginextension--task-單向流動">Kotlin Plugin：extension → task 單向流動&lt;/h3>
&lt;p>Kotlin plugin 讀取 &lt;code>kotlin {}&lt;/code> 或 &lt;code>kotlinOptions {}&lt;/code> extension 的值，寫入對應的 &lt;code>KotlinCompile&lt;/code> task。&lt;strong>寫入一次，之後不再同步&lt;/strong>。&lt;/p>





&lt;pre tabindex="0">&lt;code class="language-mermaid" data-lang="mermaid">flowchart LR
 E[kotlin extension] --&amp;gt;|一次性寫入| T[KotlinCompile task]
 C[configureEach] --&amp;gt;|後寫的贏| T&lt;/code>&lt;/pre>&lt;p>這就是為什麼 &lt;code>configureEach&lt;/code> 能贏 —— 它註冊的 configuration action 在 task realization 時才套用，比 plugin 的 extension 寫入更晚。&lt;/p>
&lt;h3 id="agpextension--task-雙向同步">AGP：extension ↔ task 雙向同步&lt;/h3>
&lt;p>AGP 把 &lt;code>android.compileOptions.sourceCompatibility&lt;/code> 視為&lt;strong>真相來源&lt;/strong>，每次 JavaCompile task 被 realize 或 configure 時，都會從 extension 重新同步過去。&lt;/p>





&lt;pre tabindex="0">&lt;code class="language-mermaid" data-lang="mermaid">flowchart LR
 E[android.compileOptions] &amp;lt;--&amp;gt;|持續同步| T[JavaCompile task]
 C[configureEach] -.-&amp;gt;|被 AGP 蓋回去| T&lt;/code>&lt;/pre>&lt;p>在 task 上直接賦值沒用 —— AGP 會用 extension 的值把你蓋掉。真正有效的治理點是 &lt;strong>extension 本身&lt;/strong>。&lt;/p>
&lt;hr>
&lt;h2 id="正確解法切入點依-plugin-機制決定">正確解法：切入點依 plugin 機制決定&lt;/h2>
&lt;h3 id="kotlin鎖-task">Kotlin：鎖 task&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-groovy" data-lang="groovy">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="n">tasks&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="na">withType&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="n">org&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="na">jetbrains&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="na">kotlin&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="na">gradle&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="na">tasks&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="na">KotlinCompile&lt;/span>&lt;span class="o">).&lt;/span>&lt;span class="na">configureEach&lt;/span> &lt;span class="o">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="n">kotlinOptions&lt;/span> &lt;span class="o">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="n">jvmTarget&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s1">&amp;#39;17&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="o">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="o">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="java鎖-extension而且要在-afterevaluate-時機">Java：鎖 extension，而且要在 &lt;code>afterEvaluate&lt;/code> 時機&lt;/h3>
&lt;p>直接在 &lt;code>subprojects {}&lt;/code> 最外層寫 &lt;code>plugins.withId(&amp;quot;com.android.library&amp;quot;) { android { compileOptions {...} } }&lt;/code> &lt;strong>也沒用&lt;/strong>：這個 callback 在 plugin 被 apply 時立刻觸發，早於 plugin 自己的 build.gradle 執行，會被 plugin 後來的 &lt;code>android { compileOptions = 1.8 }&lt;/code> 蓋回去。&lt;/p></description><content:encoded><![CDATA[<h2 id="問題情境">問題情境</h2>
<p>Android Flutter 專案升到 Kotlin 2.2 + AGP 8.12 後，build 時出現：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Execution failed for task &#39;:external_display:compileDebugKotlin&#39;.
</span></span><span class="line"><span class="ln">2</span><span class="cl">&gt; ⛔ Inconsistent JVM Target Compatibility Between Java and Kotlin Tasks
</span></span><span class="line"><span class="ln">3</span><span class="cl">  Inconsistent JVM-target compatibility detected for tasks
</span></span><span class="line"><span class="ln">4</span><span class="cl">  &#39;compileDebugJavaWithJavac&#39; (1.8) and &#39;compileDebugKotlin&#39; (17).</span></span></code></pre></div><p>主專案 <code>:app</code> 已經設定 JVM 17，但第三方 plugin（例如 <code>external_display</code>）的 <code>build.gradle</code> 硬寫死 JVM 1.8。想從主專案這邊強制覆寫，卻發現 Kotlin 用一種寫法能贏、Java 用同樣的寫法卻會輸。</p>
<hr>
<h2 id="kotlin-與-java-的覆寫結果不一樣">Kotlin 與 Java 的覆寫結果不一樣</h2>
<h3 id="kotlin-端task-級-configureeach-能贏">Kotlin 端：task 級 configureEach 能贏</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-groovy" data-lang="groovy"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">subprojects</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="n">tasks</span><span class="o">.</span><span class="na">withType</span><span class="o">(</span><span class="n">org</span><span class="o">.</span><span class="na">jetbrains</span><span class="o">.</span><span class="na">kotlin</span><span class="o">.</span><span class="na">gradle</span><span class="o">.</span><span class="na">tasks</span><span class="o">.</span><span class="na">KotlinCompile</span><span class="o">).</span><span class="na">configureEach</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="n">kotlinOptions</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">            <span class="n">jvmTarget</span> <span class="o">=</span> <span class="s1">&#39;17&#39;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="o">}</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="o">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="o">}</span></span></span></code></pre></div><p>即使 plugin 的 build.gradle 寫了 <code>kotlinOptions { jvmTarget = '1.8' }</code>，這段覆寫仍然會贏。</p>
<h3 id="java-端task-級-configureeach-會被蓋回去">Java 端：task 級 configureEach 會被蓋回去</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-groovy" data-lang="groovy"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">subprojects</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="n">tasks</span><span class="o">.</span><span class="na">withType</span><span class="o">(</span><span class="n">JavaCompile</span><span class="o">).</span><span class="na">configureEach</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="n">sourceCompatibility</span> <span class="o">=</span> <span class="s1">&#39;17&#39;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="n">targetCompatibility</span> <span class="o">=</span> <span class="s1">&#39;17&#39;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="o">}</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="o">}</span></span></span></code></pre></div><p>這段看起來跟 Kotlin 端對稱，但沒用 —— task 上的賦值會被 AGP 從 <code>android.compileOptions</code> 再同步回來，重新變成 1.8。</p>
<hr>
<h2 id="為什麼不對稱兩個-plugin-的內部機制不同">為什麼不對稱：兩個 plugin 的內部機制不同</h2>
<h3 id="kotlin-pluginextension--task-單向流動">Kotlin Plugin：extension → task 單向流動</h3>
<p>Kotlin plugin 讀取 <code>kotlin {}</code> 或 <code>kotlinOptions {}</code> extension 的值，寫入對應的 <code>KotlinCompile</code> task。<strong>寫入一次，之後不再同步</strong>。</p>





<pre tabindex="0"><code class="language-mermaid" data-lang="mermaid">flowchart LR
    E[kotlin extension] --&gt;|一次性寫入| T[KotlinCompile task]
    C[configureEach] --&gt;|後寫的贏| T</code></pre><p>這就是為什麼 <code>configureEach</code> 能贏 —— 它註冊的 configuration action 在 task realization 時才套用，比 plugin 的 extension 寫入更晚。</p>
<h3 id="agpextension--task-雙向同步">AGP：extension ↔ task 雙向同步</h3>
<p>AGP 把 <code>android.compileOptions.sourceCompatibility</code> 視為<strong>真相來源</strong>，每次 JavaCompile task 被 realize 或 configure 時，都會從 extension 重新同步過去。</p>





<pre tabindex="0"><code class="language-mermaid" data-lang="mermaid">flowchart LR
    E[android.compileOptions] &lt;--&gt;|持續同步| T[JavaCompile task]
    C[configureEach] -.-&gt;|被 AGP 蓋回去| T</code></pre><p>在 task 上直接賦值沒用 —— AGP 會用 extension 的值把你蓋掉。真正有效的治理點是 <strong>extension 本身</strong>。</p>
<hr>
<h2 id="正確解法切入點依-plugin-機制決定">正確解法：切入點依 plugin 機制決定</h2>
<h3 id="kotlin鎖-task">Kotlin：鎖 task</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-groovy" data-lang="groovy"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">tasks</span><span class="o">.</span><span class="na">withType</span><span class="o">(</span><span class="n">org</span><span class="o">.</span><span class="na">jetbrains</span><span class="o">.</span><span class="na">kotlin</span><span class="o">.</span><span class="na">gradle</span><span class="o">.</span><span class="na">tasks</span><span class="o">.</span><span class="na">KotlinCompile</span><span class="o">).</span><span class="na">configureEach</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="n">kotlinOptions</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="n">jvmTarget</span> <span class="o">=</span> <span class="s1">&#39;17&#39;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="o">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="o">}</span></span></span></code></pre></div><h3 id="java鎖-extension而且要在-afterevaluate-時機">Java：鎖 extension，而且要在 <code>afterEvaluate</code> 時機</h3>
<p>直接在 <code>subprojects {}</code> 最外層寫 <code>plugins.withId(&quot;com.android.library&quot;) { android { compileOptions {...} } }</code> <strong>也沒用</strong>：這個 callback 在 plugin 被 apply 時立刻觸發，早於 plugin 自己的 build.gradle 執行，會被 plugin 後來的 <code>android { compileOptions = 1.8 }</code> 蓋回去。</p>
<p>必須等 plugin 自己的 <code>android {}</code> 執行完之後再改，也就是 <code>afterEvaluate</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-groovy" data-lang="groovy"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">subprojects</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">if</span> <span class="o">(</span><span class="n">project</span><span class="o">.</span><span class="na">name</span> <span class="o">!=</span> <span class="s1">&#39;app&#39;</span><span class="o">)</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="n">afterEvaluate</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">            <span class="k">if</span> <span class="o">(</span><span class="n">project</span><span class="o">.</span><span class="na">hasProperty</span><span class="o">(</span><span class="s1">&#39;android&#39;</span><span class="o">))</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">                <span class="n">android</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">                    <span class="n">compileOptions</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">                        <span class="n">sourceCompatibility</span> <span class="o">=</span> <span class="n">JavaVersion</span><span class="o">.</span><span class="na">VERSION_17</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">                        <span class="n">targetCompatibility</span> <span class="o">=</span> <span class="n">JavaVersion</span><span class="o">.</span><span class="na">VERSION_17</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">                    <span class="o">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">                <span class="o">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="o">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="o">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="o">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="o">}</span></span></span></code></pre></div><hr>
<h2 id="診斷流程">診斷流程</h2>
<p>遇到 JVM target inconsistency 錯誤時，照以下步驟推論：</p>
<h3 id="步驟-1看錯誤訊息指的是哪個-task">步驟 1：看錯誤訊息指的是哪個 task</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Inconsistent JVM-target compatibility detected for tasks
</span></span><span class="line"><span class="ln">2</span><span class="cl">&#39;compileDebugJavaWithJavac&#39; (1.8) and &#39;compileDebugKotlin&#39; (17).</span></span></code></pre></div><ul>
<li><code>compileDebugJavaWithJavac</code> 是 Java 端的 task</li>
<li><code>compileDebugKotlin</code> 是 Kotlin 端的 task</li>
<li>括號內的數字就是各自的 target</li>
</ul>
<h3 id="步驟-2看哪一端低哪一端高">步驟 2：看哪一端低、哪一端高</h3>
<ul>
<li><strong>低的那端被 plugin 硬寫死了</strong></li>
<li><strong>高的那端是主專案設定已經生效的</strong></li>
</ul>
<p>這一步決定要覆寫哪一端。</p>
<h3 id="步驟-3看是哪個-plugin-引起的">步驟 3：看是哪個 plugin 引起的</h3>
<p>從錯誤訊息的 task 前綴 <code>:external_display:compileDebugKotlin</code> 找到是 <code>external_display</code> plugin。</p>
<p>查它的 <code>build.gradle</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">find ~/.pub-cache/hosted/ -type d -name <span class="s2">&#34;external_display*&#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">cat ~/.pub-cache/hosted/pub.dev/external_display-0.4.2+1/android/build.gradle</span></span></code></pre></div><p>通常會看到：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-groovy" data-lang="groovy"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">compileOptions</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="n">sourceCompatibility</span> <span class="n">JavaVersion</span><span class="o">.</span><span class="na">VERSION_1_8</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="n">targetCompatibility</span> <span class="n">JavaVersion</span><span class="o">.</span><span class="na">VERSION_1_8</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="n">kotlinOptions</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="n">jvmTarget</span> <span class="o">=</span> <span class="s1">&#39;1.8&#39;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="o">}</span></span></span></code></pre></div><h3 id="步驟-4依-kotlinjava-差異選擇覆寫方式">步驟 4：依 Kotlin/Java 差異選擇覆寫方式</h3>
<ul>
<li>Kotlin 寫死 → 用 <code>KotlinCompile.configureEach</code></li>
<li>Java 寫死 → 用 <code>afterEvaluate</code> 改 <code>android.compileOptions</code></li>
</ul>
<hr>
<h2 id="完整的-root-androidbuildgradle-範例">完整的 root <code>android/build.gradle</code> 範例</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-groovy" data-lang="groovy"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">subprojects</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="c1">// Java 端：在 plugin 的 android {} 執行完後覆寫 compileOptions
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"></span>    <span class="k">if</span> <span class="o">(</span><span class="n">project</span><span class="o">.</span><span class="na">name</span> <span class="o">!=</span> <span class="s1">&#39;app&#39;</span><span class="o">)</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="n">afterEvaluate</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">            <span class="k">if</span> <span class="o">(</span><span class="n">project</span><span class="o">.</span><span class="na">hasProperty</span><span class="o">(</span><span class="s1">&#39;android&#39;</span><span class="o">))</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">                <span class="n">android</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">                    <span class="n">compileOptions</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">                        <span class="n">sourceCompatibility</span> <span class="o">=</span> <span class="n">JavaVersion</span><span class="o">.</span><span class="na">VERSION_17</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">                        <span class="n">targetCompatibility</span> <span class="o">=</span> <span class="n">JavaVersion</span><span class="o">.</span><span class="na">VERSION_17</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">                    <span class="o">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">                <span class="o">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">            <span class="o">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="o">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="o">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="c1">// Kotlin 端：task 級直接覆寫
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="c1"></span>    <span class="n">tasks</span><span class="o">.</span><span class="na">withType</span><span class="o">(</span><span class="n">org</span><span class="o">.</span><span class="na">jetbrains</span><span class="o">.</span><span class="na">kotlin</span><span class="o">.</span><span class="na">gradle</span><span class="o">.</span><span class="na">tasks</span><span class="o">.</span><span class="na">KotlinCompile</span><span class="o">).</span><span class="na">configureEach</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="n">kotlinOptions</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">            <span class="n">jvmTarget</span> <span class="o">=</span> <span class="s1">&#39;17&#39;</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="o">}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="o">}</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="o">}</span></span></span></code></pre></div><p><code>:app</code> 跳過是因為它透過 <code>kotlin { jvmToolchain(17) }</code> 自己處理了（見下節）。</p>
<hr>
<h2 id="延伸為什麼-app-不能用同一套覆寫">延伸：為什麼 <code>:app</code> 不能用同一套覆寫</h2>
<p>若專案的 root <code>build.gradle</code> 裡有：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-groovy" data-lang="groovy"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">subprojects</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="n">project</span><span class="o">.</span><span class="na">evaluationDependsOn</span><span class="o">(</span><span class="s2">&#34;:app&#34;</span><span class="o">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="o">}</span></span></span></code></pre></div><p>這行強制 <code>:app</code> 比所有其他 subproject 先 evaluate。等到 <code>subprojects { afterEvaluate {} }</code> 想註冊到 <code>:app</code> 時，<code>:app</code> 已經 evaluate 完畢，Gradle 拋：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Cannot run Project.afterEvaluate(Closure) when the project is already evaluated.</span></span></code></pre></div><p>所以要在呼叫 <code>afterEvaluate</code> 之前用 <code>project.name != 'app'</code> 跳過它。
<code>:app</code> 的 JVM 設定交給 <code>:app/build.gradle</code> 自己處理，例如：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-groovy" data-lang="groovy"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">kotlin</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="n">jvmToolchain</span><span class="o">(</span><span class="mi">17</span><span class="o">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="o">}</span></span></span></code></pre></div>]]></content:encoded></item><item><title>為什麼 Bug 在合併後才爆：Gradle Cache 掩蓋潛伏問題的邏輯</title><link>https://tarrragon.github.io/blog/work-log/%E7%82%BA%E4%BB%80%E9%BA%BC-bug-%E5%9C%A8%E5%90%88%E4%BD%B5%E5%BE%8C%E6%89%8D%E7%88%86gradle-cache-%E6%8E%A9%E8%93%8B%E6%BD%9B%E4%BC%8F%E5%95%8F%E9%A1%8C%E7%9A%84%E9%82%8F%E8%BC%AF/</link><pubDate>Fri, 17 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/%E7%82%BA%E4%BB%80%E9%BA%BC-bug-%E5%9C%A8%E5%90%88%E4%BD%B5%E5%BE%8C%E6%89%8D%E7%88%86gradle-cache-%E6%8E%A9%E8%93%8B%E6%BD%9B%E4%BC%8F%E5%95%8F%E9%A1%8C%E7%9A%84%E9%82%8F%E8%BC%AF/</guid><description>&lt;h2 id="問題情境">問題情境&lt;/h2>
&lt;p>一個典型描述：&lt;/p>
&lt;blockquote>
&lt;p>「我在 feature branch 開發都沒問題，合併到 main 之後 build 就爆了。但合併前 main 也沒這個錯誤。」&lt;/p>&lt;/blockquote>
&lt;p>直覺反應會是「合併帶進來什麼壞東西」，但實際除錯後會發現：&lt;strong>根因在幾個月前就存在，合併只是觸發條件&lt;/strong>。&lt;/p>
&lt;hr>
&lt;h2 id="先檢查直覺真的是這次合併造成的嗎">先檢查直覺：真的是這次合併造成的嗎？&lt;/h2>
&lt;h3 id="步驟-1確認根因-commit">步驟 1：確認根因 commit&lt;/h3>
&lt;p>看具體錯誤訊息。例如 JVM target inconsistency，去找兩個關鍵時間點：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># JVM target 升級的 commit&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">git log --oneline --all -p -- android/app/build.gradle &lt;span class="p">|&lt;/span> grep -B1 &lt;span class="s2">&amp;#34;jvmTarget&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1"># Kotlin plugin 版本升級的 commit&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">git log --oneline --all -p -- android/settings.gradle &lt;span class="p">|&lt;/span> grep -B1 &lt;span class="s2">&amp;#34;kotlin&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="c1"># 問題 plugin 引入的 commit&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">git log --all --oneline -p -S &lt;span class="s2">&amp;#34;problematic_plugin&amp;#34;&lt;/span> -- pubspec.yaml&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>三個時間點疊起來就能看出地雷是什麼時候埋下的。&lt;/p>
&lt;h3 id="步驟-2確認地雷埋好後有幾次成功-build">步驟 2：確認地雷埋好後有幾次成功 build&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">git log --since&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&amp;lt;地雷埋下的日期&amp;gt;&amp;#34;&lt;/span> --oneline -- android/&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>如果清單裡有好幾個 commit，其中有些是 CI 或本地曾經成功 build 的，代表&lt;strong>地雷埋下後確實 build 過、卻沒炸&lt;/strong>。這就是 cache 掩蓋的證據。&lt;/p>
&lt;h3 id="步驟-3確認合併帶進的改動">步驟 3：確認合併帶進的改動&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">git show --stat &amp;lt;合併 commit&amp;gt;&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>看改到什麼檔案。關鍵檢查：&lt;/p>
&lt;ul>
&lt;li>&lt;code>pubspec.lock&lt;/code>、&lt;code>pubspec.yaml&lt;/code> → 會讓 Gradle 重新 resolve 依賴&lt;/li>
&lt;li>&lt;code>android/*.gradle&lt;/code> → 直接改 build script&lt;/li>
&lt;li>&lt;code>.gradle/&lt;/code> 或 &lt;code>build/&lt;/code> 目錄被清過 → cache 失效&lt;/li>
&lt;/ul>
&lt;p>這三類任何一項存在都可能打破 configuration cache。&lt;/p>
&lt;hr>
&lt;h2 id="gradle-的四層快取掩蓋機制">Gradle 的四層快取掩蓋機制&lt;/h2>
&lt;h3 id="四層-cache-各自掩蓋什麼">四層 cache 各自掩蓋什麼&lt;/h3>





&lt;pre tabindex="0">&lt;code class="language-mermaid" data-lang="mermaid">flowchart TD
 Build[一次 build] --&amp;gt; C1[Configuration cache]
 C1 --&amp;gt;|命中| Skip1[跳過 configuration 階段]
 C1 --&amp;gt;|miss| C2[Task up-to-date 檢查]
 C2 --&amp;gt;|up-to-date| Skip2[跳過 task execution]
 C2 --&amp;gt;|需執行| C3[Build cache]
 C3 --&amp;gt;|命中| Skip3[reuse 之前的 output]
 C3 --&amp;gt;|miss| C4[Incremental compilation]
 C4 --&amp;gt;|小改| Skip4[只編改動部分]
 C4 --&amp;gt;|大改| Full[完整編譯]&lt;/code>&lt;/pre>&lt;p>&lt;strong>每一層都能掩蓋不同的問題&lt;/strong>：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Cache&lt;/th>
 &lt;th>掩蓋的情境&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Configuration cache&lt;/td>
 &lt;td>跳過 build script 重跑，所以 &lt;code>tasks.withType(...)&lt;/code> 內的 validation 不會再跑&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Task up-to-date&lt;/td>
 &lt;td>plugin 的 &lt;code>.class&lt;/code> 已存在，整個 compile task skip，validation 也跳過&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Build cache&lt;/td>
 &lt;td>從其他機器或之前的 build 拉 output，完全不編譯&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Incremental&lt;/td>
 &lt;td>只編改動的 source 檔，新加的 validation 若沒影響到改動檔就不觸發&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="cache-失效的觸發條件">Cache 失效的觸發條件&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Cache&lt;/th>
 &lt;th>失效 trigger&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Configuration cache&lt;/td>
 &lt;td>build script 改動、依賴 resolution 結果變、Gradle 版本變&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Task up-to-date&lt;/td>
 &lt;td>input 檔改動、task 的 configuration 改動&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Build cache&lt;/td>
 &lt;td>cache key 改（input hash 變）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Incremental&lt;/td>
 &lt;td>compiler 認為需要重跑&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;code>pubspec.lock&lt;/code> 改動會打破 configuration cache 和 dependency resolution cache，這就是合併後最常見的引爆點。&lt;/p></description><content:encoded><![CDATA[<h2 id="問題情境">問題情境</h2>
<p>一個典型描述：</p>
<blockquote>
<p>「我在 feature branch 開發都沒問題，合併到 main 之後 build 就爆了。但合併前 main 也沒這個錯誤。」</p></blockquote>
<p>直覺反應會是「合併帶進來什麼壞東西」，但實際除錯後會發現：<strong>根因在幾個月前就存在，合併只是觸發條件</strong>。</p>
<hr>
<h2 id="先檢查直覺真的是這次合併造成的嗎">先檢查直覺：真的是這次合併造成的嗎？</h2>
<h3 id="步驟-1確認根因-commit">步驟 1：確認根因 commit</h3>
<p>看具體錯誤訊息。例如 JVM target inconsistency，去找兩個關鍵時間點：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># JVM target 升級的 commit</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git log --oneline --all -p -- android/app/build.gradle <span class="p">|</span> grep -B1 <span class="s2">&#34;jvmTarget&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># Kotlin plugin 版本升級的 commit</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">git log --oneline --all -p -- android/settings.gradle <span class="p">|</span> grep -B1 <span class="s2">&#34;kotlin&#34;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"># 問題 plugin 引入的 commit</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">git log --all --oneline -p -S <span class="s2">&#34;problematic_plugin&#34;</span> -- pubspec.yaml</span></span></code></pre></div><p>三個時間點疊起來就能看出地雷是什麼時候埋下的。</p>
<h3 id="步驟-2確認地雷埋好後有幾次成功-build">步驟 2：確認地雷埋好後有幾次成功 build</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git log --since<span class="o">=</span><span class="s2">&#34;&lt;地雷埋下的日期&gt;&#34;</span> --oneline -- android/</span></span></code></pre></div><p>如果清單裡有好幾個 commit，其中有些是 CI 或本地曾經成功 build 的，代表<strong>地雷埋下後確實 build 過、卻沒炸</strong>。這就是 cache 掩蓋的證據。</p>
<h3 id="步驟-3確認合併帶進的改動">步驟 3：確認合併帶進的改動</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git show --stat &lt;合併 commit&gt;</span></span></code></pre></div><p>看改到什麼檔案。關鍵檢查：</p>
<ul>
<li><code>pubspec.lock</code>、<code>pubspec.yaml</code> → 會讓 Gradle 重新 resolve 依賴</li>
<li><code>android/*.gradle</code> → 直接改 build script</li>
<li><code>.gradle/</code> 或 <code>build/</code> 目錄被清過 → cache 失效</li>
</ul>
<p>這三類任何一項存在都可能打破 configuration cache。</p>
<hr>
<h2 id="gradle-的四層快取掩蓋機制">Gradle 的四層快取掩蓋機制</h2>
<h3 id="四層-cache-各自掩蓋什麼">四層 cache 各自掩蓋什麼</h3>





<pre tabindex="0"><code class="language-mermaid" data-lang="mermaid">flowchart TD
    Build[一次 build] --&gt; C1[Configuration cache]
    C1 --&gt;|命中| Skip1[跳過 configuration 階段]
    C1 --&gt;|miss| C2[Task up-to-date 檢查]
    C2 --&gt;|up-to-date| Skip2[跳過 task execution]
    C2 --&gt;|需執行| C3[Build cache]
    C3 --&gt;|命中| Skip3[reuse 之前的 output]
    C3 --&gt;|miss| C4[Incremental compilation]
    C4 --&gt;|小改| Skip4[只編改動部分]
    C4 --&gt;|大改| Full[完整編譯]</code></pre><p><strong>每一層都能掩蓋不同的問題</strong>：</p>
<table>
  <thead>
      <tr>
          <th>Cache</th>
          <th>掩蓋的情境</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Configuration cache</td>
          <td>跳過 build script 重跑，所以 <code>tasks.withType(...)</code> 內的 validation 不會再跑</td>
      </tr>
      <tr>
          <td>Task up-to-date</td>
          <td>plugin 的 <code>.class</code> 已存在，整個 compile task skip，validation 也跳過</td>
      </tr>
      <tr>
          <td>Build cache</td>
          <td>從其他機器或之前的 build 拉 output，完全不編譯</td>
      </tr>
      <tr>
          <td>Incremental</td>
          <td>只編改動的 source 檔，新加的 validation 若沒影響到改動檔就不觸發</td>
      </tr>
  </tbody>
</table>
<h3 id="cache-失效的觸發條件">Cache 失效的觸發條件</h3>
<table>
  <thead>
      <tr>
          <th>Cache</th>
          <th>失效 trigger</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Configuration cache</td>
          <td>build script 改動、依賴 resolution 結果變、Gradle 版本變</td>
      </tr>
      <tr>
          <td>Task up-to-date</td>
          <td>input 檔改動、task 的 configuration 改動</td>
      </tr>
      <tr>
          <td>Build cache</td>
          <td>cache key 改（input hash 變）</td>
      </tr>
      <tr>
          <td>Incremental</td>
          <td>compiler 認為需要重跑</td>
      </tr>
  </tbody>
</table>
<p><code>pubspec.lock</code> 改動會打破 configuration cache 和 dependency resolution cache，這就是合併後最常見的引爆點。</p>
<hr>
<h2 id="為什麼-kotlin-22-的-validation-會被-cache-掩蓋">為什麼 Kotlin 2.2 的 validation 會被 cache 掩蓋</h2>
<p>這次的具體案例：</p>
<ol>
<li><strong>T1</strong>：專案初始化，引入 <code>flutter_broadcasts_4m</code>，plugin 的 <code>build.gradle</code> 硬寫 <code>jvmTarget = '1.8'</code></li>
<li><strong>T2</strong>：升級 Kotlin 1.8.22 → 2.2.10（strict validation 從此 enabled）</li>
<li><strong>T3</strong>：升級 <code>:app</code> 的 JVM target 1.8 → 17</li>
</ol>
<p>從 T3 開始，理論上每次 build 都應該觸發 validation 炸掉。但實際上：</p>
<ul>
<li>升級當下的 build：可能在本地用 <code>./gradlew --stop</code> 重啟過 daemon，有一次完整 configuration，validation 觸發 → 但因為「一次」而工程師沒記錄下來</li>
<li>更可能：升級時恰好在 CI 跑過一次綠燈（因為 CI cache），之後所有 local build 都吃 configuration cache 跳過 validation</li>
</ul>
<p>後續幾個月：</p>
<ul>
<li>每次 build 靠 configuration cache 或 task up-to-date 跳過 validation</li>
<li>地雷一直存在但看不見</li>
<li>合併 PR 改到 <code>pubspec.lock</code> → configuration cache 失效 → validation 終於被執行 → 爆炸</li>
</ul>
<hr>
<h2 id="診斷流程">診斷流程</h2>
<h3 id="步驟-1判斷根因vs觸發條件">步驟 1：判斷「根因」vs「觸發條件」</h3>
<p>錯誤訊息說的是<strong>當下的症狀</strong>，不一定是真正的根因。用 git log 回溯：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 找寫死有問題設定的 plugin 是何時引入的</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git log --all -p -S <span class="s2">&#34;jvmTarget = &#39;1.8&#39;&#34;</span> -- pubspec.yaml
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 找讓 strict validation 生效的配置變更</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">git log --all -p -- android/settings.gradle</span></span></code></pre></div><p>如果這些 commit 都比當前合併早很多，就能確認「根因早存在，合併只是觸發」。</p>
<h3 id="步驟-2判斷-cache-類型">步驟 2：判斷 cache 類型</h3>
<p>執行無快取 build，看錯誤會不會重現：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">./gradlew clean
</span></span><span class="line"><span class="ln">2</span><span class="cl">./gradlew --stop                           <span class="c1"># 停掉 daemon</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">rm -rf .gradle build                       <span class="c1"># 清 project-level cache</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># ~/.gradle/caches/ 也可以清但會很慢</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">flutter clean
</span></span><span class="line"><span class="ln">6</span><span class="cl">flutter build apk --no-build-cache</span></span></code></pre></div><p>如果這樣 build 還會爆 → 確認是真實問題，不是 cache 偶發
如果這樣 build 不會爆 → cache 掩蓋的真實問題已被解決，之前只是殘留 state 問題</p>
<h3 id="步驟-3驗證修復後不會復發">步驟 3：驗證修復後不會復發</h3>
<p>修復後，在<strong>乾淨環境</strong>下跑過一次完整 build：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">flutter clean
</span></span><span class="line"><span class="ln">2</span><span class="cl">rm -rf ~/.pub-cache/hosted/pub.dev/&lt;problem_plugin&gt;-*
</span></span><span class="line"><span class="ln">3</span><span class="cl">flutter pub get
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nb">cd</span> android <span class="o">&amp;&amp;</span> ./gradlew clean <span class="o">&amp;&amp;</span> ./gradlew build</span></span></code></pre></div><p>避免「修好但實際還是靠 cache 蓋著」的假綠燈。</p>
<hr>
<h2 id="防禦讓潛伏問題提早暴露">防禦：讓潛伏問題提早暴露</h2>
<h3 id="方法-1ci-定期跑無快取-build">方法 1：CI 定期跑無快取 build</h3>
<p>排程一週一次的 CI job，跑完整清除 cache 後的 build：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln">1</span><span class="cl"><span class="c"># 偽 CI 腳本</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span>- <span class="l">flutter clean</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span>- <span class="l">rm -rf ~/.gradle/caches/modules-2/metadata-*</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span>- <span class="l">cd android &amp;&amp; ./gradlew --no-configuration-cache --no-build-cache clean assembleDebug</span></span></span></code></pre></div><p>這樣 catch 到的錯誤通常比開發者自己遇到早一週到一個月，能在觸發條件（合併、升級）發生之前就看到。</p>
<h3 id="方法-2升級依賴時強制全量驗證">方法 2：升級依賴時強制全量驗證</h3>
<p>每次升 Flutter、AGP、Kotlin plugin 版本時，遵守以下流程：</p>
<ol>
<li>建立升級分支</li>
<li>升級前先 <code>flutter clean</code> + <code>./gradlew clean</code></li>
<li>升級後再跑一次無 cache build</li>
<li>確認綠燈才合併</li>
</ol>
<p>這一步常被忽略，因為「升版本的 PR 通常 diff 很小，看起來不會壞什麼」。但 Gradle 的 strict validation 規則通常就藏在這些小升級裡。</p>
<hr>
<h2 id="除錯思維的關鍵切換">除錯思維的關鍵切換</h2>
<p>看到「branch 上沒事、merge 後爆」這類時序弔詭時：</p>
<p><strong>不要先想「這次合併改了什麼造成問題」</strong>
→ 容易把時間花在閱讀無關的 diff</p>
<p><strong>要先想「是不是有什麼東西一直被 cache 蓋著」</strong>
→ 把 cache 當成嫌疑人，去找觸發條件</p>
<p>通常結論都會是：<strong>根因在幾個月前埋下，cache 蓋了很久，這次合併剛好扣扳機</strong>。</p>
<p>把這個思維框架套用在其他類似症狀上也成立：</p>
<ul>
<li>CI 一直綠燈，某次合併後才紅 → CI 的 cache 在那次被打破</li>
<li>某個開發者電腦上沒事，別人電腦上爆 → 兩台機器的 cache state 不同步</li>
<li>升級後立刻 build 綠，過幾天才出問題 → 那幾天有某個動作打破了 cache</li>
</ul>
]]></content:encoded></item></channel></rss>