<?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>Android on Tarragon</title><link>https://tarrragon.github.io/blog/tags/android/</link><description>Recent content in Android on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 19 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/android/index.xml" rel="self" type="application/rss+xml"/><item><title>Biometric fallback 完整設計</title><link>https://tarrragon.github.io/blog/ux-design/02-gate-fallback/biometric-fallback-design/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/02-gate-fallback/biometric-fallback-design/</guid><description>&lt;p>Biometric &lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/gate/" data-link-title="Gate（UX）" data-link-desc="說明使用者操作流程中「必須通過才能繼續」的關卡，以及成功/失敗/不確定三條路徑的設計責任">gate&lt;/a> 的 &lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/ux-fallback/" data-link-title="Fallback（UX）" data-link-desc="說明 gate 未通過時使用者的替代路徑，和 backend fallback（server-side 降級）的語意區別">fallback&lt;/a> 設計需要理解兩件事：平台的認證 API 在不同情境下的行為差異，以及安全收益和可用性代價之間的顯式取捨。&lt;/p>
&lt;h2 id="生物辨識失敗的情境">生物辨識失敗的情境&lt;/h2>
&lt;p>生物辨識失敗有多種原因，每種原因對使用者的影響和合理的 fallback 不同。&lt;/p>
&lt;h3 id="暫時性失敗">暫時性失敗&lt;/h3>
&lt;p>Face ID 因光線不足辨識失敗、指紋因手指潮濕讀取失敗。使用者的生物特徵正常，只是當次辨識條件不佳。重試可能成功。&lt;/p>
&lt;h3 id="持續性失敗">持續性失敗&lt;/h3>
&lt;p>使用者戴口罩讓 Face ID 無法辨識（較舊的 iOS 版本）、手指受傷影響指紋辨識。生物特徵暫時改變，短期內重試都不會成功。需要替代認證方式。&lt;/p>
&lt;h3 id="硬體不可用">硬體不可用&lt;/h3>
&lt;p>裝置沒有 Face ID / Touch ID 模組（較舊機型）、模擬器不支援生物辨識、生物辨識功能被裝置管理策略（MDM）禁用。需要替代認證方式。&lt;/p>
&lt;h3 id="使用者未設定">使用者未設定&lt;/h3>
&lt;p>裝置有硬體但使用者沒有設定 Face ID 或指紋。系統的 &lt;code>canCheckBiometrics&lt;/code> 回傳 &lt;code>true&lt;/code>（硬體存在）但實際認證會失敗。需要引導使用者設定或提供替代認證。&lt;/p>
&lt;h2 id="ios-和-android-的行為差異">iOS 和 Android 的行為差異&lt;/h2>
&lt;h3 id="ioslocalauthentication">iOS（LocalAuthentication）&lt;/h3>
&lt;p>iOS 的 &lt;code>LAContext.evaluatePolicy&lt;/code> 有兩個 policy：&lt;/p>
&lt;ul>
&lt;li>&lt;code>deviceOwnerAuthenticationWithBiometrics&lt;/code>：只接受生物辨識，失敗後不自動提示密碼&lt;/li>
&lt;li>&lt;code>deviceOwnerAuthentication&lt;/code>：先嘗試生物辨識，失敗後系統自動彈出裝置密碼輸入&lt;/li>
&lt;/ul>
&lt;p>Flutter 的 &lt;code>local_auth&lt;/code> 套件的 &lt;code>biometricOnly&lt;/code> 參數對應這兩個 policy。&lt;code>biometricOnly: true&lt;/code> 用前者，&lt;code>biometricOnly: false&lt;/code> 用後者。&lt;/p>
&lt;p>iOS 的行為特點：系統控制認證 UI（不是 app 自行繪製），認證失敗次數過多會自動鎖定（需要輸入密碼解鎖），Face ID 多次失敗後系統會自動提供密碼選項（即使 app 要求 biometricOnly）。&lt;/p>
&lt;h3 id="androidbiometricprompt">Android（BiometricPrompt）&lt;/h3>
&lt;p>Android 的 BiometricPrompt 分成三個 class：&lt;/p>
&lt;ul>
&lt;li>&lt;code>BIOMETRIC_STRONG&lt;/code>：只接受 Class 3 生物辨識（經過硬體安全模組驗證的指紋/面部）&lt;/li>
&lt;li>&lt;code>BIOMETRIC_WEAK&lt;/code>：接受 Class 2 和 Class 3 生物辨識&lt;/li>
&lt;li>&lt;code>DEVICE_CREDENTIAL&lt;/code>：接受裝置 PIN/圖形/密碼&lt;/li>
&lt;/ul>
&lt;p>三個 class 可以用 &lt;code>|&lt;/code> 組合。&lt;code>BIOMETRIC_STRONG | DEVICE_CREDENTIAL&lt;/code> 表示先嘗試強生物辨識，失敗後 fallback 到裝置密碼。&lt;/p>
&lt;p>Android 的行為特點：不同廠商的生物辨識品質差異大（Samsung 的面部辨識和 Pixel 的面部辨識安全等級不同）、部分裝置的指紋感測器在螢幕下方（使用者可能不知道在哪裡觸碰）。&lt;/p>
&lt;h2 id="安全-vs-可用性的顯式取捨">安全 vs 可用性的顯式取捨&lt;/h2>
&lt;p>&lt;code>biometricOnly&lt;/code> 的決策涉及安全和可用性的取捨。這個取捨應該在功能規格中顯式記錄，讓後續的 code review 和維護者能理解決策的背景。&lt;/p>
&lt;p>記錄格式建議：&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">Gate: biometric authentication
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">Decision: biometricOnly = false (allow device credential fallback)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">Security trade-off: device credential (PIN/password) is weaker than biometric
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">Rationale: self-hosted tool, user = owner, availability &amp;gt; auth strength
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">Risk accepted: someone with device PIN can access the app&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>app_tunnel 選擇 &lt;code>biometricOnly: true&lt;/code> 的原始意圖是「安全性更高」，但沒有顯式記錄取捨，也沒有評估「Face ID 不可用時使用者完全無法使用 app」的代價。自用工具的使用者就是 owner，密碼 fallback 的安全風險遠低於完全無法使用的可用性風險（&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2&lt;/a>）。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>Gate 設計的通用方法論 → &lt;a href="https://tarrragon.github.io/blog/ux-design/02-gate-fallback/gate-three-questions/" data-link-title="Gate 分類與三問設計法" data-link-desc="每個 gate 設計時問三個問題：成功時做什麼、失敗時做什麼、使用者不知道發生什麼時做什麼">Gate 分類與三問設計法&lt;/a>&lt;/li>
&lt;li>開發環境遮蔽 gate 問題 → &lt;a href="https://tarrragon.github.io/blog/ux-design/02-gate-fallback/dev-vs-real-gate-behavior/" data-link-title="開發環境 vs 真機的 gate 行為差異表" data-link-desc="模擬器、debug build、test 環境中的 gate 行為和真機 release build 不同 — 差異表讓開發者在上機前知道哪些 gate 還沒被真實驗證">開發環境 vs 真機的 gate 行為差異表&lt;/a>&lt;/li>
&lt;li>安全 vs 可用性在 monitoring 中的對應 → &lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">monitoring 模組七 資安&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Biometric <a href="/blog/ux-design/knowledge-cards/gate/" data-link-title="Gate（UX）" data-link-desc="說明使用者操作流程中「必須通過才能繼續」的關卡，以及成功/失敗/不確定三條路徑的設計責任">gate</a> 的 <a href="/blog/ux-design/knowledge-cards/ux-fallback/" data-link-title="Fallback（UX）" data-link-desc="說明 gate 未通過時使用者的替代路徑，和 backend fallback（server-side 降級）的語意區別">fallback</a> 設計需要理解兩件事：平台的認證 API 在不同情境下的行為差異，以及安全收益和可用性代價之間的顯式取捨。</p>
<h2 id="生物辨識失敗的情境">生物辨識失敗的情境</h2>
<p>生物辨識失敗有多種原因，每種原因對使用者的影響和合理的 fallback 不同。</p>
<h3 id="暫時性失敗">暫時性失敗</h3>
<p>Face ID 因光線不足辨識失敗、指紋因手指潮濕讀取失敗。使用者的生物特徵正常，只是當次辨識條件不佳。重試可能成功。</p>
<h3 id="持續性失敗">持續性失敗</h3>
<p>使用者戴口罩讓 Face ID 無法辨識（較舊的 iOS 版本）、手指受傷影響指紋辨識。生物特徵暫時改變，短期內重試都不會成功。需要替代認證方式。</p>
<h3 id="硬體不可用">硬體不可用</h3>
<p>裝置沒有 Face ID / Touch ID 模組（較舊機型）、模擬器不支援生物辨識、生物辨識功能被裝置管理策略（MDM）禁用。需要替代認證方式。</p>
<h3 id="使用者未設定">使用者未設定</h3>
<p>裝置有硬體但使用者沒有設定 Face ID 或指紋。系統的 <code>canCheckBiometrics</code> 回傳 <code>true</code>（硬體存在）但實際認證會失敗。需要引導使用者設定或提供替代認證。</p>
<h2 id="ios-和-android-的行為差異">iOS 和 Android 的行為差異</h2>
<h3 id="ioslocalauthentication">iOS（LocalAuthentication）</h3>
<p>iOS 的 <code>LAContext.evaluatePolicy</code> 有兩個 policy：</p>
<ul>
<li><code>deviceOwnerAuthenticationWithBiometrics</code>：只接受生物辨識，失敗後不自動提示密碼</li>
<li><code>deviceOwnerAuthentication</code>：先嘗試生物辨識，失敗後系統自動彈出裝置密碼輸入</li>
</ul>
<p>Flutter 的 <code>local_auth</code> 套件的 <code>biometricOnly</code> 參數對應這兩個 policy。<code>biometricOnly: true</code> 用前者，<code>biometricOnly: false</code> 用後者。</p>
<p>iOS 的行為特點：系統控制認證 UI（不是 app 自行繪製），認證失敗次數過多會自動鎖定（需要輸入密碼解鎖），Face ID 多次失敗後系統會自動提供密碼選項（即使 app 要求 biometricOnly）。</p>
<h3 id="androidbiometricprompt">Android（BiometricPrompt）</h3>
<p>Android 的 BiometricPrompt 分成三個 class：</p>
<ul>
<li><code>BIOMETRIC_STRONG</code>：只接受 Class 3 生物辨識（經過硬體安全模組驗證的指紋/面部）</li>
<li><code>BIOMETRIC_WEAK</code>：接受 Class 2 和 Class 3 生物辨識</li>
<li><code>DEVICE_CREDENTIAL</code>：接受裝置 PIN/圖形/密碼</li>
</ul>
<p>三個 class 可以用 <code>|</code> 組合。<code>BIOMETRIC_STRONG | DEVICE_CREDENTIAL</code> 表示先嘗試強生物辨識，失敗後 fallback 到裝置密碼。</p>
<p>Android 的行為特點：不同廠商的生物辨識品質差異大（Samsung 的面部辨識和 Pixel 的面部辨識安全等級不同）、部分裝置的指紋感測器在螢幕下方（使用者可能不知道在哪裡觸碰）。</p>
<h2 id="安全-vs-可用性的顯式取捨">安全 vs 可用性的顯式取捨</h2>
<p><code>biometricOnly</code> 的決策涉及安全和可用性的取捨。這個取捨應該在功能規格中顯式記錄，讓後續的 code review 和維護者能理解決策的背景。</p>
<p>記錄格式建議：</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">Gate: biometric authentication
</span></span><span class="line"><span class="ln">2</span><span class="cl">Decision: biometricOnly = false (allow device credential fallback)
</span></span><span class="line"><span class="ln">3</span><span class="cl">Security trade-off: device credential (PIN/password) is weaker than biometric
</span></span><span class="line"><span class="ln">4</span><span class="cl">Rationale: self-hosted tool, user = owner, availability &gt; auth strength
</span></span><span class="line"><span class="ln">5</span><span class="cl">Risk accepted: someone with device PIN can access the app</span></span></code></pre></div><p>app_tunnel 選擇 <code>biometricOnly: true</code> 的原始意圖是「安全性更高」，但沒有顯式記錄取捨，也沒有評估「Face ID 不可用時使用者完全無法使用 app」的代價。自用工具的使用者就是 owner，密碼 fallback 的安全風險遠低於完全無法使用的可用性風險（<a href="/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2</a>）。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Gate 設計的通用方法論 → <a href="/blog/ux-design/02-gate-fallback/gate-three-questions/" data-link-title="Gate 分類與三問設計法" data-link-desc="每個 gate 設計時問三個問題：成功時做什麼、失敗時做什麼、使用者不知道發生什麼時做什麼">Gate 分類與三問設計法</a></li>
<li>開發環境遮蔽 gate 問題 → <a href="/blog/ux-design/02-gate-fallback/dev-vs-real-gate-behavior/" data-link-title="開發環境 vs 真機的 gate 行為差異表" data-link-desc="模擬器、debug build、test 環境中的 gate 行為和真機 release build 不同 — 差異表讓開發者在上機前知道哪些 gate 還沒被真實驗證">開發環境 vs 真機的 gate 行為差異表</a></li>
<li>安全 vs 可用性在 monitoring 中的對應 → <a href="/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">monitoring 模組七 資安</a></li>
</ul>
]]></content:encoded></item><item><title>iOS HIG vs Material Design 導航差異</title><link>https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/ios-vs-material-navigation/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/ios-vs-material-navigation/</guid><description>&lt;p>iOS Human Interface Guidelines（HIG）和 Material Design 對導航行為有不同的慣例。跨平台 app（Flutter、React Native）需要決定：完全遵循一套、各平台遵循各自的慣例、或混合使用。這個決策影響使用者在不同平台上的操作體驗。&lt;/p>
&lt;h2 id="back-行為">Back 行為&lt;/h2>
&lt;h3 id="ios">iOS&lt;/h3>
&lt;p>iOS 沒有系統級的 back 按鈕。導航列左上角的 back 按鈕由 app 提供（&lt;code>UINavigationController&lt;/code> 自動加入）。使用者也可以從螢幕左邊緣向右滑動觸發 back（edge swipe gesture）。&lt;/p>
&lt;p>iOS 的 back 行為是 pop — 彈出堆疊頂端，回到前一個畫面。沒有 Android 的系統 back 按鈕覆寫機制。&lt;/p>
&lt;h3 id="android--material-design">Android / Material Design&lt;/h3>
&lt;p>Android 有系統級的 back 按鈕（虛擬或實體）。Material Design 在 app bar 左上角也放 back 箭頭或 hamburger menu 圖示。&lt;/p>
&lt;p>Android 的 back 行為由 app 控制（&lt;code>onBackPressed&lt;/code>），可以被覆寫。常見的覆寫場景：在首頁按 back 詢問「是否離開 app」、在表單中按 back 詢問「是否放棄編輯」。&lt;/p>
&lt;h3 id="跨平台決策">跨平台決策&lt;/h3>
&lt;p>Flutter 預設在 Android 上攔截系統 back 按鈕，在 iOS 上提供 back 按鈕和 edge swipe。GoRouter 的 &lt;code>pop()&lt;/code> 在兩個平台上行為一致。&lt;/p>
&lt;p>跨平台 app 需要注意的差異：iOS 使用者習慣 edge swipe back，Android 使用者習慣按系統 back 按鈕。兩者都要支援。&lt;/p>
&lt;h2 id="tab-bar-位置">Tab bar 位置&lt;/h2>
&lt;h3 id="ios-1">iOS&lt;/h3>
&lt;p>Tab bar 固定在畫面底部。iOS 使用者期望 tab bar 永遠可見、永遠在底部。Apple 的 HIG 明確建議 tab bar 在底部。&lt;/p>
&lt;h3 id="material-design">Material Design&lt;/h3>
&lt;p>Material Design 的 bottom navigation 也在底部，但額外支援 top tabs（在 app bar 下方的可滑動標籤列）。Top tabs 適合同一類內容的不同視角（全部 / 未讀 / 已標記）。&lt;/p>
&lt;h3 id="跨平台決策-1">跨平台決策&lt;/h3>
&lt;p>底部 tab bar 在兩個平台上都是標準做法。Top tabs 在 iOS 上較少見（iOS 偏好用 segmented control 代替 top tabs）。跨平台 app 用底部 tab bar 是最安全的選擇。&lt;/p>
&lt;h2 id="modal-呈現">Modal 呈現&lt;/h2>
&lt;h3 id="ios-2">iOS&lt;/h3>
&lt;p>iOS 的 modal 畫面從底部滑上來，覆蓋前一個畫面但不完全遮擋（iOS 13+ 的 sheet 呈現樣式可以看到前一個畫面的上緣）。Dismiss 操作是向下滑動或點擊關閉按鈕。&lt;/p>
&lt;h3 id="material-design-1">Material Design&lt;/h3>
&lt;p>Material Design 的 bottom sheet 和 dialog 是 modal 的主要形式。Full-screen dialog 從底部滑上來，有 close 按鈕在左上角和 action 按鈕在右上角。&lt;/p>
&lt;h3 id="跨平台決策-2">跨平台決策&lt;/h3>
&lt;p>Flutter 的 &lt;code>showModalBottomSheet&lt;/code> 和 &lt;code>showDialog&lt;/code> 在兩個平台上都可用。視覺呈現可以用 platform-adaptive widget（&lt;code>CupertinoPageRoute&lt;/code> vs &lt;code>MaterialPageRoute&lt;/code>）按平台切換。&lt;/p>
&lt;h2 id="選擇策略">選擇策略&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>策略&lt;/th>
 &lt;th>適合場景&lt;/th>
 &lt;th>代價&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>統一用 Material Design&lt;/td>
 &lt;td>以 Android 為主的 app、快速開發&lt;/td>
 &lt;td>iOS 使用者體驗不原生&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>統一用 iOS HIG&lt;/td>
 &lt;td>以 iOS 為主的 app&lt;/td>
 &lt;td>Android 使用者體驗不原生&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>各平台遵循各自慣例&lt;/td>
 &lt;td>重視兩個平台原生體驗&lt;/td>
 &lt;td>開發和測試成本翻倍&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>共用核心、差異點適配&lt;/td>
 &lt;td>多數跨平台 app 的實際選擇&lt;/td>
 &lt;td>需要判斷哪些差異值得適配&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>多數跨平台 app 選擇「共用核心、差異點適配」— 底部 tab bar、push/pop 導航在兩個平台上一致；back 手勢、modal 呈現按平台適配。&lt;/p></description><content:encoded><![CDATA[<p>iOS Human Interface Guidelines（HIG）和 Material Design 對導航行為有不同的慣例。跨平台 app（Flutter、React Native）需要決定：完全遵循一套、各平台遵循各自的慣例、或混合使用。這個決策影響使用者在不同平台上的操作體驗。</p>
<h2 id="back-行為">Back 行為</h2>
<h3 id="ios">iOS</h3>
<p>iOS 沒有系統級的 back 按鈕。導航列左上角的 back 按鈕由 app 提供（<code>UINavigationController</code> 自動加入）。使用者也可以從螢幕左邊緣向右滑動觸發 back（edge swipe gesture）。</p>
<p>iOS 的 back 行為是 pop — 彈出堆疊頂端，回到前一個畫面。沒有 Android 的系統 back 按鈕覆寫機制。</p>
<h3 id="android--material-design">Android / Material Design</h3>
<p>Android 有系統級的 back 按鈕（虛擬或實體）。Material Design 在 app bar 左上角也放 back 箭頭或 hamburger menu 圖示。</p>
<p>Android 的 back 行為由 app 控制（<code>onBackPressed</code>），可以被覆寫。常見的覆寫場景：在首頁按 back 詢問「是否離開 app」、在表單中按 back 詢問「是否放棄編輯」。</p>
<h3 id="跨平台決策">跨平台決策</h3>
<p>Flutter 預設在 Android 上攔截系統 back 按鈕，在 iOS 上提供 back 按鈕和 edge swipe。GoRouter 的 <code>pop()</code> 在兩個平台上行為一致。</p>
<p>跨平台 app 需要注意的差異：iOS 使用者習慣 edge swipe back，Android 使用者習慣按系統 back 按鈕。兩者都要支援。</p>
<h2 id="tab-bar-位置">Tab bar 位置</h2>
<h3 id="ios-1">iOS</h3>
<p>Tab bar 固定在畫面底部。iOS 使用者期望 tab bar 永遠可見、永遠在底部。Apple 的 HIG 明確建議 tab bar 在底部。</p>
<h3 id="material-design">Material Design</h3>
<p>Material Design 的 bottom navigation 也在底部，但額外支援 top tabs（在 app bar 下方的可滑動標籤列）。Top tabs 適合同一類內容的不同視角（全部 / 未讀 / 已標記）。</p>
<h3 id="跨平台決策-1">跨平台決策</h3>
<p>底部 tab bar 在兩個平台上都是標準做法。Top tabs 在 iOS 上較少見（iOS 偏好用 segmented control 代替 top tabs）。跨平台 app 用底部 tab bar 是最安全的選擇。</p>
<h2 id="modal-呈現">Modal 呈現</h2>
<h3 id="ios-2">iOS</h3>
<p>iOS 的 modal 畫面從底部滑上來，覆蓋前一個畫面但不完全遮擋（iOS 13+ 的 sheet 呈現樣式可以看到前一個畫面的上緣）。Dismiss 操作是向下滑動或點擊關閉按鈕。</p>
<h3 id="material-design-1">Material Design</h3>
<p>Material Design 的 bottom sheet 和 dialog 是 modal 的主要形式。Full-screen dialog 從底部滑上來，有 close 按鈕在左上角和 action 按鈕在右上角。</p>
<h3 id="跨平台決策-2">跨平台決策</h3>
<p>Flutter 的 <code>showModalBottomSheet</code> 和 <code>showDialog</code> 在兩個平台上都可用。視覺呈現可以用 platform-adaptive widget（<code>CupertinoPageRoute</code> vs <code>MaterialPageRoute</code>）按平台切換。</p>
<h2 id="選擇策略">選擇策略</h2>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>適合場景</th>
          <th>代價</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>統一用 Material Design</td>
          <td>以 Android 為主的 app、快速開發</td>
          <td>iOS 使用者體驗不原生</td>
      </tr>
      <tr>
          <td>統一用 iOS HIG</td>
          <td>以 iOS 為主的 app</td>
          <td>Android 使用者體驗不原生</td>
      </tr>
      <tr>
          <td>各平台遵循各自慣例</td>
          <td>重視兩個平台原生體驗</td>
          <td>開發和測試成本翻倍</td>
      </tr>
      <tr>
          <td>共用核心、差異點適配</td>
          <td>多數跨平台 app 的實際選擇</td>
          <td>需要判斷哪些差異值得適配</td>
      </tr>
  </tbody>
</table>
<p>多數跨平台 app 選擇「共用核心、差異點適配」— 底部 tab bar、push/pop 導航在兩個平台上一致；back 手勢、modal 呈現按平台適配。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Deep link 設計 → <a href="/blog/ux-design/05-navigation-patterns/deep-link-design/" data-link-title="Deep link 設計" data-link-desc="URL scheme / Universal Link / App Link — deep link 讓外部來源直接導航到 app 的特定畫面">Deep link 設計</a></li>
<li>go vs push 的語意差異 → <a href="/blog/ux-design/05-navigation-patterns/go-push-semantics/" data-link-title="go vs push vs pushReplacement 的 UX 語意表" data-link-desc="三種導航方法對堆疊、back 行為、使用者心理模型的影響 — 選擇依據是使用者的意圖而非技術方便">go vs push vs pushReplacement 語意表</a></li>
<li>導航模式分類 → <a href="/blog/ux-design/05-navigation-patterns/mobile-navigation-taxonomy/" data-link-title="Mobile 導航模式分類" data-link-desc="Push/pop stack / declarative router / tab bar / drawer — 四種 mobile 導航模式各自的適用場景和使用者心理模型">Mobile 導航模式分類</a></li>
</ul>
]]></content:encoded></item><item><title>Permission 請求時機與措辭</title><link>https://tarrragon.github.io/blog/ux-design/02-gate-fallback/permission-request-timing/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/02-gate-fallback/permission-request-timing/</guid><description>&lt;p>系統權限（相機、位置、通知、麥克風）的請求對話框由作業系統控制，app 只能決定「什麼時候觸發」和「觸發前顯示什麼說明」。使用者拒絕後，再次請求不會彈出系統對話框 — 必須引導使用者到系統設定手動開啟。這意味著第一次請求的時機和說明內容直接影響授權率。&lt;/p>
&lt;h2 id="請求時機">請求時機&lt;/h2>
&lt;h3 id="首次開啟時一次性請求">首次開啟時一次性請求&lt;/h3>
&lt;p>App 首次啟動時依序請求所有需要的權限。優點是使用者只被打斷一次；缺點是使用者尚未使用任何功能，不理解每個權限的用途，傾向拒絕。&lt;/p>
&lt;p>這個模式適合權限數量少（1-2 個）且和 app 核心功能直接相關的情境。相機 app 在首次開啟時請求相機權限，使用者能直覺理解原因。&lt;/p>
&lt;h3 id="功能使用時即時請求">功能使用時即時請求&lt;/h3>
&lt;p>使用者點擊需要權限的功能時才請求。優點是使用者在操作 context 中，能理解為什麼需要這個權限；缺點是操作流程被打斷。&lt;/p>
&lt;p>這個模式適合權限和特定功能綁定的情境。掃描 QR code 時請求相機權限，使用者正在嘗試掃描，理解為什麼需要相機。&lt;/p>
&lt;h3 id="推薦策略">推薦策略&lt;/h3>
&lt;p>功能使用時即時請求是多數場景的推薦策略。使用者有操作 context，授權率較高。打斷可以透過 pre-permission 說明畫面降低突兀感。&lt;/p>
&lt;h2 id="pre-permission-說明畫面">Pre-permission 說明畫面&lt;/h2>
&lt;p>在觸發系統權限對話框之前，app 先顯示自己的說明畫面，解釋為什麼需要這個權限和用途。&lt;/p>
&lt;p>說明畫面的設計要點：&lt;/p>
&lt;p>&lt;strong>說明用途而非技術細節&lt;/strong>。「需要相機來掃描裝置上的 QR code」比「app 需要存取 AVCaptureDevice」更有用。使用者關心的是「為什麼」，不是「用什麼 API」。&lt;/p>
&lt;p>&lt;strong>提供「稍後再說」選項&lt;/strong>。使用者可能想先了解 app 再決定是否授權。強制授權（沒有跳過選項）會讓使用者選擇拒絕。&lt;/p>
&lt;p>&lt;strong>視覺化說明&lt;/strong>。用截圖或圖示展示「授權後這個功能長什麼樣」，讓使用者預覽授權的價值。&lt;/p>
&lt;h2 id="拒絕後的處理">拒絕後的處理&lt;/h2>
&lt;p>使用者拒絕權限後，app 需要：&lt;/p>
&lt;p>&lt;strong>記住拒絕狀態&lt;/strong>。不要在每次使用者操作同一功能時都顯示 pre-permission 說明（使用者已經表達不想授權，反覆詢問是騷擾）。&lt;/p>
&lt;p>&lt;strong>提供功能降級&lt;/strong>。如果可能，提供不需要權限的替代方案。掃描 QR code 可以改成手動輸入配對碼。&lt;/p>
&lt;p>&lt;strong>在適當時機再提醒&lt;/strong>。使用者多次使用需要權限的功能但都因為沒有權限而失敗時，用非侵入式提示（Snackbar）說明「開啟相機權限可以使用掃描功能」加設定連結。&lt;/p>
&lt;p>&lt;strong>引導到系統設定&lt;/strong>。一旦使用者在系統對話框中選擇「不再詢問」（Android）或拒絕（iOS 拒絕後系統不再彈窗），唯一的路徑是引導使用者到系統設定手動開啟。提供直接跳轉到 app 設定頁面的按鈕。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>Gate 設計的通用方法論 → &lt;a href="https://tarrragon.github.io/blog/ux-design/02-gate-fallback/gate-three-questions/" data-link-title="Gate 分類與三問設計法" data-link-desc="每個 gate 設計時問三個問題：成功時做什麼、失敗時做什麼、使用者不知道發生什麼時做什麼">Gate 分類與三問設計法&lt;/a>&lt;/li>
&lt;li>網路 gate 的處理策略 → &lt;a href="https://tarrragon.github.io/blog/ux-design/02-gate-fallback/network-offline-ux/" data-link-title="網路斷線 UX 模式" data-link-desc="Offline-first / retry / degraded mode 三種網路 gate 的處理策略 — 取決於功能是否依賴即時連線">網路斷線 UX 模式&lt;/a>&lt;/li>
&lt;li>開發環境遮蔽 gate 問題 → &lt;a href="https://tarrragon.github.io/blog/ux-design/02-gate-fallback/dev-vs-real-gate-behavior/" data-link-title="開發環境 vs 真機的 gate 行為差異表" data-link-desc="模擬器、debug build、test 環境中的 gate 行為和真機 release build 不同 — 差異表讓開發者在上機前知道哪些 gate 還沒被真實驗證">開發環境 vs 真機的 gate 行為差異表&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>系統權限（相機、位置、通知、麥克風）的請求對話框由作業系統控制，app 只能決定「什麼時候觸發」和「觸發前顯示什麼說明」。使用者拒絕後，再次請求不會彈出系統對話框 — 必須引導使用者到系統設定手動開啟。這意味著第一次請求的時機和說明內容直接影響授權率。</p>
<h2 id="請求時機">請求時機</h2>
<h3 id="首次開啟時一次性請求">首次開啟時一次性請求</h3>
<p>App 首次啟動時依序請求所有需要的權限。優點是使用者只被打斷一次；缺點是使用者尚未使用任何功能，不理解每個權限的用途，傾向拒絕。</p>
<p>這個模式適合權限數量少（1-2 個）且和 app 核心功能直接相關的情境。相機 app 在首次開啟時請求相機權限，使用者能直覺理解原因。</p>
<h3 id="功能使用時即時請求">功能使用時即時請求</h3>
<p>使用者點擊需要權限的功能時才請求。優點是使用者在操作 context 中，能理解為什麼需要這個權限；缺點是操作流程被打斷。</p>
<p>這個模式適合權限和特定功能綁定的情境。掃描 QR code 時請求相機權限，使用者正在嘗試掃描，理解為什麼需要相機。</p>
<h3 id="推薦策略">推薦策略</h3>
<p>功能使用時即時請求是多數場景的推薦策略。使用者有操作 context，授權率較高。打斷可以透過 pre-permission 說明畫面降低突兀感。</p>
<h2 id="pre-permission-說明畫面">Pre-permission 說明畫面</h2>
<p>在觸發系統權限對話框之前，app 先顯示自己的說明畫面，解釋為什麼需要這個權限和用途。</p>
<p>說明畫面的設計要點：</p>
<p><strong>說明用途而非技術細節</strong>。「需要相機來掃描裝置上的 QR code」比「app 需要存取 AVCaptureDevice」更有用。使用者關心的是「為什麼」，不是「用什麼 API」。</p>
<p><strong>提供「稍後再說」選項</strong>。使用者可能想先了解 app 再決定是否授權。強制授權（沒有跳過選項）會讓使用者選擇拒絕。</p>
<p><strong>視覺化說明</strong>。用截圖或圖示展示「授權後這個功能長什麼樣」，讓使用者預覽授權的價值。</p>
<h2 id="拒絕後的處理">拒絕後的處理</h2>
<p>使用者拒絕權限後，app 需要：</p>
<p><strong>記住拒絕狀態</strong>。不要在每次使用者操作同一功能時都顯示 pre-permission 說明（使用者已經表達不想授權，反覆詢問是騷擾）。</p>
<p><strong>提供功能降級</strong>。如果可能，提供不需要權限的替代方案。掃描 QR code 可以改成手動輸入配對碼。</p>
<p><strong>在適當時機再提醒</strong>。使用者多次使用需要權限的功能但都因為沒有權限而失敗時，用非侵入式提示（Snackbar）說明「開啟相機權限可以使用掃描功能」加設定連結。</p>
<p><strong>引導到系統設定</strong>。一旦使用者在系統對話框中選擇「不再詢問」（Android）或拒絕（iOS 拒絕後系統不再彈窗），唯一的路徑是引導使用者到系統設定手動開啟。提供直接跳轉到 app 設定頁面的按鈕。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Gate 設計的通用方法論 → <a href="/blog/ux-design/02-gate-fallback/gate-three-questions/" data-link-title="Gate 分類與三問設計法" data-link-desc="每個 gate 設計時問三個問題：成功時做什麼、失敗時做什麼、使用者不知道發生什麼時做什麼">Gate 分類與三問設計法</a></li>
<li>網路 gate 的處理策略 → <a href="/blog/ux-design/02-gate-fallback/network-offline-ux/" data-link-title="網路斷線 UX 模式" data-link-desc="Offline-first / retry / degraded mode 三種網路 gate 的處理策略 — 取決於功能是否依賴即時連線">網路斷線 UX 模式</a></li>
<li>開發環境遮蔽 gate 問題 → <a href="/blog/ux-design/02-gate-fallback/dev-vs-real-gate-behavior/" data-link-title="開發環境 vs 真機的 gate 行為差異表" data-link-desc="模擬器、debug build、test 環境中的 gate 行為和真機 release build 不同 — 差異表讓開發者在上機前知道哪些 gate 還沒被真實驗證">開發環境 vs 真機的 gate 行為差異表</a></li>
</ul>
]]></content:encoded></item><item><title>flutter devices 卡住的訊號：device 數從 N 變 N-1 與 emulator 半活</title><link>https://tarrragon.github.io/blog/work-log/flutter-devices-%E5%8D%A1%E4%BD%8F%E7%9A%84%E8%A8%8A%E8%99%9Fdevice-%E6%95%B8%E5%BE%9E-n-%E8%AE%8A-n-1-%E8%88%87-emulator-%E5%8D%8A%E6%B4%BB/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter-devices-%E5%8D%A1%E4%BD%8F%E7%9A%84%E8%A8%8A%E8%99%9Fdevice-%E6%95%B8%E5%BE%9E-n-%E8%AE%8A-n-1-%E8%88%87-emulator-%E5%8D%8A%E6%B4%BB/</guid><description>&lt;p>&lt;code>flutter devices&lt;/code> 卡住時，最有用的訊號是「device 清單是否穩定」。這次的關鍵訊號是連續兩次掃描從 &lt;code>Found 4 connected devices&lt;/code> 變成 &lt;code>Found 3 connected devices&lt;/code>，再加上 &lt;code>Error -2 retrieving device properties for sdk gphone64 arm64&lt;/code>。這代表 ADB server 看得到某個 emulator entry，但對該 entry 的 property 查詢已經不穩定。&lt;/p>
&lt;p>這類狀態可以稱為 Android emulator 半活（zombie）：emulator host process 還在、ADB 清單仍殘留 device，但 emulator 內的 &lt;code>adbd&lt;/code> 或 Android system 已停止回應。Flutter 在掃描階段會對每個 Android device 查 properties，掃描到這個半活 device 就卡在 timeout。&lt;/p>
&lt;hr>
&lt;h2 id="事故場景">事故場景&lt;/h2>
&lt;p>事故場景的核心是「Flutter 指令看似卡住，其實卡在下游 device property 查詢」。連續跑 &lt;code>flutter devices&lt;/code> 時，輸出長這樣：&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">$ flutter devices
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">Found 4 connected devices:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">Error -2 retrieving device properties for sdk gphone64 arm64:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">[卡住]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">$ flutter devices
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">Found 3 connected devices:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">[繼續卡]&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段輸出有兩個值得注意的點：&lt;/p>
&lt;ol>
&lt;li>&lt;code>Error -2 retrieving device properties for sdk gphone64 arm64:&lt;/code> 訊息出現後仍繼續等待，代表 Flutter 沒有在第一個 device 失敗時 fail-fast&lt;/li>
&lt;li>第一次 &lt;code>Found 4&lt;/code>、第二次 &lt;code>Found 3&lt;/code>，代表 device 數在兩次掃描之間自己少了 1&lt;/li>
&lt;/ol>
&lt;p>&lt;code>sdk gphone64 arm64&lt;/code> 是 Android Studio AVD 預設模板（Google Phone 64-bit ARM）建出來的 emulator 顯示名稱、macOS 上跑 Android system image 都會看到這個。&lt;/p>
&lt;h3 id="為什麼計數變化是關鍵徵兆">為什麼計數變化是關鍵徵兆&lt;/h3>
&lt;p>device 數從 4 變 3，代表 ADB 對某個 emulator 的狀態判斷在兩次查詢之間變了。ADB server 內部追蹤每個 device 的狀態（&lt;code>device&lt;/code> / &lt;code>offline&lt;/code> / &lt;code>unauthorized&lt;/code> / &lt;code>no permissions&lt;/code>）；半活 emulator 在第一次掃描時仍被列在 &lt;code>Found 4&lt;/code>，第二次掃描時可能已被標成 offline 或從候選清單移除，所以掉到 &lt;code>Found 3&lt;/code>。&lt;/p>
&lt;p>判讀訊號是「同一條 list 指令連跑兩次，device 數或 device 狀態自己變」。正常穩定狀態下，清單應該保持一致；清單漂移代表 ADB server 對某個 entry 的看法不穩定，下一步要先找出那個 entry，再決定是否重啟 ADB 或 emulator。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼-flutter-devices-會卡住">為什麼 flutter devices 會卡住&lt;/h2>
&lt;p>&lt;code>flutter devices&lt;/code> 的責任是把每個候選 device 補成 Flutter 可用的 target，而不只是印出 &lt;code>adb devices&lt;/code> 的結果。Flutter 對每個 ADB 看得到的 Android device 還要做幾件事：&lt;/p>
&lt;ol>
&lt;li>跑 &lt;code>adb shell getprop ro.product.cpu.abi&lt;/code> 拉 ABI&lt;/li>
&lt;li>跑 &lt;code>adb shell getprop ro.build.version.sdk&lt;/code> 拉 SDK level&lt;/li>
&lt;li>跑 &lt;code>adb shell getprop ro.product.model&lt;/code> 拉裝置型號&lt;/li>
&lt;li>視情況跑 &lt;code>adb shell&lt;/code> 其他指令確認 Flutter 支援度&lt;/li>
&lt;/ol>
&lt;p>這些是同步、序列化、有 timeout 的呼叫；timeout 通常設得相對寬鬆，讓慢一點的真機也能跑通。當其中一個 device 是 zombie 狀態：&lt;/p></description><content:encoded><![CDATA[<p><code>flutter devices</code> 卡住時，最有用的訊號是「device 清單是否穩定」。這次的關鍵訊號是連續兩次掃描從 <code>Found 4 connected devices</code> 變成 <code>Found 3 connected devices</code>，再加上 <code>Error -2 retrieving device properties for sdk gphone64 arm64</code>。這代表 ADB server 看得到某個 emulator entry，但對該 entry 的 property 查詢已經不穩定。</p>
<p>這類狀態可以稱為 Android emulator 半活（zombie）：emulator host process 還在、ADB 清單仍殘留 device，但 emulator 內的 <code>adbd</code> 或 Android system 已停止回應。Flutter 在掃描階段會對每個 Android device 查 properties，掃描到這個半活 device 就卡在 timeout。</p>
<hr>
<h2 id="事故場景">事故場景</h2>
<p>事故場景的核心是「Flutter 指令看似卡住，其實卡在下游 device property 查詢」。連續跑 <code>flutter devices</code> 時，輸出長這樣：</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">$ flutter devices
</span></span><span class="line"><span class="ln">2</span><span class="cl">Found 4 connected devices:
</span></span><span class="line"><span class="ln">3</span><span class="cl">Error -2 retrieving device properties for sdk gphone64 arm64:
</span></span><span class="line"><span class="ln">4</span><span class="cl">[卡住]
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl">$ flutter devices
</span></span><span class="line"><span class="ln">7</span><span class="cl">Found 3 connected devices:
</span></span><span class="line"><span class="ln">8</span><span class="cl">[繼續卡]</span></span></code></pre></div><p>這段輸出有兩個值得注意的點：</p>
<ol>
<li><code>Error -2 retrieving device properties for sdk gphone64 arm64:</code> 訊息出現後仍繼續等待，代表 Flutter 沒有在第一個 device 失敗時 fail-fast</li>
<li>第一次 <code>Found 4</code>、第二次 <code>Found 3</code>，代表 device 數在兩次掃描之間自己少了 1</li>
</ol>
<p><code>sdk gphone64 arm64</code> 是 Android Studio AVD 預設模板（Google Phone 64-bit ARM）建出來的 emulator 顯示名稱、macOS 上跑 Android system image 都會看到這個。</p>
<h3 id="為什麼計數變化是關鍵徵兆">為什麼計數變化是關鍵徵兆</h3>
<p>device 數從 4 變 3，代表 ADB 對某個 emulator 的狀態判斷在兩次查詢之間變了。ADB server 內部追蹤每個 device 的狀態（<code>device</code> / <code>offline</code> / <code>unauthorized</code> / <code>no permissions</code>）；半活 emulator 在第一次掃描時仍被列在 <code>Found 4</code>，第二次掃描時可能已被標成 offline 或從候選清單移除，所以掉到 <code>Found 3</code>。</p>
<p>判讀訊號是「同一條 list 指令連跑兩次，device 數或 device 狀態自己變」。正常穩定狀態下，清單應該保持一致；清單漂移代表 ADB server 對某個 entry 的看法不穩定，下一步要先找出那個 entry，再決定是否重啟 ADB 或 emulator。</p>
<hr>
<h2 id="為什麼-flutter-devices-會卡住">為什麼 flutter devices 會卡住</h2>
<p><code>flutter devices</code> 的責任是把每個候選 device 補成 Flutter 可用的 target，而不只是印出 <code>adb devices</code> 的結果。Flutter 對每個 ADB 看得到的 Android device 還要做幾件事：</p>
<ol>
<li>跑 <code>adb shell getprop ro.product.cpu.abi</code> 拉 ABI</li>
<li>跑 <code>adb shell getprop ro.build.version.sdk</code> 拉 SDK level</li>
<li>跑 <code>adb shell getprop ro.product.model</code> 拉裝置型號</li>
<li>視情況跑 <code>adb shell</code> 其他指令確認 Flutter 支援度</li>
</ol>
<p>這些是同步、序列化、有 timeout 的呼叫；timeout 通常設得相對寬鬆，讓慢一點的真機也能跑通。當其中一個 device 是 zombie 狀態：</p>
<ul>
<li><code>adb shell getprop ...</code> 送出後，ADB 把指令轉發給 emulator 內的 <code>adbd</code></li>
<li><code>adbd</code> 收到了但 Android system 沒回應，或 emulator process 整個卡住沒在處理 ADB request</li>
<li>Flutter 端等 timeout、再 retry、再等更長 timeout，看起來就是「整個指令卡住」</li>
</ul>
<p><code>Error -2 retrieving device properties</code> 是其中一次嘗試 timeout 拿到的訊息（<code>-2</code> 是 Dart <code>ProcessException</code> 對應 <code>adb</code> exit code 的內部映射）。Flutter 仍會繼續掃描其他 device，所以使用者看到的是「印出錯誤訊息 + 繼續卡」。</p>
<hr>
<h2 id="為什麼是半活狀態">為什麼是半活狀態</h2>
<p>Android emulator 在 macOS 上的結構大致是：</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">qemu-system-aarch64 (host process)
</span></span><span class="line"><span class="ln">2</span><span class="cl">  ├─ Android kernel
</span></span><span class="line"><span class="ln">3</span><span class="cl">  ├─ Android system services
</span></span><span class="line"><span class="ln">4</span><span class="cl">  └─ adbd (在 emulator 內部，跟 host ADB server 對接)</span></span></code></pre></div><p>半活狀態指的是「host process 還在，但 device 內部服務已無法完成 ADB request」。完全正常時 emulator 跑得動、ADB 也通；完全退出時 emulator process 已結束、ADB 清單看不到它。半活介於兩者之間：</p>
<ul>
<li>qemu host process 還在（活著）</li>
<li>emulator 內的某個環節卡住（Android system 沒在 schedule、或 adbd 卡在某個 mutex）</li>
<li>ADB server 還記得有這個 device，尚未穩定 evict</li>
<li>任何 <code>adb shell</code> 指令都打不通</li>
</ul>
<p>常見成因：</p>
<ul>
<li><strong>Quick Boot snapshot 還原失敗或部分還原</strong>——AVD 預設關機是 quick boot（存 snapshot），下次開機從 snapshot 還原；snapshot 跟當前 host kernel / hypervisor 狀態不相容時會半開機</li>
<li><strong>macOS 從 sleep 喚醒後 hypervisor framework 重置</strong>——emulator 是用 Hypervisor.framework，喚醒後虛擬 CPU 可能停在奇怪 state</li>
<li><strong>host 端記憶體壓力導致 emulator 被 swap 嚴重</strong>——表面看起來像卡，其實是在等 page fault</li>
</ul>
<p>這一層的操作目標是恢復工具鏈，而不是追到每個 emulator 內部 race condition。若症狀符合清單漂移與 property 查詢 timeout，先按恢復順序處理；只有反覆發生時，再追 AVD snapshot、system image 或 host 資源壓力。</p>
<hr>
<h2 id="恢復順序從輕到重">恢復順序（從輕到重）</h2>
<p>恢復順序的核心是先重置最小邊界，再逐層擴大。每一步都要重新跑一次 <code>flutter devices</code> 或 <code>adb devices</code>，確認是否已經恢復，避免直接砍掉 emulator 或清資料。</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"># 1. 看 ADB 對每個 device 的狀態</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">adb devices
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># 看到 offline / no device / unauthorized 等異常狀態 → 先鎖定該 device</span></span></span></code></pre></div><p>如果有 device 顯示 <code>offline</code>，或正常列出但實際打不通，先重啟 ADB server：</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"># 2. 重啟 ADB server（只重置 host 端 ADB session）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">adb kill-server <span class="o">&amp;&amp;</span> adb start-server
</span></span><span class="line"><span class="ln">3</span><span class="cl">adb devices
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 多數狀況下，ADB 重啟後對該 device 的查詢會 fail-fast，flutter devices 會恢復</span></span></span></code></pre></div><p>如果 ADB 重啟後仍打不通該 emulator，再處理 emulator process：</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"># 3. 對特定 emulator 發 emu kill（讓它優雅關閉）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">adb -s emulator-5554 emu <span class="nb">kill</span>   <span class="c1"># 把 5554 換成實際 port</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"># 4. 還在的話，終止 qemu process</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">pkill -f qemu-system-aarch64</span></span></code></pre></div><p>長期修復路由是清掉不穩定的 snapshot。開 Android Studio → <strong>AVD Manager</strong> → 該 emulator 旁邊的小箭頭 → <strong>Cold Boot Now</strong>（避免 Quick Boot）。如果冷啟動後仍反覆壞，選 <strong>Wipe Data</strong> 把 snapshot 與 emulator 內資料整個清掉。</p>
<hr>
<h2 id="通用診斷思維">通用診斷思維</h2>
<p>工具鏈卡住的診斷核心是先區分「上游 CLI 壞掉」還是「下游 target 沒回應」。<code>flutter</code> / <code>adb</code> 指令卡住時，先用清單穩定性與 device 識別碼定位下游狀態，再決定重啟邊界。</p>
<ol>
<li><strong>觀察「同一指令連跑兩次結果是否一致」</strong>：不一致（device 數變、訊息變）等於某層狀態不穩定</li>
<li><strong>訊息裡有 device 識別碼就釘住它</strong>：<code>sdk gphone64 arm64</code>、<code>emulator-5554</code>、序號等都是 ADB 層的識別，可直接拿來 <code>adb -s &lt;id&gt; ...</code> 局部診斷</li>
<li><strong>從外往內排除</strong>：ADB server → 個別 device → emulator process → emulator 內 system，逐層重啟</li>
<li><strong>重啟邊界越大、副作用越大</strong>：<code>adb kill-server</code> 只影響 ADB session（其他 device 連線會斷一下），<code>pkill qemu</code> 直接砍 emulator，<code>Wipe Data</code> 連 emulator 內的資料都清。能用輕量手段解決就停在那層</li>
</ol>
<hr>
<h2 id="操作判準">操作判準</h2>
<ol>
<li><strong>「device 數兩次掃描之間自己變」是 zombie emulator 的關鍵徵兆</strong>：計數變化代表 ADB 內部狀態不穩定</li>
<li><strong><code>Error -2 retrieving device properties</code> 是 property 查詢失敗訊號</strong>：Flutter 仍可能繼續處理其他 device，結果是「印出錯誤訊息但繼續卡」</li>
<li><strong><code>adb kill-server &amp;&amp; adb start-server</code> 是輕量首選</strong>：它只重置 ADB session，不動 emulator 本身，多數狀況下可讓壞 device fail-fast</li>
<li><strong>半活狀態跟 application code 層級不同</strong>：先把工具鏈狀態釐清，再回到剛改的程式碼</li>
</ol>
<hr>
<h2 id="適用範圍">適用範圍</h2>
<p>這個診斷思維不限於 Android emulator：</p>
<ul>
<li>iOS Simulator 卡住時 <code>xcrun simctl list</code> 印不出來——同樣的「指令卡 + 訊息看似 fatal 但 process 仍存在」結構</li>
<li><code>flutter devices</code> 對任何 device（含 iOS、Web、desktop）的查詢都會走類似的「列出 → 逐個 query property」流程、任一層卡都會表現為類似症狀</li>
<li>廣義地說，任何「server 維護一份 client 清單 + 對每個 client 做同步呼叫」的架構（k8s <code>kubectl get pods</code> 對 zombie node、docker <code>docker ps</code> 對掛掉的 container runtime 等）都有同款 failure mode</li>
</ul>
<p>辨認規則一致：<strong>list 指令連跑兩次結果不一致 → 維護清單的 server 對某個 entry 的看法不穩定 → 找出那個 entry 局部處理</strong>。這條規則的邊界是：如果清單穩定但操作失敗，問題更可能在該 target 的權限、版本或 runtime 狀態，需要改走對應工具的細部診斷。</p>
]]></content:encoded></item><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 強制覆寫 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>Android 無線調試連接指南</title><link>https://tarrragon.github.io/blog/other/android-%E7%84%A1%E7%B7%9A%E8%AA%BF%E8%A9%A6%E9%80%A3%E6%8E%A5%E6%8C%87%E5%8D%97/</link><pubDate>Sat, 01 Feb 2025 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/other/android-%E7%84%A1%E7%B7%9A%E8%AA%BF%E8%A9%A6%E9%80%A3%E6%8E%A5%E6%8C%87%E5%8D%97/</guid><description>&lt;h2 id="前置條件">前置條件&lt;/h2>
&lt;ul>
&lt;li>Android 裝置系統版本 &lt;strong>Android 11 以上&lt;/strong>&lt;/li>
&lt;li>電腦與 Android 裝置連接在&lt;strong>同一個區域網路&lt;/strong>&lt;/li>
&lt;li>已安裝 ADB 工具（通常隨 Android Studio 安裝）&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="步驟一啟用無線偵錯">步驟一：啟用無線偵錯&lt;/h2>
&lt;ol>
&lt;li>進入 Android 裝置的 &lt;strong>設定 → 開發人員選項&lt;/strong>&lt;/li>
&lt;li>開啟 &lt;strong>無線偵錯（Wireless debugging）&lt;/strong>&lt;/li>
&lt;li>在彈出的對話框中選擇「允許」&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="步驟二配對裝置首次連接需要">步驟二：配對裝置（首次連接需要）&lt;/h2>
&lt;h3 id="在-android-裝置上">在 Android 裝置上&lt;/h3>
&lt;ol>
&lt;li>點擊 &lt;strong>無線偵錯&lt;/strong> 進入詳細頁面&lt;/li>
&lt;li>點擊 &lt;strong>使用配對碼配對裝置&lt;/strong>&lt;/li>
&lt;li>記下畫面上顯示的：
&lt;ul>
&lt;li>&lt;strong>IP 位址與配對端口&lt;/strong>（例如：&lt;code>192.168.1.100:45213&lt;/code>）&lt;/li>
&lt;li>&lt;strong>配對碼&lt;/strong>（6 位數字）&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ol>
&lt;h3 id="在電腦終端機上">在電腦終端機上&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">adb pair &amp;lt;IP&amp;gt;:&amp;lt;配對端口&amp;gt;&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>範例：&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">adb pair 192.168.1.100:45213&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>輸入配對碼後，看到以下訊息表示配對成功：&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">Successfully paired to 192.168.1.100:45213&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;blockquote>
&lt;p>&lt;strong>注意&lt;/strong>：配對碼有時效性，產生後請盡快輸入&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="步驟三連接裝置">步驟三：連接裝置&lt;/h2>
&lt;h3 id="在-android-裝置上-1">在 Android 裝置上&lt;/h3>
&lt;p>回到&lt;strong>無線偵錯主頁面&lt;/strong>，查看顯示的 &lt;strong>IP 位址與連接端口&lt;/strong>（與配對端口不同）&lt;/p>
&lt;p>例如：&lt;code>192.168.1.100:38745&lt;/code>&lt;/p>
&lt;h3 id="在電腦終端機上-1">在電腦終端機上&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">adb connect &amp;lt;IP&amp;gt;:&amp;lt;連接端口&amp;gt;&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>範例：&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">adb connect 192.168.1.100:38745&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>看到以下訊息表示連接成功：&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">connected to 192.168.1.100:38745&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="步驟四驗證連接">步驟四：驗證連接&lt;/h2>





