<?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>Flutter on Tarragon</title><link>https://tarrragon.github.io/blog/tags/flutter/</link><description>Recent content in Flutter on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Tue, 30 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/flutter/index.xml" rel="self" type="application/rss+xml"/><item><title>T.C1 WebSocket text/binary frame 被 FakeWebSocketChannel 遮蔽</title><link>https://tarrragon.github.io/blog/testing/cases/ws-text-binary-frame-mock-blindspot/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/cases/ws-text-binary-frame-mock-blindspot/</guid><description>&lt;p>這個案例的核心責任是說明 mock 的「API 層級模擬」和真實服務的「協議層級行為」之間的結構性斷裂。WebSocket 的 text frame（opcode 0x1）和 binary frame（opcode 0x2）在 Dart API 層面都是 &lt;code>sink.add(dynamic)&lt;/code>，但在協議層是不同的 opcode，ttyd 只接受 text frame。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>app_tunnel Flutter app 連接 ttyd WebSocket 終端機。&lt;code>ConnectionManager.sendData()&lt;/code> 接收 &lt;code>Uint8List&lt;/code> 型別的鍵盤輸入，直接傳給 &lt;code>_channel!.sink.add(data)&lt;/code>。Dart 的 &lt;code>IOWebSocketChannel&lt;/code> 對 &lt;code>Uint8List&lt;/code> 發送 binary frame（opcode 0x2），ttyd 期望 text frame（opcode 0x1），收到 binary frame 靜默忽略。&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;/td>
 &lt;td>所有鍵盤輸入無效（使用者打字無回應）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Unit test 結果&lt;/td>
 &lt;td>192 個全過（&lt;code>FakeWebSocketChannel.sink.add&lt;/code> 不區分型別）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>實機表現&lt;/td>
 &lt;td>連線成功但終端機完全無反應&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>修復&lt;/td>
 &lt;td>&lt;code>if (data is Uint8List) sink.add(String.fromCharCodes(data))&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Mock 模擬的是 Dart API 契約，不是 WebSocket 協議契約&lt;/strong>。&lt;code>FakeWebSocketChannel&lt;/code> 忠實實作了 &lt;code>WebSocketChannel&lt;/code> 的 Dart interface — &lt;code>sink.add(dynamic)&lt;/code> 接受任何型別。但 &lt;code>IOWebSocketChannel&lt;/code> 的 &lt;code>sink.add&lt;/code> 實際行為是：&lt;code>String&lt;/code> → text frame，&lt;code>List&amp;lt;int&amp;gt;&lt;/code> / &lt;code>Uint8List&lt;/code> → binary frame。Mock 沒有也不應該模擬這個協議層行為。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>ttyd 的靜默忽略放大了問題&lt;/strong>。如果 ttyd 對 binary frame 回傳錯誤碼或斷線，app 至少會進入 error 狀態讓開發者察覺。靜默忽略讓問題從「連線失敗」變成「連線成功但無回應」，debug 方向完全錯誤。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>型別系統幫不上忙&lt;/strong>。Dart 的 &lt;code>WebSocketSink.add&lt;/code> 簽名是 &lt;code>void add(dynamic event)&lt;/code> — &lt;code>dynamic&lt;/code> 吃掉了型別資訊。即使用強型別語言，如果 API 設計成 &lt;code>dynamic&lt;/code>，型別檢查無法區分協議語意。&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="策略">策略&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>Protocol integration test&lt;/strong>：對真實 ttyd 發送 &lt;code>Uint8List&lt;/code> 和 &lt;code>String&lt;/code>，斷言兩者行為差異。一個 5 行 test 就能抓到這個問題。&lt;/li>
&lt;li>&lt;strong>在 sendData 層做型別轉換&lt;/strong>：不依賴下游 channel 的行為，在自己的 API 邊界確保型別正確。&lt;/li>
&lt;li>&lt;strong>Log 送出的 frame type&lt;/strong>：&lt;code>developer.log('WS send: type=${data.runtimeType}')&lt;/code> 讓 debug 時立即可見。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>想寫 protocol integration test → &lt;a href="https://tarrragon.github.io/blog/testing/03-protocol-integration-test/" data-link-title="模組三：協議整合測試" data-link-desc="對真實服務驗證 WebSocket / gRPC / HTTP 協議契約 — unit test 和 E2E test 之間的一層">模組三：協議整合測試&lt;/a>&lt;/li>
&lt;li>想理解 mock 遮蔽的系統性機制 → &lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/mock-masking-mechanism/" data-link-title="Mock 遮蔽機制分析" data-link-desc="Mock 在 API 層、協議層、環境層之間製造的結構性盲區 — 斷裂點在哪、為什麼 mock 無法也不應該模擬協議行為">Mock 遮蔽機制分析&lt;/a>&lt;/li>
&lt;li>類似案例（auth handshake） → &lt;a href="https://tarrragon.github.io/blog/testing/cases/auth-handshake-missing-mock-blindspot/" data-link-title="T.C2 Auth handshake 邏輯缺失被 FakeWebSocketChannel 遮蔽" data-link-desc="ttyd 連線後需要發送 auth token JSON frame 完成認證，整個邏輯未實作 — FakeWebSocketChannel 的 ready 立即完成不需認證，test 永遠看到連線成功">T.C2 Auth handshake 缺失&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 mock 的「API 層級模擬」和真實服務的「協議層級行為」之間的結構性斷裂。WebSocket 的 text frame（opcode 0x1）和 binary frame（opcode 0x2）在 Dart API 層面都是 <code>sink.add(dynamic)</code>，但在協議層是不同的 opcode，ttyd 只接受 text frame。</p>
<h2 id="觀察">觀察</h2>
<p>app_tunnel Flutter app 連接 ttyd WebSocket 終端機。<code>ConnectionManager.sendData()</code> 接收 <code>Uint8List</code> 型別的鍵盤輸入，直接傳給 <code>_channel!.sink.add(data)</code>。Dart 的 <code>IOWebSocketChannel</code> 對 <code>Uint8List</code> 發送 binary frame（opcode 0x2），ttyd 期望 text frame（opcode 0x1），收到 binary frame 靜默忽略。</p>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>影響範圍</td>
          <td>所有鍵盤輸入無效（使用者打字無回應）</td>
      </tr>
      <tr>
          <td>Unit test 結果</td>
          <td>192 個全過（<code>FakeWebSocketChannel.sink.add</code> 不區分型別）</td>
      </tr>
      <tr>
          <td>實機表現</td>
          <td>連線成功但終端機完全無反應</td>
      </tr>
      <tr>
          <td>修復</td>
          <td><code>if (data is Uint8List) sink.add(String.fromCharCodes(data))</code></td>
      </tr>
  </tbody>
</table>
<h2 id="判讀">判讀</h2>
<ol>
<li>
<p><strong>Mock 模擬的是 Dart API 契約，不是 WebSocket 協議契約</strong>。<code>FakeWebSocketChannel</code> 忠實實作了 <code>WebSocketChannel</code> 的 Dart interface — <code>sink.add(dynamic)</code> 接受任何型別。但 <code>IOWebSocketChannel</code> 的 <code>sink.add</code> 實際行為是：<code>String</code> → text frame，<code>List&lt;int&gt;</code> / <code>Uint8List</code> → binary frame。Mock 沒有也不應該模擬這個協議層行為。</p>
</li>
<li>
<p><strong>ttyd 的靜默忽略放大了問題</strong>。如果 ttyd 對 binary frame 回傳錯誤碼或斷線，app 至少會進入 error 狀態讓開發者察覺。靜默忽略讓問題從「連線失敗」變成「連線成功但無回應」，debug 方向完全錯誤。</p>
</li>
<li>
<p><strong>型別系統幫不上忙</strong>。Dart 的 <code>WebSocketSink.add</code> 簽名是 <code>void add(dynamic event)</code> — <code>dynamic</code> 吃掉了型別資訊。即使用強型別語言，如果 API 設計成 <code>dynamic</code>，型別檢查無法區分協議語意。</p>
</li>
</ol>
<h2 id="策略">策略</h2>
<ol>
<li><strong>Protocol integration test</strong>：對真實 ttyd 發送 <code>Uint8List</code> 和 <code>String</code>，斷言兩者行為差異。一個 5 行 test 就能抓到這個問題。</li>
<li><strong>在 sendData 層做型別轉換</strong>：不依賴下游 channel 的行為，在自己的 API 邊界確保型別正確。</li>
<li><strong>Log 送出的 frame type</strong>：<code>developer.log('WS send: type=${data.runtimeType}')</code> 讓 debug 時立即可見。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>想寫 protocol integration test → <a href="/blog/testing/03-protocol-integration-test/" data-link-title="模組三：協議整合測試" data-link-desc="對真實服務驗證 WebSocket / gRPC / HTTP 協議契約 — unit test 和 E2E test 之間的一層">模組三：協議整合測試</a></li>
<li>想理解 mock 遮蔽的系統性機制 → <a href="/blog/testing/01-test-strategy-layers/mock-masking-mechanism/" data-link-title="Mock 遮蔽機制分析" data-link-desc="Mock 在 API 層、協議層、環境層之間製造的結構性盲區 — 斷裂點在哪、為什麼 mock 無法也不應該模擬協議行為">Mock 遮蔽機制分析</a></li>
<li>類似案例（auth handshake） → <a href="/blog/testing/cases/auth-handshake-missing-mock-blindspot/" data-link-title="T.C2 Auth handshake 邏輯缺失被 FakeWebSocketChannel 遮蔽" data-link-desc="ttyd 連線後需要發送 auth token JSON frame 完成認證，整個邏輯未實作 — FakeWebSocketChannel 的 ready 立即完成不需認證，test 永遠看到連線成功">T.C2 Auth handshake 缺失</a></li>
</ul>
]]></content:encoded></item><item><title>U.C1 Terminal 畫面五個狀態零個退出路徑</title><link>https://tarrragon.github.io/blog/ux-design/cases/five-states-zero-exits/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/cases/five-states-zero-exits/</guid><description>&lt;p>這個案例的核心責任是說明「每個畫面每個狀態都需要退出路徑」這個原則為什麼容易在企劃階段被遺漏，以及用什麼工具能系統性地捕捉這類缺口。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>app_tunnel 的 Terminal 畫面用一個 &lt;code>TerminalScreenUiState&lt;/code> enum 管理五個狀態。實機測試前，五個狀態的 UI 實作如下：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>狀態&lt;/th>
 &lt;th>顯示&lt;/th>
 &lt;th>可用操作&lt;/th>
 &lt;th>退出路徑&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>idle&lt;/td>
 &lt;td>空白（自動連線）&lt;/td>
 &lt;td>無&lt;/td>
 &lt;td>無&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>connecting&lt;/td>
 &lt;td>進度指示&lt;/td>
 &lt;td>無&lt;/td>
 &lt;td>無&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>connected&lt;/td>
 &lt;td>終端機 + 工具列&lt;/td>
 &lt;td>打字、特殊鍵&lt;/td>
 &lt;td>無&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>error&lt;/td>
 &lt;td>錯誤訊息 + 重連按鈕&lt;/td>
 &lt;td>重新連線&lt;/td>
 &lt;td>無&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>disconnected&lt;/td>
 &lt;td>「連線中斷」+ 重連按鈕&lt;/td>
 &lt;td>重新連線&lt;/td>
 &lt;td>無&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>使用者從首頁點 Connect Terminal 進入後，無論處於哪個狀態都無法返回首頁。唯一退出方式是殺掉 app。&lt;/p>
&lt;p>W2-001 修復後加入 back 按鈕的狀態：error、disconnected、connecting。但 idle 和 connected 仍缺退出路徑。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>企劃文件的「前端引導」欄位只描述顯示，不描述操作和退出&lt;/strong>。操作盤點表的「前端引導」欄位寫了「連線失敗顯示無法連線」— 覆蓋了 error 狀態的顯示，但沒回答「能做什麼」和「怎麼離開」。從 BDD 操作盤點到 UI 實作之間，缺少把「情境」展開成「畫面 × 狀態 × 操作 × 退出」矩陣的步驟。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>開發者假設使用者只走 happy path&lt;/strong>。「connected 後使用者不會想回首頁」是開發者的隱性假設。實際上使用者可能想：切換到配對畫面重新配對、暫時離開終端機做其他事、遇到問題想重新開始。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>error 和 disconnected 有重連按鈕但沒有 back，也是半成品&lt;/strong>。重連失敗時使用者被困在 error → retry → error 的循環裡。加 back 按鈕讓使用者有第二條路。&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="策略">策略&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>畫面狀態矩陣作為設計產物&lt;/strong>：把每個畫面的每個狀態展開成四欄表格（顯示 / 可用操作 / 進入條件 / 退出路徑）。退出路徑欄位為空 = UX 死胡同，10 分鐘能查完所有畫面。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>退出路徑是預設要求&lt;/strong>：每個畫面的每個狀態至少要有一條退出路徑。即使是 connecting 這種過渡狀態，使用者也應該能取消。這跟 iOS HIG 和 Material Design 對 modal 畫面的 dismiss 要求一致。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Widget test 覆蓋退出路徑&lt;/strong>：狀態矩陣直接轉成 test case — 每個狀態找到 back 按鈕、tap、斷言導航到首頁。&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>想用狀態矩陣設計畫面 → &lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">畫面狀態矩陣的定義與填寫方法&lt;/a>&lt;/li>
&lt;li>想建 widget test 覆蓋導航 → &lt;a href="https://tarrragon.github.io/blog/testing/04-ui-automation/" data-link-title="模組四：自動化 UI 驗證" data-link-desc="Widget test 的狀態覆蓋策略、Playwright 驗證流程、螢幕狀態 coverage">模組四：自動化 UI 驗證&lt;/a>&lt;/li>
&lt;li>類似案例（Gate 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 biometricOnly 無 fallback&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明「每個畫面每個狀態都需要退出路徑」這個原則為什麼容易在企劃階段被遺漏，以及用什麼工具能系統性地捕捉這類缺口。</p>
<h2 id="觀察">觀察</h2>
<p>app_tunnel 的 Terminal 畫面用一個 <code>TerminalScreenUiState</code> enum 管理五個狀態。實機測試前，五個狀態的 UI 實作如下：</p>
<table>
  <thead>
      <tr>
          <th>狀態</th>
          <th>顯示</th>
          <th>可用操作</th>
          <th>退出路徑</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>idle</td>
          <td>空白（自動連線）</td>
          <td>無</td>
          <td>無</td>
      </tr>
      <tr>
          <td>connecting</td>
          <td>進度指示</td>
          <td>無</td>
          <td>無</td>
      </tr>
      <tr>
          <td>connected</td>
          <td>終端機 + 工具列</td>
          <td>打字、特殊鍵</td>
          <td>無</td>
      </tr>
      <tr>
          <td>error</td>
          <td>錯誤訊息 + 重連按鈕</td>
          <td>重新連線</td>
          <td>無</td>
      </tr>
      <tr>
          <td>disconnected</td>
          <td>「連線中斷」+ 重連按鈕</td>
          <td>重新連線</td>
          <td>無</td>
      </tr>
  </tbody>
</table>
<p>使用者從首頁點 Connect Terminal 進入後，無論處於哪個狀態都無法返回首頁。唯一退出方式是殺掉 app。</p>
<p>W2-001 修復後加入 back 按鈕的狀態：error、disconnected、connecting。但 idle 和 connected 仍缺退出路徑。</p>
<h2 id="判讀">判讀</h2>
<ol>
<li>
<p><strong>企劃文件的「前端引導」欄位只描述顯示，不描述操作和退出</strong>。操作盤點表的「前端引導」欄位寫了「連線失敗顯示無法連線」— 覆蓋了 error 狀態的顯示，但沒回答「能做什麼」和「怎麼離開」。從 BDD 操作盤點到 UI 實作之間，缺少把「情境」展開成「畫面 × 狀態 × 操作 × 退出」矩陣的步驟。</p>
</li>
<li>
<p><strong>開發者假設使用者只走 happy path</strong>。「connected 後使用者不會想回首頁」是開發者的隱性假設。實際上使用者可能想：切換到配對畫面重新配對、暫時離開終端機做其他事、遇到問題想重新開始。</p>
</li>
<li>
<p><strong>error 和 disconnected 有重連按鈕但沒有 back，也是半成品</strong>。重連失敗時使用者被困在 error → retry → error 的循環裡。加 back 按鈕讓使用者有第二條路。</p>
</li>
</ol>
<h2 id="策略">策略</h2>
<ol>
<li>
<p><strong>畫面狀態矩陣作為設計產物</strong>：把每個畫面的每個狀態展開成四欄表格（顯示 / 可用操作 / 進入條件 / 退出路徑）。退出路徑欄位為空 = UX 死胡同，10 分鐘能查完所有畫面。</p>
</li>
<li>
<p><strong>退出路徑是預設要求</strong>：每個畫面的每個狀態至少要有一條退出路徑。即使是 connecting 這種過渡狀態，使用者也應該能取消。這跟 iOS HIG 和 Material Design 對 modal 畫面的 dismiss 要求一致。</p>
</li>
<li>
<p><strong>Widget test 覆蓋退出路徑</strong>：狀態矩陣直接轉成 test case — 每個狀態找到 back 按鈕、tap、斷言導航到首頁。</p>
</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>想用狀態矩陣設計畫面 → <a href="/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">畫面狀態矩陣的定義與填寫方法</a></li>
<li>想建 widget test 覆蓋導航 → <a href="/blog/testing/04-ui-automation/" data-link-title="模組四：自動化 UI 驗證" data-link-desc="Widget test 的狀態覆蓋策略、Playwright 驗證流程、螢幕狀態 coverage">模組四：自動化 UI 驗證</a></li>
<li>類似案例（Gate 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 biometricOnly 無 fallback</a></li>
</ul>
]]></content:encoded></item><item><title>Widget test 的狀態覆蓋策略</title><link>https://tarrragon.github.io/blog/testing/04-ui-automation/state-coverage-strategy/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/04-ui-automation/state-coverage-strategy/</guid><description>&lt;p>Widget test 的狀態覆蓋策略是用&lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/screen-state-matrix/" data-link-title="畫面狀態矩陣" data-link-desc="說明用四欄表格（顯示/可用操作/進入條件/退出路徑）系統性地暴露畫面導航缺口的設計工具">畫面狀態矩陣&lt;/a>（&lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">ux-design 模組一&lt;/a>）作為 test case 的來源。矩陣的每一行（一個狀態）對應至少一個 test case，矩陣的每一欄（顯示 / 可用操作 / 退出路徑）對應該 test case 中的斷言。&lt;/p>
&lt;h2 id="從矩陣到-test-case-的轉換規則">從矩陣到 test case 的轉換規則&lt;/h2>
&lt;h3 id="每個狀態至少一個-test-case">每個狀態至少一個 test case&lt;/h3>
&lt;p>矩陣中的每一行代表畫面的一個狀態。每個狀態產生一個 test case，驗證三件事：&lt;/p>
&lt;ol>
&lt;li>該狀態下的顯示元素是否存在&lt;/li>
&lt;li>該狀態下的可用操作是否可觸發&lt;/li>
&lt;li>該狀態下的退出路徑是否可到達&lt;/li>
&lt;/ol>
&lt;p>以 app_tunnel Terminal 畫面為例，五個狀態產生五個 test case：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="n">testWidgets&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;idle state shows blank and allows cancel&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">tester&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">async&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpWidget&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">terminalScreen&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">state:&lt;/span> &lt;span class="n">idle&lt;/span>&lt;span class="p">));&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">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byType&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">CircularProgressIndicator&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">findsNothing&lt;/span>&lt;span class="p">);&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">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byKey&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Key&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;cancel_button&amp;#39;&lt;/span>&lt;span class="p">)),&lt;/span> &lt;span class="n">findsOneWidget&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="n">testWidgets&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;error state shows message, retry, and back&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">tester&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">async&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpWidget&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">terminalScreen&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">state:&lt;/span> &lt;span class="n">error&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">text&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;連線失敗&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">findsOneWidget&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byKey&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Key&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;retry_button&amp;#39;&lt;/span>&lt;span class="p">)),&lt;/span> &lt;span class="n">findsOneWidget&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byKey&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Key&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;back_button&amp;#39;&lt;/span>&lt;span class="p">)),&lt;/span> &lt;span class="n">findsOneWidget&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="退出路徑是獨立的斷言">退出路徑是獨立的斷言&lt;/h3>
&lt;p>退出路徑驗證的是「使用者能否離開當前狀態」。斷言方式是 tap 退出按鈕後驗證導航是否發生：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="n">testWidgets&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;error state back button navigates to home&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">tester&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">async&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpWidget&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">terminalScreen&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">state:&lt;/span> &lt;span class="n">error&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">tap&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byKey&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Key&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;back_button&amp;#39;&lt;/span>&lt;span class="p">)));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpAndSettle&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byType&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">HomeScreen&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">findsOneWidget&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>矩陣中退出路徑為空的狀態 = 沒有退出路徑的 test case = UX 死胡同。如果在填寫 test case 時發現某個狀態沒有退出路徑可以斷言，這本身就是設計缺口的發現。&lt;/p>
&lt;h2 id="覆蓋率的衡量">覆蓋率的衡量&lt;/h2>
&lt;p>Widget test 的狀態覆蓋率 = 有 test case 的狀態數 / 矩陣中的總狀態數。100% 代表矩陣中每個狀態都有對應的 test case。&lt;/p>
&lt;p>狀態覆蓋率和 line coverage 衡量不同的東西。Line coverage 衡量「程式碼中有多少行被執行過」，狀態覆蓋率衡量「設計中有多少狀態被驗證過」。一個狀態的 test case 可能覆蓋很少的程式碼行（只驗證特定狀態下的 UI），但確認了該狀態的設計意圖被正確實作。&lt;/p>
&lt;h2 id="狀態轉換的-test">狀態轉換的 test&lt;/h2>
&lt;p>除了靜態狀態的驗證，狀態之間的轉換也需要 test。矩陣的「進入條件」欄定義了觸發轉換的事件。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="n">testWidgets&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;connecting transitions to connected on ws success&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">tester&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">async&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpWidget&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">terminalScreen&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">state:&lt;/span> &lt;span class="n">connecting&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 模擬 WebSocket 連線成功
&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">&lt;/span> &lt;span class="n">connectionManager&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">emit&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ConnectionState&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">connected&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpAndSettle&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byType&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">TerminalView&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">findsOneWidget&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>狀態轉換 test 的數量 = 矩陣中的狀態轉換邊數。五個狀態的畫面可能有 8-12 條轉換邊，每條邊一個 test case。&lt;/p>
&lt;p>狀態覆蓋和轉換覆蓋確認畫面的邏輯正確性後，&lt;a href="https://tarrragon.github.io/blog/testing/04-ui-automation/navigation-path-test/" data-link-title="導航路徑 test" data-link-desc="Back 按鈕、route 可達性、go vs push 語意 — 驗證使用者能從任何畫面回到預期的位置">導航路徑 test&lt;/a> 進一步驗證 back 按鈕和 route 可達性。矩陣本身的填寫方法和四欄定義見 &lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">ux-design 模組一 畫面狀態矩陣&lt;/a>。如果需要在視覺層面確認 UI 呈現的一致性，&lt;a href="https://tarrragon.github.io/blog/testing/04-ui-automation/visual-regression/" data-link-title="螢幕截圖比對" data-link-desc="Visual regression testing — 用螢幕截圖比對偵測非預期的視覺變化、baseline 管理和 diff 閾值設定">螢幕截圖比對&lt;/a>提供 visual regression 的實作方式。&lt;/p></description><content:encoded><![CDATA[<p>Widget test 的狀態覆蓋策略是用<a href="/blog/ux-design/knowledge-cards/screen-state-matrix/" data-link-title="畫面狀態矩陣" data-link-desc="說明用四欄表格（顯示/可用操作/進入條件/退出路徑）系統性地暴露畫面導航缺口的設計工具">畫面狀態矩陣</a>（<a href="/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">ux-design 模組一</a>）作為 test case 的來源。矩陣的每一行（一個狀態）對應至少一個 test case，矩陣的每一欄（顯示 / 可用操作 / 退出路徑）對應該 test case 中的斷言。</p>
<h2 id="從矩陣到-test-case-的轉換規則">從矩陣到 test case 的轉換規則</h2>
<h3 id="每個狀態至少一個-test-case">每個狀態至少一個 test case</h3>
<p>矩陣中的每一行代表畫面的一個狀態。每個狀態產生一個 test case，驗證三件事：</p>
<ol>
<li>該狀態下的顯示元素是否存在</li>
<li>該狀態下的可用操作是否可觸發</li>
<li>該狀態下的退出路徑是否可到達</li>
</ol>
<p>以 app_tunnel Terminal 畫面為例，五個狀態產生五個 test case：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">testWidgets</span><span class="p">(</span><span class="s1">&#39;idle state shows blank and allows cancel&#39;</span><span class="p">,</span> <span class="p">(</span><span class="n">tester</span><span class="p">)</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpWidget</span><span class="p">(</span><span class="n">terminalScreen</span><span class="p">(</span><span class="nl">state:</span> <span class="n">idle</span><span class="p">));</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byType</span><span class="p">(</span><span class="n">CircularProgressIndicator</span><span class="p">),</span> <span class="n">findsNothing</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byKey</span><span class="p">(</span><span class="n">Key</span><span class="p">(</span><span class="s1">&#39;cancel_button&#39;</span><span class="p">)),</span> <span class="n">findsOneWidget</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="p">});</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">testWidgets</span><span class="p">(</span><span class="s1">&#39;error state shows message, retry, and back&#39;</span><span class="p">,</span> <span class="p">(</span><span class="n">tester</span><span class="p">)</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpWidget</span><span class="p">(</span><span class="n">terminalScreen</span><span class="p">(</span><span class="nl">state:</span> <span class="n">error</span><span class="p">));</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">text</span><span class="p">(</span><span class="s1">&#39;連線失敗&#39;</span><span class="p">),</span> <span class="n">findsOneWidget</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byKey</span><span class="p">(</span><span class="n">Key</span><span class="p">(</span><span class="s1">&#39;retry_button&#39;</span><span class="p">)),</span> <span class="n">findsOneWidget</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byKey</span><span class="p">(</span><span class="n">Key</span><span class="p">(</span><span class="s1">&#39;back_button&#39;</span><span class="p">)),</span> <span class="n">findsOneWidget</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><h3 id="退出路徑是獨立的斷言">退出路徑是獨立的斷言</h3>
<p>退出路徑驗證的是「使用者能否離開當前狀態」。斷言方式是 tap 退出按鈕後驗證導航是否發生：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">testWidgets</span><span class="p">(</span><span class="s1">&#39;error state back button navigates to home&#39;</span><span class="p">,</span> <span class="p">(</span><span class="n">tester</span><span class="p">)</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpWidget</span><span class="p">(</span><span class="n">terminalScreen</span><span class="p">(</span><span class="nl">state:</span> <span class="n">error</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">tap</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byKey</span><span class="p">(</span><span class="n">Key</span><span class="p">(</span><span class="s1">&#39;back_button&#39;</span><span class="p">)));</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpAndSettle</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byType</span><span class="p">(</span><span class="n">HomeScreen</span><span class="p">),</span> <span class="n">findsOneWidget</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>矩陣中退出路徑為空的狀態 = 沒有退出路徑的 test case = UX 死胡同。如果在填寫 test case 時發現某個狀態沒有退出路徑可以斷言，這本身就是設計缺口的發現。</p>
<h2 id="覆蓋率的衡量">覆蓋率的衡量</h2>
<p>Widget test 的狀態覆蓋率 = 有 test case 的狀態數 / 矩陣中的總狀態數。100% 代表矩陣中每個狀態都有對應的 test case。</p>
<p>狀態覆蓋率和 line coverage 衡量不同的東西。Line coverage 衡量「程式碼中有多少行被執行過」，狀態覆蓋率衡量「設計中有多少狀態被驗證過」。一個狀態的 test case 可能覆蓋很少的程式碼行（只驗證特定狀態下的 UI），但確認了該狀態的設計意圖被正確實作。</p>
<h2 id="狀態轉換的-test">狀態轉換的 test</h2>
<p>除了靜態狀態的驗證，狀態之間的轉換也需要 test。矩陣的「進入條件」欄定義了觸發轉換的事件。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">testWidgets</span><span class="p">(</span><span class="s1">&#39;connecting transitions to connected on ws success&#39;</span><span class="p">,</span> <span class="p">(</span><span class="n">tester</span><span class="p">)</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpWidget</span><span class="p">(</span><span class="n">terminalScreen</span><span class="p">(</span><span class="nl">state:</span> <span class="n">connecting</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="c1">// 模擬 WebSocket 連線成功
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span>  <span class="n">connectionManager</span><span class="p">.</span><span class="n">emit</span><span class="p">(</span><span class="n">ConnectionState</span><span class="p">.</span><span class="n">connected</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpAndSettle</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byType</span><span class="p">(</span><span class="n">TerminalView</span><span class="p">),</span> <span class="n">findsOneWidget</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>狀態轉換 test 的數量 = 矩陣中的狀態轉換邊數。五個狀態的畫面可能有 8-12 條轉換邊，每條邊一個 test case。</p>
<p>狀態覆蓋和轉換覆蓋確認畫面的邏輯正確性後，<a href="/blog/testing/04-ui-automation/navigation-path-test/" data-link-title="導航路徑 test" data-link-desc="Back 按鈕、route 可達性、go vs push 語意 — 驗證使用者能從任何畫面回到預期的位置">導航路徑 test</a> 進一步驗證 back 按鈕和 route 可達性。矩陣本身的填寫方法和四欄定義見 <a href="/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">ux-design 模組一 畫面狀態矩陣</a>。如果需要在視覺層面確認 UI 呈現的一致性，<a href="/blog/testing/04-ui-automation/visual-regression/" data-link-title="螢幕截圖比對" data-link-desc="Visual regression testing — 用螢幕截圖比對偵測非預期的視覺變化、baseline 管理和 diff 閾值設定">螢幕截圖比對</a>提供 visual regression 的實作方式。</p>
]]></content:encoded></item><item><title>Flutter GoRouter 導航設計</title><link>https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/flutter-gorouter/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/flutter-gorouter/</guid><description>&lt;p>GoRouter 是 Flutter 官方推薦的 declarative router。路由定義集中在一個 &lt;code>GoRouter&lt;/code> 物件中，導航操作用 URL path 表達（&lt;code>context.go('/terminal')&lt;/code>），支援 deep link、redirect、和巢狀路由。&lt;/p>
&lt;h2 id="路由定義">路由定義&lt;/h2>
&lt;p>GoRouter 的路由定義是一棵樹，每個節點是一個 &lt;code>GoRoute&lt;/code>，指定 path 和 builder。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="n">GoRouter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">routes:&lt;/span> &lt;span class="p">[&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">GoRoute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">path:&lt;/span> &lt;span class="s1">&amp;#39;/&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nl">builder:&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">HomeScreen&lt;/span>&lt;span class="p">()),&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">GoRoute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">path:&lt;/span> &lt;span class="s1">&amp;#39;/enrollment&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nl">builder:&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">EnrollmentScreen&lt;/span>&lt;span class="p">()),&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">GoRoute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">path:&lt;/span> &lt;span class="s1">&amp;#39;/terminal&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nl">builder:&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">TerminalScreen&lt;/span>&lt;span class="p">()),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="p">]);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>路由定義是 app 所有可到達畫面的完整清單。新增畫面時先在路由定義中加入 path，再實作 builder。路由定義同時也是路由可達性檢查的 source of truth（&lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/route-reachability/" data-link-title="路由可達性檢查" data-link-desc="Router 定義的路由 vs UI 實際可達的路由 — 路由存在但 UI 不可達等於死程式碼的 UX 版本">路由可達性&lt;/a>）。&lt;/p>
&lt;h2 id="導航-api">導航 API&lt;/h2>
&lt;p>GoRouter 提供三個主要的導航方法，語意不同，適用場景不同。&lt;/p>
&lt;h3 id="contextgopath">context.go(path)&lt;/h3>
&lt;p>替換整個導航堆疊。&lt;code>go('/terminal')&lt;/code> 讓使用者直接到 terminal 畫面，按 back 不會回到前一個畫面（堆疊已被替換）。&lt;/p>
&lt;p>適合場景：切換主要工作區。從登入畫面到首頁（登入成功後使用者不應該按 back 回到登入畫面）。&lt;/p>
&lt;h3 id="contextpushpath">context.push(path)&lt;/h3>
&lt;p>把新畫面推入導航堆疊。&lt;code>push('/enrollment')&lt;/code> 讓使用者到 enrollment 畫面，按 back 回到前一個畫面。&lt;/p>
&lt;p>適合場景：暫時離開做一件事，做完回來。從首頁到配對畫面，配對完成後按 back 回首頁。&lt;/p>
&lt;h3 id="contextpushreplacementpath">context.pushReplacement(path)&lt;/h3>
&lt;p>替換堆疊頂端的畫面。不改變堆疊深度 — 前一個畫面被新畫面取代，按 back 回到更早的畫面。&lt;/p>
&lt;p>適合場景：步驟式流程中的前進。步驟 1 → pushReplacement 步驟 2 → pushReplacement 步驟 3。使用者在步驟 3 按 back 不會回到步驟 2（已被替換），而是回到流程開始前的畫面。&lt;/p>
&lt;h2 id="redirect-機制">Redirect 機制&lt;/h2>
&lt;p>GoRouter 的 redirect 在每次導航前執行，可以根據 app 狀態（登入狀態、權限）把使用者導向不同畫面。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="n">GoRouter&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nl">redirect:&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">isLoggedIn&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">authState&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">isLoggedIn&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="n">isLoggedIn&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">matchedLocation&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="s1">&amp;#39;/login&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">return&lt;/span> &lt;span class="s1">&amp;#39;/login&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">isLoggedIn&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">matchedLocation&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s1">&amp;#39;/login&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">return&lt;/span> &lt;span class="s1">&amp;#39;/&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">null&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// 不 redirect
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl"> &lt;span class="nl">routes:&lt;/span> &lt;span class="p">[...],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Redirect 集中管理「什麼條件下使用者不能到某個畫面」的邏輯。比在每個畫面的 &lt;code>initState&lt;/code> 中各自檢查更容易維護和測試。&lt;/p>
&lt;h2 id="shellroute巢狀導航">ShellRoute（巢狀導航）&lt;/h2>
&lt;p>ShellRoute 讓多個畫面共享同一個外殼（tab bar、bottom navigation、drawer）。子路由的導航在 shell 內發生，shell 本身不變。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="n">ShellRoute&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nl">builder:&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">child&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">ScaffoldWithNavBar&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">child:&lt;/span> &lt;span class="n">child&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nl">routes:&lt;/span> &lt;span class="p">[&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">GoRoute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">path:&lt;/span> &lt;span class="s1">&amp;#39;/home&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nl">builder:&lt;/span> &lt;span class="p">...),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="n">GoRoute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">path:&lt;/span> &lt;span class="s1">&amp;#39;/search&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nl">builder:&lt;/span> &lt;span class="p">...),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="n">GoRoute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">path:&lt;/span> &lt;span class="s1">&amp;#39;/profile&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nl">builder:&lt;/span> &lt;span class="p">...),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>ShellRoute 適合 tab bar 導航模式 — 底部的 tab bar 是 shell，每個 tab 的內容是子路由。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>go / push / pushReplacement 的 UX 語意 → &lt;a href="https://tarrragon.github.io/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 語意表&lt;/a>&lt;/li>
&lt;li>iOS 和 Android 的導航差異 → &lt;a href="https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/ios-vs-material-navigation/" data-link-title="iOS HIG vs Material Design 導航差異" data-link-desc="兩個平台在 back 行為、手勢、tab bar 位置、modal 呈現上的差異 — 跨平台 app 需要決定遵循哪套慣例">iOS HIG vs Material Design 導航差異&lt;/a>&lt;/li>
&lt;li>Deep link 設計 → &lt;a href="https://tarrragon.github.io/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 設計&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>GoRouter 是 Flutter 官方推薦的 declarative router。路由定義集中在一個 <code>GoRouter</code> 物件中，導航操作用 URL path 表達（<code>context.go('/terminal')</code>），支援 deep link、redirect、和巢狀路由。</p>
<h2 id="路由定義">路由定義</h2>
<p>GoRouter 的路由定義是一棵樹，每個節點是一個 <code>GoRoute</code>，指定 path 和 builder。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">GoRouter</span><span class="p">(</span><span class="nl">routes:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="n">GoRoute</span><span class="p">(</span><span class="nl">path:</span> <span class="s1">&#39;/&#39;</span><span class="p">,</span> <span class="nl">builder:</span> <span class="p">(</span><span class="n">context</span><span class="p">,</span> <span class="n">state</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">HomeScreen</span><span class="p">()),</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="n">GoRoute</span><span class="p">(</span><span class="nl">path:</span> <span class="s1">&#39;/enrollment&#39;</span><span class="p">,</span> <span class="nl">builder:</span> <span class="p">(</span><span class="n">context</span><span class="p">,</span> <span class="n">state</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">EnrollmentScreen</span><span class="p">()),</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="n">GoRoute</span><span class="p">(</span><span class="nl">path:</span> <span class="s1">&#39;/terminal&#39;</span><span class="p">,</span> <span class="nl">builder:</span> <span class="p">(</span><span class="n">context</span><span class="p">,</span> <span class="n">state</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">TerminalScreen</span><span class="p">()),</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">]);</span></span></span></code></pre></div><p>路由定義是 app 所有可到達畫面的完整清單。新增畫面時先在路由定義中加入 path，再實作 builder。路由定義同時也是路由可達性檢查的 source of truth（<a href="/blog/ux-design/01-screen-state-machine/route-reachability/" data-link-title="路由可達性檢查" data-link-desc="Router 定義的路由 vs UI 實際可達的路由 — 路由存在但 UI 不可達等於死程式碼的 UX 版本">路由可達性</a>）。</p>
<h2 id="導航-api">導航 API</h2>
<p>GoRouter 提供三個主要的導航方法，語意不同，適用場景不同。</p>
<h3 id="contextgopath">context.go(path)</h3>
<p>替換整個導航堆疊。<code>go('/terminal')</code> 讓使用者直接到 terminal 畫面，按 back 不會回到前一個畫面（堆疊已被替換）。</p>
<p>適合場景：切換主要工作區。從登入畫面到首頁（登入成功後使用者不應該按 back 回到登入畫面）。</p>
<h3 id="contextpushpath">context.push(path)</h3>
<p>把新畫面推入導航堆疊。<code>push('/enrollment')</code> 讓使用者到 enrollment 畫面，按 back 回到前一個畫面。</p>
<p>適合場景：暫時離開做一件事，做完回來。從首頁到配對畫面，配對完成後按 back 回首頁。</p>
<h3 id="contextpushreplacementpath">context.pushReplacement(path)</h3>
<p>替換堆疊頂端的畫面。不改變堆疊深度 — 前一個畫面被新畫面取代，按 back 回到更早的畫面。</p>
<p>適合場景：步驟式流程中的前進。步驟 1 → pushReplacement 步驟 2 → pushReplacement 步驟 3。使用者在步驟 3 按 back 不會回到步驟 2（已被替換），而是回到流程開始前的畫面。</p>
<h2 id="redirect-機制">Redirect 機制</h2>
<p>GoRouter 的 redirect 在每次導航前執行，可以根據 app 狀態（登入狀態、權限）把使用者導向不同畫面。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">GoRouter</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nl">redirect:</span> <span class="p">(</span><span class="n">context</span><span class="p">,</span> <span class="n">state</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="kd">final</span> <span class="n">isLoggedIn</span> <span class="o">=</span> <span class="n">authState</span><span class="p">.</span><span class="n">isLoggedIn</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">isLoggedIn</span> <span class="o">&amp;&amp;</span> <span class="n">state</span><span class="p">.</span><span class="n">matchedLocation</span> <span class="o">!=</span> <span class="s1">&#39;/login&#39;</span><span class="p">)</span> <span class="k">return</span> <span class="s1">&#39;/login&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">isLoggedIn</span> <span class="o">&amp;&amp;</span> <span class="n">state</span><span class="p">.</span><span class="n">matchedLocation</span> <span class="o">==</span> <span class="s1">&#39;/login&#39;</span><span class="p">)</span> <span class="k">return</span> <span class="s1">&#39;/&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="k">return</span> <span class="kc">null</span><span class="p">;</span> <span class="c1">// 不 redirect
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span>  <span class="p">},</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  <span class="nl">routes:</span> <span class="p">[...],</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">);</span></span></span></code></pre></div><p>Redirect 集中管理「什麼條件下使用者不能到某個畫面」的邏輯。比在每個畫面的 <code>initState</code> 中各自檢查更容易維護和測試。</p>
<h2 id="shellroute巢狀導航">ShellRoute（巢狀導航）</h2>
<p>ShellRoute 讓多個畫面共享同一個外殼（tab bar、bottom navigation、drawer）。子路由的導航在 shell 內發生，shell 本身不變。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">ShellRoute</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nl">builder:</span> <span class="p">(</span><span class="n">context</span><span class="p">,</span> <span class="n">state</span><span class="p">,</span> <span class="n">child</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">ScaffoldWithNavBar</span><span class="p">(</span><span class="nl">child:</span> <span class="n">child</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nl">routes:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="n">GoRoute</span><span class="p">(</span><span class="nl">path:</span> <span class="s1">&#39;/home&#39;</span><span class="p">,</span> <span class="nl">builder:</span> <span class="p">...),</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="n">GoRoute</span><span class="p">(</span><span class="nl">path:</span> <span class="s1">&#39;/search&#39;</span><span class="p">,</span> <span class="nl">builder:</span> <span class="p">...),</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="n">GoRoute</span><span class="p">(</span><span class="nl">path:</span> <span class="s1">&#39;/profile&#39;</span><span class="p">,</span> <span class="nl">builder:</span> <span class="p">...),</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="p">],</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><p>ShellRoute 適合 tab bar 導航模式 — 底部的 tab bar 是 shell，每個 tab 的內容是子路由。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>go / push / pushReplacement 的 UX 語意 → <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>iOS 和 Android 的導航差異 → <a href="/blog/ux-design/05-navigation-patterns/ios-vs-material-navigation/" data-link-title="iOS HIG vs Material Design 導航差異" data-link-desc="兩個平台在 back 行為、手勢、tab bar 位置、modal 呈現上的差異 — 跨平台 app 需要決定遵循哪套慣例">iOS HIG vs Material Design 導航差異</a></li>
<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>
</ul>
]]></content:encoded></item><item><title>Flutter 平台適配</title><link>https://tarrragon.github.io/blog/monitoring/05-platform-adaptation/flutter-platform/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/05-platform-adaptation/flutter-platform/</guid><description>&lt;p>Flutter 應用程式在 Dart VM 中執行，有自己的執行緒模型（Isolate）、原生平台橋接（Platform channel）和 app 生命週期管理。監控 SDK 在 Flutter 中需要處理的平台特殊問題集中在這三個面向。&lt;/p>
&lt;h2 id="isolate-安全">Isolate 安全&lt;/h2>
&lt;p>Dart 的 Isolate 是獨立的記憶體空間，Isolate 之間不共享記憶體，只能透過 message passing 溝通。SDK 的記憶體 buffer 存在於 main isolate 中，其他 isolate 產生的事件需要透過 port 傳送到 main isolate 才能進入 buffer。&lt;/p>
&lt;p>SDK 端的適配：&lt;/p>
&lt;p>提供 &lt;code>Monitor.eventFromIsolate(SendPort port)&lt;/code> 方法，在子 isolate 中透過 port 把事件送回 main isolate。或者提供 isolate-aware 的 &lt;code>Monitor.init()&lt;/code> 變體，在子 isolate 中初始化一個輕量的 event forwarder。&lt;/p>
&lt;p>如果 SDK 使用 compute 或 Isolate.spawn 做背景任務（例如壓縮 buffer），需要透過 port 把結果送回 main isolate — 背景 isolate 無法直接存取 main isolate 的 HTTP client 或 buffer。&lt;/p>
&lt;h2 id="platform-channel-攔截">Platform channel 攔截&lt;/h2>
&lt;p>Flutter 透過 Platform channel 呼叫原生平台功能（iOS 的 Swift/ObjC、Android 的 Kotlin/Java）。Platform channel 的呼叫可能失敗（原生端未實作、參數格式錯誤、原生端拋出例外），這些錯誤在 Dart 端表現為 &lt;code>PlatformException&lt;/code>。&lt;/p>
&lt;p>SDK 可以攔截 Platform channel 的呼叫記錄每次呼叫的方法名稱、參數、結果和耗時。攔截方式是替換 &lt;code>ServicesBinding.defaultBinaryMessenger&lt;/code> 的處理器，在轉發前後記錄事件。&lt;/p>
&lt;p>攔截的價值是：Platform channel 的錯誤通常難以 debug（stack trace 跨越 Dart 和原生兩層），監控記錄提供「呼叫了哪個 channel method、傳了什麼參數、在哪一層失敗」的完整 context。&lt;/p>
&lt;p>注意：攔截 Platform channel 會增加每次呼叫的延遲（記錄事件的開銷）。對高頻的 Platform channel 呼叫（例如每幀都呼叫的渲染相關 channel），攔截可能影響效能。SDK 應該提供 channel 過濾機制 — 只攔截特定 channel 或只在 debug mode 攔截。&lt;/p>
&lt;h2 id="app-lifecycle-事件">App lifecycle 事件&lt;/h2>
&lt;p>Flutter 的 &lt;code>WidgetsBindingObserver&lt;/code> 提供 app 生命週期回呼：&lt;/p>
&lt;ul>
&lt;li>&lt;code>didChangeAppLifecycleState(AppLifecycleState state)&lt;/code> — app 在 resumed（前景）、inactive（部分可見）、paused（背景）、detached（即將關閉）之間切換。&lt;/li>
&lt;/ul>
&lt;p>SDK 在 init 時註冊 observer，記錄每次狀態轉換為 lifecycle 事件。&lt;/p>
&lt;p>lifecycle 事件在 flush 策略中有特殊意義：&lt;/p>
&lt;p>&lt;strong>paused（進入背景）&lt;/strong>：觸發 flush — 把 buffer 中的事件送出，因為 app 在背景可能被系統殺掉，buffer 中的事件會遺失。iOS 在 app 進入背景後約 5 秒 suspend，flush 必須在這個時間窗口內完成。&lt;/p>
&lt;p>&lt;strong>resumed（回到前景）&lt;/strong>：檢查上次 flush 是否成功。如果 paused 時的 flush 失敗（網路超時），在 resumed 時重試。&lt;/p>
&lt;p>&lt;strong>detached（即將關閉）&lt;/strong>：呼叫 &lt;code>Monitor.close()&lt;/code> 做最後一次 flush 和資源釋放。detached 的時間窗口更短，close flush 可能被截斷。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>Python 平台的適配 → &lt;a href="https://tarrragon.github.io/blog/monitoring/05-platform-adaptation/python-platform/" data-link-title="Python 平台適配" data-link-desc="GIL 與 threading、atexit 可靠性、subprocess 監控 — Python SDK 的平台特殊考量">Python 平台適配&lt;/a>&lt;/li>
&lt;li>跨平台 timestamp 一致性 → &lt;a href="https://tarrragon.github.io/blog/monitoring/05-platform-adaptation/cross-platform-timestamp/" data-link-title="跨平台 timestamp 一致性" data-link-desc="時區、精度、clock drift — 不同平台產生的 timestamp 在 collector 端需要能正確比對和排序">跨平台 timestamp 一致性&lt;/a>&lt;/li>
&lt;li>自動攔截機制 → &lt;a href="https://tarrragon.github.io/blog/monitoring/03-sdk-design/auto-intercept/" data-link-title="自動攔截機制" data-link-desc="JS window.onerror / Flutter FlutterError.onError / Python sys.excepthook — 各平台攔截未捕獲例外的機制和限制">模組三 自動攔截&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Flutter 應用程式在 Dart VM 中執行，有自己的執行緒模型（Isolate）、原生平台橋接（Platform channel）和 app 生命週期管理。監控 SDK 在 Flutter 中需要處理的平台特殊問題集中在這三個面向。</p>
<h2 id="isolate-安全">Isolate 安全</h2>
<p>Dart 的 Isolate 是獨立的記憶體空間，Isolate 之間不共享記憶體，只能透過 message passing 溝通。SDK 的記憶體 buffer 存在於 main isolate 中，其他 isolate 產生的事件需要透過 port 傳送到 main isolate 才能進入 buffer。</p>
<p>SDK 端的適配：</p>
<p>提供 <code>Monitor.eventFromIsolate(SendPort port)</code> 方法，在子 isolate 中透過 port 把事件送回 main isolate。或者提供 isolate-aware 的 <code>Monitor.init()</code> 變體，在子 isolate 中初始化一個輕量的 event forwarder。</p>
<p>如果 SDK 使用 compute 或 Isolate.spawn 做背景任務（例如壓縮 buffer），需要透過 port 把結果送回 main isolate — 背景 isolate 無法直接存取 main isolate 的 HTTP client 或 buffer。</p>
<h2 id="platform-channel-攔截">Platform channel 攔截</h2>
<p>Flutter 透過 Platform channel 呼叫原生平台功能（iOS 的 Swift/ObjC、Android 的 Kotlin/Java）。Platform channel 的呼叫可能失敗（原生端未實作、參數格式錯誤、原生端拋出例外），這些錯誤在 Dart 端表現為 <code>PlatformException</code>。</p>
<p>SDK 可以攔截 Platform channel 的呼叫記錄每次呼叫的方法名稱、參數、結果和耗時。攔截方式是替換 <code>ServicesBinding.defaultBinaryMessenger</code> 的處理器，在轉發前後記錄事件。</p>
<p>攔截的價值是：Platform channel 的錯誤通常難以 debug（stack trace 跨越 Dart 和原生兩層），監控記錄提供「呼叫了哪個 channel method、傳了什麼參數、在哪一層失敗」的完整 context。</p>
<p>注意：攔截 Platform channel 會增加每次呼叫的延遲（記錄事件的開銷）。對高頻的 Platform channel 呼叫（例如每幀都呼叫的渲染相關 channel），攔截可能影響效能。SDK 應該提供 channel 過濾機制 — 只攔截特定 channel 或只在 debug mode 攔截。</p>
<h2 id="app-lifecycle-事件">App lifecycle 事件</h2>
<p>Flutter 的 <code>WidgetsBindingObserver</code> 提供 app 生命週期回呼：</p>
<ul>
<li><code>didChangeAppLifecycleState(AppLifecycleState state)</code> — app 在 resumed（前景）、inactive（部分可見）、paused（背景）、detached（即將關閉）之間切換。</li>
</ul>
<p>SDK 在 init 時註冊 observer，記錄每次狀態轉換為 lifecycle 事件。</p>
<p>lifecycle 事件在 flush 策略中有特殊意義：</p>
<p><strong>paused（進入背景）</strong>：觸發 flush — 把 buffer 中的事件送出，因為 app 在背景可能被系統殺掉，buffer 中的事件會遺失。iOS 在 app 進入背景後約 5 秒 suspend，flush 必須在這個時間窗口內完成。</p>
<p><strong>resumed（回到前景）</strong>：檢查上次 flush 是否成功。如果 paused 時的 flush 失敗（網路超時），在 resumed 時重試。</p>
<p><strong>detached（即將關閉）</strong>：呼叫 <code>Monitor.close()</code> 做最後一次 flush 和資源釋放。detached 的時間窗口更短，close flush 可能被截斷。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Python 平台的適配 → <a href="/blog/monitoring/05-platform-adaptation/python-platform/" data-link-title="Python 平台適配" data-link-desc="GIL 與 threading、atexit 可靠性、subprocess 監控 — Python SDK 的平台特殊考量">Python 平台適配</a></li>
<li>跨平台 timestamp 一致性 → <a href="/blog/monitoring/05-platform-adaptation/cross-platform-timestamp/" data-link-title="跨平台 timestamp 一致性" data-link-desc="時區、精度、clock drift — 不同平台產生的 timestamp 在 collector 端需要能正確比對和排序">跨平台 timestamp 一致性</a></li>
<li>自動攔截機制 → <a href="/blog/monitoring/03-sdk-design/auto-intercept/" data-link-title="自動攔截機制" data-link-desc="JS window.onerror / Flutter FlutterError.onError / Python sys.excepthook — 各平台攔截未捕獲例外的機制和限制">模組三 自動攔截</a></li>
</ul>
]]></content:encoded></item><item><title>U.C2 biometricOnly=true 無密碼 fallback</title><link>https://tarrragon.github.io/blog/ux-design/cases/biometric-only-no-fallback/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/cases/biometric-only-no-fallback/</guid><description>&lt;p>這個案例的核心責任是說明 Gate（使用者必須通過的關卡）的設計不只是「成功時怎麼做」，還必須包含「失敗時的替代路徑」。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>app_tunnel 使用 &lt;code>local_auth&lt;/code> 套件進行生物辨識認證。&lt;code>AuthenticationOptions&lt;/code> 設定 &lt;code>biometricOnly: true&lt;/code>，表示只接受生物辨識（Face ID / 指紋），不接受裝置密碼作為 fallback。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1">// 修復前
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="nl">options:&lt;/span> &lt;span class="kd">const&lt;/span> &lt;span class="n">AuthenticationOptions&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nl">stickyAuth:&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nl">biometricOnly:&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// Face ID 不可用 → 認證直接失敗
&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="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="c1">// 修復後
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="nl">options:&lt;/span> &lt;span class="kd">const&lt;/span> &lt;span class="n">AuthenticationOptions&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nl">stickyAuth:&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="nl">biometricOnly:&lt;/span> &lt;span class="kc">false&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// Face ID 不可用 → 系統自動提示輸入裝置密碼
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">),&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&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>Face ID 不可用時（戴口罩、光線差、指紋模糊、模擬器）完全無法使用 app&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>修復成本&lt;/td>
 &lt;td>改一個 boolean&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>根因&lt;/td>
 &lt;td>企劃階段未設計 biometric gate 的 fallback&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Gate fallback 是設計問題，不是實作問題&lt;/strong>。&lt;code>biometricOnly&lt;/code> 的預設值是 &lt;code>false&lt;/code>（允許密碼 fallback），開發時特意改成 &lt;code>true&lt;/code> 是因為認為「安全性更高」。但這個判斷沒有考慮 fallback 缺失時的 UX 代價 — 使用者完全無法進入 app。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>開發環境遮蔽了問題&lt;/strong>。iOS 模擬器預設不支援 Face ID，但 &lt;code>isAvailable()&lt;/code> 的實作會檢查 &lt;code>isDeviceSupported()&lt;/code> + &lt;code>getAvailableBiometrics().isNotEmpty&lt;/code>。模擬器回傳 &lt;code>isDeviceSupported() = true&lt;/code> 但 &lt;code>getAvailableBiometrics() = []&lt;/code>，所以在模擬器上 &lt;code>isAvailable()&lt;/code> 回傳 false，直接跳過認證走預設路徑。真實裝置上 &lt;code>isAvailable() = true&lt;/code> 但 Face ID 可能失敗，這時沒有 fallback。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>安全性 vs 可用性的取捨需要顯式記錄&lt;/strong>。&lt;code>biometricOnly: true&lt;/code> 的安全收益是「確保只有生物特徵擁有者能操作」；代價是「任何生物辨識失敗場景都阻擋使用」。自用工具的使用者就是 owner，密碼 fallback 的安全風險遠低於「完全無法使用」的可用性風險。&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="策略">策略&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>每個 gate 設計時列三問&lt;/strong>：成功時做什麼？失敗時做什麼？使用者不知道發生什麼時做什麼？&lt;/li>
&lt;li>&lt;strong>在狀態矩陣標注 gate fallback&lt;/strong>：biometric / network / auth 每個 gate 旁邊標注替代路徑，空白 = 使用者被擋住。&lt;/li>
&lt;li>&lt;strong>安全 vs 可用性取捨顯式記錄&lt;/strong>：在 spec 文件記錄「&lt;code>biometricOnly: false&lt;/code> — 接受密碼 fallback，因為自用工具可用性優先於生物辨識強制」。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>想設計 Gate fallback 體系 → &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>想了解 biometric 在不同平台的行為差異 → 待補：iOS/Android biometric API 行為對照&lt;/li>
&lt;li>類似案例（導航死胡同）→ &lt;a href="https://tarrragon.github.io/blog/ux-design/cases/five-states-zero-exits/" data-link-title="U.C1 Terminal 畫面五個狀態零個退出路徑" data-link-desc="Flutter app 的 Terminal 畫面有 idle/connecting/connected/error/disconnected 五個 enum 狀態，每個狀態都沒有 back 或 disconnect 按鈕 — 使用者一旦進入就出不去">U.C1 五個狀態零個退出&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 Gate（使用者必須通過的關卡）的設計不只是「成功時怎麼做」，還必須包含「失敗時的替代路徑」。</p>
<h2 id="觀察">觀察</h2>
<p>app_tunnel 使用 <code>local_auth</code> 套件進行生物辨識認證。<code>AuthenticationOptions</code> 設定 <code>biometricOnly: true</code>，表示只接受生物辨識（Face ID / 指紋），不接受裝置密碼作為 fallback。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// 修復前
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="nl">options:</span> <span class="kd">const</span> <span class="n">AuthenticationOptions</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nl">stickyAuth:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="nl">biometricOnly:</span> <span class="kc">true</span><span class="p">,</span>  <span class="c1">// Face ID 不可用 → 認證直接失敗
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1">// 修復後
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span><span class="nl">options:</span> <span class="kd">const</span> <span class="n">AuthenticationOptions</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="nl">stickyAuth:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="nl">biometricOnly:</span> <span class="kc">false</span><span class="p">,</span> <span class="c1">// Face ID 不可用 → 系統自動提示輸入裝置密碼
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="p">),</span></span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>指標</th>
          <th>值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>影響範圍</td>
          <td>Face ID 不可用時（戴口罩、光線差、指紋模糊、模擬器）完全無法使用 app</td>
      </tr>
      <tr>
          <td>修復成本</td>
          <td>改一個 boolean</td>
      </tr>
      <tr>
          <td>根因</td>
          <td>企劃階段未設計 biometric gate 的 fallback</td>
      </tr>
  </tbody>
</table>
<h2 id="判讀">判讀</h2>
<ol>
<li>
<p><strong>Gate fallback 是設計問題，不是實作問題</strong>。<code>biometricOnly</code> 的預設值是 <code>false</code>（允許密碼 fallback），開發時特意改成 <code>true</code> 是因為認為「安全性更高」。但這個判斷沒有考慮 fallback 缺失時的 UX 代價 — 使用者完全無法進入 app。</p>
</li>
<li>
<p><strong>開發環境遮蔽了問題</strong>。iOS 模擬器預設不支援 Face ID，但 <code>isAvailable()</code> 的實作會檢查 <code>isDeviceSupported()</code> + <code>getAvailableBiometrics().isNotEmpty</code>。模擬器回傳 <code>isDeviceSupported() = true</code> 但 <code>getAvailableBiometrics() = []</code>，所以在模擬器上 <code>isAvailable()</code> 回傳 false，直接跳過認證走預設路徑。真實裝置上 <code>isAvailable() = true</code> 但 Face ID 可能失敗，這時沒有 fallback。</p>
</li>
<li>
<p><strong>安全性 vs 可用性的取捨需要顯式記錄</strong>。<code>biometricOnly: true</code> 的安全收益是「確保只有生物特徵擁有者能操作」；代價是「任何生物辨識失敗場景都阻擋使用」。自用工具的使用者就是 owner，密碼 fallback 的安全風險遠低於「完全無法使用」的可用性風險。</p>
</li>
</ol>
<h2 id="策略">策略</h2>
<ol>
<li><strong>每個 gate 設計時列三問</strong>：成功時做什麼？失敗時做什麼？使用者不知道發生什麼時做什麼？</li>
<li><strong>在狀態矩陣標注 gate fallback</strong>：biometric / network / auth 每個 gate 旁邊標注替代路徑，空白 = 使用者被擋住。</li>
<li><strong>安全 vs 可用性取捨顯式記錄</strong>：在 spec 文件記錄「<code>biometricOnly: false</code> — 接受密碼 fallback，因為自用工具可用性優先於生物辨識強制」。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>想設計 Gate fallback 體系 → <a href="/blog/ux-design/02-gate-fallback/gate-three-questions/" data-link-title="Gate 分類與三問設計法" data-link-desc="每個 gate 設計時問三個問題：成功時做什麼、失敗時做什麼、使用者不知道發生什麼時做什麼">Gate 分類與三問設計法</a></li>
<li>想了解 biometric 在不同平台的行為差異 → 待補：iOS/Android biometric API 行為對照</li>
<li>類似案例（導航死胡同）→ <a href="/blog/ux-design/cases/five-states-zero-exits/" data-link-title="U.C1 Terminal 畫面五個狀態零個退出路徑" data-link-desc="Flutter app 的 Terminal 畫面有 idle/connecting/connected/error/disconnected 五個 enum 狀態，每個狀態都沒有 back 或 disconnect 按鈕 — 使用者一旦進入就出不去">U.C1 五個狀態零個退出</a></li>
</ul>
]]></content:encoded></item><item><title>導航路徑 test</title><link>https://tarrragon.github.io/blog/testing/04-ui-automation/navigation-path-test/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/04-ui-automation/navigation-path-test/</guid><description>&lt;p>導航路徑 test 驗證的是使用者在畫面之間的移動是否符合設計 — 每個畫面的 back 按鈕是否導向正確的上層畫面、每個 router 定義的路由是否從 UI 可達、&lt;code>go&lt;/code> 和 &lt;code>push&lt;/code> 的語意是否產生正確的返回堆疊。&lt;/p>
&lt;h2 id="back-按鈕-test">Back 按鈕 test&lt;/h2>
&lt;p>每個有 back 按鈕的畫面需要一個 test 驗證「按下 back 後導航到哪裡」。Back 按鈕的目標畫面依導航方式而定：&lt;/p>
&lt;ul>
&lt;li>&lt;code>context.push('/terminal')&lt;/code> 進入 → back 回到推入前的畫面（首頁）&lt;/li>
&lt;li>&lt;code>context.go('/terminal')&lt;/code> 進入 → back 行為依 router 設定，可能沒有上一頁&lt;/li>
&lt;/ul>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="n">testWidgets&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;back from terminal returns to home (pushed)&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">tester&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">async&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpWidget&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">app&lt;/span>&lt;span class="p">());&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 從首頁 push 到 terminal
&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">&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">tap&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">text&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;Connect Terminal&amp;#39;&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpAndSettle&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byType&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">TerminalScreen&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">findsOneWidget&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 按 back
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">tap&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byKey&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Key&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;back_button&amp;#39;&lt;/span>&lt;span class="p">)));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpAndSettle&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byType&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">HomeScreen&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">findsOneWidget&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="route-可達性-test">Route 可達性 test&lt;/h2>
&lt;p>Router 定義的每個路由都應該有從 UI 可達的路徑（&lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/route-reachability/" data-link-title="路由可達性檢查" data-link-desc="Router 定義的路由 vs UI 實際可達的路由 — 路由存在但 UI 不可達等於死程式碼的 UX 版本">ux-design 模組一 路由可達性&lt;/a>）。Route 可達性 test 驗證「從首頁出發，透過 UI 操作能到達每個路由」。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="n">testWidgets&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;enrollment route is reachable from home&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">tester&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">async&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpWidget&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">app&lt;/span>&lt;span class="p">());&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 找到配對入口按鈕
&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">&lt;/span> &lt;span class="kd">final&lt;/span> &lt;span class="n">enrollButton&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">text&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;Enroll Device&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">enrollButton&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">findsOneWidget&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 點擊後到達 enrollment 畫面
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">tap&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">enrollButton&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpAndSettle&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byType&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">EnrollmentScreen&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">findsOneWidget&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>不可達的路由在 test 中表現為「找不到導航到該路由的 UI 元素」。如果 router 定義了 &lt;code>/enrollment&lt;/code> 但首頁沒有對應按鈕，&lt;code>find.text('Enroll Device')&lt;/code> 會找不到元素 — test 失敗暴露入口缺失。&lt;/p>
&lt;h2 id="go-vs-push-語意的-test">&lt;code>go&lt;/code> vs &lt;code>push&lt;/code> 語意的 test&lt;/h2>
&lt;p>&lt;code>go&lt;/code> 和 &lt;code>push&lt;/code> 對返回堆疊的影響不同（&lt;a href="https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/" data-link-title="模組五：導航模式" data-link-desc="Push/pop stack、GoRouter 命名路由、tab bar、drawer — 導航方法選擇是設計決策">ux-design 模組五 導航模式&lt;/a>）。Test 需要驗證正確的導航方式被使用：&lt;/p>
&lt;h3 id="push-語意保留返回堆疊">Push 語意：保留返回堆疊&lt;/h3>
&lt;p>Push 後按系統 back 鍵應該回到推入前的畫面。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="n">testWidgets&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;push preserves back stack&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">tester&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">async&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpWidget&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">app&lt;/span>&lt;span class="p">());&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="c1">// push to enrollment
&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">&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">tap&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">text&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;Enroll Device&amp;#39;&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpAndSettle&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 系統 back 鍵
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kd">final&lt;/span> &lt;span class="n">backButton&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byTooltip&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;Back&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">tap&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">backButton&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpAndSettle&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 應該回到首頁
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byType&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">HomeScreen&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">findsOneWidget&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="go-語意替換路由堆疊">Go 語意：替換路由堆疊&lt;/h3>
&lt;p>Go 後按系統 back 鍵的行為依 router 設定。如果 go 到的路由是根層級，系統 back 鍵可能退出 app 而非回到前一個畫面。&lt;/p></description><content:encoded><![CDATA[<p>導航路徑 test 驗證的是使用者在畫面之間的移動是否符合設計 — 每個畫面的 back 按鈕是否導向正確的上層畫面、每個 router 定義的路由是否從 UI 可達、<code>go</code> 和 <code>push</code> 的語意是否產生正確的返回堆疊。</p>
<h2 id="back-按鈕-test">Back 按鈕 test</h2>
<p>每個有 back 按鈕的畫面需要一個 test 驗證「按下 back 後導航到哪裡」。Back 按鈕的目標畫面依導航方式而定：</p>
<ul>
<li><code>context.push('/terminal')</code> 進入 → back 回到推入前的畫面（首頁）</li>
<li><code>context.go('/terminal')</code> 進入 → back 行為依 router 設定，可能沒有上一頁</li>
</ul>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">testWidgets</span><span class="p">(</span><span class="s1">&#39;back from terminal returns to home (pushed)&#39;</span><span class="p">,</span> <span class="p">(</span><span class="n">tester</span><span class="p">)</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpWidget</span><span class="p">(</span><span class="n">app</span><span class="p">());</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="c1">// 從首頁 push 到 terminal
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span>  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">tap</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">text</span><span class="p">(</span><span class="s1">&#39;Connect Terminal&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpAndSettle</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byType</span><span class="p">(</span><span class="n">TerminalScreen</span><span class="p">),</span> <span class="n">findsOneWidget</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="c1">// 按 back
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span>  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">tap</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byKey</span><span class="p">(</span><span class="n">Key</span><span class="p">(</span><span class="s1">&#39;back_button&#39;</span><span class="p">)));</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpAndSettle</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byType</span><span class="p">(</span><span class="n">HomeScreen</span><span class="p">),</span> <span class="n">findsOneWidget</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><h2 id="route-可達性-test">Route 可達性 test</h2>
<p>Router 定義的每個路由都應該有從 UI 可達的路徑（<a href="/blog/ux-design/01-screen-state-machine/route-reachability/" data-link-title="路由可達性檢查" data-link-desc="Router 定義的路由 vs UI 實際可達的路由 — 路由存在但 UI 不可達等於死程式碼的 UX 版本">ux-design 模組一 路由可達性</a>）。Route 可達性 test 驗證「從首頁出發，透過 UI 操作能到達每個路由」。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">testWidgets</span><span class="p">(</span><span class="s1">&#39;enrollment route is reachable from home&#39;</span><span class="p">,</span> <span class="p">(</span><span class="n">tester</span><span class="p">)</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpWidget</span><span class="p">(</span><span class="n">app</span><span class="p">());</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="c1">// 找到配對入口按鈕
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span>  <span class="kd">final</span> <span class="n">enrollButton</span> <span class="o">=</span> <span class="n">find</span><span class="p">.</span><span class="n">text</span><span class="p">(</span><span class="s1">&#39;Enroll Device&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">enrollButton</span><span class="p">,</span> <span class="n">findsOneWidget</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="c1">// 點擊後到達 enrollment 畫面
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span>  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">tap</span><span class="p">(</span><span class="n">enrollButton</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpAndSettle</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byType</span><span class="p">(</span><span class="n">EnrollmentScreen</span><span class="p">),</span> <span class="n">findsOneWidget</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>不可達的路由在 test 中表現為「找不到導航到該路由的 UI 元素」。如果 router 定義了 <code>/enrollment</code> 但首頁沒有對應按鈕，<code>find.text('Enroll Device')</code> 會找不到元素 — test 失敗暴露入口缺失。</p>
<h2 id="go-vs-push-語意的-test"><code>go</code> vs <code>push</code> 語意的 test</h2>
<p><code>go</code> 和 <code>push</code> 對返回堆疊的影響不同（<a href="/blog/ux-design/05-navigation-patterns/" data-link-title="模組五：導航模式" data-link-desc="Push/pop stack、GoRouter 命名路由、tab bar、drawer — 導航方法選擇是設計決策">ux-design 模組五 導航模式</a>）。Test 需要驗證正確的導航方式被使用：</p>
<h3 id="push-語意保留返回堆疊">Push 語意：保留返回堆疊</h3>
<p>Push 後按系統 back 鍵應該回到推入前的畫面。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">testWidgets</span><span class="p">(</span><span class="s1">&#39;push preserves back stack&#39;</span><span class="p">,</span> <span class="p">(</span><span class="n">tester</span><span class="p">)</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpWidget</span><span class="p">(</span><span class="n">app</span><span class="p">());</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="c1">// push to enrollment
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span>  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">tap</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">text</span><span class="p">(</span><span class="s1">&#39;Enroll Device&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpAndSettle</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="c1">// 系統 back 鍵
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span>  <span class="kd">final</span> <span class="n">backButton</span> <span class="o">=</span> <span class="n">find</span><span class="p">.</span><span class="n">byTooltip</span><span class="p">(</span><span class="s1">&#39;Back&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">tap</span><span class="p">(</span><span class="n">backButton</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpAndSettle</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="c1">// 應該回到首頁
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span>  <span class="n">expect</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byType</span><span class="p">(</span><span class="n">HomeScreen</span><span class="p">),</span> <span class="n">findsOneWidget</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><h3 id="go-語意替換路由堆疊">Go 語意：替換路由堆疊</h3>
<p>Go 後按系統 back 鍵的行為依 router 設定。如果 go 到的路由是根層級，系統 back 鍵可能退出 app 而非回到前一個畫面。</p>
<p>Test 策略：驗證 go 後的路由堆疊狀態。如果設計意圖是「切換工作區，不保留前一個畫面」，斷言系統 back 鍵不回到前一個畫面。</p>
<h2 id="深層連結-test">深層連結 test</h2>
<p>深層連結（deep link）讓使用者從 app 外部直接進入特定畫面。Deep link test 驗證「直接導航到內部路由時，畫面和導航堆疊是否正確」。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">testWidgets</span><span class="p">(</span><span class="s1">&#39;deep link to /terminal shows terminal&#39;</span><span class="p">,</span> <span class="p">(</span><span class="n">tester</span><span class="p">)</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpWidget</span><span class="p">(</span><span class="n">app</span><span class="p">(</span><span class="nl">initialRoute:</span> <span class="s1">&#39;/terminal&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byType</span><span class="p">(</span><span class="n">TerminalScreen</span><span class="p">),</span> <span class="n">findsOneWidget</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>深層連結的特殊性在於使用者跳過了正常的導航流程。從首頁到 terminal 的正常流程可能經過認證 gate，但深層連結直接到 terminal — 認證 gate 是否仍然生效需要額外的 test。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>狀態覆蓋策略 → <a href="/blog/testing/04-ui-automation/state-coverage-strategy/" data-link-title="Widget test 的狀態覆蓋策略" data-link-desc="從畫面狀態矩陣推導 widget test case — 每個狀態的顯示、操作、退出路徑都是獨立的斷言目標">Widget test 的狀態覆蓋策略</a></li>
<li>Playwright 驗證流程 → <a href="/blog/testing/04-ui-automation/playwright-verification/" data-link-title="Playwright 瀏覽器驗證流程" data-link-desc="用 Playwright 驗證 web 版本的 UI 行為 — test 結構、selector 策略、和 widget test 的互補關係">Playwright 瀏覽器驗證流程</a></li>
<li>路由可達性的設計原則 → <a href="/blog/ux-design/01-screen-state-machine/route-reachability/" data-link-title="路由可達性檢查" data-link-desc="Router 定義的路由 vs UI 實際可達的路由 — 路由存在但 UI 不可達等於死程式碼的 UX 版本">ux-design 模組一 路由可達性</a></li>
</ul>
]]></content:encoded></item><item><title>U.C3 終端機文字輸入機制未設計、事後 hotfix 補 TextField</title><link>https://tarrragon.github.io/blog/ux-design/cases/terminal-input-mechanism-absent/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/cases/terminal-input-mechanism-absent/</guid><description>&lt;p>這個案例的核心責任是說明輸入機制是設計產物（在企劃階段決定），不是實作細節（在寫 code 時順便加）。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>app_tunnel 的 Terminal 畫面在 W2 修復前沒有任何文字輸入元件。使用者只能透過底部工具列的特殊鍵（Esc/Tab/Ctrl/方向鍵）操作終端機，無法打字。&lt;/p>
&lt;p>W2-001 修復時加入的 &lt;code>TextField&lt;/code> 及其參數：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="n">TextField&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nl">keyboardType:&lt;/span> &lt;span class="n">TextInputType&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">visiblePassword&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// 避免自動校正
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nl">enableSuggestions:&lt;/span> &lt;span class="kc">false&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// 關閉建議列
&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">&lt;/span> &lt;span class="nl">autocorrect:&lt;/span> &lt;span class="kc">false&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// 關閉自動校正
&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="nl">enableIMEPersonalizedLearning:&lt;/span> &lt;span class="kc">false&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// 關閉 IME 個人化學習
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nl">onSubmitted:&lt;/span> &lt;span class="n">_submitInput&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// Enter 送出整行
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nl">textInputAction:&lt;/span> &lt;span class="n">TextInputAction&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">send&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// 鍵盤顯示「傳送」
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>每個參數都是一個設計決策，但沒有一個是事前規劃的 — 全部是寫 code 時臨時判斷。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>設計決策&lt;/th>
 &lt;th>事前規劃&lt;/th>
 &lt;th>事後 hotfix 的風險&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>visiblePassword&lt;/code>&lt;/td>
 &lt;td>沒有&lt;/td>
 &lt;td>如果用預設 &lt;code>text&lt;/code>，iOS 會自動校正 &lt;code>ls -la&lt;/code> 成其他東西&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>enableSuggestions: false&lt;/code>&lt;/td>
 &lt;td>沒有&lt;/td>
 &lt;td>建議列遮擋終端機畫面下方&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>autocorrect: false&lt;/code>&lt;/td>
 &lt;td>沒有&lt;/td>
 &lt;td>路徑 &lt;code>/usr/bin/&lt;/code> 可能被校正&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>enableIMEPersonalizedLearning: false&lt;/code>&lt;/td>
 &lt;td>沒有&lt;/td>
 &lt;td>CLI 輸入含密碼和路徑，IME 學習是安全風險&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>onSubmitted&lt;/code>（整行送出）&lt;/td>
 &lt;td>沒有&lt;/td>
 &lt;td>如果逐字元送出，Tab 補全和命令編輯需要完全不同的 protocol 設計&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>TextInputAction.send&lt;/code>&lt;/td>
 &lt;td>沒有&lt;/td>
 &lt;td>如果用 &lt;code>newline&lt;/code>，使用者按 Enter 會換行不送出&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>輸入設計影響 UI layout 和 protocol&lt;/strong>。&lt;code>onSubmitted&lt;/code>（整行送出）vs 逐字元即時送出不只是 UI 問題 — 整行送出代表 protocol 層送的是 &lt;code>command\n&lt;/code>，逐字元送出代表每個按鍵都是一個 WS frame。這個決策應該在 protocol spec 階段就做，因為它影響 server 端的行為預期。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>IME 控制有安全意涵&lt;/strong>。&lt;code>enableIMEPersonalizedLearning: false&lt;/code> 不只是 UX 偏好 — CLI 輸入可能包含資料庫密碼、API key、伺服器路徑。IME 學習這些內容等於把 secret 存到了 IME 的詞庫裡，跨 app 可用。這是安全問題，不是 UX 問題。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>事後 hotfix 的六個參數每個都有 gotcha&lt;/strong>。如果這些決策在企劃階段做，可以寫成決策表並在 code review 時對照。事後 hotfix 時開發者可能漏掉其中一兩個（例如只加 &lt;code>autocorrect: false&lt;/code> 但忘了 &lt;code>enableIMEPersonalizedLearning: false&lt;/code>），漏掉的那個就成為安全漏洞。&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="策略">策略&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>功能規格新增「輸入機制決策表」&lt;/strong>：keyboard type / submit model / IME policy / special keys 四個維度，每個列出選項和取捨理由。&lt;/li>
&lt;li>&lt;strong>輸入機制跟 protocol 一起設計&lt;/strong>：「整行送出」還是「逐字元」決定了 WS 訊框的設計，必須在 protocol spec 階段決定。&lt;/li>
&lt;li>&lt;strong>安全敏感參數強制列入 review checklist&lt;/strong>：&lt;code>enableIMEPersonalizedLearning&lt;/code>、&lt;code>autocorrect&lt;/code> 在處理 secret 的輸入框中是安全要求，不是可選項。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>想設計 mobile 輸入機制 → &lt;a href="https://tarrragon.github.io/blog/ux-design/03-input-mechanism/four-dimension-decision/" data-link-title="輸入機制決策表" data-link-desc="Keyboard type / submit model / IME policy / special keys 四個維度的決策框架 — 每個維度都是設計決策，影響 UI layout 和 protocol">輸入機制決策表&lt;/a>&lt;/li>
&lt;li>想看 protocol 跟輸入的關聯 → &lt;a href="https://tarrragon.github.io/blog/testing/cases/ws-text-binary-frame-mock-blindspot/" data-link-title="T.C1 WebSocket text/binary frame 被 FakeWebSocketChannel 遮蔽" data-link-desc="Flutter app 用 Uint8List 發送 WS 資料走 binary frame，ttyd 期望 text frame 靜默忽略 — FakeWebSocketChannel 的 sink.add 接受 dynamic 不區分 frame type，192 個 test 全過但實機無回應">T.C1 WS frame type&lt;/a>（sendData 的型別決策）&lt;/li>
&lt;li>想做安全審查 → 待補：CLI 輸入安全 checklist&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明輸入機制是設計產物（在企劃階段決定），不是實作細節（在寫 code 時順便加）。</p>
<h2 id="觀察">觀察</h2>
<p>app_tunnel 的 Terminal 畫面在 W2 修復前沒有任何文字輸入元件。使用者只能透過底部工具列的特殊鍵（Esc/Tab/Ctrl/方向鍵）操作終端機，無法打字。</p>
<p>W2-001 修復時加入的 <code>TextField</code> 及其參數：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">TextField</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nl">keyboardType:</span> <span class="n">TextInputType</span><span class="p">.</span><span class="n">visiblePassword</span><span class="p">,</span>   <span class="c1">// 避免自動校正
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span>  <span class="nl">enableSuggestions:</span> <span class="kc">false</span><span class="p">,</span>                       <span class="c1">// 關閉建議列
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span>  <span class="nl">autocorrect:</span> <span class="kc">false</span><span class="p">,</span>                             <span class="c1">// 關閉自動校正
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span>  <span class="nl">enableIMEPersonalizedLearning:</span> <span class="kc">false</span><span class="p">,</span>           <span class="c1">// 關閉 IME 個人化學習
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span>  <span class="nl">onSubmitted:</span> <span class="n">_submitInput</span><span class="p">,</span>                      <span class="c1">// Enter 送出整行
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span>  <span class="nl">textInputAction:</span> <span class="n">TextInputAction</span><span class="p">.</span><span class="n">send</span><span class="p">,</span>          <span class="c1">// 鍵盤顯示「傳送」
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"></span><span class="p">)</span></span></span></code></pre></div><p>每個參數都是一個設計決策，但沒有一個是事前規劃的 — 全部是寫 code 時臨時判斷。</p>
<table>
  <thead>
      <tr>
          <th>設計決策</th>
          <th>事前規劃</th>
          <th>事後 hotfix 的風險</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>visiblePassword</code></td>
          <td>沒有</td>
          <td>如果用預設 <code>text</code>，iOS 會自動校正 <code>ls -la</code> 成其他東西</td>
      </tr>
      <tr>
          <td><code>enableSuggestions: false</code></td>
          <td>沒有</td>
          <td>建議列遮擋終端機畫面下方</td>
      </tr>
      <tr>
          <td><code>autocorrect: false</code></td>
          <td>沒有</td>
          <td>路徑 <code>/usr/bin/</code> 可能被校正</td>
      </tr>
      <tr>
          <td><code>enableIMEPersonalizedLearning: false</code></td>
          <td>沒有</td>
          <td>CLI 輸入含密碼和路徑，IME 學習是安全風險</td>
      </tr>
      <tr>
          <td><code>onSubmitted</code>（整行送出）</td>
          <td>沒有</td>
          <td>如果逐字元送出，Tab 補全和命令編輯需要完全不同的 protocol 設計</td>
      </tr>
      <tr>
          <td><code>TextInputAction.send</code></td>
          <td>沒有</td>
          <td>如果用 <code>newline</code>，使用者按 Enter 會換行不送出</td>
      </tr>
  </tbody>
</table>
<h2 id="判讀">判讀</h2>
<ol>
<li>
<p><strong>輸入設計影響 UI layout 和 protocol</strong>。<code>onSubmitted</code>（整行送出）vs 逐字元即時送出不只是 UI 問題 — 整行送出代表 protocol 層送的是 <code>command\n</code>，逐字元送出代表每個按鍵都是一個 WS frame。這個決策應該在 protocol spec 階段就做，因為它影響 server 端的行為預期。</p>
</li>
<li>
<p><strong>IME 控制有安全意涵</strong>。<code>enableIMEPersonalizedLearning: false</code> 不只是 UX 偏好 — CLI 輸入可能包含資料庫密碼、API key、伺服器路徑。IME 學習這些內容等於把 secret 存到了 IME 的詞庫裡，跨 app 可用。這是安全問題，不是 UX 問題。</p>
</li>
<li>
<p><strong>事後 hotfix 的六個參數每個都有 gotcha</strong>。如果這些決策在企劃階段做，可以寫成決策表並在 code review 時對照。事後 hotfix 時開發者可能漏掉其中一兩個（例如只加 <code>autocorrect: false</code> 但忘了 <code>enableIMEPersonalizedLearning: false</code>），漏掉的那個就成為安全漏洞。</p>
</li>
</ol>
<h2 id="策略">策略</h2>
<ol>
<li><strong>功能規格新增「輸入機制決策表」</strong>：keyboard type / submit model / IME policy / special keys 四個維度，每個列出選項和取捨理由。</li>
<li><strong>輸入機制跟 protocol 一起設計</strong>：「整行送出」還是「逐字元」決定了 WS 訊框的設計，必須在 protocol spec 階段決定。</li>
<li><strong>安全敏感參數強制列入 review checklist</strong>：<code>enableIMEPersonalizedLearning</code>、<code>autocorrect</code> 在處理 secret 的輸入框中是安全要求，不是可選項。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>想設計 mobile 輸入機制 → <a href="/blog/ux-design/03-input-mechanism/four-dimension-decision/" data-link-title="輸入機制決策表" data-link-desc="Keyboard type / submit model / IME policy / special keys 四個維度的決策框架 — 每個維度都是設計決策，影響 UI layout 和 protocol">輸入機制決策表</a></li>
<li>想看 protocol 跟輸入的關聯 → <a href="/blog/testing/cases/ws-text-binary-frame-mock-blindspot/" data-link-title="T.C1 WebSocket text/binary frame 被 FakeWebSocketChannel 遮蔽" data-link-desc="Flutter app 用 Uint8List 發送 WS 資料走 binary frame，ttyd 期望 text frame 靜默忽略 — FakeWebSocketChannel 的 sink.add 接受 dynamic 不區分 frame type，192 個 test 全過但實機無回應">T.C1 WS frame type</a>（sendData 的型別決策）</li>
<li>想做安全審查 → 待補：CLI 輸入安全 checklist</li>
</ul>
]]></content:encoded></item><item><title>模組三：SDK 設計模式</title><link>https://tarrragon.github.io/blog/monitoring/03-sdk-design/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/03-sdk-design/</guid><description>&lt;p>回答「怎麼在各平台埋點」。三個 SDK（JS/Flutter/Python）共用同一套事件格式，公開 API 保持一致。&lt;/p>
&lt;h2 id="待寫章節">待寫章節&lt;/h2>
&lt;ul>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> SDK 公開 API 設計（init / event / error / metric / flush / close）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 自動攔截機制（JS window.onerror / Flutter FlutterError / Python sys.excepthook）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 攢批送出策略（flush interval / buffer size / flush on close）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 離線 buffer 與重試（FIFO 丟棄 / 本地 persistence / 恢復後補發的取捨）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> SDK redaction helper（模組七的實作層）&lt;/li>
&lt;/ul>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/testing/03-protocol-integration-test/" data-link-title="模組三：協議整合測試" data-link-desc="對真實服務驗證 WebSocket / gRPC / HTTP 協議契約 — unit test 和 E2E test 之間的一層">testing 模組三 協議整合測試&lt;/a>：SDK 的 HTTP POST 行為需要 protocol test&lt;/li>
&lt;li>→ &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>：redaction 在 SDK 端做&lt;/li>
&lt;li>← &lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/" data-link-title="模組一：測試策略分層" data-link-desc="Unit / Protocol Integration / Screen State 三層測試各自的職責、盲區和判斷原則">testing 模組一 測試策略&lt;/a>：mock 遮蔽機制影響 SDK 的 auto-intercept 行為驗證&lt;/li>
&lt;li>實作 repo：tarrragon/monitor 的 sdk-js / sdk-flutter / sdk-python&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>回答「怎麼在各平台埋點」。三個 SDK（JS/Flutter/Python）共用同一套事件格式，公開 API 保持一致。</p>
<h2 id="待寫章節">待寫章節</h2>
<ul>
<li><input checked="" disabled="" type="checkbox"> SDK 公開 API 設計（init / event / error / metric / flush / close）</li>
<li><input checked="" disabled="" type="checkbox"> 自動攔截機制（JS window.onerror / Flutter FlutterError / Python sys.excepthook）</li>
<li><input checked="" disabled="" type="checkbox"> 攢批送出策略（flush interval / buffer size / flush on close）</li>
<li><input checked="" disabled="" type="checkbox"> 離線 buffer 與重試（FIFO 丟棄 / 本地 persistence / 恢復後補發的取捨）</li>
<li><input checked="" disabled="" type="checkbox"> SDK redaction helper（模組七的實作層）</li>
</ul>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/testing/03-protocol-integration-test/" data-link-title="模組三：協議整合測試" data-link-desc="對真實服務驗證 WebSocket / gRPC / HTTP 協議契約 — unit test 和 E2E test 之間的一層">testing 模組三 協議整合測試</a>：SDK 的 HTTP POST 行為需要 protocol test</li>
<li>→ <a href="/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">monitoring 模組七 資安</a>：redaction 在 SDK 端做</li>
<li>← <a href="/blog/testing/01-test-strategy-layers/" data-link-title="模組一：測試策略分層" data-link-desc="Unit / Protocol Integration / Screen State 三層測試各自的職責、盲區和判斷原則">testing 模組一 測試策略</a>：mock 遮蔽機制影響 SDK 的 auto-intercept 行為驗證</li>
<li>實作 repo：tarrragon/monitor 的 sdk-js / sdk-flutter / sdk-python</li>
</ul>
]]></content:encoded></item><item><title>T.C4 Client-side log 缺失導致 debug 只能靠實機盲測</title><link>https://tarrragon.github.io/blog/testing/cases/client-log-absent-debug-cost/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/cases/client-log-absent-debug-cost/</guid><description>&lt;p>這個案例的核心責任是說明「客戶端 log 設計」為什麼應該在功能企劃階段完成，而不是 debug 時才補。Log 不是 debug 工具，是可觀測性基礎設施。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>app_tunnel 的六個核心元件在實機測試前的 log 覆蓋狀態：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>元件&lt;/th>
 &lt;th>log 點數&lt;/th>
 &lt;th>備註&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>ConnectionManager&lt;/td>
 &lt;td>0 → 10&lt;/td>
 &lt;td>W2 修復後補的 &lt;code>developer.log&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>TerminalScreen&lt;/td>
 &lt;td>0 → 5&lt;/td>
 &lt;td>W2 修復後補的&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>TtydProtocol&lt;/td>
 &lt;td>0&lt;/td>
 &lt;td>encode/decode/buildAuth 無 log&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>BiometricService&lt;/td>
 &lt;td>0&lt;/td>
 &lt;td>isAvailable/authenticate 結果無 log&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CredentialRepository&lt;/td>
 &lt;td>0&lt;/td>
 &lt;td>load/save/delete 操作無 log&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>EnrollmentScreen&lt;/td>
 &lt;td>0&lt;/td>
 &lt;td>QR 掃描/解析/儲存無 log&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>W2-004（P0：iOS 實機 WS stream 不觸發）的 debug 過程：無法從任何 log 判斷問題發生在 biometric → credential → WS connect → auth token → stream listen 的哪一步。開發者被迫在每個函式手動加 &lt;code>developer.log&lt;/code>，重新編譯，插拔裝置測試，反覆數次才定位到「stream 訂閱時機」問題。&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>debug 成本&lt;/td>
 &lt;td>每次修改→編譯→部署→測試約 3-5 分鐘&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>定位 W2-002 (auth token) 花費&lt;/td>
 &lt;td>約 30 分鐘反覆測試&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>若有連線生命週期 log&lt;/td>
 &lt;td>第一次連線就能看到「Step 3 之後無 auth token 發送」&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Log 缺失把 debug 成本從秒級升到分鐘級&lt;/strong>。如果 ConnectionManager 在企劃階段就設計了「Step 1: biometric → Step 2: credential → Step 3: WS connect → Step 4: auth token → Step 5: listen stream」五步 log，W2-002 的 auth token 問題在第一次連線就能從 log 看到「Step 3 完成，Step 4 未執行」。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>「事後補 log」的 log 品質較低&lt;/strong>。W2 修復時補的 &lt;code>developer.log&lt;/code> 格式不統一（有的帶 &lt;code>name:&lt;/code>，有的不帶；有的用 &lt;code>// i18n-exempt&lt;/code> 標記，有的忘了），沒有統一的 log 層級，沒有結構化欄位。事後補的 log 是救火工具，不是可觀測性設計。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>自用工具最適合自架 log 收集&lt;/strong>。app_tunnel 的 server 和 client 都在同一台機器上（或同一個 Tailscale tailnet），client 可以直接打 HTTP POST 到本機的 log endpoint，不需要 Sentry 或 Crashlytics。一個 Go 寫的 JSON log receiver（20 行）+ grep 就是完整的 debug 工具鏈。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Log 設計是功能規格的一部分&lt;/strong>。「連線到 ttyd 終端機」這個功能的規格不只是「建立 WS 連線」，還包含「每步有 log、失敗有 log、成功有 log」。跟 API 規格需要定義 request/response 一樣，連線功能需要定義 log 點。&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="策略">策略&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>功能規格階段列出 log 點清單&lt;/strong>：每個功能的規格文件新增「可觀測性」欄位，列出啟動/步驟/錯誤/完成四類 log 點。&lt;/li>
&lt;li>&lt;strong>建立統一 log 層&lt;/strong>：封裝 &lt;code>developer.log&lt;/code> 為 &lt;code>AppLogger&lt;/code>，統一 name、level、格式。開發期用 &lt;code>developer.log&lt;/code>，後續可切換到 HTTP log endpoint。&lt;/li>
&lt;li>&lt;strong>自架 log endpoint 方案&lt;/strong>：本機 Go server 開一個 &lt;code>/log&lt;/code> POST endpoint，接收 JSON log，寫入檔案。Client 端 &lt;code>AppLogger&lt;/code> 在 debug mode 同時寫 console + POST 到 endpoint。開發期 grep 查詢，不需要 dashboard。&lt;/li>
&lt;li>&lt;strong>Protocol log 獨立一層&lt;/strong>：WebSocket frame type、payload 前綴、auth handshake 結果獨立記錄，跟 business log 分開。這層 log 在 release mode 應該能關閉。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>想設計客戶端 log 方案 → &lt;a href="https://tarrragon.github.io/blog/testing/02-client-observability/" data-link-title="模組二：客戶端可觀測性" data-link-desc="連線生命週期 log、protocol 訊息 log、使用者行為 log — log 設計是功能規格的一部分">模組二：客戶端可觀測性&lt;/a>&lt;/li>
&lt;li>想理解三層 log 設計 → &lt;a href="https://tarrragon.github.io/blog/testing/02-client-observability/three-layer-log-design/" data-link-title="三層 log 設計" data-link-desc="連線生命週期 log、protocol 訊息 log、使用者行為 log — 三層各自的職責、詳細程度和啟停控制">三層 log 設計&lt;/a>&lt;/li>
&lt;li>想建自架 log endpoint → &lt;a href="https://tarrragon.github.io/blog/testing/02-client-observability/log-endpoint-tradeoff/" data-link-title="自架 log endpoint vs 商業方案的取捨判斷" data-link-desc="自用工具用自架 log receiver（20 行 Go &amp;#43; grep）、商業 app 用 Sentry/Crashlytics — 判斷依據是使用者規模和 debug 需求">自架 log endpoint vs 商業方案&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明「客戶端 log 設計」為什麼應該在功能企劃階段完成，而不是 debug 時才補。Log 不是 debug 工具，是可觀測性基礎設施。</p>
<h2 id="觀察">觀察</h2>
<p>app_tunnel 的六個核心元件在實機測試前的 log 覆蓋狀態：</p>
<table>
  <thead>
      <tr>
          <th>元件</th>
          <th>log 點數</th>
          <th>備註</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>ConnectionManager</td>
          <td>0 → 10</td>
          <td>W2 修復後補的 <code>developer.log</code></td>
      </tr>
      <tr>
          <td>TerminalScreen</td>
          <td>0 → 5</td>
          <td>W2 修復後補的</td>
      </tr>
      <tr>
          <td>TtydProtocol</td>
          <td>0</td>
          <td>encode/decode/buildAuth 無 log</td>
      </tr>
      <tr>
          <td>BiometricService</td>
          <td>0</td>
          <td>isAvailable/authenticate 結果無 log</td>
      </tr>
      <tr>
          <td>CredentialRepository</td>
          <td>0</td>
          <td>load/save/delete 操作無 log</td>
      </tr>
      <tr>
          <td>EnrollmentScreen</td>
          <td>0</td>
          <td>QR 掃描/解析/儲存無 log</td>
      </tr>
  </tbody>
</table>
<p>W2-004（P0：iOS 實機 WS stream 不觸發）的 debug 過程：無法從任何 log 判斷問題發生在 biometric → credential → WS connect → auth token → stream listen 的哪一步。開發者被迫在每個函式手動加 <code>developer.log</code>，重新編譯，插拔裝置測試，反覆數次才定位到「stream 訂閱時機」問題。</p>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>debug 成本</td>
          <td>每次修改→編譯→部署→測試約 3-5 分鐘</td>
      </tr>
      <tr>
          <td>定位 W2-002 (auth token) 花費</td>
          <td>約 30 分鐘反覆測試</td>
      </tr>
      <tr>
          <td>若有連線生命週期 log</td>
          <td>第一次連線就能看到「Step 3 之後無 auth token 發送」</td>
      </tr>
  </tbody>
</table>
<h2 id="判讀">判讀</h2>
<ol>
<li>
<p><strong>Log 缺失把 debug 成本從秒級升到分鐘級</strong>。如果 ConnectionManager 在企劃階段就設計了「Step 1: biometric → Step 2: credential → Step 3: WS connect → Step 4: auth token → Step 5: listen stream」五步 log，W2-002 的 auth token 問題在第一次連線就能從 log 看到「Step 3 完成，Step 4 未執行」。</p>
</li>
<li>
<p><strong>「事後補 log」的 log 品質較低</strong>。W2 修復時補的 <code>developer.log</code> 格式不統一（有的帶 <code>name:</code>，有的不帶；有的用 <code>// i18n-exempt</code> 標記，有的忘了），沒有統一的 log 層級，沒有結構化欄位。事後補的 log 是救火工具，不是可觀測性設計。</p>
</li>
<li>
<p><strong>自用工具最適合自架 log 收集</strong>。app_tunnel 的 server 和 client 都在同一台機器上（或同一個 Tailscale tailnet），client 可以直接打 HTTP POST 到本機的 log endpoint，不需要 Sentry 或 Crashlytics。一個 Go 寫的 JSON log receiver（20 行）+ grep 就是完整的 debug 工具鏈。</p>
</li>
<li>
<p><strong>Log 設計是功能規格的一部分</strong>。「連線到 ttyd 終端機」這個功能的規格不只是「建立 WS 連線」，還包含「每步有 log、失敗有 log、成功有 log」。跟 API 規格需要定義 request/response 一樣，連線功能需要定義 log 點。</p>
</li>
</ol>
<h2 id="策略">策略</h2>
<ol>
<li><strong>功能規格階段列出 log 點清單</strong>：每個功能的規格文件新增「可觀測性」欄位，列出啟動/步驟/錯誤/完成四類 log 點。</li>
<li><strong>建立統一 log 層</strong>：封裝 <code>developer.log</code> 為 <code>AppLogger</code>，統一 name、level、格式。開發期用 <code>developer.log</code>，後續可切換到 HTTP log endpoint。</li>
<li><strong>自架 log endpoint 方案</strong>：本機 Go server 開一個 <code>/log</code> POST endpoint，接收 JSON log，寫入檔案。Client 端 <code>AppLogger</code> 在 debug mode 同時寫 console + POST 到 endpoint。開發期 grep 查詢，不需要 dashboard。</li>
<li><strong>Protocol log 獨立一層</strong>：WebSocket frame type、payload 前綴、auth handshake 結果獨立記錄，跟 business log 分開。這層 log 在 release mode 應該能關閉。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>想設計客戶端 log 方案 → <a href="/blog/testing/02-client-observability/" data-link-title="模組二：客戶端可觀測性" data-link-desc="連線生命週期 log、protocol 訊息 log、使用者行為 log — log 設計是功能規格的一部分">模組二：客戶端可觀測性</a></li>
<li>想理解三層 log 設計 → <a href="/blog/testing/02-client-observability/three-layer-log-design/" data-link-title="三層 log 設計" data-link-desc="連線生命週期 log、protocol 訊息 log、使用者行為 log — 三層各自的職責、詳細程度和啟停控制">三層 log 設計</a></li>
<li>想建自架 log endpoint → <a href="/blog/testing/02-client-observability/log-endpoint-tradeoff/" data-link-title="自架 log endpoint vs 商業方案的取捨判斷" data-link-desc="自用工具用自架 log receiver（20 行 Go &#43; grep）、商業 app 用 Sentry/Crashlytics — 判斷依據是使用者規模和 debug 需求">自架 log endpoint vs 商業方案</a></li>
</ul>
]]></content:encoded></item><item><title>U.C4 首頁缺配對入口按鈕、導航流未完整列出</title><link>https://tarrragon.github.io/blog/ux-design/cases/missing-enrollment-entry-point/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/cases/missing-enrollment-entry-point/</guid><description>&lt;p>這個案例的核心責任是說明導航流設計必須覆蓋所有操作情境的入口，不只是最常用的那個。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>app_tunnel 首頁在 W2-001 修復前只有一個按鈕：Connect Terminal（對應 UC-02 日常連線）。配對功能（UC-01 首次配對）沒有入口 — &lt;code>EnrollmentScreen&lt;/code> 和 &lt;code>QrScannerScreen&lt;/code> 都存在且可運作，但首頁沒有按鈕導航過去。&lt;/p>
&lt;p>Router 定義了三條路由，全部可存取：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="n">GoRouter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">routes:&lt;/span> &lt;span class="p">[&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">GoRoute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">path:&lt;/span> &lt;span class="s1">&amp;#39;/&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nl">builder:&lt;/span> &lt;span class="p">...&lt;/span> &lt;span class="n">HomeScreen&lt;/span>&lt;span class="p">()),&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">GoRoute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">path:&lt;/span> &lt;span class="s1">&amp;#39;/enrollment&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nl">builder:&lt;/span> &lt;span class="p">...&lt;/span> &lt;span class="n">EnrollmentScreen&lt;/span>&lt;span class="p">()),&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">GoRoute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">path:&lt;/span> &lt;span class="s1">&amp;#39;/terminal&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nl">builder:&lt;/span> &lt;span class="p">...&lt;/span> &lt;span class="n">TerminalScreen&lt;/span>&lt;span class="p">()),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="p">]);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>但 HomeScreen 只有一個 &lt;code>context.go('/terminal')&lt;/code> 按鈕，&lt;code>/enrollment&lt;/code> 路由存在但從 UI 無法到達。&lt;/p>
&lt;p>W2-001 修復加入 &lt;code>OutlinedButton.icon&lt;/code> 連結到 &lt;code>/enrollment&lt;/code>，並用 &lt;code>context.push&lt;/code>（非 &lt;code>context.go&lt;/code>）讓配對完成後能返回首頁。&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;/td>
 &lt;td>首次使用者無法配對（功能存在但入口缺失）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>修復&lt;/td>
 &lt;td>加一個 &lt;code>OutlinedButton&lt;/code> + &lt;code>context.push('/enrollment')&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>根因&lt;/td>
 &lt;td>導航流只設計了「日常連線」入口，遺漏「首次配對」入口&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>操作盤點有四個操作，首頁只有一個入口&lt;/strong>。操作盤點段列出四個操作：配對、連線、輪替、啟停。首頁應該是這四個操作的導航 hub，至少要有「配對」和「連線」兩個入口（輪替和啟停是主機端操作，不需要 app 入口）。只放 Connect Terminal 等於假設「使用者已經配對過」。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>路由存在但 UI 不可達 = 死程式碼的 UX 版本&lt;/strong>。&lt;code>/enrollment&lt;/code> 路由在 router 裡定義了，&lt;code>EnrollmentScreen&lt;/code> 也完整實作了，但使用者從 UI 無法觸及。這跟寫了函式但沒有呼叫者一樣 — 功能正確但不可存取。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>&lt;code>go&lt;/code> vs &lt;code>push&lt;/code> 的語意差異影響 UX&lt;/strong>。W2 修復用 &lt;code>context.push('/enrollment')&lt;/code> 而非 &lt;code>context.go('/enrollment')&lt;/code> — &lt;code>push&lt;/code> 保留返回堆疊讓使用者配對後按 back 回首頁；&lt;code>go&lt;/code> 替換整個路由堆疊、沒有 back。這個決策影響使用者的導航體驗，但也是事後才想到的。&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="策略">策略&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>導航流從操作盤點反推&lt;/strong>：每個 UC（用例）的主入口在哪？首頁應該是哪些 UC 的 hub？列出來，確認每個 UC 至少有一條從首頁可達的路徑。&lt;/li>
&lt;li>&lt;strong>路由可達性檢查&lt;/strong>：router 定義的每個路由都應該從 UI 可達。不可達的路由要嘛是遺漏入口（本案例），要嘛是應該刪除的死路由。可以寫一個 lint 檢查。&lt;/li>
&lt;li>&lt;strong>首次 vs 日常使用者的 UX 區分&lt;/strong>：首次使用者需要 onboarding 流程（配對 → 連線），日常使用者只需要連線。兩種入口都要在首頁可見，但可以用視覺層級區分主要/次要。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>想設計完整導航流 → &lt;a href="https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/" data-link-title="模組五：導航模式" data-link-desc="Push/pop stack、GoRouter 命名路由、tab bar、drawer — 導航方法選擇是設計決策">模組五：導航模式&lt;/a>&lt;/li>
&lt;li>想檢查畫面狀態矩陣的退出路徑 → &lt;a href="https://tarrragon.github.io/blog/ux-design/cases/five-states-zero-exits/" data-link-title="U.C1 Terminal 畫面五個狀態零個退出路徑" data-link-desc="Flutter app 的 Terminal 畫面有 idle/connecting/connected/error/disconnected 五個 enum 狀態，每個狀態都沒有 back 或 disconnect 按鈕 — 使用者一旦進入就出不去">U.C1 五狀態零退出&lt;/a>&lt;/li>
&lt;li>想做路由可達性自動化檢查 → 待補：Flutter GoRouter 路由可達性 lint&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明導航流設計必須覆蓋所有操作情境的入口，不只是最常用的那個。</p>
<h2 id="觀察">觀察</h2>
<p>app_tunnel 首頁在 W2-001 修復前只有一個按鈕：Connect Terminal（對應 UC-02 日常連線）。配對功能（UC-01 首次配對）沒有入口 — <code>EnrollmentScreen</code> 和 <code>QrScannerScreen</code> 都存在且可運作，但首頁沒有按鈕導航過去。</p>
<p>Router 定義了三條路由，全部可存取：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">GoRouter</span><span class="p">(</span><span class="nl">routes:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="n">GoRoute</span><span class="p">(</span><span class="nl">path:</span> <span class="s1">&#39;/&#39;</span><span class="p">,</span> <span class="nl">builder:</span> <span class="p">...</span> <span class="n">HomeScreen</span><span class="p">()),</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="n">GoRoute</span><span class="p">(</span><span class="nl">path:</span> <span class="s1">&#39;/enrollment&#39;</span><span class="p">,</span> <span class="nl">builder:</span> <span class="p">...</span> <span class="n">EnrollmentScreen</span><span class="p">()),</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="n">GoRoute</span><span class="p">(</span><span class="nl">path:</span> <span class="s1">&#39;/terminal&#39;</span><span class="p">,</span> <span class="nl">builder:</span> <span class="p">...</span> <span class="n">TerminalScreen</span><span class="p">()),</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">]);</span></span></span></code></pre></div><p>但 HomeScreen 只有一個 <code>context.go('/terminal')</code> 按鈕，<code>/enrollment</code> 路由存在但從 UI 無法到達。</p>
<p>W2-001 修復加入 <code>OutlinedButton.icon</code> 連結到 <code>/enrollment</code>，並用 <code>context.push</code>（非 <code>context.go</code>）讓配對完成後能返回首頁。</p>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>影響</td>
          <td>首次使用者無法配對（功能存在但入口缺失）</td>
      </tr>
      <tr>
          <td>修復</td>
          <td>加一個 <code>OutlinedButton</code> + <code>context.push('/enrollment')</code></td>
      </tr>
      <tr>
          <td>根因</td>
          <td>導航流只設計了「日常連線」入口，遺漏「首次配對」入口</td>
      </tr>
  </tbody>
</table>
<h2 id="判讀">判讀</h2>
<ol>
<li>
<p><strong>操作盤點有四個操作，首頁只有一個入口</strong>。操作盤點段列出四個操作：配對、連線、輪替、啟停。首頁應該是這四個操作的導航 hub，至少要有「配對」和「連線」兩個入口（輪替和啟停是主機端操作，不需要 app 入口）。只放 Connect Terminal 等於假設「使用者已經配對過」。</p>
</li>
<li>
<p><strong>路由存在但 UI 不可達 = 死程式碼的 UX 版本</strong>。<code>/enrollment</code> 路由在 router 裡定義了，<code>EnrollmentScreen</code> 也完整實作了，但使用者從 UI 無法觸及。這跟寫了函式但沒有呼叫者一樣 — 功能正確但不可存取。</p>
</li>
<li>
<p><strong><code>go</code> vs <code>push</code> 的語意差異影響 UX</strong>。W2 修復用 <code>context.push('/enrollment')</code> 而非 <code>context.go('/enrollment')</code> — <code>push</code> 保留返回堆疊讓使用者配對後按 back 回首頁；<code>go</code> 替換整個路由堆疊、沒有 back。這個決策影響使用者的導航體驗，但也是事後才想到的。</p>
</li>
</ol>
<h2 id="策略">策略</h2>
<ol>
<li><strong>導航流從操作盤點反推</strong>：每個 UC（用例）的主入口在哪？首頁應該是哪些 UC 的 hub？列出來，確認每個 UC 至少有一條從首頁可達的路徑。</li>
<li><strong>路由可達性檢查</strong>：router 定義的每個路由都應該從 UI 可達。不可達的路由要嘛是遺漏入口（本案例），要嘛是應該刪除的死路由。可以寫一個 lint 檢查。</li>
<li><strong>首次 vs 日常使用者的 UX 區分</strong>：首次使用者需要 onboarding 流程（配對 → 連線），日常使用者只需要連線。兩種入口都要在首頁可見，但可以用視覺層級區分主要/次要。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>想設計完整導航流 → <a href="/blog/ux-design/05-navigation-patterns/" data-link-title="模組五：導航模式" data-link-desc="Push/pop stack、GoRouter 命名路由、tab bar、drawer — 導航方法選擇是設計決策">模組五：導航模式</a></li>
<li>想檢查畫面狀態矩陣的退出路徑 → <a href="/blog/ux-design/cases/five-states-zero-exits/" data-link-title="U.C1 Terminal 畫面五個狀態零個退出路徑" data-link-desc="Flutter app 的 Terminal 畫面有 idle/connecting/connected/error/disconnected 五個 enum 狀態，每個狀態都沒有 back 或 disconnect 按鈕 — 使用者一旦進入就出不去">U.C1 五狀態零退出</a></li>
<li>想做路由可達性自動化檢查 → 待補：Flutter GoRouter 路由可達性 lint</li>
</ul>
]]></content:encoded></item><item><title>go vs push vs pushReplacement 的 UX 語意表</title><link>https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/go-push-semantics/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/go-push-semantics/</guid><description>&lt;p>&lt;code>go&lt;/code>、&lt;code>push&lt;/code>、&lt;code>pushReplacement&lt;/code> 三種導航方法改變導航堆疊的方式不同，直接影響使用者按 back 時的行為。選擇哪種方法的依據是使用者的操作意圖 — 使用者期望按 back 時回到哪裡。&lt;/p>
&lt;h2 id="語意對照表">語意對照表&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>方法&lt;/th>
 &lt;th>堆疊行為&lt;/th>
 &lt;th>按 back 回到&lt;/th>
 &lt;th>使用者意圖&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>go(path)&lt;/code>&lt;/td>
 &lt;td>替換整個堆疊&lt;/td>
 &lt;td>無（離開 app）&lt;/td>
 &lt;td>切換到另一個工作區&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>push(path)&lt;/code>&lt;/td>
 &lt;td>推入堆疊頂端&lt;/td>
 &lt;td>前一個畫面&lt;/td>
 &lt;td>暫時離開，做完回來&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>pushReplacement&lt;/code>&lt;/td>
 &lt;td>替換堆疊頂端&lt;/td>
 &lt;td>更早的畫面&lt;/td>
 &lt;td>流程中的下一步（不可回退）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="go切換工作區">go：切換工作區&lt;/h2>
&lt;p>&lt;code>go&lt;/code> 把整個導航堆疊替換成新的路徑。使用者按 back 不會回到操作前的畫面，因為堆疊已經被替換。&lt;/p>
&lt;p>適合場景：&lt;/p>
&lt;ul>
&lt;li>登入成功後到首頁（使用者不應該按 back 回到登入畫面）&lt;/li>
&lt;li>登出後到登入畫面（使用者不應該按 back 回到需要認證的畫面）&lt;/li>
&lt;li>從 onboarding 到主畫面（onboarding 完成後不需要回去）&lt;/li>
&lt;/ul>
&lt;p>誤用 &lt;code>go&lt;/code> 的後果：使用者期望按 back 回到前一個畫面但堆疊已空，按 back 直接離開 app。app_tunnel 修復時選擇 &lt;code>push('/enrollment')&lt;/code> 而非 &lt;code>go('/enrollment')&lt;/code>，讓使用者配對完成後能按 back 回首頁（&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/missing-enrollment-entry-point/" data-link-title="U.C4 首頁缺配對入口按鈕、導航流未完整列出" data-link-desc="Flutter app 首頁只有 Connect Terminal 按鈕、沒有 Enroll Device 入口 — 使用者首次使用時找不到配對功能。根因是導航流設計只考慮了日常操作（UC-02 連線）、遺漏了首次操作（UC-01 配對）的入口">U.C4&lt;/a>）。&lt;/p>
&lt;h2 id="push暫時離開做完回來">push：暫時離開，做完回來&lt;/h2>
&lt;p>&lt;code>push&lt;/code> 在堆疊頂端加入新畫面。使用者按 back 回到前一個畫面。&lt;/p>
&lt;p>適合場景：&lt;/p>
&lt;ul>
&lt;li>從列表到詳細頁（看完回到列表）&lt;/li>
&lt;li>從首頁到配對畫面（配對完回首頁）&lt;/li>
&lt;li>從任何畫面到設定頁（改完設定回原畫面）&lt;/li>
&lt;/ul>
&lt;p>&lt;code>push&lt;/code> 是最常用的導航方法，因為多數導航都是「暫時去另一個畫面做事，做完回來」的模式。&lt;/p>
&lt;h2 id="pushreplacement流程中前進">pushReplacement：流程中前進&lt;/h2>
&lt;p>&lt;code>pushReplacement&lt;/code> 用新畫面替換堆疊頂端。堆疊深度不變，按 back 回到替換前畫面的前一個畫面（跳過被替換的畫面）。&lt;/p>
&lt;p>適合場景：&lt;/p>
&lt;ul>
&lt;li>步驟式流程：步驟 1 → pushReplacement 步驟 2 → pushReplacement 步驟 3。使用者在步驟 3 按 back 回到流程開始前的畫面，不會回到步驟 2 或 1。&lt;/li>
&lt;li>結果頁替換搜尋頁：搜尋結果替換搜尋條件頁，使用者按 back 回到搜尋前的畫面。&lt;/li>
&lt;/ul>
&lt;p>pushReplacement 的語意是「這一步完成後使用者不需要回到這裡」。用於不可回退的流程步驟。&lt;/p>
&lt;h2 id="選擇決策流程">選擇決策流程&lt;/h2>
&lt;p>對每個導航操作問一個問題：&lt;strong>使用者按 back 時，期望回到哪裡？&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>回到前一個畫面 → &lt;code>push&lt;/code>&lt;/li>
&lt;li>離開 app 或回到 app 的根畫面 → &lt;code>go&lt;/code>&lt;/li>
&lt;li>跳過當前畫面，回到更早的畫面 → &lt;code>pushReplacement&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>這個決策應該在 UX 設計階段做，記錄在畫面狀態矩陣的「退出路徑」欄中。開發者實作時對照矩陣選擇正確的導航方法。&lt;/p>
&lt;h2 id="常見誤用">常見誤用&lt;/h2>
&lt;h3 id="用-go-做應該用-push-的導航">用 go 做應該用 push 的導航&lt;/h3>
&lt;p>「首頁 → 配對畫面」如果用 &lt;code>go&lt;/code>，使用者配對完成後按 back 離開 app 而非回到首頁。使用者期望的是「配對完成回首頁」（push 行為）。&lt;/p>
&lt;h3 id="用-push-做應該用-go-的導航">用 push 做應該用 go 的導航&lt;/h3>
&lt;p>「登入 → 首頁」如果用 &lt;code>push&lt;/code>，使用者在首頁按 back 回到登入畫面。使用者已經登入，不應該看到登入畫面。&lt;/p>
&lt;h3 id="用-push-做應該用-pushreplacement-的導航">用 push 做應該用 pushReplacement 的導航&lt;/h3>
&lt;p>步驟式流程中「步驟 1 → 步驟 2」如果用 &lt;code>push&lt;/code>，使用者在步驟 2 按 back 回到步驟 1。如果步驟 1 的操作不可逆（已經提交了資料），回到步驟 1 沒有意義。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>Flutter GoRouter 的完整導航 API → &lt;a href="https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/flutter-gorouter/" data-link-title="Flutter GoRouter 導航設計" data-link-desc="GoRouter 的路由定義、導航 API（go / push / pushReplacement）、redirect 機制和 ShellRoute 的使用場景">Flutter GoRouter 導航設計&lt;/a>&lt;/li>
&lt;li>導航模式分類 → &lt;a href="https://tarrragon.github.io/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 導航模式分類&lt;/a>&lt;/li>
&lt;li>路由可達性檢查 → &lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/route-reachability/" data-link-title="路由可達性檢查" data-link-desc="Router 定義的路由 vs UI 實際可達的路由 — 路由存在但 UI 不可達等於死程式碼的 UX 版本">ux-design 模組一 路由可達性&lt;/a>&lt;/li>
&lt;li>導航路徑的自動化測試 → &lt;a href="https://tarrragon.github.io/blog/testing/04-ui-automation/" data-link-title="模組四：自動化 UI 驗證" data-link-desc="Widget test 的狀態覆蓋策略、Playwright 驗證流程、螢幕狀態 coverage">testing 模組四 自動化 UI 驗證&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p><code>go</code>、<code>push</code>、<code>pushReplacement</code> 三種導航方法改變導航堆疊的方式不同，直接影響使用者按 back 時的行為。選擇哪種方法的依據是使用者的操作意圖 — 使用者期望按 back 時回到哪裡。</p>
<h2 id="語意對照表">語意對照表</h2>
<table>
  <thead>
      <tr>
          <th>方法</th>
          <th>堆疊行為</th>
          <th>按 back 回到</th>
          <th>使用者意圖</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>go(path)</code></td>
          <td>替換整個堆疊</td>
          <td>無（離開 app）</td>
          <td>切換到另一個工作區</td>
      </tr>
      <tr>
          <td><code>push(path)</code></td>
          <td>推入堆疊頂端</td>
          <td>前一個畫面</td>
          <td>暫時離開，做完回來</td>
      </tr>
      <tr>
          <td><code>pushReplacement</code></td>
          <td>替換堆疊頂端</td>
          <td>更早的畫面</td>
          <td>流程中的下一步（不可回退）</td>
      </tr>
  </tbody>
</table>
<h2 id="go切換工作區">go：切換工作區</h2>
<p><code>go</code> 把整個導航堆疊替換成新的路徑。使用者按 back 不會回到操作前的畫面，因為堆疊已經被替換。</p>
<p>適合場景：</p>
<ul>
<li>登入成功後到首頁（使用者不應該按 back 回到登入畫面）</li>
<li>登出後到登入畫面（使用者不應該按 back 回到需要認證的畫面）</li>
<li>從 onboarding 到主畫面（onboarding 完成後不需要回去）</li>
</ul>
<p>誤用 <code>go</code> 的後果：使用者期望按 back 回到前一個畫面但堆疊已空，按 back 直接離開 app。app_tunnel 修復時選擇 <code>push('/enrollment')</code> 而非 <code>go('/enrollment')</code>，讓使用者配對完成後能按 back 回首頁（<a href="/blog/ux-design/cases/missing-enrollment-entry-point/" data-link-title="U.C4 首頁缺配對入口按鈕、導航流未完整列出" data-link-desc="Flutter app 首頁只有 Connect Terminal 按鈕、沒有 Enroll Device 入口 — 使用者首次使用時找不到配對功能。根因是導航流設計只考慮了日常操作（UC-02 連線）、遺漏了首次操作（UC-01 配對）的入口">U.C4</a>）。</p>
<h2 id="push暫時離開做完回來">push：暫時離開，做完回來</h2>
<p><code>push</code> 在堆疊頂端加入新畫面。使用者按 back 回到前一個畫面。</p>
<p>適合場景：</p>
<ul>
<li>從列表到詳細頁（看完回到列表）</li>
<li>從首頁到配對畫面（配對完回首頁）</li>
<li>從任何畫面到設定頁（改完設定回原畫面）</li>
</ul>
<p><code>push</code> 是最常用的導航方法，因為多數導航都是「暫時去另一個畫面做事，做完回來」的模式。</p>
<h2 id="pushreplacement流程中前進">pushReplacement：流程中前進</h2>
<p><code>pushReplacement</code> 用新畫面替換堆疊頂端。堆疊深度不變，按 back 回到替換前畫面的前一個畫面（跳過被替換的畫面）。</p>
<p>適合場景：</p>
<ul>
<li>步驟式流程：步驟 1 → pushReplacement 步驟 2 → pushReplacement 步驟 3。使用者在步驟 3 按 back 回到流程開始前的畫面，不會回到步驟 2 或 1。</li>
<li>結果頁替換搜尋頁：搜尋結果替換搜尋條件頁，使用者按 back 回到搜尋前的畫面。</li>
</ul>
<p>pushReplacement 的語意是「這一步完成後使用者不需要回到這裡」。用於不可回退的流程步驟。</p>
<h2 id="選擇決策流程">選擇決策流程</h2>
<p>對每個導航操作問一個問題：<strong>使用者按 back 時，期望回到哪裡？</strong></p>
<ul>
<li>回到前一個畫面 → <code>push</code></li>
<li>離開 app 或回到 app 的根畫面 → <code>go</code></li>
<li>跳過當前畫面，回到更早的畫面 → <code>pushReplacement</code></li>
</ul>
<p>這個決策應該在 UX 設計階段做，記錄在畫面狀態矩陣的「退出路徑」欄中。開發者實作時對照矩陣選擇正確的導航方法。</p>
<h2 id="常見誤用">常見誤用</h2>
<h3 id="用-go-做應該用-push-的導航">用 go 做應該用 push 的導航</h3>
<p>「首頁 → 配對畫面」如果用 <code>go</code>，使用者配對完成後按 back 離開 app 而非回到首頁。使用者期望的是「配對完成回首頁」（push 行為）。</p>
<h3 id="用-push-做應該用-go-的導航">用 push 做應該用 go 的導航</h3>
<p>「登入 → 首頁」如果用 <code>push</code>，使用者在首頁按 back 回到登入畫面。使用者已經登入，不應該看到登入畫面。</p>
<h3 id="用-push-做應該用-pushreplacement-的導航">用 push 做應該用 pushReplacement 的導航</h3>
<p>步驟式流程中「步驟 1 → 步驟 2」如果用 <code>push</code>，使用者在步驟 2 按 back 回到步驟 1。如果步驟 1 的操作不可逆（已經提交了資料），回到步驟 1 沒有意義。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Flutter GoRouter 的完整導航 API → <a href="/blog/ux-design/05-navigation-patterns/flutter-gorouter/" data-link-title="Flutter GoRouter 導航設計" data-link-desc="GoRouter 的路由定義、導航 API（go / push / pushReplacement）、redirect 機制和 ShellRoute 的使用場景">Flutter GoRouter 導航設計</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>
<li>路由可達性檢查 → <a href="/blog/ux-design/01-screen-state-machine/route-reachability/" data-link-title="路由可達性檢查" data-link-desc="Router 定義的路由 vs UI 實際可達的路由 — 路由存在但 UI 不可達等於死程式碼的 UX 版本">ux-design 模組一 路由可達性</a></li>
<li>導航路徑的自動化測試 → <a href="/blog/testing/04-ui-automation/" data-link-title="模組四：自動化 UI 驗證" data-link-desc="Widget test 的狀態覆蓋策略、Playwright 驗證流程、螢幕狀態 coverage">testing 模組四 自動化 UI 驗證</a></li>
</ul>
]]></content:encoded></item><item><title>模組五：導航模式</title><link>https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/</guid><description>&lt;p>回答「畫面之間怎麼跳」。&lt;/p>
&lt;h2 id="對應-findings">對應 findings&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Finding&lt;/th>
 &lt;th>來源&lt;/th>
 &lt;th>內容&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>UF-10&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/missing-enrollment-entry-point/" data-link-title="U.C4 首頁缺配對入口按鈕、導航流未完整列出" data-link-desc="Flutter app 首頁只有 Connect Terminal 按鈕、沒有 Enroll Device 入口 — 使用者首次使用時找不到配對功能。根因是導航流設計只考慮了日常操作（UC-02 連線）、遺漏了首次操作（UC-01 配對）的入口">U.C4&lt;/a>&lt;/td>
 &lt;td>go vs push 語意差異影響 UX — &lt;strong>本模組主寫&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="待寫章節">待寫章節&lt;/h2>
&lt;ul>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Mobile 導航模式分類（push/pop / declarative router / tab / drawer）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Flutter GoRouter 導航設計&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> iOS HIG vs Material Design 導航差異&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Deep link 設計&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> go vs push vs pushReplacement 的 UX 語意表&lt;/li>
&lt;/ul>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>← &lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">ux-design 模組一&lt;/a>：狀態矩陣的「退出路徑」欄位決定用 go 還是 push&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/testing/04-ui-automation/" data-link-title="模組四：自動化 UI 驗證" data-link-desc="Widget test 的狀態覆蓋策略、Playwright 驗證流程、螢幕狀態 coverage">testing 模組四&lt;/a>：導航路徑需要 widget test 驗證&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>回答「畫面之間怎麼跳」。</p>
<h2 id="對應-findings">對應 findings</h2>
<table>
  <thead>
      <tr>
          <th>Finding</th>
          <th>來源</th>
          <th>內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>UF-10</td>
          <td><a href="/blog/ux-design/cases/missing-enrollment-entry-point/" data-link-title="U.C4 首頁缺配對入口按鈕、導航流未完整列出" data-link-desc="Flutter app 首頁只有 Connect Terminal 按鈕、沒有 Enroll Device 入口 — 使用者首次使用時找不到配對功能。根因是導航流設計只考慮了日常操作（UC-02 連線）、遺漏了首次操作（UC-01 配對）的入口">U.C4</a></td>
          <td>go vs push 語意差異影響 UX — <strong>本模組主寫</strong></td>
      </tr>
  </tbody>
</table>
<h2 id="待寫章節">待寫章節</h2>
<ul>
<li><input checked="" disabled="" type="checkbox"> Mobile 導航模式分類（push/pop / declarative router / tab / drawer）</li>
<li><input checked="" disabled="" type="checkbox"> Flutter GoRouter 導航設計</li>
<li><input checked="" disabled="" type="checkbox"> iOS HIG vs Material Design 導航差異</li>
<li><input checked="" disabled="" type="checkbox"> Deep link 設計</li>
<li><input checked="" disabled="" type="checkbox"> go vs push vs pushReplacement 的 UX 語意表</li>
</ul>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>← <a href="/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">ux-design 模組一</a>：狀態矩陣的「退出路徑」欄位決定用 go 還是 push</li>
<li>→ <a href="/blog/testing/04-ui-automation/" data-link-title="模組四：自動化 UI 驗證" data-link-desc="Widget test 的狀態覆蓋策略、Playwright 驗證流程、螢幕狀態 coverage">testing 模組四</a>：導航路徑需要 widget test 驗證</li>
</ul>
]]></content:encoded></item><item><title>Widget 子類重新宣告 key — 遮蔽父類屬性與 duplicate key 風險</title><link>https://tarrragon.github.io/blog/work-log/widget-%E5%AD%90%E9%A1%9E%E9%87%8D%E6%96%B0%E5%AE%A3%E5%91%8A-key-%E9%81%AE%E8%94%BD%E7%88%B6%E9%A1%9E%E5%B1%AC%E6%80%A7%E8%88%87-duplicate-key-%E9%A2%A8%E9%9A%AA/</link><pubDate>Tue, 30 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/widget-%E5%AD%90%E9%A1%9E%E9%87%8D%E6%96%B0%E5%AE%A3%E5%91%8A-key-%E9%81%AE%E8%94%BD%E7%88%B6%E9%A1%9E%E5%B1%AC%E6%80%A7%E8%88%87-duplicate-key-%E9%A2%A8%E9%9A%AA/</guid><description>&lt;h2 id="事件">事件&lt;/h2>
&lt;p>測試用的 &lt;code>TestRiveAnimation extends StatelessWidget&lt;/code> 裡宣告了 &lt;code>final Key? key;&lt;/code>，constructor 中透過 &lt;code>super(key: key)&lt;/code> 傳給父類。Dart analyzer 警告 &lt;code>key&lt;/code> overrides an inherited member。&lt;/p>
&lt;p>加了 &lt;code>@override&lt;/code> 可以消除警告，但問題沒有解決——class 裡現在有兩個 &lt;code>key&lt;/code> slot（子類自己的和 &lt;code>Widget&lt;/code> 繼承的），而 &lt;code>build&lt;/code> 方法裡又寫了 &lt;code>Container(key: key)&lt;/code>，把同一個 key 同時掛在 parent widget 和 child widget 上。&lt;/p>
&lt;h2 id="根因">根因&lt;/h2>
&lt;p>&lt;code>Widget&lt;/code> 的 &lt;code>key&lt;/code> 是 &lt;code>final&lt;/code> 屬性，由 constructor 的 &lt;code>super(key:)&lt;/code> 設定。子類重新宣告同名欄位會產生 shadowing：&lt;/p>
&lt;ul>
&lt;li>子類的程式碼（包括 &lt;code>build&lt;/code>）讀到的是子類自己的那份 &lt;code>key&lt;/code>&lt;/li>
&lt;li>父類 &lt;code>Widget&lt;/code> 的框架程式碼讀到的是父類的那份 &lt;code>key&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>兩份值相同（因為 constructor 都有寫入），但語意上是兩個獨立的 slot。更危險的是，如果在 &lt;code>build&lt;/code> 裡把 &lt;code>key&lt;/code> 往下傳給 child，同一棵 widget 子樹會出現兩個相同的 &lt;code>Key&lt;/code> 值，Flutter 在 diff 時可能拋出 duplicate key 錯誤。&lt;/p>
&lt;h2 id="修法">修法&lt;/h2>
&lt;p>不要重新宣告 &lt;code>key&lt;/code>，改用 &lt;code>super.key&lt;/code>：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">const&lt;/span> &lt;span class="n">TestRiveAnimation&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">asset&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="k">this&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">asset&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&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">super&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">key&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// 直接傳給 Widget，不產生新 slot
&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">&lt;/span> &lt;span class="k">this&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">useArtboardSize&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">false&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>build&lt;/code> 裡也不要把 widget 自身的 key 再傳給 child——key 是給 framework 用來識別這個 widget 的，不該手動轉發。&lt;/p>
&lt;h2 id="判斷原則">判斷原則&lt;/h2>
&lt;p>在 Flutter 中，&lt;code>key&lt;/code>、&lt;code>hashCode&lt;/code>、&lt;code>runtimeType&lt;/code> 這類從 &lt;code>Widget&lt;/code> / &lt;code>Object&lt;/code> 繼承的屬性，子類永遠不該用欄位覆蓋。如果需要自訂行為，覆寫 getter。&lt;/p></description><content:encoded><![CDATA[<h2 id="事件">事件</h2>
<p>測試用的 <code>TestRiveAnimation extends StatelessWidget</code> 裡宣告了 <code>final Key? key;</code>，constructor 中透過 <code>super(key: key)</code> 傳給父類。Dart analyzer 警告 <code>key</code> overrides an inherited member。</p>
<p>加了 <code>@override</code> 可以消除警告，但問題沒有解決——class 裡現在有兩個 <code>key</code> slot（子類自己的和 <code>Widget</code> 繼承的），而 <code>build</code> 方法裡又寫了 <code>Container(key: key)</code>，把同一個 key 同時掛在 parent widget 和 child widget 上。</p>
<h2 id="根因">根因</h2>
<p><code>Widget</code> 的 <code>key</code> 是 <code>final</code> 屬性，由 constructor 的 <code>super(key:)</code> 設定。子類重新宣告同名欄位會產生 shadowing：</p>
<ul>
<li>子類的程式碼（包括 <code>build</code>）讀到的是子類自己的那份 <code>key</code></li>
<li>父類 <code>Widget</code> 的框架程式碼讀到的是父類的那份 <code>key</code></li>
</ul>
<p>兩份值相同（因為 constructor 都有寫入），但語意上是兩個獨立的 slot。更危險的是，如果在 <code>build</code> 裡把 <code>key</code> 往下傳給 child，同一棵 widget 子樹會出現兩個相同的 <code>Key</code> 值，Flutter 在 diff 時可能拋出 duplicate key 錯誤。</p>
<h2 id="修法">修法</h2>
<p>不要重新宣告 <code>key</code>，改用 <code>super.key</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">const</span> <span class="n">TestRiveAnimation</span><span class="p">.</span><span class="n">asset</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="k">this</span><span class="p">.</span><span class="n">asset</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">super</span><span class="p">.</span><span class="n">key</span><span class="p">,</span>           <span class="c1">// 直接傳給 Widget，不產生新 slot
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span>  <span class="k">this</span><span class="p">.</span><span class="n">useArtboardSize</span> <span class="o">=</span> <span class="kc">false</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p><code>build</code> 裡也不要把 widget 自身的 key 再傳給 child——key 是給 framework 用來識別這個 widget 的，不該手動轉發。</p>
<h2 id="判斷原則">判斷原則</h2>
<p>在 Flutter 中，<code>key</code>、<code>hashCode</code>、<code>runtimeType</code> 這類從 <code>Widget</code> / <code>Object</code> 繼承的屬性，子類永遠不該用欄位覆蓋。如果需要自訂行為，覆寫 getter。</p>
]]></content:encoded></item><item><title>192 個測試全過、實機全壞：Mock 遮蔽真實行為的三層測試策略</title><link>https://tarrragon.github.io/blog/work-log/192-%E5%80%8B%E6%B8%AC%E8%A9%A6%E5%85%A8%E9%81%8E%E5%AF%A6%E6%A9%9F%E5%85%A8%E5%A3%9Emock-%E9%81%AE%E8%94%BD%E7%9C%9F%E5%AF%A6%E8%A1%8C%E7%82%BA%E7%9A%84%E4%B8%89%E5%B1%A4%E6%B8%AC%E8%A9%A6%E7%AD%96%E7%95%A5/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/192-%E5%80%8B%E6%B8%AC%E8%A9%A6%E5%85%A8%E9%81%8E%E5%AF%A6%E6%A9%9F%E5%85%A8%E5%A3%9Emock-%E9%81%AE%E8%94%BD%E7%9C%9F%E5%AF%A6%E8%A1%8C%E7%82%BA%E7%9A%84%E4%B8%89%E5%B1%A4%E6%B8%AC%E8%A9%A6%E7%AD%96%E7%95%A5/</guid><description>&lt;h2 id="這篇要解決什麼">這篇要解決什麼&lt;/h2>
&lt;blockquote>
&lt;p>192 個 unit test 全綠、實機部署後全部功能壞掉。&lt;/p>&lt;/blockquote>
&lt;p>這不是測試寫得差 — 每個 test 都有明確斷言、覆蓋了正常和錯誤路徑。問題出在測試策略的結構：所有 test 都用 &lt;code>FakeWebSocketChannel&lt;/code> 替代真實 WebSocket，永遠不會觸碰真實協議行為。結果是 mock 和真實服務之間的差異，在整個測試套件中完全不可見。&lt;/p>
&lt;p>本文拆解三個被 mock 遮蔽的真實問題、分析 mock 遮蔽的機制、提出三層測試策略作為防護。&lt;/p>
&lt;hr>
&lt;h2 id="三個被-mock-遮蔽的真實問題">三個被 Mock 遮蔽的真實問題&lt;/h2>
&lt;h3 id="問題-1text-frame-vs-binary-frame">問題 1：text frame vs binary frame&lt;/h3>
&lt;p>ttyd 的 WebSocket 協議期望 &lt;strong>text frame&lt;/strong>，Flutter 的 &lt;code>WebSocketChannel.sink.add(Uint8List)&lt;/code> 預設發送 &lt;strong>binary frame&lt;/strong>。兩者在 WebSocket 協議層是不同的 opcode（0x1 text vs 0x2 binary），ttyd 收到 binary frame 會靜默忽略。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1">// 原始寫法 — Uint8List 走 binary frame，ttyd 靜默忽略
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kt">void&lt;/span> &lt;span class="n">sendData&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kt">dynamic&lt;/span> &lt;span class="n">data&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&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">_channel&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">sink&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">data&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="c1">// data 是 Uint8List → binary frame
&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">&lt;/span>&lt;span class="p">}&lt;/span>
&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 class="c1">// 修正 — 轉成 String 走 text frame
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kt">void&lt;/span> &lt;span class="n">sendData&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kt">dynamic&lt;/span> &lt;span class="n">data&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">data&lt;/span> &lt;span class="k">is&lt;/span> &lt;span class="n">Uint8List&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="n">_channel&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">sink&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kt">String&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">fromCharCodes&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">data&lt;/span>&lt;span class="p">));&lt;/span> &lt;span class="c1">// text frame
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="k">else&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="n">_channel&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">sink&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">data&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>為什麼 mock 抓不到&lt;/strong>：&lt;code>FakeWebSocketChannel&lt;/code> 的 &lt;code>sink.add&lt;/code> 接受 &lt;code>dynamic&lt;/code>，不區分 &lt;code>String&lt;/code> 和 &lt;code>Uint8List&lt;/code>，兩者都直接存入 &lt;code>_sinkItems&lt;/code> list。Mock 層沒有 frame type 的概念 — 它模擬的是 Dart API，不是 WebSocket 協議。&lt;/p>
&lt;h3 id="問題-2auth-token-handshake-缺失">問題 2：auth token handshake 缺失&lt;/h3>
&lt;p>ttyd 連線後需要發送一個 auth token JSON frame 完成認證，否則 ttyd 關閉連線。整個 auth handshake 的邏輯根本沒實作，因為 &lt;code>FakeWebSocketChannel&lt;/code> 不需要認證就能「連線成功」。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1">// 缺失的 auth handshake — 連線建立後需發送
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kt">void&lt;/span> &lt;span class="n">_sendAuthTokenIfNeeded&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Credential&lt;/span> &lt;span class="n">credential&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">token&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">base64Encode&lt;/span>&lt;span class="p">(&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">utf8&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">encode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="n">credential&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">ttydUser&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s1">:&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="n">credential&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">ttydPass&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s1">&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">frame&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">_protocol&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">buildAuthTokenFrame&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">authToken:&lt;/span> &lt;span class="n">token&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">frame&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">null&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="n">_channel&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">sink&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">frame&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>為什麼 mock 抓不到&lt;/strong>：&lt;code>FakeWebSocketChannel&lt;/code> 的 &lt;code>ready&lt;/code> 立即完成、&lt;code>stream&lt;/code> 立即可用。真實 ttyd 需要收到正確的 auth token 才會開始推送 terminal output；mock 不需要，所以 test 永遠看到「連線成功」。&lt;/p>
&lt;h3 id="問題-3ansi-控制序列多樣性">問題 3：ANSI 控制序列多樣性&lt;/h3>
&lt;p>真實 shell 輸出包含 OSC 序列（&lt;code>ESC]...BEL&lt;/code> 終端機標題設定）、CSI private mode（&lt;code>ESC[?...h/l&lt;/code> 游標隱藏、括號貼上模式）等控制序列。ANSI parser 只處理基本 SGR 色彩碼，其他序列全部殘留在輸出中顯示為亂碼。&lt;/p>
&lt;p>&lt;strong>為什麼 mock 抓不到&lt;/strong>：test 的輸入資料是手寫的乾淨 ANSI 字串（如 &lt;code>\x1B[31mred\x1B[0m&lt;/code>），不包含真實 shell 會產生的 OSC/CSI private mode 序列。真實 zsh prompt 一打開就送幾十種控制序列，但 test data 是人工挑選的乾淨子集。&lt;/p></description><content:encoded><![CDATA[<h2 id="這篇要解決什麼">這篇要解決什麼</h2>
<blockquote>
<p>192 個 unit test 全綠、實機部署後全部功能壞掉。</p></blockquote>
<p>這不是測試寫得差 — 每個 test 都有明確斷言、覆蓋了正常和錯誤路徑。問題出在測試策略的結構：所有 test 都用 <code>FakeWebSocketChannel</code> 替代真實 WebSocket，永遠不會觸碰真實協議行為。結果是 mock 和真實服務之間的差異，在整個測試套件中完全不可見。</p>
<p>本文拆解三個被 mock 遮蔽的真實問題、分析 mock 遮蔽的機制、提出三層測試策略作為防護。</p>
<hr>
<h2 id="三個被-mock-遮蔽的真實問題">三個被 Mock 遮蔽的真實問題</h2>
<h3 id="問題-1text-frame-vs-binary-frame">問題 1：text frame vs binary frame</h3>
<p>ttyd 的 WebSocket 協議期望 <strong>text frame</strong>，Flutter 的 <code>WebSocketChannel.sink.add(Uint8List)</code> 預設發送 <strong>binary frame</strong>。兩者在 WebSocket 協議層是不同的 opcode（0x1 text vs 0x2 binary），ttyd 收到 binary frame 會靜默忽略。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// 原始寫法 — Uint8List 走 binary frame，ttyd 靜默忽略
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="kt">void</span> <span class="n">sendData</span><span class="p">(</span><span class="kt">dynamic</span> <span class="n">data</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="n">_channel</span><span class="o">!</span><span class="p">.</span><span class="n">sink</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="n">data</span><span class="p">);</span> <span class="c1">// data 是 Uint8List → binary frame
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span><span class="p">}</span>
</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 class="c1">// 修正 — 轉成 String 走 text frame
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span><span class="kt">void</span> <span class="n">sendData</span><span class="p">(</span><span class="kt">dynamic</span> <span class="n">data</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="n">data</span> <span class="k">is</span> <span class="n">Uint8List</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="n">_channel</span><span class="o">!</span><span class="p">.</span><span class="n">sink</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="kt">String</span><span class="p">.</span><span class="n">fromCharCodes</span><span class="p">(</span><span class="n">data</span><span class="p">));</span> <span class="c1">// text frame
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span>  <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="n">_channel</span><span class="o">!</span><span class="p">.</span><span class="n">sink</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="n">data</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><strong>為什麼 mock 抓不到</strong>：<code>FakeWebSocketChannel</code> 的 <code>sink.add</code> 接受 <code>dynamic</code>，不區分 <code>String</code> 和 <code>Uint8List</code>，兩者都直接存入 <code>_sinkItems</code> list。Mock 層沒有 frame type 的概念 — 它模擬的是 Dart API，不是 WebSocket 協議。</p>
<h3 id="問題-2auth-token-handshake-缺失">問題 2：auth token handshake 缺失</h3>
<p>ttyd 連線後需要發送一個 auth token JSON frame 完成認證，否則 ttyd 關閉連線。整個 auth handshake 的邏輯根本沒實作，因為 <code>FakeWebSocketChannel</code> 不需要認證就能「連線成功」。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// 缺失的 auth handshake — 連線建立後需發送
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="kt">void</span> <span class="n">_sendAuthTokenIfNeeded</span><span class="p">(</span><span class="n">Credential</span> <span class="n">credential</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="kd">final</span> <span class="n">token</span> <span class="o">=</span> <span class="n">base64Encode</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="n">utf8</span><span class="p">.</span><span class="n">encode</span><span class="p">(</span><span class="s1">&#39;</span><span class="si">${</span><span class="n">credential</span><span class="p">.</span><span class="n">ttydUser</span><span class="si">}</span><span class="s1">:</span><span class="si">${</span><span class="n">credential</span><span class="p">.</span><span class="n">ttydPass</span><span class="si">}</span><span class="s1">&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="p">);</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="kd">final</span> <span class="n">frame</span> <span class="o">=</span> <span class="n">_protocol</span><span class="p">.</span><span class="n">buildAuthTokenFrame</span><span class="p">(</span><span class="nl">authToken:</span> <span class="n">token</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="n">frame</span> <span class="o">!=</span> <span class="kc">null</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="n">_channel</span><span class="o">!</span><span class="p">.</span><span class="n">sink</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="n">frame</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><strong>為什麼 mock 抓不到</strong>：<code>FakeWebSocketChannel</code> 的 <code>ready</code> 立即完成、<code>stream</code> 立即可用。真實 ttyd 需要收到正確的 auth token 才會開始推送 terminal output；mock 不需要，所以 test 永遠看到「連線成功」。</p>
<h3 id="問題-3ansi-控制序列多樣性">問題 3：ANSI 控制序列多樣性</h3>
<p>真實 shell 輸出包含 OSC 序列（<code>ESC]...BEL</code> 終端機標題設定）、CSI private mode（<code>ESC[?...h/l</code> 游標隱藏、括號貼上模式）等控制序列。ANSI parser 只處理基本 SGR 色彩碼，其他序列全部殘留在輸出中顯示為亂碼。</p>
<p><strong>為什麼 mock 抓不到</strong>：test 的輸入資料是手寫的乾淨 ANSI 字串（如 <code>\x1B[31mred\x1B[0m</code>），不包含真實 shell 會產生的 OSC/CSI private mode 序列。真實 zsh prompt 一打開就送幾十種控制序列，但 test data 是人工挑選的乾淨子集。</p>
<hr>
<h2 id="mock-遮蔽的機制">Mock 遮蔽的機制</h2>
<p>三個問題有共同的結構：</p>
<table>
  <thead>
      <tr>
          <th>問題</th>
          <th>Mock 模擬的層級</th>
          <th>真實差異存在的層級</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>text vs binary frame</td>
          <td>Dart API（<code>sink.add</code>）</td>
          <td>WebSocket 協議（opcode）</td>
      </tr>
      <tr>
          <td>auth handshake</td>
          <td>連線生命週期（<code>ready</code> future）</td>
          <td>應用層協議（ttyd 握手）</td>
      </tr>
      <tr>
          <td>ANSI 多樣性</td>
          <td>輸入資料（手寫測試字串）</td>
          <td>真實環境（shell output）</td>
      </tr>
  </tbody>
</table>
<p><strong>共同模式</strong>：mock 忠實模擬了 Dart API 的行為契約，但 Dart API 和真實服務之間還有一層協議語意（WebSocket frame type、ttyd auth handshake、shell 完整輸出），mock 把這層完全跳過了。</p>
<p><strong>這是 mock 的本質</strong>。Mock 的職責是讓 unit test 快速、確定性、不依賴外部服務。但當被測元件的正確性取決於「與外部服務的協議契約」時，mock 從結構上就無法驗證這件事。</p>
<hr>
<h2 id="三層測試策略">三層測試策略</h2>
<table>
  <thead>
      <tr>
          <th>層</th>
          <th>職責</th>
          <th>驗證什麼</th>
          <th>抓不到什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Unit（mock）</strong></td>
          <td>內部邏輯正確性</td>
          <td>狀態轉換、錯誤處理、資料轉換</td>
          <td>協議差異、真實服務行為、環境特異性</td>
      </tr>
      <tr>
          <td><strong>Protocol integration</strong></td>
          <td>協議契約正確性</td>
          <td>frame type、auth handshake、序列完整性</td>
          <td>UI 互動、畫面渲染、用戶體驗</td>
      </tr>
      <tr>
          <td><strong>Screen state（widget test）</strong></td>
          <td>UI 行為正確性</td>
          <td>狀態轉換 UI、導航、用戶操作</td>
          <td>底層協議、網路行為</td>
      </tr>
  </tbody>
</table>
<h3 id="unit-test已有保留">Unit test（已有，保留）</h3>
<p>用 <code>FakeWebSocketChannel</code> 驗證 <code>ConnectionManager</code> 的狀態機：idle → connecting → connected → disconnected，錯誤處理路徑（biometric 失敗、credential 缺失、timeout）。192 個 test 全部保留。</p>
<h3 id="protocol-integration-test新增">Protocol integration test（新增）</h3>
<p><strong>對真實 ttyd + proxy 驗證 WebSocket 協議契約。</strong> 這一層的關鍵是：不用 mock，直接連真實服務。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// 概念示例 — 對真實 ttyd 驗證協議
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="n">test</span><span class="p">(</span><span class="s1">&#39;auth token handshake succeeds against real ttyd&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="c1">// 前提：本機 ttyd 已啟動（test fixture 或 CI 腳本啟動）
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span>  <span class="kd">final</span> <span class="n">channel</span> <span class="o">=</span> <span class="n">IOWebSocketChannel</span><span class="p">.</span><span class="n">connect</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="n">Uri</span><span class="p">.</span><span class="n">parse</span><span class="p">(</span><span class="s1">&#39;ws://127.0.0.1:7681/ws&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nl">protocols:</span> <span class="p">[</span><span class="s1">&#39;tty&#39;</span><span class="p">],</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="p">);</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="kd">await</span> <span class="n">channel</span><span class="p">.</span><span class="n">ready</span><span class="p">;</span>
</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">  <span class="c1">// 發送 auth token
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span>  <span class="kd">final</span> <span class="n">token</span> <span class="o">=</span> <span class="n">base64Encode</span><span class="p">(</span><span class="n">utf8</span><span class="p">.</span><span class="n">encode</span><span class="p">(</span><span class="s1">&#39;testuser:testpass&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="n">channel</span><span class="p">.</span><span class="n">sink</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="s1">&#39;{&#34;AuthToken&#34;:&#34;</span><span class="si">$</span><span class="n">token</span><span class="s1">&#34;}&#39;</span><span class="p">);</span>
</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 class="c1">// 驗證收到 terminal output（text frame，prefix &#39;0&#39;）
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="c1"></span>  <span class="kd">final</span> <span class="n">firstFrame</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">channel</span><span class="p">.</span><span class="n">stream</span><span class="p">.</span><span class="n">first</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">firstFrame</span><span class="p">,</span> <span class="n">isA</span><span class="o">&lt;</span><span class="kt">String</span><span class="o">&gt;</span><span class="p">());</span> <span class="c1">// text frame, not binary
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="c1"></span>  <span class="n">expect</span><span class="p">(</span><span class="n">firstFrame</span><span class="p">[</span><span class="m">0</span><span class="p">],</span> <span class="s1">&#39;0&#39;</span><span class="p">);</span>        <span class="c1">// ttyd output prefix
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="c1"></span><span class="p">});</span></span></span></code></pre></div><p><strong>為什麼這層成本低</strong>：ttyd 和 proxy 都在本機，<code>ttyd --port 7681 --credential &quot;test:test&quot; /bin/echo hello</code> 一行就能啟動一個最小測試服務。CI 腳本先啟動 ttyd → 跑 Dart integration test → 停止 ttyd。不需要模擬器、不需要真實手機。</p>
<h3 id="screen-state-test補強">Screen state test（補強）</h3>
<p>Widget test 覆蓋所有畫面狀態的 UI 行為：每個狀態顯示什麼 widget、哪些按鈕可按、按了之後導航到哪裡。這層已有 7 個 test，但不覆蓋 back 按鈕和 text input。</p>
<hr>
<h2 id="判斷原則什麼時候需要-protocol-integration-test">判斷原則：什麼時候需要 protocol integration test</h2>
<p>不是所有專案都需要三層。判斷標準：</p>
<table>
  <thead>
      <tr>
          <th>條件</th>
          <th>需要 protocol integration test</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>被測元件直接對接外部協議（WS、gRPC、SMTP）</td>
          <td>是</td>
      </tr>
      <tr>
          <td>Mock 和真實服務之間有協議語意差異</td>
          <td>是</td>
      </tr>
      <tr>
          <td>外部服務可在本機啟動（成本低）</td>
          <td>強烈建議</td>
      </tr>
      <tr>
          <td>被測元件只做資料轉換（不碰網路）</td>
          <td>不需要</td>
      </tr>
      <tr>
          <td>外部服務只能在雲端啟動（成本高）</td>
          <td>用 contract test 替代</td>
      </tr>
  </tbody>
</table>
<p><strong>app_tunnel 的特殊優勢</strong>：server 和 client 都在同一台機器上。啟動 ttyd + proxy 然後跑 Dart test，成本極低但價值極高 — 三個實機問題中的兩個（text/binary frame、auth handshake）都能在這層直接抓到。</p>
<hr>
<h2 id="反模式用-mock-數量彌補-mock-盲區">反模式：用 mock 數量彌補 mock 盲區</h2>
<p>「192 個 test 全過」給了虛假的信心。常見的反應是「測試不夠多」然後再加更多 mock test，但問題在層級覆蓋 — 300 個用同一個 <code>FakeWebSocketChannel</code> 的 test 仍然抓不到 text vs binary frame。</p>
<p><strong>測試策略的品質用層級覆蓋衡量，而非數量。</strong> 一個對真實 ttyd 的 5 行 protocol test，比 50 個新增的 mock test 更能防止實機部署失敗。</p>
<h2 id="延伸閱讀">延伸閱讀</h2>
<p>本文的觀察和判讀在 <a href="/blog/testing/" data-link-title="開發測試實務指南" data-link-desc="整理測試策略分層、協議整合驗證、客戶端可觀測性、錯誤收集與自動化驗證 — 從「測試全過但實機全壞」的結構性盲區出發，建立可操作的品質驗證體系">Testing 測試策略</a> 教學系列中展開為系統性的教學模組：<a href="/blog/testing/01-test-strategy-layers/three-layer-definition/" data-link-title="三層定義與職責表" data-link-desc="Unit Test / Protocol Integration Test / Screen State Test 各層職責、驗證目標與盲區的完整論述">三層定義與職責表</a>、<a href="/blog/testing/01-test-strategy-layers/mock-masking-mechanism/" data-link-title="Mock 遮蔽機制分析" data-link-desc="Mock 在 API 層、協議層、環境層之間製造的結構性盲區 — 斷裂點在哪、為什麼 mock 無法也不應該模擬協議行為">Mock 遮蔽機制分析</a>、<a href="/blog/testing/03-protocol-integration-test/" data-link-title="模組三：協議整合測試" data-link-desc="對真實服務驗證 WebSocket / gRPC / HTTP 協議契約 — unit test 和 E2E test 之間的一層">Protocol integration test</a>。</p>
]]></content:encoded></item><item><title>每個畫面都需要出口：畫面狀態機設計與 UX 導航的系統性方法</title><link>https://tarrragon.github.io/blog/work-log/%E6%AF%8F%E5%80%8B%E7%95%AB%E9%9D%A2%E9%83%BD%E9%9C%80%E8%A6%81%E5%87%BA%E5%8F%A3%E7%95%AB%E9%9D%A2%E7%8B%80%E6%85%8B%E6%A9%9F%E8%A8%AD%E8%A8%88%E8%88%87-ux-%E5%B0%8E%E8%88%AA%E7%9A%84%E7%B3%BB%E7%B5%B1%E6%80%A7%E6%96%B9%E6%B3%95/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/%E6%AF%8F%E5%80%8B%E7%95%AB%E9%9D%A2%E9%83%BD%E9%9C%80%E8%A6%81%E5%87%BA%E5%8F%A3%E7%95%AB%E9%9D%A2%E7%8B%80%E6%85%8B%E6%A9%9F%E8%A8%AD%E8%A8%88%E8%88%87-ux-%E5%B0%8E%E8%88%AA%E7%9A%84%E7%B3%BB%E7%B5%B1%E6%80%A7%E6%96%B9%E6%B3%95/</guid><description>&lt;h2 id="這篇要解決什麼">這篇要解決什麼&lt;/h2>
&lt;blockquote>
&lt;p>使用者連上遠端終端機後、無法返回首頁。&lt;/p>&lt;/blockquote>
&lt;p>這是設計遺漏。Terminal 畫面的 &lt;code>connected&lt;/code> 狀態沒有 disconnect 按鈕也沒有 back 按鈕。&lt;code>error&lt;/code> 和 &lt;code>disconnected&lt;/code> 狀態也沒有。使用者被困在畫面裡，唯一的出路是殺掉 app。&lt;/p>
&lt;p>這不是「忘記加按鈕」的問題。回頭看企劃文件，操作盤點段確實列了「連線失敗顯示無法連線」這個失敗情境，但沒有系統性地問：&lt;strong>這個畫面有幾個狀態？每個狀態能做什麼操作？怎麼離開？&lt;/strong>&lt;/p>
&lt;p>本文整理畫面狀態機設計的方法、示範用狀態矩陣捕捉導航缺口、歸納 mobile app UX 的三個設計原則。&lt;/p>
&lt;hr>
&lt;h2 id="實際案例terminal-畫面的五個狀態">實際案例：Terminal 畫面的五個狀態&lt;/h2>
&lt;p>Terminal 畫面有一個 &lt;code>TerminalScreenUiState&lt;/code> enum 定義了五個狀態：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="n">enum&lt;/span> &lt;span class="n">TerminalScreenUiState&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="n">idle&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">connecting&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">connected&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">error&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">disconnected&lt;/span> &lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>實機測試前、這五個狀態各自的 UI 長這樣：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>狀態&lt;/th>
 &lt;th>顯示&lt;/th>
 &lt;th>可用操作&lt;/th>
 &lt;th>退出路徑&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>idle&lt;/td>
 &lt;td>空白（自動開始連線）&lt;/td>
 &lt;td>無&lt;/td>
 &lt;td>&lt;strong>無&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>connecting&lt;/td>
 &lt;td>「連線中&amp;hellip;」進度指示&lt;/td>
 &lt;td>無&lt;/td>
 &lt;td>&lt;strong>無&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>connected&lt;/td>
 &lt;td>終端機畫面 + 工具列&lt;/td>
 &lt;td>打字、Esc/Tab/Ctrl/方向鍵&lt;/td>
 &lt;td>&lt;strong>無&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>error&lt;/td>
 &lt;td>錯誤訊息 + 重連按鈕&lt;/td>
 &lt;td>重新連線&lt;/td>
 &lt;td>&lt;strong>無&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>disconnected&lt;/td>
 &lt;td>「連線中斷」+ 重連按鈕&lt;/td>
 &lt;td>重新連線&lt;/td>
 &lt;td>&lt;strong>無&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>五個狀態、零個退出路徑。使用者一旦進入 Terminal 畫面就出不去。&lt;/p>
&lt;hr>
&lt;h2 id="問題不在按鈕在設計方法">問題不在按鈕、在設計方法&lt;/h2>
&lt;p>加 back 按鈕是 5 分鐘的事。真正的問題是：&lt;strong>企劃階段沒有工具強制你為每個狀態想退出路徑。&lt;/strong>&lt;/p>
&lt;p>操作盤點表長這樣：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>操作&lt;/th>
 &lt;th>主情境&lt;/th>
 &lt;th>失敗情境&lt;/th>
 &lt;th>前端引導&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>日常連線&lt;/td>
 &lt;td>Face ID → 讀憑證 → WS 連線 → 雙向 I/O&lt;/td>
 &lt;td>辨識失敗；Tailscale 離線；ttyd 認證失敗&lt;/td>
 &lt;td>辨識失敗不讀憑證；連線失敗顯示「無法連線」&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>「前端引導」只有一句話。它沒有被展開成畫面狀態。「連線失敗顯示無法連線」這句話覆蓋了 &lt;code>error&lt;/code> 狀態的&lt;strong>顯示&lt;/strong>，但沒有回答&lt;strong>操作&lt;/strong>（重連？返回？）和&lt;strong>退出&lt;/strong>（怎麼離開這個畫面？）。&lt;/p>
&lt;hr>
&lt;h2 id="畫面狀態矩陣">畫面狀態矩陣&lt;/h2>
&lt;p>把狀態機設計變成一張表，強制回答每個狀態的四個面向：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>畫面.狀態&lt;/th>
 &lt;th>顯示&lt;/th>
 &lt;th>可用操作&lt;/th>
 &lt;th>進入條件&lt;/th>
 &lt;th>退出路徑&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Terminal.idle&lt;/td>
 &lt;td>空白&lt;/td>
 &lt;td>—&lt;/td>
 &lt;td>從首頁導航進入&lt;/td>
 &lt;td>back → 首頁&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Terminal.connecting&lt;/td>
 &lt;td>進度指示&lt;/td>
 &lt;td>—&lt;/td>
 &lt;td>自動觸發連線&lt;/td>
 &lt;td>back → 首頁（取消連線）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Terminal.connected&lt;/td>
 &lt;td>終端機 + 工具列&lt;/td>
 &lt;td>打字、特殊鍵&lt;/td>
 &lt;td>WS 連線成功&lt;/td>
 &lt;td>disconnect → idle；back → 首頁&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Terminal.error&lt;/td>
 &lt;td>錯誤訊息&lt;/td>
 &lt;td>重新連線&lt;/td>
 &lt;td>連線失敗&lt;/td>
 &lt;td>back → 首頁；retry → connecting&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Terminal.disconnected&lt;/td>
 &lt;td>「連線中斷」&lt;/td>
 &lt;td>重新連線&lt;/td>
 &lt;td>WS 斷線&lt;/td>
 &lt;td>back → 首頁；retry → connecting&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>表格的威力在「退出路徑」欄位：&lt;strong>如果這格是空的，這就是一個 UX 死胡同。&lt;/strong>&lt;/p>
&lt;hr>
&lt;h2 id="三個-mobile-app-ux-設計原則">三個 Mobile App UX 設計原則&lt;/h2>
&lt;p>從這個案例提煉出的三個原則，適用於所有 mobile app：&lt;/p>
&lt;h3 id="原則-1每個畫面的每個狀態都需要退出路徑">原則 1：每個畫面的每個狀態都需要退出路徑&lt;/h3>
&lt;p>沒有例外。即使是「connecting」這種過渡狀態，使用者也可能想取消。iOS 的 HIG 和 Material Design 都要求 modal 畫面提供 dismiss 機制 — 如果使用者進不了某個狀態的下一步（連線失敗、timeout、服務無回應），他至少得能退出。&lt;/p>
&lt;p>&lt;strong>反模式&lt;/strong>：假設使用者只走 happy path。「connected 之後使用者不會想回首頁」是開發者的假設，不是使用者的需求。&lt;/p>
&lt;h3 id="原則-2gate-必須有-fallback">原則 2：Gate 必須有 fallback&lt;/h3>
&lt;p>Gate = 使用者必須通過的關卡（biometric、network、auth）。每個 gate 的設計不只是「成功時怎麼做」，還包含「失敗時的替代路徑」。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Gate&lt;/th>
 &lt;th>成功&lt;/th>
 &lt;th>失敗 fallback&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Biometric（Face ID / 指紋）&lt;/td>
 &lt;td>讀取憑證、繼續連線&lt;/td>
 &lt;td>密碼 fallback（&lt;code>biometricOnly: false&lt;/code>）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Network（Tailscale VPN）&lt;/td>
 &lt;td>WS 連線&lt;/td>
 &lt;td>顯示「網路不可用」+ 重試&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Auth（ttyd basic auth）&lt;/td>
 &lt;td>進入終端機&lt;/td>
 &lt;td>顯示「認證失敗」+ 建議重新配對&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;code>biometricOnly: true&lt;/code> 就是缺少 fallback 的典型案例 — Face ID 不可用（戴口罩、光線差、指紋模糊）時使用者直接被擋住，沒有替代方案。改為 &lt;code>biometricOnly: false&lt;/code> 讓系統提供密碼 fallback。&lt;/p></description><content:encoded><![CDATA[<h2 id="這篇要解決什麼">這篇要解決什麼</h2>
<blockquote>
<p>使用者連上遠端終端機後、無法返回首頁。</p></blockquote>
<p>這是設計遺漏。Terminal 畫面的 <code>connected</code> 狀態沒有 disconnect 按鈕也沒有 back 按鈕。<code>error</code> 和 <code>disconnected</code> 狀態也沒有。使用者被困在畫面裡，唯一的出路是殺掉 app。</p>
<p>這不是「忘記加按鈕」的問題。回頭看企劃文件，操作盤點段確實列了「連線失敗顯示無法連線」這個失敗情境，但沒有系統性地問：<strong>這個畫面有幾個狀態？每個狀態能做什麼操作？怎麼離開？</strong></p>
<p>本文整理畫面狀態機設計的方法、示範用狀態矩陣捕捉導航缺口、歸納 mobile app UX 的三個設計原則。</p>
<hr>
<h2 id="實際案例terminal-畫面的五個狀態">實際案例：Terminal 畫面的五個狀態</h2>
<p>Terminal 畫面有一個 <code>TerminalScreenUiState</code> enum 定義了五個狀態：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">enum</span> <span class="n">TerminalScreenUiState</span> <span class="p">{</span> <span class="n">idle</span><span class="p">,</span> <span class="n">connecting</span><span class="p">,</span> <span class="n">connected</span><span class="p">,</span> <span class="n">error</span><span class="p">,</span> <span class="n">disconnected</span> <span class="p">}</span></span></span></code></pre></div><p>實機測試前、這五個狀態各自的 UI 長這樣：</p>
<table>
  <thead>
      <tr>
          <th>狀態</th>
          <th>顯示</th>
          <th>可用操作</th>
          <th>退出路徑</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>idle</td>
          <td>空白（自動開始連線）</td>
          <td>無</td>
          <td><strong>無</strong></td>
      </tr>
      <tr>
          <td>connecting</td>
          <td>「連線中&hellip;」進度指示</td>
          <td>無</td>
          <td><strong>無</strong></td>
      </tr>
      <tr>
          <td>connected</td>
          <td>終端機畫面 + 工具列</td>
          <td>打字、Esc/Tab/Ctrl/方向鍵</td>
          <td><strong>無</strong></td>
      </tr>
      <tr>
          <td>error</td>
          <td>錯誤訊息 + 重連按鈕</td>
          <td>重新連線</td>
          <td><strong>無</strong></td>
      </tr>
      <tr>
          <td>disconnected</td>
          <td>「連線中斷」+ 重連按鈕</td>
          <td>重新連線</td>
          <td><strong>無</strong></td>
      </tr>
  </tbody>
</table>
<p>五個狀態、零個退出路徑。使用者一旦進入 Terminal 畫面就出不去。</p>
<hr>
<h2 id="問題不在按鈕在設計方法">問題不在按鈕、在設計方法</h2>
<p>加 back 按鈕是 5 分鐘的事。真正的問題是：<strong>企劃階段沒有工具強制你為每個狀態想退出路徑。</strong></p>
<p>操作盤點表長這樣：</p>
<table>
  <thead>
      <tr>
          <th>操作</th>
          <th>主情境</th>
          <th>失敗情境</th>
          <th>前端引導</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>日常連線</td>
          <td>Face ID → 讀憑證 → WS 連線 → 雙向 I/O</td>
          <td>辨識失敗；Tailscale 離線；ttyd 認證失敗</td>
          <td>辨識失敗不讀憑證；連線失敗顯示「無法連線」</td>
      </tr>
  </tbody>
</table>
<p>「前端引導」只有一句話。它沒有被展開成畫面狀態。「連線失敗顯示無法連線」這句話覆蓋了 <code>error</code> 狀態的<strong>顯示</strong>，但沒有回答<strong>操作</strong>（重連？返回？）和<strong>退出</strong>（怎麼離開這個畫面？）。</p>
<hr>
<h2 id="畫面狀態矩陣">畫面狀態矩陣</h2>
<p>把狀態機設計變成一張表，強制回答每個狀態的四個面向：</p>
<table>
  <thead>
      <tr>
          <th>畫面.狀態</th>
          <th>顯示</th>
          <th>可用操作</th>
          <th>進入條件</th>
          <th>退出路徑</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Terminal.idle</td>
          <td>空白</td>
          <td>—</td>
          <td>從首頁導航進入</td>
          <td>back → 首頁</td>
      </tr>
      <tr>
          <td>Terminal.connecting</td>
          <td>進度指示</td>
          <td>—</td>
          <td>自動觸發連線</td>
          <td>back → 首頁（取消連線）</td>
      </tr>
      <tr>
          <td>Terminal.connected</td>
          <td>終端機 + 工具列</td>
          <td>打字、特殊鍵</td>
          <td>WS 連線成功</td>
          <td>disconnect → idle；back → 首頁</td>
      </tr>
      <tr>
          <td>Terminal.error</td>
          <td>錯誤訊息</td>
          <td>重新連線</td>
          <td>連線失敗</td>
          <td>back → 首頁；retry → connecting</td>
      </tr>
      <tr>
          <td>Terminal.disconnected</td>
          <td>「連線中斷」</td>
          <td>重新連線</td>
          <td>WS 斷線</td>
          <td>back → 首頁；retry → connecting</td>
      </tr>
  </tbody>
</table>
<p>表格的威力在「退出路徑」欄位：<strong>如果這格是空的，這就是一個 UX 死胡同。</strong></p>
<hr>
<h2 id="三個-mobile-app-ux-設計原則">三個 Mobile App UX 設計原則</h2>
<p>從這個案例提煉出的三個原則，適用於所有 mobile app：</p>
<h3 id="原則-1每個畫面的每個狀態都需要退出路徑">原則 1：每個畫面的每個狀態都需要退出路徑</h3>
<p>沒有例外。即使是「connecting」這種過渡狀態，使用者也可能想取消。iOS 的 HIG 和 Material Design 都要求 modal 畫面提供 dismiss 機制 — 如果使用者進不了某個狀態的下一步（連線失敗、timeout、服務無回應），他至少得能退出。</p>
<p><strong>反模式</strong>：假設使用者只走 happy path。「connected 之後使用者不會想回首頁」是開發者的假設，不是使用者的需求。</p>
<h3 id="原則-2gate-必須有-fallback">原則 2：Gate 必須有 fallback</h3>
<p>Gate = 使用者必須通過的關卡（biometric、network、auth）。每個 gate 的設計不只是「成功時怎麼做」，還包含「失敗時的替代路徑」。</p>
<table>
  <thead>
      <tr>
          <th>Gate</th>
          <th>成功</th>
          <th>失敗 fallback</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Biometric（Face ID / 指紋）</td>
          <td>讀取憑證、繼續連線</td>
          <td>密碼 fallback（<code>biometricOnly: false</code>）</td>
      </tr>
      <tr>
          <td>Network（Tailscale VPN）</td>
          <td>WS 連線</td>
          <td>顯示「網路不可用」+ 重試</td>
      </tr>
      <tr>
          <td>Auth（ttyd basic auth）</td>
          <td>進入終端機</td>
          <td>顯示「認證失敗」+ 建議重新配對</td>
      </tr>
  </tbody>
</table>
<p><code>biometricOnly: true</code> 就是缺少 fallback 的典型案例 — Face ID 不可用（戴口罩、光線差、指紋模糊）時使用者直接被擋住，沒有替代方案。改為 <code>biometricOnly: false</code> 讓系統提供密碼 fallback。</p>
<h3 id="原則-3輸入機制是設計產物不是實作細節">原則 3：輸入機制是設計產物，不是實作細節</h3>
<p>「手機打字操作 CLI」的輸入設計決策比想像的多：</p>
<table>
  <thead>
      <tr>
          <th>設計決策</th>
          <th>選項</th>
          <th>取捨</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Keyboard type</td>
          <td><code>visiblePassword</code>（無自動校正）vs <code>text</code>（有校正）</td>
          <td>CLI 命令不需要自動校正，<code>visiblePassword</code> 避免系統「幫忙」修改輸入</td>
      </tr>
      <tr>
          <td>Submit model</td>
          <td>Enter 送出整行 vs 逐字元即時送出</td>
          <td>整行送出減少網路來回，但沒有即時 tab 補全回饋</td>
      </tr>
      <tr>
          <td>IME policy</td>
          <td>關閉建議、關閉自動校正、關閉個人化學習</td>
          <td>CLI 輸入內容可能包含密碼和路徑，IME 學習是安全風險</td>
      </tr>
      <tr>
          <td>Special keys</td>
          <td>Esc / Tab / Ctrl 組合鍵</td>
          <td>手機鍵盤沒有這些鍵，需要自訂工具列</td>
      </tr>
  </tbody>
</table>
<p>這些決策在企劃階段就應該做，因為它們影響 UI layout（是否需要輸入框？工具列放什麼鍵？）和 protocol 設計（逐字元還是整行？）。事後補的 <code>TextField</code> 參數列表（<code>enableSuggestions: false, autocorrect: false, enableIMEPersonalizedLearning: false</code>）全是散落的 hotfix，不是設計產物。</p>
<hr>
<h2 id="系統性方法從操作盤點到畫面狀態矩陣">系統性方法：從操作盤點到畫面狀態矩陣</h2>
<p>操作盤點是 BDD 的起點（使用者做什麼、成功時發生什麼、失敗時發生什麼）。但盤點到「前端引導」就停了 — 它回答了「顯示什麼」但沒回答「能做什麼」「怎麼離開」。</p>
<p>補上的步驟：</p>
<ol>
<li><strong>從操作盤點列出所有畫面</strong>：每個操作涉及哪些畫面？（首頁 → 配對畫面 → QR 掃描 → 終端機畫面）</li>
<li><strong>每個畫面列出所有狀態</strong>：這個畫面有哪些 enum 值或邏輯分支？</li>
<li><strong>填畫面狀態矩陣</strong>：顯示 / 可用操作 / 進入條件 / 退出路徑。退出路徑欄位為空 = UX 死胡同</li>
<li><strong>每個 gate 標注 fallback</strong>：biometric / network / auth 各有什麼替代方案？</li>
<li><strong>輸入機制列決策表</strong>：keyboard type / submit model / IME policy / special keys</li>
</ol>
<p>這是操作盤點本來就該產出的下一層。一張表能在 10 分鐘內暴露所有 UX 死胡同，省掉實機測試才發現的成本。</p>
<h2 id="延伸閱讀">延伸閱讀</h2>
<p>本文的觀察和判讀在 <a href="/blog/ux-design/" data-link-title="UX 設計實務指南" data-link-desc="整理畫面狀態機、導航設計、Gate fallback、輸入機制與使用者行為驗證 — 從「使用者被困在畫面裡出不去」的結構性遺漏出發，建立系統性的 UX 設計方法">UX Design 畫面設計</a> 教學系列中展開為系統性的教學模組：<a href="/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">畫面狀態矩陣的定義與填寫方法</a>、<a href="/blog/ux-design/02-gate-fallback/gate-three-questions/" data-link-title="Gate 分類與三問設計法" data-link-desc="每個 gate 設計時問三個問題：成功時做什麼、失敗時做什麼、使用者不知道發生什麼時做什麼">Gate 分類與三問設計法</a>、<a href="/blog/ux-design/03-input-mechanism/four-dimension-decision/" data-link-title="輸入機制決策表" data-link-desc="Keyboard type / submit model / IME policy / special keys 四個維度的決策框架 — 每個維度都是設計決策，影響 UI layout 和 protocol">輸入機制決策表</a>。</p>
]]></content:encoded></item><item><title>為什麼這個場景適合用高階函式？以 Flutter 設定更新為例，比較 typedef 改寫前後</title><link>https://tarrragon.github.io/blog/work-log/dart_hof_typedef_readability/</link><pubDate>Mon, 01 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/dart_hof_typedef_readability/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>核心議題&lt;/strong>：高階函式是特定場景的自然解 — 當「流程固定、變化點單一且開放」時，把變化點抽成函式參數最省。要不要用它，由場景特徵決定。本文先論證這個場景為何適合 HOF，再比較同一 pattern 的兩種表達（裸函式型別 vs &lt;code>typedef&lt;/code>）各自的優缺點。
&lt;strong>案例骨幹&lt;/strong>：&lt;code>SettingsController.update(transform)&lt;/code> — 9 個設定欄位共用同一條「取值→算新值→去重→通知」流程，唯一的變化是「改哪個欄位」。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="1-案例一個收函式的設定更新方法">1. 案例：一個收函式的設定更新方法&lt;/h2>
&lt;p>設定有 9 個欄位（字型、顏色、描邊、時間格式、目標螢幕、開機啟動…）。每個欄位變更都要走同一串流程：取當前設定 → 算出新設定 → 比對是否確實改變 → 賦回並通知 UI 重繪。把這串流程封裝成一個方法：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">class&lt;/span> &lt;span class="nc">SettingsController&lt;/span> &lt;span class="kd">extends&lt;/span> &lt;span class="n">ValueNotifier&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">SettingsModel&lt;/span>&lt;span class="o">&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kt">void&lt;/span> &lt;span class="n">update&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">SettingsModel&lt;/span> &lt;span class="n">Function&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">SettingsModel&lt;/span> &lt;span class="n">current&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="n">mutate&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">SettingsModel&lt;/span> &lt;span class="n">next&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">mutate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">value&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">next&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="n">value&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="n">value&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">next&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&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-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="n">controller&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">update&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">s&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">s&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">copyWith&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">fillColor:&lt;/span> &lt;span class="n">c&lt;/span>&lt;span class="p">));&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">controller&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">update&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">s&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">s&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">copyWith&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">fontSize:&lt;/span> &lt;span class="n">v&lt;/span>&lt;span class="p">));&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>update&lt;/code> 收的這個參數本身是「一個函式」 — 把函式當成可傳遞的值。這就是 higher-order function。&lt;/p>
&lt;h3 id="簽章的型別與名字拆解">簽章的型別與名字拆解&lt;/h3>
&lt;p>這個簽章的關鍵是分清「哪裡是型別、哪裡是名字」。它是一個普通的參數宣告，順序跟常見的 &lt;code>int count&lt;/code>、&lt;code>Color color&lt;/code> 一樣是 &lt;strong>&lt;code>型別 名字&lt;/code>&lt;/strong>，只是這次型別換成了較長的函式型別：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kt">void&lt;/span> &lt;span class="n">update&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">SettingsModel&lt;/span> &lt;span class="n">Function&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">SettingsModel&lt;/span> &lt;span class="n">current&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="n">mutate&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="o">//&lt;/span> &lt;span class="err">└────────────&lt;/span> &lt;span class="err">型別（函式型別）────────────┘&lt;/span> &lt;span class="err">└名字┘&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>mutate&lt;/code> 是&lt;strong>這個參數的名字&lt;/strong> — 方法內部靠它指涉傳進來的那個函式：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kt">void&lt;/span> &lt;span class="n">update&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">SettingsModel&lt;/span> &lt;span class="n">Function&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">SettingsModel&lt;/span> &lt;span class="n">current&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="n">mutate&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">SettingsModel&lt;/span> &lt;span class="n">next&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">mutate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">value&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="c1">// ← 用名字 mutate「呼叫」傳進來的函式
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>容易混淆的是型別裡面那個 &lt;code>current&lt;/code>：它和 &lt;code>mutate&lt;/code> 不同層級 — &lt;code>current&lt;/code> 只是函式型別內標記參數的名字，&lt;strong>純文件性&lt;/strong>，寫成 &lt;code>SettingsModel Function(SettingsModel)&lt;/code> 行為完全一樣，只是讓型別讀起來更清楚。換句話說，前半的函式型別規定「這個名字必須是什麼形狀的函式」，最後的 &lt;code>mutate&lt;/code> 則是「這個函式參數叫什麼」。下一節先補 HOF 的基礎，第 4 節再回頭談「前半那串型別裸寫在簽章」造成的閱讀摩擦。&lt;/p>
&lt;hr>
&lt;h2 id="2-higher-order-function-是什麼最小定義">2. Higher-order function 是什麼（最小定義）&lt;/h2>
&lt;p>&lt;strong>把函式當資料處理的函式&lt;/strong> — 接收函式當參數，或回傳函式，符合其一即是。前提是語言把函式視為一等公民（first-class），能像變數一樣傳遞。Dart、JS、Kotlin、Swift 皆成立。&lt;/p>
&lt;p>常見的 &lt;code>list.map((x) =&amp;gt; x*2)&lt;/code>、&lt;code>list.where((x) =&amp;gt; x&amp;gt;0)&lt;/code>、&lt;code>onPressed: () =&amp;gt; ...&lt;/code> 都屬此類。&lt;code>update((s) =&amp;gt; ...)&lt;/code> 是同一家族。&lt;/p>
&lt;hr>
&lt;h2 id="3-為什麼這個場景適合用-hof">3. 為什麼這個場景適合用 HOF&lt;/h2>
&lt;p>這個場景有三個特徵，剛好對上 HOF 的強項 — HOF 適不適用，由這些特徵決定。&lt;/p>
&lt;h3 id="31-流程固定變化點單一">3.1 流程固定、變化點單一&lt;/h3>
&lt;p>9 個欄位的更新，&lt;strong>流程 100% 相同&lt;/strong>（取值、去重、賦回、通知），&lt;strong>唯一差異&lt;/strong>是中間那一步「&lt;code>copyWith(哪個欄位: 值)&lt;/code>」。&lt;/p>
&lt;p>當「共用流程」與「變化點」能這樣切乾淨時，HOF 正好對上這個結構：把固定流程寫死在 &lt;code>update&lt;/code> 裡，把變化點抽成函式參數 &lt;code>transform&lt;/code> 由呼叫端帶入。&lt;code>map&lt;/code> 對「走訪迴圈（固定）+ 元素變換（變化）」做的是同一件事。&lt;/p>
&lt;h3 id="32-模型不可變本來就是current--next">3.2 模型不可變，本來就是「current → next」&lt;/h3>
&lt;p>&lt;code>SettingsModel&lt;/code> 是不可變物件（&lt;code>@immutable&lt;/code> + 全 &lt;code>final&lt;/code>）：要改 &lt;code>fillColor&lt;/code>，得用 &lt;code>copyWith&lt;/code> 產生新副本、再把整個物件替換回去。&lt;/p>
&lt;p>也就是說，不可變模型下的更新，在語意上&lt;strong>就是一個 &lt;code>(current) =&amp;gt; next&lt;/code> 的函式&lt;/strong> — 拿舊值算出新值。用函式參數表達這件事，是最貼合的形狀。&lt;/p>
&lt;h3 id="33-變化點開放難以列舉">3.3 變化點開放、難以列舉&lt;/h3>
&lt;p>「未來會改哪些欄位、怎麼組合」是開放的（可能同時改兩個欄位、可能有條件邏輯）。函式參數能表達任意轉換；若改用「enum 指定欄位 + switch」則被固定的列舉鎖死，每加一種改法都要動 &lt;code>update&lt;/code> 內部。HOF 把「怎麼改」的決定權留在呼叫端，&lt;code>update&lt;/code> 不需要知道。&lt;/p>
&lt;p>反過來說，當「變化集合是封閉的、而且需要被序列化或跨層比對」時，enum + switch 反而較好 — 例如要把「使用者改了哪個欄位」存進 undo 堆疊、或透過網路傳給後端，列舉值是可序列化的資料，閉包不是。本案例的變化點純粹發生在呼叫端、不需要 persist，HOF 才站得住。所以「開放」算不算優點，要跟「變化是否需要被當資料搬運」一起看。&lt;/p>
&lt;blockquote>
&lt;p>判準：&lt;strong>流程固定 + 變化點單一 + 變化開放&lt;/strong> 三者同時成立時，HOF 幾乎總是比「列舉 + 分支」或「複製多個方法」更省。&lt;/p>&lt;/blockquote>
&lt;p>對照反例放進具體場景更清楚。假設一個只有「深色模式開關」單一布林設定的 controller，更新邏輯就是 &lt;code>value = !value&lt;/code>，既沒有共用流程、也沒有開放的變化點 — 這時把它包成收函式的 &lt;code>update&lt;/code>，只是逼讀者解析一串函式型別去做一件 &lt;code>toggleDarkMode()&lt;/code> 就講完的事，抽象成本大於收益。另一種反向情境是：9 個欄位看似共用流程，實際每個的更新路徑各不相同（有的要打 API、有的要寫檔、有的純記憶體），那麼「固定流程」的前提根本不成立，硬抽進 &lt;code>update&lt;/code> 反而把三條不同的路徑塞進同一個殼裡。三條件少一條，具名方法通常更省 — 場景不對時硬用，才是過度設計。&lt;/p>
&lt;hr>
&lt;h2 id="4-原始寫法的優缺點裸函式型別">4. 原始寫法的優缺點（裸函式型別）&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kt">void&lt;/span> &lt;span class="n">update&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">SettingsModel&lt;/span> &lt;span class="n">Function&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">SettingsModel&lt;/span> &lt;span class="n">current&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="n">mutate&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">SettingsModel&lt;/span> &lt;span class="n">next&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">mutate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">value&lt;/span>&lt;span class="p">);&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="p">(&lt;/span>&lt;span class="n">next&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="n">value&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="n">value&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">next&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="什麼是函式型別裸寫在簽章">什麼是「函式型別裸寫在簽章」&lt;/h3>
&lt;p>這是整個討論的起點，值得單獨講清楚。把術語拆三個詞：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>函式型別&lt;/strong>：描述「一個函式長什麼樣」的型別，例如 &lt;code>SettingsModel Function(SettingsModel current)&lt;/code> — 收一個 &lt;code>SettingsModel&lt;/code>、回傳一個 &lt;code>SettingsModel&lt;/code>。&lt;/li>
&lt;li>&lt;strong>裸寫&lt;/strong>：把完整型別&lt;strong>整串攤開寫出來&lt;/strong>，沒有先取名包裝（對比「裸數字 / magic number」直接寫 &lt;code>120&lt;/code> 而非具名常數）。&lt;/li>
&lt;li>&lt;strong>在簽章&lt;/strong>：寫在方法的參數列（signature）裡。&lt;/li>
&lt;/ul>
&lt;p>合起來就是：&lt;strong>把那串 &lt;code>SettingsModel Function(SettingsModel current)&lt;/code> 原封不動塞進參數位，而不是先用 &lt;code>typedef&lt;/code> 取個名字再引用。&lt;/strong>&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>核心議題</strong>：高階函式是特定場景的自然解 — 當「流程固定、變化點單一且開放」時，把變化點抽成函式參數最省。要不要用它，由場景特徵決定。本文先論證這個場景為何適合 HOF，再比較同一 pattern 的兩種表達（裸函式型別 vs <code>typedef</code>）各自的優缺點。
<strong>案例骨幹</strong>：<code>SettingsController.update(transform)</code> — 9 個設定欄位共用同一條「取值→算新值→去重→通知」流程，唯一的變化是「改哪個欄位」。</p></blockquote>
<hr>
<h2 id="1-案例一個收函式的設定更新方法">1. 案例：一個收函式的設定更新方法</h2>
<p>設定有 9 個欄位（字型、顏色、描邊、時間格式、目標螢幕、開機啟動…）。每個欄位變更都要走同一串流程：取當前設定 → 算出新設定 → 比對是否確實改變 → 賦回並通知 UI 重繪。把這串流程封裝成一個方法：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">class</span> <span class="nc">SettingsController</span> <span class="kd">extends</span> <span class="n">ValueNotifier</span><span class="o">&lt;</span><span class="n">SettingsModel</span><span class="o">&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kt">void</span> <span class="n">update</span><span class="p">(</span><span class="n">SettingsModel</span> <span class="n">Function</span><span class="p">(</span><span class="n">SettingsModel</span> <span class="n">current</span><span class="p">)</span> <span class="n">mutate</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="kd">final</span> <span class="n">SettingsModel</span> <span class="n">next</span> <span class="o">=</span> <span class="n">mutate</span><span class="p">(</span><span class="n">value</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">next</span> <span class="o">!=</span> <span class="n">value</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">      <span class="n">value</span> <span class="o">=</span> <span class="n">next</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>呼叫端只描述「改哪個欄位」：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">controller</span><span class="p">.</span><span class="n">update</span><span class="p">((</span><span class="n">s</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">s</span><span class="p">.</span><span class="n">copyWith</span><span class="p">(</span><span class="nl">fillColor:</span> <span class="n">c</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">controller</span><span class="p">.</span><span class="n">update</span><span class="p">((</span><span class="n">s</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">s</span><span class="p">.</span><span class="n">copyWith</span><span class="p">(</span><span class="nl">fontSize:</span> <span class="n">v</span><span class="p">));</span></span></span></code></pre></div><p><code>update</code> 收的這個參數本身是「一個函式」 — 把函式當成可傳遞的值。這就是 higher-order function。</p>
<h3 id="簽章的型別與名字拆解">簽章的型別與名字拆解</h3>
<p>這個簽章的關鍵是分清「哪裡是型別、哪裡是名字」。它是一個普通的參數宣告，順序跟常見的 <code>int count</code>、<code>Color color</code> 一樣是 <strong><code>型別 名字</code></strong>，只是這次型別換成了較長的函式型別：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="kt">void</span> <span class="n">update</span><span class="p">(</span><span class="n">SettingsModel</span> <span class="n">Function</span><span class="p">(</span><span class="n">SettingsModel</span> <span class="n">current</span><span class="p">)</span>  <span class="n">mutate</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="o">//</span>          <span class="err">└────────────</span> <span class="err">型別（函式型別）────────────┘</span>  <span class="err">└名字┘</span></span></span></code></pre></div><p><code>mutate</code> 是<strong>這個參數的名字</strong> — 方法內部靠它指涉傳進來的那個函式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="kt">void</span> <span class="n">update</span><span class="p">(</span><span class="n">SettingsModel</span> <span class="n">Function</span><span class="p">(</span><span class="n">SettingsModel</span> <span class="n">current</span><span class="p">)</span> <span class="n">mutate</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">final</span> <span class="n">SettingsModel</span> <span class="n">next</span> <span class="o">=</span> <span class="n">mutate</span><span class="p">(</span><span class="n">value</span><span class="p">);</span>  <span class="c1">// ← 用名字 mutate「呼叫」傳進來的函式
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="p">}</span></span></span></code></pre></div><p>容易混淆的是型別裡面那個 <code>current</code>：它和 <code>mutate</code> 不同層級 — <code>current</code> 只是函式型別內標記參數的名字，<strong>純文件性</strong>，寫成 <code>SettingsModel Function(SettingsModel)</code> 行為完全一樣，只是讓型別讀起來更清楚。換句話說，前半的函式型別規定「這個名字必須是什麼形狀的函式」，最後的 <code>mutate</code> 則是「這個函式參數叫什麼」。下一節先補 HOF 的基礎，第 4 節再回頭談「前半那串型別裸寫在簽章」造成的閱讀摩擦。</p>
<hr>
<h2 id="2-higher-order-function-是什麼最小定義">2. Higher-order function 是什麼（最小定義）</h2>
<p><strong>把函式當資料處理的函式</strong> — 接收函式當參數，或回傳函式，符合其一即是。前提是語言把函式視為一等公民（first-class），能像變數一樣傳遞。Dart、JS、Kotlin、Swift 皆成立。</p>
<p>常見的 <code>list.map((x) =&gt; x*2)</code>、<code>list.where((x) =&gt; x&gt;0)</code>、<code>onPressed: () =&gt; ...</code> 都屬此類。<code>update((s) =&gt; ...)</code> 是同一家族。</p>
<hr>
<h2 id="3-為什麼這個場景適合用-hof">3. 為什麼這個場景適合用 HOF</h2>
<p>這個場景有三個特徵，剛好對上 HOF 的強項 — HOF 適不適用，由這些特徵決定。</p>
<h3 id="31-流程固定變化點單一">3.1 流程固定、變化點單一</h3>
<p>9 個欄位的更新，<strong>流程 100% 相同</strong>（取值、去重、賦回、通知），<strong>唯一差異</strong>是中間那一步「<code>copyWith(哪個欄位: 值)</code>」。</p>
<p>當「共用流程」與「變化點」能這樣切乾淨時，HOF 正好對上這個結構：把固定流程寫死在 <code>update</code> 裡，把變化點抽成函式參數 <code>transform</code> 由呼叫端帶入。<code>map</code> 對「走訪迴圈（固定）+ 元素變換（變化）」做的是同一件事。</p>
<h3 id="32-模型不可變本來就是current--next">3.2 模型不可變，本來就是「current → next」</h3>
<p><code>SettingsModel</code> 是不可變物件（<code>@immutable</code> + 全 <code>final</code>）：要改 <code>fillColor</code>，得用 <code>copyWith</code> 產生新副本、再把整個物件替換回去。</p>
<p>也就是說，不可變模型下的更新，在語意上<strong>就是一個 <code>(current) =&gt; next</code> 的函式</strong> — 拿舊值算出新值。用函式參數表達這件事，是最貼合的形狀。</p>
<h3 id="33-變化點開放難以列舉">3.3 變化點開放、難以列舉</h3>
<p>「未來會改哪些欄位、怎麼組合」是開放的（可能同時改兩個欄位、可能有條件邏輯）。函式參數能表達任意轉換；若改用「enum 指定欄位 + switch」則被固定的列舉鎖死，每加一種改法都要動 <code>update</code> 內部。HOF 把「怎麼改」的決定權留在呼叫端，<code>update</code> 不需要知道。</p>
<p>反過來說，當「變化集合是封閉的、而且需要被序列化或跨層比對」時，enum + switch 反而較好 — 例如要把「使用者改了哪個欄位」存進 undo 堆疊、或透過網路傳給後端，列舉值是可序列化的資料，閉包不是。本案例的變化點純粹發生在呼叫端、不需要 persist，HOF 才站得住。所以「開放」算不算優點，要跟「變化是否需要被當資料搬運」一起看。</p>
<blockquote>
<p>判準：<strong>流程固定 + 變化點單一 + 變化開放</strong> 三者同時成立時，HOF 幾乎總是比「列舉 + 分支」或「複製多個方法」更省。</p></blockquote>
<p>對照反例放進具體場景更清楚。假設一個只有「深色模式開關」單一布林設定的 controller，更新邏輯就是 <code>value = !value</code>，既沒有共用流程、也沒有開放的變化點 — 這時把它包成收函式的 <code>update</code>，只是逼讀者解析一串函式型別去做一件 <code>toggleDarkMode()</code> 就講完的事，抽象成本大於收益。另一種反向情境是：9 個欄位看似共用流程，實際每個的更新路徑各不相同（有的要打 API、有的要寫檔、有的純記憶體），那麼「固定流程」的前提根本不成立，硬抽進 <code>update</code> 反而把三條不同的路徑塞進同一個殼裡。三條件少一條，具名方法通常更省 — 場景不對時硬用，才是過度設計。</p>
<hr>
<h2 id="4-原始寫法的優缺點裸函式型別">4. 原始寫法的優缺點（裸函式型別）</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="kt">void</span> <span class="n">update</span><span class="p">(</span><span class="n">SettingsModel</span> <span class="n">Function</span><span class="p">(</span><span class="n">SettingsModel</span> <span class="n">current</span><span class="p">)</span> <span class="n">mutate</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">final</span> <span class="n">SettingsModel</span> <span class="n">next</span> <span class="o">=</span> <span class="n">mutate</span><span class="p">(</span><span class="n">value</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="n">next</span> <span class="o">!=</span> <span class="n">value</span><span class="p">)</span> <span class="n">value</span> <span class="o">=</span> <span class="n">next</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h3 id="什麼是函式型別裸寫在簽章">什麼是「函式型別裸寫在簽章」</h3>
<p>這是整個討論的起點，值得單獨講清楚。把術語拆三個詞：</p>
<ul>
<li><strong>函式型別</strong>：描述「一個函式長什麼樣」的型別，例如 <code>SettingsModel Function(SettingsModel current)</code> — 收一個 <code>SettingsModel</code>、回傳一個 <code>SettingsModel</code>。</li>
<li><strong>裸寫</strong>：把完整型別<strong>整串攤開寫出來</strong>，沒有先取名包裝（對比「裸數字 / magic number」直接寫 <code>120</code> 而非具名常數）。</li>
<li><strong>在簽章</strong>：寫在方法的參數列（signature）裡。</li>
</ul>
<p>合起來就是：<strong>把那串 <code>SettingsModel Function(SettingsModel current)</code> 原封不動塞進參數位，而不是先用 <code>typedef</code> 取個名字再引用。</strong></p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 裸寫：函式型別整串長在簽章裡
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kt">void</span> <span class="n">update</span><span class="p">(</span><span class="n">SettingsModel</span> <span class="n">Function</span><span class="p">(</span><span class="n">SettingsModel</span> <span class="n">current</span><span class="p">)</span> <span class="n">mutate</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="o">//</span>          <span class="err">└──────────</span> <span class="err">這一整串就是「裸寫的函式型別」──────────┘</span></span></span></code></pre></div><p>為什麼偏偏是「函式型別」會因為裸寫而卡住，一般型別卻不會？因為 <code>int</code>、<code>Color</code> 這類型別已經是短名稱，裸寫毫無負擔；而函式型別的完整語法 <code>X Function(Y)</code> 較長、巢狀時更難讀，<strong>讀者得當場在腦中解析「這是收什麼、回什麼的函式」</strong>。讀程式碼第一眼卡住的，正是這串裸寫的函式型別 — 它才是這篇要討論「要不要抽 typedef」的真正觸發點。下面的優缺點，都圍繞「裸寫 vs 取名」這個軸展開。</p>
<h3 id="優點">優點</h3>
<ul>
<li><strong>型別就地可見</strong>：函式的形狀（收什麼、回什麼）直接寫在簽章上，讀者不必跳到別處查定義。</li>
<li><strong>零額外宣告</strong>：不需要為了一個參數多定義一個型別別名。</li>
</ul>
<h3 id="缺點">缺點</h3>
<ul>
<li><strong>簽章冗長、語法門檻</strong>：<code>SettingsModel Function(SettingsModel current)</code> 對不熟函式型別語法的人構成解析負擔，一眼難消化。</li>
<li><strong>命名與語境矛盾</strong>：參數叫 <code>mutate</code>（變異／就地修改），但模型不可變、實際是「產生新副本」，名稱會誤導。</li>
<li><strong>缺使用錨點</strong>：簽章沒有範例，第一次用的人不知道該傳什麼形狀的 lambda。</li>
</ul>
<hr>
<h2 id="5-typedef-改寫後的優缺點">5. typedef 改寫後的優缺點</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">/// 設定轉換規則：收當前設定、回傳改好的新設定（通常以 copyWith 實作）。
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kd">typedef</span> <span class="n">SettingsMutator</span> <span class="o">=</span> <span class="n">SettingsModel</span> <span class="n">Function</span><span class="p">(</span><span class="n">SettingsModel</span> <span class="n">current</span><span class="p">);</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">/// 套用一條「目前設定 → 新設定」的轉換規則 …
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">/// 範例：`controller.update((s) =&gt; s.copyWith(fillColor: c));`
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="kt">void</span> <span class="n">update</span><span class="p">(</span><span class="n">SettingsMutator</span> <span class="n">transform</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="kd">final</span> <span class="n">SettingsModel</span> <span class="n">next</span> <span class="o">=</span> <span class="n">transform</span><span class="p">(</span><span class="n">value</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="n">next</span> <span class="o">!=</span> <span class="n">value</span><span class="p">)</span> <span class="n">value</span> <span class="o">=</span> <span class="n">next</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h3 id="優點-1">優點</h3>
<ul>
<li><strong>簽章簡潔、概念命名</strong>：<code>SettingsMutator</code> 把函式型別升格成領域詞彙，認知從「解析 <code>X Function(Y)</code>」降到「讀一個名詞」。</li>
<li><strong>命名精準</strong>：<code>transform</code>（轉換）貼合不可變語境，不再暗示就地修改。</li>
<li><strong>有錨點</strong>：doc comment 的範例讓第一次使用者立即知道怎麼傳。</li>
<li><strong>錯誤訊息更易讀</strong>：型別對不上時，編譯器印的是 <code>SettingsMutator</code> 這個名字，而不是整串 <code>SettingsModel Function(SettingsModel)</code>；裸寫版的錯誤訊息會把完整型別攤開，較難一眼定位。</li>
<li><strong>可重用</strong>：同一個 <code>SettingsMutator</code> 型別若日後被多個 API 共用，定義集中一處。</li>
</ul>
<h3 id="缺點-1">缺點</h3>
<ul>
<li><strong>多一層 indirection</strong>：想知道 <code>transform</code> 的確切型別，得跳到 <code>typedef</code> 定義；只看 <code>update</code> 簽章看不到形狀。</li>
<li><strong>多一個命名負擔</strong>：<code>SettingsMutator</code> 本身要取得好；命名不當反而多一層要理解的東西。</li>
<li><strong>對單一用途略顯重</strong>：若這個函式型別只在一處使用，typedef 的「集中重用」優點用不上，只剩「命名」一項收益。</li>
</ul>
<hr>
<h2 id="6-並排比較">6. 並排比較</h2>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>原始（裸函式型別）</th>
          <th>typedef 改寫後</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>簽章可讀性</td>
          <td>冗長、需解析語法</td>
          <td>簡潔、讀一個名詞</td>
      </tr>
      <tr>
          <td>型別形狀可見性</td>
          <td>就地可見（優）</td>
          <td>需跳到 typedef 定義（劣）</td>
      </tr>
      <tr>
          <td>命名語意</td>
          <td><code>mutate</code> 與不可變矛盾</td>
          <td><code>transform</code> 貼合</td>
      </tr>
      <tr>
          <td>使用門檻</td>
          <td>無範例</td>
          <td>有範例錨點</td>
      </tr>
      <tr>
          <td>額外宣告成本</td>
          <td>無</td>
          <td>多一個 typedef 要命名/維護</td>
      </tr>
      <tr>
          <td>多處共用時</td>
          <td>各自裸寫、重複</td>
          <td>集中定義、重用</td>
      </tr>
      <tr>
          <td>pattern / 行為</td>
          <td>HOF</td>
          <td>HOF（不變）</td>
      </tr>
  </tbody>
</table>
<p>關鍵：<strong>兩者是同一個 pattern（HOF + ValueNotifier）的兩種表達</strong>。取捨重點在「型別就地可見」對上「簽章簡潔 + 概念命名」—— 當函式型別會被多處使用、或語法門檻造成實際閱讀摩擦時，typedef 划算；若只用一次且團隊熟悉函式型別語法，裸寫也完全合理。</p>
<p>改寫的驗證也印證它停在「表達層」：呼叫端傳 lambda 不依賴參數名 → 零修改；行為不變；全套測試原封不動通過。</p>
<hr>
<h2 id="7-收斂">7. 收斂</h2>
<ul>
<li>HOF 適合的場景特徵：<strong>流程固定 + 變化點單一 + 變化開放</strong>。三者齊備時，把變化點抽成函式參數最省；場景不符（欄位少、流程各異）則具名方法更直白。</li>
<li>不可變模型的更新本質就是 <code>(current) =&gt; next</code>，用函式參數表達是語意上最貼合的形狀。</li>
<li>兩種寫法的取捨：裸函式型別型別就地可見、零宣告；typedef 簽章簡潔、命名成概念、可重用，但多一層 indirection。</li>
<li>選擇依據：函式型別是否多處共用、語法是否造成實際閱讀摩擦。摩擦明顯就抽 typedef，否則裸寫無妨。</li>
</ul>
<blockquote>
<p><strong>延伸</strong>：本文「模型不可變」段是整個 HOF 適配的前提之一。<code>SettingsModel</code> 那種 <code>@immutable</code> + <code>copyWith</code> 結構怎麼產生、以及更好懂的替代路徑，見 <a href="/blog/work-log/freezed-%E7%9A%84%E4%B8%89%E5%B1%A4%E7%B5%90%E6%A7%8B%E8%A7%A3%E5%89%96with_%E4%BB%A5%E5%8F%8A%E6%9B%B4%E5%A5%BD%E6%87%82%E7%9A%84%E6%9B%BF%E4%BB%A3%E8%B7%AF%E5%BE%91/" data-link-title="Freezed 的三層結構解剖：with、_$、以及更好懂的替代路徑" data-link-desc="freezed `class X with _$X implements Y` 的分層結構解剖：`with` 與 `_$` 各自的角色、沒有 freezed 怎麼手做、中間投影物件 vs DTO 直接 implements 的維護取捨。">Freezed 的三層結構解剖</a>。</p></blockquote>
]]></content:encoded></item><item><title>寫測試時 sync try-catch 接不到 BotToast 的 async 錯誤：fire-and-forget API 的接管設計</title><link>https://tarrragon.github.io/blog/work-log/%E5%AF%AB%E6%B8%AC%E8%A9%A6%E6%99%82-sync-try-catch-%E6%8E%A5%E4%B8%8D%E5%88%B0-bottoast-%E7%9A%84-async-%E9%8C%AF%E8%AA%A4fire-and-forget-api-%E7%9A%84%E6%8E%A5%E7%AE%A1%E8%A8%AD%E8%A8%88/</link><pubDate>Tue, 26 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/%E5%AF%AB%E6%B8%AC%E8%A9%A6%E6%99%82-sync-try-catch-%E6%8E%A5%E4%B8%8D%E5%88%B0-bottoast-%E7%9A%84-async-%E9%8C%AF%E8%AA%A4fire-and-forget-api-%E7%9A%84%E6%8E%A5%E7%AE%A1%E8%A8%AD%E8%A8%88/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>核心議題&lt;/strong>：寫測試時觸及 type system 看不到的 runtime contract — service locator 的注入契約、widget tree 的 framework state、async error 的 try-catch 邊界。三類都要 runtime 才會炸、test 跑到才會曝光。
&lt;strong>案例骨幹&lt;/strong>：&lt;code>Popup.hint&lt;/code> 同一條呼叫路徑同時持有 sync 與 async 兩條失敗路徑（缺 service 注入、BotToast 同步 assert、BotToast 從 async gap 後拋 &lt;code>LateInitializationError&lt;/code>）。用 &lt;code>runZonedGuarded&lt;/code> 把兩條路徑收斂到同一個 fallback handler、用 fallback signature 設計讓訊息不被誤判為 error。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="1-type-system-看不到的-runtime-contract">1. Type system 看不到的 runtime contract&lt;/h2>
&lt;p>&lt;code>flutter analyze&lt;/code>（與一般的 type checker）的責任是檢查宣告與名稱層的契約 — 型別一致、import 能解析、識別字能對到符號。它驗證的是「靜態可決定的事」：missing import、undefined method、type mismatch 都會在 compile 前被攔下。&lt;/p>
&lt;p>它&lt;strong>看不到&lt;/strong>的是 runtime 才成立的契約，這正是寫測試最容易暴露的盲區：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Service locator 的注入契約&lt;/strong>：GetX 的 &lt;code>Get.find&amp;lt;T&amp;gt;()&lt;/code>、&lt;code>get_it&lt;/code> 的 &lt;code>GetIt.I&amp;lt;T&amp;gt;()&lt;/code>、Provider 的 &lt;code>Provider.of&amp;lt;T&amp;gt;()&lt;/code> 都是 runtime 查找機制（Map lookup 或 widget tree 上溯，視實作而定）。「呼叫前 T 必須先註冊或在 ancestor 提供」是執行期前置條件，型別系統看不見。&lt;/li>
&lt;li>&lt;strong>Framework state 的存在前提&lt;/strong>：BotToast 需要 widget tree 上有 &lt;code>BotToastInit&lt;/code>、Navigator 需要 &lt;code>MaterialApp&lt;/code> 包著。這是 framework 的執行期狀態，不是型別。&lt;/li>
&lt;li>&lt;strong>&lt;code>late&lt;/code> 變數的跨呼叫順序契約&lt;/strong>：宣告對了不代表用對了。analyzer 對單一檔案內某些 unsafe pattern 能出警告，但「A 函式必須在 B 函式前被呼叫」這類跨呼叫順序契約，型別系統看不見。&lt;/li>
&lt;/ul>
&lt;p>這個邊界對「寫測試」的意涵：test setUp 不只是準備資料，更是補上 type system 看不到的 runtime contract — 注入哪些 service、提供哪些 framework state、控制哪些 init 順序。&lt;strong>主程式裡那些「靠 widget tree」「靠 service locator」「靠 framework lifecycle」的契約，每一條都對應到 test setUp 的一個責任&lt;/strong>。&lt;/p>
&lt;hr>
&lt;h2 id="2-案例一條呼叫路徑觸及三類邊界">2. 案例：一條呼叫路徑觸及三類邊界&lt;/h2>
&lt;p>下面以 &lt;code>Popup.hint&lt;/code> 對 &lt;code>BotToast.showNotification&lt;/code> 的呼叫為例。寫一個跑 &lt;code>AuthService.afterLogin&lt;/code> 的 unit test 時，這條呼叫一次觸及 runtime contract 段列的三類邊界：service locator 注入缺失、widget tree 缺 &lt;code>BotToastInit&lt;/code>、&lt;code>late&lt;/code> 變數在 async 排程後讀取。三組訊號攤開：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>訊號&lt;/th>
 &lt;th>性質&lt;/th>
 &lt;th>sync try-catch 能接？&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>&amp;quot;LogService&amp;quot; not found.&lt;/code> 從 &lt;code>Get.find&amp;lt;LogService&amp;gt;()&lt;/code> 拋出&lt;/td>
 &lt;td>同步（service locator 查無注入）&lt;/td>
 &lt;td>能，但這層該補 setUp 而非包 try&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>Failed assertion: '_key.currentState != null'&lt;/code> 在 &lt;code>BotToast.showNotification&lt;/code> 入口&lt;/td>
 &lt;td>同步（widget tree 缺 &lt;code>BotToastInit&lt;/code> 入口 assert）&lt;/td>
 &lt;td>能&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>LateInitializationError: Local 'cancelFunc' has not been initialized.&lt;/code> 出現在 &lt;code>===== asynchronous gap =====&lt;/code> 之後&lt;/td>
 &lt;td>async + 跨呼叫順序契約破裂（&lt;code>late cancelFunc&lt;/code> 預期在某次 init 之後才讀、但 BotToast 排到下一 frame 時順序對不上）&lt;/td>
 &lt;td>不能&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>第一條的修法是 setUp 補注入。第二條的同步 assert 單獨看，sync try-catch 接得住。但它跟第三條 async error 是&lt;strong>同一個 API 的兩種失敗模式&lt;/strong> — 包 sync try-catch 只罩到同步那條、async 那條仍漏。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>核心議題</strong>：寫測試時觸及 type system 看不到的 runtime contract — service locator 的注入契約、widget tree 的 framework state、async error 的 try-catch 邊界。三類都要 runtime 才會炸、test 跑到才會曝光。
<strong>案例骨幹</strong>：<code>Popup.hint</code> 同一條呼叫路徑同時持有 sync 與 async 兩條失敗路徑（缺 service 注入、BotToast 同步 assert、BotToast 從 async gap 後拋 <code>LateInitializationError</code>）。用 <code>runZonedGuarded</code> 把兩條路徑收斂到同一個 fallback handler、用 fallback signature 設計讓訊息不被誤判為 error。</p></blockquote>
<hr>
<h2 id="1-type-system-看不到的-runtime-contract">1. Type system 看不到的 runtime contract</h2>
<p><code>flutter analyze</code>（與一般的 type checker）的責任是檢查宣告與名稱層的契約 — 型別一致、import 能解析、識別字能對到符號。它驗證的是「靜態可決定的事」：missing import、undefined method、type mismatch 都會在 compile 前被攔下。</p>
<p>它<strong>看不到</strong>的是 runtime 才成立的契約，這正是寫測試最容易暴露的盲區：</p>
<ul>
<li><strong>Service locator 的注入契約</strong>：GetX 的 <code>Get.find&lt;T&gt;()</code>、<code>get_it</code> 的 <code>GetIt.I&lt;T&gt;()</code>、Provider 的 <code>Provider.of&lt;T&gt;()</code> 都是 runtime 查找機制（Map lookup 或 widget tree 上溯，視實作而定）。「呼叫前 T 必須先註冊或在 ancestor 提供」是執行期前置條件，型別系統看不見。</li>
<li><strong>Framework state 的存在前提</strong>：BotToast 需要 widget tree 上有 <code>BotToastInit</code>、Navigator 需要 <code>MaterialApp</code> 包著。這是 framework 的執行期狀態，不是型別。</li>
<li><strong><code>late</code> 變數的跨呼叫順序契約</strong>：宣告對了不代表用對了。analyzer 對單一檔案內某些 unsafe pattern 能出警告，但「A 函式必須在 B 函式前被呼叫」這類跨呼叫順序契約，型別系統看不見。</li>
</ul>
<p>這個邊界對「寫測試」的意涵：test setUp 不只是準備資料，更是補上 type system 看不到的 runtime contract — 注入哪些 service、提供哪些 framework state、控制哪些 init 順序。<strong>主程式裡那些「靠 widget tree」「靠 service locator」「靠 framework lifecycle」的契約，每一條都對應到 test setUp 的一個責任</strong>。</p>
<hr>
<h2 id="2-案例一條呼叫路徑觸及三類邊界">2. 案例：一條呼叫路徑觸及三類邊界</h2>
<p>下面以 <code>Popup.hint</code> 對 <code>BotToast.showNotification</code> 的呼叫為例。寫一個跑 <code>AuthService.afterLogin</code> 的 unit test 時，這條呼叫一次觸及 runtime contract 段列的三類邊界：service locator 注入缺失、widget tree 缺 <code>BotToastInit</code>、<code>late</code> 變數在 async 排程後讀取。三組訊號攤開：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>性質</th>
          <th>sync try-catch 能接？</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>&quot;LogService&quot; not found.</code> 從 <code>Get.find&lt;LogService&gt;()</code> 拋出</td>
          <td>同步（service locator 查無注入）</td>
          <td>能，但這層該補 setUp 而非包 try</td>
      </tr>
      <tr>
          <td><code>Failed assertion: '_key.currentState != null'</code> 在 <code>BotToast.showNotification</code> 入口</td>
          <td>同步（widget tree 缺 <code>BotToastInit</code> 入口 assert）</td>
          <td>能</td>
      </tr>
      <tr>
          <td><code>LateInitializationError: Local 'cancelFunc' has not been initialized.</code> 出現在 <code>===== asynchronous gap =====</code> 之後</td>
          <td>async + 跨呼叫順序契約破裂（<code>late cancelFunc</code> 預期在某次 init 之後才讀、但 BotToast 排到下一 frame 時順序對不上）</td>
          <td>不能</td>
      </tr>
  </tbody>
</table>
<p>第一條的修法是 setUp 補注入。第二條的同步 assert 單獨看，sync try-catch 接得住。但它跟第三條 async error 是<strong>同一個 API 的兩種失敗模式</strong> — 包 sync try-catch 只罩到同步那條、async 那條仍漏。</p>
<p>結論：要兩條都接到，需要一個同時 cover sync 與 async 的接管機制。</p>
<hr>
<h2 id="3-sync-try-catch-與-async-error-的邊界">3. Sync try-catch 與 async error 的邊界</h2>
<p>Sync <code>try-catch</code> 的作用範圍是同步調用棧：try block 執行期間棧上拋的錯誤會被接住。一旦執行流程穿越 async 邊界（Future、Timer、microtask 排程），原 try-catch 已經出 scope：</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">Popup.hint() {
</span></span><span class="line"><span class="ln">2</span><span class="cl">  try {
</span></span><span class="line"><span class="ln">3</span><span class="cl">    BotToast.showNotification(...)   ← 同步返回，立刻離開 try
</span></span><span class="line"><span class="ln">4</span><span class="cl">      └─ 內部排到下一個 frame 或 microtask {  ← 之後才跑
</span></span><span class="line"><span class="ln">5</span><span class="cl">           ...拋 LateInitializationError...   ← try-catch 已經出 scope
</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">  } catch (e) { ... }
</span></span><span class="line"><span class="ln">8</span><span class="cl">}</span></span></code></pre></div><p>辨識 async unhandled error 的訊號是 stack trace 裡有 <code>===== asynchronous gap =====</code> — 它代表錯誤穿越了一個 async 邊界。從 caller frame 來看「沒人在 stack 上」，錯誤會上溯到 zone 的 uncaught error handler；root zone 把它印到 stderr，或讓 flutter_test runner 當作 test failure。</p>
<p><code>async</code> 函式內的 try-catch 是常見混淆點：寫成 <code>try { await x; } catch (e)</code> 時，try-catch <strong>能</strong>接住 <code>await</code> 的 future rejection（<code>await</code> 把 async error rewire 成 sync throw）。但對沒 await 的 fire-and-forget 排程（直接呼叫一個會內部 schedule microtask 的 API），try-catch 的覆蓋範圍止於同步路徑。</p>
<h3 id="風險fire-and-forget-api-的-error-路徑跨-async-邊界">風險：fire-and-forget API 的 error 路徑跨 async 邊界</h3>
<p>BotToast、analytics、Toast、SnackBar 這類 API 通常<strong>同步返回</strong>（讓 caller 不必 await），內部排到下一個 frame 或 microtask 做 UI 工作。caller 看到的是同步呼叫，但錯誤可能從 async 邊界後跑出來。caller 端的 sync try-catch 看起來罩住了，實際接不到。</p>
<hr>
<h2 id="4-接管機制runzonedguarded-同時罩-sync-與-async">4. 接管機制：runZonedGuarded 同時罩 sync 與 async</h2>
<p>接 async unhandled error 要用 zone-aware 機制。<code>runZonedGuarded(body, onError)</code> 建立一個子 zone，<strong>任何在這個 zone 內 schedule 的 async work，錯誤都會冒泡到 <code>onError</code></strong> — 不管錯誤穿越幾層 microtask、Timer、Stream。它同時也 cover 同步拋錯，可以取代 try-catch 包住整個 best-effort 邊界：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// toast 是 best-effort：BotToast 需要 widget tree (BotToastInit)，
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1">// 在非 UI 環境（unit test、isolate）顯示失敗時保留 log、不向 caller 傳遞錯誤。
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1">// 用 runZonedGuarded 因為 BotToast 部分錯誤從 async gap 後拋出，sync try-catch 接不到。
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span><span class="n">runZonedGuarded</span><span class="p">(()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="n">BotToast</span><span class="p">.</span><span class="n">showNotification</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nl">title:</span> <span class="p">(</span><span class="n">_</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">Text</span><span class="p">(</span><span class="n">message</span><span class="p">,</span> <span class="nl">style:</span> <span class="n">AppTheme</span><span class="p">.</span><span class="n">whiteTextButtonStyle</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nl">backgroundColor:</span> <span class="n">contentColor</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nl">duration:</span> <span class="kd">const</span> <span class="n">Duration</span><span class="p">(</span><span class="nl">seconds:</span> <span class="m">2</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nl">animationDuration:</span> <span class="kd">const</span> <span class="n">Duration</span><span class="p">(</span><span class="nl">milliseconds:</span> <span class="m">300</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nl">animationReverseDuration:</span> <span class="kd">const</span> <span class="n">Duration</span><span class="p">(</span><span class="nl">milliseconds:</span> <span class="m">300</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="p">);</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">},</span> <span class="p">(</span><span class="n">error</span><span class="p">,</span> <span class="n">stack</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="n">kDebugMode</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="n">debugPrint</span><span class="p">(</span><span class="s1">&#39;[Popup.hint][fallback] BotToast 不可用，僅記 log：</span><span class="si">$</span><span class="n">error</span><span class="s1">&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>機制重點：同一個 <code>onError</code> 同時接住同步的 <code>Failed assertion</code> 與 async 的 <code>LateInitializationError</code> — sync 與 async 兩條失敗路徑收斂到單一 fallback handler，不需要為兩條各寫一套錯誤處理。</p>
<hr>
<h2 id="5-runzonedguarded-的責任邊界">5. runZonedGuarded 的責任邊界</h2>
<p><code>runZonedGuarded</code> 把整個邊界的錯誤導向 fallback handler，責任範圍要劃清楚：</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>行為</th>
          <th>設計意涵</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>async work 自己處理掉錯誤（try-catch 或 <code>.catchError</code>）</td>
          <td>接不到</td>
          <td>zone 看不到已被吞的錯誤；要 zone 接，內層別自吞</td>
      </tr>
      <tr>
          <td><code>onError</code> handler 自己拋錯</td>
          <td>上溯到 parent zone</td>
          <td>handler 要簡短可靠；fallback 自己掛是上層責任</td>
      </tr>
      <tr>
          <td>同步拋錯</td>
          <td>也會被接住</td>
          <td>zone 同時 cover sync 與 async，可取代 try-catch</td>
      </tr>
      <tr>
          <td>zone 內建立的 Timer / Stream</td>
          <td>屬於這個 zone</td>
          <td>spawn 出的 async 物件「記得」自己屬於哪個 zone</td>
      </tr>
  </tbody>
</table>
<p><strong>zone ≠ thread</strong>。Dart 是單線程的，zone 只是邏輯標籤、不涉及並發。它<strong>只改變錯誤的去向、不會 cancel 已 schedule 的 work</strong>。</p>
<h3 id="注意事項何時不該用">注意事項：何時不該用</h3>
<p>Zone 歸屬以 schedule 時的 zone 為準、不是執行時 — async 物件「屬於」schedule 它的那個 zone。這個規則讓跨 zone 操作 Timer、Stream 的行為偏離直覺。實務上最常見的觸發場景是 <code>WidgetsFlutterBinding.ensureInitialized()</code> 在 root zone 註冊了 framework binding 後、才用 <code>runZonedGuarded</code> 包 <code>runApp</code>，binding 內部 callback 已綁在 root zone、外層 zone 接不到。<a href="https://docs.flutter.dev/release/breaking-changes/zone-errors">Flutter 官方明確建議</a> <code>ensureInitialized()</code> 跟 <code>runApp()</code> 都在同一個 <code>runZonedGuarded</code> 內。</p>
<p>zone 適合包「整個邊界」：整個 isolate entry、整個 best-effort UI 工作、整個 background task。<strong>不適合包關鍵 transaction logic</strong> — 那是 try-catch + Future error handling 的責任，zone 是 fallback 收斂層、不是主要錯誤處理。</p>
<hr>
<h2 id="6-fallback-訊息設計可識別的-signature">6. Fallback 訊息設計：可識別的 signature</h2>
<p>Fallback path 跑通之後，留在 console 的訊息會被讀到很多次（每次 test 都會跑）。<strong>訊息措辭要與設計意圖一致</strong>，否則讀者每次都要花心力辨識「這是設計內降級、還是真的 bug」。</p>
<h3 id="風險fallback-長得像-error">風險：fallback 長得像 error</h3>
<p>直覺寫法 <code>debugPrint('toast 顯示失敗：$error')</code> 加上 framework 的 assert stack，字面看起來就是個 error。讀者第一眼會緊張、要花心力比對程式才能確認「這是設計內路徑」。test 跑很多次、每次都付一次辨識成本。</p>
<h3 id="三條設計原則">三條設計原則</h3>
<p><strong>Fallback path 要有可識別的 signature</strong>（標籤、prefix、特定字眼）、長得不像 error。對人類讀者，prefix 是視覺上一眼識別「設計內路徑」；對工具，<code>grep -v &quot;\[fallback\]&quot;</code> 可快速剔除 test 輸出裡的預期降級訊息。</p>
<p><strong>字眼要表達因果與處置</strong>：「BotToast 不可用，僅記 log」比「顯示失敗」更完整 — 前者說了為什麼降級、後者只描述現象。寫 fallback 訊息要回答兩個問題：為什麼進這條路徑、降級到哪。</p>
<p><strong>主程式不該感知測試框架</strong>：主程式 import <code>dart:io</code>、查 <code>Platform.environment['FLUTTER_TEST']</code> 等於「主程式對自己被 test 跑」有意識 — 這違反「主程式不該知道 test 存在」的原則，test 框架是 caller 的事、不是 callee 的事。違反後續成本：app 行為依賴環境變數時，QA / staging / production 的環境一致性會多一條檢查線。</p>
<h3 id="三個候選方案在原則上的取捨">三個候選方案在原則上的取捨</h3>
<p>下列三個方案分別在「signature 識別度」「主程式對 test 框架感知」「dev 可見性」三條原則上做不同取捨：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>A. 標籤化（<code>[fallback]</code> prefix）</th>
          <th>B. 偵測 <code>FLUTTER_TEST</code> 環境 silent</th>
          <th>C. 完全靜默</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>改動大小</td>
          <td>+1 行</td>
          <td>~10 行 + 新 import</td>
          <td>−1 行</td>
      </tr>
      <tr>
          <td>test 輸出乾淨度</td>
          <td>仍有訊息，但 prefix 一眼識別</td>
          <td>完全乾淨</td>
          <td>完全乾淨</td>
      </tr>
      <tr>
          <td>dev app 跑時可見性</td>
          <td>保留</td>
          <td>保留</td>
          <td>失去</td>
      </tr>
      <tr>
          <td>主程式對 test 框架的感知</td>
          <td>無</td>
          <td>有（import dart:io 查 env）</td>
          <td>無</td>
      </tr>
      <tr>
          <td>grep 友善度</td>
          <td>好（<code>[fallback]</code> prefix）</td>
          <td>—</td>
          <td>—</td>
      </tr>
      <tr>
          <td>BotToast 真壞時 debug 難度</td>
          <td>容易（訊號 + 標籤）</td>
          <td>中（test 看不到、要切環境）</td>
          <td>難（無線索）</td>
      </tr>
  </tbody>
</table>
<h3 id="為什麼選-a">為什麼選 A</h3>
<p>保留 dev 訊號（BotToast 在 dev app 真的壞時 console 仍會印） + 主程式對 test 框架無感知 + prefix 雙贏（人類視覺辨識 + grep 過濾）。方案 C 完全靜默會失去保險、dev 環境真壞時看不見；方案 B 雖然 test 輸出乾淨，代價是違反設計原則。</p>
<hr>
<h2 id="7-設計副產物修主程式對缺依賴的容錯">7. 設計副產物：修主程式對缺依賴的容錯</h2>
<p><code>Popup.hint</code> 對「沒有 widget tree」連環倒，這個失敗不只 unit test 會遇到 — isolate 內、background task 內、任何非 UI 環境都會炸。修 test 順手把主程式對缺依賴的容錯加上，是合理副產物：unit test 是觸發訊號、主程式被觸發後變得更能適應多元 caller 環境，這個改動的受益面大於原本 test 暴露的那個情境。</p>
<p><strong>主程式變 robust 的價值大於「讓 test 過」</strong>。修主程式對 caller 環境的容錯時要分辨「容錯」與「掩蓋」的界線：log 仍要留、fallback signature 仍要可識別（Fallback 訊息設計段），錯誤完全靜默會讓 dev app 真壞掉時也看不見。</p>
<hr>
<h2 id="適用範圍">適用範圍</h2>
<p><code>runZonedGuarded</code> 適用情境：</p>
<ul>
<li><strong>Fire-and-forget 的 UI 通知</strong>：Toast、SnackBar、analytics 上報；這些是 best-effort，caller 連環倒不合理。</li>
<li><strong>Isolate entry point</strong>：spawn 出來的 isolate 沒有預設 error handler，包一層 zone 才不會靜默掛掉。</li>
<li><strong>Background task / Timer 包裝</strong>：long-running periodic job 內部錯誤不該炸掉整個 process。</li>
<li><strong>flutter_test 內掛 Stream / Future 驗證</strong>：把測試體包進 zone 才能完整接 async 拋出的東西。</li>
</ul>
<p>「Type system 看不到的 runtime contract」適用任何用 service locator / DI 容器、framework state、late init 的 Flutter 專案。Test 是這些 runtime contract 的事實驗證者 — analyze 過了不代表這些契約沒破，test 跑到才會炸。</p>
<hr>
<h2 id="參考資料">參考資料</h2>
<ul>
<li><a href="https://api.dart.dev/stable/dart-async/runZonedGuarded.html">Dart <code>runZonedGuarded</code> API</a></li>
<li><a href="https://dart.dev/articles/archive/zones">Dart Zone 概念與 zone-local variables</a></li>
<li><a href="https://docs.flutter.dev/release/breaking-changes/zone-errors">Flutter Zone mismatch breaking change</a> — <code>ensureInitialized()</code> 與 <code>runApp()</code> 必須同 zone</li>
<li><a href="https://api.flutter.dev/flutter/flutter_test/FlutterTest-library.html"><code>flutter_test</code> async error 處理機制</a></li>
<li>同主題本站文章：<a href="../dart_test_getx_cross_file_state_pollution/">Dart test 的跨檔案 GetX 狀態污染</a> — 另一種「test 環境組裝不完整」的 case</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>Freezed 的三層結構解剖：with、_$、以及更好懂的替代路徑</title><link>https://tarrragon.github.io/blog/work-log/freezed-%E7%9A%84%E4%B8%89%E5%B1%A4%E7%B5%90%E6%A7%8B%E8%A7%A3%E5%89%96with_%E4%BB%A5%E5%8F%8A%E6%9B%B4%E5%A5%BD%E6%87%82%E7%9A%84%E6%9B%BF%E4%BB%A3%E8%B7%AF%E5%BE%91/</link><pubDate>Mon, 11 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/freezed-%E7%9A%84%E4%B8%89%E5%B1%A4%E7%B5%90%E6%A7%8B%E8%A7%A3%E5%89%96with_%E4%BB%A5%E5%8F%8A%E6%9B%B4%E5%A5%BD%E6%87%82%E7%9A%84%E6%9B%BF%E4%BB%A3%E8%B7%AF%E5%BE%91/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：實作營運端報表 API、寫了一個 freezed model
&lt;strong>疑問來源&lt;/strong>：&lt;code>abstract class PeriodReportRow with _$PeriodReportRow implements ReportAmountsView&lt;/code> 這一行包含太多陌生語法
&lt;strong>整理目的&lt;/strong>：把「為什麼長這樣」與「是否有更好懂做法」的脈絡記錄下來、避免下次又從零開始查
&lt;strong>本文邊界&lt;/strong>：這是一篇 work-log，目標是回溯一次具體實作中的理解成本；它不取代 freezed 官方文件，也不把某個專案的模型分層當成通用規則。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="事件起點">事件起點&lt;/h2>
&lt;p>今天在某個營運端 Flutter 專案新增週期彙總報表 API，這份報表和既有的單次作業報表共用呈現邏輯、各自有獨立的 DTO。為了讓兩個 DTO 共用 sections builder、抽了一個 &lt;code>ReportAmountsView&lt;/code> 介面、讓兩邊的 &lt;code>*Row&lt;/code> 都 &lt;code>implements&lt;/code> 它。&lt;/p>
&lt;p>寫完後盯著這行程式碼看了一下：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="err">@&lt;/span>&lt;span class="n">freezed&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="kd">abstract&lt;/span> &lt;span class="kd">class&lt;/span> &lt;span class="nc">PeriodReportRow&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="kd">with&lt;/span> &lt;span class="n">_$PeriodReportRow&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="kd">implements&lt;/span> &lt;span class="n">ReportAmountsView&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="kd">const&lt;/span> &lt;span class="kd">factory&lt;/span> &lt;span class="n">PeriodReportRow&lt;/span>&lt;span class="p">({&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="kd">required&lt;/span> &lt;span class="kt">String&lt;/span> &lt;span class="n">date&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="c1">// ... 18 個欄位
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="p">})&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">_PeriodReportRow&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>短短四行裡塞了好幾個需要分層理解的語法：&lt;code>abstract&lt;/code> 為什麼能配 &lt;code>factory&lt;/code>、&lt;code>with _$PeriodReportRow&lt;/code> 在做什麼、&lt;code>_$&lt;/code> 這個前綴代表什麼、&lt;code>= _PeriodReportRow&lt;/code> 如何接到生成類，以及為什麼要分成「我寫的 abstract」+「生成的 mixin」+「生成的具體類」三層。&lt;/p>
&lt;p>這篇筆記把那次停下來查證的路徑整理成可重讀的判斷脈絡。&lt;/p>
&lt;hr>
&lt;h2 id="第一層with-是什麼">第一層：&lt;code>with&lt;/code> 是什麼&lt;/h2>
&lt;p>&lt;code>with&lt;/code> 是 Dart 的 &lt;strong>mixin 語法&lt;/strong>、把另一個型別的成員「混入」當前 class。當前 class 會接上 mixin 提供的成員；如果 mixin 宣告了抽象成員，最後的具體類仍要提供實作。&lt;/p>
&lt;h3 id="三個關鍵字的差異">三個關鍵字的差異&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">abstract&lt;/span> &lt;span class="kd">class&lt;/span> &lt;span class="nc">PeriodReportRow&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kd">with&lt;/span> &lt;span class="n">_$PeriodReportRow&lt;/span> &lt;span class="c1">// ← mixin：接上生成 API surface
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kd">implements&lt;/span> &lt;span class="n">ReportAmountsView&lt;/span> &lt;span class="o">//&lt;/span> &lt;span class="err">←&lt;/span> &lt;span class="n">interface&lt;/span>&lt;span class="err">：拿到契約&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&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>&lt;code>extends&lt;/code>&lt;/td>
 &lt;td>繼承父類別（單一）&lt;/td>
 &lt;td>可選擇覆寫&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>implements&lt;/code>&lt;/td>
 &lt;td>只拿型別契約&lt;/td>
 &lt;td>&lt;strong>要自己全部實作&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>with&lt;/code>&lt;/td>
 &lt;td>拿到 mixin 成員，可含實作或要求&lt;/td>
 &lt;td>取決於 mixin 內的成員是否已實作&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;code>extends&lt;/code> 佔據唯一父類別位置，適合真正的 is-a 關係；&lt;code>implements&lt;/code> 只拿契約，適合用型別描述能力；&lt;code>with&lt;/code> 在中間，適合把一組生成或共用的成員接到 class 上。&lt;/p>
&lt;h3 id="在-freezed-中的角色">在 freezed 中的角色&lt;/h3>
&lt;p>&lt;code>_$PeriodReportRow&lt;/code> 是 build_runner 跑完後在 &lt;code>period_report_dto.freezed.dart&lt;/code> 裡產出的 mixin，角色是把 Freezed 生成的 API surface 接到你宣告的 &lt;code>PeriodReportRow&lt;/code> 門面上。&lt;/p>
&lt;ul>
&lt;li>欄位 getter 的契約或 forwarding surface（&lt;code>date&lt;/code>、&lt;code>grossAmount&lt;/code>、&lt;code>channelA&lt;/code> 等）&lt;/li>
&lt;li>&lt;code>==&lt;/code> 和 &lt;code>hashCode&lt;/code> 相關生成邏輯&lt;/li>
&lt;li>&lt;code>copyWith&lt;/code>&lt;/li>
&lt;li>&lt;code>toString&lt;/code>&lt;/li>
&lt;li>JSON 相關的 generated function / method 接線（取決於是否搭配 &lt;code>json_serializable&lt;/code> 與 &lt;code>fromJson&lt;/code> factory）&lt;/li>
&lt;/ul>
&lt;p>所以 &lt;code>abstract class PeriodReportRow with _$PeriodReportRow&lt;/code> 在做的事是：&lt;/p>
&lt;blockquote>
&lt;p>「我這個 class 是抽象門面，Freezed 會把生成 API 放在 &lt;code>_$PeriodReportRow&lt;/code> mixin 與 &lt;code>_PeriodReportRow&lt;/code> 具體類裡；門面透過 &lt;code>with&lt;/code> 接上生成 surface，factory 再回傳真正持有欄位的生成類。」&lt;/p>&lt;/blockquote>
&lt;p>這裡最容易誤解的是「mixin 等於所有實作」。在 Freezed 的常見生成模式裡，mixin 會宣告或提供部分生成成員，真正持有 &lt;code>final&lt;/code> 欄位並滿足 getter 的通常是 factory 指向的 &lt;code>_PeriodReportRow&lt;/code> 具體類。&lt;code>with _$PeriodReportRow&lt;/code> 的價值是讓門面型別擁有一致的生成 API 形狀，而不是把每個欄位的儲存都塞進 mixin。&lt;/p>
&lt;h3 id="為什麼-freezed-用-mixin-而不是-extends">為什麼 freezed 用 mixin 而不是 extends&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>mixin 不佔「父類別」的獨生子位置&lt;/strong>：Dart 只允許單一 &lt;code>extends&lt;/code>、freezed 如果用 extends 強佔了、你就不能讓 model 繼承自己的 base class。&lt;code>with&lt;/code> 可以無限疊加、給你自由度&lt;/li>
&lt;li>&lt;strong>mixin 支援多個疊加&lt;/strong>：&lt;code>class Foo with A, B, C&lt;/code> 會把 A、B、C 的方法依序混入。Freezed 利用這個語法位置，把生成 API 接到使用者宣告的門面類&lt;/li>
&lt;li>&lt;strong>&lt;code>implements ReportAmountsView&lt;/code> 在這裡剛好成立&lt;/strong>：&lt;code>ReportAmountsView&lt;/code> 要求的是一組 getter 契約，而 Freezed 會讓生成的 &lt;code>_PeriodReportRow&lt;/code> 具體類依照 factory 參數產生對應欄位。門面類宣告 &lt;code>implements&lt;/code>，具體類回傳時提供欄位實作，所以不需要再手寫 18 個 forwarding getter&lt;/li>
&lt;/ul>
&lt;h3 id="簡化的等價心智模型">簡化的等價心智模型&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1">// 你寫的：
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">abstract&lt;/span> &lt;span class="kd">class&lt;/span> &lt;span class="nc">PeriodReportRow&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="kd">with&lt;/span> &lt;span class="n">_$PeriodReportRow&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="kd">implements&lt;/span> &lt;span class="n">ReportAmountsView&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="p">...&lt;/span> &lt;span class="p">}&lt;/span>
&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 class="c1">// 大致等於（觀念上）：
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">abstract&lt;/span> &lt;span class="kd">class&lt;/span> &lt;span class="nc">PeriodReportRow&lt;/span> &lt;span class="kd">implements&lt;/span> &lt;span class="n">ReportAmountsView&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 門面接上 generated API surface：
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="n">PeriodReportRow&lt;/span> &lt;span class="n">copyWith&lt;/span>&lt;span class="p">(...);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&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 class="kd">class&lt;/span> &lt;span class="nc">_PeriodReportRow&lt;/span> &lt;span class="kd">implements&lt;/span> &lt;span class="n">PeriodReportRow&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 具體生成類持有欄位並滿足 interface getters：
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="err">@&lt;/span>&lt;span class="n">override&lt;/span> &lt;span class="kd">final&lt;/span> &lt;span class="kt">String&lt;/span> &lt;span class="n">date&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="err">@&lt;/span>&lt;span class="n">override&lt;/span> &lt;span class="kd">final&lt;/span> &lt;span class="n">Decimal&lt;/span> &lt;span class="n">grossAmount&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="err">@&lt;/span>&lt;span class="n">override&lt;/span> &lt;span class="kd">final&lt;/span> &lt;span class="n">Decimal&lt;/span> &lt;span class="n">channelA&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="c1">// ... 等等所有 factory 參數對應的欄位
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這是心智模型：&lt;code>with&lt;/code> 接上 generated surface，&lt;code>factory = _PeriodReportRow&lt;/code> 接到真正的資料承載類。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：實作營運端報表 API、寫了一個 freezed model
<strong>疑問來源</strong>：<code>abstract class PeriodReportRow with _$PeriodReportRow implements ReportAmountsView</code> 這一行包含太多陌生語法
<strong>整理目的</strong>：把「為什麼長這樣」與「是否有更好懂做法」的脈絡記錄下來、避免下次又從零開始查
<strong>本文邊界</strong>：這是一篇 work-log，目標是回溯一次具體實作中的理解成本；它不取代 freezed 官方文件，也不把某個專案的模型分層當成通用規則。</p></blockquote>
<hr>
<h2 id="事件起點">事件起點</h2>
<p>今天在某個營運端 Flutter 專案新增週期彙總報表 API，這份報表和既有的單次作業報表共用呈現邏輯、各自有獨立的 DTO。為了讓兩個 DTO 共用 sections builder、抽了一個 <code>ReportAmountsView</code> 介面、讓兩邊的 <code>*Row</code> 都 <code>implements</code> 它。</p>
<p>寫完後盯著這行程式碼看了一下：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="err">@</span><span class="n">freezed</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kd">abstract</span> <span class="kd">class</span> <span class="nc">PeriodReportRow</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="kd">with</span> <span class="n">_$PeriodReportRow</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="kd">implements</span> <span class="n">ReportAmountsView</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="kd">const</span> <span class="kd">factory</span> <span class="n">PeriodReportRow</span><span class="p">({</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="kd">required</span> <span class="kt">String</span> <span class="n">date</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="c1">// ... 18 個欄位
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"></span>  <span class="p">})</span> <span class="o">=</span> <span class="n">_PeriodReportRow</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>短短四行裡塞了好幾個需要分層理解的語法：<code>abstract</code> 為什麼能配 <code>factory</code>、<code>with _$PeriodReportRow</code> 在做什麼、<code>_$</code> 這個前綴代表什麼、<code>= _PeriodReportRow</code> 如何接到生成類，以及為什麼要分成「我寫的 abstract」+「生成的 mixin」+「生成的具體類」三層。</p>
<p>這篇筆記把那次停下來查證的路徑整理成可重讀的判斷脈絡。</p>
<hr>
<h2 id="第一層with-是什麼">第一層：<code>with</code> 是什麼</h2>
<p><code>with</code> 是 Dart 的 <strong>mixin 語法</strong>、把另一個型別的成員「混入」當前 class。當前 class 會接上 mixin 提供的成員；如果 mixin 宣告了抽象成員，最後的具體類仍要提供實作。</p>
<h3 id="三個關鍵字的差異">三個關鍵字的差異</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">abstract</span> <span class="kd">class</span> <span class="nc">PeriodReportRow</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="kd">with</span> <span class="n">_$PeriodReportRow</span>         <span class="c1">// ← mixin：接上生成 API surface
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span>    <span class="kd">implements</span> <span class="n">ReportAmountsView</span>  <span class="o">//</span> <span class="err">←</span> <span class="n">interface</span><span class="err">：拿到契約</span></span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>關鍵字</th>
          <th>拿到什麼</th>
          <th>是否要自己寫實作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>extends</code></td>
          <td>繼承父類別（單一）</td>
          <td>可選擇覆寫</td>
      </tr>
      <tr>
          <td><code>implements</code></td>
          <td>只拿型別契約</td>
          <td><strong>要自己全部實作</strong></td>
      </tr>
      <tr>
          <td><code>with</code></td>
          <td>拿到 mixin 成員，可含實作或要求</td>
          <td>取決於 mixin 內的成員是否已實作</td>
      </tr>
  </tbody>
</table>
<p><code>extends</code> 佔據唯一父類別位置，適合真正的 is-a 關係；<code>implements</code> 只拿契約，適合用型別描述能力；<code>with</code> 在中間，適合把一組生成或共用的成員接到 class 上。</p>
<h3 id="在-freezed-中的角色">在 freezed 中的角色</h3>
<p><code>_$PeriodReportRow</code> 是 build_runner 跑完後在 <code>period_report_dto.freezed.dart</code> 裡產出的 mixin，角色是把 Freezed 生成的 API surface 接到你宣告的 <code>PeriodReportRow</code> 門面上。</p>
<ul>
<li>欄位 getter 的契約或 forwarding surface（<code>date</code>、<code>grossAmount</code>、<code>channelA</code> 等）</li>
<li><code>==</code> 和 <code>hashCode</code> 相關生成邏輯</li>
<li><code>copyWith</code></li>
<li><code>toString</code></li>
<li>JSON 相關的 generated function / method 接線（取決於是否搭配 <code>json_serializable</code> 與 <code>fromJson</code> factory）</li>
</ul>
<p>所以 <code>abstract class PeriodReportRow with _$PeriodReportRow</code> 在做的事是：</p>
<blockquote>
<p>「我這個 class 是抽象門面，Freezed 會把生成 API 放在 <code>_$PeriodReportRow</code> mixin 與 <code>_PeriodReportRow</code> 具體類裡；門面透過 <code>with</code> 接上生成 surface，factory 再回傳真正持有欄位的生成類。」</p></blockquote>
<p>這裡最容易誤解的是「mixin 等於所有實作」。在 Freezed 的常見生成模式裡，mixin 會宣告或提供部分生成成員，真正持有 <code>final</code> 欄位並滿足 getter 的通常是 factory 指向的 <code>_PeriodReportRow</code> 具體類。<code>with _$PeriodReportRow</code> 的價值是讓門面型別擁有一致的生成 API 形狀，而不是把每個欄位的儲存都塞進 mixin。</p>
<h3 id="為什麼-freezed-用-mixin-而不是-extends">為什麼 freezed 用 mixin 而不是 extends</h3>
<ul>
<li><strong>mixin 不佔「父類別」的獨生子位置</strong>：Dart 只允許單一 <code>extends</code>、freezed 如果用 extends 強佔了、你就不能讓 model 繼承自己的 base class。<code>with</code> 可以無限疊加、給你自由度</li>
<li><strong>mixin 支援多個疊加</strong>：<code>class Foo with A, B, C</code> 會把 A、B、C 的方法依序混入。Freezed 利用這個語法位置，把生成 API 接到使用者宣告的門面類</li>
<li><strong><code>implements ReportAmountsView</code> 在這裡剛好成立</strong>：<code>ReportAmountsView</code> 要求的是一組 getter 契約，而 Freezed 會讓生成的 <code>_PeriodReportRow</code> 具體類依照 factory 參數產生對應欄位。門面類宣告 <code>implements</code>，具體類回傳時提供欄位實作，所以不需要再手寫 18 個 forwarding getter</li>
</ul>
<h3 id="簡化的等價心智模型">簡化的等價心智模型</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// 你寫的：
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="kd">abstract</span> <span class="kd">class</span> <span class="nc">PeriodReportRow</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="kd">with</span> <span class="n">_$PeriodReportRow</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="kd">implements</span> <span class="n">ReportAmountsView</span> <span class="p">{</span> <span class="p">...</span> <span class="p">}</span>
</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 class="c1">// 大致等於（觀念上）：
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span><span class="kd">abstract</span> <span class="kd">class</span> <span class="nc">PeriodReportRow</span> <span class="kd">implements</span> <span class="n">ReportAmountsView</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="c1">// 門面接上 generated API surface：
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span>  <span class="n">PeriodReportRow</span> <span class="n">copyWith</span><span class="p">(...);</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span>
</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 class="kd">class</span> <span class="nc">_PeriodReportRow</span> <span class="kd">implements</span> <span class="n">PeriodReportRow</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="c1">// 具體生成類持有欄位並滿足 interface getters：
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"></span>  <span class="err">@</span><span class="n">override</span> <span class="kd">final</span> <span class="kt">String</span> <span class="n">date</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">  <span class="err">@</span><span class="n">override</span> <span class="kd">final</span> <span class="n">Decimal</span> <span class="n">grossAmount</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">  <span class="err">@</span><span class="n">override</span> <span class="kd">final</span> <span class="n">Decimal</span> <span class="n">channelA</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">  <span class="c1">// ... 等等所有 factory 參數對應的欄位
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="c1"></span><span class="p">}</span></span></span></code></pre></div><p>這是心智模型：<code>with</code> 接上 generated surface，<code>factory = _PeriodReportRow</code> 接到真正的資料承載類。</p>
<hr>
<h2 id="第二層_-命名約定">第二層：<code>_$</code> 命名約定</h2>
<p>第一次看到 <code>_$PeriodReportRow</code> 容易以為這是某個 framework 的特殊符號。實際上是<strong>兩個獨立慣例疊加</strong>的結果。</p>
<h3 id="_-和--各自的角色"><code>_</code> 和 <code>$</code> 各自的角色</h3>
<table>
  <thead>
      <tr>
          <th>符號</th>
          <th>來源</th>
          <th>意義</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>_</code></td>
          <td><strong>Dart 語言本身</strong>的規則</td>
          <td>開頭底線 = library-private、只有同個 library 看得到</td>
      </tr>
      <tr>
          <td><code>$</code></td>
          <td><strong>codegen 工具的慣例</strong>（freezed、json_serializable、retrofit 都遵守）</td>
          <td>「這個名字是機器產的、請別自己取一樣的名字」</td>
      </tr>
  </tbody>
</table>
<p>組合起來：</p>
<ul>
<li><code>_$PeriodReportRow</code> → 機器產的 + 只給內部用（你不該在外部檔案引用它）</li>
<li><code>$PeriodReportRowCopyWith</code> → 機器產的 + 公開介面（呼叫 <code>instance.copyWith(...)</code> 時要看得到型別）</li>
</ul>
<p>兩個前綴分別代表不同意圖——freezed 透過 <code>_</code> 的有無、區分「實作細節」跟「公開介面」。</p>
<h3 id="_foo-為什麼你的檔案看得到"><code>_$Foo</code> 為什麼你的檔案看得到</h3>
<p>Dart 的 library-private（<code>_</code> 前綴）並非「檔案私有」、是「<strong>library 私有</strong>」。預設一個 <code>.dart</code> 檔就是一個 library、但 <strong><code>part</code> 指令會把多個檔案併成同一個 library</strong>。</p>
<p>freezed model 檔案開頭那兩行：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">part</span> <span class="s1">&#39;period_report_dto.freezed.dart&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">part</span> <span class="s1">&#39;period_report_dto.g.dart&#39;</span><span class="p">;</span></span></span></code></pre></div><p>就是在說：「這三個檔屬於同一個 library」。</p>
<p>結果：generated 檔裡的 <code>_$PeriodReportRow</code> 雖然 <code>_</code> 開頭、但因為 <code>part</code> 連通、你的主檔還是看得見、可以 <code>with</code> 它。其他 import 你檔案的人就看不到、正好符合「只給內部生成檔用」的意圖。</p>
<p>這也是為什麼<strong>忘記寫 <code>part 'xxx.freezed.dart';</code> 會編譯失敗</strong>——不是因為「找不到檔案」、是因為「<code>_$Foo</code> 不在同一個 library 內、外部不能引用」。</p>
<h3 id="一個快速辨認方式">一個快速辨認方式</h3>
<p>下次看 freezed / codegen 產出的名字、可以這樣判斷：</p>
<ul>
<li><code>_$Foo</code> → mixin / 實作類（內部用）</li>
<li><code>$Foo</code> → public 介面（給外部呼叫）</li>
<li><code>_Foo</code> → 純內部 class（如 <code>_PeriodReportRow</code> 是 freezed 為你的 factory 產的具體類）</li>
<li><code>Foo</code> → 你自己寫的 abstract class、是門面（facade）</li>
</ul>
<p>所以這次寫的：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">abstract</span> <span class="kd">class</span> <span class="nc">PeriodReportRow</span> <span class="kd">with</span> <span class="n">_$PeriodReportRow</span> <span class="kd">implements</span> <span class="n">ReportAmountsView</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="o">//</span>             <span class="err">↑</span> <span class="err">門面</span>            <span class="err">↑</span> <span class="err">內部</span> <span class="n">mixin</span>           <span class="err">↑</span> <span class="err">你定義的介面</span></span></span></code></pre></div><p>三層責任可以被辨認：你自己寫的門面類、機器產的實作、你自己定義的契約。它不是透明抽象，因為使用者仍要看懂 <code>part</code>、<code>with _$Foo</code> 與 factory redirect 這些接線。</p>
<hr>
<h2 id="第三層為什麼要這樣拆是設計不當嗎">第三層：為什麼要這樣拆——是設計不當嗎</h2>
<p><code>with _$Foo</code> 加 <code>part</code> 加 <code>abstract class</code> 加 <code>factory</code> 加 <code>_$ / $ / _ / 無前綴</code> 四種命名……理解到這裡會自然冒出一個問題：<strong>這個拆分本身、是不是 freezed 設計不當？</strong></p>
<p>我的看法：<strong>這個拆分不是 freezed 設計不當、但它確實暴露了 Dart 語言層的能力缺口</strong>。換個角度、「需要這樣拆」是症狀、不是病因——病因在語言本身。</p>
<h3 id="拆分到底解決了什麼問題">拆分到底解決了什麼問題</h3>
<p>把那幾個元素還原成「想做的事 vs 不得不這樣寫」：</p>
<table>
  <thead>
      <tr>
          <th>想做的事</th>
          <th>在 Dart 中需要的東西</th>
          <th>為什麼要拆</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>不可變 class DTO + <code>copyWith</code></td>
          <td><code>==</code>、<code>hashCode</code>、<code>toString</code>、<code>copyWith</code></td>
          <td>Dart 有 records，但沒有能取代 class DTO 的 nominal data class</td>
      </tr>
      <tr>
          <td>JSON 序列化</td>
          <td><code>fromJson</code> / <code>toJson</code></td>
          <td>Dart 沒有 reflection（AOT 砍了）、只能 codegen</td>
      </tr>
      <tr>
          <td>Sum types（多個 constructor + pattern matching）</td>
          <td>sealed class + 多個 factory</td>
          <td>Dart 3 才有 sealed、pattern matching 也是 Dart 3</td>
      </tr>
      <tr>
          <td>把上面塞進<strong>一個</strong>讓人能寫的 class</td>
          <td>abstract class + mixin + factory</td>
          <td>這是「組裝零件」的膠水、不是真實功能</td>
      </tr>
  </tbody>
</table>
<p>前 3 行是真實需求；最後一行是「為了實現前 3 行、Dart 缺工具、所以要組裝」。</p>
<h3 id="對比其他語言處理同樣問題">對比其他語言處理同樣問題</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// Kotlin —— 語言內建
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">data</span> <span class="k">class</span> <span class="nc">PeriodReport</span><span class="p">(</span><span class="k">val</span> <span class="py">date</span><span class="p">:</span> <span class="n">String</span><span class="p">,</span> <span class="k">val</span> <span class="py">grossAmount</span><span class="p">:</span> <span class="n">BigDecimal</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">// copy、equals、hashCode、toString 全部自動、0 行 codegen</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-rust" data-lang="rust"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// Rust —— derive macro 內建在語言
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="cp">#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="k">struct</span> <span class="nc">PeriodReport</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="n">date</span>: <span class="nb">String</span><span class="p">,</span><span class="w"> </span><span class="n">grossAmount</span>: <span class="nc">Decimal</span><span class="w"> </span><span class="p">}</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// TypeScript —— 結構型別 + 解構即拷貝
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kr">type</span> <span class="nx">PeriodReport</span> <span class="o">=</span> <span class="p">{</span> <span class="nx">date</span>: <span class="kt">string</span><span class="p">;</span> <span class="nx">grossAmount</span>: <span class="kt">Decimal</span> <span class="p">};</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="kr">const</span> <span class="nx">next</span> <span class="o">=</span> <span class="p">{</span> <span class="p">...</span><span class="nx">prev</span><span class="p">,</span> <span class="nx">grossAmount</span>: <span class="kt">newAmount</span> <span class="p">};</span>  <span class="c1">// copyWith 不用存在
</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// Swift —— struct 是值類型</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kd">struct</span> <span class="nc">PeriodReport</span><span class="p">:</span> <span class="n">Codable</span><span class="p">,</span> <span class="nb">Equatable</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="kd">let</span> <span class="nv">date</span><span class="p">:</span> <span class="nb">String</span><span class="p">;</span> <span class="kd">let</span> <span class="nv">grossAmount</span><span class="p">:</span> <span class="n">Decimal</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// Dart 2 —— 你只能這樣寫（沒 freezed 的話）
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="kd">class</span> <span class="nc">PeriodReport</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="kd">final</span> <span class="kt">String</span> <span class="n">date</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="kd">final</span> <span class="n">Decimal</span> <span class="n">grossAmount</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="kd">const</span> <span class="n">PeriodReport</span><span class="p">({</span><span class="kd">required</span> <span class="k">this</span><span class="p">.</span><span class="n">date</span><span class="p">,</span> <span class="kd">required</span> <span class="k">this</span><span class="p">.</span><span class="n">grossAmount</span><span class="p">});</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="n">PeriodReport</span> <span class="n">copyWith</span><span class="p">({</span><span class="kt">String</span><span class="o">?</span> <span class="n">date</span><span class="p">,</span> <span class="n">Decimal</span><span class="o">?</span> <span class="n">grossAmount</span><span class="p">})</span> <span class="o">=&gt;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">      <span class="n">PeriodReport</span><span class="p">(</span><span class="nl">date:</span> <span class="n">date</span> <span class="o">??</span> <span class="k">this</span><span class="p">.</span><span class="n">date</span><span class="p">,</span> <span class="nl">grossAmount:</span> <span class="n">grossAmount</span> <span class="o">??</span> <span class="k">this</span><span class="p">.</span><span class="n">grossAmount</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="err">@</span><span class="n">override</span> <span class="kt">bool</span> <span class="kd">operator</span> <span class="o">==</span><span class="p">(...)</span> <span class="o">=&gt;</span> <span class="p">...;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="err">@</span><span class="n">override</span> <span class="kt">int</span> <span class="kd">get</span> <span class="n">hashCode</span> <span class="o">=&gt;</span> <span class="p">...;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="err">@</span><span class="n">override</span> <span class="kt">String</span> <span class="n">toString</span><span class="p">()</span> <span class="o">=&gt;</span> <span class="p">...;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="kd">factory</span> <span class="n">PeriodReport</span><span class="p">.</span><span class="n">fromJson</span><span class="p">(</span><span class="n">Map</span><span class="o">&lt;</span><span class="kt">String</span><span class="p">,</span> <span class="kt">dynamic</span><span class="o">&gt;</span> <span class="n">json</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">...;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="n">Map</span><span class="o">&lt;</span><span class="kt">String</span><span class="p">,</span> <span class="kt">dynamic</span><span class="o">&gt;</span> <span class="n">toJson</span><span class="p">()</span> <span class="o">=&gt;</span> <span class="p">...;</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="o">//</span> <span class="m">18</span> <span class="err">個欄位</span> <span class="err">×</span> <span class="m">6</span> <span class="err">個樣板</span> <span class="err">≈</span> <span class="m">150</span> <span class="err">行手寫、每加一個欄位要改</span> <span class="m">5</span> <span class="err">個地方</span></span></span></code></pre></div><p>Freezed 是在這個現實下做的工程權衡：<strong>用一個外部工具、把這上百行壓回十幾行宣告</strong>。代價就是看到的「分三層」。</p>
<h3 id="freezed-自己有沒有設計可議的地方">Freezed 自己有沒有設計可議的地方</h3>
<p>Freezed 的設計可議之處集中在抽象洩漏，而不是功能是否成立：</p>
<ul>
<li><strong><code>part</code> directive 是漏出的實作細節</strong>：使用者必須知道 library / part 的概念才能寫對。Freezed 依賴 <code>part</code>，是因為生成檔需要和主檔落在同一個 library，讓 <code>_</code> 開頭的 generated member 可以被主檔看到</li>
<li><strong><code>with _$Foo</code> 暴露了 codegen 接線</strong>：理想上 <code>@freezed</code> 只描述資料形狀，使用者不用知道生成 mixin 的名字。現行 codegen surface 需要使用者把生成 mixin 接上去，這就是學習成本來源</li>
<li><strong><code>abstract class</code> + <code>factory</code> 需要語言模型支撐</strong>：abstract class 不能直接 <code>new</code>，但 <code>factory</code> 可以回傳具體子類。Freezed 產生 <code>_PeriodReportRow</code>，因此這個寫法在語言上成立；直覺成本來自「門面類」和「具體生成類」分離</li>
</ul>
<h3 id="那設計得不當的真正主體是誰">那「設計得不當」的真正主體是誰</h3>
<p>這個問題要拆成三層看：</p>
<ol>
<li><strong>你的 model 設計</strong>：宣告一個 immutable DTO 並實作金額視圖契約，這個方向成立</li>
<li><strong>Freezed 的設計</strong>：它用 codegen 換掉大量樣板，代價是 <code>part</code>、<code>with _$Foo</code>、factory redirect 這些接線露在使用者面前</li>
<li><strong>Dart 的語言能力</strong>：Dart 長期缺少穩定的 data class / static metaprogramming 能力，讓資料模型的重複樣板需要靠 build_runner 與外部 codegen 補齊</li>
</ol>
<h3 id="未來改善方向不是-macros-這條直線">未來改善方向不是 macros 這條直線</h3>
<p>Dart 官方在 2025-01-29 宣布停止 macros 工作，因此「等 Dart macros 穩定後，這層拆分自然消失」已經不是可靠判斷。更務實的觀察是：Dart 仍會改善資料建模與 codegen 體驗，但方向可能是更專門的 data language features、build_runner 改善或 augmentations，而不是通用 macros。</p>
<p>理想中的資料模型語法可能長得像這樣：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="err">@</span><span class="n">Data</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kd">class</span> <span class="nc">PeriodReportRow</span> <span class="kd">implements</span> <span class="n">ReportAmountsView</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="kd">final</span> <span class="kt">String</span> <span class="n">date</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="kd">final</span> <span class="n">Decimal</span> <span class="n">grossAmount</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="c1">// ... 18 個欄位
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="o">//</span> <span class="err">目標是讓資料形狀、序列化、</span><span class="n">value</span> <span class="n">equality</span><span class="err">、</span><span class="n">copyWith</span> <span class="err">更接近語言級宣告</span></span></span></code></pre></div><p>這段只能當作「期待中的語言表達能力」，不能當作 Dart 已承諾的 roadmap。對今天的專案來說，Freezed 仍然是把資料模型樣板壓低的成熟工具；它的成本是 build_runner、生成檔、以及本文拆解的三層心智模型。</p>
<hr>
<h2 id="第四層沒有-freezed-怎麼做">第四層：沒有 freezed 怎麼做</h2>
<p>如果規劃時就決定不裝 freezed、Dart 怎麼處理「immutable + JSON + copyWith + equality」這組需求？</p>
<h3 id="路線一純手寫">路線一：純手寫</h3>
<p>把 freezed 產的東西自己寫一遍：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">class</span> <span class="nc">PeriodReportRow</span> <span class="kd">implements</span> <span class="n">ReportAmountsView</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kd">final</span> <span class="kt">String</span> <span class="n">date</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="kd">final</span> <span class="kt">int</span> <span class="n">primaryOrderCount</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="kd">final</span> <span class="n">Decimal</span> <span class="n">grossAmount</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="c1">// ... 其他 16 個欄位
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="kd">const</span> <span class="n">PeriodReportRow</span><span class="p">({</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="kd">required</span> <span class="k">this</span><span class="p">.</span><span class="n">date</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="kd">required</span> <span class="k">this</span><span class="p">.</span><span class="n">primaryOrderCount</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="kd">required</span> <span class="k">this</span><span class="p">.</span><span class="n">grossAmount</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="c1">// ...
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"></span>  <span class="p">});</span>
</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 class="kd">factory</span> <span class="n">PeriodReportRow</span><span class="p">.</span><span class="n">fromJson</span><span class="p">(</span><span class="n">Map</span><span class="o">&lt;</span><span class="kt">String</span><span class="p">,</span> <span class="kt">dynamic</span><span class="o">&gt;</span> <span class="n">json</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">return</span> <span class="n">PeriodReportRow</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">      <span class="nl">date:</span> <span class="n">json</span><span class="p">[</span><span class="s1">&#39;date&#39;</span><span class="p">]</span> <span class="o">as</span> <span class="kt">String</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">      <span class="nl">primaryOrderCount:</span> <span class="n">json</span><span class="p">[</span><span class="s1">&#39;primary_order_count&#39;</span><span class="p">]</span> <span class="o">as</span> <span class="kt">int</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">      <span class="nl">grossAmount:</span> <span class="n">jsonToDecimal</span><span class="p">(</span><span class="n">json</span><span class="p">[</span><span class="s1">&#39;gross_amount&#39;</span><span class="p">]),</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">      <span class="c1">// ... 重複 18 次
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="c1"></span>    <span class="p">);</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">  <span class="p">}</span>
</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 class="n">Map</span><span class="o">&lt;</span><span class="kt">String</span><span class="p">,</span> <span class="kt">dynamic</span><span class="o">&gt;</span> <span class="n">toJson</span><span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">        <span class="s1">&#39;date&#39;</span><span class="o">:</span> <span class="n">date</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">        <span class="s1">&#39;primary_order_count&#39;</span><span class="o">:</span> <span class="n">primaryOrderCount</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">        <span class="s1">&#39;gross_amount&#39;</span><span class="o">:</span> <span class="n">grossAmount</span><span class="p">.</span><span class="n">toString</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">        <span class="c1">// ... 重複 18 次
</span></span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="c1"></span>      <span class="p">};</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">
</span></span><span class="line"><span class="ln">30</span><span class="cl">  <span class="n">PeriodReportRow</span> <span class="n">copyWith</span><span class="p">({</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">    <span class="kt">String</span><span class="o">?</span> <span class="n">date</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">    <span class="kt">int</span><span class="o">?</span> <span class="n">primaryOrderCount</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">    <span class="n">Decimal</span><span class="o">?</span> <span class="n">grossAmount</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">  <span class="p">})</span> <span class="o">=&gt;</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">      <span class="n">PeriodReportRow</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">        <span class="nl">date:</span> <span class="n">date</span> <span class="o">??</span> <span class="k">this</span><span class="p">.</span><span class="n">date</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">        <span class="nl">primaryOrderCount:</span> <span class="n">primaryOrderCount</span> <span class="o">??</span> <span class="k">this</span><span class="p">.</span><span class="n">primaryOrderCount</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">        <span class="nl">grossAmount:</span> <span class="n">grossAmount</span> <span class="o">??</span> <span class="k">this</span><span class="p">.</span><span class="n">grossAmount</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">        <span class="c1">// ... 重複 18 次
</span></span></span><span class="line"><span class="ln">40</span><span class="cl"><span class="c1"></span>      <span class="p">);</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl">
</span></span><span class="line"><span class="ln">42</span><span class="cl">  <span class="err">@</span><span class="n">override</span>
</span></span><span class="line"><span class="ln">43</span><span class="cl">  <span class="kt">bool</span> <span class="kd">operator</span> <span class="o">==</span><span class="p">(</span><span class="kt">Object</span> <span class="n">other</span><span class="p">)</span> <span class="o">=&gt;</span>
</span></span><span class="line"><span class="ln">44</span><span class="cl">      <span class="n">identical</span><span class="p">(</span><span class="k">this</span><span class="p">,</span> <span class="n">other</span><span class="p">)</span> <span class="o">||</span>
</span></span><span class="line"><span class="ln">45</span><span class="cl">      <span class="n">other</span> <span class="k">is</span> <span class="n">PeriodReportRow</span> <span class="o">&amp;&amp;</span>
</span></span><span class="line"><span class="ln">46</span><span class="cl">          <span class="n">other</span><span class="p">.</span><span class="n">date</span> <span class="o">==</span> <span class="n">date</span> <span class="o">&amp;&amp;</span>
</span></span><span class="line"><span class="ln">47</span><span class="cl">          <span class="n">other</span><span class="p">.</span><span class="n">primaryOrderCount</span> <span class="o">==</span> <span class="n">primaryOrderCount</span> <span class="o">&amp;&amp;</span>
</span></span><span class="line"><span class="ln">48</span><span class="cl">          <span class="n">other</span><span class="p">.</span><span class="n">grossAmount</span> <span class="o">==</span> <span class="n">grossAmount</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">49</span><span class="cl">          <span class="c1">// ... 重複 18 次
</span></span></span><span class="line"><span class="ln">50</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln">51</span><span class="cl">  <span class="err">@</span><span class="n">override</span>
</span></span><span class="line"><span class="ln">52</span><span class="cl">  <span class="kt">int</span> <span class="kd">get</span> <span class="n">hashCode</span> <span class="o">=&gt;</span> <span class="kt">Object</span><span class="p">.</span><span class="n">hash</span><span class="p">(</span><span class="n">date</span><span class="p">,</span> <span class="n">primaryOrderCount</span><span class="p">,</span> <span class="n">grossAmount</span> <span class="cm">/* 18 個 */</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">53</span><span class="cl">
</span></span><span class="line"><span class="ln">54</span><span class="cl">  <span class="err">@</span><span class="n">override</span>
</span></span><span class="line"><span class="ln">55</span><span class="cl">  <span class="kt">String</span> <span class="n">toString</span><span class="p">()</span> <span class="o">=&gt;</span> <span class="s1">&#39;PeriodReportRow(date: </span><span class="si">$</span><span class="n">date</span><span class="s1">, ...)&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">56</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><strong>18 個欄位 × 6 個樣板 ≈ 150 行</strong>、每加一個欄位要改 5 處（constructor、fromJson、toJson、copyWith、==、hashCode）。漏改一處 → 隱性 bug。</p>
<h3 id="路線二只-codegen-序列化其他手寫">路線二：只 codegen 序列化、其他手寫</h3>
<p>只用 <code>json_serializable</code>（比 freezed 輕量很多）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="err">@</span><span class="n">JsonSerializable</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">class</span> <span class="nc">PeriodReportRow</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="kd">final</span> <span class="kt">String</span> <span class="n">date</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="err">@</span><span class="n">JsonKey</span><span class="p">(</span><span class="nl">name:</span> <span class="s1">&#39;primary_order_count&#39;</span><span class="p">)</span> <span class="kd">final</span> <span class="kt">int</span> <span class="n">primaryOrderCount</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="err">@</span><span class="n">DecimalConverter</span><span class="p">()</span> <span class="kd">final</span> <span class="n">Decimal</span> <span class="n">grossAmount</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="c1">// ...
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="kd">const</span> <span class="n">PeriodReportRow</span><span class="p">({</span><span class="kd">required</span> <span class="k">this</span><span class="p">.</span><span class="n">date</span><span class="p">,</span> <span class="p">...});</span>
</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">  <span class="kd">factory</span> <span class="n">PeriodReportRow</span><span class="p">.</span><span class="n">fromJson</span><span class="p">(</span><span class="n">Map</span><span class="o">&lt;</span><span class="kt">String</span><span class="p">,</span> <span class="kt">dynamic</span><span class="o">&gt;</span> <span class="n">json</span><span class="p">)</span> <span class="o">=&gt;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">      <span class="n">_$PeriodReportRowFromJson</span><span class="p">(</span><span class="n">json</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="n">Map</span><span class="o">&lt;</span><span class="kt">String</span><span class="p">,</span> <span class="kt">dynamic</span><span class="o">&gt;</span> <span class="n">toJson</span><span class="p">()</span> <span class="o">=&gt;</span> <span class="n">_$PeriodReportRowToJson</span><span class="p">(</span><span class="k">this</span><span class="p">);</span>
</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 class="c1">// 不寫 ==、hashCode、copyWith
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="c1"></span><span class="p">}</span></span></span></code></pre></div><p>省掉 fromJson / toJson 的樣板（最容易出錯的部分）、但仍要自己寫 <code>==</code> 和 <code>copyWith</code>（如果需要）。</p>
<h3 id="路線三dart-3-records">路線三：Dart 3 Records</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">typedef</span> <span class="n">PeriodReportRow</span> <span class="o">=</span> <span class="p">({</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kt">String</span> <span class="n">date</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="kt">int</span> <span class="n">primaryOrderCount</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="n">Decimal</span> <span class="n">grossAmount</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="c1">// ...
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span><span class="p">});</span>
</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 class="c1">// 建立
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="kd">final</span> <span class="n">row</span> <span class="o">=</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="nl">date:</span> <span class="s1">&#39;2026-05-11&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="nl">primaryOrderCount:</span> <span class="m">0</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="nl">grossAmount:</span> <span class="n">Decimal</span><span class="p">.</span><span class="n">zero</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="c1">// ...
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"></span><span class="p">);</span>
</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 class="c1">// 「copyWith」就是用解構重組
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="c1"></span><span class="kd">final</span> <span class="n">next</span> <span class="o">=</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">  <span class="nl">date:</span> <span class="n">row</span><span class="p">.</span><span class="n">date</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">  <span class="nl">grossAmount:</span> <span class="n">newAmount</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">  <span class="nl">primaryOrderCount:</span> <span class="n">row</span><span class="p">.</span><span class="n">primaryOrderCount</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">  <span class="c1">// ...
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="c1"></span><span class="p">);</span></span></span></code></pre></div><p>Record 是 Dart 3 內建的不可變值型別，適合短距離攜帶一組值：</p>
<ul>
<li>支援：自動 <code>==</code> / <code>hashCode</code> / <code>toString</code></li>
<li>支援：不可變</li>
<li>限制：無名 → 不能 <code>implements ReportAmountsView</code>、不能加方法、不能 <code>extends</code></li>
<li>限制：JSON 還是要手寫</li>
<li>限制：沒有 named constructor → 無法做「from raw API JSON」的轉換邏輯</li>
</ul>
<p>對「跨模組共享、需要實作介面、需要 fromJson」的 DTO，record 的語意承載力不足。對「函式內部短暫的多回傳值」，record 很合適。</p>
<hr>
<h2 id="真正該問的問題你需要的是哪幾項">真正該問的問題：你需要的是哪幾項</h2>
<p>回頭把「freezed 給你的功能」拆開看、對 DTO 真正用得到的有：</p>
<table>
  <thead>
      <tr>
          <th>功能</th>
          <th>DTO 需求程度</th>
          <th>為什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>fromJson</code> / <code>toJson</code></td>
          <td>必要</td>
          <td>後端來的 raw JSON、必須轉成型別</td>
      </tr>
      <tr>
          <td>Immutable（<code>final</code>）</td>
          <td>必要</td>
          <td>DTO 被多處引用、可變會引入難追的 bug</td>
      </tr>
      <tr>
          <td><code>==</code> / <code>hashCode</code></td>
          <td>看用法</td>
          <td>若放進 <code>RxBool</code>、<code>Set</code>、<code>Map</code> 才需要；單純傳遞用不到</td>
      </tr>
      <tr>
          <td><code>copyWith</code></td>
          <td>通常不需要</td>
          <td>DTO 從 API 來就餵給 domain layer，修改通常發生在 domain model</td>
      </tr>
      <tr>
          <td>Sealed union</td>
          <td>不需要</td>
          <td>DTO 是固定形狀、不是「多種變體擇一」</td>
      </tr>
      <tr>
          <td><code>toString</code> 除錯</td>
          <td>看情境</td>
          <td>開發 / 除錯時方便、prod 用不到</td>
      </tr>
  </tbody>
</table>
<p>這個 DTO 情境的核心需求是 JSON 轉換與 immutable；其他能力是 Freezed 順手提供的附加價值，是否有用取決於後續資料流。</p>
<h3 id="過剩功能不是壞事但會誤導">過剩功能不是壞事、但會誤導</h3>
<p>用了 freezed 後會傾向「reach for <code>copyWith</code>」，因為它就在那。如果一開始只用 <code>json_serializable</code>，可能根本不會在 DTO 上做修改。較穩定的 DTO 用法是把 DTO 視為 API 邊界的快照；需要變更行為時，轉成 domain model 再承載狀態變化。</p>
<h3 id="這次-dto-只吃到-freezed-的部分價值">這次 DTO 只吃到 Freezed 的部分價值</h3>
<p>Freezed 在 DTO 上仍有價值，尤其是 immutable、JSON 轉換接線、欄位同步與 <code>toString</code> 除錯。這次報表 DTO 的資料流比較單純，主要吃到的是 JSON 轉換與 immutable；<code>copyWith</code>、sealed union、複雜狀態轉移這些能力比較像附加值。</p>
<p>Domain 物件（如 <code>ShoppingCart</code>、<code>Order</code>）常有「在現有狀態上做小修改」或「多種狀態擇一」的場景，這時 <code>copyWith</code> 與 sealed union 更容易回收那層拆分成本。比較精確的判斷不是「Freezed 不適合 DTO」，而是「不同 model 層吃到的 Freezed 價值不同」。</p>
<hr>
<h2 id="第五層更好懂的路徑是中間投影物件">第五層：更好懂的路徑是中間投影物件</h2>
<p>重新用 WARP 看這個設計時，決策錨點不是「怎樣讓 builder 少寫一次」，而是「下一個維護者能不能快速看懂資料怎麼從後端 row 變成報表 sections」。如果這個錨點成立，讓 DTO 直接 <code>implements ReportAmountsView</code> 的寫法就不一定是最佳答案。</p>
<p>目前的做法把共用點放在 DTO 型別上。兩種報表 row 都是後端 API row，卻為了共用 <code>_buildGeneralSections</code> / <code>_buildAccountSections</code>，一起實作一個 18 個 getter 的 <code>ReportAmountsView</code>。這在型別上可行，但讀者要同時理解 Freezed 生成類、mixin、interface、DTO 與報表 builder，才能知道為什麼這行能編譯。</p>
<h3 id="共用-builder-的三個局部方案">共用 builder 的三個局部方案</h3>
<table>
  <thead>
      <tr>
          <th>方案</th>
          <th>核心做法</th>
          <th>讀者要理解什麼</th>
          <th>主要成本</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1. DTO 直接實作共用介面</td>
          <td>兩個 row 都 <code>implements View</code></td>
          <td>Freezed + mixin + interface + builder</td>
          <td>抽象位置偏早，型別關係較難讀</td>
      </tr>
      <tr>
          <td>2. 直接重複兩份 builder</td>
          <td>兩種報表各自寫 sections builder</td>
          <td>每個 builder 自己讀自己的 row</td>
          <td>重複邏輯，後續欄位變動要改兩處</td>
      </tr>
      <tr>
          <td>3. 先投影成報表金額模型</td>
          <td>row 先轉 <code>ReportAmounts</code></td>
          <td>API row → 報表金額投影 → sections</td>
          <td>多一個 model 與兩份 mapping</td>
      </tr>
  </tbody>
</table>
<p>方案 1 是目前寫法。它的優點是 <code>_buildGeneralSections</code> / <code>_buildAccountSections</code> 可以直接共用，而且沒有額外 mapping；缺點是共用介面綁在 API DTO 上，讓「後端資料形狀」和「報表需要的共同金額視圖」混在同一層。這種寫法對熟悉 Freezed 的人不難，但對第一次接手的人，理解成本集中在一行 class 宣告上。</p>
<p>方案 2 是最直白的寫法。每種報表 row 用自己的 builder，讀者不用理解跨 DTO 介面；缺點是兩份 builder 很容易長得幾乎一樣。當報表欄位增加或文字調整時，維護者要記得同步兩邊，重複會變成一致性風險。</p>
<p>方案 3 把共用點移到更貼近需求的中間層。DTO 仍然只描述 API 回傳形狀，報表 builder 只吃 <code>ReportAmounts</code>，兩個 row 各自用 extension 或 mapper 明確轉成報表需要的共同資料。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">class</span> <span class="nc">ReportAmounts</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kd">const</span> <span class="n">ReportAmounts</span><span class="p">({</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="kd">required</span> <span class="k">this</span><span class="p">.</span><span class="n">primaryOrderCount</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="kd">required</span> <span class="k">this</span><span class="p">.</span><span class="n">primaryTurnover</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="kd">required</span> <span class="k">this</span><span class="p">.</span><span class="n">grossAmount</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="c1">// ...其餘報表需要的金額欄位
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span>  <span class="p">});</span>
</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 class="kd">final</span> <span class="kt">int</span> <span class="n">primaryOrderCount</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="kd">final</span> <span class="n">Decimal</span> <span class="n">primaryTurnover</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="kd">final</span> <span class="n">Decimal</span> <span class="n">grossAmount</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span>
</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 class="n">extension</span> <span class="n">SingleRunReportRowAmounts</span> <span class="n">on</span> <span class="n">SingleRunReportRow</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">  <span class="n">ReportAmounts</span> <span class="n">toAmounts</span><span class="p">()</span> <span class="o">=&gt;</span> <span class="n">ReportAmounts</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="nl">primaryOrderCount:</span> <span class="n">primaryOrderCount</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="nl">primaryTurnover:</span> <span class="n">primaryTurnover</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="nl">grossAmount:</span> <span class="n">grossAmount</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">      <span class="p">);</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="p">}</span>
</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 class="n">extension</span> <span class="n">PeriodReportRowAmounts</span> <span class="n">on</span> <span class="n">PeriodReportRow</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">  <span class="n">ReportAmounts</span> <span class="n">toAmounts</span><span class="p">()</span> <span class="o">=&gt;</span> <span class="n">ReportAmounts</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">        <span class="nl">primaryOrderCount:</span> <span class="n">primaryOrderCount</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">        <span class="nl">primaryTurnover:</span> <span class="n">primaryTurnover</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">        <span class="nl">grossAmount:</span> <span class="n">grossAmount</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">      <span class="p">);</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 mapping 看起來重複，但它是有價值的重複：它明確標出「哪些 API 欄位被投影成報表金額」。後端欄位名稱或語意改變時，維護者會在 mapper 裡看到轉換邊界，而不是在一個 18-getter interface 裡推理兩個 DTO 為什麼剛好長得一樣。</p>
<h3 id="重新判斷">重新判斷</h3>
<p>以好懂與好維護為核心，方案 3 比方案 1 更穩。它多寫一個 <code>ReportAmounts</code> 和兩份 mapping，但把複雜度放在比較合理的位置：DTO 層接 API，projection 層接報表語意，builder 層只處理畫面 / 呈現 sections。</p>
<p>方案 1 可以短期保留，因為它型別安全、改動小、和既有 Freezed 寫法一致。但若這段程式會長期被不同人維護，或未來還會增加其他 report row，應把 <code>ReportAmountsView</code> 換成明確的 <code>ReportAmounts</code> 投影模型。</p>
<p>實作落地時還有一個命名細節：如果已經從「共用介面」改成「中間投影模型」，檔名也應從 <code>report_amounts_view.dart</code> 改成 <code>report_amounts.dart</code>。否則程式碼雖然改成 projection，讀者仍會被舊的 View 命名帶回「DTO 實作介面」的心智模型。</p>
<h3 id="實作後驗證">實作後驗證</h3>
<p>這輪實作已經把 <code>ReportAmountsView</code> 移除，改成 <code>ReportAmounts</code> 投影模型與兩個 <code>toAmounts()</code> extension。局部 <code>flutter analyze</code> 對修改檔案通過，並補了 <code>report_amounts_test.dart</code> 驗證兩種報表 row 的共同金額欄位投影正確。</p>
<p>這個驗證證明 projection 邊界在型別與欄位對應上可行，但它還沒有驗證呈現版面或實際 API response 的完整結果。後續若報表內容有差異，應回到 sections builder 或 API 欄位語意，而不是回頭讓 DTO 重新實作共用介面。</p>
<hr>
<h2 id="規劃有沒有瑕疵">規劃有沒有瑕疵</h2>
<p>整體判斷：<strong>使用 Freezed 本身不是瑕疵，但共用 builder 的抽象位置值得調整</strong>。</p>
<h3 id="1-工具選擇是一致性-vs-適配度的取捨">1. 工具選擇是「一致性 vs 適配度」的取捨</h3>
<p>這類專案統一使用 freezed 的收益：</p>
<ul>
<li><strong>一致性</strong>：所有 model 一樣寫，接手者不用學兩套</li>
<li><strong>未雨綢繆</strong>：今天 DTO 不需要 <code>copyWith</code>、明天可能要（例如做 optimistic update 時要短暫修改 DTO）</li>
<li><strong>降低決策成本</strong>：不用每個 model 問「這個需要 copyWith 嗎？」</li>
</ul>
<p>成本：</p>
<ul>
<li><strong>DTO 上「邊際過剩」</strong>：用不到的功能也產出來、多花 build_runner 時間</li>
<li><strong>抽象洩漏</strong>：使用者必須懂 <code>_$</code> / <code>part</code> / mixin</li>
</ul>
<p>這個取捨<strong>沒標準答案</strong>、看團隊規模和維護週期。若系統長期維護、多人接手、既有專案已經採用 Freezed、而 build_runner 成本可接受，一致性的價值通常會高於 DTO 上的邊際過剩。</p>
<h3 id="2-dto-與-domain-model-兩層分離仍然合理">2. DTO 與 domain model 兩層分離仍然合理</h3>
<p>不在「用了 freezed」、而在於——<strong>是否需要 DTO 與 domain model 兩層分離</strong>？</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">SingleRunReportRow（DTO、貼著 API）
</span></span><span class="line"><span class="ln">2</span><span class="cl">       ↓ service 轉換
</span></span><span class="line"><span class="ln">3</span><span class="cl">ReportSummary（domain、貼著 UI / 呈現）</span></span></code></pre></div><p>兩層是分開的。這個分層有成本：</p>
<ul>
<li>多寫一個 model</li>
<li>多寫一份轉換邏輯</li>
<li>多一份要維護</li>
</ul>
<p>但價值：</p>
<ul>
<li>後端改 API 欄位名 → 只動 DTO 層、domain 不受影響</li>
<li>UI 要新增顯示邏輯 → 只動 domain 層、DTO 不受影響</li>
<li>呈現報表的格式可以脫離 API 變化</li>
</ul>
<p>對長期維護、資料語意敏感的營運系統，這層分離通常值得；對短期 prototype，這層分離的維護成本可能高於收益。</p>
<h3 id="3-共用-builder-的抽象位置可能放太早">3. 共用 builder 的抽象位置可能放太早</h3>
<p><code>ReportAmountsView</code> 把報表需要的共同欄位直接壓到 API DTO 上，這是目前寫法最需要檢討的地方。更清楚的分層是：DTO 先完整接住後端 row，再由 mapper 投影成 <code>ReportAmounts</code>，最後由 sections builder 使用這個報表模型。</p>
<p>這個調整不會否定 Freezed，也不會否定 DTO / domain 分層。它只是把「共同報表金額」從 API DTO interface 移到報表投影層，讓型別關係更接近讀者真正要理解的資料流。</p>
<h3 id="一個反向思考">一個反向思考</h3>
<p>如果<strong>沒有 freezed</strong>、會怎麼做？</p>
<p>我猜會：</p>
<ol>
<li>DTO 只用 <code>json_serializable</code>（最輕量）</li>
<li>domain model 手寫（反正欄位通常比 DTO 少）</li>
<li>用 immutable 慣例但不強制（<code>final</code> 欄位 + 沒有 setter）</li>
</ol>
<p>這樣寫出來會比現在<strong>少一層拆分但多一些手寫樣板</strong>。誰好誰壞、看 trade-off 什麼：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>用 freezed</th>
          <th>不用 freezed</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>寫起來</td>
          <td>短</td>
          <td>長</td>
      </tr>
      <tr>
          <td>讀起來</td>
          <td>多層、要懂 mixin</td>
          <td>直白</td>
      </tr>
      <tr>
          <td>改起來</td>
          <td>改一處</td>
          <td>改多處</td>
      </tr>
      <tr>
          <td>學習門檻</td>
          <td>高</td>
          <td>低</td>
      </tr>
      <tr>
          <td>出錯機率</td>
          <td>欄位同步漏改風險低，但有工具鏈風險</td>
          <td>手寫易漏改</td>
      </tr>
      <tr>
          <td>Build 時間</td>
          <td>增加 build_runner 成本</td>
          <td>沒影響</td>
      </tr>
      <tr>
          <td>Debug 體驗</td>
          <td>IDE 跳轉差</td>
          <td>直接看到</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="結論">結論</h2>
<ol>
<li><strong>「拆」是 Freezed 在 Dart 現有 codegen surface 下的工程妥協</strong>：它用三層結構換掉大量手寫樣板</li>
<li><strong><code>with _$Foo</code> 和 <code>part</code> 是漏出的實作細節</strong>：使用者需要理解 library、mixin、factory redirect，才能讀懂 Freezed 生成模型</li>
<li><strong>不同 model 層吃到的 Freezed 價值不同</strong>：DTO 常吃到 immutable / JSON / 欄位同步，domain model 更容易吃到 <code>copyWith</code> / union / 狀態轉移能力；統一用法換來的一致性，在長期維護的專案上可能值得</li>
<li><strong>Dart macros 不是可期待的解法路線</strong>：官方已停止 macros 工作，後續改善更可能來自 data features、build_runner 或 augmentations</li>
<li><strong>真正要檢討的是分層邊界</strong>：DTO 與 domain model 分離是否值得，比 <code>with _$Foo</code> 本身更接近架構決策</li>
<li><strong>目前 <code>implements ReportAmountsView</code> 可行但不一定最好懂</strong>：若核心目標是長期維護，<code>ReportAmounts</code> 投影模型通常比讓 API DTO 直接實作共用介面更清楚；落地時連檔名也要改成 projection 命名，避免舊抽象殘留</li>
</ol>
<p>換個角度說：當你寫 <code>with _$PeriodReportRow</code> 時，你是在接受一個 codegen 工具的心智模型，用它補上資料類型在手寫 Dart 裡會產生的大量樣板。</p>
<hr>
<h2 id="附錄今日實作中相關的設計決策">附錄：今日實作中相關的設計決策</h2>
<p>這次新增週期彙總報表 API 時，面對的關鍵設計選擇是「沿用既有 row、還是新增一個獨立 row」。</p>
<p>當下選擇了新增，然後抽 <code>ReportAmountsView</code> 介面共用 sections builder。這個決策當時在 A/B/C 三個選項裡合理，但重新用「好懂、好維護」作為錨點審查後，應該補上第四個選項：</p>
<table>
  <thead>
      <tr>
          <th>選項</th>
          <th>優點</th>
          <th>缺點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>A. 沿用既有 row、把獨有欄位改 optional</td>
          <td>共用一個 model、少寫 18 個欄位</td>
          <td>兩個語意完全不同的東西放在一起、型別會說謊</td>
      </tr>
      <tr>
          <td>B. 新增獨立 row、各自獨立</td>
          <td>語意清楚、各自演化</td>
          <td>報表 sections builder 可能重複</td>
      </tr>
      <tr>
          <td>C. 新增 + 抽 <code>ReportAmountsView</code> 介面共用 builder</td>
          <td>兼顧 A 的 DRY + B 的清楚</td>
          <td>多一個 interface 檔案、需理解 Freezed <code>implements</code> 用法</td>
      </tr>
      <tr>
          <td>D. 新增 + 投影成 <code>ReportAmounts</code></td>
          <td>DTO 與報表語意分層清楚</td>
          <td>多一個投影 model 與兩份 mapping</td>
      </tr>
  </tbody>
</table>
<p>選項 A 的主要問題是型別會說謊。既有 row 有單次作業、操作者、時間等語意，新的 row 是跨作業週期彙總；把兩種欄位塞進同一個 row，會讓 optional 欄位承擔太多語意分支。</p>
<p>選項 B 的主要問題是同步成本。它最容易讀，但如果兩種報表的 sections 幾乎一致，後續調整顯示項目時就要維護兩份相似邏輯。</p>
<p>選項 C 是當下採用的路徑。<code>ReportAmountsView</code> 只覆蓋「金額部分」、操作者 / 作業週期 / 日期等識別欄位刻意留給各自的 row 自管，避免介面變成 god interface；但它也讓 API DTO 直接承擔報表共用介面，讀者必須理解 Freezed 的門面類、generated mixin 與具體生成類。</p>
<p>選項 D 是重新審查後更好的候選。它保留兩種報表 row 各自獨立，也保留 sections builder 共用，但把共用點移到 <code>ReportAmounts</code> 這個報表投影模型。這樣多寫的 mapping 是刻意暴露資料轉換邊界，而不是無效樣板。</p>
<p>因此，本文更新後的判斷是：<strong>當下選 C 可以理解，但若要讓程式碼更好懂、更好維護，實作上應改成 D</strong>。</p>
<hr>
<h2 id="參考資料">參考資料</h2>
<ul>
<li><a href="https://pub.dev/packages/freezed">freezed 套件</a></li>
<li><a href="https://dart.dev/language/mixins">Dart language tour - Mixins</a></li>
<li><a href="https://dart.dev/language/libraries">Dart language tour - Libraries and imports</a></li>
<li><a href="https://dart.dev/blog/an-update-on-dart-macros-data-serialization">Dart Blog - An update on Dart macros &amp; data serialization</a></li>
<li><a href="https://dart.dev/language/records">Dart Records</a></li>
<li><a href="../freezed/">既有的 freezed 選型評估筆記</a></li>
</ul>
]]></content:encoded></item><item><title>Dart test 的跨檔案 GetX 狀態污染：flaky 真因不是 fail 訊息上的那個 test</title><link>https://tarrragon.github.io/blog/work-log/dart-test-%E7%9A%84%E8%B7%A8%E6%AA%94%E6%A1%88-getx-%E7%8B%80%E6%85%8B%E6%B1%A1%E6%9F%93flaky-%E7%9C%9F%E5%9B%A0%E4%B8%8D%E6%98%AF-fail-%E8%A8%8A%E6%81%AF%E4%B8%8A%E7%9A%84%E9%82%A3%E5%80%8B-test/</link><pubDate>Thu, 07 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/dart-test-%E7%9A%84%E8%B7%A8%E6%AA%94%E6%A1%88-getx-%E7%8B%80%E6%85%8B%E6%B1%A1%E6%9F%93flaky-%E7%9C%9F%E5%9B%A0%E4%B8%8D%E6%98%AF-fail-%E8%A8%8A%E6%81%AF%E4%B8%8A%E7%9A%84%E9%82%A3%E5%80%8B-test/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>事故類型&lt;/strong>：cross-file 狀態污染、dart test runner 同 process 共用 GetX
&lt;strong>症狀&lt;/strong>：&lt;code>flutter test&lt;/code> 約 50% 機率隨機失敗、每次失敗的 test 不固定；單獨跑該 test file 100% 通過
&lt;strong>根因&lt;/strong>：dart test runner 在同 process 內跑多個 test file 共用 GetX 容器；前面 file 的 setUp 留下殘留（測試 mode 旗標、未 dispose 的 controller、stream subscription）污染後面 file 的測試環境&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="事故場景">事故場景&lt;/h2>
&lt;h3 id="表面症狀">表面症狀&lt;/h3>
&lt;p>跑 &lt;code>flutter test&lt;/code> 全 suite，Run 1 fail、Run 2 pass、Run 3 pass、Run 4 fail、Run 5 fail。看到的失敗訊息類似：&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">00:27 +125: PrintCenter 廚房印表機管理 kitchenPrinter 向後兼容取第一台 - did not complete [E]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">00:27 +125: PrintCenter 廚房印表機管理 重複呼叫 initFakeKitchenPrinters 會清除舊的 - did not complete [E]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">00:27 +125: Some tests failed.&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>訊息直接點名 &lt;code>PrintCenter 廚房印表機管理&lt;/code> group 的兩個 test「did not complete」。直覺反應：那兩個 test 有問題、去看那個 file。&lt;/p>
&lt;h3 id="第一次診斷與失敗的修法">第一次診斷與失敗的修法&lt;/h3>
&lt;p>打開 &lt;code>online_order_print_handler_test.dart&lt;/code>，看到 &lt;code>PrintCenter 廚房印表機管理&lt;/code> group 的 setUp 沒做 &lt;code>Get.reset()&lt;/code>、純粹依賴 outer setUp 的 &lt;code>Get.reset()&lt;/code>。判斷可能是 outer setUp 的 &lt;code>OnlineOrderPrintHandler.onInit&lt;/code> 在這個 group 留下副作用（stream subscription 之類），於是給這個 group 加自己的 reset：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="n">group&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;PrintCenter 廚房印表機管理&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">{&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">late&lt;/span> &lt;span class="n">PrintCenter&lt;/span> &lt;span class="n">printCenter&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="n">setUp&lt;/span>&lt;span class="p">(()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="n">Get&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">reset&lt;/span>&lt;span class="p">();&lt;/span> &lt;span class="c1">// ← 加這行隔離 outer setUp 的副作用
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="n">printCenter&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">PrintCenter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">FakePrinterAdapter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;main&amp;#39;&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="n">Get&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">put&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">printCenter&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&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"> &lt;span class="n">tearDown&lt;/span>&lt;span class="p">(()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="n">Get&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">reset&lt;/span>&lt;span class="p">();&lt;/span> &lt;span class="c1">// ← 加這行確保不殘留
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>跑 5 次：Run 1 fail、Run 2 pass、Run 3 pass、Run 4 fail、Run 5 fail——&lt;strong>flakiness 比例沒改變&lt;/strong>。&lt;/p>
&lt;p>修錯了。&lt;/p>
&lt;h3 id="重新診斷看-n--1-計數的真正位置">重新診斷：看 &lt;code>+N -1&lt;/code> 計數的真正位置&lt;/h3>
&lt;p>把 fail 輸出存進檔案、仔細看 progress line 的 &lt;code>+N -1&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">00:08 +125 -1: ... auto_service_config_test.dart: ...
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">00:08 +126 -1: ... settle_page_order_object_test.dart: SettlePage.orderObject reactivity searchedOrder 變更：badge 立即更新（list 與 selected 都沒命中時）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">00:08 +127 -1: ... auto_service_config_test.dart: ...&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>-1&lt;/code> 在第 126 個 test 才第一次出現——失敗的不是 print handler，是中間夾的 &lt;strong>widget test&lt;/strong>。再看另一次 fail：&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>事故類型</strong>：cross-file 狀態污染、dart test runner 同 process 共用 GetX
<strong>症狀</strong>：<code>flutter test</code> 約 50% 機率隨機失敗、每次失敗的 test 不固定；單獨跑該 test file 100% 通過
<strong>根因</strong>：dart test runner 在同 process 內跑多個 test file 共用 GetX 容器；前面 file 的 setUp 留下殘留（測試 mode 旗標、未 dispose 的 controller、stream subscription）污染後面 file 的測試環境</p></blockquote>
<hr>
<h2 id="事故場景">事故場景</h2>
<h3 id="表面症狀">表面症狀</h3>
<p>跑 <code>flutter test</code> 全 suite，Run 1 fail、Run 2 pass、Run 3 pass、Run 4 fail、Run 5 fail。看到的失敗訊息類似：</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">00:27 +125: PrintCenter 廚房印表機管理 kitchenPrinter 向後兼容取第一台 - did not complete [E]
</span></span><span class="line"><span class="ln">2</span><span class="cl">00:27 +125: PrintCenter 廚房印表機管理 重複呼叫 initFakeKitchenPrinters 會清除舊的 - did not complete [E]
</span></span><span class="line"><span class="ln">3</span><span class="cl">00:27 +125: Some tests failed.</span></span></code></pre></div><p>訊息直接點名 <code>PrintCenter 廚房印表機管理</code> group 的兩個 test「did not complete」。直覺反應：那兩個 test 有問題、去看那個 file。</p>
<h3 id="第一次診斷與失敗的修法">第一次診斷與失敗的修法</h3>
<p>打開 <code>online_order_print_handler_test.dart</code>，看到 <code>PrintCenter 廚房印表機管理</code> group 的 setUp 沒做 <code>Get.reset()</code>、純粹依賴 outer setUp 的 <code>Get.reset()</code>。判斷可能是 outer setUp 的 <code>OnlineOrderPrintHandler.onInit</code> 在這個 group 留下副作用（stream subscription 之類），於是給這個 group 加自己的 reset：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">group</span><span class="p">(</span><span class="s1">&#39;PrintCenter 廚房印表機管理&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="n">late</span> <span class="n">PrintCenter</span> <span class="n">printCenter</span><span class="p">;</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="n">setUp</span><span class="p">(()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="n">Get</span><span class="p">.</span><span class="n">reset</span><span class="p">();</span>  <span class="c1">// ← 加這行隔離 outer setUp 的副作用
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span>    <span class="n">printCenter</span> <span class="o">=</span> <span class="n">PrintCenter</span><span class="p">(</span><span class="n">FakePrinterAdapter</span><span class="p">(</span><span class="s1">&#39;main&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="n">Get</span><span class="p">.</span><span class="n">put</span><span class="p">(</span><span class="n">printCenter</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="p">});</span>
</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">  <span class="n">tearDown</span><span class="p">(()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="n">Get</span><span class="p">.</span><span class="n">reset</span><span class="p">();</span>  <span class="c1">// ← 加這行確保不殘留
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"></span>  <span class="p">});</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>跑 5 次：Run 1 fail、Run 2 pass、Run 3 pass、Run 4 fail、Run 5 fail——<strong>flakiness 比例沒改變</strong>。</p>
<p>修錯了。</p>
<h3 id="重新診斷看-n--1-計數的真正位置">重新診斷：看 <code>+N -1</code> 計數的真正位置</h3>
<p>把 fail 輸出存進檔案、仔細看 progress line 的 <code>+N -1</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">00:08 +125 -1: ... auto_service_config_test.dart: ...
</span></span><span class="line"><span class="ln">2</span><span class="cl">00:08 +126 -1: ... settle_page_order_object_test.dart: SettlePage.orderObject reactivity searchedOrder 變更：badge 立即更新（list 與 selected 都沒命中時）
</span></span><span class="line"><span class="ln">3</span><span class="cl">00:08 +127 -1: ... auto_service_config_test.dart: ...</span></span></code></pre></div><p><code>-1</code> 在第 126 個 test 才第一次出現——失敗的不是 print handler，是中間夾的 <strong>widget test</strong>。再看另一次 fail：</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">00:09 +124 -1: ... settle_page_order_object_test.dart: SettlePage.orderObject reactivity orderList[i] 替換：badge 從「已完成」立即變「退貨」</span></span></code></pre></div><p>不同 run 失敗的 test 不一樣，但都是 <code>settle_page_order_object_test.dart</code> 的不同 case。print handler 的 <code>did not complete</code> 是被牽連、不是源頭。</p>
<h3 id="確認-root-cause單獨跑全綠">確認 root cause：單獨跑全綠</h3>
<p>把 widget test 單獨重複跑 8 次：</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="k">for</span> i in <span class="m">1</span> <span class="m">2</span> <span class="m">3</span> <span class="m">4</span> <span class="m">5</span> <span class="m">6</span> <span class="m">7</span> 8<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  flutter <span class="nb">test</span> test/widgets/settle_page_order_object_test.dart 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="p">|</span> tail -1
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="k">done</span></span></span></code></pre></div><p>8/8 全綠。<strong>單獨跑沒問題、混進全 suite 跑就 flaky</strong>——這是 cross-file pollution 的固定特徵。</p>
<hr>
<h2 id="為什麼-did-not-complete-訊息會誤導">為什麼 <code>did not complete</code> 訊息會誤導</h2>
<p>dart test runner 的失敗訊息設計上有個盲點：</p>
<ul>
<li><code>+N</code> 是累計通過數</li>
<li><code>-N</code> 是累計失敗數</li>
<li><code>did not complete</code> 是某個 test 還沒跑完整體就終止了（process 退出 / 超時 / 前面有未捕捉錯誤導致 runner 提前結束）</li>
</ul>
<p>當前面有 test 失敗、後面的 test 沒機會跑、這些後面的 test 會印 <code>did not complete</code>——但<strong>它們本身沒問題</strong>。看到 <code>did not complete</code> 直覺會想「這個 test 卡住了」、但真實意思更接近「這個 test 還沒跑、上游已掛」。</p>
<p>正確的診斷流程：</p>
<ol>
<li>找 <code>-N</code> 第一次出現的位置（<code>-1</code> 表示第一個失敗）</li>
<li>對照那一行的 test 名稱、那才是真正失敗的源頭</li>
<li><code>did not complete</code> 出現的 test 通常只是受牽連</li>
</ol>
<p>我第一次掉的坑：直接讀 <code>did not complete</code> 的 test 名、跳過了「往前找 <code>-1</code> 第一次出現」這步。</p>
<hr>
<h2 id="為什麼-cross-file-會污染dart-test-runner-與-getx-的不對齊">為什麼 cross-file 會污染：dart test runner 與 GetX 的不對齊</h2>
<h3 id="dart-test-runner-的執行模型">dart test runner 的執行模型</h3>
<p><code>flutter test</code>（背後是 <code>dart test</code>）跑全 suite 時不一定 1 file = 1 isolate。預設行為：</p>
<ul>
<li>多個 test file 可能共用同一個 isolate / Dart VM</li>
<li>共用 isolate 等於共用所有 process-scoped state（static field、singleton、未 GC 的全域物件）</li>
</ul>
<p>並發策略受 <code>--concurrency</code> 與 platform 影響、行為不固定，但「共用 process」是日常常見現象。</p>
<h3 id="getx-的-state-是-process-scoped">GetX 的 state 是 process-scoped</h3>
<p>GetX 的 <code>Get.put</code> / <code>Get.find</code> 把 instance 放進一個 process-global 容器。<code>Get.reset()</code> 清空容器、但有些東西不會被 reset：</p>
<ul>
<li><code>Get.testMode</code> 是 static field、<code>reset()</code> 不動它</li>
<li>如果 instance 在 onInit 內 subscribe 了 stream（例如 <code>BroadcastReceiveService.messages.listen</code>）、<code>Get.reset()</code> 移除 instance reference 但 <strong>subscription 不會自動 cancel</strong></li>
<li>StreamController / Timer / Future.delayed 在 GetX 容器外仍然活著</li>
</ul>
<h3 id="實際發生的污染鏈">實際發生的污染鏈</h3>
<p>跑全 suite 時，假設執行順序是：</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">1. test/services/online_order/...      ← 最前面
</span></span><span class="line"><span class="ln">2</span><span class="cl">2. test/widgets/settle_page_order_...   ← 中間
</span></span><span class="line"><span class="ln">3</span><span class="cl">3. test/services/auth_service_config... ← 後面</span></span></code></pre></div><p>第 1 個 file 的 setUp 若有 <code>Get.put(SomeService())</code>，service 在 onInit 內訂閱了 stream，就算 tearDown 跑了 <code>Get.reset()</code>、那條 stream subscription 仍 active。第 2 個 file 開始跑時：</p>
<ul>
<li>它的 setUp 也呼叫 <code>Get.put(...)</code>、放進去的物件可能是 <strong>完全不同類型</strong> ——但 GetX 容器內可能還有上一輪殘留的物件</li>
<li>第 2 個 file 的 widget test 進入 widget tree、Obx 訂閱、各種 reactive 路徑啟動</li>
<li>上一輪殘留的 stream / timer 此時 fire、進到不該觸及的 state</li>
</ul>
<p>整個 race 在「殘留事件何時 fire vs widget test 何時 expect」之間，所以 flakiness 是 ~50% 而不是 100%。</p>
<hr>
<h2 id="解法setup-開頭主動-reset">解法：setUp 開頭主動 reset</h2>
<p>對任何用 GetX 的 test，setUp 最開頭就該 reset、不要依賴上一個 file 的 tearDown 跑乾淨：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">setUp</span><span class="p">(()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="c1">// 同 process 內跑全 suite 時其他 test file 可能在 GetX 容器留殘留
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"></span>  <span class="c1">// （Get.testMode、未 dispose 的 controller、未 cancel 的 stream subscription），
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span>  <span class="c1">// setUp 開頭主動 reset 切斷 cross-file 污染
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span>  <span class="n">Get</span><span class="p">.</span><span class="n">reset</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="n">Get</span><span class="p">.</span><span class="n">testMode</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="c1">// ... 之後再 Get.put 自己需要的東西
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span><span class="p">});</span>
</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"><span class="n">tearDown</span><span class="p">(()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="n">Get</span><span class="p">.</span><span class="n">reset</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>把這個 pattern 加到所有 widget test 與 controller test 的 setUp 之後，全 suite 連跑 5 次：</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">Run 1: All tests passed!
</span></span><span class="line"><span class="ln">2</span><span class="cl">Run 2: All tests passed!
</span></span><span class="line"><span class="ln">3</span><span class="cl">Run 3: All tests passed!
</span></span><span class="line"><span class="ln">4</span><span class="cl">Run 4: All tests passed!
</span></span><span class="line"><span class="ln">5</span><span class="cl">Run 5: All tests passed!</span></span></code></pre></div><p>5/5 全綠，flakiness 消失。</p>
<h3 id="為什麼-teardown-的-reset-不夠">為什麼 tearDown 的 reset 不夠</h3>
<p>理論上 tearDown 已經 <code>Get.reset()</code> 了，下個 test 的 setUp 看到的應該是乾淨容器——但這個推理在「同 file 內」成立、跨 file 不成立：</p>
<ul>
<li>跨 file 之間 dart test runner 在 file 邊界做的事是不確定的（可能整個 isolate 重啟、也可能只是切換 group）</li>
<li>即使前一個 file 的 tearDown 跑完，跨 file 的某個 microtask / timer callback 仍可能在後一個 file 的 setUp 之前 fire</li>
<li>用 setUp 開頭的 reset 等於再保險一次、把這個邊界內的不確定性吃掉</li>
</ul>
<hr>
<h2 id="除錯思維flaky-test-的固定診斷流程">除錯思維：flaky test 的固定診斷流程</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">1. 看是不是真的 flaky
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">   - 連跑 5~10 次、計算成功率
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">   - 隨機失敗（不是 100% 也不是 0%）→ 進入 flaky 診斷
</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">2. 找真正的失敗源頭
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">   - 看 progress line <span class="sb">`</span>+N -M<span class="sb">`</span>、找 -1 第一次出現位置
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">   - 不要直接讀 <span class="s2">&#34;did not complete&#34;</span>、那是受牽連訊息
</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">3. 判斷是 in-file 還是 cross-file 污染
</span></span><span class="line"><span class="ln">10</span><span class="cl">   - 失敗的 <span class="nb">test</span> 單獨跑：
</span></span><span class="line"><span class="ln">11</span><span class="cl">     - 100% 通過 → cross-file 污染（其他 file 的殘留進來）
</span></span><span class="line"><span class="ln">12</span><span class="cl">     - 也會隨機 fail → in-file 污染（同 file 的 <span class="nb">test</span> 之間互相污染）
</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">4. 補對應的隔離
</span></span><span class="line"><span class="ln">15</span><span class="cl">   - cross-file → setUp 開頭 Get.reset<span class="o">()</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">   - in-file → 看是 setUp/tearDown 沒清乾淨還是 <span class="nb">test</span> 之間共享 mutable state</span></span></code></pre></div><hr>
<h2 id="教訓">教訓</h2>
<ol>
<li><strong><code>did not complete</code> 不是失敗源、是被牽連訊息</strong>——往前找 <code>-1</code> 第一次出現的位置才是真正失敗的 test。</li>
<li><strong>單獨跑通過 + 全 suite fail = cross-file pollution</strong>——這是 flaky test 最常見的固定模式之一、有專屬的解法（setUp reset）、不要當成「資料時序的隨機性」隨便重跑。</li>
<li><strong>tearDown 清不夠、setUp 也要清</strong>——任何用 GetX 的 test 應該在 setUp 開頭主動 <code>Get.reset()</code>、不要依賴上一個 file 的 tearDown。</li>
<li><strong>第一次診斷錯誤是常態、要回到證據</strong>——順著 fail 訊息修是直覺反應、但訊息可能誤導；停下來看計數欄位、單獨跑驗證、才是穩定的診斷方式。</li>
</ol>
<hr>
<h2 id="適用範圍">適用範圍</h2>
<p>這個 pattern 不限於 GetX、適用於任何在 process-scoped global state 註冊東西的框架：</p>
<ul>
<li><code>Provider</code> 的 <code>MultiProvider</code> / 全域 instance</li>
<li><code>Riverpod</code> 的 <code>ProviderContainer</code>（雖然 Riverpod 設計上更鼓勵 per-test container）</li>
<li>自寫的 service locator / singleton</li>
<li>任何 <code>static</code> field 累積的狀態</li>
</ul>
<p>只要框架的 state 跨 test boundary 而 dart test runner 又在同 process 跑多 file，cross-file pollution 都可能發生。setUp 開頭主動 reset 是通用防身術。</p>
<hr>
<h2 id="參考資料">參考資料</h2>
<ul>
<li><a href="https://github.com/dart-lang/test/blob/master/pkgs/test/doc/configuration.md#concurrency">Dart <code>package:test</code> runner concurrency docs</a></li>
<li><a href="https://github.com/jonataslaw/getx">GetX <code>Get.reset()</code> source</a></li>
<li><a href="https://api.flutter.dev/flutter/flutter_test/TestWidgetsFlutterBinding-class.html">Flutter <code>flutter_test</code> binding lifecycle</a></li>
</ul>
]]></content:encoded></item><item><title>Dart StreamController：single-subscription vs broadcast 的設計選型問題</title><link>https://tarrragon.github.io/blog/work-log/dart-streamcontrollersingle-subscription-vs-broadcast-%E7%9A%84%E8%A8%AD%E8%A8%88%E9%81%B8%E5%9E%8B%E5%95%8F%E9%A1%8C/</link><pubDate>Tue, 05 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/dart-streamcontrollersingle-subscription-vs-broadcast-%E7%9A%84%E8%A8%AD%E8%A8%88%E9%81%B8%E5%9E%8B%E5%95%8F%E9%A1%8C/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>事故類型&lt;/strong>：潛伏型設計缺陷、第二個訂閱者出現時才暴露
&lt;strong>症狀&lt;/strong>：&lt;code>Bad state: Stream has already been listened to.&lt;/code>
&lt;strong>根因&lt;/strong>：在「&lt;code>StreamController()&lt;/code> vs &lt;code>StreamController.broadcast()&lt;/code>」這個零成本差異的選擇下、選了限制更高的單訂閱版本——當下只有一個訂閱者、限制沒曝光；新增第二個訂閱者就觸發底層型別契約。設計缺陷的本質是「&lt;strong>在零成本差異下不必要地縮小了未來空間&lt;/strong>」、不是「沒預測到後來需求」。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="事故場景">事故場景&lt;/h2>
&lt;h3 id="業務背景pos-的多視角狀態同步">業務背景：POS 的多視角狀態同步&lt;/h3>
&lt;p>POS 系統本質上是「&lt;strong>單一交易狀態 + 多個視角同步呈現&lt;/strong>」。一筆購物車的變化通常要立刻反映到：&lt;/p>
&lt;ul>
&lt;li>收銀員操作的主螢幕&lt;/li>
&lt;li>給顧客看的副螢幕（純顯示，看商品、總價、找零）&lt;/li>
&lt;li>廚房或後場的出餐顯示&lt;/li>
&lt;li>列印機（結帳當下觸發）&lt;/li>
&lt;li>雲端同步、報表、會員紀錄&lt;/li>
&lt;/ul>
&lt;p>這些視角各自關心交易狀態的不同切面，但&lt;strong>都需要在狀態變動的當下被通知&lt;/strong>。在系統設計上，這是個典型的「一個資料源、多個訂閱者」場景，本質就是事件廣播。&lt;/p>
&lt;h3 id="原始設計一個事件來源一個訂閱者">原始設計：一個事件來源，一個訂閱者&lt;/h3>
&lt;p>實作初期，「需要訂閱購物車變動」的角色只有一個——副螢幕。副螢幕在 app 啟動時就訂閱、整個 app 生命週期都在聽，純粹做主畫面的鏡像顯示。&lt;/p>
&lt;p>於是負責提供「狀態變更通知」的 service 用了 dart:async 預設的 &lt;code>StreamController&lt;/code> 對外發事件。事件 payload 設計成兩段資訊：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>當前完整商品列表&lt;/strong>（給副螢幕這類「鏡像當前狀態」的訂閱者用）&lt;/li>
&lt;li>&lt;strong>這次變動的具體品項&lt;/strong>（移除或清空時為 null，預留給「需要知道改了哪一筆」的訂閱者）&lt;/li>
&lt;/ol>
&lt;p>第二段資訊當下沒人用，但 service 設計者保留了它，理由是「未來如果有訂閱者需要知道每次具體變動是什麼，不必再改介面」——一個合理的擴充性設計。&lt;/p>
&lt;p>幾個月過去，這條 stream 只有副螢幕一個訂閱者，運作正常。&lt;/p>
&lt;h3 id="新需求操作體驗優化">新需求：操作體驗優化&lt;/h3>
&lt;p>新需求出現：收銀員在尖峰時段連續掃商品，&lt;strong>畫面更新太快會分不清剛剛動到的是哪一筆&lt;/strong>。如果是改價、改數量這類修改更明顯——數字突然變了，但視線焦點不在那一行就會錯過。&lt;/p>
&lt;p>業務上希望：每次操作後，被改動的那一行在 UI 上有個視覺標記（高亮、邊框或角標都可），讓收銀員一眼確認剛剛動的是對的品項。標記停在最後一次操作的那行，直到下一次操作才轉移。&lt;/p>
&lt;p>這個需求對應 service 已經備妥但尚未被消費的資訊——service 對外的事件 payload 從原始設計就分兩段：一段是「當前完整的商品列表」、另一段是「這次變動的具體品項」。第二段是當初為「需要追蹤單筆變動的訂閱者」預留的擴充欄位、過去幾個月一直沒被消費。新需求只要新增一個訂閱者讀這段資訊、再把它對應到 UI 上的視覺標記即可——介面不需要變動、payload 結構不需要調整、實作範圍只限於新增訂閱端。&lt;/p>
&lt;h3 id="第二個訂閱者觸發底層限制">第二個訂閱者觸發底層限制&lt;/h3>
&lt;p>第二個訂閱者寫好、進入收銀頁面當下就 throw：&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">The following StateError was thrown building Obx(...):
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">Bad state: Stream has already been listened to.&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>第一反應通常是「我哪裡寫錯了 / 是不是哪邊忘了 cancel」。檢查程式碼會發現新訂閱者寫得沒問題，副螢幕的訂閱也沒問題——&lt;strong>問題在底層 stream 的型別契約：整個生命週期內只允許被 listen 一次&lt;/strong>。&lt;/p>
&lt;p>這是 &lt;code>StreamController()&lt;/code> 預設建構子的契約：建立的是 single-subscription stream、生命週期內最多承載&lt;strong>一個&lt;/strong> listener。副螢幕第一個訂閱後佔據了唯一的 listener 位置；新加第二個訂閱者直接違反契約、執行期 throw。&lt;/p>
&lt;p>更深一層的觀察是設計層面的不一致：業務需求一直具備廣播語義（多個視角同步呈現）、技術選型卻是「單一管線」的工具。需求初期只有一個訂閱者讓限制沒有可見的影響、但限制一直存在於型別契約裡。第二個訂閱者只是觸發條件、不是根因。&lt;/p>
&lt;hr>
&lt;h2 id="兩種-streamcontroller-的核心差異">兩種 StreamController 的核心差異&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>&lt;code>StreamController()&lt;/code>（單訂閱）&lt;/th>
 &lt;th>&lt;code>StreamController.broadcast()&lt;/code>&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>同時 listener 數&lt;/td>
 &lt;td>至多 1 個&lt;/td>
 &lt;td>任意&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>第二個 &lt;code>.listen()&lt;/code>&lt;/td>
 &lt;td>throw &lt;code>Bad state&lt;/code>&lt;/td>
 &lt;td>OK&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>listener cancel 後重新 listen&lt;/td>
 &lt;td>throw &lt;code>Bad state&lt;/code>&lt;/td>
 &lt;td>OK&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>無 listener 時 add 的事件&lt;/td>
 &lt;td>&lt;strong>buffer&lt;/strong>，listener 出現時補送&lt;/td>
 &lt;td>&lt;strong>直接丟棄&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>listener &lt;code>pause()&lt;/code> 行為&lt;/td>
 &lt;td>整個 stream 暫停（上游也卡）&lt;/td>
 &lt;td>對其他 listener 無影響&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>適用語義&lt;/td>
 &lt;td>資料管線（單一消費者）&lt;/td>
 &lt;td>事件佈告欄（多消費者）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="三組行為差異的程式碼驗證">三組行為差異的程式碼驗證&lt;/h2>
&lt;h3 id="1-重複監聽">1. 重複監聽&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">final&lt;/span> &lt;span class="n">c&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">StreamController&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="kt">int&lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="p">();&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">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">stream&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">listen&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">print&lt;/span>&lt;span class="p">);&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">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">stream&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">listen&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">print&lt;/span>&lt;span class="p">);&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">// 錯誤：Bad state: Stream has already been listened to.
&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="kd">final&lt;/span> &lt;span class="n">b&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">StreamController&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="kt">int&lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">broadcast&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="n">b&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">stream&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">listen&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">v&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;A: &lt;/span>&lt;span class="si">$&lt;/span>&lt;span class="n">v&lt;/span>&lt;span class="s1">&amp;#39;&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="n">b&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">stream&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">listen&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">v&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;B: &lt;/span>&lt;span class="si">$&lt;/span>&lt;span class="n">v&lt;/span>&lt;span class="s1">&amp;#39;&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="n">b&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="c1">// A: 1
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="o">//&lt;/span> &lt;span class="nl">B:&lt;/span> &lt;span class="m">1&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>值得注意的不只是「不能同時兩個 listener」——單訂閱 stream 的限制是&lt;strong>整個 lifecycle 只能 listen 一次&lt;/strong>。即使第一個 listener 已經 &lt;code>cancel()&lt;/code>、再呼叫 &lt;code>.listen()&lt;/code> 仍會違反契約 throw。要重新訂閱必須重建 &lt;code>StreamController&lt;/code>。&lt;/p>
&lt;p>對 POS 場景的意義：副螢幕服務在 app 啟動時就建立訂閱、且不會 cancel——換句話說、stream 在啟動時就把唯一的 listener 配額分配給副螢幕、之後沒有可釋出的空間。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>事故類型</strong>：潛伏型設計缺陷、第二個訂閱者出現時才暴露
<strong>症狀</strong>：<code>Bad state: Stream has already been listened to.</code>
<strong>根因</strong>：在「<code>StreamController()</code> vs <code>StreamController.broadcast()</code>」這個零成本差異的選擇下、選了限制更高的單訂閱版本——當下只有一個訂閱者、限制沒曝光；新增第二個訂閱者就觸發底層型別契約。設計缺陷的本質是「<strong>在零成本差異下不必要地縮小了未來空間</strong>」、不是「沒預測到後來需求」。</p></blockquote>
<hr>
<h2 id="事故場景">事故場景</h2>
<h3 id="業務背景pos-的多視角狀態同步">業務背景：POS 的多視角狀態同步</h3>
<p>POS 系統本質上是「<strong>單一交易狀態 + 多個視角同步呈現</strong>」。一筆購物車的變化通常要立刻反映到：</p>
<ul>
<li>收銀員操作的主螢幕</li>
<li>給顧客看的副螢幕（純顯示，看商品、總價、找零）</li>
<li>廚房或後場的出餐顯示</li>
<li>列印機（結帳當下觸發）</li>
<li>雲端同步、報表、會員紀錄</li>
</ul>
<p>這些視角各自關心交易狀態的不同切面，但<strong>都需要在狀態變動的當下被通知</strong>。在系統設計上，這是個典型的「一個資料源、多個訂閱者」場景，本質就是事件廣播。</p>
<h3 id="原始設計一個事件來源一個訂閱者">原始設計：一個事件來源，一個訂閱者</h3>
<p>實作初期，「需要訂閱購物車變動」的角色只有一個——副螢幕。副螢幕在 app 啟動時就訂閱、整個 app 生命週期都在聽，純粹做主畫面的鏡像顯示。</p>
<p>於是負責提供「狀態變更通知」的 service 用了 dart:async 預設的 <code>StreamController</code> 對外發事件。事件 payload 設計成兩段資訊：</p>
<ol>
<li><strong>當前完整商品列表</strong>（給副螢幕這類「鏡像當前狀態」的訂閱者用）</li>
<li><strong>這次變動的具體品項</strong>（移除或清空時為 null，預留給「需要知道改了哪一筆」的訂閱者）</li>
</ol>
<p>第二段資訊當下沒人用，但 service 設計者保留了它，理由是「未來如果有訂閱者需要知道每次具體變動是什麼，不必再改介面」——一個合理的擴充性設計。</p>
<p>幾個月過去，這條 stream 只有副螢幕一個訂閱者，運作正常。</p>
<h3 id="新需求操作體驗優化">新需求：操作體驗優化</h3>
<p>新需求出現：收銀員在尖峰時段連續掃商品，<strong>畫面更新太快會分不清剛剛動到的是哪一筆</strong>。如果是改價、改數量這類修改更明顯——數字突然變了，但視線焦點不在那一行就會錯過。</p>
<p>業務上希望：每次操作後，被改動的那一行在 UI 上有個視覺標記（高亮、邊框或角標都可），讓收銀員一眼確認剛剛動的是對的品項。標記停在最後一次操作的那行，直到下一次操作才轉移。</p>
<p>這個需求對應 service 已經備妥但尚未被消費的資訊——service 對外的事件 payload 從原始設計就分兩段：一段是「當前完整的商品列表」、另一段是「這次變動的具體品項」。第二段是當初為「需要追蹤單筆變動的訂閱者」預留的擴充欄位、過去幾個月一直沒被消費。新需求只要新增一個訂閱者讀這段資訊、再把它對應到 UI 上的視覺標記即可——介面不需要變動、payload 結構不需要調整、實作範圍只限於新增訂閱端。</p>
<h3 id="第二個訂閱者觸發底層限制">第二個訂閱者觸發底層限制</h3>
<p>第二個訂閱者寫好、進入收銀頁面當下就 throw：</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">The following StateError was thrown building Obx(...):
</span></span><span class="line"><span class="ln">2</span><span class="cl">Bad state: Stream has already been listened to.</span></span></code></pre></div><p>第一反應通常是「我哪裡寫錯了 / 是不是哪邊忘了 cancel」。檢查程式碼會發現新訂閱者寫得沒問題，副螢幕的訂閱也沒問題——<strong>問題在底層 stream 的型別契約：整個生命週期內只允許被 listen 一次</strong>。</p>
<p>這是 <code>StreamController()</code> 預設建構子的契約：建立的是 single-subscription stream、生命週期內最多承載<strong>一個</strong> listener。副螢幕第一個訂閱後佔據了唯一的 listener 位置；新加第二個訂閱者直接違反契約、執行期 throw。</p>
<p>更深一層的觀察是設計層面的不一致：業務需求一直具備廣播語義（多個視角同步呈現）、技術選型卻是「單一管線」的工具。需求初期只有一個訂閱者讓限制沒有可見的影響、但限制一直存在於型別契約裡。第二個訂閱者只是觸發條件、不是根因。</p>
<hr>
<h2 id="兩種-streamcontroller-的核心差異">兩種 StreamController 的核心差異</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th><code>StreamController()</code>（單訂閱）</th>
          <th><code>StreamController.broadcast()</code></th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>同時 listener 數</td>
          <td>至多 1 個</td>
          <td>任意</td>
      </tr>
      <tr>
          <td>第二個 <code>.listen()</code></td>
          <td>throw <code>Bad state</code></td>
          <td>OK</td>
      </tr>
      <tr>
          <td>listener cancel 後重新 listen</td>
          <td>throw <code>Bad state</code></td>
          <td>OK</td>
      </tr>
      <tr>
          <td>無 listener 時 add 的事件</td>
          <td><strong>buffer</strong>，listener 出現時補送</td>
          <td><strong>直接丟棄</strong></td>
      </tr>
      <tr>
          <td>listener <code>pause()</code> 行為</td>
          <td>整個 stream 暫停（上游也卡）</td>
          <td>對其他 listener 無影響</td>
      </tr>
      <tr>
          <td>適用語義</td>
          <td>資料管線（單一消費者）</td>
          <td>事件佈告欄（多消費者）</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="三組行為差異的程式碼驗證">三組行為差異的程式碼驗證</h2>
<h3 id="1-重複監聽">1. 重複監聽</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">final</span> <span class="n">c</span> <span class="o">=</span> <span class="n">StreamController</span><span class="o">&lt;</span><span class="kt">int</span><span class="o">&gt;</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">c</span><span class="p">.</span><span class="n">stream</span><span class="p">.</span><span class="n">listen</span><span class="p">(</span><span class="n">print</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">c</span><span class="p">.</span><span class="n">stream</span><span class="p">.</span><span class="n">listen</span><span class="p">(</span><span class="n">print</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1">// 錯誤：Bad state: Stream has already been listened to.
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="kd">final</span> <span class="n">b</span> <span class="o">=</span> <span class="n">StreamController</span><span class="o">&lt;</span><span class="kt">int</span><span class="o">&gt;</span><span class="p">.</span><span class="n">broadcast</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">b</span><span class="p">.</span><span class="n">stream</span><span class="p">.</span><span class="n">listen</span><span class="p">((</span><span class="n">v</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">print</span><span class="p">(</span><span class="s1">&#39;A: </span><span class="si">$</span><span class="n">v</span><span class="s1">&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">b</span><span class="p">.</span><span class="n">stream</span><span class="p">.</span><span class="n">listen</span><span class="p">((</span><span class="n">v</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">print</span><span class="p">(</span><span class="s1">&#39;B: </span><span class="si">$</span><span class="n">v</span><span class="s1">&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">b</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="m">1</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1">// A: 1
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="o">//</span> <span class="nl">B:</span> <span class="m">1</span></span></span></code></pre></div><p>值得注意的不只是「不能同時兩個 listener」——單訂閱 stream 的限制是<strong>整個 lifecycle 只能 listen 一次</strong>。即使第一個 listener 已經 <code>cancel()</code>、再呼叫 <code>.listen()</code> 仍會違反契約 throw。要重新訂閱必須重建 <code>StreamController</code>。</p>
<p>對 POS 場景的意義：副螢幕服務在 app 啟動時就建立訂閱、且不會 cancel——換句話說、stream 在啟動時就把唯一的 listener 配額分配給副螢幕、之後沒有可釋出的空間。</p>
<h3 id="2-監聽前的事件處理">2. 監聽前的事件處理</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">final</span> <span class="n">single</span> <span class="o">=</span> <span class="n">StreamController</span><span class="o">&lt;</span><span class="kt">int</span><span class="o">&gt;</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">single</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="m">1</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">single</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="m">2</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1">// 此時還沒有 listener
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="n">single</span><span class="p">.</span><span class="n">stream</span><span class="p">.</span><span class="n">listen</span><span class="p">(</span><span class="n">print</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">single</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="m">3</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1">// 輸出：1, 2, 3 ← 之前的事件被 buffer，listener 接上後補送
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">final</span> <span class="n">broadcast</span> <span class="o">=</span> <span class="n">StreamController</span><span class="o">&lt;</span><span class="kt">int</span><span class="o">&gt;</span><span class="p">.</span><span class="n">broadcast</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">broadcast</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="m">1</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">broadcast</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="m">2</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1">// 此時還沒有 listener
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"></span><span class="n">broadcast</span><span class="p">.</span><span class="n">stream</span><span class="p">.</span><span class="n">listen</span><span class="p">(</span><span class="n">print</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="n">broadcast</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="m">3</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="o">//</span> <span class="err">輸出：</span><span class="m">3</span> <span class="err">←</span> <span class="err">監聽前的事件全部丟掉</span></span></span></code></pre></div><p>這個差異對應用設計的影響：</p>
<ul>
<li><strong>單訂閱</strong>保證 listener 不漏接，適合「資料完整性 &gt; 即時性」（檔案讀取、計算結果序列）</li>
<li><strong>broadcast</strong> 不保留歷史，適合「即時性 &gt; 完整性」（UI 事件、狀態變更通知）</li>
</ul>
<p>如果改成 broadcast 後，希望「新訂閱者進場時能拿到一次當下的狀態」（例如 controller 進場時想知道當前購物車內容），broadcast 本身做不到，要靠 service 自己保留 <code>latest</code> 或在新訂閱時手動 push 一次。RxDart 的 <code>BehaviorSubject</code> 內建這行為，純 dart:async 沒有。</p>
<p>對 POS 案例：sticky 高亮只關心未來變更，<strong>不在意歷史事件</strong>——broadcast 的丟棄行為跟這個語義一致、不造成資料缺失。但如果是「副螢幕鏡像當前購物車」這種需求，新副螢幕插入時若需要立即顯示當下狀態，就要在訂閱後手動 read 一次 <code>cart.items</code>。</p>
<h3 id="3-pause-行為最反直覺">3. Pause 行為（最反直覺）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">final</span> <span class="n">single</span> <span class="o">=</span> <span class="n">StreamController</span><span class="o">&lt;</span><span class="kt">int</span><span class="o">&gt;</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kd">final</span> <span class="n">sub</span> <span class="o">=</span> <span class="n">single</span><span class="p">.</span><span class="n">stream</span><span class="p">.</span><span class="n">listen</span><span class="p">(</span><span class="n">print</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">sub</span><span class="p">.</span><span class="n">pause</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">single</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="m">1</span><span class="p">);</span>  <span class="c1">// 不會立刻送出
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="n">sub</span><span class="p">.</span><span class="n">resume</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="o">//</span> <span class="err">輸出：</span><span class="m">1</span> <span class="err">←</span> <span class="err">暫停期間的事件</span> <span class="n">resume</span> <span class="err">後補送</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">final</span> <span class="n">broadcast</span> <span class="o">=</span> <span class="n">StreamController</span><span class="o">&lt;</span><span class="kt">int</span><span class="o">&gt;</span><span class="p">.</span><span class="n">broadcast</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kd">final</span> <span class="n">subA</span> <span class="o">=</span> <span class="n">broadcast</span><span class="p">.</span><span class="n">stream</span><span class="p">.</span><span class="n">listen</span><span class="p">((</span><span class="n">v</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">print</span><span class="p">(</span><span class="s1">&#39;A: </span><span class="si">$</span><span class="n">v</span><span class="s1">&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="kd">final</span> <span class="n">subB</span> <span class="o">=</span> <span class="n">broadcast</span><span class="p">.</span><span class="n">stream</span><span class="p">.</span><span class="n">listen</span><span class="p">((</span><span class="n">v</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">print</span><span class="p">(</span><span class="s1">&#39;B: </span><span class="si">$</span><span class="n">v</span><span class="s1">&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">subA</span><span class="p">.</span><span class="n">pause</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="n">broadcast</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="m">1</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1">// 輸出：B: 1   ← B 照收，A 暫存
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span><span class="n">subA</span><span class="p">.</span><span class="n">resume</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="o">//</span> <span class="err">輸出：</span><span class="nl">A:</span> <span class="m">1</span>   <span class="err">←</span> <span class="n">A</span> <span class="n">resume</span> <span class="err">後補回</span></span></span></code></pre></div><p>單訂閱的 pause 等於「整條管線暫停」，上游 add 的資料堆在 controller 內部、記憶體會漲。Broadcast 是 per-listener 暫停，互不影響。</p>
<p>POS 的副螢幕場景如果搭配無界事件源（例如背景條碼掃描器）、用單訂閱且某條路徑沒 resume、<strong>會在 controller 內部累積未送出的事件、記憶體佔用持續上升</strong>——這是 production OOM 的常見來源之一。</p>
<hr>
<h2 id="設計缺陷為什麼在初期沒有可見影響">設計缺陷為什麼在初期沒有可見影響</h2>
<h3 id="訂閱者單一時限制處於沉默狀態">訂閱者單一時、限制處於沉默狀態</h3>
<p>副螢幕訂閱寫在 service 啟動時、屬於 app lifetime 訂閱、沒有 cancel / 重新訂閱的情境。在這個訂閱模式下：</p>
<ol>
<li>副螢幕第一個訂閱 → 佔據 single-subscription 的「唯一 listener」配額</li>
<li>沒有第二個訂閱方 → 違反契約的條件不會出現</li>
<li>限制存在於型別契約裡、但沒有可見的影響</li>
</ol>
<p>當訂閱者擴增到第二個時、<strong>這條 stream 的型別契約「整個生命週期只承載 1 個 listener」才開始產生可見的執行期影響</strong>。注意這裡描述的是「<strong>契約一直存在、只是沒有觸發違反條件</strong>」——不是「契約因為新需求才變成限制」。型別契約是當下選擇 <code>StreamController()</code> 時就確定的、訂閱者數量只決定它何時被觸發。</p>
<h3 id="設計缺陷-vs-需求演化的分界">設計缺陷 vs 需求演化的分界</h3>
<p>但「為什麼能算設計缺陷」這個問題值得停下來釐清——當下只有一個訂閱者、需求變了才需要多訂閱、這聽起來不像是「設計缺陷」、更像是「需求演化」。兩者怎麼分？</p>
<p>關鍵不是「<strong>有沒有預測到後來的需求</strong>」、是「<strong>當下的選擇是否在零成本差異下不必要地縮小了未來空間</strong>」：</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>算什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>當下零成本差、選了限制更高的選項（本 case：single 的 11 字元差）</td>
          <td><strong>設計缺陷</strong></td>
      </tr>
      <tr>
          <td>當下高成本差、選了便宜的、後來需求變了（如「沒先建 plugin 系統」）</td>
          <td><strong>需求演化、非缺陷</strong></td>
      </tr>
      <tr>
          <td>當下零成本差、選了通用的、後來真的不需要</td>
          <td>中性、額外彈性留著</td>
      </tr>
      <tr>
          <td>當下高成本差、為「可能的未來」付了昂貴成本</td>
          <td><strong>過度設計</strong></td>
      </tr>
  </tbody>
</table>
<p>本 case 落在第一格——<code>StreamController()</code> vs <code>StreamController.broadcast()</code> 是 11 字元差、零認知負擔、零維護成本差異。即使當下只有副螢幕一個訂閱者、選 broadcast 也沒付任何代價、卻保留了未來的彈性。寫成 single 不是「對當下需求的精確匹配」、是<strong>在零成本差異下不必要地縮小了未來空間</strong>——這才是「設計缺陷」這個詞要描述的事。</p>
<p>加上 POS 系統的領域先驗強烈指向「多視角同步」（主螢幕 / 副螢幕 / 廚顯 / 雲端 / 列印是教科書級的 pub-sub 場景）、選 single-subscription 等於假設「這個 service 不會有多訂閱需求」——這個假設跟領域常識矛盾、即使在當下也站不住。</p>
<blockquote>
<p>「成本對稱性 / 可逆性 / 領域先驗」三軸框架的完整推導見 <a href="/blog/record/%E8%A8%AD%E8%A8%88%E7%91%95%E7%96%B5%E9%82%84%E6%98%AF%E9%81%BF%E5%85%8D%E9%81%8E%E5%BA%A6%E8%A8%AD%E8%A8%88yagni-%E7%9A%84%E7%9C%9F%E5%AF%A6%E9%81%A9%E7%94%A8%E6%A2%9D%E4%BB%B6/" data-link-title="設計瑕疵還是避免過度設計？YAGNI 的真實適用條件" data-link-desc="YAGNI 不是「永遠選最受限選項」、是「不為未來投入額外成本」的原則。用成本對稱性、可逆性、領域先驗三軸框架釐清「該選通用 default」與「該避免過度設計」的邊界、並補上 review checklist、架構規範、領域先驗清單三層制度補強。">設計瑕疵還是避免過度設計？YAGNI 的真實適用條件</a>——本 case 三軸都指向 broadcast、屬於 YAGNI 不適用的標準情境。</p></blockquote>
<h3 id="為什麼-ide-與測試抓不到">為什麼 IDE 與測試抓不到</h3>
<ul>
<li><strong>Dart 編譯器</strong>：型別簽章一樣（<code>Stream&lt;T&gt;</code>），編譯不會錯</li>
<li><strong>靜態分析</strong>：<code>dart analyze</code> 不會警告 single-subscription 用法的潛在風險</li>
<li><strong>單元測試</strong>：通常 mock 整條 stream，不會驗證真實 controller 是不是支援多訂閱</li>
<li><strong>Widget test</strong>：只跑單一頁面，不會同時掛多個訂閱模組</li>
<li><strong>整合測試</strong>：理論上能抓，但成本高，多數專案在這層覆蓋稀疏</li>
</ul>
<p>要在事前抓到，可行的方式：</p>
<ul>
<li><strong>Lint rule</strong>：自訂規則檢查 <code>StreamController()</code> 預設用法，要求加註解說明「為何刻意不用 broadcast」</li>
<li><strong>Code review checklist</strong>：service 對外暴露 stream 時，預設假設要 broadcast，single 必須有書面理由</li>
<li><strong>架構規範</strong>：直接禁用 raw <code>StreamController</code> 在 service 層，強制透過框架的廣播原語（<code>Rx</code>, <code>BehaviorSubject</code>, <code>ValueNotifier</code>）</li>
</ul>
<hr>
<h2 id="修復決策過程">修復決策過程</h2>
<h3 id="選項列舉">選項列舉</h3>
<p>事故當下的選項：</p>
<table>
  <thead>
      <tr>
          <th>選項</th>
          <th>改動範圍</th>
          <th>風險</th>
          <th>適用條件</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>A. 改成 <code>.broadcast()</code></td>
          <td>service 一行</td>
          <td>低</td>
          <td>多訂閱本來就合理</td>
      </tr>
      <tr>
          <td>B. 第二個訂閱者透過第一個轉送</td>
          <td>副螢幕服務變成 hub</td>
          <td>高，副螢幕不該知道 sticky 高亮</td>
          <td>第二個需求是第一個的 strict subset</td>
      </tr>
      <tr>
          <td>C. 新加一條平行 broadcast stream</td>
          <td>service 增 API</td>
          <td>中</td>
          <td>兩訂閱關心不同維度</td>
      </tr>
      <tr>
          <td>D. 改用框架的廣播原語（<code>Rx</code>、<code>Subject</code>）</td>
          <td>service 介面變動</td>
          <td>中</td>
          <td>系統性重構契機</td>
      </tr>
  </tbody>
</table>
<h3 id="為什麼選-a">為什麼選 A</h3>
<p>POS 的這條 stream 語義就是「購物車狀態變更廣播」、多訂閱者本來就符合領域模型。選 B 會讓副螢幕服務變成轉發中樞、跟它「純顯示」的職責衝突。選 C 增加重複資料源、未來容易兩條 stream 不同步。選 D 雖然在架構層更一致、但 scope 過大、不是事故當下適合做的決定。</p>
<p>A 是改一行的 minimal fix，且<strong>修正了原本的設計缺陷</strong>而不是繞過它。</p>
<h3 id="容易漏的細節mock-也要改">容易漏的細節：mock 也要改</h3>
<p>Service 如果有 mock 實作（測試替身）、mock 端也要同步改成 broadcast。否則會出現「測試環境通過、production 仍然 throw」的不對齊狀況——單元測試（注入 mock）跟 production（真實 service）使用不同的 stream 契約、限制沒被測試覆蓋。</p>
<p>這是「測試環境與 production 配置不對齊」的典型陷阱。事故當下要把「修真實實作」「修 mock」當成同一件事的兩個必做動作，分開做就會漏。比較好的長期策略是把這個約束放進 code review checklist，或在 service 介面層加註解註明「實作不論真假都必須是 broadcast 語義」。</p>
<h3 id="還要檢查所有寫入路徑都有完整-emit">還要檢查：所有寫入路徑都有完整 emit</h3>
<p>事故修復不只是改 stream 類型，還要回頭審視「事件 payload 的完整性」。</p>
<p>回到事故場景：事件 payload 第二段（這次變動是哪筆）原本沒人用，所以幾個寫入路徑可能根本沒傳。副螢幕只看第一段（完整列表），傳不傳第二段對它沒差。<strong>只有第二個訂閱者開始消費這段資訊時，遺漏才會暴露</strong>。</p>
<p>這是廣播設計的一個系統性風險：<strong>service 提供「為未來訂閱者保留」的擴充欄位時、這些欄位若沒有當下的消費者、缺漏不會在測試中浮現</strong>。第一個真正使用該欄位的訂閱者出現後、才會暴露出某些 mutation 路徑沒填寫該欄位。</p>
<p>修復清單：</p>
<ul>
<li><input disabled="" type="checkbox"> 把 single-subscription 改成 broadcast（真實實作 + mock 雙改）</li>
<li><input disabled="" type="checkbox"> 審視所有寫入路徑，確保事件 payload 的每個欄位都正確填寫</li>
<li><input disabled="" type="checkbox"> 確認第二個訂閱者的 dispose / cancel 邏輯</li>
<li><input disabled="" type="checkbox"> 訂閱者進場時若需要「當下狀態」，要補一次直接讀取（broadcast 不保留歷史）</li>
</ul>
<hr>
<h2 id="何時該選哪個">何時該選哪個</h2>
<h3 id="選-streamcontroller-的情境">選 <code>StreamController()</code> 的情境</h3>
<ul>
<li>確定<strong>只有一個消費者</strong>，且這個契約被寫進文件 / 介面註解</li>
<li>需要保證<strong>每個事件都被消費</strong>（buffer 是 feature）</li>
<li>像 Future 但會發多個值：檔案讀取、HTTP response body chunks、long-running task 進度回報</li>
</ul>
<h3 id="選-streamcontrollerbroadcast-的情境">選 <code>StreamController.broadcast()</code> 的情境</h3>
<ul>
<li>有<strong>多個訂閱者</strong>，或不確定未來會不會多</li>
<li>事件是「正在發生」的通知，<strong>錯過就算了</strong>（UI 事件、狀態變更廣播、event bus、application-level domain events）</li>
<li>不在意進場前的歷史事件（如果在意，自己保留 <code>latestValue</code>）</li>
</ul>
<h3 id="一個粗略的決策法">一個粗略的決策法</h3>
<blockquote>
<p>「如果某天有人想加第二個 listener，這在語義上合理嗎？」</p>
<ul>
<li>合理 → 一開始就用 broadcast</li>
<li>不合理 → 用單訂閱，並在註解寫清楚為什麼</li>
</ul></blockquote>
<p>應用層的 service 通知絕大多數情境都偏向 broadcast；single-subscription 的甜蜜點在底層 I/O 或一次性 task 進度（兩者都有「單一消費者 + 不能漏接」的明確契約）。</p>
<p>對 POS 場景：service 對外暴露的「狀態變更通知」幾乎都落在 broadcast 區——POS 的本質就是多裝置 / 多視圖共享同一份交易狀態（主螢幕、副螢幕、廚顯、雲端、列印機）。</p>
<hr>
<h2 id="補救與替代方案">補救與替代方案</h2>
<h3 id="已有-single-subscription-stream想對外提供-broadcast">已有 single-subscription stream，想對外提供 broadcast</h3>
<p>不用改 controller 類型，可以包一層：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">final</span> <span class="n">singleStream</span> <span class="o">=</span> <span class="n">someController</span><span class="p">.</span><span class="n">stream</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kd">final</span> <span class="n">broadcastView</span> <span class="o">=</span> <span class="n">singleStream</span><span class="p">.</span><span class="n">asBroadcastStream</span><span class="p">();</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="o">//</span> <span class="err">對外公開</span> <span class="n">broadcastView</span><span class="err">，原本的</span> <span class="n">singleStream</span> <span class="err">內部仍是</span> <span class="n">single</span><span class="o">-</span><span class="n">subscription</span></span></span></code></pre></div><p><code>asBroadcastStream()</code> 把單訂閱當 source，對外提供 broadcast view。一旦呼叫過一次，後續訂閱者都拿這個 view。</p>
<p>注意：這個方法只能呼叫<strong>一次</strong>、第二次會 throw。實務上要保留回傳值在 service 內部做 cache。</p>
<h3 id="想要broadcast--新訂閱拿最後一次值">想要「broadcast + 新訂閱拿最後一次值」</h3>
<p>標準 <code>dart:async</code> 沒有這功能。要嘛自己實作：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">class</span> <span class="nc">ReplayLastNotifier</span><span class="o">&lt;</span><span class="n">T</span><span class="o">&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kd">final</span> <span class="n">_controller</span> <span class="o">=</span> <span class="n">StreamController</span><span class="o">&lt;</span><span class="n">T</span><span class="o">&gt;</span><span class="p">.</span><span class="n">broadcast</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="n">T</span><span class="o">?</span> <span class="n">_latest</span><span class="p">;</span>
</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 class="n">Stream</span><span class="o">&lt;</span><span class="n">T</span><span class="o">&gt;</span> <span class="kd">get</span> <span class="n">stream</span> <span class="kd">async</span><span class="o">*</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">_latest</span> <span class="o">!=</span> <span class="kc">null</span><span class="p">)</span> <span class="kd">yield</span> <span class="n">_latest</span> <span class="o">as</span> <span class="n">T</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="kd">yield</span><span class="o">*</span> <span class="n">_controller</span><span class="p">.</span><span class="n">stream</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="p">}</span>
</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">  <span class="kt">void</span> <span class="n">add</span><span class="p">(</span><span class="n">T</span> <span class="n">value</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="n">_latest</span> <span class="o">=</span> <span class="n">value</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="n">_controller</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="n">value</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>要嘛用 RxDart 的 <code>BehaviorSubject</code>，內建這行為。POS 副螢幕鏡像場景特別適合 <code>BehaviorSubject</code>：副螢幕進場時就能立即看到當下購物車內容，不必等下一次變更。</p>
<h3 id="flutter-生態系的替代">Flutter 生態系的替代</h3>
<p>純 <code>StreamController</code> 在 Flutter app 層比較少見，更常用的是：</p>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>廣播語義</th>
          <th>內建保留最後值</th>
          <th>備註</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>ValueNotifier&lt;T&gt;</code></td>
          <td>是</td>
          <td>是</td>
          <td>適合單一值狀態</td>
      </tr>
      <tr>
          <td><code>ChangeNotifier</code></td>
          <td>是</td>
          <td>N/A（無資料傳遞）</td>
          <td>訂閱者自己讀狀態</td>
      </tr>
      <tr>
          <td><code>Rx&lt;T&gt;</code>（GetX）</td>
          <td>是</td>
          <td>是</td>
          <td><code>.listen()</code> / <code>ever()</code></td>
      </tr>
      <tr>
          <td><code>BehaviorSubject</code>（RxDart）</td>
          <td>是</td>
          <td>是</td>
          <td>API 接近原生 stream</td>
      </tr>
      <tr>
          <td><code>StateNotifier</code>（Riverpod）</td>
          <td>是</td>
          <td>是</td>
          <td>不可變狀態風格</td>
      </tr>
  </tbody>
</table>
<p>如果你已經在用某個狀態管理框架，優先用框架的廣播原語，而不是 raw <code>StreamController</code>。<code>StreamController</code> 在 Flutter app 通常是底層 I/O service 才用（藍牙、socket、sensor）。</p>
<p>下一節對其中最常被混用的一組——raw <code>StreamController</code> 跟 GetX 的 <code>Rx</code> / <code>.obs</code>——做完整對比，因為這也是事故當下會考慮「是不是該整個換掉」的對象。</p>
<hr>
<h2 id="深入比較raw-streamcontroller-vs-getx-的-rx--obs">深入比較：raw StreamController vs GetX 的 Rx / .obs</h2>
<h3 id="先釐清rx-跟-obs-的關係">先釐清：Rx 跟 .obs 的關係</h3>
<p>在 GetX 裡，<code>Rx&lt;T&gt;</code> 是底層 reactive value container，<code>.obs</code> 是把任何值包成對應 Rx 子類的 syntax sugar：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 三種寫法本質等價
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kd">final</span> <span class="n">count1</span> <span class="o">=</span> <span class="m">0.</span><span class="n">obs</span><span class="p">;</span>            <span class="c1">// 推導為 RxInt
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="kd">final</span> <span class="n">count2</span> <span class="o">=</span> <span class="n">RxInt</span><span class="p">(</span><span class="m">0</span><span class="p">);</span>         <span class="c1">// 顯式建構特化子類
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="kd">final</span> <span class="n">count3</span> <span class="o">=</span> <span class="n">Rx</span><span class="o">&lt;</span><span class="kt">int</span><span class="o">&gt;</span><span class="p">(</span><span class="m">0</span><span class="p">);</span>       <span class="c1">// 較少用，因為 RxInt 提供更多 operator overload
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="n">count1</span><span class="p">.</span><span class="n">value</span><span class="o">++</span><span class="p">;</span>  <span class="c1">// RxInt 可直接用 ++
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span><span class="n">count3</span><span class="p">.</span><span class="n">value</span><span class="o">++</span><span class="p">;</span>  <span class="o">//</span> <span class="n">Rx</span><span class="o">&lt;</span><span class="kt">int</span><span class="o">&gt;</span> <span class="err">也行，但缺了</span> <span class="n">RxInt</span> <span class="err">的算術特化</span></span></span></code></pre></div><p><code>.obs</code> 對不同型別回傳不同特化子類：</p>
<table>
  <thead>
      <tr>
          <th>寫法</th>
          <th>回傳型別</th>
          <th>特化能力</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>0.obs</code></td>
          <td><code>RxInt</code></td>
          <td>算術 operator (<code>+=</code>, <code>++</code>, <code>&lt;</code> 等)</td>
      </tr>
      <tr>
          <td><code>0.0.obs</code></td>
          <td><code>RxDouble</code></td>
          <td>算術 operator</td>
      </tr>
      <tr>
          <td><code>''.obs</code></td>
          <td><code>RxString</code></td>
          <td>字串 operator (<code>+</code>, <code>==</code>, <code>compareTo</code>)</td>
      </tr>
      <tr>
          <td><code>false.obs</code></td>
          <td><code>RxBool</code></td>
          <td><code>toggle()</code>、邏輯 operator</td>
      </tr>
      <tr>
          <td><code>[1,2].obs</code></td>
          <td><code>RxList&lt;int&gt;</code></td>
          <td><code>add</code>/<code>remove</code>/<code>assignAll</code> 自動觸發</td>
      </tr>
      <tr>
          <td><code>{}.obs</code></td>
          <td><code>RxMap</code>/<code>RxSet</code></td>
          <td>集合 mutation 自動觸發</td>
      </tr>
      <tr>
          <td><code>User().obs</code></td>
          <td><code>Rx&lt;User&gt;</code></td>
          <td>一般 reassign 觸發</td>
      </tr>
  </tbody>
</table>
<p>特化子類的核心好處：<strong>原生語法的 mutation（<code>+=</code>、list <code>add</code>、string concat）都直接觸發 reactive 通知</strong>，不需要手動 <code>notifyListeners()</code> 或 <code>add()</code>。</p>
<p>結論：<code>.obs</code> 跟 <code>Rx</code> 不是兩個不同概念，是同一個機制的兩種建構寫法。後者多了型別推導與特化命名。</p>
<h3 id="概念差異">概念差異</h3>
<table>
  <thead>
      <tr>
          <th></th>
          <th>StreamController</th>
          <th>Rx<T> / .obs</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>本質</td>
          <td>事件管線（push events）</td>
          <td>反應式值容器（push values + 保留 current）</td>
      </tr>
      <tr>
          <td>比喻</td>
          <td>水管</td>
          <td>帶讀數的水位感應器</td>
      </tr>
      <tr>
          <td>起始狀態</td>
          <td>沒有 latest，listener 加入後才開始接</td>
          <td>出生就有 <code>.value</code>，隨時可讀</td>
      </tr>
      <tr>
          <td>設計目的</td>
          <td>通用非同步資料流</td>
          <td>專為 UI 反應式更新設計</td>
      </tr>
  </tbody>
</table>
<h3 id="相同任務的程式碼對比">相同任務的程式碼對比</h3>
<p><strong>任務</strong>：service 對外暴露一個整數狀態，UI 顯示它且當值變化時自動 rebuild。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// ===== Raw StreamController 寫法 =====
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kd">class</span> <span class="nc">CounterService</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="kt">int</span> <span class="n">_value</span> <span class="o">=</span> <span class="m">0</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="kd">final</span> <span class="n">_controller</span> <span class="o">=</span> <span class="n">StreamController</span><span class="o">&lt;</span><span class="kt">int</span><span class="o">&gt;</span><span class="p">.</span><span class="n">broadcast</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="kt">int</span> <span class="kd">get</span> <span class="n">value</span> <span class="o">=&gt;</span> <span class="n">_value</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="n">Stream</span><span class="o">&lt;</span><span class="kt">int</span><span class="o">&gt;</span> <span class="kd">get</span> <span class="n">stream</span> <span class="o">=&gt;</span> <span class="n">_controller</span><span class="p">.</span><span class="n">stream</span><span class="p">;</span>
</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">  <span class="kt">void</span> <span class="n">increment</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="n">_value</span><span class="o">++</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="n">_controller</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="n">_value</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="p">}</span>
</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 class="kt">void</span> <span class="n">dispose</span><span class="p">()</span> <span class="o">=&gt;</span> <span class="n">_controller</span><span class="p">.</span><span class="n">close</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="p">}</span>
</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"><span class="c1">// UI:
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="c1"></span><span class="n">StreamBuilder</span><span class="o">&lt;</span><span class="kt">int</span><span class="o">&gt;</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">  <span class="nl">stream:</span> <span class="n">service</span><span class="p">.</span><span class="n">stream</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">  <span class="nl">initialData:</span> <span class="n">service</span><span class="p">.</span><span class="n">value</span><span class="p">,</span>  <span class="c1">// 不帶這個首次 build 是 null
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="c1"></span>  <span class="nl">builder:</span> <span class="p">(</span><span class="n">context</span><span class="p">,</span> <span class="n">snap</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">Text</span><span class="p">(</span><span class="s1">&#39;</span><span class="si">${</span><span class="n">snap</span><span class="p">.</span><span class="n">data</span><span class="si">}</span><span class="s1">&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="p">)</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// ===== Rx / .obs 寫法 =====
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kd">class</span> <span class="nc">CounterService</span> <span class="kd">extends</span> <span class="n">GetxController</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="kd">final</span> <span class="n">value</span> <span class="o">=</span> <span class="m">0.</span><span class="n">obs</span><span class="p">;</span>
</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 class="kt">void</span> <span class="n">increment</span><span class="p">()</span> <span class="o">=&gt;</span> <span class="n">value</span><span class="p">.</span><span class="n">value</span><span class="o">++</span><span class="p">;</span>
</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 class="c1">// 不需要寫 dispose；Rx 隨 controller 生命週期自動清理
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span><span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1">// UI:
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"></span><span class="n">Obx</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="n">Text</span><span class="p">(</span><span class="s1">&#39;</span><span class="si">${</span><span class="n">service</span><span class="p">.</span><span class="n">value</span><span class="p">.</span><span class="n">value</span><span class="si">}</span><span class="s1">&#39;</span><span class="p">))</span></span></span></code></pre></div><p>差異一目了然：</p>
<ul>
<li><strong>樣板量約 4-5 倍差距</strong></li>
<li>StreamController 要自己維護 latest value</li>
<li>StreamController 要記得寫 <code>dispose</code></li>
<li><code>Obx</code> 自動追蹤所有 <code>.value</code> 讀取，不需要手動 listen/cancel</li>
<li>StreamBuilder 要處理 <code>initialData</code> 與 <code>snap.data</code> 為 null 的情境，Rx 沒這問題（永遠有值）</li>
</ul>
<h3 id="rx-內部其實就是-streamcontroller--valuenotifier">Rx 內部其實就是 StreamController + ValueNotifier</h3>
<p><code>Rx&lt;T&gt;</code> 底層用 <code>StreamController.broadcast()</code> 加上一個 <code>_value</code> 欄位。<code>Obx</code> widget 在 build 時開一個訂閱範圍，期間任何 <code>.value</code> getter 會被追蹤；build 結束後對應的 stream 訂閱自動建立，值變化時觸發 widget rebuild。</p>
<p>簡化心智模型：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">class</span> <span class="nc">Rx</span><span class="o">&lt;</span><span class="n">T</span><span class="o">&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="n">T</span> <span class="n">_value</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="kd">final</span> <span class="n">_ctrl</span> <span class="o">=</span> <span class="n">StreamController</span><span class="o">&lt;</span><span class="n">T</span><span class="o">&gt;</span><span class="p">.</span><span class="n">broadcast</span><span class="p">();</span>
</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 class="n">Rx</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="n">_value</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="n">T</span> <span class="kd">get</span> <span class="n">value</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="n">RxInterface</span><span class="p">.</span><span class="n">proxy</span><span class="o">?</span><span class="p">.</span><span class="n">addListener</span><span class="p">(</span><span class="n">_ctrl</span><span class="p">.</span><span class="n">stream</span><span class="p">);</span>  <span class="c1">// Obx 注入的依賴追蹤代理
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span>    <span class="k">return</span> <span class="n">_value</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="p">}</span>
</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 class="kd">set</span> <span class="n">value</span><span class="p">(</span><span class="n">T</span> <span class="n">v</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">_value</span> <span class="o">==</span> <span class="n">v</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>  <span class="c1">// ← 等值不觸發
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"></span>    <span class="n">_value</span> <span class="o">=</span> <span class="n">v</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="n">_ctrl</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="n">v</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>（真實實作更複雜，但骨架是這樣。）</p>
<p>換句話說 <strong>Rx ≈ broadcast StreamController + ValueNotifier + 自動依賴追蹤 + 特化子類</strong>。理解這層之後，後面所有「Rx 為什麼這樣」的問題都能從這個本質推回去。</p>
<h3 id="完整對比表格">完整對比表格</h3>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>StreamController</th>
          <th>Rx<T> / .obs</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Framework 依賴</td>
          <td>無（dart:async 標準庫）</td>
          <td>需 GetX</td>
      </tr>
      <tr>
          <td>同訂閱數</td>
          <td>single 或 broadcast 二選一</td>
          <td>永遠 broadcast</td>
      </tr>
      <tr>
          <td>Latest value 保留</td>
          <td>不保留，自己管 <code>_latest</code></td>
          <td>內建 <code>.value</code></td>
      </tr>
      <tr>
          <td>訂閱機制</td>
          <td>手動 <code>.listen()</code></td>
          <td><code>Obx</code> 自動 / <code>ever()</code> worker 手動</td>
      </tr>
      <tr>
          <td>取消訂閱</td>
          <td>手動 <code>sub.cancel()</code></td>
          <td>Obx widget dispose 時自動 / worker 綁 controller 時自動</td>
      </tr>
      <tr>
          <td>Widget 整合</td>
          <td><code>StreamBuilder</code></td>
          <td><code>Obx</code> / <code>GetX&lt;T&gt;</code></td>
      </tr>
      <tr>
          <td>初始值處理</td>
          <td>需 <code>initialData</code> 或 listener 加入後才有</td>
          <td>出生就有，無 null 期</td>
      </tr>
      <tr>
          <td>等值是否觸發</td>
          <td>是，每次 add 都送</td>
          <td>否，<code>==</code> 相等不觸發（可 <code>.refresh()</code> 強制）</td>
      </tr>
      <tr>
          <td>集合反應性</td>
          <td>List 變動要自己 emit</td>
          <td>RxList/Map/Set 內建 mutation hook</td>
      </tr>
      <tr>
          <td>物件內部變動</td>
          <td>自己控制何時 emit</td>
          <td>需 <code>.refresh()</code> 或換新 reference</td>
      </tr>
      <tr>
          <td>Stream operators (map/where/buffer/&hellip;)</td>
          <td>完整 dart:async API</td>
          <td>用 <code>.stream</code> 取出後可接</td>
      </tr>
      <tr>
          <td>Pause/resume</td>
          <td>支援（broadcast 為 per-listener）</td>
          <td>透過 underlying stream 才有</td>
      </tr>
      <tr>
          <td>Error 傳遞</td>
          <td><code>addError()</code> + <code>onError</code> callback</td>
          <td>較少使用，多以 try/catch 處理上游</td>
      </tr>
      <tr>
          <td>樣板量</td>
          <td>多（5-10 行/欄位）</td>
          <td>少（1 行/欄位）</td>
      </tr>
      <tr>
          <td>學習曲線</td>
          <td>標準 Stream 概念，跨框架通用</td>
          <td>GetX 特有 API，受框架綁定</td>
      </tr>
      <tr>
          <td>測試</td>
          <td>直接測 stream，工具豐富（<code>expectLater</code>/<code>emitsInOrder</code>）</td>
          <td>Rx 可用 <code>.value</code> assert，跨 controller 測試要 mock GetX 注入</td>
      </tr>
      <tr>
          <td>跨 isolate</td>
          <td>支援</td>
          <td>不支援（Obx 依賴 main isolate）</td>
      </tr>
      <tr>
          <td>Type safety</td>
          <td>強 generic</td>
          <td>強 generic，但 <code>.obs</code> 推導要注意特化型別</td>
      </tr>
      <tr>
          <td>適用場景</td>
          <td>底層 I/O、需要 stream 組合運算</td>
          <td>UI state、application state</td>
      </tr>
  </tbody>
</table>
<h3 id="rx-的特殊行為與陷阱">Rx 的特殊行為與陷阱</h3>
<h4 id="1-等值不觸發更新">1. 等值不觸發更新</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">final</span> <span class="n">name</span> <span class="o">=</span> <span class="s1">&#39;&#39;</span><span class="p">.</span><span class="n">obs</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">name</span><span class="p">.</span><span class="n">value</span> <span class="o">=</span> <span class="s1">&#39;&#39;</span><span class="p">;</span>     <span class="c1">// 不觸發 listener（&#39;&#39; == &#39;&#39;）
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="n">name</span><span class="p">.</span><span class="n">value</span> <span class="o">=</span> <span class="s1">&#39;A&#39;</span><span class="p">;</span>    <span class="c1">// 觸發
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="n">name</span><span class="p">.</span><span class="n">value</span> <span class="o">=</span> <span class="s1">&#39;A&#39;</span><span class="p">;</span>    <span class="o">//</span> <span class="err">不觸發（</span><span class="s1">&#39;A&#39;</span> <span class="o">==</span> <span class="s1">&#39;A&#39;</span><span class="err">）</span></span></span></code></pre></div><p>如果需要「每次 set 都觸發」（例如重新打 API 不管值有沒有變），用 <code>.refresh()</code> 或 <code>.trigger()</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">name</span><span class="p">.</span><span class="n">refresh</span><span class="p">();</span>              <span class="c1">// 強制通知所有 listener，不變更 value
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="n">name</span><span class="p">.</span><span class="n">trigger</span><span class="p">(</span><span class="s1">&#39;A&#39;</span><span class="p">);</span>           <span class="o">//</span> <span class="err">強制通知，且</span> <span class="kd">set</span> <span class="n">value</span></span></span></code></pre></div><h4 id="2-物件內部變動不觸發">2. 物件內部變動不觸發</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">final</span> <span class="n">user</span> <span class="o">=</span> <span class="n">User</span><span class="p">(</span><span class="nl">name:</span> <span class="s1">&#39;A&#39;</span><span class="p">).</span><span class="n">obs</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">user</span><span class="p">.</span><span class="n">value</span><span class="p">.</span><span class="n">name</span> <span class="o">=</span> <span class="s1">&#39;B&#39;</span><span class="p">;</span>                         <span class="c1">// 不觸發，reference 沒變
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="n">user</span><span class="p">.</span><span class="n">refresh</span><span class="p">();</span>                                <span class="c1">// 強制觸發
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="n">user</span><span class="p">.</span><span class="n">value</span> <span class="o">=</span> <span class="n">user</span><span class="p">.</span><span class="n">value</span><span class="p">.</span><span class="n">copyWith</span><span class="p">(</span><span class="nl">name:</span> <span class="s1">&#39;B&#39;</span><span class="p">);</span>   <span class="o">//</span> <span class="err">換新</span> <span class="n">reference</span> <span class="err">自然觸發</span></span></span></code></pre></div><p>這跟 immutable 風格（Freezed、Equatable）配合最自然，<code>copyWith</code> 一定產出新 reference。</p>
<h4 id="3-obx-必須讀到至少一個-value">3. Obx 必須讀到至少一個 <code>.value</code></h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">Obx</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="n">Text</span><span class="p">(</span><span class="s1">&#39;hello&#39;</span><span class="p">))</span>                  <span class="c1">// warning: improper use
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="n">Obx</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="n">Text</span><span class="p">(</span><span class="s1">&#39;</span><span class="si">${</span><span class="n">counter</span><span class="p">.</span><span class="n">value</span><span class="si">}</span><span class="s1">&#39;</span><span class="p">))</span>       <span class="o">//</span> <span class="err">正確</span></span></span></code></pre></div><p><code>Obx</code> 靠 build 期間攔截 <code>.value</code> getter 建立訂閱關係，build callback 內完全沒讀任何 Rx 就不知道要 subscribe 誰。</p>
<h4 id="4-rxlist--rxmap-的-mutation-規則">4. RxList / RxMap 的 mutation 規則</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">final</span> <span class="n">items</span> <span class="o">=</span> <span class="o">&lt;</span><span class="kt">int</span><span class="o">&gt;</span><span class="p">[].</span><span class="n">obs</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">items</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="m">1</span><span class="p">);</span>          <span class="c1">// 觸發（RxList 重寫了 add）
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="n">items</span><span class="p">.</span><span class="n">value</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="m">2</span><span class="p">);</span>    <span class="c1">// 不觸發（操作的是底層 List）
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="n">items</span><span class="p">[</span><span class="m">0</span><span class="p">]</span> <span class="o">=</span> <span class="m">99</span><span class="p">;</span>         <span class="c1">// 觸發（RxList 重寫了 []=）
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="n">items</span><span class="p">.</span><span class="n">refresh</span><span class="p">();</span>       <span class="o">//</span> <span class="err">補救</span></span></span></code></pre></div><p>特化集合類別重寫了 <code>add</code>/<code>remove</code>/<code>[]=</code>/<code>clear</code> 等 method 讓它們自動 emit；繞過 wrapper 直接操作 <code>.value</code> 就會跳過這層。</p>
<h4 id="5-obs-推導出的特化型別可能不是你想要的">5. .obs 推導出的特化型別可能不是你想要的</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">final</span> <span class="n">list</span> <span class="o">=</span> <span class="p">[</span><span class="m">1</span><span class="p">,</span> <span class="m">2</span><span class="p">,</span> <span class="m">3</span><span class="p">].</span><span class="n">obs</span><span class="p">;</span>        <span class="c1">// RxList&lt;int&gt;
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kd">final</span> <span class="n">list2</span> <span class="o">=</span> <span class="o">&lt;</span><span class="kt">num</span><span class="o">&gt;</span><span class="p">[</span><span class="m">1</span><span class="p">,</span> <span class="m">2</span><span class="p">,</span> <span class="m">3</span><span class="p">].</span><span class="n">obs</span><span class="p">;</span>  <span class="c1">// RxList&lt;num&gt; — 注意泛型推導
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1">// 自定義型別需明確
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="kd">final</span> <span class="n">user</span> <span class="o">=</span> <span class="n">User</span><span class="p">(</span><span class="nl">name:</span> <span class="s1">&#39;A&#39;</span><span class="p">).</span><span class="n">obs</span><span class="p">;</span>  <span class="o">//</span> <span class="n">Rx</span><span class="o">&lt;</span><span class="n">User</span><span class="o">&gt;</span><span class="err">，不是「</span><span class="n">RxUser</span><span class="err">」</span></span></span></code></pre></div><h3 id="rx-的-worker-類型service-之間的訂閱模式">Rx 的 worker 類型（service 之間的訂閱模式）</h3>
<p><code>Obx</code> 是 widget 自動訂閱；service 內或 controller 之間的訂閱用 <code>worker</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// 每次變化都觸發
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="kd">final</span> <span class="n">disposer</span> <span class="o">=</span> <span class="n">ever</span><span class="p">(</span><span class="n">counter</span><span class="p">,</span> <span class="p">(</span><span class="n">value</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">print</span><span class="p">(</span><span class="s1">&#39;changed to </span><span class="si">$</span><span class="n">value</span><span class="s1">&#39;</span><span class="p">));</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">// debounce — 連續變化只取最後一次
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="n">debounce</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="n">searchText</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="p">(</span><span class="n">value</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">searchAPI</span><span class="p">(</span><span class="n">value</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="nl">time:</span> <span class="n">Duration</span><span class="p">(</span><span class="nl">milliseconds:</span> <span class="m">500</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="p">);</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1">// throttle — 固定間隔最多觸發一次
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"></span><span class="n">interval</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="n">scrollPosition</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="p">(</span><span class="n">value</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">analytics</span><span class="p">(</span><span class="n">value</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">  <span class="nl">time:</span> <span class="n">Duration</span><span class="p">(</span><span class="nl">seconds:</span> <span class="m">1</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="p">);</span>
</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"><span class="c1">// 只觸發一次後自動移除
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="c1"></span><span class="n">once</span><span class="p">(</span><span class="n">loginState</span><span class="p">,</span> <span class="p">(</span><span class="n">value</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">navigateHome</span><span class="p">());</span>
</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 class="c1">// 監聽多個 Rx，任一變動就觸發
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="c1"></span><span class="n">everAll</span><span class="p">([</span><span class="n">a</span><span class="p">,</span> <span class="n">b</span><span class="p">,</span> <span class="n">c</span><span class="p">],</span> <span class="p">(</span><span class="n">_</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">recompute</span><span class="p">());</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">
</span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="c1">// 手動清理
</span></span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="c1"></span><span class="n">disposer</span><span class="p">.</span><span class="n">dispose</span><span class="p">();</span></span></span></code></pre></div><p>這些 worker 在 <code>GetxController.onInit</code> 裡註冊時會被綁定到 controller 生命週期，controller dispose 時自動清；在 controller 外註冊就要自己 <code>.dispose()</code>。</p>
<h3 id="何時選哪個">何時選哪個</h3>
<h4 id="選-raw-streamcontroller">選 raw <code>StreamController</code></h4>
<ul>
<li>寫<strong>底層 service</strong>（藍牙、socket、sensor、background isolate 通訊）</li>
<li>需要<strong>豐富的 stream operators 鏈</strong>（<code>map</code>/<code>where</code>/<code>buffer</code>/<code>distinct</code>/<code>merge</code>/<code>combineLatest</code>&hellip;）</li>
<li>對外提供的 API 不想綁特定狀態管理框架，要保持框架中立</li>
<li>需要 backpressure / pause-resume 等進階流量控制</li>
<li>跨 isolate 資料傳遞</li>
</ul>
<h4 id="選-rx--obs">選 <code>Rx</code> / <code>.obs</code></h4>
<ul>
<li>寫 <strong>UI state</strong> 或 <strong>application state</strong></li>
<li>已在用 GetX，沿用一致</li>
<li>需要「保留當前值 + 多訂閱者」這個常見組合</li>
<li>想要 widget 自動追蹤，不想手動寫 listen/cancel</li>
<li>service 內部 latest value 與通知的樣板太多次，懶得繼續寫</li>
</ul>
<h3 id="把事故場景改寫成-rx-看看">把事故場景改寫成 Rx 看看</h3>
<p>回到事故場景。如果 service 從一開始就用 reactive value container（如 Rx）來表達它的對外契約，整個問題會以另一種方式消失。</p>
<p><strong>對外契約的轉變</strong>：service 不再「對外發送事件」，而是「對外暴露兩個可被觀察的狀態屬性」——當前完整的商品列表、最後一次變動的品項。訂閱方不需要 <code>listen()</code> 一條 stream，而是直接讀取屬性的當前值，並且系統保證屬性變化時觀察者會被通知。</p>
<p><strong>在這個契約下回頭看每個訂閱方的需求</strong>：</p>
<ul>
<li><strong>副螢幕（鏡像當前商品列表）</strong>：只關心「列表屬性」變動，不在乎是哪一筆變動。它建立一個對列表屬性的觀察，每次變動就重畫</li>
<li><strong>收銀主畫面（最後變更項標記）</strong>：只關心「最後變動屬性」，每次變動就更新高亮哪一行</li>
<li><strong>未來的訂閱方</strong>（KDS、列印、雲端、analytics）：各自選關心的屬性建立觀察</li>
</ul>
<p>兩個訂閱者觀察的是<strong>不同屬性</strong>，互不干擾；同一個屬性也允許多個觀察者（reactive value 天生是廣播語義）。</p>
<p><strong>事故的兩個技術問題在這個契約下自動消失</strong>：</p>
<ol>
<li><strong>single vs broadcast 的選擇問題不存在</strong>——reactive value 沒有「單訂閱版本」，每個觀察者天生並存</li>
<li><strong>進場拿不到歷史事件的問題不存在</strong>——觀察者進場時可以直接讀屬性的「當前值」，不必等下一次變動</li>
</ol>
<p>更深一層的觀察：raw stream 是「以時間軸上的事件為一等公民」的工具，適合「事件本身就是有意義的（log、命令、訊息）」場景；reactive value 是「以狀態為一等公民」的工具，適合「下游關心的是當前是什麼，不是過去發生了什麼」場景。<strong>POS 多視角同步的本質是後者</strong>——副螢幕關心的是「現在購物車裡有什麼」，不是「過去 5 分鐘掃進了哪些商品的時序」。</p>
<p>把這個認知一般化：當業務語義是「多個視角共享當前狀態」時，工具應該是 reactive value（Rx / ValueNotifier / BehaviorSubject）；當業務語義是「事件流的時序」時，工具才是 stream。本案的根因是「業務語義（共享狀態）」跟「工具語義（事件流）」錯配；single-subscription 是錯配關係下第一個被觸發的契約限制、但即使換成 broadcast、仍會在「進場拿不到歷史事件」這個層次暴露語義錯配。</p>
<h3 id="是否該全面改寫成-rx">是否該全面改寫成 Rx</h3>
<p>事故當下不該。理由：</p>
<ol>
<li><strong>scope 控制</strong>：事故修復原則是 minimal change，<code>StreamController()</code> → <code>.broadcast()</code> 一字之差就解決</li>
<li><strong>回歸風險</strong>：把 service 介面從 <code>Stream&lt;T&gt;</code> 改成 <code>Rx&lt;T&gt;</code>，所有訂閱方（副螢幕、UI、未來的 KDS / 雲端同步）都要改 listen 方式</li>
<li><strong>耦合代價</strong>：如果 service 介面原本是 framework-neutral 的（純 dart:async），改 Rx 等於把 GetX 綁進公開 API，未來要換框架成本變高</li>
<li><strong>測試成本</strong>：改 Rx 之後，所有針對該 service 的測試都要改 mock 方式</li>
</ol>
<p>該重構的時機：</p>
<ul>
<li>整個系統已經 implicit 綁 GetX，介面 framework-neutral 的成本沒實質效益</li>
<li>新增 service 時直接用 Rx，舊的 stream-based service 等下次大改一起換</li>
<li>發現自己重複寫「<code>_latest</code> + <code>StreamController.broadcast</code> + getter + emit + close」的樣板太多次，Rx 是現成解</li>
<li>整理技術債的專屬 sprint，可以系統性換掉</li>
</ul>
<p>事故修復應該專注 minimal fix；架構改造是另一張單。</p>
<hr>
<h2 id="除錯思維">除錯思維</h2>
<p><code>Bad state: Stream has already been listened to.</code> 的根因落在 stream 定義端的型別契約、不在訂閱端。檢查順序：</p>
<ol>
<li><strong>這條 stream 是 single-subscription 還是 broadcast？</strong>
<ul>
<li>從定義端確認（<code>StreamController()</code> vs <code>StreamController.broadcast()</code>）、訂閱端只承載限制、看不出契約類型</li>
</ul>
</li>
<li><strong>若是 single、選 single 的理由有書面記錄嗎？</strong>
<ul>
<li>介面註解 / 設計文件有記錄 → 看理由是否仍成立</li>
<li>沒有記錄 → 屬於「用了預設建構子、沒做選擇」、回到當下三軸判斷</li>
</ul>
</li>
<li><strong>多訂閱在語義上合理嗎？</strong>
<ul>
<li>合理 → 改 broadcast、屬於修正型別契約跟業務語義對齊</li>
<li>不合理 → 第二個訂閱者的需求要重新設計（透過第一個 listener 轉送、或拉新 stream）</li>
</ul>
</li>
</ol>
<p>把「這條 stream 該不該支援多訂閱」做為設計階段的明確決策、判斷成本（跑三軸）落在當下、且不依賴未來需求是否實際出現。</p>
<hr>
<h2 id="延伸pos-場景的多訂閱模式">延伸：POS 場景的多訂閱模式</h2>
<p>POS 系統本質上就是「中央交易狀態 + 多視圖/多裝置鏡像」，是 broadcast stream 最自然的應用領域。常見訂閱者：</p>
<table>
  <thead>
      <tr>
          <th>訂閱方</th>
          <th>關心什麼</th>
          <th>訂閱生命週期</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>收銀員主螢幕</td>
          <td>完整購物車、UI 高亮、結帳金額</td>
          <td>收銀頁面開啟期間</td>
      </tr>
      <tr>
          <td>副螢幕（顧客面）</td>
          <td>商品名、單價、總價、找零</td>
          <td>App lifetime</td>
      </tr>
      <tr>
          <td>廚房顯示（KDS）</td>
          <td>已下單品項、出餐順序</td>
          <td>App lifetime</td>
      </tr>
      <tr>
          <td>列印服務</td>
          <td>結帳明細、會員資訊</td>
          <td>觸發式（結帳當下）</td>
      </tr>
      <tr>
          <td>雲端同步</td>
          <td>所有交易事件</td>
          <td>App lifetime</td>
      </tr>
      <tr>
          <td>Analytics</td>
          <td>使用者行為、轉換率</td>
          <td>App lifetime</td>
      </tr>
  </tbody>
</table>
<p>設計階段先假設「會有多個訂閱者」、「未來訂閱者數量會增加」、「每個訂閱者只關心事件的一部分屬性」——這正是 broadcast 的典型語義；之後新功能要訂閱、設計上會自然容納。</p>
<p>對應的設計建議：</p>
<ol>
<li><strong>Service 對外的事件 stream 預設 broadcast</strong>——single-subscription 視為例外、要在介面註解書面說明</li>
<li><strong>事件 payload 設計成 record 或 sealed class</strong>——包含「是什麼變動 + 變動的詳細資料」、讓不同訂閱者各取所需</li>
<li><strong>不要假設訂閱者之間的觸發順序</strong>——broadcast 的 listener 之間沒有保證順序、訂閱者要假設彼此獨立</li>
<li><strong>進場時若需要初始狀態、提供 <code>currentValue</code> getter</strong>——broadcast 不保留歷史、用 explicit getter 補這個缺口</li>
</ol>
<hr>
<h2 id="參考資料">參考資料</h2>
<ul>
<li><a href="https://api.dart.dev/stable/dart-async/StreamController-class.html">Dart <code>StreamController</code> API doc</a></li>
<li><a href="https://api.dart.dev/stable/dart-async/StreamController/StreamController.broadcast.html">Dart <code>StreamController.broadcast</code> constructor</a></li>
<li><a href="https://api.dart.dev/stable/dart-async/Stream/asBroadcastStream.html">Dart <code>Stream.asBroadcastStream</code> method</a></li>
<li><a href="https://dart.dev/tutorials/language/streams">Dart language tour - Asynchronous programming: streams</a></li>
<li><a href="https://pub.dev/documentation/rxdart/latest/rx/BehaviorSubject-class.html">RxDart <code>BehaviorSubject</code> doc</a></li>
</ul>
]]></content:encoded></item><item><title>Gradle JVM target 除錯復盤：七個節點的策略權衡</title><link>https://tarrragon.github.io/blog/work-log/gradle-jvm-target-%E9%99%A4%E9%8C%AF%E5%BE%A9%E7%9B%A4%E4%B8%83%E5%80%8B%E7%AF%80%E9%BB%9E%E7%9A%84%E7%AD%96%E7%95%A5%E6%AC%8A%E8%A1%A1/</link><pubDate>Fri, 17 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/gradle-jvm-target-%E9%99%A4%E9%8C%AF%E5%BE%A9%E7%9B%A4%E4%B8%83%E5%80%8B%E7%AF%80%E9%BB%9E%E7%9A%84%E7%AD%96%E7%95%A5%E6%AC%8A%E8%A1%A1/</guid><description>&lt;h2 id="為什麼寫這篇">為什麼寫這篇&lt;/h2>
&lt;p>排查 Gradle JVM target inconsistency 時走了七個節點才收斂。這篇復盤每個節點的完整決策流：&lt;/p>
&lt;hr>
&lt;h2 id="節點-a第一次錯誤出現">節點 A：第一次錯誤出現&lt;/h2>
&lt;h3 id="當下看到">當下看到&lt;/h3>





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





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





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





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





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





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





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





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





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





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

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





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





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





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





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





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





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





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





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





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





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





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





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





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





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





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





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





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





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





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





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





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





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





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





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





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





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





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





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





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





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





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln">1</span><span class="cl"><span class="c"># 偽 CI 腳本</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span>- <span class="l">flutter clean</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span>- <span class="l">rm -rf ~/.gradle/caches/modules-2/metadata-*</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span>- <span class="l">cd android &amp;&amp; ./gradlew --no-configuration-cache --no-build-cache clean assembleDebug</span></span></span></code></pre></div><p>這樣 catch 到的錯誤通常比開發者自己遇到早一週到一個月，能在觸發條件（合併、升級）發生之前就看到。</p>
<h3 id="方法-2升級依賴時強制全量驗證">方法 2：升級依賴時強制全量驗證</h3>
<p>每次升 Flutter、AGP、Kotlin plugin 版本時，遵守以下流程：</p>
<ol>
<li>建立升級分支</li>
<li>升級前先 <code>flutter clean</code> + <code>./gradlew clean</code></li>
<li>升級後再跑一次無 cache build</li>
<li>確認綠燈才合併</li>
</ol>
<p>這一步常被忽略，因為「升版本的 PR 通常 diff 很小，看起來不會壞什麼」。但 Gradle 的 strict validation 規則通常就藏在這些小升級裡。</p>
<hr>
<h2 id="除錯思維的關鍵切換">除錯思維的關鍵切換</h2>
<p>看到「branch 上沒事、merge 後爆」這類時序弔詭時：</p>
<p><strong>不要先想「這次合併改了什麼造成問題」</strong>
→ 容易把時間花在閱讀無關的 diff</p>
<p><strong>要先想「是不是有什麼東西一直被 cache 蓋著」</strong>
→ 把 cache 當成嫌疑人，去找觸發條件</p>
<p>通常結論都會是：<strong>根因在幾個月前埋下，cache 蓋了很久，這次合併剛好扣扳機</strong>。</p>
<p>把這個思維框架套用在其他類似症狀上也成立：</p>
<ul>
<li>CI 一直綠燈，某次合併後才紅 → CI 的 cache 在那次被打破</li>
<li>某個開發者電腦上沒事，別人電腦上爆 → 兩台機器的 cache state 不同步</li>
<li>升級後立刻 build 綠，過幾天才出問題 → 那幾天有某個動作打破了 cache</li>
</ul>
]]></content:encoded></item><item><title>Flutter HitTestBehavior：控制點擊命中測試的三種模式</title><link>https://tarrragon.github.io/blog/work-log/flutter-hittestbehavior%E6%8E%A7%E5%88%B6%E9%BB%9E%E6%93%8A%E5%91%BD%E4%B8%AD%E6%B8%AC%E8%A9%A6%E7%9A%84%E4%B8%89%E7%A8%AE%E6%A8%A1%E5%BC%8F/</link><pubDate>Tue, 07 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter-hittestbehavior%E6%8E%A7%E5%88%B6%E9%BB%9E%E6%93%8A%E5%91%BD%E4%B8%AD%E6%B8%AC%E8%A9%A6%E7%9A%84%E4%B8%89%E7%A8%AE%E6%A8%A1%E5%BC%8F/</guid><description>&lt;h2 id="概念">概念&lt;/h2>
&lt;p>當使用者點擊螢幕，Flutter 從 widget tree 根節點往下做 &lt;strong>hit test&lt;/strong>（命中測試），判斷哪些 widget 被點擊命中。&lt;/p>
&lt;p>&lt;code>HitTestBehavior&lt;/code> 控制 GestureDetector &lt;strong>自己要不要算作被命中&lt;/strong>，以及&lt;strong>如何影響子元件的命中判定&lt;/strong>。&lt;/p>
&lt;hr>
&lt;h2 id="三種模式">三種模式&lt;/h2>
&lt;h3 id="defertochild預設">&lt;code>deferToChild&lt;/code>（預設）&lt;/h3>
&lt;blockquote>
&lt;p>「我自己不算，讓子元件決定」&lt;/p>&lt;/blockquote>





&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">GestureDetector (100x100)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> └── Container (50x50, 置中)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;ul>
&lt;li>點擊 Container 範圍內 → Container 被命中 → GestureDetector &lt;strong>也算命中&lt;/strong> → onTap 觸發&lt;/li>
&lt;li>點擊 Container 範圍外（空白 padding）→ 沒有子元件被命中 → GestureDetector &lt;strong>不算命中&lt;/strong> → onTap &lt;strong>不觸發&lt;/strong>&lt;/li>
&lt;/ul>
&lt;p>適合：只想在子元件的可視範圍內接收點擊。&lt;/p>
&lt;hr>
&lt;h3 id="opaque">&lt;code>opaque&lt;/code>&lt;/h3>
&lt;blockquote>
&lt;p>「整個區域都算我的，而且我擋住下面所有人」&lt;/p>&lt;/blockquote>





&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">GestureDetector (100x100) ← 整個 100x100 都算命中
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> └── Container (50x50, 置中)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;ul>
&lt;li>點擊任何位置（包含空白處）→ GestureDetector &lt;strong>都算命中&lt;/strong> → onTap 觸發&lt;/li>
&lt;li>同時&lt;strong>阻擋&lt;/strong>同層或下層的 widget 接收這個點擊&lt;/li>
&lt;/ul>
&lt;p>適合：需要一個「全範圍點擊區域」，例如整個螢幕的 barrier。&lt;/p>
&lt;hr>
&lt;h3 id="translucent">&lt;code>translucent&lt;/code>&lt;/h3>
&lt;blockquote>
&lt;p>「整個區域都算我的，但我不擋別人」&lt;/p>&lt;/blockquote>





&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">GestureDetector (100x100) ← 整個 100x100 都算命中
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> └── Button (50x50, 置中) ← Button 也算命中&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;ul>
&lt;li>點擊 Button 範圍 → &lt;strong>兩者都進入手勢競爭&lt;/strong>（gesture arena），Button 更具體所以勝出&lt;/li>
&lt;li>點擊空白處 → 只有 GestureDetector 參與 → onTap 觸發&lt;/li>
&lt;/ul>
&lt;p>適合：想在空白處接收點擊，但&lt;strong>不干擾子元件自身的手勢處理&lt;/strong>。&lt;/p>
&lt;hr>
&lt;h2 id="手勢競爭gesture-arena">手勢競爭（Gesture Arena）&lt;/h2>
&lt;p>當多個 widget 都被命中並註冊了同一種手勢（如 onTap），Flutter 透過 &lt;strong>gesture arena&lt;/strong> 決定誰贏：&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">外層 GestureDetector (onTap: close)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> └── 內層 GestureDetector (onTap: close, translucent)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> └── Button (onTap: doSomething)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;ul>
&lt;li>點擊 Button → 三者都進入競爭 → &lt;strong>最深層（最具體）的 Button 勝出&lt;/strong> → 只執行 &lt;code>doSomething&lt;/code>&lt;/li>
&lt;li>點擊空白處 → 只有外層和內層參與 → &lt;strong>內層勝出&lt;/strong>（更具體）→ 執行 &lt;code>close&lt;/code>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="對照表">對照表&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>&lt;/th>
 &lt;th>deferToChild&lt;/th>
 &lt;th>opaque&lt;/th>
 &lt;th>translucent&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>空白處命中？&lt;/td>
 &lt;td>否&lt;/td>
 &lt;td>是&lt;/td>
 &lt;td>是&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>阻擋下層？&lt;/td>
 &lt;td>—&lt;/td>
 &lt;td>是&lt;/td>
 &lt;td>否&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>子元件能收到？&lt;/td>
 &lt;td>是&lt;/td>
 &lt;td>是（子元件更優先）&lt;/td>
 &lt;td>是（子元件更優先）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>典型用途&lt;/td>
 &lt;td>一般按鈕&lt;/td>
 &lt;td>barrier、全螢幕手勢&lt;/td>
 &lt;td>透明背景 dialog&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="實際應用透明-dialog-點擊穿透">實際應用：透明 Dialog 點擊穿透&lt;/h2>
&lt;p>自訂 Dialog 使用透明背景時，點擊空白處無法關閉 Dialog，因為內容區域攔截了所有點擊事件。&lt;/p>
&lt;h2 id="解法雙層-gesturedetector--hittestbehavior">解法：雙層 GestureDetector + HitTestBehavior&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="n">Get&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">dialog&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="kt">String&lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 外層：攔截 Dialog 以外的區域（barrier）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="n">GestureDetector&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nl">behavior:&lt;/span> &lt;span class="n">HitTestBehavior&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">opaque&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nl">onTap:&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">Get&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">back&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nl">child:&lt;/span> &lt;span class="n">Center&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 內層：攔截 Dialog 內的空白區域，同時讓子元件參與手勢競爭
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nl">child:&lt;/span> &lt;span class="n">GestureDetector&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nl">behavior:&lt;/span> &lt;span class="n">HitTestBehavior&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">translucent&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="nl">onTap:&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">Get&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">back&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nl">child:&lt;/span> &lt;span class="n">Material&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nl">color:&lt;/span> &lt;span class="n">Colors&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">transparent&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nl">child:&lt;/span> &lt;span class="n">MyDialog&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="為什麼有效">為什麼有效&lt;/h2>
&lt;ul>
&lt;li>外層 &lt;code>opaque&lt;/code>：確保 Dialog 外的透明區域也能接收點擊&lt;/li>
&lt;li>內層 &lt;code>translucent&lt;/code>：Dialog 內的空白處觸發 &lt;code>Get.back()&lt;/code>，但卡片和按鈕因為在 widget tree 中更深層（更具體），在手勢競爭中勝出，執行自身的 onTap/onPressed&lt;/li>
&lt;/ul>
&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>Dialog 外空白&lt;/td>
 &lt;td>外層 GestureDetector → 關閉&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Dialog 內空白（間距、標題）&lt;/td>
 &lt;td>內層 GestureDetector → 關閉&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>卡片&lt;/td>
 &lt;td>卡片自身 GestureDetector → 執行卡片邏輯&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>按鈕&lt;/td>
 &lt;td>按鈕 onPressed → 執行按鈕邏輯&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table></description><content:encoded><![CDATA[<h2 id="概念">概念</h2>
<p>當使用者點擊螢幕，Flutter 從 widget tree 根節點往下做 <strong>hit test</strong>（命中測試），判斷哪些 widget 被點擊命中。</p>
<p><code>HitTestBehavior</code> 控制 GestureDetector <strong>自己要不要算作被命中</strong>，以及<strong>如何影響子元件的命中判定</strong>。</p>
<hr>
<h2 id="三種模式">三種模式</h2>
<h3 id="defertochild預設"><code>deferToChild</code>（預設）</h3>
<blockquote>
<p>「我自己不算，讓子元件決定」</p></blockquote>





<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">GestureDetector (100x100)
</span></span><span class="line"><span class="ln">2</span><span class="cl">  └── Container (50x50, 置中)</span></span></code></pre></div><ul>
<li>點擊 Container 範圍內 → Container 被命中 → GestureDetector <strong>也算命中</strong> → onTap 觸發</li>
<li>點擊 Container 範圍外（空白 padding）→ 沒有子元件被命中 → GestureDetector <strong>不算命中</strong> → onTap <strong>不觸發</strong></li>
</ul>
<p>適合：只想在子元件的可視範圍內接收點擊。</p>
<hr>
<h3 id="opaque"><code>opaque</code></h3>
<blockquote>
<p>「整個區域都算我的，而且我擋住下面所有人」</p></blockquote>





<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">GestureDetector (100x100)  ← 整個 100x100 都算命中
</span></span><span class="line"><span class="ln">2</span><span class="cl">  └── Container (50x50, 置中)</span></span></code></pre></div><ul>
<li>點擊任何位置（包含空白處）→ GestureDetector <strong>都算命中</strong> → onTap 觸發</li>
<li>同時<strong>阻擋</strong>同層或下層的 widget 接收這個點擊</li>
</ul>
<p>適合：需要一個「全範圍點擊區域」，例如整個螢幕的 barrier。</p>
<hr>
<h3 id="translucent"><code>translucent</code></h3>
<blockquote>
<p>「整個區域都算我的，但我不擋別人」</p></blockquote>





<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">GestureDetector (100x100)  ← 整個 100x100 都算命中
</span></span><span class="line"><span class="ln">2</span><span class="cl">  └── Button (50x50, 置中)  ← Button 也算命中</span></span></code></pre></div><ul>
<li>點擊 Button 範圍 → <strong>兩者都進入手勢競爭</strong>（gesture arena），Button 更具體所以勝出</li>
<li>點擊空白處 → 只有 GestureDetector 參與 → onTap 觸發</li>
</ul>
<p>適合：想在空白處接收點擊，但<strong>不干擾子元件自身的手勢處理</strong>。</p>
<hr>
<h2 id="手勢競爭gesture-arena">手勢競爭（Gesture Arena）</h2>
<p>當多個 widget 都被命中並註冊了同一種手勢（如 onTap），Flutter 透過 <strong>gesture arena</strong> 決定誰贏：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">外層 GestureDetector (onTap: close)
</span></span><span class="line"><span class="ln">2</span><span class="cl">  └── 內層 GestureDetector (onTap: close, translucent)
</span></span><span class="line"><span class="ln">3</span><span class="cl">        └── Button (onTap: doSomething)</span></span></code></pre></div><ul>
<li>點擊 Button → 三者都進入競爭 → <strong>最深層（最具體）的 Button 勝出</strong> → 只執行 <code>doSomething</code></li>
<li>點擊空白處 → 只有外層和內層參與 → <strong>內層勝出</strong>（更具體）→ 執行 <code>close</code></li>
</ul>
<hr>
<h2 id="對照表">對照表</h2>
<table>
  <thead>
      <tr>
          <th></th>
          <th>deferToChild</th>
          <th>opaque</th>
          <th>translucent</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>空白處命中？</td>
          <td>否</td>
          <td>是</td>
          <td>是</td>
      </tr>
      <tr>
          <td>阻擋下層？</td>
          <td>—</td>
          <td>是</td>
          <td>否</td>
      </tr>
      <tr>
          <td>子元件能收到？</td>
          <td>是</td>
          <td>是（子元件更優先）</td>
          <td>是（子元件更優先）</td>
      </tr>
      <tr>
          <td>典型用途</td>
          <td>一般按鈕</td>
          <td>barrier、全螢幕手勢</td>
          <td>透明背景 dialog</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="實際應用透明-dialog-點擊穿透">實際應用：透明 Dialog 點擊穿透</h2>
<p>自訂 Dialog 使用透明背景時，點擊空白處無法關閉 Dialog，因為內容區域攔截了所有點擊事件。</p>
<h2 id="解法雙層-gesturedetector--hittestbehavior">解法：雙層 GestureDetector + HitTestBehavior</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">Get</span><span class="p">.</span><span class="n">dialog</span><span class="o">&lt;</span><span class="kt">String</span><span class="o">&gt;</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="c1">// 外層：攔截 Dialog 以外的區域（barrier）
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"></span>  <span class="n">GestureDetector</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nl">behavior:</span> <span class="n">HitTestBehavior</span><span class="p">.</span><span class="n">opaque</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nl">onTap:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="n">Get</span><span class="p">.</span><span class="n">back</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nl">child:</span> <span class="n">Center</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">      <span class="c1">// 內層：攔截 Dialog 內的空白區域，同時讓子元件參與手勢競爭
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span>      <span class="nl">child:</span> <span class="n">GestureDetector</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nl">behavior:</span> <span class="n">HitTestBehavior</span><span class="p">.</span><span class="n">translucent</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nl">onTap:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="n">Get</span><span class="p">.</span><span class="n">back</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nl">child:</span> <span class="n">Material</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">          <span class="nl">color:</span> <span class="n">Colors</span><span class="p">.</span><span class="n">transparent</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">          <span class="nl">child:</span> <span class="n">MyDialog</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="p">),</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">      <span class="p">),</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="p">),</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">  <span class="p">),</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">);</span></span></span></code></pre></div><h2 id="為什麼有效">為什麼有效</h2>
<ul>
<li>外層 <code>opaque</code>：確保 Dialog 外的透明區域也能接收點擊</li>
<li>內層 <code>translucent</code>：Dialog 內的空白處觸發 <code>Get.back()</code>，但卡片和按鈕因為在 widget tree 中更深層（更具體），在手勢競爭中勝出，執行自身的 onTap/onPressed</li>
</ul>
<h2 id="點擊結果">點擊結果</h2>
<table>
  <thead>
      <tr>
          <th>點擊位置</th>
          <th>行為</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Dialog 外空白</td>
          <td>外層 GestureDetector → 關閉</td>
      </tr>
      <tr>
          <td>Dialog 內空白（間距、標題）</td>
          <td>內層 GestureDetector → 關閉</td>
      </tr>
      <tr>
          <td>卡片</td>
          <td>卡片自身 GestureDetector → 執行卡片邏輯</td>
      </tr>
      <tr>
          <td>按鈕</td>
          <td>按鈕 onPressed → 執行按鈕邏輯</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>Freezed 選型評估</title><link>https://tarrragon.github.io/blog/work-log/freezed-%E9%81%B8%E5%9E%8B%E8%A9%95%E4%BC%B0/</link><pubDate>Thu, 26 Mar 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/freezed-%E9%81%B8%E5%9E%8B%E8%A9%95%E4%BC%B0/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>關聯 Ticket&lt;/strong>：0.2.0-W5-007
&lt;strong>決策結論&lt;/strong>：移除 freezed，採用 json_serializable + Equatable&lt;/p>&lt;/blockquote>
&lt;!-- 前言 -->
&lt;p>我設定了一個新的需求開了一個專案，我沒有專門指定開發的框架或者細節，我只有很簡單的先建立我需求的 spec 文件，這個文件當然並不完整，我是希望先讓AI做一個 原形，我會在 prototype 符合我的需求動起來之後再介入去調整設計。&lt;/p>
&lt;p>我的初始技術規範就只有我要用 flutter 去寫，所以AI就動了，但是在中間我發現 AI使用了 Freezed ，我並不喜歡在我 build 之外還要做一次
code generation 的動作，所以我就跟AI討論一次關於 Freezed 這種做法的必要性，至少在原形階段我覺得單純一點的 model 檔案沒有什麼不好，也不大會出錯，整體討論下來我選擇 捨棄 已有的 Freezed 程式碼，重構成更簡易的 版本，但是我覺得這個評估還是很有價值，所以讓AI重新整理了一次討論的內容作為備查。&lt;/p>
&lt;hr>
&lt;h2 id="1-freezed-是什麼">1. Freezed 是什麼&lt;/h2>
&lt;p>Freezed 是 Dart 的自動程式碼產生（code generation）套件，專門用來幫你自動生成資料類別（data class）裡那些重複的樣板程式碼（boilerplate），包括：&lt;/p>
&lt;ul>
&lt;li>&lt;code>copyWith&lt;/code>：複製一份物件，但可以只改其中幾個欄位，常用在狀態管理時產生新狀態&lt;/li>
&lt;li>&lt;code>==&lt;/code> / &lt;code>hashCode&lt;/code>：值相等比較（value equality），讓兩個內容相同的物件被判定為「相等」&lt;/li>
&lt;li>&lt;code>toString&lt;/code>：把物件轉成易讀的字串，方便除錯&lt;/li>
&lt;li>&lt;code>fromJson&lt;/code> / &lt;code>toJson&lt;/code>：JSON 序列化與反序列化，搭配 json_serializable 使用，處理前後端資料交換&lt;/li>
&lt;li>聯合型別（Union types）/ 密封類別（sealed class）：用 &lt;code>@freezed&lt;/code> 的多建構子語法，實現型別安全的多態模式&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>解決的核心問題&lt;/strong>：Dart 的類別預設是可變的（mutable），而且比較兩個物件時只看記憶體位址是否相同（identity equality），不會比較欄位內容。如果要手刻一個有 10 個欄位的不可變資料物件（immutable value object），大約需要 80-120 行程式碼，而且每次修改欄位都要同步更動 6 個地方（欄位宣告、建構子、&lt;code>copyWith&lt;/code>、&lt;code>==&lt;/code>、&lt;code>hashCode&lt;/code>、&lt;code>toJson&lt;/code>），非常容易漏改出錯。&lt;/p>
&lt;hr>
&lt;h2 id="2-優缺點分析">2. 優缺點分析&lt;/h2>
&lt;h3 id="優點">優點&lt;/h3>
&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>copyWith&lt;/td>
 &lt;td>建立修改後的新實例，State 管理必備&lt;/td>
 &lt;td>高（State 類別頻繁使用）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>== / hashCode&lt;/td>
 &lt;td>Value equality，Riverpod 用於判斷狀態是否變更&lt;/td>
 &lt;td>中&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>fromJson / toJson&lt;/td>
 &lt;td>JSON 序列化，WebSocket 通訊必備&lt;/td>
 &lt;td>高&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Immutability 保證&lt;/td>
 &lt;td>編譯期強制不可變&lt;/td>
 &lt;td>中&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Union types / sealed&lt;/td>
 &lt;td>型別安全的多態模式&lt;/td>
 &lt;td>視需求（本專案未使用）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="缺點">缺點&lt;/h3>
&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>build_runner 依賴&lt;/td>
 &lt;td>每次改模型需執行 &lt;code>dart run build_runner build&lt;/code>&lt;/td>
 &lt;td>高（開發體驗）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>生成檔案膨脹&lt;/td>
 &lt;td>12 個類別產生約 20 個 &lt;code>.freezed.dart&lt;/code> / &lt;code>.g.dart&lt;/code> 檔案&lt;/td>
 &lt;td>中&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>編譯時間&lt;/td>
 &lt;td>code generation 拖慢整體編譯&lt;/td>
 &lt;td>中&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>學習成本&lt;/td>
 &lt;td>需理解 &lt;code>part&lt;/code>、&lt;code>_$ClassName&lt;/code>、code generation 機制&lt;/td>
 &lt;td>中（新手門檻）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>版本耦合&lt;/td>
 &lt;td>freezed 3.x + json_serializable + build_runner 三者版本需相容&lt;/td>
 &lt;td>高（升級風險）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="3-適用場景判斷表">3. 適用場景判斷表&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>指標&lt;/th>
 &lt;th>適合 freezed&lt;/th>
 &lt;th>不需要 freezed&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>模型數量&lt;/td>
 &lt;td>50+ 個&lt;/td>
 &lt;td>&amp;lt; 20 個&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>欄位變動頻率&lt;/td>
 &lt;td>頻繁新增/修改欄位&lt;/td>
 &lt;td>欄位穩定（如對應後端 struct）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Union types 需求&lt;/td>
 &lt;td>大量使用（BLoC State/Event）&lt;/td>
 &lt;td>無或極少&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>巢狀 copyWith&lt;/td>
 &lt;td>深層巢狀物件需逐層複製&lt;/td>
 &lt;td>結構扁平&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>BLoC（State/Event union 是標配）&lt;/td>
 &lt;td>Riverpod（不依賴 union）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Dart 版本&lt;/td>
 &lt;td>&amp;lt; 3.0（無原生 sealed class）&lt;/td>
 &lt;td>&amp;gt;= 3.0（原生 sealed class 可用）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="4-替代方案比較">4. 替代方案比較&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>方案&lt;/th>
 &lt;th>描述&lt;/th>
 &lt;th>copyWith&lt;/th>
 &lt;th>== / hashCode&lt;/th>
 &lt;th>JSON&lt;/th>
 &lt;th>維護成本&lt;/th>
 &lt;th>code gen&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>A：維持 freezed&lt;/td>
 &lt;td>現狀不變&lt;/td>
 &lt;td>自動&lt;/td>
 &lt;td>自動&lt;/td>
 &lt;td>自動&lt;/td>
 &lt;td>低&lt;/td>
 &lt;td>需要&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>B：json_serializable + Equatable&lt;/td>
 &lt;td>保留 JSON 生成，手寫 copyWith，Equatable 處理 equality&lt;/td>
 &lt;td>手寫（僅 2 個 State）&lt;/td>
 &lt;td>Equatable（零 code gen）&lt;/td>
 &lt;td>自動&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>僅 JSON&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>C：完全手寫&lt;/td>
 &lt;td>移除所有 code generation&lt;/td>
 &lt;td>手寫&lt;/td>
 &lt;td>手寫&lt;/td>
 &lt;td>手寫&lt;/td>
 &lt;td>高&lt;/td>
 &lt;td>不需要&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>D：Dart 3 原生特性&lt;/td>
 &lt;td>使用 &lt;code>sealed class&lt;/code> + &lt;code>record&lt;/code> + &lt;code>final class&lt;/code>&lt;/td>
 &lt;td>手寫&lt;/td>
 &lt;td>record 自帶；class 需手寫或 Equatable&lt;/td>
 &lt;td>手寫或 json_serializable&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>可選&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="方案-b-詳細說明本專案推薦">方案 B 詳細說明（本專案推薦）&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>JSON 序列化&lt;/strong>：保留 json_serializable（10 個模型仍需 &lt;code>fromJson&lt;/code> / &lt;code>toJson&lt;/code>），build_runner 僅用於 JSON&lt;/li>
&lt;li>&lt;strong>Value equality&lt;/strong>：使用 Equatable 套件，繼承 &lt;code>Equatable&lt;/code> 並宣告 &lt;code>props&lt;/code> 即可，零 code generation&lt;/li>
&lt;li>&lt;strong>copyWith&lt;/strong>：僅 2 個 State 類別（SessionListState、ConversationState）需要，手寫工作量極小&lt;/li>
&lt;li>&lt;strong>Immutability&lt;/strong>：使用 &lt;code>final&lt;/code> 欄位 + 命名建構子，Dart 語言層級保證&lt;/li>
&lt;/ul>
&lt;h3 id="方案-d-補充說明dart-3-原生特性">方案 D 補充說明（Dart 3 原生特性）&lt;/h3>
&lt;p>Dart 3.0+ 引入的原生特性可部分替代 freezed：&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>關聯 Ticket</strong>：0.2.0-W5-007
<strong>決策結論</strong>：移除 freezed，採用 json_serializable + Equatable</p></blockquote>
<!-- 前言 -->
<p>我設定了一個新的需求開了一個專案，我沒有專門指定開發的框架或者細節，我只有很簡單的先建立我需求的 spec 文件，這個文件當然並不完整，我是希望先讓AI做一個 原形，我會在 prototype 符合我的需求動起來之後再介入去調整設計。</p>
<p>我的初始技術規範就只有我要用 flutter 去寫，所以AI就動了，但是在中間我發現 AI使用了 Freezed ，我並不喜歡在我 build 之外還要做一次
code generation 的動作，所以我就跟AI討論一次關於 Freezed 這種做法的必要性，至少在原形階段我覺得單純一點的 model 檔案沒有什麼不好，也不大會出錯，整體討論下來我選擇 捨棄 已有的 Freezed 程式碼，重構成更簡易的 版本，但是我覺得這個評估還是很有價值，所以讓AI重新整理了一次討論的內容作為備查。</p>
<hr>
<h2 id="1-freezed-是什麼">1. Freezed 是什麼</h2>
<p>Freezed 是 Dart 的自動程式碼產生（code generation）套件，專門用來幫你自動生成資料類別（data class）裡那些重複的樣板程式碼（boilerplate），包括：</p>
<ul>
<li><code>copyWith</code>：複製一份物件，但可以只改其中幾個欄位，常用在狀態管理時產生新狀態</li>
<li><code>==</code> / <code>hashCode</code>：值相等比較（value equality），讓兩個內容相同的物件被判定為「相等」</li>
<li><code>toString</code>：把物件轉成易讀的字串，方便除錯</li>
<li><code>fromJson</code> / <code>toJson</code>：JSON 序列化與反序列化，搭配 json_serializable 使用，處理前後端資料交換</li>
<li>聯合型別（Union types）/ 密封類別（sealed class）：用 <code>@freezed</code> 的多建構子語法，實現型別安全的多態模式</li>
</ul>
<p><strong>解決的核心問題</strong>：Dart 的類別預設是可變的（mutable），而且比較兩個物件時只看記憶體位址是否相同（identity equality），不會比較欄位內容。如果要手刻一個有 10 個欄位的不可變資料物件（immutable value object），大約需要 80-120 行程式碼，而且每次修改欄位都要同步更動 6 個地方（欄位宣告、建構子、<code>copyWith</code>、<code>==</code>、<code>hashCode</code>、<code>toJson</code>），非常容易漏改出錯。</p>
<hr>
<h2 id="2-優缺點分析">2. 優缺點分析</h2>
<h3 id="優點">優點</h3>
<table>
  <thead>
      <tr>
          <th>功能</th>
          <th>說明</th>
          <th>受益程度</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>copyWith</td>
          <td>建立修改後的新實例，State 管理必備</td>
          <td>高（State 類別頻繁使用）</td>
      </tr>
      <tr>
          <td>== / hashCode</td>
          <td>Value equality，Riverpod 用於判斷狀態是否變更</td>
          <td>中</td>
      </tr>
      <tr>
          <td>fromJson / toJson</td>
          <td>JSON 序列化，WebSocket 通訊必備</td>
          <td>高</td>
      </tr>
      <tr>
          <td>Immutability 保證</td>
          <td>編譯期強制不可變</td>
          <td>中</td>
      </tr>
      <tr>
          <td>Union types / sealed</td>
          <td>型別安全的多態模式</td>
          <td>視需求（本專案未使用）</td>
      </tr>
  </tbody>
</table>
<h3 id="缺點">缺點</h3>
<table>
  <thead>
      <tr>
          <th>問題</th>
          <th>說明</th>
          <th>影響程度</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>build_runner 依賴</td>
          <td>每次改模型需執行 <code>dart run build_runner build</code></td>
          <td>高（開發體驗）</td>
      </tr>
      <tr>
          <td>生成檔案膨脹</td>
          <td>12 個類別產生約 20 個 <code>.freezed.dart</code> / <code>.g.dart</code> 檔案</td>
          <td>中</td>
      </tr>
      <tr>
          <td>編譯時間</td>
          <td>code generation 拖慢整體編譯</td>
          <td>中</td>
      </tr>
      <tr>
          <td>學習成本</td>
          <td>需理解 <code>part</code>、<code>_$ClassName</code>、code generation 機制</td>
          <td>中（新手門檻）</td>
      </tr>
      <tr>
          <td>版本耦合</td>
          <td>freezed 3.x + json_serializable + build_runner 三者版本需相容</td>
          <td>高（升級風險）</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="3-適用場景判斷表">3. 適用場景判斷表</h2>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>適合 freezed</th>
          <th>不需要 freezed</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>模型數量</td>
          <td>50+ 個</td>
          <td>&lt; 20 個</td>
      </tr>
      <tr>
          <td>欄位變動頻率</td>
          <td>頻繁新增/修改欄位</td>
          <td>欄位穩定（如對應後端 struct）</td>
      </tr>
      <tr>
          <td>Union types 需求</td>
          <td>大量使用（BLoC State/Event）</td>
          <td>無或極少</td>
      </tr>
      <tr>
          <td>巢狀 copyWith</td>
          <td>深層巢狀物件需逐層複製</td>
          <td>結構扁平</td>
      </tr>
      <tr>
          <td>團隊規模</td>
          <td>多人協作，需統一生成減少出錯</td>
          <td>小團隊或個人</td>
      </tr>
      <tr>
          <td>狀態管理</td>
          <td>BLoC（State/Event union 是標配）</td>
          <td>Riverpod（不依賴 union）</td>
      </tr>
      <tr>
          <td>Dart 版本</td>
          <td>&lt; 3.0（無原生 sealed class）</td>
          <td>&gt;= 3.0（原生 sealed class 可用）</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="4-替代方案比較">4. 替代方案比較</h2>
<table>
  <thead>
      <tr>
          <th>方案</th>
          <th>描述</th>
          <th>copyWith</th>
          <th>== / hashCode</th>
          <th>JSON</th>
          <th>維護成本</th>
          <th>code gen</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>A：維持 freezed</td>
          <td>現狀不變</td>
          <td>自動</td>
          <td>自動</td>
          <td>自動</td>
          <td>低</td>
          <td>需要</td>
      </tr>
      <tr>
          <td>B：json_serializable + Equatable</td>
          <td>保留 JSON 生成，手寫 copyWith，Equatable 處理 equality</td>
          <td>手寫（僅 2 個 State）</td>
          <td>Equatable（零 code gen）</td>
          <td>自動</td>
          <td>中</td>
          <td>僅 JSON</td>
      </tr>
      <tr>
          <td>C：完全手寫</td>
          <td>移除所有 code generation</td>
          <td>手寫</td>
          <td>手寫</td>
          <td>手寫</td>
          <td>高</td>
          <td>不需要</td>
      </tr>
      <tr>
          <td>D：Dart 3 原生特性</td>
          <td>使用 <code>sealed class</code> + <code>record</code> + <code>final class</code></td>
          <td>手寫</td>
          <td>record 自帶；class 需手寫或 Equatable</td>
          <td>手寫或 json_serializable</td>
          <td>中</td>
          <td>可選</td>
      </tr>
  </tbody>
</table>
<h3 id="方案-b-詳細說明本專案推薦">方案 B 詳細說明（本專案推薦）</h3>
<ul>
<li><strong>JSON 序列化</strong>：保留 json_serializable（10 個模型仍需 <code>fromJson</code> / <code>toJson</code>），build_runner 僅用於 JSON</li>
<li><strong>Value equality</strong>：使用 Equatable 套件，繼承 <code>Equatable</code> 並宣告 <code>props</code> 即可，零 code generation</li>
<li><strong>copyWith</strong>：僅 2 個 State 類別（SessionListState、ConversationState）需要，手寫工作量極小</li>
<li><strong>Immutability</strong>：使用 <code>final</code> 欄位 + 命名建構子，Dart 語言層級保證</li>
</ul>
<h3 id="方案-d-補充說明dart-3-原生特性">方案 D 補充說明（Dart 3 原生特性）</h3>
<p>Dart 3.0+ 引入的原生特性可部分替代 freezed：</p>
<table>
  <thead>
      <tr>
          <th>Dart 3 特性</th>
          <th>替代 freezed 功能</th>
          <th>限制</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>sealed class</code></td>
          <td>Union types / when / switch</td>
          <td>不自動生成 copyWith、==</td>
      </tr>
      <tr>
          <td><code>final class</code></td>
          <td>Immutability 保證</td>
          <td>不自動生成 boilerplate</td>
      </tr>
      <tr>
          <td>Records <code>(int, String)</code></td>
          <td>輕量 value type（自帶 ==）</td>
          <td>無命名欄位語法糖有限</td>
      </tr>
      <tr>
          <td>Pattern matching</td>
          <td>exhaustive switch</td>
          <td>僅用於控制流，不生成程式碼</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="5-與狀態管理框架的關係">5. 與狀態管理框架的關係</h2>
<h3 id="riverpod-的-value-equality-機制">Riverpod 的 Value Equality 機制</h3>
<p><strong>常見誤解</strong>：「Riverpod 需要 freezed 才能正確判斷狀態變更」。</p>
<p><strong>事實釐清</strong>：</p>
<ol>
<li>Dart 預設是 <strong>identity equality</strong>（比較記憶體位址）。兩個欄位完全相同的新物件，<code>==</code> 仍為 <code>false</code></li>
<li>Riverpod 在 <code>state = newValue</code> 時使用 <code>==</code> 判斷是否通知 listener rebuild。相同則不通知</li>
<li>Riverpod <strong>本身不做任何額外 equality 優化</strong>，完全依賴物件自身的 <code>==</code> 運算子</li>
</ol>
<h3 id="此專案的實際影響">此專案的實際影響</h3>
<p>在本專案中，不使用 value equality 的影響極小：</p>
<table>
  <thead>
      <tr>
          <th>因素</th>
          <th>說明</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>狀態更新來源</td>
          <td>每次都是收到 WebSocket 新訊息才更新，值幾乎必然不同</td>
      </tr>
      <tr>
          <td>AsyncData 包裝</td>
          <td>Riverpod 的 <code>AsyncData</code> 每次都是新實例，外層已經不等</td>
      </tr>
      <tr>
          <td>UI rebuild 成本</td>
          <td>Flutter 本身的 Widget diff 機制已足夠高效，多餘 rebuild 不構成效能問題</td>
      </tr>
  </tbody>
</table>
<p><strong>結論</strong>：Equatable 零 code generation 即可解決 value equality 需求。在本專案場景下，甚至完全不處理也感受不到效能差異。</p>
<hr>
<h2 id="6-決策流程">6. 決策流程</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">是否需要 freezed?
</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">    v
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">模型數量 &gt; 50?
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    +-- 是 --&gt; 強烈建議使用 freezed
</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">使用 union types / sealed class?
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    +-- 大量使用 --&gt; 建議使用 freezed（或 Dart 3 sealed class）
</span></span><span class="line"><span class="ln">10</span><span class="cl">    +-- 未使用 ↓
</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">    +-- 是 --&gt; 建議使用 freezed（減少同步維護）
</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">需要深層巢狀 copyWith?
</span></span><span class="line"><span class="ln">17</span><span class="cl">    +-- 是 --&gt; 建議使用 freezed
</span></span><span class="line"><span class="ln">18</span><span class="cl">    +-- 否 ↓
</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">需要 JSON 序列化?
</span></span><span class="line"><span class="ln">21</span><span class="cl">    +-- 是 --&gt; json_serializable 即可
</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><span class="line"><span class="ln">24</span><span class="cl">需要 value equality?
</span></span><span class="line"><span class="ln">25</span><span class="cl">    +-- 是 --&gt; Equatable 或手寫 ==
</span></span><span class="line"><span class="ln">26</span><span class="cl">    +-- 否 --&gt; 完全不需要 freezed</span></span></code></pre></div><hr>
<h2 id="7-本專案評估結論">7. 本專案評估結論</h2>
<h3 id="現況盤點">現況盤點</h3>
<table>
  <thead>
      <tr>
          <th>項目</th>
          <th>數量</th>
          <th>說明</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>@freezed</code> 類別</td>
          <td>12 個</td>
          <td>規模小</td>
      </tr>
      <tr>
          <td>資料模型（JSON）</td>
          <td>10 個</td>
          <td>SessionInfo, SessionEvent 等</td>
      </tr>
      <tr>
          <td>UI State</td>
          <td>2 個</td>
          <td>SessionListState, ConversationState</td>
      </tr>
      <tr>
          <td>Union types 使用</td>
          <td>0 個</td>
          <td>未使用 freezed 殺手功能</td>
      </tr>
      <tr>
          <td>巢狀 copyWith</td>
          <td>0 處</td>
          <td>結構扁平</td>
      </tr>
      <tr>
          <td>欄位變動頻率</td>
          <td>低</td>
          <td>對應 Go struct，後端穩定後前端不常改</td>
      </tr>
  </tbody>
</table>
<h3 id="評估對照">評估對照</h3>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>本專案狀況</th>
          <th>結論</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>模型數量</td>
          <td>12 個（&lt; 20）</td>
          <td>不需要</td>
      </tr>
      <tr>
          <td>欄位穩定度</td>
          <td>對應 Go struct，穩定</td>
          <td>不需要</td>
      </tr>
      <tr>
          <td>Union types</td>
          <td>0 個</td>
          <td>不需要</td>
      </tr>
      <tr>
          <td>狀態管理</td>
          <td>Riverpod（非 BLoC）</td>
          <td>不需要</td>
      </tr>
      <tr>
          <td>巢狀 copyWith</td>
          <td>無</td>
          <td>不需要</td>
      </tr>
      <tr>
          <td>團隊規模</td>
          <td>小</td>
          <td>不需要</td>
      </tr>
  </tbody>
</table>
<h3 id="決策">決策</h3>
<p><strong>移除 freezed，採用方案 B</strong>：保留 json_serializable 處理 JSON 序列化，使用 Equatable 處理 value equality，手寫 copyWith（僅 2 個 State 類別）。</p>
<p><strong>理由</strong>：freezed 在本專案中只用到最基礎功能（copyWith、==、JSON），全部可被更輕量的方案替代。移除後減少 build_runner 依賴範圍、消除生成檔案膨脹、降低版本耦合風險。</p>
<hr>
<h2 id="8-遷移檢查清單">8. 遷移檢查清單</h2>
<h3 id="準備階段">準備階段</h3>
<ul>
<li><input disabled="" type="checkbox"> 確認所有 <code>@freezed</code> 類別清單（12 個）</li>
<li><input disabled="" type="checkbox"> 備份現有生成檔案</li>
<li><input disabled="" type="checkbox"> 確認 json_serializable 獨立使用的配置方式</li>
</ul>
<h3 id="資料模型遷移10-個">資料模型遷移（10 個）</h3>
<ul>
<li><input disabled="" type="checkbox"> 移除 <code>@freezed</code> 註解，改為 <code>@JsonSerializable</code> + <code>final class</code></li>
<li><input disabled="" type="checkbox"> 保留 <code>part '*.g.dart'</code>（json_serializable 仍需要）</li>
<li><input disabled="" type="checkbox"> 移除 <code>part '*.freezed.dart'</code></li>
<li><input disabled="" type="checkbox"> 繼承 <code>Equatable</code>，宣告 <code>props</code></li>
<li><input disabled="" type="checkbox"> 手寫建構子（<code>const</code> 建構子 + <code>final</code> 欄位）</li>
<li><input disabled="" type="checkbox"> 確認 <code>fromJson</code> / <code>toJson</code> 正常運作</li>
</ul>
<h3 id="ui-state-遷移2-個">UI State 遷移（2 個）</h3>
<ul>
<li><input disabled="" type="checkbox"> 同上資料模型遷移步驟</li>
<li><input disabled="" type="checkbox"> 手寫 <code>copyWith</code> 方法</li>
<li><input disabled="" type="checkbox"> 確認 Riverpod 狀態更新行為正確</li>
</ul>
<h3 id="清理階段">清理階段</h3>
<ul>
<li><input disabled="" type="checkbox"> 刪除所有 <code>.freezed.dart</code> 生成檔案</li>
<li><input disabled="" type="checkbox"> 從 <code>pubspec.yaml</code> 移除 <code>freezed</code> 和 <code>freezed_annotation</code> 依賴</li>
<li><input disabled="" type="checkbox"> 執行 <code>dart run build_runner build</code> 確認 json_serializable 正常</li>
<li><input disabled="" type="checkbox"> 執行全量測試確認無回歸</li>
<li><input disabled="" type="checkbox"> <code>dart analyze</code> 0 issues</li>
</ul>
<hr>
<h2 id="參考資源">參考資源</h2>
<ul>
<li><a href="https://pub.dev/packages/freezed">freezed 套件</a></li>
<li><a href="https://pub.dev/packages/json_serializable">json_serializable 套件</a></li>
<li><a href="https://pub.dev/packages/equatable">equatable 套件</a></li>
<li><a href="https://dart.dev/language/patterns">Dart 3 Patterns and Sealed Classes</a></li>
</ul>
<hr>
<p><strong>Last Updated</strong>: 2026-03-26
<strong>Version</strong>: 1.0.0</p>
]]></content:encoded></item><item><title>系統化除錯方法論</title><link>https://tarrragon.github.io/blog/record/%E7%B3%BB%E7%B5%B1%E5%8C%96%E9%99%A4%E9%8C%AF%E6%96%B9%E6%B3%95%E8%AB%96/</link><pubDate>Fri, 26 Sep 2025 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/record/%E7%B3%BB%E7%B5%B1%E5%8C%96%E9%99%A4%E9%8C%AF%E6%96%B9%E6%B3%95%E8%AB%96/</guid><description>&lt;h2 id="為什麼需要系統化除錯方法論">為什麼需要系統化除錯方法論&lt;/h2>
&lt;p>除錯不是試錯過程，是品質提升的系統性工程。隨機修復會產生隨機品質。系統化除錯產生一致的架構改善。&lt;/p></description><content:encoded><![CDATA[<h2 id="為什麼需要系統化除錯方法論">為什麼需要系統化除錯方法論</h2>
<p>除錯不是試錯過程，是品質提升的系統性工程。隨機修復會產生隨機品質。系統化除錯產生一致的架構改善。</p>
<p>當AI協作處理複雜程式問題時，系統化除錯方法論成為唯一的執行準則。模糊的修復策略會產生模糊的結果。明確的除錯方法論產生一致的品質改善。</p>
<h2 id="系統化除錯的本質">系統化除錯的本質</h2>
<h3 id="系統化除錯不是什麼">系統化除錯不是什麼</h3>
<p>系統化除錯不是：</p>
<ul>
<li><strong>症狀修復</strong>：不掩蓋警告，只找根本原因</li>
<li><strong>批量處理</strong>：不自動修復，只精確分析</li>
<li><strong>簡單先行</strong>：不從容易修的開始，只按風險優先級</li>
<li><strong>表面清理</strong>：不只消除警告，只完成未完成的設計</li>
</ul>
<h3 id="系統化除錯是什麼">系統化除錯是什麼</h3>
<p>系統化除錯是：</p>
<ul>
<li><strong>根因分析</strong>：明確區分未完成實作vs過度設計</li>
<li><strong>風險導向</strong>：按業務風險和架構影響排序修復</li>
<li><strong>主從分工</strong>：主線程管控進度，代理人執行修復</li>
<li><strong>品質提升</strong>：每次修復都強化程式設計完整性</li>
</ul>
<h2 id="除錯的第一原則根因分析優先">除錯的第一原則：根因分析優先</h2>
<h3 id="問題本質分類">問題本質分類</h3>
<p>每個unused警告都屬於以下三類之一：</p>
<h4 id="未完成實作">未完成實作</h4>
<ul>
<li><strong>識別</strong>：功能設計完整但驗證邏輯缺失</li>
<li><strong>處理</strong>：補完實作而非移除程式碼</li>
<li><strong>範例</strong>：測試中建立了secondImport變數但未驗證重複匯入行為</li>
</ul>
<h4 id="過度設計">過度設計</h4>
<ul>
<li><strong>識別</strong>：功能已完成但包含不必要的複雜性</li>
<li><strong>處理</strong>：移除多餘程式碼保持精簡設計</li>
<li><strong>範例</strong>：建立獨立服務實例但架構採用單例模式</li>
</ul>
<h4 id="程式碼風格問題">程式碼風格問題</h4>
<ul>
<li><strong>識別</strong>：邏輯正確但命名或結構不一致</li>
<li><strong>處理</strong>：重構改善可讀性和一致性</li>
<li><strong>範例</strong>：使用File物件但混用path字串操作</li>
</ul>
<h3 id="分析判斷標準">分析判斷標準</h3>
<ul>
<li>變數有明確的業務意圖 → 未完成實作</li>
<li>變數創建後立即被丟棄 → 過度設計</li>
<li>變數使用方式不一致 → 程式碼風格問題</li>
</ul>
<p>不存在「可能是」的情況。如果無法明確分類，則需要更深入的程式碼分析。</p>
<h3 id="範例完整的根因分析">範例：完整的根因分析</h3>
<h4 id="情境test檔案中unused變數-initialmemory">情境：test檔案中unused變數 &lsquo;initialMemory&rsquo;</h4>
<h5 id="錯誤分析">錯誤分析</h5>
<p>「這個變數沒用到，直接刪掉。」</p>
<h5 id="正確分析過程">正確分析過程</h5>
<ol>
<li><strong>變數意圖</strong>：記憶體效率測試的基線測量</li>
<li><strong>使用模式</strong>：建立但未在驗證邏輯中引用</li>
<li><strong>分類判斷</strong>：未完成實作（測試設計完整但驗證缺失）</li>
<li><strong>修復策略</strong>：補完基線比較邏輯而非移除變數</li>
</ol>
<h2 id="除錯的第二原則風險導向排序">除錯的第二原則：風險導向排序</h2>
<h3 id="檔案風險等級">檔案風險等級</h3>
<p>檔案修復必須按風險等級執行：</p>
<h4 id="高風險檔案立即修復">高風險檔案（立即修復）</h4>
<ul>
<li><strong>核心業務邏輯</strong>：Domain層實作檔案</li>
<li><strong>基礎設施元件</strong>：Database、Service、Repository</li>
<li><strong>關鍵測試</strong>：端到端測試、整合測試</li>
</ul>
<h4 id="中風險檔案計畫修復">中風險檔案（計畫修復）</h4>
<ul>
<li><strong>輔助功能</strong>：Utility、Helper類別</li>
<li><strong>測試工具</strong>：Mock、TestData產生器</li>
<li><strong>配置檔案</strong>：Configuration、Environment設定</li>
</ul>
<h4 id="低風險檔案可延後修復">低風險檔案（可延後修復）</h4>
<ul>
<li><strong>單元測試變數</strong>：純測試輔助變數</li>
<li><strong>範例程式碼</strong>：Demo、Sample實作</li>
<li><strong>文件產生器</strong>：Documentation工具</li>
</ul>
<h3 id="風險評估標準">風險評估標準</h3>
<ul>
<li>影響核心功能 → 高風險</li>
<li>影響開發效率 → 中風險</li>
<li>純粹警告清理 → 低風險</li>
</ul>
<p>每個檔案只能歸類到一個風險等級。無法分類表示需要進一步的架構分析。</p>
<h3 id="修復優先序執行規則">修復優先序執行規則</h3>
<ul>
<li>高風險檔案：立即修復，不考慮複雜度</li>
<li>中風險檔案：當前Sprint完成</li>
<li>低風險檔案：下個版本或維護期處理</li>
</ul>
<h2 id="除錯的第三原則主從分工模式">除錯的第三原則：主從分工模式</h2>
<h3 id="角色定義">角色定義</h3>
<p>系統化除錯採用明確的角色分工：</p>
<h4 id="主線程職責">主線程職責</h4>
<ul>
<li><strong>進度管控</strong>：追蹤修復狀態和整體進展</li>
<li><strong>策略決策</strong>：確定修復優先序和資源配置</li>
<li><strong>品質檢查</strong>：驗證修復結果符合品質要求</li>
<li><strong>工作記錄</strong>：更新工作日誌避免遺漏</li>
</ul>
<h4 id="代理人職責">代理人職責</h4>
<ul>
<li><strong>詳細分析</strong>：深入檢查程式碼設計意圖</li>
<li><strong>修復執行</strong>：實際編寫和修改程式碼</li>
<li><strong>測試驗證</strong>：確保修復後功能正常</li>
<li><strong>技術回報</strong>：提供修復細節和影響評估</li>
</ul>
<h3 id="協作執行規則">協作執行規則</h3>
<ul>
<li>主線程永不直接修復程式碼</li>
<li>代理人永不決定修復優先序</li>
<li>每修復一個檔案都必須更新工作日誌</li>
<li>所有修復決策都必須通過主線程確認</li>
</ul>
<h3 id="範例完整的協作流程">範例：完整的協作流程</h3>
<h4 id="情境發現5個檔案有unused警告">情境：發現5個檔案有unused警告</h4>
<h5 id="主線程執行">主線程執行</h5>
<ol>
<li><strong>風險評估</strong>：將5個檔案按業務風險分類</li>
<li><strong>優先排序</strong>：確定高風險檔案的修復順序</li>
<li><strong>委託分析</strong>：要求代理人分析第一個檔案</li>
<li><strong>進度追蹤</strong>：更新TodoList標記當前處理檔案</li>
</ol>
<h5 id="代理人執行">代理人執行</h5>
<ol>
<li><strong>根因分析</strong>：判斷unused變數屬於未完成實作vs過度設計</li>
<li><strong>修復實施</strong>：根據分析結果執行對應的修復策略</li>
<li><strong>結果驗證</strong>：執行靜態分析工具確認警告消除</li>
<li><strong>影響報告</strong>：回報修復內容和對整體架構的影響</li>
</ol>
<h5 id="主線程確認">主線程確認</h5>
<ol>
<li><strong>驗證結果</strong>：檢查靜態分析工具輸出確認修復成功</li>
<li><strong>更新記錄</strong>：在工作日誌中記錄修復成果</li>
<li><strong>繼續協作</strong>：標記完成並委託下一個檔案分析</li>
</ol>
<h2 id="品質標準">品質標準</h2>
<h3 id="修復完成的判斷標準">修復完成的判斷標準</h3>
<p>每個檔案修復完成必須滿足：</p>
<ul>
<li><strong>警告消除</strong>：靜態分析工具不再顯示該檔案的unused警告</li>
<li><strong>功能完整</strong>：所有測試通過，不引入新的錯誤</li>
<li><strong>架構一致</strong>：修復符合Clean Architecture分層原則</li>
<li><strong>文件更新</strong>：工作日誌記錄修復內容和影響</li>
</ul>
<h3 id="整體品質提升指標">整體品質提升指標</h3>
<ul>
<li><strong>警告減少率</strong>：unused警告數量持續下降</li>
<li><strong>功能完整性</strong>：修復過程中完成更多未完成的實作</li>
<li><strong>架構一致性</strong>：程式碼風格和設計模式更加統一</li>
<li><strong>可維護性</strong>：程式碼可讀性和邏輯清晰度提升</li>
</ul>
<h3 id="品質驗證機制">品質驗證機制</h3>
<ul>
<li>每個檔案修復後立即執行靜態分析工具驗證</li>
<li>定期檢查整體警告數量變化趨勢</li>
<li>記錄修復過程中發現的架構改善機會</li>
<li>確認每次修復都強化而非弱化程式品質</li>
</ul>
<h2 id="執行流程">執行流程</h2>
<h3 id="標準修復流程">標準修復流程</h3>
<ol>
<li>
<p><strong>問題評估</strong>
執行靜態分析工具識別所有unused警告</p>
</li>
<li>
<p><strong>風險分析</strong>
將含有警告的檔案按風險等級分類</p>
</li>
<li>
<p><strong>優先排序</strong>
確定高風險檔案的修復順序</p>
</li>
<li>
<p><strong>逐檔修復</strong>
按優先序對每個檔案執行：</p>
<ul>
<li>委託代理人詳細分析</li>
<li>根因判斷(未完成實作vs過度設計vs程式碼風格)</li>
<li>執行對應修復策略</li>
<li>驗證修復結果</li>
<li>更新工作記錄</li>
</ul>
</li>
<li>
<p><strong>整體驗證</strong>
確認警告總數下降且無新錯誤引入</p>
</li>
</ol>
<h3 id="修復策略對應表">修復策略對應表</h3>
<table>
  <thead>
      <tr>
          <th>根因類型</th>
          <th>修復策略</th>
          <th>驗證標準</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>未完成實作</td>
          <td>補完功能實作</td>
          <td>變數在邏輯中被正確使用</td>
      </tr>
      <tr>
          <td>過度設計</td>
          <td>移除多餘程式碼</td>
          <td>功能完整但程式碼更簡潔</td>
      </tr>
      <tr>
          <td>程式碼風格</td>
          <td>重構改善一致性</td>
          <td>邏輯不變但可讀性提升</td>
      </tr>
  </tbody>
</table>
<h3 id="例外處理原則">例外處理原則</h3>
<ul>
<li><strong>分析器誤報</strong>：確認變數確實被使用後保持現狀</li>
<li><strong>架構衝突</strong>：優先解決架構問題後再處理警告</li>
<li><strong>測試失敗</strong>：立即修復測試問題，暫停警告修復</li>
<li><strong>複雜邊界</strong>：分解為更小的問題單位處理</li>
</ul>
<h2 id="成果評估">成果評估</h2>
<h3 id="量化指標">量化指標</h3>
<ul>
<li><strong>警告消除數量</strong>：已修復的unused警告總數</li>
<li><strong>警告減少率</strong>：相對於初始狀態的改善百分比</li>
<li><strong>檔案修復數量</strong>：完成修復的檔案總數</li>
<li><strong>架構改善項目</strong>：修復過程中完成的設計改善</li>
</ul>
<h3 id="質化評估">質化評估</h3>
<ul>
<li><strong>根因解決率</strong>：真正解決問題vs僅消除警告的比例</li>
<li><strong>架構一致性</strong>：程式碼風格和設計模式統一程度</li>
<li><strong>功能完整性</strong>：修復過程中完成的未完成實作數量</li>
<li><strong>可維護性提升</strong>：程式碼清晰度和邏輯簡潔性改善</li>
</ul>
<h3 id="實戰案例v0819成果">實戰案例：v0.8.19成果</h3>
<p><strong>量化成果</strong>：</p>
<ul>
<li>初始警告：49個</li>
<li>最終警告：25個</li>
<li>改善率：49.0%</li>
<li>修復檔案：7個高風險檔案</li>
</ul>
<p><strong>質化成果</strong>：</p>
<ul>
<li>補完3個未完成的功能實作</li>
<li>移除4處過度設計的複雜程式碼</li>
<li>統一5個檔案的程式碼風格</li>
<li>解決2個架構不一致問題</li>
</ul>
<h2 id="持續改進">持續改進</h2>
<h3 id="方法論優化">方法論優化</h3>
<p>系統化除錯方法論必須持續優化：</p>
<ul>
<li><strong>記錄邊界案例</strong>：遇到的特殊情況和處理方式</li>
<li><strong>更新風險分類</strong>：基於實戰經驗調整風險評估標準</li>
<li><strong>改進協作模式</strong>：優化主線程和代理人的分工效率</li>
<li><strong>補充修復策略</strong>：新增針對特定問題類型的處理方法</li>
</ul>
<h3 id="知識累積">知識累積</h3>
<p>每次系統化除錯的經驗都必須沉澱為方法論改進：</p>
<ul>
<li>成功的修復策略納入標準流程</li>
<li>失效的方法從規範中移除</li>
<li>新發現的問題模式補充到分類標準</li>
<li>協作過程中的效率改善點持續優化</li>
</ul>
<h2 id="結論">結論</h2>
<p>系統化除錯方法論是品質提升的執行標準。它的價值在精確，它的目的是完成設計。</p>
<p>每個修復都是一次架構改善。每個分析都是一次設計檢視。每個協作都是一次品質提升。</p>
<p>執行系統化除錯就是執行品質標準。遵循這個方法論，我們能持續強化程式架構完整性和設計一致性。</p>
<p>這是工程規範，確保每次除錯都提升而非妥協專案品質。</p>
<h2 id="延伸套用到-linux-系統除錯">延伸：套用到 Linux 系統除錯</h2>
<p>這套方法論是語言與領域無關的通則。把它落到 Linux 系統除錯這個具體領域——「讀權威狀態而非肉眼猜表象」的紀律、症狀到情境的分流、逐層定位——見 <a href="/blog/linux/debug/diagnosis-read-authoritative-state/" data-link-title="診斷心法：讀權威狀態，不靠肉眼猜表象" data-link-desc="Linux 上一個現象看起來像 A 卻可能是 B、想建立一套先讀權威狀態再下判斷的除錯紀律、避免看畫面就猜而猜錯時回來讀">Linux 除錯與診斷：診斷心法</a>。那裡用實機案例（把鎖屏誤判兩次的教訓）展示同一套系統化紀律在 Linux 現場長什麼樣。</p>]]></content:encoded></item><item><title>flutter 可以使用的 togglebutton 樣式</title><link>https://tarrragon.github.io/blog/work-log/flutter-%E5%8F%AF%E4%BB%A5%E4%BD%BF%E7%94%A8%E7%9A%84-togglebutton-%E6%A8%A3%E5%BC%8F/</link><pubDate>Tue, 09 Sep 2025 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter-%E5%8F%AF%E4%BB%A5%E4%BD%BF%E7%94%A8%E7%9A%84-togglebutton-%E6%A8%A3%E5%BC%8F/</guid><description>&lt;h2 id="有製作切換選項的按鈕需求查詢之後得到三種可用的樣式">有製作切換選項的按鈕需求，查詢之後得到三種可用的樣式&lt;/h2>
&lt;ol>
&lt;li>ToggleButtons&lt;/li>
&lt;/ol>
&lt;figure>&lt;img src="https://tarrragon.github.io/blog/work-log/flutter_toggle_button/ToggleButtons.png"
 alt="ToggleButtons 樣式">
&lt;/figure>






&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="n">ToggleButtons&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nl">isSelected:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">controller&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">isFirstSelected&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">controller&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">isSecondSelected&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nl">onPressed:&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="kt">int&lt;/span> &lt;span class="n">index&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&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">controller&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">toggleSelection&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">index&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nl">children:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="n">Text&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;選項一&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="n">Text&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;選項二&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="nl">selectedColor:&lt;/span> &lt;span class="n">Colors&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">white&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nl">fillColor:&lt;/span> &lt;span class="n">Colors&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">blue&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nl">borderColor:&lt;/span> &lt;span class="n">Colors&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">blue&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nl">borderRadius:&lt;/span> &lt;span class="n">BorderRadius&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">circular&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="m">8&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;ol>
&lt;li>SegmentedButton (Flutter 3.12+)&lt;/li>
&lt;/ol>
&lt;figure>&lt;img src="https://tarrragon.github.io/blog/work-log/flutter_toggle_button/SegmentedButton.png"
 alt="SegmentedButton 樣式">
&lt;/figure>






&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="n">SegmentedButton&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="kt">String&lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nl">segments:&lt;/span> &lt;span class="p">[&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">ButtonSegment&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">value:&lt;/span> &lt;span class="s1">&amp;#39;option1&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nl">label:&lt;/span> &lt;span class="n">Text&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;選項一&amp;#39;&lt;/span>&lt;span class="p">)),&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">ButtonSegment&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">value:&lt;/span> &lt;span class="s1">&amp;#39;option2&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nl">label:&lt;/span> &lt;span class="n">Text&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;選項二&amp;#39;&lt;/span>&lt;span class="p">)),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nl">selected:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="n">controller&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">selectedOption&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nl">onSelectionChanged:&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">Set&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="kt">String&lt;/span>&lt;span class="o">&amp;gt;&lt;/span> &lt;span class="n">newSelection&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="n">controller&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">updateSelection&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">newSelection&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">first&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;ol>
&lt;li>CupertinoSlidingSegmentedControl (iOS 風格)&lt;/li>
&lt;/ol>
&lt;figure>&lt;img src="https://tarrragon.github.io/blog/work-log/flutter_toggle_button/CupertinoSlidingSegmentedControl.png"
 alt="CupertinoSlidingSegmentedControl 樣式">
&lt;/figure>






&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="n">CupertinoSlidingSegmentedControl&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="kt">String&lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nl">children:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="s1">&amp;#39;option1&amp;#39;&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="n">Text&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;選項一&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="s1">&amp;#39;option2&amp;#39;&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="n">Text&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;選項二&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nl">groupValue:&lt;/span> &lt;span class="n">controller&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">selectedOption&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nl">onValueChanged:&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="kt">String&lt;/span>&lt;span class="o">?&lt;/span> &lt;span class="n">value&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="n">controller&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">updateSelection&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">value&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div></description><content:encoded><![CDATA[<h2 id="有製作切換選項的按鈕需求查詢之後得到三種可用的樣式">有製作切換選項的按鈕需求，查詢之後得到三種可用的樣式</h2>
<ol>
<li>ToggleButtons</li>
</ol>
<figure><img src="/blog/work-log/flutter_toggle_button/ToggleButtons.png"
    alt="ToggleButtons 樣式">
</figure>






<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">ToggleButtons</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="nl">isSelected:</span> <span class="p">[</span><span class="n">controller</span><span class="p">.</span><span class="n">isFirstSelected</span><span class="p">,</span> <span class="n">controller</span><span class="p">.</span><span class="n">isSecondSelected</span><span class="p">],</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nl">onPressed:</span> <span class="p">(</span><span class="kt">int</span> <span class="n">index</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="n">controller</span><span class="p">.</span><span class="n">toggleSelection</span><span class="p">(</span><span class="n">index</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="nl">children:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="n">Text</span><span class="p">(</span><span class="s1">&#39;選項一&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="n">Text</span><span class="p">(</span><span class="s1">&#39;選項二&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="p">],</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="nl">selectedColor:</span> <span class="n">Colors</span><span class="p">.</span><span class="n">white</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="nl">fillColor:</span> <span class="n">Colors</span><span class="p">.</span><span class="n">blue</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="nl">borderColor:</span> <span class="n">Colors</span><span class="p">.</span><span class="n">blue</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="nl">borderRadius:</span> <span class="n">BorderRadius</span><span class="p">.</span><span class="n">circular</span><span class="p">(</span><span class="m">8</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><ol>
<li>SegmentedButton (Flutter 3.12+)</li>
</ol>
<figure><img src="/blog/work-log/flutter_toggle_button/SegmentedButton.png"
    alt="SegmentedButton 樣式">
</figure>






<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">SegmentedButton</span><span class="o">&lt;</span><span class="kt">String</span><span class="o">&gt;</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="nl">segments:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="n">ButtonSegment</span><span class="p">(</span><span class="nl">value:</span> <span class="s1">&#39;option1&#39;</span><span class="p">,</span> <span class="nl">label:</span> <span class="n">Text</span><span class="p">(</span><span class="s1">&#39;選項一&#39;</span><span class="p">)),</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="n">ButtonSegment</span><span class="p">(</span><span class="nl">value:</span> <span class="s1">&#39;option2&#39;</span><span class="p">,</span> <span class="nl">label:</span> <span class="n">Text</span><span class="p">(</span><span class="s1">&#39;選項二&#39;</span><span class="p">)),</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="p">],</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="nl">selected:</span> <span class="p">{</span><span class="n">controller</span><span class="p">.</span><span class="n">selectedOption</span><span class="p">},</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="nl">onSelectionChanged:</span> <span class="p">(</span><span class="n">Set</span><span class="o">&lt;</span><span class="kt">String</span><span class="o">&gt;</span> <span class="n">newSelection</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="n">controller</span><span class="p">.</span><span class="n">updateSelection</span><span class="p">(</span><span class="n">newSelection</span><span class="p">.</span><span class="n">first</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><ol>
<li>CupertinoSlidingSegmentedControl (iOS 風格)</li>
</ol>
<figure><img src="/blog/work-log/flutter_toggle_button/CupertinoSlidingSegmentedControl.png"
    alt="CupertinoSlidingSegmentedControl 樣式">
</figure>






<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">CupertinoSlidingSegmentedControl</span><span class="o">&lt;</span><span class="kt">String</span><span class="o">&gt;</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="nl">children:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="s1">&#39;option1&#39;</span><span class="o">:</span> <span class="n">Text</span><span class="p">(</span><span class="s1">&#39;選項一&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="s1">&#39;option2&#39;</span><span class="o">:</span> <span class="n">Text</span><span class="p">(</span><span class="s1">&#39;選項二&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="nl">groupValue:</span> <span class="n">controller</span><span class="p">.</span><span class="n">selectedOption</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="nl">onValueChanged:</span> <span class="p">(</span><span class="kt">String</span><span class="o">?</span> <span class="n">value</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="n">controller</span><span class="p">.</span><span class="n">updateSelection</span><span class="p">(</span><span class="n">value</span><span class="o">!</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">)</span></span></span></code></pre></div>]]></content:encoded></item></channel></rss>