&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">adb devices&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>或使用 Flutter：&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">flutter devices&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>應該能看到無線連接的裝置列表。&lt;/p>
&lt;hr>
&lt;h2 id="常用指令速查">常用指令速查&lt;/h2>
&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;/td>
 &lt;td>&lt;code>adb pair &amp;lt;IP&amp;gt;:&amp;lt;配對端口&amp;gt;&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>連接裝置&lt;/td>
 &lt;td>&lt;code>adb connect &amp;lt;IP&amp;gt;:&amp;lt;連接端口&amp;gt;&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>查看已連接裝置&lt;/td>
 &lt;td>&lt;code>adb devices&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>中斷連接&lt;/td>
 &lt;td>&lt;code>adb disconnect &amp;lt;IP&amp;gt;:&amp;lt;連接端口&amp;gt;&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>重啟 ADB 服務&lt;/td>
 &lt;td>&lt;code>adb kill-server &amp;amp;&amp;amp; adb start-server&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="疑難排解">疑難排解&lt;/h2>
&lt;h3 id="配對失敗">配對失敗&lt;/h3>
&lt;ul>
&lt;li>確認電腦與裝置在同一區網&lt;/li>
&lt;li>檢查路由器是否開啟 AP 隔離功能&lt;/li>
&lt;li>暫時關閉電腦防火牆測試&lt;/li>
&lt;/ul>
&lt;h3 id="連接後無法使用">連接後無法使用&lt;/h3>
&lt;ul>
&lt;li>確認使用的是「連接端口」而非「配對端口」&lt;/li>
&lt;li>嘗試重啟 ADB 服務：&lt;code>adb kill-server &amp;amp;&amp;amp; adb start-server&lt;/code>&lt;/li>
&lt;/ul>
&lt;h3 id="裝置離線或消失">裝置離線或消失&lt;/h3>
&lt;ul>
&lt;li>無線偵錯可能因裝置休眠而中斷&lt;/li>
&lt;li>重新執行 &lt;code>adb connect&lt;/code> 即可恢復&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="流程圖">流程圖&lt;/h2>





&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">┌─────────────────┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">│ 啟用無線偵錯 │
&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">┌─────────────────┐
&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">┌─────────────────┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">│ adb pair │ ← 首次連接需要
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">└────────┬────────┘
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">┌─────────────────┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">│ 取得連接端口 │ ← 無線偵錯主頁面
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">└────────┬────────┘
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">┌─────────────────┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">│ adb connect │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">└────────┬────────┘
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl">┌─────────────────┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl">│ 連接完成 │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl">└─────────────────┘&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr></description><content:encoded><![CDATA[<h2 id="前置條件">前置條件</h2>
<ul>
<li>Android 裝置系統版本 <strong>Android 11 以上</strong></li>
<li>電腦與 Android 裝置連接在<strong>同一個區域網路</strong></li>
<li>已安裝 ADB 工具（通常隨 Android Studio 安裝）</li>
</ul>
<hr>
<h2 id="步驟一啟用無線偵錯">步驟一：啟用無線偵錯</h2>
<ol>
<li>進入 Android 裝置的 <strong>設定 → 開發人員選項</strong></li>
<li>開啟 <strong>無線偵錯（Wireless debugging）</strong></li>
<li>在彈出的對話框中選擇「允許」</li>
</ol>
<hr>
<h2 id="步驟二配對裝置首次連接需要">步驟二：配對裝置（首次連接需要）</h2>
<h3 id="在-android-裝置上">在 Android 裝置上</h3>
<ol>
<li>點擊 <strong>無線偵錯</strong> 進入詳細頁面</li>
<li>點擊 <strong>使用配對碼配對裝置</strong></li>
<li>記下畫面上顯示的：
<ul>
<li><strong>IP 位址與配對端口</strong>（例如：<code>192.168.1.100:45213</code>）</li>
<li><strong>配對碼</strong>（6 位數字）</li>
</ul>
</li>
</ol>
<h3 id="在電腦終端機上">在電腦終端機上</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">adb pair &lt;IP&gt;:&lt;配對端口&gt;</span></span></code></pre></div><p>範例：</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">adb pair 192.168.1.100:45213</span></span></code></pre></div><p>輸入配對碼後，看到以下訊息表示配對成功：</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">Successfully paired to 192.168.1.100:45213</span></span></code></pre></div><blockquote>
<p><strong>注意</strong>：配對碼有時效性，產生後請盡快輸入</p></blockquote>
<hr>
<h2 id="步驟三連接裝置">步驟三：連接裝置</h2>
<h3 id="在-android-裝置上-1">在 Android 裝置上</h3>
<p>回到<strong>無線偵錯主頁面</strong>，查看顯示的 <strong>IP 位址與連接端口</strong>（與配對端口不同）</p>
<p>例如：<code>192.168.1.100:38745</code></p>
<h3 id="在電腦終端機上-1">在電腦終端機上</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">adb connect &lt;IP&gt;:&lt;連接端口&gt;</span></span></code></pre></div><p>範例：</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">adb connect 192.168.1.100:38745</span></span></code></pre></div><p>看到以下訊息表示連接成功：</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">connected to 192.168.1.100:38745</span></span></code></pre></div><hr>
<h2 id="步驟四驗證連接">步驟四：驗證連接</h2>





<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">adb devices</span></span></code></pre></div><p>或使用 Flutter：</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 devices</span></span></code></pre></div><p>應該能看到無線連接的裝置列表。</p>
<hr>
<h2 id="常用指令速查">常用指令速查</h2>
<table>
  <thead>
      <tr>
          <th>用途</th>
          <th>指令</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>配對裝置</td>
          <td><code>adb pair &lt;IP&gt;:&lt;配對端口&gt;</code></td>
      </tr>
      <tr>
          <td>連接裝置</td>
          <td><code>adb connect &lt;IP&gt;:&lt;連接端口&gt;</code></td>
      </tr>
      <tr>
          <td>查看已連接裝置</td>
          <td><code>adb devices</code></td>
      </tr>
      <tr>
          <td>中斷連接</td>
          <td><code>adb disconnect &lt;IP&gt;:&lt;連接端口&gt;</code></td>
      </tr>
      <tr>
          <td>重啟 ADB 服務</td>
          <td><code>adb kill-server &amp;&amp; adb start-server</code></td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="疑難排解">疑難排解</h2>
<h3 id="配對失敗">配對失敗</h3>
<ul>
<li>確認電腦與裝置在同一區網</li>
<li>檢查路由器是否開啟 AP 隔離功能</li>
<li>暫時關閉電腦防火牆測試</li>
</ul>
<h3 id="連接後無法使用">連接後無法使用</h3>
<ul>
<li>確認使用的是「連接端口」而非「配對端口」</li>
<li>嘗試重啟 ADB 服務：<code>adb kill-server &amp;&amp; adb start-server</code></li>
</ul>
<h3 id="裝置離線或消失">裝置離線或消失</h3>
<ul>
<li>無線偵錯可能因裝置休眠而中斷</li>
<li>重新執行 <code>adb connect</code> 即可恢復</li>
</ul>
<hr>
<h2 id="流程圖">流程圖</h2>





<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">┌─────────────────┐
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">│  啟用無線偵錯   │
</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></span><span class="line"><span class="ln"> 5</span><span class="cl">┌─────────────────┐
</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></span><span class="line"><span class="ln"> 8</span><span class="cl">         ▼
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">┌─────────────────┐
</span></span><span class="line"><span class="ln">10</span><span class="cl">│   adb pair      │ ← 首次連接需要
</span></span><span class="line"><span class="ln">11</span><span class="cl">└────────┬────────┘
</span></span><span class="line"><span class="ln">12</span><span class="cl">         ▼
</span></span><span class="line"><span class="ln">13</span><span class="cl">┌─────────────────┐
</span></span><span class="line"><span class="ln">14</span><span class="cl">│  取得連接端口   │ ← 無線偵錯主頁面
</span></span><span class="line"><span class="ln">15</span><span class="cl">└────────┬────────┘
</span></span><span class="line"><span class="ln">16</span><span class="cl">         ▼
</span></span><span class="line"><span class="ln">17</span><span class="cl">┌─────────────────┐
</span></span><span class="line"><span class="ln">18</span><span class="cl">│  adb connect    │
</span></span><span class="line"><span class="ln">19</span><span class="cl">└────────┬────────┘
</span></span><span class="line"><span class="ln">20</span><span class="cl">         ▼
</span></span><span class="line"><span class="ln">21</span><span class="cl">┌─────────────────┐
</span></span><span class="line"><span class="ln">22</span><span class="cl">│    連接完成     │
</span></span><span class="line"><span class="ln">23</span><span class="cl">└─────────────────┘</span></span></code></pre></div><hr>
]]></content:encoded></item></channel></rss>