<?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>工作筆記 on Tarragon</title><link>https://tarrragon.github.io/blog/work-log/</link><description>Recent content in 工作筆記 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/work-log/index.xml" rel="self" type="application/rss+xml"/><item><title>late final 欄位不能用欄位覆蓋 — Dart 欄位的隱藏 getter/setter 機制</title><link>https://tarrragon.github.io/blog/work-log/late-final-%E6%AC%84%E4%BD%8D%E4%B8%8D%E8%83%BD%E7%94%A8%E6%AC%84%E4%BD%8D%E8%A6%86%E8%93%8B-dart-%E6%AC%84%E4%BD%8D%E7%9A%84%E9%9A%B1%E8%97%8F-getter/setter-%E6%A9%9F%E5%88%B6/</link><pubDate>Tue, 30 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/late-final-%E6%AC%84%E4%BD%8D%E4%B8%8D%E8%83%BD%E7%94%A8%E6%AC%84%E4%BD%8D%E8%A6%86%E8%93%8B-dart-%E6%AC%84%E4%BD%8D%E7%9A%84%E9%9A%B1%E8%97%8F-getter/setter-%E6%A9%9F%E5%88%B6/</guid><description>&lt;h2 id="事件">事件&lt;/h2>
&lt;p>&lt;code>AppService&lt;/code> 宣告了 &lt;code>late final PackageInfo packageInfo;&lt;/code>，在 &lt;code>init()&lt;/code> 中透過 &lt;code>PackageInfo.fromPlatform()&lt;/code> 非同步初始化。測試用的 &lt;code>TestAppService extends AppService&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">// ❌ analyzer 報錯：overridden_fields
&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="err">@&lt;/span>&lt;span class="n">override&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">late&lt;/span> &lt;span class="kd">final&lt;/span> &lt;span class="n">PackageInfo&lt;/span> &lt;span class="n">packageInfo&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">PackageInfo&lt;/span>&lt;span class="p">(...);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Dart analyzer 報 &lt;code>overridden_fields&lt;/code>：欄位覆蓋了從 &lt;code>AppService&lt;/code> 繼承的欄位。&lt;/p>
&lt;h2 id="根因late-final-欄位--getter--setter">根因：late final 欄位 = getter + setter&lt;/h2>
&lt;p>Dart 的每個 instance 欄位在底層都會生成對應的 getter（和非 final 的會生成 setter）。&lt;code>late final&lt;/code> 更特殊——它生成的 getter 包含「是否已初始化」的檢查邏輯，setter 包含「只能寫入一次」的 guard。&lt;/p>
&lt;p>當子類用另一個欄位覆蓋時，記憶體中會有兩個獨立的儲存槽：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>存取來源&lt;/th>
 &lt;th>讀到的 slot&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>子類自己的程式碼&lt;/td>
 &lt;td>子類的 slot（已初始化）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>父類的程式碼（繼承的方法）&lt;/td>
 &lt;td>父類的 slot（未初始化）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>父類的 &lt;code>init()&lt;/code> 或其他方法如果存取 &lt;code>packageInfo&lt;/code>，會讀到父類那份未初始化的 slot，拋出 &lt;code>LateInitializationError&lt;/code>。這就是為什麼 Dart 不允許用欄位覆蓋欄位。&lt;/p>
&lt;h2 id="修法改用-getter-覆寫">修法：改用 getter 覆寫&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="err">@&lt;/span>&lt;span class="n">override&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">PackageInfo&lt;/span> &lt;span class="kd">get&lt;/span> &lt;span class="n">packageInfo&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">PackageInfo&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">appName:&lt;/span> &lt;span class="s1">&amp;#39;UniPos Test&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="nl">packageName:&lt;/span> &lt;span class="s1">&amp;#39;com.mxkj.unipos.test&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="nl">version:&lt;/span> &lt;span class="s1">&amp;#39;1.0.0&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="nl">buildNumber:&lt;/span> &lt;span class="s1">&amp;#39;1&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="nl">buildSignature:&lt;/span> &lt;span class="s1">&amp;#39;test&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="nl">installerStore:&lt;/span> &lt;span class="kc">null&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>Getter 覆寫只有一個讀取入口——不管父類還是子類的程式碼呼叫 &lt;code>packageInfo&lt;/code>，都走子類的 getter。不會有兩份 slot 的問題。&lt;/p>
&lt;h2 id="通則">通則&lt;/h2>
&lt;p>Dart 中覆寫父類的欄位，一律用 getter（必要時加 setter），不要用欄位。這不只適用於 &lt;code>late final&lt;/code>——所有欄位覆蓋都有同樣的雙 slot 風險，只是 &lt;code>late final&lt;/code> 的症狀最明顯（直接拋 &lt;code>LateInitializationError&lt;/code>），普通欄位的症狀更隱蔽（值不同步）。&lt;/p></description><content:encoded><![CDATA[<h2 id="事件">事件</h2>
<p><code>AppService</code> 宣告了 <code>late final PackageInfo packageInfo;</code>，在 <code>init()</code> 中透過 <code>PackageInfo.fromPlatform()</code> 非同步初始化。測試用的 <code>TestAppService extends AppService</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">// ❌ analyzer 報錯：overridden_fields
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="err">@</span><span class="n">override</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">late</span> <span class="kd">final</span> <span class="n">PackageInfo</span> <span class="n">packageInfo</span> <span class="o">=</span> <span class="n">PackageInfo</span><span class="p">(...);</span></span></span></code></pre></div><p>Dart analyzer 報 <code>overridden_fields</code>：欄位覆蓋了從 <code>AppService</code> 繼承的欄位。</p>
<h2 id="根因late-final-欄位--getter--setter">根因：late final 欄位 = getter + setter</h2>
<p>Dart 的每個 instance 欄位在底層都會生成對應的 getter（和非 final 的會生成 setter）。<code>late final</code> 更特殊——它生成的 getter 包含「是否已初始化」的檢查邏輯，setter 包含「只能寫入一次」的 guard。</p>
<p>當子類用另一個欄位覆蓋時，記憶體中會有兩個獨立的儲存槽：</p>
<table>
  <thead>
      <tr>
          <th>存取來源</th>
          <th>讀到的 slot</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>子類自己的程式碼</td>
          <td>子類的 slot（已初始化）</td>
      </tr>
      <tr>
          <td>父類的程式碼（繼承的方法）</td>
          <td>父類的 slot（未初始化）</td>
      </tr>
  </tbody>
</table>
<p>父類的 <code>init()</code> 或其他方法如果存取 <code>packageInfo</code>，會讀到父類那份未初始化的 slot，拋出 <code>LateInitializationError</code>。這就是為什麼 Dart 不允許用欄位覆蓋欄位。</p>
<h2 id="修法改用-getter-覆寫">修法：改用 getter 覆寫</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="err">@</span><span class="n">override</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">PackageInfo</span> <span class="kd">get</span> <span class="n">packageInfo</span> <span class="o">=&gt;</span> <span class="n">PackageInfo</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nl">appName:</span> <span class="s1">&#39;UniPos Test&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nl">packageName:</span> <span class="s1">&#39;com.mxkj.unipos.test&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="nl">version:</span> <span class="s1">&#39;1.0.0&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="nl">buildNumber:</span> <span class="s1">&#39;1&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="nl">buildSignature:</span> <span class="s1">&#39;test&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  <span class="nl">installerStore:</span> <span class="kc">null</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>Getter 覆寫只有一個讀取入口——不管父類還是子類的程式碼呼叫 <code>packageInfo</code>，都走子類的 getter。不會有兩份 slot 的問題。</p>
<h2 id="通則">通則</h2>
<p>Dart 中覆寫父類的欄位，一律用 getter（必要時加 setter），不要用欄位。這不只適用於 <code>late final</code>——所有欄位覆蓋都有同樣的雙 slot 風險，只是 <code>late final</code> 的症狀最明顯（直接拋 <code>LateInitializationError</code>），普通欄位的症狀更隱蔽（值不同步）。</p>
]]></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>工具的預設行為決定使用者習慣 — 從版本錯置看工具設計的 opinion 責任</title><link>https://tarrragon.github.io/blog/work-log/%E5%B7%A5%E5%85%B7%E7%9A%84%E9%A0%90%E8%A8%AD%E8%A1%8C%E7%82%BA%E6%B1%BA%E5%AE%9A%E4%BD%BF%E7%94%A8%E8%80%85%E7%BF%92%E6%85%A3-%E5%BE%9E%E7%89%88%E6%9C%AC%E9%8C%AF%E7%BD%AE%E7%9C%8B%E5%B7%A5%E5%85%B7%E8%A8%AD%E8%A8%88%E7%9A%84-opinion-%E8%B2%AC%E4%BB%BB/</link><pubDate>Thu, 25 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/%E5%B7%A5%E5%85%B7%E7%9A%84%E9%A0%90%E8%A8%AD%E8%A1%8C%E7%82%BA%E6%B1%BA%E5%AE%9A%E4%BD%BF%E7%94%A8%E8%80%85%E7%BF%92%E6%85%A3-%E5%BE%9E%E7%89%88%E6%9C%AC%E9%8C%AF%E7%BD%AE%E7%9C%8B%E5%B7%A5%E5%85%B7%E8%A8%AD%E8%A8%88%E7%9A%84-opinion-%E8%B2%AC%E4%BB%BB/</guid><description>&lt;p>這篇從一個版本錯置的經驗出發，討論工具設計中一個容易忽略的面向：工具接受自由輸入時，預設路徑如何影響使用者的決策。適用於 CLI、API、表單、自動化流程——任何需要使用者做選擇的介面。&lt;/p>
&lt;hr>
&lt;h2 id="背景我們怎麼管理版本和工作項目">背景：我們怎麼管理版本和工作項目&lt;/h2>
&lt;p>我們的專案用 semver（語意化版本）管理發布節奏。每個版本（如 v0.3.0）有明確的功能範圍，由數個提案定義——每個提案描述一組要交付的功能和邊界。版本內部再拆成多個工作項目（ticket），按批次排序執行（類似 Sprint，但以依賴順序而非時間框分批）。&lt;/p>
&lt;p>版本的生命週期很單純：&lt;code>planned → active → completed&lt;/code>。一個版本的所有 ticket 完成後，跑發布流程、打 tag、標記 completed。&lt;/p>
&lt;p>圍繞這個流程，我們自建了兩個 CLI 工具：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>工具&lt;/th>
 &lt;th>用途&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>ticket create&lt;/code>&lt;/td>
 &lt;td>建立工作項目，指定歸屬版本&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>version-release&lt;/code>&lt;/td>
 &lt;td>版本發布（pre-flight 檢查、文件更新、打 tag）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這兩個工具在設計時，都選擇了「彈性優先」——接受任何合法輸入，不對使用者的選擇做判斷。&lt;/p>
&lt;p>這個選擇在後來被證明是錯的。&lt;/p>
&lt;h2 id="版本語意大版本和小版本的分工">版本語意：大版本和小版本的分工&lt;/h2>
&lt;p>semver 的 &lt;code>MAJOR.MINOR.PATCH&lt;/code> 有明確的語意分工：&lt;/p>
&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>MAJOR（0.x → 1.0）&lt;/td>
 &lt;td>不相容的 API 變更&lt;/td>
 &lt;td>破壞既有介面&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>MINOR（0.3 → 0.4）&lt;/td>
 &lt;td>新功能&lt;/td>
 &lt;td>新增向後相容功能&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>PATCH（0.3.0 → 0.3.1）&lt;/td>
 &lt;td>修復和改善&lt;/td>
 &lt;td>bug fix（我們擴充涵蓋重構和流程改善）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>版本號不只是標記——它決定了&lt;strong>工作項目應該放在哪裡&lt;/strong>。一個 bug fix 放進 MINOR 版本，語意上等於說「這個 bug fix 和下一批新功能綁定發布」——多數情況下這不是你想要的。&lt;/p>
&lt;p>版本管理只是其中一個場景——任何接受自由輸入的內部工具，只要輸入涉及分類或歸屬判斷，都可能有同樣的問題。我們的工具沒有表達這個語意，接下來的兩個事件是後果。&lt;/p>
&lt;h2 id="事件一改善類工作放進了新功能版本">事件一：改善類工作放進了新功能版本&lt;/h2>
&lt;p>v0.3.0 發布了三個新功能。發布後的版本檢討發現了一個測試隔離問題，v0.3.1 做了 hotfix。&lt;/p>
&lt;p>接下來要做根因分析和系統性防護。建立工作項目時，順手指定了 &lt;code>--version 0.4.0&lt;/code>——v0.3.0 和 v0.3.1 都已發布，v0.4.0 是下一個功能版本，看起來是合理的選擇。&lt;/p>
&lt;p>CLI 接受了這個輸入，沒有任何提示。&lt;/p>
&lt;p>三張改善類的工作項目（根因分析、重構、規則文件）就這樣和 PostgreSQL Storage Backend（v0.4.0 的核心功能）混在一起。直到使用者檢視版本看板時才發現不對——改善類工作和新功能綁在同一個發布週期，語意混亂。&lt;/p>
&lt;p>修正方式：建立 v0.3.2、遷移三張 ticket、重新發布。額外花了一輪操作成本。&lt;/p>
&lt;h2 id="事件二已完成版本的幽靈">事件二：已完成版本的幽靈&lt;/h2>
&lt;p>版本看板的異常不止一處。同一次檢視中，看板顯示 v0.2.0 有未完成任務。&lt;/p>
&lt;p>查證後發現 v0.2.0（38 張 ticket 全部完成）、v0.2.1（7 張全完成）、v0.2.2（1 張已結案）三個版本在版本清單中仍標記為 &lt;code>active&lt;/code>。它們在數個月前就該標為 &lt;code>completed&lt;/code>，但沒有。&lt;/p>
&lt;p>原因是版本發布工具的 pre-flight 檢查只看「當前版本的 ticket 是否完成」，不掃描「更早的版本是否有 active 殘留」。早期版本可能是手動發布的，跳過了狀態同步步驟。工具沒有補救機制，殘留就一直留著。&lt;/p>
&lt;p>看板靜默地把這些版本顯示為「有未完成工作」，產生誤導。&lt;/p>
&lt;h2 id="為什麼會這樣工具沒有-opinion">為什麼會這樣：工具沒有 opinion&lt;/h2>
&lt;p>兩個事件的共通根因：&lt;strong>工具在應該有立場的地方選擇了沉默。&lt;/strong>&lt;/p>
&lt;h3 id="建立工作項目時">建立工作項目時&lt;/h3>
&lt;p>&lt;code>ticket create --version 0.4.0 --type ANA --action &amp;quot;分析&amp;quot;&lt;/code> — 工具知道這是一張分析類的 ticket，也知道 v0.4.0 的 scope 是 PostgreSQL Storage。但它不認為自己有責任判斷「分析類 ticket 放在新功能版本是否合理」。它只做格式驗證：版本號存在嗎？通過就建立。&lt;/p>
&lt;h3 id="發布版本時">發布版本時&lt;/h3>
&lt;p>發布工具的盲區更隱蔽。每次發布時，工具會檢查「這個版本的所有工作項目都完成了嗎？」——如果答案是「是」，就繼續打 tag、更新文件、推送。但它從不回頭看更早的版本：有沒有哪個舊版本的工作項目早已全部完成，卻一直沒被標記為「已完成」？這種殘留不影響當前發布，但會讓看板持續顯示「舊版本有未完成工作」，誤導每一個後續查看看板的人。&lt;/p>
&lt;p>兩者都是「工具做了它被要求做的事，但沒做它應該做的事」。&lt;/p>
&lt;h2 id="工具什麼時候應該有-opinion">工具什麼時候應該有 opinion？&lt;/h2>
&lt;p>不是所有情境都需要工具有立場。有一個簡單的判斷標準：&lt;/p>
&lt;blockquote>
&lt;p>當存在一個「多數情況下正確的預設行為」時，工具應該把它表達出來。使用者可以覆蓋，但預設路徑應該引導正確做法。&lt;/p>&lt;/blockquote>
&lt;p>這裡的 opinion 是&lt;strong>建議而非阻擋&lt;/strong>——工具提示預設路徑，使用者可以覆蓋。這個區分很重要：阻擋式的 opinion（必須額外操作才能繞過）適合風險高的操作（如 force push to main、刪除生產資料）；建議式的 opinion 適合歸屬判斷。錯誤成本不對稱決定了形式：建議錯了，使用者覆蓋一次，幾秒鐘；沉默錯了，事後修正，幾小時。只要建議的正確率不是極低，建議就比沉默划算。&lt;/p>
&lt;p>這個邏輯不限於 CLI。API 的預設參數、表單的預選值、自動化流程的預設路由——任何使用者需要做選擇的介面，都有機會用預設行為表達 opinion。&lt;/p>
&lt;p>改善類 ticket 放 patch 版本，在多數情況下是正確的。「多數情況下對」已經足夠讓工具表達立場：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="err">$&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ticket&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">create&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">--type IMP --action &amp;#34;修復&amp;#34; --target &amp;#34;retry test&amp;#34;
&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="p">[&lt;/span>&lt;span class="err">建議&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">此&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">ticket&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">為修復類，建議放&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">v0&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="err">（&lt;/span>&lt;span class="n">patch&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">bump&lt;/span>&lt;span class="err">）&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="err">而非&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">v0&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="mi">4&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="err">（下一個功能版本）&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="err">使用&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c1">--version 覆蓋此建議&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>前版本 status 掃描也是。已完成版本仍為 active 在所有情況下都是異常——工具不需要猜，只需要報告：&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">$ version-release check
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">[WARN] v0.2.0：38 張 ticket 全部完成但 status 仍為 active&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="為什麼使用者是-ai-agent-時問題更嚴重">為什麼使用者是 AI agent 時問題更嚴重&lt;/h2>
&lt;p>這個 pattern 在人類使用者身上已經存在——人類也會走阻力最小的路徑。但人類有跨次記憶：「上次放錯版本被糾正過，這次注意一下。」&lt;/p></description><content:encoded><![CDATA[<p>這篇從一個版本錯置的經驗出發，討論工具設計中一個容易忽略的面向：工具接受自由輸入時，預設路徑如何影響使用者的決策。適用於 CLI、API、表單、自動化流程——任何需要使用者做選擇的介面。</p>
<hr>
<h2 id="背景我們怎麼管理版本和工作項目">背景：我們怎麼管理版本和工作項目</h2>
<p>我們的專案用 semver（語意化版本）管理發布節奏。每個版本（如 v0.3.0）有明確的功能範圍，由數個提案定義——每個提案描述一組要交付的功能和邊界。版本內部再拆成多個工作項目（ticket），按批次排序執行（類似 Sprint，但以依賴順序而非時間框分批）。</p>
<p>版本的生命週期很單純：<code>planned → active → completed</code>。一個版本的所有 ticket 完成後，跑發布流程、打 tag、標記 completed。</p>
<p>圍繞這個流程，我們自建了兩個 CLI 工具：</p>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>用途</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>ticket create</code></td>
          <td>建立工作項目，指定歸屬版本</td>
      </tr>
      <tr>
          <td><code>version-release</code></td>
          <td>版本發布（pre-flight 檢查、文件更新、打 tag）</td>
      </tr>
  </tbody>
</table>
<p>這兩個工具在設計時，都選擇了「彈性優先」——接受任何合法輸入，不對使用者的選擇做判斷。</p>
<p>這個選擇在後來被證明是錯的。</p>
<h2 id="版本語意大版本和小版本的分工">版本語意：大版本和小版本的分工</h2>
<p>semver 的 <code>MAJOR.MINOR.PATCH</code> 有明確的語意分工：</p>
<table>
  <thead>
      <tr>
          <th>層級</th>
          <th>語意</th>
          <th>觸發條件</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>MAJOR（0.x → 1.0）</td>
          <td>不相容的 API 變更</td>
          <td>破壞既有介面</td>
      </tr>
      <tr>
          <td>MINOR（0.3 → 0.4）</td>
          <td>新功能</td>
          <td>新增向後相容功能</td>
      </tr>
      <tr>
          <td>PATCH（0.3.0 → 0.3.1）</td>
          <td>修復和改善</td>
          <td>bug fix（我們擴充涵蓋重構和流程改善）</td>
      </tr>
  </tbody>
</table>
<p>版本號不只是標記——它決定了<strong>工作項目應該放在哪裡</strong>。一個 bug fix 放進 MINOR 版本，語意上等於說「這個 bug fix 和下一批新功能綁定發布」——多數情況下這不是你想要的。</p>
<p>版本管理只是其中一個場景——任何接受自由輸入的內部工具，只要輸入涉及分類或歸屬判斷，都可能有同樣的問題。我們的工具沒有表達這個語意，接下來的兩個事件是後果。</p>
<h2 id="事件一改善類工作放進了新功能版本">事件一：改善類工作放進了新功能版本</h2>
<p>v0.3.0 發布了三個新功能。發布後的版本檢討發現了一個測試隔離問題，v0.3.1 做了 hotfix。</p>
<p>接下來要做根因分析和系統性防護。建立工作項目時，順手指定了 <code>--version 0.4.0</code>——v0.3.0 和 v0.3.1 都已發布，v0.4.0 是下一個功能版本，看起來是合理的選擇。</p>
<p>CLI 接受了這個輸入，沒有任何提示。</p>
<p>三張改善類的工作項目（根因分析、重構、規則文件）就這樣和 PostgreSQL Storage Backend（v0.4.0 的核心功能）混在一起。直到使用者檢視版本看板時才發現不對——改善類工作和新功能綁在同一個發布週期，語意混亂。</p>
<p>修正方式：建立 v0.3.2、遷移三張 ticket、重新發布。額外花了一輪操作成本。</p>
<h2 id="事件二已完成版本的幽靈">事件二：已完成版本的幽靈</h2>
<p>版本看板的異常不止一處。同一次檢視中，看板顯示 v0.2.0 有未完成任務。</p>
<p>查證後發現 v0.2.0（38 張 ticket 全部完成）、v0.2.1（7 張全完成）、v0.2.2（1 張已結案）三個版本在版本清單中仍標記為 <code>active</code>。它們在數個月前就該標為 <code>completed</code>，但沒有。</p>
<p>原因是版本發布工具的 pre-flight 檢查只看「當前版本的 ticket 是否完成」，不掃描「更早的版本是否有 active 殘留」。早期版本可能是手動發布的，跳過了狀態同步步驟。工具沒有補救機制，殘留就一直留著。</p>
<p>看板靜默地把這些版本顯示為「有未完成工作」，產生誤導。</p>
<h2 id="為什麼會這樣工具沒有-opinion">為什麼會這樣：工具沒有 opinion</h2>
<p>兩個事件的共通根因：<strong>工具在應該有立場的地方選擇了沉默。</strong></p>
<h3 id="建立工作項目時">建立工作項目時</h3>
<p><code>ticket create --version 0.4.0 --type ANA --action &quot;分析&quot;</code> — 工具知道這是一張分析類的 ticket，也知道 v0.4.0 的 scope 是 PostgreSQL Storage。但它不認為自己有責任判斷「分析類 ticket 放在新功能版本是否合理」。它只做格式驗證：版本號存在嗎？通過就建立。</p>
<h3 id="發布版本時">發布版本時</h3>
<p>發布工具的盲區更隱蔽。每次發布時，工具會檢查「這個版本的所有工作項目都完成了嗎？」——如果答案是「是」，就繼續打 tag、更新文件、推送。但它從不回頭看更早的版本：有沒有哪個舊版本的工作項目早已全部完成，卻一直沒被標記為「已完成」？這種殘留不影響當前發布，但會讓看板持續顯示「舊版本有未完成工作」，誤導每一個後續查看看板的人。</p>
<p>兩者都是「工具做了它被要求做的事，但沒做它應該做的事」。</p>
<h2 id="工具什麼時候應該有-opinion">工具什麼時候應該有 opinion？</h2>
<p>不是所有情境都需要工具有立場。有一個簡單的判斷標準：</p>
<blockquote>
<p>當存在一個「多數情況下正確的預設行為」時，工具應該把它表達出來。使用者可以覆蓋，但預設路徑應該引導正確做法。</p></blockquote>
<p>這裡的 opinion 是<strong>建議而非阻擋</strong>——工具提示預設路徑，使用者可以覆蓋。這個區分很重要：阻擋式的 opinion（必須額外操作才能繞過）適合風險高的操作（如 force push to main、刪除生產資料）；建議式的 opinion 適合歸屬判斷。錯誤成本不對稱決定了形式：建議錯了，使用者覆蓋一次，幾秒鐘；沉默錯了，事後修正，幾小時。只要建議的正確率不是極低，建議就比沉默划算。</p>
<p>這個邏輯不限於 CLI。API 的預設參數、表單的預選值、自動化流程的預設路由——任何使用者需要做選擇的介面，都有機會用預設行為表達 opinion。</p>
<p>改善類 ticket 放 patch 版本，在多數情況下是正確的。「多數情況下對」已經足夠讓工具表達立場：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="err">$</span><span class="w"> </span><span class="n">ticket</span><span class="w"> </span><span class="k">create</span><span class="w"> </span><span class="c1">--type IMP --action &#34;修復&#34; --target &#34;retry test&#34;
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="p">[</span><span class="err">建議</span><span class="p">]</span><span class="w"> </span><span class="err">此</span><span class="w"> </span><span class="n">ticket</span><span class="w"> </span><span class="err">為修復類，建議放</span><span class="w"> </span><span class="n">v0</span><span class="p">.</span><span class="mi">3</span><span class="p">.</span><span class="mi">2</span><span class="err">（</span><span class="n">patch</span><span class="w"> </span><span class="n">bump</span><span class="err">）</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">       </span><span class="err">而非</span><span class="w"> </span><span class="n">v0</span><span class="p">.</span><span class="mi">4</span><span class="p">.</span><span class="mi">0</span><span class="err">（下一個功能版本）</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">       </span><span class="err">使用</span><span class="w"> </span><span class="c1">--version 覆蓋此建議</span></span></span></code></pre></div><p>前版本 status 掃描也是。已完成版本仍為 active 在所有情況下都是異常——工具不需要猜，只需要報告：</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">$ version-release check
</span></span><span class="line"><span class="ln">2</span><span class="cl">[WARN] v0.2.0：38 張 ticket 全部完成但 status 仍為 active</span></span></code></pre></div><h2 id="為什麼使用者是-ai-agent-時問題更嚴重">為什麼使用者是 AI agent 時問題更嚴重</h2>
<p>這個 pattern 在人類使用者身上已經存在——人類也會走阻力最小的路徑。但人類有跨次記憶：「上次放錯版本被糾正過，這次注意一下。」</p>
<p>AI agent 沒有這個。</p>
<p>每個 session 是一個全新的 agent，它讀到的是：版本清單中 v0.4.0 是 active、CLI 接受 <code>--version 0.4.0</code>、沒有警告。於是它每次都會用最直覺的選擇——當前 active 的最大版本。</p>
<p>上次的教訓不會自動傳遞到下次。除非教訓被固化成工具行為。</p>
<p>這把「工具應該有 opinion」從「建議做法」升級為「必要條件」：</p>
<ul>
<li><strong>人類使用者</strong>：opinion 是提醒，有助於減少錯誤</li>
<li><strong>AI agent 使用者</strong>：opinion 是最可靠的防線，因為工具在操作當下的即時引導是離決策點最近的攔截</li>
</ul>
<h2 id="工具的預設行為就是團隊的實際流程">工具的預設行為，就是團隊的實際流程</h2>
<blockquote>
<p>工具的預設行為，就是團隊的實際流程。</p></blockquote>
<p>文件上寫「改善類工作放 patch 版本」沒有用——如果工具不引導，使用者會走工具預設的路徑。人類和 AI 都是。文件說的和工具做的不一致時，工具會贏。</p>
<p>但文件不是敵人。文件定義「應該是什麼樣」，傳遞設計理由和架構決策；工具實現「實際是什麼樣」。兩者不一致時，優先修工具。</p>
<blockquote>
<p>如果你希望使用者做 X，不要寫文件說「請做 X」——把工具的預設行為設成 X。</p></blockquote>
<p>這個原則適用於所有內部工具設計，不限於版本管理：</p>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>寫文件的做法</th>
          <th>改工具的做法</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>commit 前跑測試</td>
          <td>README 寫「請先跑測試」</td>
          <td>pre-commit hook 自動跑</td>
      </tr>
      <tr>
          <td>PR 描述格式</td>
          <td>貢獻指南寫範本</td>
          <td>PR template 預填結構</td>
      </tr>
      <tr>
          <td>改善放 patch 版本</td>
          <td>版本策略文件寫規則</td>
          <td>CLI 根據 ticket type 建議版本</td>
      </tr>
      <tr>
          <td>API 環境參數</td>
          <td>文件寫「production 需額外確認」</td>
          <td>API 預設 staging，production 需顯式指定</td>
      </tr>
      <tr>
          <td>表單必填欄位</td>
          <td>說明文字寫「建議填寫」</td>
          <td>欄位預設值 + 必填驗證</td>
      </tr>
  </tbody>
</table>
<p>每一個「寫文件提醒使用者遵守操作規範」都是一個信號——工具的預設行為還有空間改善。看到這個信號時，優先評估能否把提醒轉化為工具的預設行為。</p>
<p>Rails 的「Convention over Configuration」是同一個觀念的先驅表達：框架用約定引導開發者走正確路徑，省去不必要的配置決策。有 opinion 的工具在必要決策時引導方向。兩者共通的是把判斷成本從「每次使用時」前移到「設計工具時」——一次判斷，永久生效。</p>
<h2 id="回去檢查你的工具">回去檢查你的工具</h2>
<ol>
<li>列出你的工具中所有使用者需要做選擇的地方——CLI 參數、API 欄位、表單選項、流程分支</li>
<li>對每個問：有沒有「多數情況下正確」的預設值或建議值？</li>
<li>有的話，加建議式 opinion（提示預設 + 允許覆蓋）</li>
<li>檢查工具的清理路徑：有沒有前一次操作應該同步但沒有同步的狀態？</li>
<li>如果你的工具會被 AI agent 或自動化流程呼叫，上述每一項的優先級加倍——自動化沒有判斷力，它只走預設路徑</li>
</ol>
]]></content:encoded></item><item><title>改善類工作放進新功能版本 — 版本歸屬判斷的工具化</title><link>https://tarrragon.github.io/blog/work-log/%E6%94%B9%E5%96%84%E9%A1%9E%E5%B7%A5%E4%BD%9C%E6%94%BE%E9%80%B2%E6%96%B0%E5%8A%9F%E8%83%BD%E7%89%88%E6%9C%AC-%E7%89%88%E6%9C%AC%E6%AD%B8%E5%B1%AC%E5%88%A4%E6%96%B7%E7%9A%84%E5%B7%A5%E5%85%B7%E5%8C%96/</link><pubDate>Thu, 25 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/%E6%94%B9%E5%96%84%E9%A1%9E%E5%B7%A5%E4%BD%9C%E6%94%BE%E9%80%B2%E6%96%B0%E5%8A%9F%E8%83%BD%E7%89%88%E6%9C%AC-%E7%89%88%E6%9C%AC%E6%AD%B8%E5%B1%AC%E5%88%A4%E6%96%B7%E7%9A%84%E5%B7%A5%E5%85%B7%E5%8C%96/</guid><description>&lt;h2 id="事件">事件&lt;/h2>
&lt;p>v0.3.0 發布後發現一個測試隔離問題，v0.3.1 做了 hotfix。接著要做根因分析和系統性防護（重構 + 品質規則更新）。&lt;/p>
&lt;p>建立工作項目時指定了 &lt;code>--version 0.4.0&lt;/code>——v0.3.0 和 v0.3.1 都已發布，v0.4.0 是下一個功能版本。工具接受了，沒有提示。&lt;/p>
&lt;p>結果：三張改善類工作（根因分析、State Registry 重構、品質規則文件）和 PostgreSQL Storage Backend 混在同一個版本裡。改善和新功能綁定發布，語意混亂。事後建立 v0.3.2 遷移工作項目並重新發布。&lt;/p>
&lt;h2 id="根因工具只做格式驗證">根因：工具只做格式驗證&lt;/h2>
&lt;p>&lt;code>ticket create --version 0.4.0&lt;/code> 被接受的條件是「v0.4.0 存在於版本清單且為 active」。工具不分析工作類型（分析 / 修復 / 重構 / 新功能）和版本層級（MINOR / PATCH）的匹配度。&lt;/p>
&lt;p>semver 有明確的語意分工——MINOR 用於新功能，PATCH 用於修復和改善。這個語意可以被工具表達：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>工作類型&lt;/th>
 &lt;th>semver 語意&lt;/th>
 &lt;th>建議版本&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>新功能&lt;/td>
 &lt;td>MINOR bump&lt;/td>
 &lt;td>下一個功能版本&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>修復&lt;/td>
 &lt;td>PATCH bump&lt;/td>
 &lt;td>當前系列的下一個 patch&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>改善 / 重構&lt;/td>
 &lt;td>PATCH bump&lt;/td>
 &lt;td>同上&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>文件&lt;/td>
 &lt;td>PATCH bump&lt;/td>
 &lt;td>同上&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>工具可以根據工作類型自動建議版本，使用者可以覆蓋。建議錯了，使用者多打一個參數；沉默錯了，事後遷移。&lt;/p>
&lt;h2 id="教訓">教訓&lt;/h2>
&lt;p>&lt;strong>語意已經存在，工具有責任表達它。&lt;/strong> semver 的 MINOR/PATCH 分工是廣泛認知的慣例。但「知道」和「每次建立工作項目時都記得套用」是兩件事。工具可以把這個「記得套用」的成本降到零：讀取工作類型，對照 semver 語意，輸出建議。&lt;/p>
&lt;p>這個 pattern 適用於任何「輸入涉及分類判斷」的工具介面。工具不需要代替使用者做決策，但可以把分類規則從「腦中的知識」轉化為「介面上的提示」。同一次版本檢視中發現的另一個工具盲區（狀態殘留）見 &lt;a href="https://tarrragon.github.io/blog/work-log/%E7%89%88%E6%9C%AC%E7%8B%80%E6%85%8B%E6%AE%98%E7%95%99%E7%82%BA%E4%BB%80%E9%BA%BC%E5%B7%B2%E5%AE%8C%E6%88%90%E7%9A%84%E7%89%88%E6%9C%AC%E5%9C%A8%E7%9C%8B%E6%9D%BF%E4%B8%8A%E9%A1%AF%E7%A4%BA%E6%9C%AA%E5%AE%8C%E6%88%90/" data-link-title="版本狀態殘留：為什麼已完成的版本在看板上顯示未完成" data-link-desc="看板顯示早已完成的版本仍為 active、誤導查看者。根因是發布工具只檢查當前版本完成度、不掃前版本的狀態殘留；工具的檢查範圍決定了系統的一致性邊界。">version_status_residual_ghost&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<h2 id="事件">事件</h2>
<p>v0.3.0 發布後發現一個測試隔離問題，v0.3.1 做了 hotfix。接著要做根因分析和系統性防護（重構 + 品質規則更新）。</p>
<p>建立工作項目時指定了 <code>--version 0.4.0</code>——v0.3.0 和 v0.3.1 都已發布，v0.4.0 是下一個功能版本。工具接受了，沒有提示。</p>
<p>結果：三張改善類工作（根因分析、State Registry 重構、品質規則文件）和 PostgreSQL Storage Backend 混在同一個版本裡。改善和新功能綁定發布，語意混亂。事後建立 v0.3.2 遷移工作項目並重新發布。</p>
<h2 id="根因工具只做格式驗證">根因：工具只做格式驗證</h2>
<p><code>ticket create --version 0.4.0</code> 被接受的條件是「v0.4.0 存在於版本清單且為 active」。工具不分析工作類型（分析 / 修復 / 重構 / 新功能）和版本層級（MINOR / PATCH）的匹配度。</p>
<p>semver 有明確的語意分工——MINOR 用於新功能，PATCH 用於修復和改善。這個語意可以被工具表達：</p>
<table>
  <thead>
      <tr>
          <th>工作類型</th>
          <th>semver 語意</th>
          <th>建議版本</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>新功能</td>
          <td>MINOR bump</td>
          <td>下一個功能版本</td>
      </tr>
      <tr>
          <td>修復</td>
          <td>PATCH bump</td>
          <td>當前系列的下一個 patch</td>
      </tr>
      <tr>
          <td>改善 / 重構</td>
          <td>PATCH bump</td>
          <td>同上</td>
      </tr>
      <tr>
          <td>文件</td>
          <td>PATCH bump</td>
          <td>同上</td>
      </tr>
  </tbody>
</table>
<p>工具可以根據工作類型自動建議版本，使用者可以覆蓋。建議錯了，使用者多打一個參數；沉默錯了，事後遷移。</p>
<h2 id="教訓">教訓</h2>
<p><strong>語意已經存在，工具有責任表達它。</strong> semver 的 MINOR/PATCH 分工是廣泛認知的慣例。但「知道」和「每次建立工作項目時都記得套用」是兩件事。工具可以把這個「記得套用」的成本降到零：讀取工作類型，對照 semver 語意，輸出建議。</p>
<p>這個 pattern 適用於任何「輸入涉及分類判斷」的工具介面。工具不需要代替使用者做決策，但可以把分類規則從「腦中的知識」轉化為「介面上的提示」。同一次版本檢視中發現的另一個工具盲區（狀態殘留）見 <a href="/blog/work-log/%E7%89%88%E6%9C%AC%E7%8B%80%E6%85%8B%E6%AE%98%E7%95%99%E7%82%BA%E4%BB%80%E9%BA%BC%E5%B7%B2%E5%AE%8C%E6%88%90%E7%9A%84%E7%89%88%E6%9C%AC%E5%9C%A8%E7%9C%8B%E6%9D%BF%E4%B8%8A%E9%A1%AF%E7%A4%BA%E6%9C%AA%E5%AE%8C%E6%88%90/" data-link-title="版本狀態殘留：為什麼已完成的版本在看板上顯示未完成" data-link-desc="看板顯示早已完成的版本仍為 active、誤導查看者。根因是發布工具只檢查當前版本完成度、不掃前版本的狀態殘留；工具的檢查範圍決定了系統的一致性邊界。">version_status_residual_ghost</a>。</p>
]]></content:encoded></item><item><title>並行 AI Agent 修改同一檔案的衝突模式與協調策略</title><link>https://tarrragon.github.io/blog/work-log/%E4%B8%A6%E8%A1%8C-ai-agent-%E4%BF%AE%E6%94%B9%E5%90%8C%E4%B8%80%E6%AA%94%E6%A1%88%E7%9A%84%E8%A1%9D%E7%AA%81%E6%A8%A1%E5%BC%8F%E8%88%87%E5%8D%94%E8%AA%BF%E7%AD%96%E7%95%A5/</link><pubDate>Thu, 25 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/%E4%B8%A6%E8%A1%8C-ai-agent-%E4%BF%AE%E6%94%B9%E5%90%8C%E4%B8%80%E6%AA%94%E6%A1%88%E7%9A%84%E8%A1%9D%E7%AA%81%E6%A8%A1%E5%BC%8F%E8%88%87%E5%8D%94%E8%AA%BF%E7%AD%96%E7%95%A5/</guid><description>&lt;h2 id="事件">事件&lt;/h2>
&lt;p>多人（或多 agent）並行開發時，如果修改集中在同一個檔案，協調成本可能抵消並行的收益。以下是一個具體案例。&lt;/p>
&lt;p>v0.3.0 的 JS SDK 開發中，五張 ticket 被並行派發給五個 AI agent：flush 邏輯、離線容錯、自動攔截、頁面生命週期、rate limiting。前四個都需要修改同一個檔案 &lt;code>monitor.ts&lt;/code>。&lt;/p>
&lt;p>結果：&lt;/p>
&lt;ul>
&lt;li>三個 agent 回報 branch protection hook 阻擋 src 編輯&lt;/li>
&lt;li>兩個 agent 回報 &lt;code>file modified since read&lt;/code> 拒絕 Edit（另一個 agent 正在寫同一檔案）&lt;/li>
&lt;li>PM 花了多個回合協調 commit 策略：「你先 commit」「你等他完成」「你只 git add 你的檔案」&lt;/li>
&lt;li>最終 PM 手動合併所有 agent 的變更，做了一個統一 commit&lt;/li>
&lt;/ul>
&lt;p>並行派發的目標是縮短總工時。但五個 agent 改同一檔案時，協調成本抵消了並行的收益。&lt;/p>
&lt;h2 id="根因派發粒度錯在-ticket-層而非檔案層">根因：派發粒度錯在 ticket 層而非檔案層&lt;/h2>
&lt;p>派發決策看的是 ticket 的獨立性——五張 ticket 描述的功能確實獨立（flush、離線、攔截、生命週期各自有清楚的邊界）。但獨立的功能不等於獨立的檔案。五個功能的修改都集中在 &lt;code>monitor.ts&lt;/code> 這一個檔案上。&lt;/p>
&lt;p>ticket 獨立 =/= 檔案獨立。並行安全的判斷基準應該是後者。&lt;/p>
&lt;h2 id="教訓">教訓&lt;/h2>
&lt;p>&lt;strong>派發前掃描 &lt;code>where.files&lt;/code>&lt;/strong>：如果多張 ticket 的目標檔案有交集，序列化派發。前一張完成並 commit 後，再派下一張。&lt;/p>
&lt;p>&lt;strong>序列的代價比衝突的代價低&lt;/strong>：五個 agent 序列執行可能需要 5 倍時間，但每個 agent 在乾淨的工作區上操作，不需要協調。五個 agent 並行但衝突，PM 的協調時間加上 agent 的等待和重試，總成本可能更高。&lt;/p>
&lt;p>&lt;strong>Worktree 隔離不是萬靈丹&lt;/strong>：git worktree 讓每個 agent 有獨立的工作目錄，避免 working tree 衝突。但如果兩個 agent 修改同一檔案的不同區段，merge 時仍需人工判斷。Worktree 解決的是「同時寫同一個 working tree」的問題，不解決「同時改同一個檔案的語意衝突」。&lt;/p>
&lt;h2 id="適用場景">適用場景&lt;/h2>
&lt;p>這個 pattern 不限於 AI agent。人類開發者在同一個 Sprint 中被分配修改同一個檔案的不同功能時，也會遇到 merge conflict。差異在於人類可以口頭協調（「我先改完你再改」），agent 目前缺乏這個即時溝通管道。派發者（PM 或 CI 系統）需要在派發時就做好檔案衝突預判。&lt;/p></description><content:encoded><![CDATA[<h2 id="事件">事件</h2>
<p>多人（或多 agent）並行開發時，如果修改集中在同一個檔案，協調成本可能抵消並行的收益。以下是一個具體案例。</p>
<p>v0.3.0 的 JS SDK 開發中，五張 ticket 被並行派發給五個 AI agent：flush 邏輯、離線容錯、自動攔截、頁面生命週期、rate limiting。前四個都需要修改同一個檔案 <code>monitor.ts</code>。</p>
<p>結果：</p>
<ul>
<li>三個 agent 回報 branch protection hook 阻擋 src 編輯</li>
<li>兩個 agent 回報 <code>file modified since read</code> 拒絕 Edit（另一個 agent 正在寫同一檔案）</li>
<li>PM 花了多個回合協調 commit 策略：「你先 commit」「你等他完成」「你只 git add 你的檔案」</li>
<li>最終 PM 手動合併所有 agent 的變更，做了一個統一 commit</li>
</ul>
<p>並行派發的目標是縮短總工時。但五個 agent 改同一檔案時，協調成本抵消了並行的收益。</p>
<h2 id="根因派發粒度錯在-ticket-層而非檔案層">根因：派發粒度錯在 ticket 層而非檔案層</h2>
<p>派發決策看的是 ticket 的獨立性——五張 ticket 描述的功能確實獨立（flush、離線、攔截、生命週期各自有清楚的邊界）。但獨立的功能不等於獨立的檔案。五個功能的修改都集中在 <code>monitor.ts</code> 這一個檔案上。</p>
<p>ticket 獨立 =/= 檔案獨立。並行安全的判斷基準應該是後者。</p>
<h2 id="教訓">教訓</h2>
<p><strong>派發前掃描 <code>where.files</code></strong>：如果多張 ticket 的目標檔案有交集，序列化派發。前一張完成並 commit 後，再派下一張。</p>
<p><strong>序列的代價比衝突的代價低</strong>：五個 agent 序列執行可能需要 5 倍時間，但每個 agent 在乾淨的工作區上操作，不需要協調。五個 agent 並行但衝突，PM 的協調時間加上 agent 的等待和重試，總成本可能更高。</p>
<p><strong>Worktree 隔離不是萬靈丹</strong>：git worktree 讓每個 agent 有獨立的工作目錄，避免 working tree 衝突。但如果兩個 agent 修改同一檔案的不同區段，merge 時仍需人工判斷。Worktree 解決的是「同時寫同一個 working tree」的問題，不解決「同時改同一個檔案的語意衝突」。</p>
<h2 id="適用場景">適用場景</h2>
<p>這個 pattern 不限於 AI agent。人類開發者在同一個 Sprint 中被分配修改同一個檔案的不同功能時，也會遇到 merge conflict。差異在於人類可以口頭協調（「我先改完你再改」），agent 目前缺乏這個即時溝通管道。派發者（PM 或 CI 系統）需要在派發時就做好檔案衝突預判。</p>
]]></content:encoded></item><item><title>版本狀態殘留：為什麼已完成的版本在看板上顯示未完成</title><link>https://tarrragon.github.io/blog/work-log/%E7%89%88%E6%9C%AC%E7%8B%80%E6%85%8B%E6%AE%98%E7%95%99%E7%82%BA%E4%BB%80%E9%BA%BC%E5%B7%B2%E5%AE%8C%E6%88%90%E7%9A%84%E7%89%88%E6%9C%AC%E5%9C%A8%E7%9C%8B%E6%9D%BF%E4%B8%8A%E9%A1%AF%E7%A4%BA%E6%9C%AA%E5%AE%8C%E6%88%90/</link><pubDate>Thu, 25 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/%E7%89%88%E6%9C%AC%E7%8B%80%E6%85%8B%E6%AE%98%E7%95%99%E7%82%BA%E4%BB%80%E9%BA%BC%E5%B7%B2%E5%AE%8C%E6%88%90%E7%9A%84%E7%89%88%E6%9C%AC%E5%9C%A8%E7%9C%8B%E6%9D%BF%E4%B8%8A%E9%A1%AF%E7%A4%BA%E6%9C%AA%E5%AE%8C%E6%88%90/</guid><description>&lt;h2 id="事件">事件&lt;/h2>
&lt;p>版本看板顯示 v0.2.0 有未完成任務。查證後發現 v0.2.0 的 38 張工作項目全部完成、v0.2.1 的 7 張全部完成、v0.2.2 的 1 張已結案——但三個版本在版本清單中仍標記為 &lt;code>active&lt;/code>。&lt;/p>
&lt;p>這些版本在數個月前就完成了所有工作，但從未被標記為 &lt;code>completed&lt;/code>。看板忠實地反映了版本清單的狀態，所以持續顯示「有未完成工作」。&lt;/p>
&lt;h2 id="根因工具的檢查範圍太窄">根因：工具的檢查範圍太窄&lt;/h2>
&lt;p>版本發布工具在發布 v0.3.0 時，只做一件事：「v0.3.0 的所有 ticket 都完成了嗎？」答案是「是」，就繼續發布。&lt;/p>
&lt;p>它從不問：「比 v0.3.0 更早的版本中，有沒有哪個版本的 ticket 早已全部完成，但 status 仍為 active？」&lt;/p>
&lt;p>這個檢查加起來不難（遍歷版本清單、對每個 active 版本計算 ticket 完成率、完成率 100% 但 status 不是 completed 就報 warning）。但沒有人想到要加——因為在設計工具時，焦點在「當前版本的發布流程」，不在「全局狀態一致性」。&lt;/p>
&lt;h2 id="教訓">教訓&lt;/h2>
&lt;p>資料庫設計中，如果只在寫入時驗證單筆資料的格式而不檢查跨表一致性，orphan record 就會累積。版本管理工具的 pre-flight check 是同一個 pattern——它是內部流程的「外鍵約束」。範圍太窄，殘留就會累積。&lt;/p>
&lt;p>工具只檢查當前版本，一致性就只在當前版本內維持。歷史版本的狀態漂移不會被發現——直到有人手動查看看板。&lt;/p>
&lt;h2 id="修正">修正&lt;/h2>
&lt;p>在版本發布的 pre-flight check 加入全局掃描：&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">$ version-release check
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">[OK] v0.3.0：所有 ticket 完成，可發布
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">[WARN] v0.2.0：38 張 ticket 全部完成但 status 仍為 active
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">[WARN] v0.2.1：7 張 ticket 全部完成但 status 仍為 active&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>修正成本極低（一個迴圈 + 一個 warning），但能在問題累積前暴露。&lt;/p></description><content:encoded><![CDATA[<h2 id="事件">事件</h2>
<p>版本看板顯示 v0.2.0 有未完成任務。查證後發現 v0.2.0 的 38 張工作項目全部完成、v0.2.1 的 7 張全部完成、v0.2.2 的 1 張已結案——但三個版本在版本清單中仍標記為 <code>active</code>。</p>
<p>這些版本在數個月前就完成了所有工作，但從未被標記為 <code>completed</code>。看板忠實地反映了版本清單的狀態，所以持續顯示「有未完成工作」。</p>
<h2 id="根因工具的檢查範圍太窄">根因：工具的檢查範圍太窄</h2>
<p>版本發布工具在發布 v0.3.0 時，只做一件事：「v0.3.0 的所有 ticket 都完成了嗎？」答案是「是」，就繼續發布。</p>
<p>它從不問：「比 v0.3.0 更早的版本中，有沒有哪個版本的 ticket 早已全部完成，但 status 仍為 active？」</p>
<p>這個檢查加起來不難（遍歷版本清單、對每個 active 版本計算 ticket 完成率、完成率 100% 但 status 不是 completed 就報 warning）。但沒有人想到要加——因為在設計工具時，焦點在「當前版本的發布流程」，不在「全局狀態一致性」。</p>
<h2 id="教訓">教訓</h2>
<p>資料庫設計中，如果只在寫入時驗證單筆資料的格式而不檢查跨表一致性，orphan record 就會累積。版本管理工具的 pre-flight check 是同一個 pattern——它是內部流程的「外鍵約束」。範圍太窄，殘留就會累積。</p>
<p>工具只檢查當前版本，一致性就只在當前版本內維持。歷史版本的狀態漂移不會被發現——直到有人手動查看看板。</p>
<h2 id="修正">修正</h2>
<p>在版本發布的 pre-flight check 加入全局掃描：</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">$ version-release check
</span></span><span class="line"><span class="ln">2</span><span class="cl">[OK] v0.3.0：所有 ticket 完成，可發布
</span></span><span class="line"><span class="ln">3</span><span class="cl">[WARN] v0.2.0：38 張 ticket 全部完成但 status 仍為 active
</span></span><span class="line"><span class="ln">4</span><span class="cl">[WARN] v0.2.1：7 張 ticket 全部完成但 status 仍為 active</span></span></code></pre></div><p>修正成本極低（一個迴圈 + 一個 warning），但能在問題累積前暴露。</p>
]]></content:encoded></item><item><title>新增欄位忘記同步 reset — 跨測試狀態洩漏的系統性根因</title><link>https://tarrragon.github.io/blog/work-log/%E6%96%B0%E5%A2%9E%E6%AC%84%E4%BD%8D%E5%BF%98%E8%A8%98%E5%90%8C%E6%AD%A5-reset-%E8%B7%A8%E6%B8%AC%E8%A9%A6%E7%8B%80%E6%85%8B%E6%B4%A9%E6%BC%8F%E7%9A%84%E7%B3%BB%E7%B5%B1%E6%80%A7%E6%A0%B9%E5%9B%A0/</link><pubDate>Thu, 25 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/%E6%96%B0%E5%A2%9E%E6%AC%84%E4%BD%8D%E5%BF%98%E8%A8%98%E5%90%8C%E6%AD%A5-reset-%E8%B7%A8%E6%B8%AC%E8%A9%A6%E7%8B%80%E6%85%8B%E6%B4%A9%E6%BC%8F%E7%9A%84%E7%B3%BB%E7%B5%B1%E6%80%A7%E6%A0%B9%E5%9B%A0/</guid><description>&lt;h2 id="事件">事件&lt;/h2>
&lt;p>JS SDK 的 Monitor class 在一輪並行開發中，三個開發者各自新增了 private 欄位：&lt;code>flushing&lt;/code>（flush 併發 guard）、&lt;code>retryCount&lt;/code>（重試計數）、&lt;code>lastHeartbeat&lt;/code>（心跳時間戳）。三個欄位各自在功能邏輯中被正確使用，但都沒有加進 &lt;code>__reset()&lt;/code> 方法。&lt;/p>
&lt;p>測試框架在每個 test case 之間呼叫 &lt;code>__reset()&lt;/code> 清理狀態。因為 &lt;code>retryCount&lt;/code> 沒被重置，第一個 test case 把 retryCount 遞增到 1，第二個 test case 繼承了這個值，retry 邏輯提前觸發，測試失敗。&lt;/p>
&lt;p>失敗的測試看起來像是 retry 邏輯有 bug，但實際上 retry 邏輯完全正確——問題出在測試隔離。&lt;/p>
&lt;h2 id="根因隱含契約沒有顯性化">根因：隱含契約沒有顯性化&lt;/h2>
&lt;p>Class 的每個 private 欄位都有一個隱含契約：「所有生命週期路徑都知道你的存在。」這包括初始化（constructor / init）、重置（reset / dispose）、序列化（toJSON，如適用）。&lt;/p>
&lt;p>新增欄位時，開發者通常會先在功能邏輯中使用這個欄位——因為那是他加欄位的目的。但「同步到 reset」不是功能邏輯的一部分，它是一個跨切面的維護動作。遺漏的機率隨欄位數和開發者數增加而上升。&lt;/p>
&lt;p>多人（或多 AI agent）並行開發時問題更嚴重——每個人只看自己加的欄位，沒有人有動機去檢查 reset 的完整性。並行修改同一檔案的協調問題見 &lt;a href="https://tarrragon.github.io/blog/work-log/%E4%B8%A6%E8%A1%8C-ai-agent-%E4%BF%AE%E6%94%B9%E5%90%8C%E4%B8%80%E6%AA%94%E6%A1%88%E7%9A%84%E8%A1%9D%E7%AA%81%E6%A8%A1%E5%BC%8F%E8%88%87%E5%8D%94%E8%AA%BF%E7%AD%96%E7%95%A5/" data-link-title="並行 AI Agent 修改同一檔案的衝突模式與協調策略" data-link-desc="並行派多個開發者或 AI agent 同一批 ticket，反覆修改同一個檔案、卡在 branch protection 與 file-modified-since-read。問題在派發策略沒考慮檔案層級的衝突。">parallel_agent_same_file_conflict&lt;/a>。&lt;/p>
&lt;h2 id="防護state-registry-pattern">防護：State Registry Pattern&lt;/h2>
&lt;p>將所有 private 欄位的初始值集中宣告一次：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-typescript" data-lang="typescript">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">initialState() {&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">return&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="nx">config&lt;/span>: &lt;span class="kt">null&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="nx">buffer&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"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">flushing&lt;/span>: &lt;span class="kt">false&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="nx">retryCount&lt;/span>: &lt;span class="kt">0&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="nx">lastHeartbeat&lt;/span>: &lt;span class="kt">0&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">// 新增欄位加在這裡——init 和 reset 自動包含
&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="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>reset 改用 &lt;code>Object.assign(this, initialState())&lt;/code>。新增欄位只改一處，init 和 reset 自動同步。&lt;/p>
&lt;p>配合一個 reset 完整性測試：reset 後 snapshot 比對 initialState 的所有 key——新增欄位但忘記加到 initialState 會因型別或 key 不一致而紅燈。&lt;/p>
&lt;h2 id="適用場景">適用場景&lt;/h2>
&lt;p>任何有「重置到初始狀態」需求的 class：測試框架的 setUp/tearDown、物件池的回收、singleton 的 reinit。問題在「新增欄位」和「同步 reset」是兩個分開的動作（TypeScript、Go、Dart 都會遇到）——只要是分開的，就有遺漏的可能。State Registry 把兩者合併成一個動作。&lt;/p></description><content:encoded><![CDATA[<h2 id="事件">事件</h2>
<p>JS SDK 的 Monitor class 在一輪並行開發中，三個開發者各自新增了 private 欄位：<code>flushing</code>（flush 併發 guard）、<code>retryCount</code>（重試計數）、<code>lastHeartbeat</code>（心跳時間戳）。三個欄位各自在功能邏輯中被正確使用，但都沒有加進 <code>__reset()</code> 方法。</p>
<p>測試框架在每個 test case 之間呼叫 <code>__reset()</code> 清理狀態。因為 <code>retryCount</code> 沒被重置，第一個 test case 把 retryCount 遞增到 1，第二個 test case 繼承了這個值，retry 邏輯提前觸發，測試失敗。</p>
<p>失敗的測試看起來像是 retry 邏輯有 bug，但實際上 retry 邏輯完全正確——問題出在測試隔離。</p>
<h2 id="根因隱含契約沒有顯性化">根因：隱含契約沒有顯性化</h2>
<p>Class 的每個 private 欄位都有一個隱含契約：「所有生命週期路徑都知道你的存在。」這包括初始化（constructor / init）、重置（reset / dispose）、序列化（toJSON，如適用）。</p>
<p>新增欄位時，開發者通常會先在功能邏輯中使用這個欄位——因為那是他加欄位的目的。但「同步到 reset」不是功能邏輯的一部分，它是一個跨切面的維護動作。遺漏的機率隨欄位數和開發者數增加而上升。</p>
<p>多人（或多 AI agent）並行開發時問題更嚴重——每個人只看自己加的欄位，沒有人有動機去檢查 reset 的完整性。並行修改同一檔案的協調問題見 <a href="/blog/work-log/%E4%B8%A6%E8%A1%8C-ai-agent-%E4%BF%AE%E6%94%B9%E5%90%8C%E4%B8%80%E6%AA%94%E6%A1%88%E7%9A%84%E8%A1%9D%E7%AA%81%E6%A8%A1%E5%BC%8F%E8%88%87%E5%8D%94%E8%AA%BF%E7%AD%96%E7%95%A5/" data-link-title="並行 AI Agent 修改同一檔案的衝突模式與協調策略" data-link-desc="並行派多個開發者或 AI agent 同一批 ticket，反覆修改同一個檔案、卡在 branch protection 與 file-modified-since-read。問題在派發策略沒考慮檔案層級的衝突。">parallel_agent_same_file_conflict</a>。</p>
<h2 id="防護state-registry-pattern">防護：State Registry Pattern</h2>
<p>將所有 private 欄位的初始值集中宣告一次：</p>





<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="kd">function</span> <span class="nx">initialState() {</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="k">return</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">config</span>: <span class="kt">null</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">buffer</span><span class="o">:</span> <span class="p">[],</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">flushing</span>: <span class="kt">false</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">retryCount</span>: <span class="kt">0</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">lastHeartbeat</span>: <span class="kt">0</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="c1">// 新增欄位加在這裡——init 和 reset 自動包含
</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 class="p">}</span></span></span></code></pre></div><p>reset 改用 <code>Object.assign(this, initialState())</code>。新增欄位只改一處，init 和 reset 自動同步。</p>
<p>配合一個 reset 完整性測試：reset 後 snapshot 比對 initialState 的所有 key——新增欄位但忘記加到 initialState 會因型別或 key 不一致而紅燈。</p>
<h2 id="適用場景">適用場景</h2>
<p>任何有「重置到初始狀態」需求的 class：測試框架的 setUp/tearDown、物件池的回收、singleton 的 reinit。問題在「新增欄位」和「同步 reset」是兩個分開的動作（TypeScript、Go、Dart 都會遇到）——只要是分開的，就有遺漏的可能。State Registry 把兩者合併成一個動作。</p>
]]></content:encoded></item><item><title>10 個 Ticket、57 個綠燈、0 條追溯：從需求文件到測試的銜接檢討</title><link>https://tarrragon.github.io/blog/work-log/10-%E5%80%8B-ticket57-%E5%80%8B%E7%B6%A0%E7%87%880-%E6%A2%9D%E8%BF%BD%E6%BA%AF%E5%BE%9E%E9%9C%80%E6%B1%82%E6%96%87%E4%BB%B6%E5%88%B0%E6%B8%AC%E8%A9%A6%E7%9A%84%E9%8A%9C%E6%8E%A5%E6%AA%A2%E8%A8%8E/</link><pubDate>Tue, 23 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/10-%E5%80%8B-ticket57-%E5%80%8B%E7%B6%A0%E7%87%880-%E6%A2%9D%E8%BF%BD%E6%BA%AF%E5%BE%9E%E9%9C%80%E6%B1%82%E6%96%87%E4%BB%B6%E5%88%B0%E6%B8%AC%E8%A9%A6%E7%9A%84%E9%8A%9C%E6%8E%A5%E6%AA%A2%E8%A8%8E/</guid><description>&lt;h2 id="這篇要解決什麼">這篇要解決什麼&lt;/h2>
&lt;blockquote>
&lt;p>57 個 unit test 全綠，但沒有任何機制能回答「這些測試覆蓋了哪些 UseCase 場景」。&lt;/p>&lt;/blockquote>
&lt;p>monitor 專案 v0.1.0 從需求文件系統（Proposal → Spec → UseCase）一路走到 Collector 實作，中間經過 BDD 測試設計、紅燈測試撰寫、骨架實作讓綠。流程表面上順暢——10 個根 Ticket 全部完成、Collector 可啟動、所有 unit test 通過。但回頭檢視發現：需求→測試的銜接是單向管道，沒有反向追溯，也沒有邊界回補流程。&lt;/p>
&lt;p>本文記錄 v0.1.0 的完整流程、發現的五個結構性差異、和落地的解決方案。&lt;/p>
&lt;hr>
&lt;h2 id="實際走過的流程">實際走過的流程&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">saas 選型訪談
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> → Proposal（MVP 範圍界定）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> → Spec（14 份，涵蓋 schema/ingestion/query/storage/rule-engine/SDK）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> → UseCase（5 個，UC-01 端到端事件流 ~ UC-05 Web 監控）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> → BDD 測試設計 ANA（全專案 26 個行為場景 → 整合/單元/協議測試清單）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> → 紅燈測試（9 個 Ticket 並行，72 個測試 FAIL）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> → 骨架實作（1 個 Ticket，57 個 unit test GREEN）&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>每個箭頭都有對應的框架機制：saas→doc 有 Stage 6 銜接、doc→TDD 有 doc-handoff 映射表。但箭頭只往右——沒有任何箭頭往左。&lt;/p>
&lt;hr>
&lt;h2 id="五個結構性差異">五個結構性差異&lt;/h2>
&lt;h3 id="差異-1全專案-bdd-設計不在-tdd-phase-模型中">差異 1：「全專案 BDD 設計」不在 TDD Phase 模型中&lt;/h3>
&lt;p>TDD Skill 定義 Phase 0→1→2→3→4 的逐功能流程。v0.1.0 做的是「全專案 UseCase 一次性展開為 BDD 測試設計」，跨越 Phase 1 和 Phase 2 的邊界，粒度是專案級不是功能級。&lt;/p>
&lt;p>這不是 Phase 設計的錯——Phase 模型適合增量開發（每次加一個功能）。新專案起手是不同的工作模式：批量設計、模組群組粒度。&lt;/p>
&lt;p>&lt;strong>解法&lt;/strong>：在 doc-handoff 新增「新專案起手模式」章節，描述批量 BDD 設計流程、Phase 0 豁免條件、模組群組粒度。&lt;/p>
&lt;h3 id="差異-2紅燈測試需要存根stub">差異 2：紅燈測試需要存根（stub）&lt;/h3>
&lt;p>Go 是靜態語言，&lt;code>go test&lt;/code> 必須編譯通過才能執行。紅燈測試引用的 type/interface 不存在時直接編譯失敗，不是「測試 FAIL」。&lt;/p>
&lt;p>TDD Skill 的 Phase 2 說「設計測試」、Phase 3b 說「讓測試綠」，但中間的「建存根讓測試可紅」沒有定義。&lt;/p>
&lt;p>&lt;strong>實作驗證&lt;/strong>：v0.1.0 的每個紅燈 Ticket 都自帶建立存根（空 function return nil / 空 struct / 回 501 的 HTTP handler），存根讓 &lt;code>go test&lt;/code> 編譯通過，合法測試 PASS、非法測試 FAIL = 紅燈狀態。&lt;/p>
&lt;p>&lt;strong>解法&lt;/strong>：Phase 3 rules 新增「存根策略」章節，涵蓋靜態語言（Go/Dart）和動態語言（Python/JS）的不同處理。&lt;/p>
&lt;h3 id="差異-3測試usecase-沒有反向追溯">差異 3：測試→UseCase 沒有反向追溯&lt;/h3>
&lt;p>寫完 57 個 unit test 後，問「UC-01 的替代場景 01a（批次部分失敗 → 207）被哪些測試覆蓋？」——沒有任何機制能回答。&lt;/p>
&lt;p>&lt;code>doc test-map UC-01&lt;/code> 工具存在但回傳 0 個測試——因為它搜尋 UC frontmatter 的 &lt;code>ticket_refs&lt;/code>，和測試檔案沒有連結。Spec 的「三方交叉比對」是建 Ticket 時的一次性動作，不是持續追溯。&lt;/p>
&lt;p>&lt;strong>解法&lt;/strong>：建立 &lt;code>docs/traceability.yaml&lt;/code> 追溯矩陣，三層追溯（UC 場景 → 整合測試 IT-* → 單元測試 UT-* → Spec FR）。每個 entry 標記 &lt;code>covered&lt;/code> / &lt;code>gap&lt;/code> / &lt;code>deferred&lt;/code>。&lt;/p></description><content:encoded><![CDATA[<h2 id="這篇要解決什麼">這篇要解決什麼</h2>
<blockquote>
<p>57 個 unit test 全綠，但沒有任何機制能回答「這些測試覆蓋了哪些 UseCase 場景」。</p></blockquote>
<p>monitor 專案 v0.1.0 從需求文件系統（Proposal → Spec → UseCase）一路走到 Collector 實作，中間經過 BDD 測試設計、紅燈測試撰寫、骨架實作讓綠。流程表面上順暢——10 個根 Ticket 全部完成、Collector 可啟動、所有 unit test 通過。但回頭檢視發現：需求→測試的銜接是單向管道，沒有反向追溯，也沒有邊界回補流程。</p>
<p>本文記錄 v0.1.0 的完整流程、發現的五個結構性差異、和落地的解決方案。</p>
<hr>
<h2 id="實際走過的流程">實際走過的流程</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">saas 選型訪談
</span></span><span class="line"><span class="ln">2</span><span class="cl">  → Proposal（MVP 範圍界定）
</span></span><span class="line"><span class="ln">3</span><span class="cl">    → Spec（14 份，涵蓋 schema/ingestion/query/storage/rule-engine/SDK）
</span></span><span class="line"><span class="ln">4</span><span class="cl">      → UseCase（5 個，UC-01 端到端事件流 ~ UC-05 Web 監控）
</span></span><span class="line"><span class="ln">5</span><span class="cl">        → BDD 測試設計 ANA（全專案 26 個行為場景 → 整合/單元/協議測試清單）
</span></span><span class="line"><span class="ln">6</span><span class="cl">          → 紅燈測試（9 個 Ticket 並行，72 個測試 FAIL）
</span></span><span class="line"><span class="ln">7</span><span class="cl">            → 骨架實作（1 個 Ticket，57 個 unit test GREEN）</span></span></code></pre></div><p>每個箭頭都有對應的框架機制：saas→doc 有 Stage 6 銜接、doc→TDD 有 doc-handoff 映射表。但箭頭只往右——沒有任何箭頭往左。</p>
<hr>
<h2 id="五個結構性差異">五個結構性差異</h2>
<h3 id="差異-1全專案-bdd-設計不在-tdd-phase-模型中">差異 1：「全專案 BDD 設計」不在 TDD Phase 模型中</h3>
<p>TDD Skill 定義 Phase 0→1→2→3→4 的逐功能流程。v0.1.0 做的是「全專案 UseCase 一次性展開為 BDD 測試設計」，跨越 Phase 1 和 Phase 2 的邊界，粒度是專案級不是功能級。</p>
<p>這不是 Phase 設計的錯——Phase 模型適合增量開發（每次加一個功能）。新專案起手是不同的工作模式：批量設計、模組群組粒度。</p>
<p><strong>解法</strong>：在 doc-handoff 新增「新專案起手模式」章節，描述批量 BDD 設計流程、Phase 0 豁免條件、模組群組粒度。</p>
<h3 id="差異-2紅燈測試需要存根stub">差異 2：紅燈測試需要存根（stub）</h3>
<p>Go 是靜態語言，<code>go test</code> 必須編譯通過才能執行。紅燈測試引用的 type/interface 不存在時直接編譯失敗，不是「測試 FAIL」。</p>
<p>TDD Skill 的 Phase 2 說「設計測試」、Phase 3b 說「讓測試綠」，但中間的「建存根讓測試可紅」沒有定義。</p>
<p><strong>實作驗證</strong>：v0.1.0 的每個紅燈 Ticket 都自帶建立存根（空 function return nil / 空 struct / 回 501 的 HTTP handler），存根讓 <code>go test</code> 編譯通過，合法測試 PASS、非法測試 FAIL = 紅燈狀態。</p>
<p><strong>解法</strong>：Phase 3 rules 新增「存根策略」章節，涵蓋靜態語言（Go/Dart）和動態語言（Python/JS）的不同處理。</p>
<h3 id="差異-3測試usecase-沒有反向追溯">差異 3：測試→UseCase 沒有反向追溯</h3>
<p>寫完 57 個 unit test 後，問「UC-01 的替代場景 01a（批次部分失敗 → 207）被哪些測試覆蓋？」——沒有任何機制能回答。</p>
<p><code>doc test-map UC-01</code> 工具存在但回傳 0 個測試——因為它搜尋 UC frontmatter 的 <code>ticket_refs</code>，和測試檔案沒有連結。Spec 的「三方交叉比對」是建 Ticket 時的一次性動作，不是持續追溯。</p>
<p><strong>解法</strong>：建立 <code>docs/traceability.yaml</code> 追溯矩陣，三層追溯（UC 場景 → 整合測試 IT-* → 單元測試 UT-* → Spec FR）。每個 entry 標記 <code>covered</code> / <code>gap</code> / <code>deferred</code>。</p>
<h3 id="差異-4邊界條件發現後沒有回補-uc-的流程">差異 4：邊界條件發現後沒有回補 UC 的流程</h3>
<p>寫 Ingest Handler 測試時發現：「如果 POST body 不是 JSON 怎麼辦？」「如果 Content-Type 是 text/plain（sendBeacon）怎麼辦？」這些邊界在 UC-01 的場景描述中不存在。</p>
<p>測試設計的 BDD ANA 有涵蓋這些邊界場景，但 UC 文件本身沒有更新。邊界條件「住」在測試設計文件而非 UseCase——下次有人讀 UC 不會知道這些邊界存在。</p>
<p><strong>解法</strong>：追溯矩陣增加 <code>boundaries:</code> 區段，測試撰寫者發現新邊界時加 gap entry，PM 建 DOC Ticket 回補 UC/Spec。Phase 4d 掃描所有 gap 確認無遺漏。</p>
<h3 id="差異-5ticket-拆分邊界未對齊測試變綠驗收點">差異 5：Ticket 拆分邊界未對齊測試變綠驗收點</h3>
<p>Collector 實作被拆為 4 個 Ticket：骨架（interface 定義）/ Storage / Ingestion Handler / Query Handler。骨架 Ticket 指派做「main.go + Config + Storage interface」，代理人完成了所有模組實作——57 個 unit test 從紅全部變綠，其餘 3 個 Ticket 的 acceptance 全被涵蓋。</p>
<p>初看像是「代理人超額完成」，回頭用判讀三問檢查骨架 Ticket：完成後有測試變綠嗎？→ 沒有（只定義 interface）。能獨立跑測試嗎？→ 不能（其他模組引用骨架的 type）。共用 type？→ 是。三問全部指向「不應獨立拆」。<strong>根因是 Ticket 拆分設計</strong>，不是代理人行為——按 Spec FR 拆（輸入驅動）導致骨架 Ticket 完成後 0 個測試狀態改變，不是有意義的驗收點。</p>
<p><strong>判讀規則</strong>：實作 Ticket 的拆分邊界必須對齊「測試從紅變綠」的驗收點。一個 Ticket 完成後若沒有任何測試狀態改變，它不應該是獨立 Ticket。</p>
<p>判讀三問：</p>
<ol>
<li>這個 Ticket 完成後，有測試從 FAIL 變 PASS 嗎？</li>
<li>拆出的各部分能獨立跑測試嗎？</li>
<li>不同部分共用同一組 type/error/constant 嗎？</li>
</ol>
<p><strong>反模式</strong>：按 Spec FR 拆（輸入驅動）。<strong>正確做法</strong>：按「哪組測試變綠」拆（輸出驅動）。</p>
<hr>
<h2 id="追溯矩陣的設計">追溯矩陣的設計</h2>
<p>追溯矩陣是三個問題（向上追溯 + 覆蓋驗證 + 邊界回補）的統一解法。</p>
<h3 id="結構">結構</h3>





<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="nt">UC-01</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w">  </span><span class="nt">title</span><span class="p">:</span><span class="w"> </span><span class="l">端到端事件流</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">  </span><span class="nt">scenarios</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="nt">main</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">      </span><span class="nt">integration_tests</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">IT-01-01]</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">      </span><span class="nt">unit_tests</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">UT-COL-01-01, UT-COL-02-01, UT-COL-04-01]</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">      </span><span class="nt">spec_frs</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">SPEC-002-FR-01, SPEC-003-FR-01]</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">      </span><span class="nt">status</span><span class="p">:</span><span class="w"> </span><span class="l">covered</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">    </span><span class="nt">alt-01a</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">      </span><span class="nt">integration_tests</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">IT-01-02]</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">      </span><span class="nt">unit_tests</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">UT-COL-01-03, UT-COL-02-03]</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">      </span><span class="nt">spec_frs</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">SPEC-002-FR-02]</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">      </span><span class="nt">status</span><span class="p">:</span><span class="w"> </span><span class="l">covered</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w"></span><span class="nt">boundaries</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">  </span><span class="nt">batch-limit</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w">    </span><span class="nt">discovered_during</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;ingestion-handler-red-tests&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w">    </span><span class="nt">status</span><span class="p">:</span><span class="w"> </span><span class="l">gap </span><span class="w"> </span><span class="c"># 需回補 UC/Spec</span></span></span></code></pre></div><h3 id="三個問題的對應">三個問題的對應</h3>
<table>
  <thead>
      <tr>
          <th>問題</th>
          <th>矩陣欄位</th>
          <th>查法</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>這個 UT 為了哪個 UC？</td>
          <td><code>unit_tests</code></td>
          <td>搜尋 UT ID → 找到歸屬的 scenario</td>
      </tr>
      <tr>
          <td>UC 場景都有測試嗎？</td>
          <td><code>status</code></td>
          <td>掃描 <code>gap</code> entry</td>
      </tr>
      <tr>
          <td>新邊界怎麼回補 UC？</td>
          <td><code>boundaries</code></td>
          <td>gap entry → DOC Ticket → 回補 → covered</td>
      </tr>
  </tbody>
</table>
<h3 id="整合點">整合點</h3>
<table>
  <thead>
      <tr>
          <th>機制</th>
          <th>時機</th>
          <th>動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>doc-handoff</td>
          <td>銜接時</td>
          <td>初始化矩陣骨架（UC scenario 空映射）</td>
      </tr>
      <tr>
          <td>紅燈測試撰寫</td>
          <td>Phase 2→3</td>
          <td>填入 unit_tests 映射</td>
      </tr>
      <tr>
          <td>邊界發現</td>
          <td>實作中</td>
          <td>加 boundary gap entry</td>
      </tr>
      <tr>
          <td>Phase 4d</td>
          <td>重構評估</td>
          <td>掃描所有 gap，建 DOC Ticket</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="附帶發現並行派發的-git-隔離問題">附帶發現：並行派發的 Git 隔離問題</h2>
<p>5 個代理人以 worktree 並行派發時，commit 內容交叉混入——A 代理人的 commit 包含 B 代理人的檔案。根因：主 repo 不在 main 分支，多個 worktree 共用同一分支 ref，<code>git add + commit</code> race condition。</p>
<p><strong>防護</strong>：派發前確保主 repo 在 main + 已 push。單一代理人和正確條件下的多代理人都驗證通過。</p>
<hr>
<h2 id="結論">結論</h2>
<p>v0.1.0 的流程不是失敗——Collector 可用、57 個 test GREEN。問題在於「走到終點後沒有辦法回頭驗證起點」。需求→測試的管道是單向的：Proposal 說了什麼、Spec 定了什麼 FR、UC 描述了什麼場景，和最終的測試之間沒有結構化連結。</p>
<p>追溯矩陣不增加任何程式碼——它是一個 YAML 檔案，記錄「每個測試為什麼存在」。維護成本是每次寫測試多填一行映射。回報是：任何時候都能回答「這個 UC 場景有沒有被測試保護」。</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>Git：git stash 的 -u 參數（連未追蹤檔案一起暫存）</title><link>https://tarrragon.github.io/blog/work-log/gitgit-stash-%E7%9A%84-u-%E5%8F%83%E6%95%B8%E9%80%A3%E6%9C%AA%E8%BF%BD%E8%B9%A4%E6%AA%94%E6%A1%88%E4%B8%80%E8%B5%B7%E6%9A%AB%E5%AD%98/</link><pubDate>Fri, 05 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/gitgit-stash-%E7%9A%84-u-%E5%8F%83%E6%95%B8%E9%80%A3%E6%9C%AA%E8%BF%BD%E8%B9%A4%E6%AA%94%E6%A1%88%E4%B8%80%E8%B5%B7%E6%9A%AB%E5%AD%98/</guid><description>&lt;h2 id="問題情境">問題情境&lt;/h2>
&lt;p>開新功能時，工作目錄常常同時有「改過的舊檔案」和「全新建立的檔案」。
想用 &lt;code>git stash&lt;/code> 暫時收起來去拉主線變更，卻發現新檔案沒被收進去，
還散在工作目錄裡，導致 rebase / 切分支時出狀況。&lt;/p>
&lt;p>原因是預設的 &lt;code>git stash&lt;/code> &lt;strong>不會收 untracked 檔案&lt;/strong>。&lt;/p>
&lt;hr>
&lt;h2 id="-u-是什麼">&lt;code>-u&lt;/code> 是什麼&lt;/h2>
&lt;p>&lt;code>-u&lt;/code> 是 &lt;code>--include-untracked&lt;/code> 的縮寫，&lt;code>u&lt;/code> 就是 &lt;strong>untracked&lt;/strong>（未追蹤檔案）。&lt;/p>
&lt;p>Git 把工作目錄的檔案分成幾種狀態，跟 stash 有關的是這三種：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>狀態&lt;/th>
 &lt;th>意思&lt;/th>
 &lt;th>預設 &lt;code>git stash&lt;/code> 會收嗎？&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>tracked + modified&lt;/td>
 &lt;td>Git 已追蹤、改過的檔案&lt;/td>
 &lt;td>會&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>untracked&lt;/td>
 &lt;td>全新檔案，從未 &lt;code>git add&lt;/code> 過&lt;/td>
 &lt;td>不會（要 &lt;code>-u&lt;/code>）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>ignored&lt;/td>
 &lt;td>被 &lt;code>.gitignore&lt;/code> 忽略的檔案&lt;/td>
 &lt;td>不會（要 &lt;code>-a&lt;/code> / &lt;code>--all&lt;/code>）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>「untracked」就是 &lt;code>git status&lt;/code> 裡出現在 &lt;code>Untracked files:&lt;/code> 區塊的新檔案。&lt;/p>
&lt;p>Git 預設不收 untracked，是因為這類檔案常是編譯產物、log、暫存檔，全收進 stash 反而把雜物一起搬動；要求用 &lt;code>-u&lt;/code> 明確表態，是把「要不要連新檔一起暫存」的決定權留給操作者。&lt;code>-a&lt;/code>（&lt;code>--all&lt;/code>）範圍更大，連 &lt;code>.gitignore&lt;/code> 忽略的也一起收，日常少用。&lt;/p>
&lt;hr>
&lt;h2 id="正確流程拉主線變更">正確流程（拉主線變更）&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">git stash push -u -m &lt;span class="s2">&amp;#34;暫存&amp;#34;&lt;/span> &lt;span class="c1"># -u 連 untracked 新檔案一起收&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">git pull --rebase origin main &lt;span class="c1"># 或 git fetch + git rebase&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">git stash pop &lt;span class="c1"># 把修改倒回工作目錄&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="替代做法">替代做法&lt;/h2>
&lt;p>也可以「先 commit 再 rebase，事後再 &lt;code>git reset --mixed HEAD~1&lt;/code> 把 commit 拆回未提交狀態」。
這個做法會把 untracked 新檔一起收進 commit，省去記得加 &lt;code>-u&lt;/code> 的步驟，適合改動較大、想先有一個完整快照的情況。&lt;/p>
&lt;p>兩者取捨：commit 法會在分支歷史暫時多一顆 commit，rebase 完要記得 &lt;code>reset&lt;/code> 拆回；stash 法把改動收在 &lt;code>refs/stash&lt;/code>、不進分支 log，但 untracked 檔要記得 &lt;code>-u&lt;/code>。日常小改動用 stash 心智負擔較低，改動大或想保留完整快照時用 commit 法。&lt;/p></description><content:encoded><![CDATA[<h2 id="問題情境">問題情境</h2>
<p>開新功能時，工作目錄常常同時有「改過的舊檔案」和「全新建立的檔案」。
想用 <code>git stash</code> 暫時收起來去拉主線變更，卻發現新檔案沒被收進去，
還散在工作目錄裡，導致 rebase / 切分支時出狀況。</p>
<p>原因是預設的 <code>git stash</code> <strong>不會收 untracked 檔案</strong>。</p>
<hr>
<h2 id="-u-是什麼"><code>-u</code> 是什麼</h2>
<p><code>-u</code> 是 <code>--include-untracked</code> 的縮寫，<code>u</code> 就是 <strong>untracked</strong>（未追蹤檔案）。</p>
<p>Git 把工作目錄的檔案分成幾種狀態，跟 stash 有關的是這三種：</p>
<table>
  <thead>
      <tr>
          <th>狀態</th>
          <th>意思</th>
          <th>預設 <code>git stash</code> 會收嗎？</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>tracked + modified</td>
          <td>Git 已追蹤、改過的檔案</td>
          <td>會</td>
      </tr>
      <tr>
          <td>untracked</td>
          <td>全新檔案，從未 <code>git add</code> 過</td>
          <td>不會（要 <code>-u</code>）</td>
      </tr>
      <tr>
          <td>ignored</td>
          <td>被 <code>.gitignore</code> 忽略的檔案</td>
          <td>不會（要 <code>-a</code> / <code>--all</code>）</td>
      </tr>
  </tbody>
</table>
<p>「untracked」就是 <code>git status</code> 裡出現在 <code>Untracked files:</code> 區塊的新檔案。</p>
<p>Git 預設不收 untracked，是因為這類檔案常是編譯產物、log、暫存檔，全收進 stash 反而把雜物一起搬動；要求用 <code>-u</code> 明確表態，是把「要不要連新檔一起暫存」的決定權留給操作者。<code>-a</code>（<code>--all</code>）範圍更大，連 <code>.gitignore</code> 忽略的也一起收，日常少用。</p>
<hr>
<h2 id="正確流程拉主線變更">正確流程（拉主線變更）</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git stash push -u -m <span class="s2">&#34;暫存&#34;</span>    <span class="c1"># -u 連 untracked 新檔案一起收</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git pull --rebase origin main  <span class="c1"># 或 git fetch + git rebase</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">git stash pop                  <span class="c1"># 把修改倒回工作目錄</span></span></span></code></pre></div><hr>
<h2 id="替代做法">替代做法</h2>
<p>也可以「先 commit 再 rebase，事後再 <code>git reset --mixed HEAD~1</code> 把 commit 拆回未提交狀態」。
這個做法會把 untracked 新檔一起收進 commit，省去記得加 <code>-u</code> 的步驟，適合改動較大、想先有一個完整快照的情況。</p>
<p>兩者取捨：commit 法會在分支歷史暫時多一顆 commit，rebase 完要記得 <code>reset</code> 拆回；stash 法把改動收在 <code>refs/stash</code>、不進分支 log，但 untracked 檔要記得 <code>-u</code>。日常小改動用 stash 心智負擔較低，改動大或想保留完整快照時用 commit 法。</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>CI step silent hang：時間真空才是訊號、happy log 反而是 anti-signal</title><link>https://tarrragon.github.io/blog/work-log/ci-step-silent-hang%E6%99%82%E9%96%93%E7%9C%9F%E7%A9%BA%E6%89%8D%E6%98%AF%E8%A8%8A%E8%99%9Fhappy-log-%E5%8F%8D%E8%80%8C%E6%98%AF-anti-signal/</link><pubDate>Thu, 28 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/ci-step-silent-hang%E6%99%82%E9%96%93%E7%9C%9F%E7%A9%BA%E6%89%8D%E6%98%AF%E8%A8%8A%E8%99%9Fhappy-log-%E5%8F%8D%E8%80%8C%E6%98%AF-anti-signal/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>核心議題&lt;/strong>：CI step 看起來「跑了很久才 timeout」時，要分辨「真的時間不夠」跟「silent hang 占滿時間」 — 兩者修法完全不同。Silent hang 的訊號是「最後一行 happy log 到 cancel 之間有大段時間真空」、不是「最後一行錯誤訊息」。第一次歸因錯誤後、第二次 fail 不該再加 timeout、該停下來重看 detailed log。
&lt;strong>案例骨幹&lt;/strong>：本 blog 的 Playwright CI 一直 timeout、初診「cache 缺失 + timeout 太緊」加了 cache + bump timeout、仍 timeout。重看 detailed log 發現 chromium 下載 2 秒完成、之後 24 分 31 秒&lt;strong>完全沒任何 log&lt;/strong> 才被 cancel — Playwright 1.59 在 Node.js 24.16.0 的 extract-zip regression（&lt;a href="https://github.com/microsoft/playwright/issues/41000">microsoft/playwright#41000&lt;/a>、上游 &lt;a href="https://github.com/nodejs/node/issues/63487">nodejs/node#63487&lt;/a>）。升 Playwright 1.60.0 後該 step 從 25 分鐘卡死降到 22 秒。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="1-silent-hang-是-happy-log-的-anti-signal">1. Silent hang 是 happy log 的 anti-signal&lt;/h2>
&lt;p>CI step timeout 時、第一個本能是看「step 跑了多久」。15 分鐘 timeout 然後被砍、直覺判斷是「時間不夠、bump timeout」。這個直覺對應的失敗模式是「step 真的需要 16 分鐘才能跑完」。&lt;/p>
&lt;p>但有另一種失敗模式長得很像、修法完全不同：&lt;strong>silent hang&lt;/strong> — step 在某個點之後就不再輸出任何 log、process 仍在執行（沒有 crash）、直到外部 timeout 才被砍。表面看跟「時間不夠」一樣（step 跑很久才被 cancel）、但根因是 process 本身卡死、給多少時間都跑不完。&lt;/p>
&lt;p>辨識 silent hang 的關鍵訊號是「最後一行 happy log 到 cancel 訊息之間有大段時間真空」。&lt;strong>「Happy log」指的是看起來成功的訊息&lt;/strong>（例：下載 100% 完成、build succeeded、X tests passed）— 這類訊息特別會誤導判斷、因為它讓人以為任務在進展。Silent hang 開始之前的最後一行通常正是這種 happy log、是正常結束訊號的反面。&lt;/p>
&lt;h3 id="三類-timeout-模式的對照">三類 timeout 模式的對照&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>整個 step 進度持續、最後階段加速到 timeout&lt;/td>
 &lt;td>時間真的不夠&lt;/td>
 &lt;td>bump timeout&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>有失敗訊息（exception / non-zero exit）之後 timeout&lt;/td>
 &lt;td>code 邏輯錯&lt;/td>
 &lt;td>看訊息修&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>最後一行 log 之後有大段時間真空、然後 cancel&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>Silent hang&lt;/strong>、可能 upstream bug&lt;/td>
 &lt;td>&lt;strong>查 upstream issue tracker、不是加 timeout&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>第三種最容易誤判、因為「log 之間沒輸出」沒被當成訊號 — 但&lt;strong>訊息真空本身就是訊號&lt;/strong>。寫 debug log 的人會記得補 error 訊息、但 silent hang 通常發生在工具內部的某個沒輸出 log 的等待點、所以沒有 error 訊息可看。&lt;/p>
&lt;hr>
&lt;h2 id="2-為什麼cache-缺失--bump-timeout的初診是-false-positive">2. 為什麼「cache 缺失 + bump timeout」的初診是 false positive&lt;/h2>
&lt;p>第一次看 CI fail log 時、有三件容易抓到的事：&lt;/p>
&lt;ol>
&lt;li>workflow YAML 裡的 &lt;code>timeout-minutes: 15&lt;/code>&lt;/li>
&lt;li>step 跑了 &lt;code>15m 6s&lt;/code>（幾乎等於 timeout 上限）&lt;/li>
&lt;li>step 名稱是 &lt;code>Install Playwright browsers&lt;/code>（要下載 170 MiB）&lt;/li>
&lt;/ol>
&lt;p>直覺合成的結論：「cache 缺失 + timeout 太緊」。這結論看起來「應該對」 — 因為這兩個都是「Install Playwright browsers」眾所周知的優化點。修法：加 &lt;code>actions/cache&lt;/code> + bump timeout 25 min。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>核心議題</strong>：CI step 看起來「跑了很久才 timeout」時，要分辨「真的時間不夠」跟「silent hang 占滿時間」 — 兩者修法完全不同。Silent hang 的訊號是「最後一行 happy log 到 cancel 之間有大段時間真空」、不是「最後一行錯誤訊息」。第一次歸因錯誤後、第二次 fail 不該再加 timeout、該停下來重看 detailed log。
<strong>案例骨幹</strong>：本 blog 的 Playwright CI 一直 timeout、初診「cache 缺失 + timeout 太緊」加了 cache + bump timeout、仍 timeout。重看 detailed log 發現 chromium 下載 2 秒完成、之後 24 分 31 秒<strong>完全沒任何 log</strong> 才被 cancel — Playwright 1.59 在 Node.js 24.16.0 的 extract-zip regression（<a href="https://github.com/microsoft/playwright/issues/41000">microsoft/playwright#41000</a>、上游 <a href="https://github.com/nodejs/node/issues/63487">nodejs/node#63487</a>）。升 Playwright 1.60.0 後該 step 從 25 分鐘卡死降到 22 秒。</p></blockquote>
<hr>
<h2 id="1-silent-hang-是-happy-log-的-anti-signal">1. Silent hang 是 happy log 的 anti-signal</h2>
<p>CI step timeout 時、第一個本能是看「step 跑了多久」。15 分鐘 timeout 然後被砍、直覺判斷是「時間不夠、bump timeout」。這個直覺對應的失敗模式是「step 真的需要 16 分鐘才能跑完」。</p>
<p>但有另一種失敗模式長得很像、修法完全不同：<strong>silent hang</strong> — step 在某個點之後就不再輸出任何 log、process 仍在執行（沒有 crash）、直到外部 timeout 才被砍。表面看跟「時間不夠」一樣（step 跑很久才被 cancel）、但根因是 process 本身卡死、給多少時間都跑不完。</p>
<p>辨識 silent hang 的關鍵訊號是「最後一行 happy log 到 cancel 訊息之間有大段時間真空」。<strong>「Happy log」指的是看起來成功的訊息</strong>（例：下載 100% 完成、build succeeded、X tests passed）— 這類訊息特別會誤導判斷、因為它讓人以為任務在進展。Silent hang 開始之前的最後一行通常正是這種 happy log、是正常結束訊號的反面。</p>
<h3 id="三類-timeout-模式的對照">三類 timeout 模式的對照</h3>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>可能根因</th>
          <th>修法</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>整個 step 進度持續、最後階段加速到 timeout</td>
          <td>時間真的不夠</td>
          <td>bump timeout</td>
      </tr>
      <tr>
          <td>有失敗訊息（exception / non-zero exit）之後 timeout</td>
          <td>code 邏輯錯</td>
          <td>看訊息修</td>
      </tr>
      <tr>
          <td><strong>最後一行 log 之後有大段時間真空、然後 cancel</strong></td>
          <td><strong>Silent hang</strong>、可能 upstream bug</td>
          <td><strong>查 upstream issue tracker、不是加 timeout</strong></td>
      </tr>
  </tbody>
</table>
<p>第三種最容易誤判、因為「log 之間沒輸出」沒被當成訊號 — 但<strong>訊息真空本身就是訊號</strong>。寫 debug log 的人會記得補 error 訊息、但 silent hang 通常發生在工具內部的某個沒輸出 log 的等待點、所以沒有 error 訊息可看。</p>
<hr>
<h2 id="2-為什麼cache-缺失--bump-timeout的初診是-false-positive">2. 為什麼「cache 缺失 + bump timeout」的初診是 false positive</h2>
<p>第一次看 CI fail log 時、有三件容易抓到的事：</p>
<ol>
<li>workflow YAML 裡的 <code>timeout-minutes: 15</code></li>
<li>step 跑了 <code>15m 6s</code>（幾乎等於 timeout 上限）</li>
<li>step 名稱是 <code>Install Playwright browsers</code>（要下載 170 MiB）</li>
</ol>
<p>直覺合成的結論：「cache 缺失 + timeout 太緊」。這結論看起來「應該對」 — 因為這兩個都是「Install Playwright browsers」眾所周知的優化點。修法：加 <code>actions/cache</code> + bump timeout 25 min。</p>
<p>修完仍 timeout、但這次跑 <code>25m 6s</code>（一樣頂到上限）。</p>
<p><strong>這時的訊號應該是「同樣的 step 在 1.67 倍的 timeout 下仍頂到上限」</strong> — 如果是時間不夠、bump 之後該往中間靠（譬如完成在 18-20 min）；如果一直頂到上限、意思是 step 不會自己結束、是 hang。</p>
<p>但初診時很容易略過這個訊號、轉而繼續想「是不是 cache step 設定有問題？」。這個歸因方向是錯的、因為前置假設「cache 是瓶頸」本身就沒驗證過。</p>
<h3 id="一輪-false-positive-的-anatomy">一輪 false positive 的 anatomy</h3>
<table>
  <thead>
      <tr>
          <th>步驟</th>
          <th>容易做的</th>
          <th>該做的</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>看到 timeout</td>
          <td>假設「時間不夠」</td>
          <td>先區分「時間不夠」vs「silent hang」</td>
      </tr>
      <tr>
          <td>看 high-level log</td>
          <td>假設「下載慢」</td>
          <td>應該看下載前後 timestamp 比對</td>
      </tr>
      <tr>
          <td>提解法</td>
          <td>加 cache + bump timeout</td>
          <td>應該先確認瓶頸真的在下載</td>
      </tr>
      <tr>
          <td>解法仍 fail</td>
          <td>假設「cache 沒 hit」</td>
          <td>應該意識到「同個 step 又頂到上限」是 hang 訊號</td>
      </tr>
  </tbody>
</table>
<p>每一步單看都合理、合起來就是把 false positive 越雕越精緻。這個 anatomy 對任何「初診沒驗證就改」的場景都適用、不限 CI。</p>
<hr>
<h2 id="3-wrap-的-r-在第二次-fail-時是-stop-訊號">3. WRAP 的 R 在第二次 fail 時是 stop 訊號</h2>
<p>WRAP 決策框架的 R（Reality Test）原則是「需要什麼事證才能證明這個方法可行？」。它不只是決策前的檢查、更是<strong>連續失敗後的 stop 訊號</strong>。</p>
<p>第二次 fail 時、繼續同方向加 timeout 是自動駕駛模式。WRAP 在這個位置該提醒的事：</p>
<ul>
<li>「兩次同類修法都沒解、是不是前置假設錯了？」</li>
<li>「我有沒有資料去判斷真正卡哪？」（資料充足度閘門）</li>
<li>「同類問題的 base rate 是什麼？」（基本率思考）</li>
</ul>
<p><strong>Stop 訊號的觸發條件是「同方向修法連續 fail 2 次」、不是「fail 3 次」</strong>。第二次就該回到資料層；第三次已經是浪費 cycle 而且強化錯誤假設。</p>
<p>實際上第二次 fail 後做的對的事是停下來、grep detailed log 的 timestamp 序列、發現「下載完成」跟「cancel」之間有 24 分鐘空白 — 這時才確認是 silent hang。如果第二次沒做這個轉折、第三次大概率是「換更大的 timeout」或「換不同的 cache key」、仍 fail。</p>
<hr>
<h2 id="4-detailed-log-的關鍵讀法找沒輸出的時間段">4. Detailed log 的關鍵讀法：找「沒輸出的時間段」</h2>
<p>CI 平台的 step log 通常很長、人眼掃容易跳過。看 silent hang 嫌疑時、讀法不是順序讀、是抓四個 timestamp：</p>
<ol>
<li><strong>Step 開始的 timestamp</strong>（log header 通常有）</li>
<li><strong>Step 結束（cancel / fail）的 timestamp</strong></li>
<li><strong>最後一行有意義輸出的 timestamp</strong></li>
<li>計算 #3 到 #2 之間的時間真空</li>
</ol>
<p>真空夠大（&gt; 1 分鐘）+ #3 是 happy log = silent hang 嫌疑高。</p>
<p>GitHub Actions 用 <code>gh</code> CLI 的具體做法：</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"># 取某個 step 的所有 log（filter step 名稱）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gh run view &lt;run-id&gt; --log --job &lt;job-id&gt; <span class="p">|</span> rg <span class="s2">&#34;Install Playwright browsers&#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"># 抓最後幾行看真空尾巴</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">gh run view &lt;run-id&gt; --log --job &lt;job-id&gt; <span class="p">|</span> rg <span class="s2">&#34;Install Playwright browsers&#34;</span> <span class="p">|</span> tail -3</span></span></code></pre></div><p>本案例的最後 3 行（簡化過）：</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">2026-05-27T09:59:44.110Z  | 100% of 170.4 MiB
</span></span><span class="line"><span class="ln">2</span><span class="cl">2026-05-27T10:24:15.201Z  ##[error]The operation was canceled.</span></span></code></pre></div><p>24 分 31 秒真空、最後一行 happy log 是「下載 100% 完成」 — silent hang 確認。</p>
<p>這個讀法的核心是「<strong>時間真空優先於訊息內容</strong>」。技術人員習慣讀訊息內容找 error keyword、但 silent hang 沒有 error keyword 可找、只有時間真空。轉個訊號類型才看得到。</p>
<hr>
<h2 id="5-upstream-issue-搜尋的優先序">5. Upstream issue 搜尋的優先序</h2>
<p>Silent hang 確認後、下一步通常<strong>不是繼續 reason 根因</strong>、是去查 upstream issue tracker。Silent hang 多半是工具 / 依賴的 bug、而非自己 config 錯 — 因為 config 錯通常有 error message、不會 silent。</p>
<p>查詢策略：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">gh api <span class="s1">&#39;search/issues?q=repo:&lt;upstream&gt;/&lt;repo&gt;+&lt;symptom keywords&gt;+is:issue&amp;per_page=10&amp;sort=updated&#39;</span></span></span></code></pre></div><p>關鍵是 <strong>keyword 選擇用「症狀詞」而不是「猜測詞」</strong>。症狀詞描述讀者實際觀察到的現象（<code>hangs after download</code>、<code>stuck during extract</code>），猜測詞描述讀者推測的根因（<code>slow</code>、<code>timeout</code>、<code>network issue</code>）。猜測詞會找到大量無關 issue；症狀詞通常直接命中。</p>
<p>本案例查詢 <code>playwright install hangs chromium</code> 第二筆結果就是 issue #41000、標題完全匹配「<code>playwright install chromium</code> hangs after download completes on Node.js 24.16.0 (extract-zip)」。Issue 詳情指向上游 <a href="https://github.com/nodejs/node/issues/63487">nodejs/node#63487</a>、給出兩個 workaround（升 Playwright 1.60.0 或 pin Node 24.15.0）。從查詢到確認根因、全程不到 5 分鐘。</p>
<h3 id="為什麼-issue-tracker-該優先於-self-reasoning">為什麼 issue tracker 該優先於 self-reasoning</h3>
<p>技術人員的 instinct 是「自己想出根因」。但 CI silent hang 這類問題、根因通常在工具版本、runtime 版本、OS、container image 的微妙交互、不在自己的 codebase。<strong>Reasoning 找不到的東西、社群 issue tracker 經常已經有人回報過</strong>。</p>
<p>「先 reason 再查」跟「先查再 reason」的取捨：</p>
<table>
  <thead>
      <tr>
          <th>問題範圍</th>
          <th>哪個優先</th>
          <th>為什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>自己 codebase 內的邏輯 bug</td>
          <td>reason</td>
          <td>自己最熟、reasoning 通常較快</td>
      </tr>
      <tr>
          <td>Upstream tool / runtime / OS / container 範圍</td>
          <td>查 issue</td>
          <td>自己沒上游知識、reasoning 容易卡在錯誤前置假設</td>
      </tr>
      <tr>
          <td>兩者交界（自己 config 觸發 upstream bug）</td>
          <td>並行</td>
          <td>先查找 known issue、同時 reason 自己 config</td>
      </tr>
  </tbody>
</table>
<p>Silent hang 預設屬於第二類、應該優先查 issue tracker。</p>
<hr>
<h2 id="6-整合訊號--行動-mapping">6. 整合：訊號 → 行動 mapping</h2>
<p>把本案例的經驗整理成可重用的訊號表：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>行動</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Step timeout 且最後一行是 happy log</td>
          <td>計算 timestamp 真空、確認是否 silent hang</td>
      </tr>
      <tr>
          <td>同方向修法 2 次都 fail</td>
          <td>停止、回到資料層、不再加 timeout / retry</td>
      </tr>
      <tr>
          <td>Silent hang 確認</td>
          <td>用症狀詞查 upstream issue tracker</td>
      </tr>
      <tr>
          <td>Issue 命中且有 workaround</td>
          <td>套 workaround、不要先 reason</td>
      </tr>
      <tr>
          <td>Issue 沒命中</td>
          <td>才回到 self-debug、加 verbose log（<code>DEBUG=</code> env）</td>
      </tr>
  </tbody>
</table>
<p>這張表的順序很重要：每一步的「該做的事」是下一步的「前置條件」。略過任一步、後面的判斷會建立在錯誤假設上。</p>
<hr>
<h2 id="適用範圍">適用範圍</h2>
<p>「Silent log 是 happy log 的 anti-signal」這個原則對所有非互動 process（CI、cron job、background worker、container init）都適用：</p>
<ul>
<li><strong>Docker build 卡住</strong>（特別是 RUN apt-get / npm install / pip install）— 同類 silent hang 模式</li>
<li><strong>CI cache restore 卡住</strong> — 大量小檔案的 cache 操作可能 silent hang</li>
<li><strong>Database migration 卡住</strong> — schema 變更 + 長 transaction 可能 silent hang</li>
<li><strong>任何 process 跑時間接近 timeout 上限被 cancel</strong> — 先檢查是否 silent hang 才提解法</li>
</ul>
<p>「WRAP R 在第二次 fail 時是 stop 訊號」這條原則不限 CI、適用所有「同方向修法重複 fail」的場景：debug、設定調校、效能優化。</p>
<hr>
<h2 id="參考資料">參考資料</h2>
<ul>
<li><a href="https://github.com/microsoft/playwright/issues/41000">microsoft/playwright issue #41000</a> — 本案例的 upstream issue（Playwright 1.57-1.59 在 Node 24.16.0 extract-zip hang）</li>
<li><a href="https://github.com/nodejs/node/issues/63487">nodejs/node issue #63487</a> — Node 24.16 extract-zip / yauzl regression 上游</li>
<li>同 blog 文章：<a href="/blog/skills/wrap-decision/" data-link-title="WRAP 決策框架 — 認知偏誤防護與決策品質" data-link-desc="WRAP 決策框架的 blog 好讀版：用錨點確認、資料充足度、選項擴增、現實檢驗、機會成本、行前預想與絆腳索防止自動駕駛式決策。">WRAP 決策框架的 R 階段操作</a> — Reality Test 詳細用法</li>
</ul>
]]></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>API 認證的三層信任邊界：使用者、系統、跨系統 Provisioning</title><link>https://tarrragon.github.io/blog/work-log/api-%E8%AA%8D%E8%AD%89%E7%9A%84%E4%B8%89%E5%B1%A4%E4%BF%A1%E4%BB%BB%E9%82%8A%E7%95%8C%E4%BD%BF%E7%94%A8%E8%80%85%E7%B3%BB%E7%B5%B1%E8%B7%A8%E7%B3%BB%E7%B5%B1-provisioning/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/api-%E8%AA%8D%E8%AD%89%E7%9A%84%E4%B8%89%E5%B1%A4%E4%BF%A1%E4%BB%BB%E9%82%8A%E7%95%8C%E4%BD%BF%E7%94%A8%E8%80%85%E7%B3%BB%E7%B5%B1%E8%B7%A8%E7%B3%BB%E7%B5%B1-provisioning/</guid><description>&lt;h2 id="api-認證為什麼要分層">API 認證為什麼要分層&lt;/h2>
&lt;p>&lt;strong>API 認證的核心是「身分維度的分離」&lt;/strong> — 一個 request 同時牽涉「人」「呼叫的系統」「另一個系統有沒有對應身分」三個獨立問題，每個問題的 secret 機制不同、洩漏後果不同、撤銷方式不同。混用一個機制回答全部問題，等於用同一把鑰匙開家、車、保險箱。&lt;/p>
&lt;p>看似一個 API request，其實同時要回答：&lt;/p>
&lt;ul>
&lt;li>發起這個 request 的「&lt;strong>人&lt;/strong>」是誰？（identity）&lt;/li>
&lt;li>把這個 request 傳過來的「&lt;strong>系統&lt;/strong>」是誰？（caller）&lt;/li>
&lt;li>這個人在「&lt;strong>另一個系統&lt;/strong>」有沒有對應身分？（cross-system mapping）&lt;/li>
&lt;/ul>
&lt;p>每個問題都需要不同的 secret 機制來回答。設計時先拆身分維度，再選 token、shared secret、mTLS 或 provisioning workflow，才有辦法讓洩漏範圍、撤銷粒度與排障路由各自清楚。&lt;/p>
&lt;p>這篇整理兩層信任邊界（Layer 1 使用者、Layer 2 系統）跟一個跨系統 workflow（Layer 3 Provisioning），以及它們各自對應的 secret 機制。&lt;strong>每層的實作細節都另有獨立文章深入&lt;/strong>、本文聚焦「為什麼要分」「各層解什麼問題」的心智模型。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>前提假設&lt;/strong>：以下所有機制都假設 transport 走 HTTPS / TLS。Token 與 secret 需要在加密通道內傳輸，否則中間人可直接取得 credential。HTTPS 是所有層共同依賴的 transport 前提。&lt;/p>
&lt;p>&lt;strong>本文 token 範圍&lt;/strong>：本文討論「opaque token」（隨機字串、server 端 lookup），不涵蓋 JWT（self-contained token、簽章驗證）。兩者安全模型不同，比較見 Layer 1 段落。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="layer-1使用者層bearer-token">Layer 1：使用者層（Bearer Token）&lt;/h2>
&lt;p>&lt;strong>使用者層負責把 request 綁到已登入的人類或帳號主體&lt;/strong>。它回答的問題是：「這個 request 是哪個使用者發的？」&lt;/p>
&lt;p>&lt;strong>Bearer Token 是 capability credential（持有即授權）、不是 identity credential（身分證明）&lt;/strong>。差別在於：身分證遺失可以掛失補辦、別人撿到也無法直接領錢；Bearer Token 一旦被取得、攻擊者就能即時用該使用者身分發 request、沒有第二道關卡。這個本質決定了 token 的儲存、傳輸、撤銷機制都必須以「持有即危險」為前提設計。&lt;/p>
&lt;p>「Bearer Token」是 RFC 6750 定義的 HTTP authentication scheme（&lt;code>Authorization: Bearer &amp;lt;token&amp;gt;&lt;/code>）、屬於通用概念 — GitHub PAT、Stripe API Key、OAuth access token、Laravel Sanctum 的 PAT、JWT 都是 Bearer Token 的不同實作。&lt;/p>
&lt;h3 id="opaque-token-vs-jwt兩種根本不同的設計">Opaque Token vs JWT：兩種根本不同的設計&lt;/h3>
&lt;p>「Bearer Token」是上位概念、實作上有兩條主線、安全模型完全不同：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>項目&lt;/th>
 &lt;th>Opaque Token（如 Sanctum）&lt;/th>
 &lt;th>JWT&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Token 本身&lt;/td>
 &lt;td>隨機字串、無內含資訊&lt;/td>
 &lt;td>簽章 payload、內嵌使用者 claim&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>驗證方式&lt;/td>
 &lt;td>server 查 DB lookup&lt;/td>
 &lt;td>驗簽章、不需 DB&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>載入使用者&lt;/td>
 &lt;td>從 DB row 撈&lt;/td>
 &lt;td>直接讀 claim&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>撤銷&lt;/td>
 &lt;td>刪 DB row、立即生效&lt;/td>
 &lt;td>困難、需 blacklist 或短 TTL&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>洩漏暴露範圍&lt;/td>
 &lt;td>該 row 立即停用&lt;/td>
 &lt;td>直到 expire 都有效&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>跨服務驗證&lt;/td>
 &lt;td>需要共用 DB 或驗證 endpoint&lt;/td>
 &lt;td>共享公鑰即可、stateless&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>兩者各有適合情境：opaque token 撤銷快、適合「使用者主動登出 / 帳號被盜要立即停權」；JWT 不需 DB lookup、適合「跨多個 microservice、想避免每次都查中央 DB」。下面 Layer 1 的內容&lt;strong>只聚焦 opaque token&lt;/strong> — JWT 的設計細節（簽章演算法選擇、&lt;code>alg: none&lt;/code> 攻擊、key rotation）是獨立議題、不在本篇範圍。&lt;/p>
&lt;h3 id="opaque-token-的格式設計">Opaque Token 的格式設計&lt;/h3>
&lt;p>Opaque token 是隨機字串、但實際 format 在不同產品有兩條主流分流：&lt;/p>
&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;strong>&lt;code>{PK}|{secret}&lt;/code>&lt;/strong>&lt;/td>
 &lt;td>&lt;code>1|abc123def456...&lt;/code>（Laravel Sanctum）&lt;/td>
 &lt;td>用 PK 收斂 DB 搜尋、把 timing 安全留給應用層&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>&lt;code>{prefix}_{secret}&lt;/code>&lt;/strong>&lt;/td>
 &lt;td>&lt;code>ghp_xxx&lt;/code>（GitHub）、&lt;code>sk_live_xxx&lt;/code>（Stripe）&lt;/td>
 &lt;td>用語意 prefix 支援自動洩漏掃描跟 token type 辨識&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>兩種設計&lt;strong>沒有絕對優劣&lt;/strong>、取決於 token 的傳播範圍：純內部使用、Sanctum 設計簡潔且足夠；對外開放、容易散落公開 repo、prefix 設計能讓 GitHub Secret Scanning / Stripe webhook 等工具自動偵測洩漏。&lt;/p></description><content:encoded><![CDATA[<h2 id="api-認證為什麼要分層">API 認證為什麼要分層</h2>
<p><strong>API 認證的核心是「身分維度的分離」</strong> — 一個 request 同時牽涉「人」「呼叫的系統」「另一個系統有沒有對應身分」三個獨立問題，每個問題的 secret 機制不同、洩漏後果不同、撤銷方式不同。混用一個機制回答全部問題，等於用同一把鑰匙開家、車、保險箱。</p>
<p>看似一個 API request，其實同時要回答：</p>
<ul>
<li>發起這個 request 的「<strong>人</strong>」是誰？（identity）</li>
<li>把這個 request 傳過來的「<strong>系統</strong>」是誰？（caller）</li>
<li>這個人在「<strong>另一個系統</strong>」有沒有對應身分？（cross-system mapping）</li>
</ul>
<p>每個問題都需要不同的 secret 機制來回答。設計時先拆身分維度，再選 token、shared secret、mTLS 或 provisioning workflow，才有辦法讓洩漏範圍、撤銷粒度與排障路由各自清楚。</p>
<p>這篇整理兩層信任邊界（Layer 1 使用者、Layer 2 系統）跟一個跨系統 workflow（Layer 3 Provisioning），以及它們各自對應的 secret 機制。<strong>每層的實作細節都另有獨立文章深入</strong>、本文聚焦「為什麼要分」「各層解什麼問題」的心智模型。</p>
<blockquote>
<p><strong>前提假設</strong>：以下所有機制都假設 transport 走 HTTPS / TLS。Token 與 secret 需要在加密通道內傳輸，否則中間人可直接取得 credential。HTTPS 是所有層共同依賴的 transport 前提。</p>
<p><strong>本文 token 範圍</strong>：本文討論「opaque token」（隨機字串、server 端 lookup），不涵蓋 JWT（self-contained token、簽章驗證）。兩者安全模型不同，比較見 Layer 1 段落。</p></blockquote>
<hr>
<h2 id="layer-1使用者層bearer-token">Layer 1：使用者層（Bearer Token）</h2>
<p><strong>使用者層負責把 request 綁到已登入的人類或帳號主體</strong>。它回答的問題是：「這個 request 是哪個使用者發的？」</p>
<p><strong>Bearer Token 是 capability credential（持有即授權）、不是 identity credential（身分證明）</strong>。差別在於：身分證遺失可以掛失補辦、別人撿到也無法直接領錢；Bearer Token 一旦被取得、攻擊者就能即時用該使用者身分發 request、沒有第二道關卡。這個本質決定了 token 的儲存、傳輸、撤銷機制都必須以「持有即危險」為前提設計。</p>
<p>「Bearer Token」是 RFC 6750 定義的 HTTP authentication scheme（<code>Authorization: Bearer &lt;token&gt;</code>）、屬於通用概念 — GitHub PAT、Stripe API Key、OAuth access token、Laravel Sanctum 的 PAT、JWT 都是 Bearer Token 的不同實作。</p>
<h3 id="opaque-token-vs-jwt兩種根本不同的設計">Opaque Token vs JWT：兩種根本不同的設計</h3>
<p>「Bearer Token」是上位概念、實作上有兩條主線、安全模型完全不同：</p>
<table>
  <thead>
      <tr>
          <th>項目</th>
          <th>Opaque Token（如 Sanctum）</th>
          <th>JWT</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Token 本身</td>
          <td>隨機字串、無內含資訊</td>
          <td>簽章 payload、內嵌使用者 claim</td>
      </tr>
      <tr>
          <td>驗證方式</td>
          <td>server 查 DB lookup</td>
          <td>驗簽章、不需 DB</td>
      </tr>
      <tr>
          <td>載入使用者</td>
          <td>從 DB row 撈</td>
          <td>直接讀 claim</td>
      </tr>
      <tr>
          <td>撤銷</td>
          <td>刪 DB row、立即生效</td>
          <td>困難、需 blacklist 或短 TTL</td>
      </tr>
      <tr>
          <td>洩漏暴露範圍</td>
          <td>該 row 立即停用</td>
          <td>直到 expire 都有效</td>
      </tr>
      <tr>
          <td>跨服務驗證</td>
          <td>需要共用 DB 或驗證 endpoint</td>
          <td>共享公鑰即可、stateless</td>
      </tr>
  </tbody>
</table>
<p>兩者各有適合情境：opaque token 撤銷快、適合「使用者主動登出 / 帳號被盜要立即停權」；JWT 不需 DB lookup、適合「跨多個 microservice、想避免每次都查中央 DB」。下面 Layer 1 的內容<strong>只聚焦 opaque token</strong> — JWT 的設計細節（簽章演算法選擇、<code>alg: none</code> 攻擊、key rotation）是獨立議題、不在本篇範圍。</p>
<h3 id="opaque-token-的格式設計">Opaque Token 的格式設計</h3>
<p>Opaque token 是隨機字串、但實際 format 在不同產品有兩條主流分流：</p>
<table>
  <thead>
      <tr>
          <th>設計</th>
          <th>範例</th>
          <th>解的問題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong><code>{PK}|{secret}</code></strong></td>
          <td><code>1|abc123def456...</code>（Laravel Sanctum）</td>
          <td>用 PK 收斂 DB 搜尋、把 timing 安全留給應用層</td>
      </tr>
      <tr>
          <td><strong><code>{prefix}_{secret}</code></strong></td>
          <td><code>ghp_xxx</code>（GitHub）、<code>sk_live_xxx</code>（Stripe）</td>
          <td>用語意 prefix 支援自動洩漏掃描跟 token type 辨識</td>
      </tr>
  </tbody>
</table>
<p>兩種設計<strong>沒有絕對優劣</strong>、取決於 token 的傳播範圍：純內部使用、Sanctum 設計簡潔且足夠；對外開放、容易散落公開 repo、prefix 設計能讓 GitHub Secret Scanning / Stripe webhook 等工具自動偵測洩漏。</p>
<p>Sanctum 的 <code>{PK}|{secret}</code> 設計常被誤解為「業界標準」 — 其實是 Laravel 生態的特定選擇。具體機制、跟 GitHub / Stripe 設計的比較、各語言實作範例見 <a href="/blog/work-log/laravel-sanctum-%E7%9A%84-bearer-token-%E8%A8%AD%E8%A8%88%E5%89%96%E6%9E%90pksecret-%E7%82%BA%E4%BB%80%E9%BA%BC%E9%80%99%E6%A8%A3%E8%A8%AD%E8%A8%88/" data-link-title="Laravel Sanctum 的 Bearer Token 設計剖析：{PK}|{secret} 為什麼這樣設計" data-link-desc="Laravel Sanctum `{PK}|{secret}` 格式的設計理由、hash 儲存取捨、constant-time 比對位置，以及跟 GitHub PAT、Stripe API Key 的差異。">Laravel Sanctum 的 Bearer Token 設計剖析</a>。</p>
<h3 id="token-在-db-的儲存原則簡述">Token 在 DB 的儲存原則（簡述）</h3>
<p>無論用哪種 format、有三條跨設計通用的儲存原則：</p>
<ol>
<li><strong>DB 只存 hash、不存原文</strong> — token 是高熵隨機字串、SHA-256 即可、不需 bcrypt</li>
<li><strong>比對必須是 constant-time</strong> — 用各語言提供的 <code>hash_equals</code> / <code>compare_digest</code> / <code>ConstantTimeCompare</code>、不用 <code>==</code></li>
<li><strong>Lookup 用穩定字段、機密比對放應用層</strong> — DB 引擎不保證 constant-time 比對、把機密比對搬離 DB</li>
</ol>
<p>這三條的詳細推導、各語言 constant-time 函式對照、非 Laravel 環境的實作範例見 <a href="/blog/work-log/laravel-sanctum-%E7%9A%84-bearer-token-%E8%A8%AD%E8%A8%88%E5%89%96%E6%9E%90pksecret-%E7%82%BA%E4%BB%80%E9%BA%BC%E9%80%99%E6%A8%A3%E8%A8%AD%E8%A8%88/" data-link-title="Laravel Sanctum 的 Bearer Token 設計剖析：{PK}|{secret} 為什麼這樣設計" data-link-desc="Laravel Sanctum `{PK}|{secret}` 格式的設計理由、hash 儲存取捨、constant-time 比對位置，以及跟 GitHub PAT、Stripe API Key 的差異。">Laravel Sanctum 的 Bearer Token 設計剖析</a>。</p>
<h3 id="token-的生命週期">Token 的生命週期</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">   Login                  Use                  Expire/Revoke
</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">issued → DB 存 hash  →  Bearer 驗證    →   row deleted
</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">                       set request.user</span></span></code></pre></div><ul>
<li><strong><code>expires_at</code></strong>（例如 7 天、30 天）— 限制洩漏 token 的暴露窗</li>
<li><strong><code>abilities</code> / <code>scopes</code></strong> — 限縮權限粒度（「只能讀」「只能存取某 resource」），降低單一 token 洩漏的破壞範圍</li>
<li><strong>登出即刪 row</strong> — opaque token 的撤銷成本低，這是它相對 JWT 的關鍵優勢</li>
<li><strong>rate limit / brute force 防護</strong> — token 是隨機字串、攻擊者可暴力試。應用層要對「token 驗證失敗」加 rate limit、避免被掃出有效 token</li>
<li><strong>長期 access 用 refresh token pattern</strong> — access token 短 TTL（小時級）、refresh token 長 TTL（月級）。Access token 洩漏只影響短窗、refresh token 撤銷後新的 access token 也無法發放</li>
</ul>
<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">[ 使用者 ] ─────────▶ [ API server ]
</span></span><span class="line"><span class="ln">2</span><span class="cl">              token        ↑
</span></span><span class="line"><span class="ln">3</span><span class="cl">                           知道「你是誰」
</span></span><span class="line"><span class="ln">4</span><span class="cl">                           但不會自動跨到其他系統</span></span></code></pre></div><p>Bearer Token 是 capability credential — 任何持有它的 client 都能以該使用者身分發 request。這也是為什麼 token 一旦離開原本的 API server，就會引發下一層問題：B 系統收到 A 系統的 token、根本不知道該怎麼驗證、也不該驗證。</p>
<hr>
<h2 id="layer-2系統層system-to-system-credential">Layer 2：系統層（System-to-system credential）</h2>
<p><strong>系統層負責驗證呼叫方服務本身的身分</strong>。它回答的問題是：「這個 request 是哪個系統發的？」</p>
<p>當系統 A 需要呼叫系統 B 的 API 時，Layer 1 的使用者 token 只代表「使用者」的身分。系統 B 仍需要獨立驗證「這個 request 來自合法的合作系統 A」，這個判斷要由系統層 credential 承擔。</p>
<h3 id="為什麼分得這麼清楚">為什麼分得這麼清楚</h3>
<p>想像系統 B 收到一個請求：</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">B 收到請求「給我會員 X 的資料」
</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">B 自問：這請求來自...
</span></span><span class="line"><span class="ln">4</span><span class="cl">   ├─ 我的合作夥伴系統 A？  → 可進入授權判斷
</span></span><span class="line"><span class="ln">5</span><span class="cl">   ├─ 未註冊的外部 caller？ → 回 401 / 403
</span></span><span class="line"><span class="ln">6</span><span class="cl">   └─ 偽裝成 A 的 caller？  → 回 401 / 403 並記錄告警</span></span></code></pre></div><p>純粹靠 Layer 1 的使用者 token 只能證明「這位 user 的身分」，無法證明「系統 A 的身分」。這個分工讓帳號被盜與合作系統被冒用分別走不同監控與撤銷流程。</p>
<h3 id="shared-secret與api-key的關係">「Shared Secret」與「API Key」的關係</h3>
<p>兩者常被混用、實際上是同一個機制（一邊發、一邊存的對稱字串）的不同部署方式：</p>
<table>
  <thead>
      <tr>
          <th>區分點</th>
          <th>Shared Secret</th>
          <th>API Key</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Caller identity</td>
          <td>兩邊都用同一把、沒有 caller 區分</td>
          <td>每個 client 一把、server 有 key → identity 對照表</td>
      </tr>
      <tr>
          <td>撤銷粒度</td>
          <td>換一邊、全部斷</td>
          <td>撤一把 key、只影響該 client</td>
      </tr>
      <tr>
          <td>典型部署</td>
          <td>內部固定夥伴系統</td>
          <td>對外開放 API、多 tenant</td>
      </tr>
  </tbody>
</table>
<p>下面討論的「Shared Secret」泛指這個 pattern；要做 per-client identity 與 revoke 時、改成 API Key 結構即可。</p>
<h3 id="常見方案的取捨">常見方案的取捨</h3>
<table>
  <thead>
      <tr>
          <th>方案</th>
          <th>機制</th>
          <th>撤銷粒度</th>
          <th>適合情境</th>
          <th>主要代價</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Shared Secret</strong></td>
          <td>兩邊放同一把字串</td>
          <td>全部 caller</td>
          <td>內部單一夥伴、低變更頻率</td>
          <td>多 client 時撤銷會牽動所有人</td>
      </tr>
      <tr>
          <td><strong>API Key</strong></td>
          <td>每個 client 一把、server 有對照表</td>
          <td>per-client</td>
          <td>對外開放、多 tenant</td>
          <td>server 需維護 key → identity mapping</td>
      </tr>
      <tr>
          <td><strong>HMAC 簽章</strong></td>
          <td>client 用 secret 簽 request body</td>
          <td>per-key</td>
          <td>secret 不想經過網路、需防 replay / 改寫</td>
          <td>兩邊都要實作簽章邏輯、debug 較難</td>
      </tr>
      <tr>
          <td><strong>mTLS</strong></td>
          <td>雙向 TLS 憑證</td>
          <td>撤憑證</td>
          <td>金融、醫療、零信任網路</td>
          <td>憑證生命週期管理複雜、CA / CRL 基礎建設成本</td>
      </tr>
      <tr>
          <td><strong>OAuth Client Credentials</strong></td>
          <td>client_id + secret 換短期 access token</td>
          <td>撤 long-lived secret、短 token 自然 expire</td>
          <td>跨組織、權限粒度需要、需配合 scope</td>
          <td>多一層 token endpoint、實作成本較高</td>
      </tr>
  </tbody>
</table>
<p>選擇預設值的判斷：純內部固定夥伴可從 Shared Secret 起步；對外或多 client 直接上 API Key；公網跨組織 + 需要短期撤銷上 OAuth Client Credentials；合規或高威脅環境用 mTLS。</p>
<p>mTLS 的 CA 階層、憑證生命週期、撤銷機制、nginx / service mesh 整合見 <a href="/blog/work-log/mtls-%E5%AF%A6%E9%9A%9B%E6%80%8E%E9%BA%BC%E8%A8%AD%E5%AE%9A%E8%88%87%E9%81%8B%E7%B6%ADca-%E9%9A%8E%E5%B1%A4%E6%86%91%E8%AD%89%E7%94%9F%E5%91%BD%E9%80%B1%E6%9C%9F%E6%92%A4%E9%8A%B7%E6%A9%9F%E5%88%B6/" data-link-title="mTLS 實際怎麼設定與運維：CA 階層、憑證生命週期、撤銷機制" data-link-desc="mTLS 落地的運維決策（CA 階層、憑證儲存、撤銷機制）與基礎設施整合（nginx / envoy / service mesh），以及跟 API Key / OAuth 的成本與安全取捨。">mTLS 實際怎麼設定與運維</a>。</p>
<h3 id="shared-secret-的隱形成本">Shared Secret 的隱形成本</h3>
<p>Shared Secret 部署簡單、但維運上有幾個固定痛點：</p>
<ul>
<li><strong>無法 per-caller 撤銷</strong> — 一旦洩漏，所有用這把 secret 的 client 都得換</li>
<li><strong>輪替需要兩邊同步</strong> — 任何一邊忘了更新就斷線、需要「雙密過渡期」讓兩邊有時間切換。具體實作見 <a href="/blog/work-log/shared-secret-%E5%AE%89%E5%85%A8%E8%BC%AA%E6%9B%BF%E8%A8%AD%E8%A8%88%E9%9B%99%E5%AF%86%E9%81%8E%E6%B8%A1%E6%9C%9F%E8%87%AA%E5%8B%95%E5%8C%96%E8%88%87%E7%B7%8A%E6%80%A5%E6%B5%81%E7%A8%8B/" data-link-title="Shared Secret 安全輪替設計：雙密過渡期、自動化與緊急流程" data-link-desc="系統間 Shared Secret 輪替的核心機制：dual-secret rollover、自動化工具比較（AWS Secrets Manager / Vault / GCP）、緊急 rotation 流程與多 client 環境的失敗模式。">Shared Secret 安全輪替設計</a></li>
<li><strong>常被放進 query param</strong> — 為了簡便、會留在 nginx access log、CDN log、瀏覽器 history 裡。應放在 request header（例如 <code>X-System-Secret: xxx</code>）或走 HMAC / OAuth</li>
</ul>
<h3 id="信任邊界-1">信任邊界</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">[ 系統 A ] ═════════▶ [ 系統 B ]
</span></span><span class="line"><span class="ln">2</span><span class="cl">       shared secret
</span></span><span class="line"><span class="ln">3</span><span class="cl">       (server-to-server, server-only credential)</span></span></code></pre></div><p><strong>Layer 2 secret 的安全邊界是 server-side runtime</strong>。一旦進入瀏覽器或行動 app，攻擊者就能透過反編譯、JS source map、devtools network panel 等管道取得；取得後即可假冒系統 A 呼叫系統 B。Mobile app 的反編譯工具（jadx、Hopper、Ghidra 等）讓這個攻擊成本極低，obfuscation 只能增加時間成本。</p>
<p>如果 client 端需要呼叫 B，安全路由是讓 client 先呼叫 A，由 A 在 server 端用 Layer 2 secret 呼叫 B（A 當 proxy / BFF）；另一條路是用 OAuth 把 short-lived token 發給 client，long-lived secret 留在 server。</p>
<hr>
<h2 id="layer-3跨系統-provisioning身分對應-workflow不是新的信任邊界">Layer 3：跨系統 Provisioning（身分對應 workflow、不是新的信任邊界）</h2>
<p><strong>回答的問題</strong>：「系統 A 的使用者 X、在系統 B 對應到哪個身分？」</p>
<p><strong>Layer 3 跟 Layer 1 / 2 在概念上不對等</strong> — Layer 1 / 2 是「驗證某個身分」的信任邊界、各自需要獨立的 secret 機制；Layer 3 不引入新的 secret、是「<strong>讓兩個系統的使用者身分對應上</strong>」的 workflow。它建立在 Layer 1（A 已驗證使用者）跟 Layer 2（A 已被授權呼叫 B）之上、不取代任何一層。</p>
<p>之所以仍放進「層」的編號系統、是因為實際 API 串接時、開發者會把它跟前兩層一起遇到、必須在同一個心智模型裡處理。但設計時要清楚意識到：<strong>Layer 3 的失敗模式是「身分對不上」、不是「身分被偽造」</strong>、跟 Layer 1 / 2 的安全失敗模式不同。</p>
<h3 id="為什麼需要-provisioning">為什麼需要 provisioning</h3>
<p>當 A 跟 B 是兩個獨立 service 時，「<strong>A 的使用者 X</strong>」跟「<strong>B 的使用者 X</strong>」未必是同一筆資料。可能：</p>
<ul>
<li>B 從來沒見過 X 這個人</li>
<li>B 有自己對 X 的 record、但跟 A 不同 schema</li>
<li>B 看過 X、但兩邊的 user_id 還沒對應上</li>
</ul>
<p>需要一個機制把兩邊綁定 — 這個動作叫 <strong>provisioning</strong>。</p>
<h3 id="eager-vs-lazy-兩種策略">Eager vs Lazy 兩種策略</h3>
<p>Provisioning 策略的判斷核心是「何時承擔跨系統建檔成本」。Eager 把成本前移到註冊流程，Lazy 把成本延後到第一次使用；兩者差異不只是效能，而是資料膨脹、首用體驗與文件契約的取捨。</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">EAGER (註冊時就跨系統建檔)
</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">使用者註冊系統 A
</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">   A 新增會員 row
</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">   A ──同步呼叫──▶ B.createUser()  ← 即使他可能永遠不用 B
</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">   兩邊都有資料、可以立刻呼叫 B 的 API</span></span></code></pre></div><p>Eager 適合大多數使用者都會用到 B 功能、且首用延遲成本高的服務。主要風險是 B 會累積大量低活躍 user，schema migration、備份與隱私刪除流程都會被放大。</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">LAZY (第一次需要時才建)
</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">使用者註冊系統 A
</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">   A 新增會員 row              ← 只有 A 這邊
</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">   ...日後可能很久才用到 B...
</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">使用者第一次需要 B 的功能
</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">   呼叫 A 的「provision」endpoint
</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">   A ──呼叫──▶ B.findOrCreateUser()  ← 這時候才建
</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">   之後就跟 eager 一樣</span></span></code></pre></div><p>Lazy 適合只有一部分使用者會用到 B 功能、且第一次使用可以接受一次 provisioning 延遲的服務。主要風險是「第一次使用」這個時機需要被寫進文件、SDK 或錯誤碼，否則接手者會把 B 的 404 誤判成 request 格式或權限問題。</p>
<h3 id="lazy-的隱性-api-依賴順序">Lazy 的「隱性 API 依賴順序」</h3>
<p>Lazy provisioning 的最大成本是<strong>隱性依賴順序造成的認知負擔</strong>：</p>
<ul>
<li>文件若沒有寫清楚「呼叫 B 前先呼叫 A 的 provision endpoint」，接手者會在「B 回 404 找不到 user」的訊號上花大量時間排查</li>
<li>用 SDK 包裝可以把 provision 自動處理、對外只暴露單一 API</li>
<li>不用 SDK 時，文件需要在快速上手與錯誤碼段落顯眼註明這個依賴順序</li>
</ul>
<p>折衷做法：B 的 API 在第一次發現 user 不存在時、<strong>主動回一個 <code>PROVISIONING_REQUIRED</code> 錯誤碼</strong>、client 看到就知道要去呼叫 A 的 provision endpoint。比起靜默 500 或單純 404 更能引導 client 走到正確流程。</p>
<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">[ 使用者 ] ──Layer 1──▶ [ 系統 A ] ══Layer 2══▶ [ 系統 B ]
</span></span><span class="line"><span class="ln">2</span><span class="cl">                            │  Layer 3 workflow：
</span></span><span class="line"><span class="ln">3</span><span class="cl">                            └─ 觸發後在 B 建立對應身分</span></span></code></pre></div><p>Layer 3 不引入新的 secret、是「<strong>建立兩邊身分關聯</strong>」的 lifecycle 動作。它依賴 Layer 1（確認使用者身分）跟 Layer 2（A 被授權對 B 發指令）。沒有 Layer 1 / 2 的話、provisioning 自己無法獨立成立。</p>
<hr>
<h2 id="三層怎麼組合">三層怎麼組合</h2>
<p>把三層擺在一起的典型 request 流程：</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">        ┌─────────────┐                       ┌──────────────┐
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">        │  使用者      │                       │   系統 A     │
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        │  (Browser/  │ ──── Layer 1 ──────▶ │              │
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        │   App)      │      Bearer token     │              │
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        └─────────────┘                       └──────┬───────┘
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">                                                     │
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">                                            Layer 3  │ Provision
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">                                                     │ (第一次)
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">                                                     ▼
</span></span><span class="line"><span class="ln">10</span><span class="cl">                                              ┌──────────────┐
</span></span><span class="line"><span class="ln">11</span><span class="cl">                                              │   系統 B     │
</span></span><span class="line"><span class="ln">12</span><span class="cl">                                              └──────────────┘
</span></span><span class="line"><span class="ln">13</span><span class="cl">                                                     ▲
</span></span><span class="line"><span class="ln">14</span><span class="cl">                                                     │
</span></span><span class="line"><span class="ln">15</span><span class="cl">                                            Layer 2  │ Shared secret
</span></span><span class="line"><span class="ln">16</span><span class="cl">                                                     │ (server-to-server)</span></span></code></pre></div><p>每一條線都是一層信任邊界，各自需要不同 secret 機制保護。</p>
<hr>
<h2 id="設計時最常見的三個失效模式">設計時最常見的三個失效模式</h2>
<h3 id="失效模式一讓使用者-token-也能驗-layer-2">失效模式一：讓使用者 token 也能驗 Layer 2</h3>
<p><strong>責任分工</strong>：「使用者身分」跟「呼叫系統身分」是兩個獨立維度、各自需要獨立 credential。系統 B 對「來自 A」的信任應綁定在系統層 credential，而不是任何單一使用者帳號上。</p>
<p><strong>常見誤用</strong>：B 接受「只要 request 帶有任一合法使用者 token 就放行」。</p>
<p><strong>風險判讀</strong>：這會把系統信任降階為使用者信任。任一帳號被盜（釣魚、密碼洩漏、token 外流）時，攻擊者就能用該使用者身分對 B 發 request，執行 B 開放給 A 的系統操作。</p>
<p><strong>操作路由</strong>：使用者層用 Layer 1 token，系統層用 Layer 2 credential，兩層都通過才放行。</p>
<h3 id="失效模式二把-layer-2-secret-放進-client">失效模式二：把 Layer 2 secret 放進 client</h3>
<p><strong>責任分工</strong>：Layer 2 secret 是「server 代表系統 A 對外的證明」，應留在 server 端的受信任執行環境。</p>
<p><strong>常見誤用</strong>：把 shared secret 寫進前端 JS、行動 app 編譯時、甚至 git public repo。</p>
<p><strong>風險判讀</strong>：client 環境（瀏覽器、mobile app）不在受控範圍。JS source 可在 devtools 直接看，mobile binary 可被反編譯出字串。Obfuscation 提高的是時間成本，沒有改變 secret 已散佈到不受信任環境的事實。</p>
<p><strong>操作路由</strong>：client 需要 B 的功能時，走「client → A → B」，由 A 在 server 端用 Layer 2 secret 呼叫 B；或用 OAuth 把 short-lived token 發給 client，long-lived secret 留在 server。</p>
<h3 id="失效模式三layer-3-依賴順序沒文件化">失效模式三：Layer 3 依賴順序沒文件化</h3>
<p><strong>責任分工</strong>：跨系統依賴順序是 API 契約的一部分，屬 publisher 的責任，需要在文件、SDK 或錯誤訊號中顯式表達。</p>
<p><strong>常見誤用</strong>：「呼叫 B 之前要先呼叫 A 的某個 endpoint」這個前置條件只存在於原始設計者的記憶中、文件沒寫、SDK 沒包、B 失敗時也只回 generic error。</p>
<p><strong>風險判讀</strong>：接手者看到「呼叫 B 失敗」時，會優先檢查 B 的文件、request 格式與 network 層。若真正根因是尚未呼叫 A 的 provision endpoint，偵錯路徑會被導到錯誤層級。</p>
<p><strong>操作路由</strong>（任選其一、優先序由上而下）：</p>
<ol>
<li>SDK 包裝、自動處理 provision、對外只暴露單一 API</li>
<li>B 主動回 <code>PROVISIONING_REQUIRED</code> error code、引導 client 補上前置呼叫</li>
<li>文件在「快速上手」段顯眼處註明依賴順序</li>
</ol>
<hr>
<h2 id="何時可以簡化三層">何時可以簡化三層</h2>
<p>三層框架的設計重點是「跨系統身分與 credential 分工」。當某一層回答的問題在架構裡不存在，設計可以縮小到實際存在的身分問題。</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>簡化方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>單體 application（沒有跨系統呼叫）</td>
          <td>只需 Layer 1。沒有 system-to-system 互動、Layer 2 / 3 不存在</td>
      </tr>
      <tr>
          <td>內網微服務、共用 identity provider</td>
          <td>Layer 1 透過 service mesh 或共用 token 傳遞、Layer 2 可用 service mesh 內建 mTLS 取代手動 secret 管理</td>
      </tr>
      <tr>
          <td>後端 cron / batch job 之間互呼</td>
          <td>只需 Layer 2（system-to-system credential）、沒有使用者觸發、Layer 1 不適用</td>
      </tr>
      <tr>
          <td>兩個系統共用同一份 user DB</td>
          <td>可省略 Layer 3（身分天然對應），但 Layer 1 / 2 仍各自獨立</td>
      </tr>
  </tbody>
</table>
<p>簡化的判準是「<strong>該層回答的問題是否真實存在於這個架構</strong>」。單體 application 沒有跨系統呼叫時，Layer 2 的 caller 驗證可以省略；兩個系統共用同一份 user DB 時，Layer 3 的身分對應 workflow 可以省略。</p>
<p>簡化不等於降低基礎安全前提。HTTPS / TLS 與 token 儲存原則（hash + constant-time）是任何 Layer 1 的最低要求，跟「層」的數量無關。</p>
<hr>
<h2 id="收尾">收尾</h2>
<p>兩層信任邊界 + 一個身分對應 workflow：</p>
<ul>
<li><strong>Layer 1（使用者）</strong>：解決「你是誰」 — 用 Bearer Token、注意 capability credential 的暴露成本</li>
<li><strong>Layer 2（系統）</strong>：解決「哪個系統呼叫的」 — 用 Shared Secret / API Key / OAuth / mTLS、secret 不離 server</li>
<li><strong>Layer 3（Provisioning workflow）</strong>：解決「兩邊身分怎麼對上」 — 不是新的 secret、是 lifecycle 動作</li>
</ul>
<p>設計後端 API 時，先把這三個問題分開，secret 機制的選擇會變清楚。若排障訊號是「這個 token 在那邊不能用」，下一步是先判斷它卡在使用者層、系統層，還是 provisioning workflow。</p>
<h3 id="各層的深入文章">各層的深入文章</h3>
<p>本文聚焦「為什麼要分層」的心智模型、各層的具體實作細節都另有獨立文章：</p>
<ul>
<li><strong>Layer 1（使用者）</strong> → <a href="/blog/work-log/laravel-sanctum-%E7%9A%84-bearer-token-%E8%A8%AD%E8%A8%88%E5%89%96%E6%9E%90pksecret-%E7%82%BA%E4%BB%80%E9%BA%BC%E9%80%99%E6%A8%A3%E8%A8%AD%E8%A8%88/" data-link-title="Laravel Sanctum 的 Bearer Token 設計剖析：{PK}|{secret} 為什麼這樣設計" data-link-desc="Laravel Sanctum `{PK}|{secret}` 格式的設計理由、hash 儲存取捨、constant-time 比對位置，以及跟 GitHub PAT、Stripe API Key 的差異。">Laravel Sanctum 的 Bearer Token 設計剖析</a>：<code>{PK}|{secret}</code> format 為什麼這樣設計、DB 儲存三原則、各語言 constant-time 函式對照、跟 GitHub / Stripe 的設計比較</li>
<li><strong>Layer 2（系統）→ Shared Secret 維運</strong> → <a href="/blog/work-log/shared-secret-%E5%AE%89%E5%85%A8%E8%BC%AA%E6%9B%BF%E8%A8%AD%E8%A8%88%E9%9B%99%E5%AF%86%E9%81%8E%E6%B8%A1%E6%9C%9F%E8%87%AA%E5%8B%95%E5%8C%96%E8%88%87%E7%B7%8A%E6%80%A5%E6%B5%81%E7%A8%8B/" data-link-title="Shared Secret 安全輪替設計：雙密過渡期、自動化與緊急流程" data-link-desc="系統間 Shared Secret 輪替的核心機制：dual-secret rollover、自動化工具比較（AWS Secrets Manager / Vault / GCP）、緊急 rotation 流程與多 client 環境的失敗模式。">Shared Secret 安全輪替設計</a>：雙密過渡期、自動化 rotation 工具（AWS Secrets Manager / Vault / GCP）、緊急 vs 定期流程、多 client 同步難題</li>
<li><strong>Layer 2（系統）→ mTLS 部署</strong> → <a href="/blog/work-log/mtls-%E5%AF%A6%E9%9A%9B%E6%80%8E%E9%BA%BC%E8%A8%AD%E5%AE%9A%E8%88%87%E9%81%8B%E7%B6%ADca-%E9%9A%8E%E5%B1%A4%E6%86%91%E8%AD%89%E7%94%9F%E5%91%BD%E9%80%B1%E6%9C%9F%E6%92%A4%E9%8A%B7%E6%A9%9F%E5%88%B6/" data-link-title="mTLS 實際怎麼設定與運維：CA 階層、憑證生命週期、撤銷機制" data-link-desc="mTLS 落地的運維決策（CA 階層、憑證儲存、撤銷機制）與基礎設施整合（nginx / envoy / service mesh），以及跟 API Key / OAuth 的成本與安全取捨。">mTLS 實際怎麼設定與運維</a>：CA 階層、憑證生命週期、撤銷機制（CRL / OCSP / short-lived）、nginx / Envoy / service mesh 整合</li>
</ul>
<h3 id="沒展開的延伸議題">沒展開的延伸議題</h3>
<p>JWT 的簽章演算法選擇、<code>alg: none</code> 攻擊、token rotation 的具體實作、零信任網路下的 service-to-service 認證、OAuth flow 的完整 lifecycle、SSO（SAML / OIDC）跟本文三層的對應關係。每個都值得獨立成篇、本文聚焦在「先把層數想清楚」這個前置問題。</p>
]]></content:encoded></item><item><title>Laravel Sanctum 的 Bearer Token 設計剖析：{PK}|{secret} 為什麼這樣設計</title><link>https://tarrragon.github.io/blog/work-log/laravel-sanctum-%E7%9A%84-bearer-token-%E8%A8%AD%E8%A8%88%E5%89%96%E6%9E%90pksecret-%E7%82%BA%E4%BB%80%E9%BA%BC%E9%80%99%E6%A8%A3%E8%A8%AD%E8%A8%88/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/laravel-sanctum-%E7%9A%84-bearer-token-%E8%A8%AD%E8%A8%88%E5%89%96%E6%9E%90pksecret-%E7%82%BA%E4%BB%80%E9%BA%BC%E9%80%99%E6%A8%A3%E8%A8%AD%E8%A8%88/</guid><description>&lt;h2 id="sanctum-pat-這篇要解決什麼">Sanctum PAT 這篇要解決什麼&lt;/h2>
&lt;p>Sanctum PAT 的核心設計是把「找 row」與「比對 secret」拆成兩個責任。Laravel Sanctum 的 Personal Access Token（簡稱 PAT）長這樣：&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">1|abc123def456ghi789jkl012mno345pqr678stu
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">↑ ↑
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">DB 主鍵 真正的祕密&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>豎線前的數字是 &lt;code>personal_access_tokens&lt;/code> 資料表的 primary key、豎線後是高熵隨機字串。這個設計在 Laravel 生態裡很常見、但常被誤解為「業界標準 token 格式」 — 實際上是 Sanctum 特定的設計選擇、跟 GitHub PAT（&lt;code>ghp_...&lt;/code>）、Stripe API Key（&lt;code>sk_live_...&lt;/code>）的設計取捨完全不同。&lt;/p>
&lt;p>本文拆解 Sanctum PAT 三個關鍵設計決策：&lt;/p>
&lt;ol>
&lt;li>為什麼把 PK 公開放進 token&lt;/li>
&lt;li>DB 為什麼只存 hash 不存原文&lt;/li>
&lt;li>constant-time 比對為什麼放在應用層、不放在 DB&lt;/li>
&lt;/ol>
&lt;p>讀完後，你可以用 token 的傳播範圍、撤銷需求與洩漏偵測需求，判斷自己的 application 適合 Sanctum 風格還是其他 token format，並把 hash 儲存與 constant-time 比對原則套用到非 Laravel 環境。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>本文位置&lt;/strong>：本文是 &lt;a href="https://tarrragon.github.io/blog/work-log/api-%E8%AA%8D%E8%AD%89%E7%9A%84%E4%B8%89%E5%B1%A4%E4%BF%A1%E4%BB%BB%E9%82%8A%E7%95%8C%E4%BD%BF%E7%94%A8%E8%80%85%E7%B3%BB%E7%B5%B1%E8%B7%A8%E7%B3%BB%E7%B5%B1-provisioning/" data-link-title="API 認證的三層信任邊界：使用者、系統、跨系統 Provisioning" data-link-desc="API 認證的信任邊界分層（Bearer Token / Shared Secret / Provisioning）：各層的洩漏後果與撤銷方式，以及混用造成的設計失效模式。">API 認證的三層信任邊界&lt;/a> Layer 1 的深入篇。主文聚焦「為什麼要分層」的心智模型、本文聚焦「Sanctum 這個特定實作怎麼設計、為什麼」。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="sanctum-在-laravel-認證生態的位置">Sanctum 在 Laravel 認證生態的位置&lt;/h2>
&lt;p>Laravel 官方提供三套認證套件、各自解的問題不同：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>套件&lt;/th>
 &lt;th>解的問題&lt;/th>
 &lt;th>Token 機制&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;strong>Laravel Breeze&lt;/strong>&lt;/td>
 &lt;td>server-rendered 應用的登入註冊 starter&lt;/td>
 &lt;td>session cookie&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Laravel Sanctum&lt;/strong>&lt;/td>
 &lt;td>SPA / mobile app / 簡單 API token 認證&lt;/td>
 &lt;td>session cookie + PAT（&lt;code>{PK}|{secret}&lt;/code>）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Laravel Passport&lt;/strong>&lt;/td>
 &lt;td>完整 OAuth 2.0 server 實作&lt;/td>
 &lt;td>JWT-based access token&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Sanctum 的設計目標是「&lt;strong>比 Passport 簡單、比手刻 token 嚴謹&lt;/strong>」 — 不引入 OAuth 的完整 flow，但解決 token issue、storage、revoke 的常見坑。&lt;code>{PK}|{secret}&lt;/code> 是這個設計目標下的具體 trade-off。&lt;/p>
&lt;hr>
&lt;h2 id="設計決策一為什麼把-pk-公開放進-token">設計決策一：為什麼把 PK 公開放進 token&lt;/h2>
&lt;h3 id="驗證-token-的兩個責任">驗證 token 的兩個責任&lt;/h3>
&lt;p>Server 收到 client 傳來的 token、要做兩件事：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>找到&lt;/strong> DB 裡對應的 row（這個 token 是哪個 user 的）&lt;/li>
&lt;li>&lt;strong>比對&lt;/strong> 確認 token 沒被偽造&lt;/li>
&lt;/ol>
&lt;p>如果 token 只是純隨機字串（沒有 PK 前綴），validation 的 SQL 常會被設計成：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">personal_access_tokens&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">token&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">?&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這要求 &lt;code>token&lt;/code> 欄位有 index，且 server 要讓 DB 同時負責 lookup 與 secret 比對。效能通常不是瓶頸，真正的設計問題是 secret 比對落在應用層控制範圍之外。&lt;/p>
&lt;h3 id="db-比對的-timing-不可控">DB 比對的 timing 不可控&lt;/h3>
&lt;p>DB 查詢適合處理索引搜尋，不適合承擔機密字串的 timing-safe 比對。當 &lt;code>WHERE token = ?&lt;/code> 在 DB 執行時，執行時間可能洩漏：&lt;/p></description><content:encoded><![CDATA[<h2 id="sanctum-pat-這篇要解決什麼">Sanctum PAT 這篇要解決什麼</h2>
<p>Sanctum PAT 的核心設計是把「找 row」與「比對 secret」拆成兩個責任。Laravel Sanctum 的 Personal Access Token（簡稱 PAT）長這樣：</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|abc123def456ghi789jkl012mno345pqr678stu
</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">DB 主鍵     真正的祕密</span></span></code></pre></div><p>豎線前的數字是 <code>personal_access_tokens</code> 資料表的 primary key、豎線後是高熵隨機字串。這個設計在 Laravel 生態裡很常見、但常被誤解為「業界標準 token 格式」 — 實際上是 Sanctum 特定的設計選擇、跟 GitHub PAT（<code>ghp_...</code>）、Stripe API Key（<code>sk_live_...</code>）的設計取捨完全不同。</p>
<p>本文拆解 Sanctum PAT 三個關鍵設計決策：</p>
<ol>
<li>為什麼把 PK 公開放進 token</li>
<li>DB 為什麼只存 hash 不存原文</li>
<li>constant-time 比對為什麼放在應用層、不放在 DB</li>
</ol>
<p>讀完後，你可以用 token 的傳播範圍、撤銷需求與洩漏偵測需求，判斷自己的 application 適合 Sanctum 風格還是其他 token format，並把 hash 儲存與 constant-time 比對原則套用到非 Laravel 環境。</p>
<blockquote>
<p><strong>本文位置</strong>：本文是 <a href="/blog/work-log/api-%E8%AA%8D%E8%AD%89%E7%9A%84%E4%B8%89%E5%B1%A4%E4%BF%A1%E4%BB%BB%E9%82%8A%E7%95%8C%E4%BD%BF%E7%94%A8%E8%80%85%E7%B3%BB%E7%B5%B1%E8%B7%A8%E7%B3%BB%E7%B5%B1-provisioning/" data-link-title="API 認證的三層信任邊界：使用者、系統、跨系統 Provisioning" data-link-desc="API 認證的信任邊界分層（Bearer Token / Shared Secret / Provisioning）：各層的洩漏後果與撤銷方式，以及混用造成的設計失效模式。">API 認證的三層信任邊界</a> Layer 1 的深入篇。主文聚焦「為什麼要分層」的心智模型、本文聚焦「Sanctum 這個特定實作怎麼設計、為什麼」。</p></blockquote>
<hr>
<h2 id="sanctum-在-laravel-認證生態的位置">Sanctum 在 Laravel 認證生態的位置</h2>
<p>Laravel 官方提供三套認證套件、各自解的問題不同：</p>
<table>
  <thead>
      <tr>
          <th>套件</th>
          <th>解的問題</th>
          <th>Token 機制</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Laravel Breeze</strong></td>
          <td>server-rendered 應用的登入註冊 starter</td>
          <td>session cookie</td>
      </tr>
      <tr>
          <td><strong>Laravel Sanctum</strong></td>
          <td>SPA / mobile app / 簡單 API token 認證</td>
          <td>session cookie + PAT（<code>{PK}|{secret}</code>）</td>
      </tr>
      <tr>
          <td><strong>Laravel Passport</strong></td>
          <td>完整 OAuth 2.0 server 實作</td>
          <td>JWT-based access token</td>
      </tr>
  </tbody>
</table>
<p>Sanctum 的設計目標是「<strong>比 Passport 簡單、比手刻 token 嚴謹</strong>」 — 不引入 OAuth 的完整 flow，但解決 token issue、storage、revoke 的常見坑。<code>{PK}|{secret}</code> 是這個設計目標下的具體 trade-off。</p>
<hr>
<h2 id="設計決策一為什麼把-pk-公開放進-token">設計決策一：為什麼把 PK 公開放進 token</h2>
<h3 id="驗證-token-的兩個責任">驗證 token 的兩個責任</h3>
<p>Server 收到 client 傳來的 token、要做兩件事：</p>
<ol>
<li><strong>找到</strong> DB 裡對應的 row（這個 token 是哪個 user 的）</li>
<li><strong>比對</strong> 確認 token 沒被偽造</li>
</ol>
<p>如果 token 只是純隨機字串（沒有 PK 前綴），validation 的 SQL 常會被設計成：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">personal_access_tokens</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">token</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="o">?</span></span></span></code></pre></div><p>這要求 <code>token</code> 欄位有 index，且 server 要讓 DB 同時負責 lookup 與 secret 比對。效能通常不是瓶頸，真正的設計問題是 secret 比對落在應用層控制範圍之外。</p>
<h3 id="db-比對的-timing-不可控">DB 比對的 timing 不可控</h3>
<p>DB 查詢適合處理索引搜尋，不適合承擔機密字串的 timing-safe 比對。當 <code>WHERE token = ?</code> 在 DB 執行時，執行時間可能洩漏：</p>
<ul>
<li>B-tree index 的查找路徑長度（同 prefix 的 row 多時、走的 page 不同）</li>
<li>字串比對的短路行為（多數 DB 引擎不保證 constant-time 比對）</li>
<li>Buffer pool hit / miss 造成的時間差</li>
</ul>
<p>攻擊者透過大量探測，可能推斷出有效 token 的部分結構。雖然實務上利用這個 leak 攻擊成本很高，但更穩健的設計原則是：安全機制應放在 application 能明確控制的比對函式，而不是依賴 DB 引擎的實作細節。</p>
<h3 id="sanctum-的解法用-pk-收斂搜尋把比對搬到應用層">Sanctum 的解法：用 PK 收斂搜尋、把比對搬到應用層</h3>
<p><code>{PK}|{secret}</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">client 傳來: &#34;1|abc123...&#34;
</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">   server 拆解
</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">   │ PK = 1       │ ──→ SELECT * FROM tokens WHERE id = 1
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">   │ secret = abc │      （O(log N)、行為穩定）
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">   └──────────────┘
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">       ↓
</span></span><span class="line"><span class="ln">10</span><span class="cl">   拿到該 row 的 hash
</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">   hash_equals(stored_hash, sha256(secret))
</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">   constant-time 比對、不洩漏 timing</span></span></code></pre></div><p>關鍵在於 <strong>DB 只負責「找到單一 row」、不負責「比對機密」</strong>：</p>
<table>
  <thead>
      <tr>
          <th>動作</th>
          <th>由誰處理</th>
          <th>為什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>用 PK 找到 row</td>
          <td>DB（O(log N)）</td>
          <td>PK 是公開資訊、即使 timing 洩漏也沒安全意義</td>
      </tr>
      <tr>
          <td>比對 secret hash 是否相等</td>
          <td>應用層 constant-time</td>
          <td>在控制範圍內、可保證不依輸入內容變化執行時間</td>
      </tr>
  </tbody>
</table>
<h3 id="常見誤解pk-讓查詢變-o1">常見誤解：「PK 讓查詢變 O(1)」</h3>
<p>PK 前綴的主要價值是安全責任切分，不是把查詢從慢變快。很多 Sanctum 教學文章寫「PK 把查詢變 O(1)、避免 full scan」，這個說法忽略了 hash 欄位也能被索引：</p>
<ul>
<li><strong>hash 欄位也能 index</strong> — <code>WHERE token_hash = ?</code> 用 B-tree index 是 O(log N)、不是 full scan</li>
<li><strong>兩條路都是 B-tree index lookup</strong> — token 規模下都不會是效能瓶頸；clustered（PK）跟 secondary（hash）的 IO cost 微差在多數場景可忽略</li>
</ul>
<p>PK 設計的<strong>主要價值在安全可預測性</strong>、效能差距在多數場景可忽略：把比對機密的責任明確劃在「應用層 constant-time 函式」、不依賴 DB 引擎不保證的 timing 行為。</p>
<p>效能差異反而出現在「<strong>hash 欄位是否要 index</strong>」 — 如果用 hash lookup、<code>token_hash</code> 欄位需要 unique index、寫入成本變高；用 PK lookup、<code>token_hash</code> 不需要 index、寫入更輕量。但這在 token 規模通常不是 bottleneck。</p>
<hr>
<h2 id="設計決策二db-只存-hash-的威脅模型">設計決策二：DB 只存 hash 的威脅模型</h2>
<h3 id="威脅模型db-被攻陷">威脅模型：DB 被攻陷</h3>
<p>Token 是 capability credential — 持有即授權。如果 DB 直接存 plaintext token、任何能讀取 DB 的人（SQL injection、備份外流、運維 dump 不小心 push 到 GitHub）都能直接拿 token 假冒使用者發 request。</p>
<p>Sanctum 的做法：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-php" data-lang="php"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 發放 token
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="nv">$plaintext</span> <span class="o">=</span> <span class="nx">Str</span><span class="o">::</span><span class="na">random</span><span class="p">(</span><span class="mi">40</span><span class="p">);</span>  <span class="c1">// Sanctum 預設 40 char、base62 字元集
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="nv">$hash</span> <span class="o">=</span> <span class="nx">hash</span><span class="p">(</span><span class="s1">&#39;sha256&#39;</span><span class="p">,</span> <span class="nv">$plaintext</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nx">DB</span><span class="o">::</span><span class="na">table</span><span class="p">(</span><span class="s1">&#39;personal_access_tokens&#39;</span><span class="p">)</span><span class="o">-&gt;</span><span class="na">insert</span><span class="p">([</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="s1">&#39;token&#39;</span> <span class="o">=&gt;</span> <span class="nv">$hash</span><span class="p">,</span>           <span class="c1">// DB 只存 hash
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span>    <span class="s1">&#39;tokenable_id&#39;</span> <span class="o">=&gt;</span> <span class="nv">$userId</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="k">return</span> <span class="nv">$tokenId</span> <span class="o">.</span> <span class="s1">&#39;|&#39;</span> <span class="o">.</span> <span class="nv">$plaintext</span><span class="p">;</span>  <span class="c1">// 只此一次回給 client、之後再也拿不到
</span></span></span></code></pre></div><p>意義：<strong>DB 被 dump 時，攻擊者拿到的是不可直接使用的 hash</strong>。攻擊者要還原 <code>plaintext</code> 需要對 SHA-256 做 preimage attack；對 40 字元高熵隨機字串而言，計算成本實務上不可行。</p>
<h3 id="sha-256-與-bcrypt-的適用差異">SHA-256 與 bcrypt 的適用差異</h3>
<p>密碼儲存用 bcrypt / Argon2 是因為<strong>密碼通常熵低</strong>（人類記得住的東西、entropy 通常 &lt; 40 bit）、要刻意慢、抵抗 offline brute-force。</p>
<p>Token 是<strong>高熵隨機字串</strong>（40 char base62 ≈ 238 bit entropy、比一般人類記得住的 password 高約 6 個數量級的熵）— 攻擊者就算拿到 hash、暴力枚舉 plaintext 的搜尋空間是 <code>62^40 ≈ 10^71</code>、宇宙年齡內試不完。在這個前提下：</p>
<table>
  <thead>
      <tr>
          <th>演算法</th>
          <th>處理時間（每次驗證）</th>
          <th>對 token 是否合理</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>SHA-256</td>
          <td>~微秒</td>
          <td>完全足夠</td>
      </tr>
      <tr>
          <td>bcrypt（cost=12）</td>
          <td>~250ms</td>
          <td>浪費 CPU、無增益</td>
      </tr>
  </tbody>
</table>
<p>在高熵 token 的前提下，SHA-256 的速度是優點，因為每次 API request 都需要驗證 token。bcrypt 的慢速設計主要服務低熵 password，套到高熵 token 會增加延遲而沒有對應的安全收益。</p>
<h3 id="salt-的適用邊界">Salt 的適用邊界</h3>
<p>bcrypt 用 salt 是為了防 <strong>rainbow table 攻擊</strong>（預算好常見密碼的 hash、查表）。Rainbow table 對「人類選的密碼」有效、對「40 char 高熵 token」無效（搜尋空間太大、預算表的成本超過直接 brute-force）。</p>
<p>所以 Sanctum 對 token 用 unsalted SHA-256，是符合「高熵隨機 token」威脅模型的選擇。若 credential 來源改成人類可記憶密碼，威脅模型就會改變，儲存策略也要回到 password hashing。</p>
<hr>
<h2 id="設計決策三constant-time-比對放在應用層">設計決策三：constant-time 比對放在應用層</h2>
<h3 id="constant-time-比對在解什麼">Constant-time 比對在解什麼</h3>
<p><code>==</code> 或 <code>strcmp</code> 比對字串時、會「<strong>短路</strong>」 — 一發現不同就回傳 false：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-c" data-lang="c"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 偽程式碼：strcmp 的典型實作
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">for</span> <span class="p">(</span><span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">len</span><span class="p">;</span> <span class="n">i</span><span class="o">++</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">if</span> <span class="p">(</span><span class="n">a</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">!=</span> <span class="n">b</span><span class="p">[</span><span class="n">i</span><span class="p">])</span> <span class="k">return</span> <span class="nb">false</span><span class="p">;</span>  <span class="c1">// ← 在這裡 return、不跑完
</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 class="k">return</span> <span class="nb">true</span><span class="p">;</span></span></span></code></pre></div><p>攻擊者可量測「server 從收到 request 到回 401」的時間、推斷「前幾個 byte 是對的」：</p>
<table>
  <thead>
      <tr>
          <th>嘗試的 token</th>
          <th>跑了幾個 byte 才 return</th>
          <th>server 回應時間</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>aaaaaaaa...</code></td>
          <td>1（第 1 byte 就錯）</td>
          <td>~1 μs</td>
      </tr>
      <tr>
          <td><code>1aaaaaaa...</code></td>
          <td>2（第 2 byte 才錯）</td>
          <td>~2 μs</td>
      </tr>
      <tr>
          <td><code>1a aaaaa...</code></td>
          <td>3</td>
          <td>~3 μs</td>
      </tr>
  </tbody>
</table>
<p>實務上單次 request 的網路抖動遠大於這幾 μs、但攻擊者可重複幾百萬次取平均、把雜訊濾掉、最終推出整個 hash。這就是 <strong>timing attack</strong>。</p>
<h3 id="constant-time-函式的實作策略">Constant-time 函式的實作策略</h3>
<p>Constant-time 比對的核心是「<strong>不論輸入長什麼樣、都跑完整個比對長度</strong>」：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-c" data-lang="c"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 偽程式碼：constant-time 比對
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="n">result</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="k">for</span> <span class="p">(</span><span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">len</span><span class="p">;</span> <span class="n">i</span><span class="o">++</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">result</span> <span class="o">|=</span> <span class="n">a</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">^</span> <span class="n">b</span><span class="p">[</span><span class="n">i</span><span class="p">];</span>  <span class="c1">// 用 XOR 累積差異、不 return
</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 class="k">return</span> <span class="n">result</span> <span class="o">==</span> <span class="mi">0</span><span class="p">;</span></span></span></code></pre></div><p>每次呼叫都跑完整個 loop、結果用 bitwise OR 累積、最後一次性比對。執行時間不依輸入內容變化。</p>
<h3 id="各語言的-constant-time-比對函式">各語言的 constant-time 比對函式</h3>
<table>
  <thead>
      <tr>
          <th>語言</th>
          <th>函式</th>
          <th>注意事項</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>PHP</strong></td>
          <td><code>hash_equals($known, $user_input)</code></td>
          <td>第一個參數要是 known、第二個是 user input</td>
      </tr>
      <tr>
          <td><strong>Python</strong></td>
          <td><code>hmac.compare_digest(a, b)</code></td>
          <td>也可用 <code>secrets.compare_digest</code></td>
      </tr>
      <tr>
          <td><strong>Go</strong></td>
          <td><code>subtle.ConstantTimeCompare(a, b)</code></td>
          <td>回傳 int (0 / 1)、不是 bool</td>
      </tr>
      <tr>
          <td><strong>Ruby</strong></td>
          <td><code>ActiveSupport::SecurityUtils.secure_compare(a, b)</code></td>
          <td>Rails；純 Ruby 用 <code>OpenSSL.fixed_length_secure_compare</code></td>
      </tr>
      <tr>
          <td><strong>Java</strong></td>
          <td><code>MessageDigest.isEqual(a, b)</code></td>
          <td>Java 6+ 保證 constant-time</td>
      </tr>
      <tr>
          <td><strong>Node.js</strong></td>
          <td><code>crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b))</code></td>
          <td>兩個 Buffer 長度必須相同、否則 throw</td>
      </tr>
  </tbody>
</table>
<p><strong>失效模式</strong>：用 <code>==</code>、<code>===</code>、<code>strcmp</code>、<code>String.equals</code> 比對 hash，會讓執行時間受到第一個不同 byte 的位置影響。判讀訊號是驗證邏輯直接使用語言的一般字串相等運算；下一步路由是改用標準庫或框架提供的 constant-time 函式。</p>
<h3 id="為什麼不放在-db-層">為什麼不放在 DB 層</h3>
<p>DB 引擎大多不保證 constant-time 比對。MySQL、PostgreSQL 的字串比對為了效能，底層仍可能走短路邏輯；因此「<code>WHERE hash = ?</code>」即使加 index，也不適合被當成 timing-safe 的安全邊界。</p>
<p>Sanctum 的設計把 secret 比對完全搬到應用層用 <code>hash_equals</code> — DB 只負責「用 PK 找到單一 row」、應用層負責「比對 hash」。職責清楚、安全可預測。</p>
<hr>
<h2 id="sanctum-vs-github-pat-vs-stripe-api-key">Sanctum vs GitHub PAT vs Stripe API Key</h2>
<p>三者都是 opaque token（隨機字串、server lookup）、但 format 設計取捨完全不同：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Sanctum <code>{PK}|{secret}</code></th>
          <th>GitHub <code>ghp_xxx</code></th>
          <th>Stripe <code>sk_live_xxx</code></th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>找到 row 的方式</strong></td>
          <td>用 PK lookup</td>
          <td>用 hash lookup</td>
          <td>用 hash lookup</td>
      </tr>
      <tr>
          <td><strong>格式可辨識性</strong></td>
          <td>低（看起來像一般字串）</td>
          <td>高（<code>ghp_</code> 前綴）</td>
          <td>高（<code>sk_live_</code> / <code>sk_test_</code> 前綴）</td>
      </tr>
      <tr>
          <td><strong>洩漏掃描</strong></td>
          <td>困難</td>
          <td>容易（GitHub 自己 scan 公開 repo）</td>
          <td>容易（Stripe webhook scan）</td>
      </tr>
      <tr>
          <td><strong>Token type 辨識</strong></td>
          <td>需查 DB</td>
          <td>從前綴直接知道（user / app / OAuth）</td>
          <td>從前綴直接知道（live / test、public / secret）</td>
      </tr>
      <tr>
          <td><strong>適合場景</strong></td>
          <td>單一 Laravel app 內部使用</td>
          <td>對外開放、需要洩漏偵測</td>
          <td>對外開放、多環境（live / test）</td>
      </tr>
  </tbody>
</table>
<h3 id="各自的設計動機">各自的設計動機</h3>
<p><strong>Sanctum</strong>：使用情境是「單一 Laravel application 自己發、自己驗」。Token 不會散落在公開 repo（除非開發者犯錯）、洩漏偵測不是首要需求。把 PK 直接放進 token、換 timing 安全與設計簡潔。</p>
<p><strong>GitHub PAT</strong>：使用情境是「使用者把 token 寫進 CI config、push 到 public repo」。GitHub 把 <code>ghp_</code> 前綴標準化、自家服務（Push Protection、Secret Scanning）會主動 scan 公開 repo、發現 <code>ghp_...</code> pattern 就通知 user 並 revoke。Token 的可辨識性是<strong>洩漏偵測 infrastructure 的一環</strong>、不是浪費字元。</p>
<p><strong>Stripe API Key</strong>：使用情境跨 live 跟 test 環境、且有 public / secret 兩種 key。前綴設計：</p>
<ul>
<li><code>sk_live_</code> — secret key、live 環境（會收真錢）</li>
<li><code>sk_test_</code> — secret key、test 環境</li>
<li><code>pk_live_</code> — publishable key、live 環境（可放 client）</li>
<li><code>pk_test_</code> — publishable key、test 環境</li>
</ul>
<p>工程師看一眼就知道「這把 key 能幹嘛」、避免把 live key 寫進 test config。</p>
<h3 id="怎麼選">怎麼選</h3>
<table>
  <thead>
      <tr>
          <th>你的場景</th>
          <th>建議設計</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>單一 Laravel app、token 只內部用</td>
          <td>Sanctum 預設即可</td>
      </tr>
      <tr>
          <td>對外開放 API、token 會散落第三方環境</td>
          <td>學 GitHub / Stripe 加 prefix</td>
      </tr>
      <tr>
          <td>多環境（dev / staging / prod）容易誤用</td>
          <td>加環境 prefix（如 <code>_live_</code>）</td>
      </tr>
      <tr>
          <td>多 token type（user / bot / OAuth）</td>
          <td>加 type prefix</td>
      </tr>
  </tbody>
</table>
<p>表格的判準是 token 會不會離開受控環境。單一 Laravel app 內部使用時，Sanctum 的 PK 前綴足以支撐 lookup 與撤銷；對外 API、第三方整合或多環境部署時，prefix 可提供洩漏掃描與人工辨識訊號。也可以混用成 <code>{prefix}|{PK}|{secret}</code>，同時保留 lookup 收斂與語意辨識。</p>
<hr>
<h2 id="在非-laravel-環境怎麼套用">在非 Laravel 環境怎麼套用</h2>
<p>Sanctum 的三個原則跨語言通用：</p>
<ol>
<li><strong>DB 只存 hash</strong> — 用任何語言的 SHA-256 / SHA-512 即可。Python: <code>hashlib.sha256</code>、Go: <code>crypto/sha256</code>、Node: <code>crypto.createHash('sha256')</code></li>
<li><strong>Lookup 用穩定字段</strong> — 把「找到 row」跟「比對機密」分開、<code>WHERE id = ?</code> 是穩定的、<code>WHERE hash = ?</code> 在 timing 上不可控</li>
<li><strong>應用層 constant-time 比對</strong> — 用本文上面表格列的函式、絕不用 <code>==</code></li>
</ol>
<p>非 Laravel 框架的等效實作：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># Python + SQLAlchemy 範例</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">import</span> <span class="nn">secrets</span><span class="o">,</span> <span class="nn">hashlib</span><span class="o">,</span> <span class="nn">hmac</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="k">def</span> <span class="nf">issue_token</span><span class="p">(</span><span class="n">user_id</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="n">plaintext</span> <span class="o">=</span> <span class="n">secrets</span><span class="o">.</span><span class="n">token_urlsafe</span><span class="p">(</span><span class="mi">32</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="n">hash_value</span> <span class="o">=</span> <span class="n">hashlib</span><span class="o">.</span><span class="n">sha256</span><span class="p">(</span><span class="n">plaintext</span><span class="o">.</span><span class="n">encode</span><span class="p">())</span><span class="o">.</span><span class="n">hexdigest</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="n">token</span> <span class="o">=</span> <span class="n">PersonalAccessToken</span><span class="p">(</span><span class="n">user_id</span><span class="o">=</span><span class="n">user_id</span><span class="p">,</span> <span class="nb">hash</span><span class="o">=</span><span class="n">hash_value</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="n">db</span><span class="o">.</span><span class="n">session</span><span class="o">.</span><span class="n">add</span><span class="p">(</span><span class="n">token</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="n">db</span><span class="o">.</span><span class="n">session</span><span class="o">.</span><span class="n">commit</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">token</span><span class="o">.</span><span class="n">id</span><span class="si">}</span><span class="s2">|</span><span class="si">{</span><span class="n">plaintext</span><span class="si">}</span><span class="s2">&#34;</span>  <span class="c1"># 只此一次回給 client</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="k">def</span> <span class="nf">verify_token</span><span class="p">(</span><span class="n">raw_token</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="c1"># production 範例需多一層 try-except 涵蓋 int() 轉型與 DB 例外</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="n">token_id</span><span class="p">,</span> <span class="n">plaintext</span> <span class="o">=</span> <span class="n">raw_token</span><span class="o">.</span><span class="n">split</span><span class="p">(</span><span class="s1">&#39;|&#39;</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="n">token</span> <span class="o">=</span> <span class="n">PersonalAccessToken</span><span class="o">.</span><span class="n">query</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="nb">int</span><span class="p">(</span><span class="n">token_id</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">except</span> <span class="p">(</span><span class="ne">ValueError</span><span class="p">,</span> <span class="ne">TypeError</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">token</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="n">expected_hash</span> <span class="o">=</span> <span class="n">hashlib</span><span class="o">.</span><span class="n">sha256</span><span class="p">(</span><span class="n">plaintext</span><span class="o">.</span><span class="n">encode</span><span class="p">())</span><span class="o">.</span><span class="n">hexdigest</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">hmac</span><span class="o">.</span><span class="n">compare_digest</span><span class="p">(</span><span class="n">token</span><span class="o">.</span><span class="n">hash</span><span class="p">,</span> <span class="n">expected_hash</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="k">return</span> <span class="n">token</span><span class="o">.</span><span class="n">user</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// Go + sqlx 範例</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">func</span> <span class="nf">IssueToken</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">userID</span> <span class="kt">int64</span><span class="p">)</span> <span class="p">(</span><span class="kt">string</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">plaintext</span> <span class="o">:=</span> <span class="nf">generateRandomString</span><span class="p">(</span><span class="mi">40</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">hash</span> <span class="o">:=</span> <span class="nx">sha256</span><span class="p">.</span><span class="nf">Sum256</span><span class="p">([]</span><span class="nb">byte</span><span class="p">(</span><span class="nx">plaintext</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="kd">var</span> <span class="nx">tokenID</span> <span class="kt">int64</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">err</span> <span class="o">:=</span> <span class="nx">db</span><span class="p">.</span><span class="nf">QueryRowContext</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="s">&#34;INSERT INTO personal_access_tokens (user_id, hash) VALUES ($1, $2) RETURNING id&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">userID</span><span class="p">,</span> <span class="nx">hex</span><span class="p">.</span><span class="nf">EncodeToString</span><span class="p">(</span><span class="nx">hash</span><span class="p">[:]),</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">).</span><span class="nf">Scan</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">tokenID</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="k">return</span> <span class="s">&#34;&#34;</span><span class="p">,</span> <span class="nx">err</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="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Sprintf</span><span class="p">(</span><span class="s">&#34;%d|%s&#34;</span><span class="p">,</span> <span class="nx">tokenID</span><span class="p">,</span> <span class="nx">plaintext</span><span class="p">),</span> <span class="kc">nil</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></span><span class="line"><span class="ln">16</span><span class="cl"><span class="kd">func</span> <span class="nf">VerifyToken</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">raw</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="o">*</span><span class="nx">Token</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="nx">parts</span> <span class="o">:=</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">SplitN</span><span class="p">(</span><span class="nx">raw</span><span class="p">,</span> <span class="s">&#34;|&#34;</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="nx">parts</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">2</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="k">return</span> <span class="kc">nil</span><span class="p">,</span> <span class="nx">ErrInvalidFormat</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 class="nx">tokenID</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">strconv</span><span class="p">.</span><span class="nf">ParseInt</span><span class="p">(</span><span class="nx">parts</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">64</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="k">return</span> <span class="kc">nil</span><span class="p">,</span> <span class="nx">ErrInvalidFormat</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="kd">var</span> <span class="nx">token</span> <span class="nx">Token</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="nx">err</span> <span class="p">=</span> <span class="nx">db</span><span class="p">.</span><span class="nf">GetContext</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="o">&amp;</span><span class="nx">token</span><span class="p">,</span> <span class="s">&#34;SELECT * FROM personal_access_tokens WHERE id = $1&#34;</span><span class="p">,</span> <span class="nx">tokenID</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">        <span class="k">return</span> <span class="kc">nil</span><span class="p">,</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">    <span class="nx">expectedHash</span> <span class="o">:=</span> <span class="nx">sha256</span><span class="p">.</span><span class="nf">Sum256</span><span class="p">([]</span><span class="nb">byte</span><span class="p">(</span><span class="nx">parts</span><span class="p">[</span><span class="mi">1</span><span class="p">]))</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">    <span class="nx">storedHash</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nx">hex</span><span class="p">.</span><span class="nf">DecodeString</span><span class="p">(</span><span class="nx">token</span><span class="p">.</span><span class="nx">Hash</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">    <span class="k">if</span> <span class="nx">subtle</span><span class="p">.</span><span class="nf">ConstantTimeCompare</span><span class="p">(</span><span class="nx">storedHash</span><span class="p">,</span> <span class="nx">expectedHash</span><span class="p">[:])</span> <span class="o">!=</span> <span class="mi">1</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">        <span class="k">return</span> <span class="kc">nil</span><span class="p">,</span> <span class="nx">ErrInvalidToken</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">    <span class="k">return</span> <span class="o">&amp;</span><span class="nx">token</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>兩者的關鍵都是：<code>SELECT WHERE id = ?</code> + 應用層 <code>compare_digest</code> / <code>ConstantTimeCompare</code>、不依賴 DB 比對 hash。</p>
<hr>
<h2 id="收尾">收尾</h2>
<p>Sanctum 的 <code>{PK}|{secret}</code> 是一個<strong>特定情境下的設計取捨</strong>，不是業界通用標準：</p>
<ul>
<li>它假設 token 不會散落到公開環境、所以不需要 prefix-based 洩漏偵測</li>
<li>它把比對機密的責任明確劃在應用層、不依賴 DB 引擎的 timing 行為</li>
<li>它用 SHA-256 + 不加 salt、因為 token 高熵時這個選擇符合威脅模型</li>
</ul>
<p>如果你的場景符合這些假設，Sanctum 的設計可以直接使用。若場景是對外 API、需要洩漏偵測、多環境或多 token type，prefix-based format 會提供更好的操作訊號；儲存原則（hash + constant-time）則跨設計通用。</p>
<p>延伸閱讀：</p>
<ul>
<li><a href="/blog/work-log/api-%E8%AA%8D%E8%AD%89%E7%9A%84%E4%B8%89%E5%B1%A4%E4%BF%A1%E4%BB%BB%E9%82%8A%E7%95%8C%E4%BD%BF%E7%94%A8%E8%80%85%E7%B3%BB%E7%B5%B1%E8%B7%A8%E7%B3%BB%E7%B5%B1-provisioning/" data-link-title="API 認證的三層信任邊界：使用者、系統、跨系統 Provisioning" data-link-desc="API 認證的信任邊界分層（Bearer Token / Shared Secret / Provisioning）：各層的洩漏後果與撤銷方式，以及混用造成的設計失效模式。">API 認證的三層信任邊界</a> — 本文的主篇、Sanctum 在「Layer 1 使用者層」的位置</li>
<li><a href="/blog/work-log/shared-secret-%E5%AE%89%E5%85%A8%E8%BC%AA%E6%9B%BF%E8%A8%AD%E8%A8%88%E9%9B%99%E5%AF%86%E9%81%8E%E6%B8%A1%E6%9C%9F%E8%87%AA%E5%8B%95%E5%8C%96%E8%88%87%E7%B7%8A%E6%80%A5%E6%B5%81%E7%A8%8B/" data-link-title="Shared Secret 安全輪替設計：雙密過渡期、自動化與緊急流程" data-link-desc="系統間 Shared Secret 輪替的核心機制：dual-secret rollover、自動化工具比較（AWS Secrets Manager / Vault / GCP）、緊急 rotation 流程與多 client 環境的失敗模式。">Shared Secret 安全輪替設計</a> — Layer 2 系統間 secret 的輪替議題</li>
<li><a href="/blog/work-log/mtls-%E5%AF%A6%E9%9A%9B%E6%80%8E%E9%BA%BC%E8%A8%AD%E5%AE%9A%E8%88%87%E9%81%8B%E7%B6%ADca-%E9%9A%8E%E5%B1%A4%E6%86%91%E8%AD%89%E7%94%9F%E5%91%BD%E9%80%B1%E6%9C%9F%E6%92%A4%E9%8A%B7%E6%A9%9F%E5%88%B6/" data-link-title="mTLS 實際怎麼設定與運維：CA 階層、憑證生命週期、撤銷機制" data-link-desc="mTLS 落地的運維決策（CA 階層、憑證儲存、撤銷機制）與基礎設施整合（nginx / envoy / service mesh），以及跟 API Key / OAuth 的成本與安全取捨。">mTLS 實際怎麼設定與運維</a> — Layer 2 進階方案的部署細節</li>
</ul>
]]></content:encoded></item><item><title>mTLS 實際怎麼設定與運維：CA 階層、憑證生命週期、撤銷機制</title><link>https://tarrragon.github.io/blog/work-log/mtls-%E5%AF%A6%E9%9A%9B%E6%80%8E%E9%BA%BC%E8%A8%AD%E5%AE%9A%E8%88%87%E9%81%8B%E7%B6%ADca-%E9%9A%8E%E5%B1%A4%E6%86%91%E8%AD%89%E7%94%9F%E5%91%BD%E9%80%B1%E6%9C%9F%E6%92%A4%E9%8A%B7%E6%A9%9F%E5%88%B6/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/mtls-%E5%AF%A6%E9%9A%9B%E6%80%8E%E9%BA%BC%E8%A8%AD%E5%AE%9A%E8%88%87%E9%81%8B%E7%B6%ADca-%E9%9A%8E%E5%B1%A4%E6%86%91%E8%AD%89%E7%94%9F%E5%91%BD%E9%80%B1%E6%9C%9F%E6%92%A4%E9%8A%B7%E6%A9%9F%E5%88%B6/</guid><description>&lt;h2 id="mtls-這篇要解決什麼">mTLS 這篇要解決什麼&lt;/h2>
&lt;p>mTLS 的核心是把系統身分綁到 X.509 憑證與私鑰，而不是可重用的 shared secret。介紹文章常把它簡化成「雙向 TLS 憑證、適合金融醫療」，但實際落地時，設計責任會立刻延伸到 CA 階層、憑證生命週期、撤銷與基礎設施整合：&lt;/p>
&lt;ul>
&lt;li>自簽 CA 還是商業 CA？&lt;/li>
&lt;li>憑證放哪、怎麼 rotate？&lt;/li>
&lt;li>怎麼撤銷？CRL 還是 OCSP 還是 short-lived cert？&lt;/li>
&lt;li>nginx 設定怎麼寫、service mesh 怎麼整合？&lt;/li>
&lt;li>跟 API Key、OAuth 比，什麼情境適合承擔 mTLS 的運維成本？&lt;/li>
&lt;/ul>
&lt;p>這些是 mTLS 第一次部署就要處理的基本問題。若只知道「雙向憑證」而沒有 lifecycle 設計，系統會在過期、撤銷或 mesh 升級時失去可預測性。&lt;/p>
&lt;p>本文拆解 mTLS 的工程實務：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>CA 階層&lt;/strong>：為什麼要分層、Root CA / Intermediate CA / Leaf cert&lt;/li>
&lt;li>&lt;strong>憑證生命週期&lt;/strong>：簽發、儲存、rotation、撤銷&lt;/li>
&lt;li>&lt;strong>基礎設施整合&lt;/strong>：nginx / envoy / service mesh 設定模式&lt;/li>
&lt;li>&lt;strong>跟其他 Layer 2 方案的取捨&lt;/strong>：何時 mTLS 才是對的選擇&lt;/li>
&lt;/ol>
&lt;blockquote>
&lt;p>&lt;strong>本文位置&lt;/strong>：本文是 &lt;a href="https://tarrragon.github.io/blog/work-log/api-%E8%AA%8D%E8%AD%89%E7%9A%84%E4%B8%89%E5%B1%A4%E4%BF%A1%E4%BB%BB%E9%82%8A%E7%95%8C%E4%BD%BF%E7%94%A8%E8%80%85%E7%B3%BB%E7%B5%B1%E8%B7%A8%E7%B3%BB%E7%B5%B1-provisioning/" data-link-title="API 認證的三層信任邊界：使用者、系統、跨系統 Provisioning" data-link-desc="API 認證的信任邊界分層（Bearer Token / Shared Secret / Provisioning）：各層的洩漏後果與撤銷方式，以及混用造成的設計失效模式。">API 認證的三層信任邊界&lt;/a> Layer 2 的深入篇之一。主文聚焦「為什麼系統間要獨立 credential」、本文聚焦「用 mTLS 實作這層的具體工程細節」。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="mtls-解什麼問題">mTLS 解什麼問題&lt;/h2>
&lt;h3 id="跟一般-tls-的差異">跟一般 TLS 的差異&lt;/h3>
&lt;p>一般 TLS（HTTPS）是&lt;strong>單向認證&lt;/strong>：client 驗證 server 身分，server 再透過 API Key、token 或 session 辨識 client。&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">client ────&amp;#34;我要連 example.com&amp;#34;────▶ server
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> ◀───server 出示憑證───────── server
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> 驗證:&amp;#34;這是真的 example.com 嗎&amp;#34;
&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;/code>&lt;/pre>&lt;/div>&lt;p>client 驗證 server、但 server 不驗證 client。Client 是匿名的、靠後續 API Key / token 認證。&lt;/p>
&lt;p>mTLS 加上&lt;strong>反向驗證&lt;/strong>：server 也在 TLS handshake 階段驗證 client 憑證，把系統身分提前到連線層建立。&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">client ──&amp;#34;我要連 example.com、這是我的憑證&amp;#34;──▶ server
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> ◀──server 出示憑證───────────────────── server
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> 
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> 雙方驗證對方憑證：
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> client: &amp;#34;這是真的 example.com 嗎&amp;#34;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> server: &amp;#34;這個 client 是被授權的嗎&amp;#34;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl"> 建立加密通道、且雙方都已認證&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>每個 client 有自己的憑證、server 用 CA 信任鏈驗證 client 憑證是否合法。&lt;strong>Client 的身分綁定在 X.509 憑證上、不需要額外的 API Key&lt;/strong>。&lt;/p>
&lt;h3 id="mtls-解的具體威脅">mTLS 解的具體威脅&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>威脅&lt;/th>
 &lt;th>一般 TLS + API Key&lt;/th>
 &lt;th>mTLS&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>中間人攔截&lt;/td>
 &lt;td>TLS 已解&lt;/td>
 &lt;td>TLS 已解&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>攻擊者用洩漏的 API Key 假冒 client&lt;/td>
 &lt;td>漏&lt;/td>
 &lt;td>需 client 私鑰、無法只憑網路觀察取得&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>API Key 寫在 client code、被反編譯&lt;/td>
 &lt;td>漏&lt;/td>
 &lt;td>私鑰可放硬體（HSM / TPM / Secure Enclave）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Server 端 per-client credential 被攻陷&lt;/td>
 &lt;td>漏（API Key DB 外流）&lt;/td>
 &lt;td>server 無 per-client secret、僅 CA trust chain 暴露&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Client 端被植入、用合法身分滲透&lt;/td>
 &lt;td>部分（rate limit）&lt;/td>
 &lt;td>同樣（需依靠撤銷機制）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>mTLS 的核心優勢是：&lt;strong>client 端的 private key 是 scope-bound、不跨系統共用&lt;/strong>。私鑰理論上不離開 client，且驗證憑藉的是 CA 簽章而非可重用字串；相較 shared API Key，一個 client 的私鑰外流通常可被限制在該 client 的憑證與授權範圍內。&lt;/p></description><content:encoded><![CDATA[<h2 id="mtls-這篇要解決什麼">mTLS 這篇要解決什麼</h2>
<p>mTLS 的核心是把系統身分綁到 X.509 憑證與私鑰，而不是可重用的 shared secret。介紹文章常把它簡化成「雙向 TLS 憑證、適合金融醫療」，但實際落地時，設計責任會立刻延伸到 CA 階層、憑證生命週期、撤銷與基礎設施整合：</p>
<ul>
<li>自簽 CA 還是商業 CA？</li>
<li>憑證放哪、怎麼 rotate？</li>
<li>怎麼撤銷？CRL 還是 OCSP 還是 short-lived cert？</li>
<li>nginx 設定怎麼寫、service mesh 怎麼整合？</li>
<li>跟 API Key、OAuth 比，什麼情境適合承擔 mTLS 的運維成本？</li>
</ul>
<p>這些是 mTLS 第一次部署就要處理的基本問題。若只知道「雙向憑證」而沒有 lifecycle 設計，系統會在過期、撤銷或 mesh 升級時失去可預測性。</p>
<p>本文拆解 mTLS 的工程實務：</p>
<ol>
<li><strong>CA 階層</strong>：為什麼要分層、Root CA / Intermediate CA / Leaf cert</li>
<li><strong>憑證生命週期</strong>：簽發、儲存、rotation、撤銷</li>
<li><strong>基礎設施整合</strong>：nginx / envoy / service mesh 設定模式</li>
<li><strong>跟其他 Layer 2 方案的取捨</strong>：何時 mTLS 才是對的選擇</li>
</ol>
<blockquote>
<p><strong>本文位置</strong>：本文是 <a href="/blog/work-log/api-%E8%AA%8D%E8%AD%89%E7%9A%84%E4%B8%89%E5%B1%A4%E4%BF%A1%E4%BB%BB%E9%82%8A%E7%95%8C%E4%BD%BF%E7%94%A8%E8%80%85%E7%B3%BB%E7%B5%B1%E8%B7%A8%E7%B3%BB%E7%B5%B1-provisioning/" data-link-title="API 認證的三層信任邊界：使用者、系統、跨系統 Provisioning" data-link-desc="API 認證的信任邊界分層（Bearer Token / Shared Secret / Provisioning）：各層的洩漏後果與撤銷方式，以及混用造成的設計失效模式。">API 認證的三層信任邊界</a> Layer 2 的深入篇之一。主文聚焦「為什麼系統間要獨立 credential」、本文聚焦「用 mTLS 實作這層的具體工程細節」。</p></blockquote>
<hr>
<h2 id="mtls-解什麼問題">mTLS 解什麼問題</h2>
<h3 id="跟一般-tls-的差異">跟一般 TLS 的差異</h3>
<p>一般 TLS（HTTPS）是<strong>單向認證</strong>：client 驗證 server 身分，server 再透過 API Key、token 或 session 辨識 client。</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">client ────&#34;我要連 example.com&#34;────▶ server
</span></span><span class="line"><span class="ln">2</span><span class="cl">       ◀───server 出示憑證───────── server
</span></span><span class="line"><span class="ln">3</span><span class="cl">       驗證:&#34;這是真的 example.com 嗎&#34;
</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></code></pre></div><p>client 驗證 server、但 server 不驗證 client。Client 是匿名的、靠後續 API Key / token 認證。</p>
<p>mTLS 加上<strong>反向驗證</strong>：server 也在 TLS handshake 階段驗證 client 憑證，把系統身分提前到連線層建立。</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">client ──&#34;我要連 example.com、這是我的憑證&#34;──▶ server
</span></span><span class="line"><span class="ln">2</span><span class="cl">       ◀──server 出示憑證───────────────────── server
</span></span><span class="line"><span class="ln">3</span><span class="cl">       
</span></span><span class="line"><span class="ln">4</span><span class="cl">       雙方驗證對方憑證：
</span></span><span class="line"><span class="ln">5</span><span class="cl">       client: &#34;這是真的 example.com 嗎&#34;
</span></span><span class="line"><span class="ln">6</span><span class="cl">       server: &#34;這個 client 是被授權的嗎&#34;
</span></span><span class="line"><span class="ln">7</span><span class="cl">       ↓
</span></span><span class="line"><span class="ln">8</span><span class="cl">       建立加密通道、且雙方都已認證</span></span></code></pre></div><p>每個 client 有自己的憑證、server 用 CA 信任鏈驗證 client 憑證是否合法。<strong>Client 的身分綁定在 X.509 憑證上、不需要額外的 API Key</strong>。</p>
<h3 id="mtls-解的具體威脅">mTLS 解的具體威脅</h3>
<table>
  <thead>
      <tr>
          <th>威脅</th>
          <th>一般 TLS + API Key</th>
          <th>mTLS</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>中間人攔截</td>
          <td>TLS 已解</td>
          <td>TLS 已解</td>
      </tr>
      <tr>
          <td>攻擊者用洩漏的 API Key 假冒 client</td>
          <td>漏</td>
          <td>需 client 私鑰、無法只憑網路觀察取得</td>
      </tr>
      <tr>
          <td>API Key 寫在 client code、被反編譯</td>
          <td>漏</td>
          <td>私鑰可放硬體（HSM / TPM / Secure Enclave）</td>
      </tr>
      <tr>
          <td>Server 端 per-client credential 被攻陷</td>
          <td>漏（API Key DB 外流）</td>
          <td>server 無 per-client secret、僅 CA trust chain 暴露</td>
      </tr>
      <tr>
          <td>Client 端被植入、用合法身分滲透</td>
          <td>部分（rate limit）</td>
          <td>同樣（需依靠撤銷機制）</td>
      </tr>
  </tbody>
</table>
<p>mTLS 的核心優勢是：<strong>client 端的 private key 是 scope-bound、不跨系統共用</strong>。私鑰理論上不離開 client，且驗證憑藉的是 CA 簽章而非可重用字串；相較 shared API Key，一個 client 的私鑰外流通常可被限制在該 client 的憑證與授權範圍內。</p>
<p>代價是：<strong>PKI 基礎建設複雜</strong>、憑證生命週期管理重、運維成本高。</p>
<hr>
<h2 id="ca-階層設計">CA 階層設計</h2>
<h3 id="為什麼要分層">為什麼要分層</h3>
<p>CA 分層的核心責任是降低最高信任根的暴露頻率。直覺做法是「用一張 Root CA 直接簽 client 憑證」：</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">Root CA ──signs──▶ client-A.crt
</span></span><span class="line"><span class="ln">2</span><span class="cl">        ──signs──▶ client-B.crt
</span></span><span class="line"><span class="ln">3</span><span class="cl">        ──signs──▶ client-C.crt
</span></span><span class="line"><span class="ln">4</span><span class="cl">        ...</span></span></code></pre></div><p>Root CA 私鑰是整個 PKI 的最高信任根，通常需要離線、HSM 與多人簽核。它一旦洩漏，所有信任這個 Root 的系統都要重新建立信任；Root CA 又通常活 10-20 年，撤換成本極高。</p>
<p>如果 Root CA 私鑰要常常拿出來簽 client cert、暴露風險就大幅提高。</p>
<p>解法：<strong>分層</strong>。Root CA 只簽 Intermediate CA、Intermediate CA 負責日常簽發 client cert：</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">Root CA (offline, 20 年)
</span></span><span class="line"><span class="ln">2</span><span class="cl">    ↓ signs (一次性 / 5-10 年)
</span></span><span class="line"><span class="ln">3</span><span class="cl">Intermediate CA (online, 1-5 年)
</span></span><span class="line"><span class="ln">4</span><span class="cl">    ↓ signs (日常、每張 90 天-1 年)
</span></span><span class="line"><span class="ln">5</span><span class="cl">Leaf certificates (client / server)</span></span></code></pre></div><p>Root CA 通常<strong>完全離線</strong>（air-gapped 機器、硬體 HSM）、私鑰一年只拿出來簽幾次（簽 Intermediate）。Intermediate CA 才是 online、處理日常簽發。</p>
<h3 id="階層帶來的好處">階層帶來的好處</h3>
<table>
  <thead>
      <tr>
          <th>好處</th>
          <th>機制</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Root CA 私鑰暴露次數降到最低</td>
          <td>只在簽 Intermediate 時用、其他時間離線</td>
      </tr>
      <tr>
          <td>Intermediate 被攻陷可撤換</td>
          <td>Root CA 撤掉該 Intermediate、用新 Intermediate 簽</td>
      </tr>
      <tr>
          <td>可按用途分 Intermediate</td>
          <td>一個給 server cert、一個給 client cert、一個給 internal services</td>
      </tr>
      <tr>
          <td>短 chain 仍可驗證</td>
          <td>client 只信任 Root CA、Intermediate 在 chain 中傳遞</td>
      </tr>
  </tbody>
</table>
<h3 id="三種典型部署模式">三種典型部署模式</h3>
<h4 id="模式-a自管-ca">模式 A：自管 CA</h4>
<p>完全自己跑 CA infra：</p>
<ul>
<li>Root CA：離線 HSM、年度作業簽 Intermediate</li>
<li>Intermediate CA：online、用工具如 <code>step-ca</code>、<code>cfssl</code>、<code>Vault PKI</code>、<code>Smallstep</code></li>
<li>Leaf cert：自動化簽發、短 TTL</li>
</ul>
<p>適合：純內部系統、不需 public trust、要完全控制 CA infrastructure。</p>
<h4 id="模式-b商業-cadigicert--sectigo--entrust">模式 B：商業 CA（DigiCert / Sectigo / Entrust）</h4>
<p>買商業 CA 服務、商業 CA 已預埋進所有 OS / browser trust store：</p>
<ul>
<li>適合：需要 public trust（HTTPS server cert、SSL/TLS for end users）</li>
<li>mTLS client cert 通常在自己的封閉系統內驗證，public trust 的價值較低，因此較少使用商業 CA</li>
</ul>
<h4 id="模式-ccloud-managed-pki">模式 C：Cloud-managed PKI</h4>
<p>雲廠商提供 managed PKI：</p>
<ul>
<li>AWS Private CA（ACM PCA）— managed Root + Intermediate</li>
<li>GCP Certificate Authority Service</li>
<li>Azure Key Vault Certificates</li>
</ul>
<p>適合：已在某朵雲、不想自管 CA infra、可接受 vendor lock。</p>
<h3 id="自管-ca-的最小工具鏈">自管 CA 的最小工具鏈</h3>
<p>如果走模式 A、推薦工具：</p>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>用途</th>
          <th>特性</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>step-ca</strong></td>
          <td>Lightweight CA server、支援 ACME</td>
          <td>Smallstep 開源、設定簡單</td>
      </tr>
      <tr>
          <td><strong>HashiCorp Vault PKI</strong></td>
          <td>Vault 內建 PKI engine</td>
          <td>整合 Vault 既有 secret 管理</td>
      </tr>
      <tr>
          <td><strong>cfssl</strong></td>
          <td>Cloudflare 的 CA toolkit</td>
          <td>CLI-based、適合 build pipeline</td>
      </tr>
      <tr>
          <td><strong>OpenSSL</strong></td>
          <td>純手工建 CA</td>
          <td>維運成本高、適合學習與小規模</td>
      </tr>
  </tbody>
</table>
<p><code>step-ca</code> 是最低門檻的起手選擇 — 一行 <code>step ca init</code> 建好整套 CA、自動發 ACME 給 client。</p>
<hr>
<h2 id="憑證生命週期">憑證生命週期</h2>
<h3 id="簽發">簽發</h3>
<p><strong>Server cert 簽發流程</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">1. Server 產生 private key (RSA 2048+ 或 ECDSA P-256)
</span></span><span class="line"><span class="ln">2</span><span class="cl">2. Server 用 private key 產生 CSR (Certificate Signing Request)
</span></span><span class="line"><span class="ln">3</span><span class="cl">3. CSR 送給 CA
</span></span><span class="line"><span class="ln">4</span><span class="cl">4. CA 驗證 CSR 內容（DN、SAN、用途）
</span></span><span class="line"><span class="ln">5</span><span class="cl">5. CA 用 Intermediate CA 私鑰簽 cert
</span></span><span class="line"><span class="ln">6</span><span class="cl">6. 把簽好的 cert 回給 server
</span></span><span class="line"><span class="ln">7</span><span class="cl">7. Server 部署 cert + 自己的 private key</span></span></code></pre></div><p><strong>Client cert 簽發流程</strong>：跟 server 一樣，但 SAN 通常是 client identifier（service name、device ID），而非 hostname。</p>
<h3 id="私鑰留在產生端">私鑰留在產生端</h3>
<p>關鍵安全原則是：<strong>private key 在哪產生、就只在那裡存活</strong>。CA 只收 CSR（裡面只有 public key），簽完 cert 回去；client private key 全程留在 client 的受控環境。</p>
<p><strong>失效模式</strong>：</p>
<ul>
<li>CA 幫 client 產生 keypair、把 private key 跟 cert 一起寄給 client（密鑰在 CA 經手了）</li>
<li>把 private key 跟 cert 打包成 PKCS12 用 email 寄</li>
<li>把 keypair 放進公共 git repo</li>
</ul>
<p><strong>操作路由</strong>：</p>
<ul>
<li>Client 端產生 keypair、只送 CSR 給 CA（CSR 只含 public key）、簽完 cert 回來、private key 全程不離開 client</li>
</ul>
<h3 id="儲存">儲存</h3>
<p>Private key 的儲存等級：</p>
<table>
  <thead>
      <tr>
          <th>方式</th>
          <th>安全等級</th>
          <th>適合</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Plain file（chmod 600）</td>
          <td>低</td>
          <td>dev / staging、無 HSM 的低風險環境</td>
      </tr>
      <tr>
          <td>OS keystore（Keychain / Windows Cert Store）</td>
          <td>中</td>
          <td>desktop client、laptop</td>
      </tr>
      <tr>
          <td>HSM（hardware security module）</td>
          <td>高</td>
          <td>金融、政府、私鑰永不離開硬體</td>
      </tr>
      <tr>
          <td>Cloud KMS（AWS KMS / GCP KMS）</td>
          <td>中-高</td>
          <td>cloud-native、private key 進 KMS、簽章用 API</td>
      </tr>
      <tr>
          <td>TPM / Secure Enclave</td>
          <td>高</td>
          <td>mobile / IoT、跟硬體綁定</td>
      </tr>
  </tbody>
</table>
<p>Production server cert 私鑰至少應該 OS 層保護（檔案權限 + 加密磁碟）、高敏感場景上 HSM。</p>
<h3 id="rotation">Rotation</h3>
<p>mTLS 憑證的 rotation 跟 <a href="/blog/work-log/shared-secret-%E5%AE%89%E5%85%A8%E8%BC%AA%E6%9B%BF%E8%A8%AD%E8%A8%88%E9%9B%99%E5%AF%86%E9%81%8E%E6%B8%A1%E6%9C%9F%E8%87%AA%E5%8B%95%E5%8C%96%E8%88%87%E7%B7%8A%E6%80%A5%E6%B5%81%E7%A8%8B/" data-link-title="Shared Secret 安全輪替設計：雙密過渡期、自動化與緊急流程" data-link-desc="系統間 Shared Secret 輪替的核心機制：dual-secret rollover、自動化工具比較（AWS Secrets Manager / Vault / GCP）、緊急 rotation 流程與多 client 環境的失敗模式。">shared secret rotation</a> 概念類似、但有具體差異：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Shared Secret</th>
          <th>mTLS Cert</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>過期機制</td>
          <td>沒有、要手動 rotate</td>
          <td>內建 <code>notBefore</code> / <code>notAfter</code>、自動過期</td>
      </tr>
      <tr>
          <td>雙密期</td>
          <td>兩把同時 valid</td>
          <td>過渡期 server 同時持有舊 cert（未過期）+ 新 cert（已簽發）、自動有效</td>
      </tr>
      <tr>
          <td>Rotation 觸發</td>
          <td>排程</td>
          <td>排程 + 過期前自動</td>
      </tr>
  </tbody>
</table>
<p>實務上的 rotation 模式：</p>
<p><strong>短 TTL + 自動續發（推薦）</strong>：</p>
<ul>
<li>Leaf cert TTL 設短（24 小時 ~ 7 天）</li>
<li>用 ACME protocol（如 Let&rsquo;s Encrypt 的協定）讓 client 自動續發</li>
<li>rotation 由續發流程承擔，過期前自動換新</li>
</ul>
<p>工具：<code>cert-manager</code>（K8s）、<code>step-ca</code> + <code>step</code>、<code>certbot</code>。</p>
<p><strong>中 TTL + 半自動（傳統）</strong>：</p>
<ul>
<li>TTL 1 年、年度手動 rotation</li>
<li>用工具列管所有 cert 的 <code>notAfter</code>、過期前 30 天自動告警</li>
<li>適合舊架構、無法跑短 TTL 的場景</li>
</ul>
<p><strong>長 TTL（不建議）</strong>：</p>
<ul>
<li>TTL 多年、近乎不 rotate</li>
<li>私鑰暴露窗極長、被洩漏到察覺的時間差大</li>
<li>唯一情境：IoT 設備、無法 OTA 更新</li>
</ul>
<h3 id="撤銷">撤銷</h3>
<p>當 cert 在 <code>notAfter</code> 前需要失效（私鑰洩漏、員工離職、合約終止）、需要撤銷機制。有三種主流方案：</p>
<h4 id="crlcertificate-revocation-list">CRL（Certificate Revocation List）</h4>
<p>CA 維護一份「<strong>已撤銷憑證 list</strong>」、定期發佈（小時級到天級）。Client 端要：</p>
<ol>
<li>下載最新 CRL</li>
<li>連線時檢查對方 cert 是否在 CRL 內</li>
</ol>
<p><strong>優點</strong>：簡單、infrastructure 輕。</p>
<p><strong>缺點</strong>：</p>
<ul>
<li>CRL 大、下載成本高</li>
<li>Cache 期內撤銷不生效（最差幾小時）</li>
<li>Client 沒下載 CRL、撤銷完全沒效</li>
</ul>
<h4 id="ocsponline-certificate-status-protocol">OCSP（Online Certificate Status Protocol）</h4>
<p>Real-time 查詢、client 每次連線時即時 query OCSP responder：「<strong>這張 cert 還有效嗎？</strong>」</p>
<p><strong>優點</strong>：Real-time、撤銷即時生效。</p>
<p><strong>缺點</strong>：</p>
<ul>
<li>每次連線增加一次 OCSP query、延遲</li>
<li>OCSP responder 是 single point of failure</li>
<li>Privacy 顧慮（每次連線都告訴 CA 你在連誰）</li>
</ul>
<p>進階：<strong>OCSP Stapling</strong> — server 預先 query OCSP、把結果 staple 在自己的 cert chain 裡、client 不需自己 query。解決延遲跟 privacy、但 server 端要實作。</p>
<h4 id="short-lived-cert不撤銷讓它過期">Short-lived cert（不撤銷、讓它過期）</h4>
<p>最現代的做法：<strong>cert TTL 極短（小時、甚至分鐘）、不實作撤銷機制、靠過期自然失效</strong>。</p>
<p><strong>優點</strong>：</p>
<ul>
<li>可省略 CRL / OCSP infrastructure</li>
<li>撤銷窗 = TTL（小時級）、可預期</li>
<li>Privacy 友善</li>
</ul>
<p><strong>缺點</strong>：</p>
<ul>
<li>需要可靠的自動續發機制</li>
<li>Client 無法續發時直接斷線</li>
</ul>
<p>工具：<code>SPIFFE</code>/<code>SPIRE</code> 主推這個模式、cert TTL 設小時級。</p>
<h3 id="三種撤銷方案的選擇">三種撤銷方案的選擇</h3>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>推薦撤銷方案</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>傳統 enterprise、架構變動成本高</td>
          <td>CRL（最低門檻）</td>
      </tr>
      <tr>
          <td>公開 HTTPS、需要 real-time 撤銷</td>
          <td>OCSP Stapling</td>
      </tr>
      <tr>
          <td>Cloud-native、有自動續發 infra</td>
          <td>Short-lived cert</td>
      </tr>
      <tr>
          <td>內部 service mesh</td>
          <td>Short-lived cert（mesh 自動）</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="基礎設施整合">基礎設施整合</h2>
<h3 id="nginx-設定-mtls-server">nginx 設定 mTLS server</h3>
<p>最常見的場景：nginx 當 reverse proxy、要求 client 出示憑證。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">server</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="kn">listen</span> <span class="mi">443</span> <span class="s">ssl</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="kn">server_name</span> <span class="s">api.example.com</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="c1"># Server cert (出示給 client)
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span>    <span class="kn">ssl_certificate</span>     <span class="s">/etc/ssl/certs/api.crt</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="kn">ssl_certificate_key</span> <span class="s">/etc/ssl/private/api.key</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="c1"># 要求 client 出示憑證、用這個 CA 驗證
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span>    <span class="kn">ssl_client_certificate</span> <span class="s">/etc/ssl/ca/client-ca-chain.pem</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="kn">ssl_verify_client</span> <span class="no">on</span><span class="p">;</span>            <span class="c1"># 強制 client 出示憑證、否則拒絕
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"></span>    <span class="kn">ssl_verify_depth</span> <span class="mi">2</span><span class="p">;</span>              <span class="c1"># 驗證 chain 深度、視 PKI 階層調 (Root → Intermediate → Leaf)
</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="kn">location</span> <span class="s">/</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="c1"># 把 client cert 資訊傳給後端 application
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="c1"></span>        <span class="kn">proxy_set_header</span> <span class="s">X-Client-DN</span>  <span class="nv">$ssl_client_s_dn</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="kn">proxy_set_header</span> <span class="s">X-Client-Verify</span> <span class="nv">$ssl_client_verify</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="kn">proxy_pass</span> <span class="s">http://backend</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></code></pre></div><p>關鍵 directive：</p>
<table>
  <thead>
      <tr>
          <th>Directive</th>
          <th>作用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>ssl_client_certificate</code></td>
          <td>信任的 CA chain</td>
      </tr>
      <tr>
          <td><code>ssl_verify_client on</code></td>
          <td>強制 client 出示憑證、<code>optional</code> 則彈性接受</td>
      </tr>
      <tr>
          <td><code>ssl_verify_depth</code></td>
          <td>chain 驗證深度、根據 PKI 階層調</td>
      </tr>
      <tr>
          <td><code>$ssl_client_s_dn</code></td>
          <td>傳 client cert 的 subject DN 給 backend</td>
      </tr>
  </tbody>
</table>
<h3 id="nginx-設定-mtls-client呼叫上游">nginx 設定 mTLS client（呼叫上游）</h3>
<p>當 nginx 是 client、要呼叫上游 mTLS server：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">location</span> <span class="s">/upstream</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="kn">proxy_pass</span> <span class="s">https://upstream.example.com</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="kn">proxy_ssl_certificate</span>     <span class="s">/etc/ssl/certs/client.crt</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="kn">proxy_ssl_certificate_key</span> <span class="s">/etc/ssl/private/client.key</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="kn">proxy_ssl_trusted_certificate</span> <span class="s">/etc/ssl/ca/upstream-ca.pem</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="kn">proxy_ssl_verify</span> <span class="no">on</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><h3 id="envoy--api-gateway-整合">Envoy / API Gateway 整合</h3>
<p>Envoy 是 service mesh 的常見 data plane、mTLS 設定模式：</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="nt">listeners</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">api_listener</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">  </span><span class="nt">address</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">socket_address</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">port_value</span><span class="p">:</span><span class="w"> </span><span class="m">443</span><span class="w"> </span>}<span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">  </span><span class="nt">filter_chains</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">  </span>- <span class="nt">transport_socket</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">      </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">envoy.transport_sockets.tls</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">      </span><span class="nt">typed_config</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">        </span><span class="nt">&#34;@type&#34;: </span><span class="l">type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">        </span><span class="nt">common_tls_context</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">          </span><span class="nt">tls_certificates</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">          </span>- <span class="nt">certificate_chain</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">filename</span><span class="p">:</span><span class="w"> </span><span class="l">/etc/ssl/api.crt }</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">            </span><span class="nt">private_key</span><span class="p">:</span><span class="w">      </span>{<span class="w"> </span><span class="nt">filename</span><span class="p">:</span><span class="w"> </span><span class="l">/etc/ssl/api.key }</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">          </span><span class="nt">validation_context</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">            </span><span class="nt">trusted_ca</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">filename</span><span class="p">:</span><span class="w"> </span><span class="l">/etc/ssl/client-ca.pem }</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">        </span><span class="nt">require_client_certificate</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span></span></span></code></pre></div><blockquote>
<p>上方只展 inbound listener 的 <code>DownstreamTlsContext</code>。Envoy 作為 client 呼叫上游 mTLS server 時、要在對應的 cluster 配 <code>transport_socket</code> + <code>UpstreamTlsContext</code>（含 client cert + private key + trusted CA）、不在這份 listener 設定裡。</p></blockquote>
<p>跟 nginx 比、Envoy 的優勢：</p>
<ul>
<li>動態設定（xDS API、不需 reload）</li>
<li>支援 SDS（Secret Discovery Service）動態取憑證</li>
<li>跟 Istio / Linkerd 等 mesh 整合</li>
</ul>
<h3 id="service-meshistio--linkerd">Service Mesh（Istio / Linkerd）</h3>
<p>Service mesh 內建 mTLS：</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"># Istio: 強制 mesh 內所有 service 走 mTLS</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">security.istio.io/v1beta1</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">PeerAuthentication</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">default</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w">  </span><span class="nt">namespace</span><span class="p">:</span><span class="w"> </span><span class="l">production</span><span class="w">
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="w"></span><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="w">  </span><span class="nt">mtls</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="w">    </span><span class="nt">mode</span><span class="p">:</span><span class="w"> </span><span class="l">STRICT</span></span></span></code></pre></div><p>機制：</p>
<ul>
<li>Mesh control plane（Istio: Istiod / Linkerd: identity）內建 CA、自動發每個 pod 一張 cert</li>
<li>Sidecar proxy（Envoy / Linkerd proxy）handle TLS termination、application code 完全不感</li>
<li>Cert TTL 短（Istio 預設 24 小時、視版本而定）、自動續發</li>
<li>mTLS identity 綁定 K8s ServiceAccount</li>
</ul>
<p>優點：<strong>application 完全不用改 code、不用管 cert、不用管 rotation</strong> — mesh 全包。</p>
<p>缺點：<strong>綁定整套 mesh 架構</strong>、運維 mesh 本身是大事、學習曲線陡。</p>
<h3 id="為-application-直接做-mtls">為 application 直接做 mTLS</h3>
<p>某些場景（沒 mesh、需要 application 級控制）需要 application 直接做 mTLS：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># Python requests 範例 - mTLS client</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kn">import</span> <span class="nn">requests</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">response</span> <span class="o">=</span> <span class="n">requests</span><span class="o">.</span><span class="n">get</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="s1">&#39;https://api.example.com/data&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="n">cert</span><span class="o">=</span><span class="p">(</span><span class="s1">&#39;/path/to/client.crt&#39;</span><span class="p">,</span> <span class="s1">&#39;/path/to/client.key&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="n">verify</span><span class="o">=</span><span class="s1">&#39;/path/to/server-ca.pem&#39;</span><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>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// Go net/http 範例 - mTLS client</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="nx">cert</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">tls</span><span class="p">.</span><span class="nf">LoadX509KeyPair</span><span class="p">(</span><span class="s">&#34;client.crt&#34;</span><span class="p">,</span> <span class="s">&#34;client.key&#34;</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="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span> <span class="k">return</span> <span class="nx">err</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="nx">caCert</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">os</span><span class="p">.</span><span class="nf">ReadFile</span><span class="p">(</span><span class="s">&#34;server-ca.pem&#34;</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="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span> <span class="k">return</span> <span class="nx">err</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="nx">caCertPool</span> <span class="o">:=</span> <span class="nx">x509</span><span class="p">.</span><span class="nf">NewCertPool</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="nx">caCertPool</span><span class="p">.</span><span class="nf">AppendCertsFromPEM</span><span class="p">(</span><span class="nx">caCert</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="nx">client</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">http</span><span class="p">.</span><span class="nx">Client</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">Transport</span><span class="p">:</span> <span class="o">&amp;</span><span class="nx">http</span><span class="p">.</span><span class="nx">Transport</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">TLSClientConfig</span><span class="p">:</span> <span class="o">&amp;</span><span class="nx">tls</span><span class="p">.</span><span class="nx">Config</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="nx">Certificates</span><span class="p">:</span> <span class="p">[]</span><span class="nx">tls</span><span class="p">.</span><span class="nx">Certificate</span><span class="p">{</span><span class="nx">cert</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">            <span class="nx">RootCAs</span><span class="p">:</span>      <span class="nx">caCertPool</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><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="nx">resp</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">client</span><span class="p">.</span><span class="nf">Get</span><span class="p">(</span><span class="s">&#34;https://api.example.com/data&#34;</span><span class="p">)</span></span></span></code></pre></div><p>每個語言的 stdlib 都有對應 API、寫法大同小異。但 application 要自己處理 cert reload、過期、rotation — 比 service mesh 麻煩很多。</p>
<hr>
<h2 id="跟其他-layer-2-方案的成本比較">跟其他 Layer 2 方案的成本比較</h2>
<p>mTLS 在三層信任邊界的 Layer 2 是安全強度高、運維責任也重的選項。是否採用，要看威脅模型、合規要求、私鑰保護能力與自動化成熟度。</p>
<table>
  <thead>
      <tr>
          <th>方案</th>
          <th>安全等級</th>
          <th>運維成本</th>
          <th>適合</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Shared Secret</strong></td>
          <td>低-中</td>
          <td>低</td>
          <td>純內部、低風險</td>
      </tr>
      <tr>
          <td><strong>API Key + HTTPS</strong></td>
          <td>中</td>
          <td>低</td>
          <td>一般 SaaS、對外 API</td>
      </tr>
      <tr>
          <td><strong>HMAC 簽章</strong></td>
          <td>中-高</td>
          <td>中</td>
          <td>需防 replay / tampering</td>
      </tr>
      <tr>
          <td><strong>OAuth Client Credentials</strong></td>
          <td>中-高</td>
          <td>中</td>
          <td>跨組織、需 short-lived token</td>
      </tr>
      <tr>
          <td><strong>mTLS</strong></td>
          <td>高</td>
          <td>高</td>
          <td>合規、零信任、私鑰可硬體保護</td>
      </tr>
  </tbody>
</table>
<h3 id="mtls-適合的場景">mTLS 適合的場景</h3>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>為什麼 mTLS 適合</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>金融、醫療、政府合規要求</td>
          <td>合規條款直接要求 mTLS</td>
      </tr>
      <tr>
          <td>零信任網路（zero-trust）</td>
          <td>網路不可信、每個 hop 都要驗身分</td>
      </tr>
      <tr>
          <td>內部 service mesh（K8s + Istio）</td>
          <td>Mesh 自動處理、邊際成本低</td>
      </tr>
      <tr>
          <td>私鑰能放硬體（HSM / TPM / Secure Enclave）</td>
          <td>比 API Key 強得多</td>
      </tr>
      <tr>
          <td>高頻 service-to-service、API Key rotation 痛苦</td>
          <td>短 TTL cert 自動續發、不用人介入</td>
      </tr>
  </tbody>
</table>
<h3 id="mtls-成本偏高的場景">mTLS 成本偏高的場景</h3>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>成本偏高的原因</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>對外開放給第三方 SDK</td>
          <td>第三方管理 cert 的門檻高、API Key + HTTPS 較易落地</td>
      </tr>
      <tr>
          <td>小規模、運維資源少</td>
          <td>PKI infra 維護成本超過安全增益</td>
      </tr>
      <tr>
          <td>純內部、不需強身分隔離</td>
          <td>Shared secret 已經夠用</td>
      </tr>
      <tr>
          <td>大量短連線 client（mobile app）</td>
          <td>Cert 散佈跟 rotation 複雜度高</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="常見失敗模式">常見失敗模式</h2>
<h3 id="失敗-1忘記-intermediate-cachain-不完整">失敗 1：忘記 Intermediate CA、chain 不完整</h3>
<p><strong>症狀</strong>：server 設定看似正確、但 client 連線時報 <code>certificate verify failed</code>。</p>
<p><strong>根因</strong>：server 端只放了 leaf cert、沒附 Intermediate CA。Client 端只信任 Root、無法 chain 到 Root。</p>
<p><strong>緩解</strong>：server 端 <code>ssl_certificate</code> 要放<strong>完整 chain</strong>（leaf + intermediate、不含 root）：</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 leaf.crt intermediate.crt &gt; chain.crt
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># nginx 用 chain.crt 而非單獨 leaf.crt</span></span></span></code></pre></div><h3 id="失敗-2cert-過期造成連線中斷">失敗 2：Cert 過期造成連線中斷</h3>
<p><strong>症狀</strong>：cert <code>notAfter</code> 過了、所有 client 突然連不上。</p>
<p><strong>緩解</strong>：</p>
<ul>
<li>監控 cert 過期時間、提前 30 天告警、提前 7 天緊急告警</li>
<li>用自動續發機制（cert-manager / step-ca / ACME）</li>
<li>過期防護應由系統監控與自動續發承擔，而不是依賴人工記憶</li>
</ul>
<h3 id="失敗-3私鑰權限過寬被同機其他-user-讀走">失敗 3：私鑰權限過寬、被同機其他 user 讀走</h3>
<p><strong>症狀</strong>：security audit 發現 <code>/etc/ssl/private/server.key</code> 是 644、所有 user 可讀。</p>
<p><strong>緩解</strong>：</p>
<ul>
<li>Private key 一律 <code>chmod 600</code>、owner <code>root</code> 或 application user</li>
<li>用 systemd 跑的 service、private key 放 <code>LoadCredential=</code> 而非 file path</li>
<li>定期 audit <code>/etc/ssl/</code> 權限</li>
</ul>
<h3 id="失敗-4撤銷後-cert-仍能用">失敗 4：撤銷後 cert 仍能用</h3>
<p><strong>症狀</strong>：cert 撤銷了、但 client 還能連上。</p>
<p><strong>根因</strong>：</p>
<ul>
<li>CRL 設定但 server 沒 enable CRL check</li>
<li>OCSP 設定但 client 沒 query</li>
<li>用 short-lived cert 但 TTL 太長、撤銷窗不可接受</li>
</ul>
<p><strong>緩解</strong>：撤銷機制要<strong>端到端測試</strong>、不只「設定上有」、要驗證「實際生效」。</p>
<h3 id="失敗-5service-mesh-upgrade-後-mtls-中斷">失敗 5：Service mesh upgrade 後 mTLS 中斷</h3>
<p><strong>症狀</strong>：Istio 升級後、cluster 內部分 service 互相連不上。</p>
<p><strong>根因</strong>：mesh control plane 的 CA 換了、舊 cert chain 不通。</p>
<p><strong>緩解</strong>：</p>
<ul>
<li>Mesh upgrade 走 staged rollout，分批驗證 cert chain</li>
<li>Mesh 提供的 CA migration 流程要完整執行</li>
<li>Staging 環境先跑升級流程</li>
</ul>
<hr>
<h2 id="收尾">收尾</h2>
<p>mTLS 是「<strong>用 PKI 換掉 secret 管理</strong>」的設計 — 私鑰不離 client、身分綁在 X.509 cert 上、不依賴可重用的字串。安全等級高、但代價是要建立 CA infrastructure、處理 cert 生命週期、整合到各種基礎設施。</p>
<p>幾個核心判斷：</p>
<ol>
<li><strong>CA 分層是基本盤</strong> — Root + Intermediate + Leaf，讓最高信任根維持低暴露</li>
<li><strong>私鑰留在產生端</strong> — CA 只簽 CSR、不碰 private key</li>
<li><strong>撤銷方案要實證可用</strong> — CRL / OCSP / Short-lived 三選一，並驗證實際生效</li>
<li><strong>Service mesh 是 cloud-native 的低成本入口</strong> — Istio / Linkerd 把 mTLS 變成基礎設施，application 改動較小</li>
<li><strong>mTLS 是高責任方案</strong> — 對外開放 API、小規模、無 mesh 場景，OAuth / API Key 往往更容易維運</li>
</ol>
<p>延伸閱讀：</p>
<ul>
<li><a href="/blog/work-log/api-%E8%AA%8D%E8%AD%89%E7%9A%84%E4%B8%89%E5%B1%A4%E4%BF%A1%E4%BB%BB%E9%82%8A%E7%95%8C%E4%BD%BF%E7%94%A8%E8%80%85%E7%B3%BB%E7%B5%B1%E8%B7%A8%E7%B3%BB%E7%B5%B1-provisioning/" data-link-title="API 認證的三層信任邊界：使用者、系統、跨系統 Provisioning" data-link-desc="API 認證的信任邊界分層（Bearer Token / Shared Secret / Provisioning）：各層的洩漏後果與撤銷方式，以及混用造成的設計失效模式。">API 認證的三層信任邊界</a> — 本文的主篇、mTLS 在「Layer 2 系統層」的位置</li>
<li><a href="/blog/work-log/shared-secret-%E5%AE%89%E5%85%A8%E8%BC%AA%E6%9B%BF%E8%A8%AD%E8%A8%88%E9%9B%99%E5%AF%86%E9%81%8E%E6%B8%A1%E6%9C%9F%E8%87%AA%E5%8B%95%E5%8C%96%E8%88%87%E7%B7%8A%E6%80%A5%E6%B5%81%E7%A8%8B/" data-link-title="Shared Secret 安全輪替設計：雙密過渡期、自動化與緊急流程" data-link-desc="系統間 Shared Secret 輪替的核心機制：dual-secret rollover、自動化工具比較（AWS Secrets Manager / Vault / GCP）、緊急 rotation 流程與多 client 環境的失敗模式。">Shared Secret 安全輪替設計</a> — 不用 mTLS 走 secret-based 認證的對應 lifecycle 問題</li>
<li><a href="/blog/work-log/laravel-sanctum-%E7%9A%84-bearer-token-%E8%A8%AD%E8%A8%88%E5%89%96%E6%9E%90pksecret-%E7%82%BA%E4%BB%80%E9%BA%BC%E9%80%99%E6%A8%A3%E8%A8%AD%E8%A8%88/" data-link-title="Laravel Sanctum 的 Bearer Token 設計剖析：{PK}|{secret} 為什麼這樣設計" data-link-desc="Laravel Sanctum `{PK}|{secret}` 格式的設計理由、hash 儲存取捨、constant-time 比對位置，以及跟 GitHub PAT、Stripe API Key 的差異。">Laravel Sanctum 的 Bearer Token 設計剖析</a> — Layer 1 使用者層的 token 機制、跟 mTLS 解的問題不同</li>
</ul>
]]></content:encoded></item><item><title>Shared Secret 安全輪替設計：雙密過渡期、自動化與緊急流程</title><link>https://tarrragon.github.io/blog/work-log/shared-secret-%E5%AE%89%E5%85%A8%E8%BC%AA%E6%9B%BF%E8%A8%AD%E8%A8%88%E9%9B%99%E5%AF%86%E9%81%8E%E6%B8%A1%E6%9C%9F%E8%87%AA%E5%8B%95%E5%8C%96%E8%88%87%E7%B7%8A%E6%80%A5%E6%B5%81%E7%A8%8B/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/shared-secret-%E5%AE%89%E5%85%A8%E8%BC%AA%E6%9B%BF%E8%A8%AD%E8%A8%88%E9%9B%99%E5%AF%86%E9%81%8E%E6%B8%A1%E6%9C%9F%E8%87%AA%E5%8B%95%E5%8C%96%E8%88%87%E7%B7%8A%E6%80%A5%E6%B5%81%E7%A8%8B/</guid><description>&lt;h2 id="shared-secret-rotation-這篇要解決什麼">Shared Secret Rotation 這篇要解決什麼&lt;/h2>
&lt;p>Shared Secret rotation 的核心責任是讓 credential 換新時維持可用性、可追蹤性與可撤銷性。它表面上像是一行 SQL update，實際上牽涉 server 與多個 client 的切換時序：&lt;/p>
&lt;ul>
&lt;li>兩邊不同時切、就斷線&lt;/li>
&lt;li>多 client 場景下、總有一兩個沒升級&lt;/li>
&lt;li>緊急洩漏要立即撤換、同時控制服務中斷範圍&lt;/li>
&lt;li>Rotation 中途失敗、舊新 secret 都不通&lt;/li>
&lt;/ul>
&lt;p>這些是維運層的真實痛點。只說「定期 rotate your secret」只能描述目標，還需要雙密期、測試、監控、通知與回退流程，才能把 rotation 變成可執行的操作契約。&lt;/p>
&lt;p>本文聚焦三件事：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>雙密過渡期&lt;/strong>：怎麼讓 client 可以在任意時點切換、不會斷線&lt;/li>
&lt;li>&lt;strong>自動化工具&lt;/strong>：AWS Secrets Manager / HashiCorp Vault / GCP Secret Manager 各自的 rotation 機制&lt;/li>
&lt;li>&lt;strong>緊急 vs 定期&lt;/strong>：兩種流程的差異、何時用哪個&lt;/li>
&lt;/ol>
&lt;blockquote>
&lt;p>&lt;strong>本文位置&lt;/strong>：本文是 &lt;a href="https://tarrragon.github.io/blog/work-log/api-%E8%AA%8D%E8%AD%89%E7%9A%84%E4%B8%89%E5%B1%A4%E4%BF%A1%E4%BB%BB%E9%82%8A%E7%95%8C%E4%BD%BF%E7%94%A8%E8%80%85%E7%B3%BB%E7%B5%B1%E8%B7%A8%E7%B3%BB%E7%B5%B1-provisioning/" data-link-title="API 認證的三層信任邊界：使用者、系統、跨系統 Provisioning" data-link-desc="API 認證的信任邊界分層（Bearer Token / Shared Secret / Provisioning）：各層的洩漏後果與撤銷方式，以及混用造成的設計失效模式。">API 認證的三層信任邊界&lt;/a> Layer 2 的深入篇。主文聚焦「為什麼系統間要獨立 credential」、本文聚焦「Shared Secret 輪替的工程實務」。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="rotation-解決什麼威脅">Rotation 解決什麼威脅&lt;/h2>
&lt;p>Rotation 是縮短 secret 暴露窗與清理殘留 access 的 lifecycle 控制。它降低三種具體威脅：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>威脅&lt;/th>
 &lt;th>Rotation 怎麼緩解&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;strong>未察覺的洩漏&lt;/strong>&lt;/td>
 &lt;td>Secret 可能已被外洩、定期換能限制攻擊者使用的時間窗&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>離職員工殘留 access&lt;/strong>&lt;/td>
 &lt;td>員工離職後系統 access 沒撤徹底、rotation 把該員工知道的 secret 變廢&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>長期暴露的 metadata&lt;/strong>&lt;/td>
 &lt;td>Secret 越久、log / backup / git history 留存的副本越多&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Rotation 本身有成本與風險，切換設計不完整時會造成斷線。所以實務目標是「在切換可控的前提下，選一個能接受的頻率」。&lt;/p>
&lt;p>常見定期頻率：&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>一般 SaaS&lt;/td>
 &lt;td>90 天 / 180 天&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>金融、醫療&lt;/td>
 &lt;td>30 天 / 90 天&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>高敏感（國防、政府）&lt;/td>
 &lt;td>7 天 / 14 天、或事件觸發&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>純內部、低風險&lt;/td>
 &lt;td>半年 / 一年、或永不 rotate&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;blockquote>
&lt;p>&lt;strong>頻率取決於威脅模型與操作能力&lt;/strong>：NIST SP 800-63B 對多數場景認可 30-90 天足夠、過於激進的 rotation 反而提高出錯機率。7-14 天適用於合規條款明文要求或私鑰可硬體保護的場景；多數 SaaS 可以停在 30-180 天區間。&lt;/p>&lt;/blockquote>
&lt;p>「事件觸發才換」也有合理情境。純內部 cron job、secret 外流管道少、rotation 成本大於風險時，可以選擇以事件觸發取代固定排程；重點是留下 owner、inventory 與重新評估條件。&lt;/p>
&lt;hr>
&lt;h2 id="核心機制雙密過渡期dual-secret-rollover">核心機制：雙密過渡期（Dual-secret Rollover）&lt;/h2>
&lt;h3 id="直接-atomic-切換的失效點">直接 atomic 切換的失效點&lt;/h3>
&lt;p>最直覺的 rotation 流程是：&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">T0: 兩邊都是 secret_v1
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">T1: server 端換成 secret_v2
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">T2: client 端換成 secret_v2&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>失效點出在 T1 到 T2 之間：server 只認 v2，但 client 還在用 v1，這段窗口內的 request 都會 401。即使窗口只有幾秒，production 流量也可能產生大量錯誤。&lt;/p>
&lt;p>更糟的是「client 更新後忘了 reload process」這種情境 — 配置檔已改、但跑著的 server / worker process 還握著舊 secret 在記憶體裡、直到下次重啟才生效。窗口可能拉長到幾分鐘到幾小時。&lt;/p></description><content:encoded><![CDATA[<h2 id="shared-secret-rotation-這篇要解決什麼">Shared Secret Rotation 這篇要解決什麼</h2>
<p>Shared Secret rotation 的核心責任是讓 credential 換新時維持可用性、可追蹤性與可撤銷性。它表面上像是一行 SQL update，實際上牽涉 server 與多個 client 的切換時序：</p>
<ul>
<li>兩邊不同時切、就斷線</li>
<li>多 client 場景下、總有一兩個沒升級</li>
<li>緊急洩漏要立即撤換、同時控制服務中斷範圍</li>
<li>Rotation 中途失敗、舊新 secret 都不通</li>
</ul>
<p>這些是維運層的真實痛點。只說「定期 rotate your secret」只能描述目標，還需要雙密期、測試、監控、通知與回退流程，才能把 rotation 變成可執行的操作契約。</p>
<p>本文聚焦三件事：</p>
<ol>
<li><strong>雙密過渡期</strong>：怎麼讓 client 可以在任意時點切換、不會斷線</li>
<li><strong>自動化工具</strong>：AWS Secrets Manager / HashiCorp Vault / GCP Secret Manager 各自的 rotation 機制</li>
<li><strong>緊急 vs 定期</strong>：兩種流程的差異、何時用哪個</li>
</ol>
<blockquote>
<p><strong>本文位置</strong>：本文是 <a href="/blog/work-log/api-%E8%AA%8D%E8%AD%89%E7%9A%84%E4%B8%89%E5%B1%A4%E4%BF%A1%E4%BB%BB%E9%82%8A%E7%95%8C%E4%BD%BF%E7%94%A8%E8%80%85%E7%B3%BB%E7%B5%B1%E8%B7%A8%E7%B3%BB%E7%B5%B1-provisioning/" data-link-title="API 認證的三層信任邊界：使用者、系統、跨系統 Provisioning" data-link-desc="API 認證的信任邊界分層（Bearer Token / Shared Secret / Provisioning）：各層的洩漏後果與撤銷方式，以及混用造成的設計失效模式。">API 認證的三層信任邊界</a> Layer 2 的深入篇。主文聚焦「為什麼系統間要獨立 credential」、本文聚焦「Shared Secret 輪替的工程實務」。</p></blockquote>
<hr>
<h2 id="rotation-解決什麼威脅">Rotation 解決什麼威脅</h2>
<p>Rotation 是縮短 secret 暴露窗與清理殘留 access 的 lifecycle 控制。它降低三種具體威脅：</p>
<table>
  <thead>
      <tr>
          <th>威脅</th>
          <th>Rotation 怎麼緩解</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>未察覺的洩漏</strong></td>
          <td>Secret 可能已被外洩、定期換能限制攻擊者使用的時間窗</td>
      </tr>
      <tr>
          <td><strong>離職員工殘留 access</strong></td>
          <td>員工離職後系統 access 沒撤徹底、rotation 把該員工知道的 secret 變廢</td>
      </tr>
      <tr>
          <td><strong>長期暴露的 metadata</strong></td>
          <td>Secret 越久、log / backup / git history 留存的副本越多</td>
      </tr>
  </tbody>
</table>
<p>Rotation 本身有成本與風險，切換設計不完整時會造成斷線。所以實務目標是「在切換可控的前提下，選一個能接受的頻率」。</p>
<p>常見定期頻率：</p>
<table>
  <thead>
      <tr>
          <th>業界場景</th>
          <th>典型頻率</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>一般 SaaS</td>
          <td>90 天 / 180 天</td>
      </tr>
      <tr>
          <td>金融、醫療</td>
          <td>30 天 / 90 天</td>
      </tr>
      <tr>
          <td>高敏感（國防、政府）</td>
          <td>7 天 / 14 天、或事件觸發</td>
      </tr>
      <tr>
          <td>純內部、低風險</td>
          <td>半年 / 一年、或永不 rotate</td>
      </tr>
  </tbody>
</table>
<blockquote>
<p><strong>頻率取決於威脅模型與操作能力</strong>：NIST SP 800-63B 對多數場景認可 30-90 天足夠、過於激進的 rotation 反而提高出錯機率。7-14 天適用於合規條款明文要求或私鑰可硬體保護的場景；多數 SaaS 可以停在 30-180 天區間。</p></blockquote>
<p>「事件觸發才換」也有合理情境。純內部 cron job、secret 外流管道少、rotation 成本大於風險時，可以選擇以事件觸發取代固定排程；重點是留下 owner、inventory 與重新評估條件。</p>
<hr>
<h2 id="核心機制雙密過渡期dual-secret-rollover">核心機制：雙密過渡期（Dual-secret Rollover）</h2>
<h3 id="直接-atomic-切換的失效點">直接 atomic 切換的失效點</h3>
<p>最直覺的 rotation 流程是：</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">T0: 兩邊都是 secret_v1
</span></span><span class="line"><span class="ln">2</span><span class="cl">T1: server 端換成 secret_v2
</span></span><span class="line"><span class="ln">3</span><span class="cl">T2: client 端換成 secret_v2</span></span></code></pre></div><p>失效點出在 T1 到 T2 之間：server 只認 v2，但 client 還在用 v1，這段窗口內的 request 都會 401。即使窗口只有幾秒，production 流量也可能產生大量錯誤。</p>
<p>更糟的是「client 更新後忘了 reload process」這種情境 — 配置檔已改、但跑著的 server / worker process 還握著舊 secret 在記憶體裡、直到下次重啟才生效。窗口可能拉長到幾分鐘到幾小時。</p>
<h3 id="解法server-端同時接受新舊兩把">解法：server 端同時接受新舊兩把</h3>
<p>雙密過渡期把 rotation 分成 3 個階段：</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">T0：穩態
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  server: [v1]
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  client: [v1]
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  狀態：v1 工作
</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">T1：發新 secret、server 雙密期開始
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  server: [v1, v2]   ← server 同時接受 v1 跟 v2
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  client: [v1]
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  狀態：兩個都 work、client 還沒切
</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">T2：通知 client 切到 v2
</span></span><span class="line"><span class="ln">12</span><span class="cl">  server: [v1, v2]
</span></span><span class="line"><span class="ln">13</span><span class="cl">  client: [v2]       ← client 升級、開始用 v2
</span></span><span class="line"><span class="ln">14</span><span class="cl">  狀態：v2 work、v1 也仍 work（過渡期）
</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">T3：確認所有 client 都切完、關閉 v1
</span></span><span class="line"><span class="ln">17</span><span class="cl">  server: [v2]       ← 移除 v1
</span></span><span class="line"><span class="ln">18</span><span class="cl">  client: [v2]
</span></span><span class="line"><span class="ln">19</span><span class="cl">  狀態：穩態、只 v1 失效</span></span></code></pre></div><p>關鍵在於 <strong>server 在 T1-T3 之間同時接受兩把</strong> — 不論 client 在這段期間用哪一把都能通過驗證。client 可以在自己的時程內升級、不需要跟 server 切換同步。</p>
<h3 id="雙密期的長度設計">雙密期的長度設計</h3>
<p>雙密期是一個可用性與暴露窗的取捨。兩把同時有效時，系統需要同時保護兩把 secret，也需要追蹤兩個版本的使用比例；時間拉太短會造成 client 來不及切換，時間拉太長會擴大舊 secret 的有效窗口。</p>
<p>設計建議：</p>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>雙密期長度建議</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>純內部、可強制升級</td>
          <td>24-48 小時</td>
      </tr>
      <tr>
          <td>對外 client、需要溝通</td>
          <td>7-14 天</td>
      </tr>
      <tr>
          <td>大量第三方整合</td>
          <td>30-90 天 + 多次提醒</td>
      </tr>
      <tr>
          <td>緊急 rotation（已洩漏）</td>
          <td>盡量縮短、視覆蓋速度而定</td>
      </tr>
  </tbody>
</table>
<p>監控指標：在雙密期內、應該追蹤「用 v1 vs 用 v2 的 request 比例」 — 當 v1 比例降到 0%、且持續穩定一段時間後、才安全地關閉 v1。</p>
<h3 id="怎麼實作同時接受兩把">怎麼實作「同時接受兩把」</h3>
<p>實作模式有兩種：</p>
<h4 id="模式-aarray-比對">模式 A：array 比對</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">VALID_SECRETS</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="n">os</span><span class="o">.</span><span class="n">environ</span><span class="p">[</span><span class="s1">&#39;SHARED_SECRET_CURRENT&#39;</span><span class="p">],</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="n">os</span><span class="o">.</span><span class="n">environ</span><span class="p">[</span><span class="s1">&#39;SHARED_SECRET_PREVIOUS&#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="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="k">def</span> <span class="nf">verify</span><span class="p">(</span><span class="n">received</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">for</span> <span class="n">secret</span> <span class="ow">in</span> <span class="n">VALID_SECRETS</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="ow">not</span> <span class="n">secret</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="k">continue</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">if</span> <span class="n">hmac</span><span class="o">.</span><span class="n">compare_digest</span><span class="p">(</span><span class="n">secret</span><span class="p">,</span> <span class="n">received</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="k">return</span> <span class="kc">True</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">return</span> <span class="kc">False</span></span></span></code></pre></div><p>這個模式適合內部固定夥伴與少量服務，因為驗證邏輯簡單、沒有額外狀態。主要風險是兩把 secret 都要部署到 server，env var / config 變多，且每個 instance 都要確認讀到相同版本。</p>
<h4 id="模式-bsecret-store--version">模式 B：secret store + version</h4>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">def</span> <span class="nf">verify</span><span class="p">(</span><span class="n">received</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="n">current_version</span> <span class="o">=</span> <span class="n">secret_store</span><span class="o">.</span><span class="n">get_version</span><span class="p">(</span><span class="s1">&#39;shared_secret&#39;</span><span class="p">,</span> <span class="s1">&#39;current&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="n">previous_version</span> <span class="o">=</span> <span class="n">secret_store</span><span class="o">.</span><span class="n">get_version</span><span class="p">(</span><span class="s1">&#39;shared_secret&#39;</span><span class="p">,</span> <span class="s1">&#39;previous&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="k">return</span> <span class="n">hmac</span><span class="o">.</span><span class="n">compare_digest</span><span class="p">(</span><span class="n">current_version</span><span class="p">,</span> <span class="n">received</span><span class="p">)</span> <span class="ow">or</span> \
</span></span><span class="line"><span class="ln">5</span><span class="cl">           <span class="n">hmac</span><span class="o">.</span><span class="n">compare_digest</span><span class="p">(</span><span class="n">previous_version</span><span class="p">,</span> <span class="n">received</span><span class="p">)</span></span></span></code></pre></div><p>這個模式適合對外 API 或 client 數量較多的系統，因為 secret 集中管理、版本狀態可查。主要風險是驗證流程依賴 secret store，需要設計 cache、fallback 與 store 失效時的行為。</p>
<p>對外開放 API 通常用模式 B、可結合 AWS Secrets Manager / Vault 等工具。內部固定夥伴系統可以用模式 A 起步、複雜後再遷移。</p>
<hr>
<h2 id="自動化-rotation-工具">自動化 Rotation 工具</h2>
<p>純手動 rotation 在 client 數量增加後不可持續 — 自動化工具的價值是把「<strong>產生新 secret → 部署到 server → 通知 client → 撤銷舊 secret</strong>」整套流程程式化。</p>
<h3 id="aws-secrets-manager">AWS Secrets Manager</h3>
<p>機制：</p>
<ul>
<li>註冊一個 <strong>Rotation Lambda</strong>、AWS 排程觸發（例如每 90 天）</li>
<li>Lambda 跑 4 階段流程：<code>createSecret</code> → <code>setSecret</code> → <code>testSecret</code> → <code>finishSecret</code></li>
<li>每個階段都有 retry、失敗會回到上一個穩態</li>
</ul>
<p>Lambda 範例責任分工：</p>
<table>
  <thead>
      <tr>
          <th>階段</th>
          <th>動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>createSecret</code></td>
          <td>產生新 secret、存到 AWSPENDING 版本</td>
      </tr>
      <tr>
          <td><code>setSecret</code></td>
          <td>把新 secret 部署到目標 service</td>
      </tr>
      <tr>
          <td><code>testSecret</code></td>
          <td>用新 secret 跑驗證 request</td>
      </tr>
      <tr>
          <td><code>finishSecret</code></td>
          <td>把 AWSPENDING 升級為 AWSCURRENT、舊版改為 AWSPREVIOUS</td>
      </tr>
  </tbody>
</table>
<p>雙密期天然存在：AWSCURRENT + AWSPREVIOUS 兩個 staging label 同時可讀。Client 在 rotation 進行中、可以拿到 AWSPREVIOUS 作為 fallback。</p>
<p>適合場景：AWS 生態系、目標 service 是 RDS / Redshift / DocumentDB（有 native rotation Lambda template）或自定義（custom Lambda）。</p>
<h3 id="hashicorp-vault">HashiCorp Vault</h3>
<p>Vault 有兩種 rotation 策略：</p>
<p><strong>Static Secrets + Rotation Periodic</strong>：傳統 shared secret、Vault 每 N 天自動換、puts 到 vault path、client poll 拿。</p>
<p><strong>Dynamic Secrets</strong>：Vault 不存 long-lived secret、每次 client 請求時臨時產生（DB credential、AWS IAM credential 等）、TTL 短（小時到天）、過期即廢。Dynamic secret 沒有 rotation 概念 — 因為每個 secret 都只活一小段時間、洩漏窗本來就有限。</p>
<table>
  <thead>
      <tr>
          <th>模式</th>
          <th>適合</th>
          <th>限制</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Static + Periodic</td>
          <td>跨組織 API、需可預測的 secret</td>
          <td>仍需 client 端處理雙密期</td>
      </tr>
      <tr>
          <td>Dynamic</td>
          <td>內部 service 互呼、DB access</td>
          <td>目標系統要支援 short-lived credential</td>
      </tr>
  </tbody>
</table>
<p>適合場景：multi-cloud、不想綁 AWS、需要 dynamic secret 跨多種 backend。</p>
<h3 id="gcp-secret-manager">GCP Secret Manager</h3>
<p>機制較簡單 — Secret Manager 提供 <strong>versioning</strong>、每個 secret 有多個 version、client 可指定要「latest」還是特定 version。</p>
<p>Rotation 流程通常自己實作（GCP 沒提供類似 AWS 的 Rotation Lambda template）：</p>
<ol>
<li><code>addSecretVersion(name, new_secret)</code> — 加新 version</li>
<li>部署到 server（server 同時讀 latest + previous）</li>
<li>通知 client / 等 client 升級</li>
<li><code>destroySecretVersion(name, old_version)</code> — 撤銷舊 version</li>
</ol>
<p>雙密期靠 client 端邏輯（同時試 latest 跟 previous）實現。</p>
<p>適合場景：GCP 生態系、自有 rotation 邏輯不想被 vendor opinion 綁住。</p>
<h3 id="三者比較">三者比較</h3>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>AWS Secrets Manager</th>
          <th>HashiCorp Vault</th>
          <th>GCP Secret Manager</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>排程觸發</td>
          <td>內建</td>
          <td>內建（periodic）</td>
          <td>不內建（自己排 Cloud Scheduler）</td>
      </tr>
      <tr>
          <td>雙密期支援</td>
          <td>AWSCURRENT / PREVIOUS labels</td>
          <td>Static 需自寫、Dynamic 不需</td>
          <td>Version-based</td>
      </tr>
      <tr>
          <td>Dynamic credential</td>
          <td>需 custom Lambda</td>
          <td>Native support</td>
          <td>不支援</td>
      </tr>
      <tr>
          <td>跨雲 / 跨 region</td>
          <td>AWS-only</td>
          <td>跨雲</td>
          <td>GCP-only</td>
      </tr>
      <tr>
          <td>維運成本</td>
          <td>低（managed）</td>
          <td>高（自管 Vault cluster）</td>
          <td>低（managed）</td>
      </tr>
  </tbody>
</table>
<h3 id="自建-rotation-系統的最小元件">自建 rotation 系統的最小元件</h3>
<p>小規模系統可以自建最小 rotation 元件，前提是 secret 系統本身也被視為敏感基礎設施。最小元件包含：</p>
<ol>
<li><strong>Secret 存儲</strong>：DB table <code>secrets(id, version, value, created_at, retired_at)</code></li>
<li><strong>發放 API</strong>：<code>GET /secrets/current</code> 回 latest active version</li>
<li><strong>驗證邏輯</strong>：應用層讀 current + previous 兩個 active version</li>
<li><strong>排程</strong>：cron job 觸發 <code>rotate(secret_name)</code> — 產新 version、標記舊版 retired、設 retired_at</li>
<li><strong>監控</strong>：log 每個 version 被驗證的次數、舊版降到 0 後關閉</li>
</ol>
<p>這個方案適合內部小規模系統。判斷是否可行時，要同步檢查 DB encryption at rest、access log、權限分離與備援；否則自建系統可能把 rotation 風險轉移成 secret store 風險。</p>
<hr>
<h2 id="緊急-rotation洩漏發生時的流程">緊急 rotation：洩漏發生時的流程</h2>
<h3 id="跟定期-rotation-的差異">跟定期 rotation 的差異</h3>
<p>定期 rotation 目標是「<strong>不中斷服務</strong>」、所以雙密期長、給 client 充分時間切換。</p>
<p>緊急 rotation 目標是「<strong>最快讓舊 secret 失效</strong>」 — 即使犧牲部分可用性也要立刻撤銷。兩者流程完全不同：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>定期 rotation</th>
          <th>緊急 rotation</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>觸發</td>
          <td>排程</td>
          <td>事件（洩漏、員工離職、被盜）</td>
      </tr>
      <tr>
          <td>優先級</td>
          <td>不中斷服務</td>
          <td>立即撤銷舊 secret</td>
      </tr>
      <tr>
          <td>雙密期</td>
          <td>長（天到月）</td>
          <td>短（小時、甚至不容忍）</td>
      </tr>
      <tr>
          <td>通知方式</td>
          <td>文件、email、提早提醒</td>
          <td>直接 push、必要時打電話</td>
      </tr>
      <tr>
          <td>Client 不升級</td>
          <td>等</td>
          <td>強制斷線</td>
      </tr>
  </tbody>
</table>
<h3 id="緊急-rotation-流程模板">緊急 rotation 流程模板</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">T0: 偵測或回報洩漏
</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">T0+0~15min: 評估
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">   - 確認洩漏範圍（哪些 secret、影響哪些 client）
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">   - 評估「立即斷舊 secret」對 production 的影響
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">   - 決定是否走緊急流程 vs 縮短的定期流程
</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">T0+15min~1hr: 部署新 secret
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">   - 產生新 secret
</span></span><span class="line"><span class="ln">10</span><span class="cl">   - 部署到 server、開啟雙密期
</span></span><span class="line"><span class="ln">11</span><span class="cl">   - 主動 push 新 secret 給已知 client（內部用 channel 通知、外部 client email + dashboard）
</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">T0+1hr~24hr: 強制切換
</span></span><span class="line"><span class="ln">14</span><span class="cl">   - 監控用舊 secret 的 request 比例
</span></span><span class="line"><span class="ln">15</span><span class="cl">   - 跟未升級的 client 個別聯繫
</span></span><span class="line"><span class="ln">16</span><span class="cl">   - 視情境設「強制斷線時間點」並提早警告
</span></span><span class="line"><span class="ln">17</span><span class="cl">   ↓
</span></span><span class="line"><span class="ln">18</span><span class="cl">T0+24hr~72hr: 撤銷舊 secret
</span></span><span class="line"><span class="ln">19</span><span class="cl">   - 即使仍有 client 在用舊 secret、也斷
</span></span><span class="line"><span class="ln">20</span><span class="cl">   - 接受部分服務中斷、優先於 secret 繼續暴露
</span></span><span class="line"><span class="ln">21</span><span class="cl">   ↓
</span></span><span class="line"><span class="ln">22</span><span class="cl">事後: 檢討
</span></span><span class="line"><span class="ln">23</span><span class="cl">   - 洩漏怎麼發生（log 翻查、code audit）
</span></span><span class="line"><span class="ln">24</span><span class="cl">   - 偵測機制能否更快
</span></span><span class="line"><span class="ln">25</span><span class="cl">   - 流程哪裡可以改進</span></span></code></pre></div><p>關鍵權衡：<strong>「斷線成本」vs「secret 繼續暴露的損害」</strong>。對金融、醫療等高敏感場景、寧可斷線；對非關鍵內部服務、可能可以拉長雙密期。沒有通用答案、要場景判斷。</p>
<h3 id="偵測洩漏的訊號">偵測洩漏的訊號</h3>
<p>緊急 rotation 的前提是「<strong>知道洩漏發生了</strong>」 — 但很多洩漏直到攻擊者開始用 secret 才被發現、間隔可能是幾個月。</p>
<p>主動偵測手段：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>怎麼偵測</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Secret 出現在公開 repo</strong></td>
          <td>GitHub Secret Scanning、GitGuardian、TruffleHog</td>
      </tr>
      <tr>
          <td><strong>異常使用 pattern</strong></td>
          <td>異常時間、異常 IP、異常 request 量</td>
      </tr>
      <tr>
          <td><strong>多個 IP 同時用同一 secret</strong></td>
          <td>應用層 log 分析、SIEM 工具</td>
      </tr>
      <tr>
          <td><strong>離職員工觸發 access</strong></td>
          <td>跟 HR 系統整合的 access review</td>
      </tr>
  </tbody>
</table>
<p>把這些設成監控告警、是降低「洩漏到察覺」窗口的關鍵。</p>
<hr>
<h2 id="多-client-的同步難題">多 client 的同步難題</h2>
<h3 id="問題本質client-不在你的控制範圍">問題本質：client 不在你的控制範圍</h3>
<p>對外開放 API 的場景，Shared Secret 散落在第三方 client 的 server。Rotation 因此變成「怎麼讓第三方在你的時程內配合」的協調問題，不只是技術問題。</p>
<p>常見痛點：</p>
<ul>
<li>通知 email 進垃圾匣、第三方沒看到</li>
<li>第三方的工程師離職、新接手者不知道有 rotation 排程</li>
<li>第三方的 deploy 流程慢、提前一週通知還是來不及</li>
<li>第三方根本不在線（小型客戶、半年才用一次 API）</li>
</ul>
<h3 id="grace-period-設計">Grace period 設計</h3>
<p>Grace period 是「<strong>舊 secret 撤銷後、給 client 緩衝期重新申請</strong>」的機制。比硬性 deadline 更彈性：</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">T0: 公告 rotation、雙密期開始
</span></span><span class="line"><span class="ln">2</span><span class="cl">T0+30天: 雙密期結束、舊 secret 撤銷
</span></span><span class="line"><span class="ln">3</span><span class="cl">T0+30~60天: Grace period
</span></span><span class="line"><span class="ln">4</span><span class="cl">   - 用舊 secret 的 request 回 410 Gone（或 401 + 可讀的 error code，視 API 慣例）+ 連結到 &#34;secret expired&#34; 頁
</span></span><span class="line"><span class="ln">5</span><span class="cl">   - 提供 self-service 重設 secret 的流程
</span></span><span class="line"><span class="ln">6</span><span class="cl">   - 仍然斷線、但 client 知道怎麼自己救
</span></span><span class="line"><span class="ln">7</span><span class="cl">T0+60天: 完全關閉、需要重新申請新 client account</span></span></code></pre></div><p>Grace period 的關鍵是在拒絕舊 secret 的同時，提供足夠資訊讓 client 自助修復。判讀訊號是錯誤回應是否能指出 secret 已過期、去哪裡重設、何時完全關閉；若只回無上下文的 401，client 仍會被導向錯誤排障路徑。</p>
<h3 id="強制升級的工具">強制升級的工具</h3>
<p>對於必須統一升級的場景（例如安全合規要求）、有幾種強制手段：</p>
<table>
  <thead>
      <tr>
          <th>手段</th>
          <th>怎麼運作</th>
          <th>適合</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>HTTP 410 + 訊息</strong></td>
          <td>舊 secret 不只 401、回 410 + 升級指引</td>
          <td>一般對外 API</td>
      </tr>
      <tr>
          <td><strong>暫時降級而非斷線</strong></td>
          <td>舊 secret 仍 work、但限流 / 降級權限</td>
          <td>重要 client、寧可降級不要斷</td>
      </tr>
      <tr>
          <td><strong>個別溝通 + 客製化期限</strong></td>
          <td>對大 client 個別協商 deadline</td>
          <td>高價值合作夥伴</td>
      </tr>
      <tr>
          <td><strong>合約強制條款</strong></td>
          <td>簽約時就寫清楚「Y 年內必須能配合 rotation」</td>
          <td>B2B SaaS</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="失敗模式與緩解">失敗模式與緩解</h2>
<h3 id="失敗-1雙密期太短client-沒升級">失敗 1：雙密期太短、client 沒升級</h3>
<p><strong>症狀</strong>：rotation 後第二週，某 client 開始 401，才發現他沒收到通知或尚未升級。</p>
<p><strong>緩解</strong>：</p>
<ul>
<li>雙密期至少覆蓋「最大已知 client 的 deploy cycle」</li>
<li>雙密期內監控「用舊 secret 的 client 數量」、降到 0 才關</li>
<li>緊急 rotation 例外、要事先評估可接受的斷線成本</li>
</ul>
<h3 id="失敗-2rotation-中斷新舊都不通">失敗 2：rotation 中斷、新舊都不通</h3>
<p><strong>症狀</strong>：deploy 新 secret 到 server 中途失敗、一半 server 是新、一半是舊 — request 隨機 401。</p>
<p><strong>緩解</strong>：</p>
<ul>
<li>部署用 rolling update、確認每個 instance 都生效再進下一個</li>
<li>部署前確認「server 是雙密 mode」、即使部署到一半也能容錯</li>
<li>保留快速 rollback 機制（10 分鐘內能 revert）</li>
</ul>
<h3 id="失敗-3新-secret-沒測通就上線">失敗 3：新 secret 沒測通就上線</h3>
<p><strong>症狀</strong>：新 secret 部署完、第一個 client 試了發現格式不對 / 長度限制 / 特殊字元編碼問題、大量 401。</p>
<p><strong>緩解</strong>：</p>
<ul>
<li>Rotation 流程加 <code>testSecret</code> 階段（AWS Lambda 模式）— 切換前用新 secret 跑一輪驗證 request</li>
<li>Staging 環境先跑完整 rotation 流程、再上 prod</li>
<li>新 secret 的 format 跟舊一致（同長度、同字元集）、減少 client 端的 parsing 風險</li>
</ul>
<h3 id="失敗-4rotation-缺少-ownersecret-長期暴露">失敗 4：Rotation 缺少 owner、secret 長期暴露</h3>
<p><strong>症狀</strong>：上次 rotate 已是 3 年前，原本的負責人離職，接手者不知道有這個 secret 存在。</p>
<p><strong>緩解</strong>：</p>
<ul>
<li>Secret 管理工具強制設 <code>expires_at</code>、過期前自動提醒</li>
<li>Inventory 表：所有 production secret 列管、定期 audit</li>
<li>Rotation 排程進 calendar、輪值負責</li>
</ul>
<h3 id="失敗-5rotation-後-audit-log-沒更新">失敗 5：rotation 後 audit log 沒更新</h3>
<p><strong>症狀</strong>：洩漏發生、要追「這個 secret 給過誰用」、但 audit log 只記了「secret 被用了」、沒記版本、無法區分新舊。</p>
<p><strong>緩解</strong>：</p>
<ul>
<li>Audit log 記 secret version、不只 secret 本身</li>
<li>Rotation 事件本身也要 log（誰、什麼時候、為什麼）</li>
<li>Log 保留期跨多次 rotation cycle、避免歷史追溯斷鏈</li>
</ul>
<hr>
<h2 id="收尾">收尾</h2>
<p>Shared Secret rotation 的本質是<strong>有意識管理 secret 的 lifecycle</strong>。從發放、儲存、輪替到撤銷，每個階段都有對應的工程設計與監控訊號。</p>
<p>幾個核心原則：</p>
<ol>
<li><strong>雙密過渡期是底層機制</strong> — 任何 rotation 方案都建立在「server 能同時接受兩把」之上</li>
<li><strong>自動化工具值得投資</strong> — 規模小用 secret manager（AWS / Vault / GCP），規模大可以自建，避免停在純手動</li>
<li><strong>定期跟緊急是兩套流程</strong> — 定期重不中斷，緊急重立刻撤，流程、通知與回退標準要分開</li>
<li><strong>多 client 是協調問題</strong> — 比技術問題難解、grace period + 強制升級工具是常用解法</li>
<li><strong>失敗模式要演練</strong> — production 第一次跑 rotation 前，先在 staging 演練完整流程與回退路徑</li>
</ol>
<p>延伸閱讀：</p>
<ul>
<li><a href="/blog/work-log/api-%E8%AA%8D%E8%AD%89%E7%9A%84%E4%B8%89%E5%B1%A4%E4%BF%A1%E4%BB%BB%E9%82%8A%E7%95%8C%E4%BD%BF%E7%94%A8%E8%80%85%E7%B3%BB%E7%B5%B1%E8%B7%A8%E7%B3%BB%E7%B5%B1-provisioning/" data-link-title="API 認證的三層信任邊界：使用者、系統、跨系統 Provisioning" data-link-desc="API 認證的信任邊界分層（Bearer Token / Shared Secret / Provisioning）：各層的洩漏後果與撤銷方式，以及混用造成的設計失效模式。">API 認證的三層信任邊界</a> — 本文的主篇、Shared Secret 在「Layer 2 系統層」的位置</li>
<li><a href="/blog/work-log/laravel-sanctum-%E7%9A%84-bearer-token-%E8%A8%AD%E8%A8%88%E5%89%96%E6%9E%90pksecret-%E7%82%BA%E4%BB%80%E9%BA%BC%E9%80%99%E6%A8%A3%E8%A8%AD%E8%A8%88/" data-link-title="Laravel Sanctum 的 Bearer Token 設計剖析：{PK}|{secret} 為什麼這樣設計" data-link-desc="Laravel Sanctum `{PK}|{secret}` 格式的設計理由、hash 儲存取捨、constant-time 比對位置，以及跟 GitHub PAT、Stripe API Key 的差異。">Laravel Sanctum 的 Bearer Token 設計剖析</a> — Layer 1 使用者 token 的儲存原則（hash + constant-time）也適用於 Layer 2</li>
<li><a href="/blog/work-log/mtls-%E5%AF%A6%E9%9A%9B%E6%80%8E%E9%BA%BC%E8%A8%AD%E5%AE%9A%E8%88%87%E9%81%8B%E7%B6%ADca-%E9%9A%8E%E5%B1%A4%E6%86%91%E8%AD%89%E7%94%9F%E5%91%BD%E9%80%B1%E6%9C%9F%E6%92%A4%E9%8A%B7%E6%A9%9F%E5%88%B6/" data-link-title="mTLS 實際怎麼設定與運維：CA 階層、憑證生命週期、撤銷機制" data-link-desc="mTLS 落地的運維決策（CA 階層、憑證儲存、撤銷機制）與基礎設施整合（nginx / envoy / service mesh），以及跟 API Key / OAuth 的成本與安全取捨。">mTLS 實際怎麼設定與運維</a> — 不用 shared secret 的另一條路、憑證 lifecycle 跟 secret lifecycle 的對照</li>
</ul>
]]></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>Git：把後面 commit 的部分檔案變更搬到前面的 commit</title><link>https://tarrragon.github.io/blog/work-log/git_move_partial_change_to_earlier_commit/</link><pubDate>Tue, 28 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/git_move_partial_change_to_earlier_commit/</guid><description>&lt;h2 id="問題情境">問題情境&lt;/h2>
&lt;p>開發到一半，發現 commit log 裡「實作功能」這個 commit 不只改了功能檔案，還包含了「定義 model」階段的檔案變更。這些變更在開發節奏上應該屬於前面的 commit，但實際上被混在後面的 commit 裡。想把這部分檔案的變更從後面那個 commit 抽出來，合併到前面的 commit 裡，&lt;strong>其他檔案保持原狀&lt;/strong>。&lt;/p>
&lt;h3 id="範例">範例&lt;/h3>





&lt;pre tabindex="0">&lt;code class="language-mermaid" data-lang="mermaid">gitGraph
 commit id: &amp;#34;A (model 定義)&amp;#34;
 commit id: &amp;#34;B (其他)&amp;#34;
 commit id: &amp;#34;C (功能實作)&amp;#34;
 commit id: &amp;#34;D (其他)&amp;#34;&lt;/code>&lt;/pre>&lt;p>&lt;strong>四個 commit 的角色&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>A&lt;/strong>（接收目標）：commit C 中對 &lt;code>models/foo.dart&lt;/code> 的修訂應該屬於這裡&lt;/li>
&lt;li>&lt;strong>B&lt;/strong>（中間插入）：A 跟 C 之間有別的 commit，不能簡單 squash&lt;/li>
&lt;li>&lt;strong>C&lt;/strong>（變更來源）：同時改了 &lt;code>models/foo.dart&lt;/code> 和其他 6 個檔案&lt;/li>
&lt;li>&lt;strong>限制&lt;/strong>：只想搬走 &lt;code>models/foo.dart&lt;/code>，其他檔案保持原狀&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="解法核心">解法核心&lt;/h2>
&lt;p>這個方法的核心靠 &lt;strong>3-way merge 自動跳過重複變更&lt;/strong> — 不需要手動從 C 移除檔案變更，git 會自己偵測「這個變更已在新 base」自動處理。&lt;/p>
&lt;p>具體利用兩個 git 機制：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>&lt;code>git rebase -i&lt;/code> 搭配 &lt;code>edit&lt;/code>&lt;/strong>：在指定 commit 暫停，讓我們手動修改該 commit 的內容&lt;/li>
&lt;li>&lt;strong>3-way merge 自動 dedup&lt;/strong>：當後續 commit 被 replay 時，git 比較三個版本（base / theirs / mine），發現該檔案變更已在 mine 裡，就自動跳過&lt;/li>
&lt;/ol>
&lt;h3 id="為什麼是這個方法">為什麼是這個方法&lt;/h3>
&lt;p>其他可能方案的成本：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>git cherry-pick&lt;/code> 後手動解衝突&lt;/strong>：cherry-pick 會把整個 commit（包含所有檔案）複製到 A 後面，然後再手動移除不需要的變更。多出一個 commit，且流程較長&lt;/li>
&lt;li>&lt;strong>用 &lt;code>git format-patch&lt;/code> 提取單一檔案&lt;/strong>：format-patch 提取的是整個 commit 的 patch，無法只選擇某個檔案。需要手動編輯 patch 檔，失敗風險高&lt;/li>
&lt;li>&lt;strong>直接 &lt;code>git add -p&lt;/code> 重新 commit&lt;/strong>：需要回到 A 後重新手動 commit 一遍，工作量大且容易遺漏&lt;/li>
&lt;/ul>
&lt;p>這個方法的優勢：&lt;strong>自動化程度最高&lt;/strong> — 只需在 A 和 C 時暫停，git 會在 replay C 時自動判斷哪些變更要跳過，無需手動識別衝突或編輯 patch。&lt;/p>
&lt;hr>
&lt;h2 id="步驟">步驟&lt;/h2>
&lt;h3 id="1-建立備份-tag">1. 建立備份 tag&lt;/h3>
&lt;p>歷史改寫前先綁 tag，這樣如果出現預期外的結果可以快速復原。&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">git tag backup-before-rebase HEAD&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="2-進入-interactive-rebase把-a-跟-c-都標成-edit">2. 進入 interactive rebase，把 A 跟 C 都標成 edit&lt;/h3>
&lt;p>從 A 的父 commit 開始 rebase，並把 A 跟 C 都標為 &lt;code>edit&lt;/code>：&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"># 用環境變數注入「自動把 pick 改成 edit」的 sequence editor&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="nv">GIT_SEQUENCE_EDITOR&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s1">&amp;#39;sed -i.bak \
&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"> -e &amp;#34;s/^pick \(&amp;lt;A短hash&amp;gt;\)/edit \1/&amp;#34; \
&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"> -e &amp;#34;s/^pick \(&amp;lt;C短hash&amp;gt;\)/edit \1/&amp;#34;&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span>&lt;span class="nv">GIT_EDITOR&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span>git rebase -i &amp;lt;A短hash&amp;gt;^&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;blockquote>
&lt;p>macOS 的 &lt;code>sed -i&lt;/code> 需要加空字串引數（&lt;code>-i ''&lt;/code>）或像上面用 &lt;code>-i.bak&lt;/code> 留 backup 檔。
Linux 的 &lt;code>sed -i&lt;/code> 不需要。
如果更放心用編輯器手動操作，可以拿掉 &lt;code>GIT_EDITOR=true&lt;/code>，讓 rebase 開你慣用的編輯器手動把兩行的 &lt;code>pick&lt;/code> 改成 &lt;code>edit&lt;/code>。&lt;/p></description><content:encoded><![CDATA[<h2 id="問題情境">問題情境</h2>
<p>開發到一半，發現 commit log 裡「實作功能」這個 commit 不只改了功能檔案，還包含了「定義 model」階段的檔案變更。這些變更在開發節奏上應該屬於前面的 commit，但實際上被混在後面的 commit 裡。想把這部分檔案的變更從後面那個 commit 抽出來，合併到前面的 commit 裡，<strong>其他檔案保持原狀</strong>。</p>
<h3 id="範例">範例</h3>





<pre tabindex="0"><code class="language-mermaid" data-lang="mermaid">gitGraph
   commit id: &#34;A (model 定義)&#34;
   commit id: &#34;B (其他)&#34;
   commit id: &#34;C (功能實作)&#34;
   commit id: &#34;D (其他)&#34;</code></pre><p><strong>四個 commit 的角色</strong>：</p>
<ul>
<li><strong>A</strong>（接收目標）：commit C 中對 <code>models/foo.dart</code> 的修訂應該屬於這裡</li>
<li><strong>B</strong>（中間插入）：A 跟 C 之間有別的 commit，不能簡單 squash</li>
<li><strong>C</strong>（變更來源）：同時改了 <code>models/foo.dart</code> 和其他 6 個檔案</li>
<li><strong>限制</strong>：只想搬走 <code>models/foo.dart</code>，其他檔案保持原狀</li>
</ul>
<hr>
<h2 id="解法核心">解法核心</h2>
<p>這個方法的核心靠 <strong>3-way merge 自動跳過重複變更</strong> — 不需要手動從 C 移除檔案變更，git 會自己偵測「這個變更已在新 base」自動處理。</p>
<p>具體利用兩個 git 機制：</p>
<ol>
<li><strong><code>git rebase -i</code> 搭配 <code>edit</code></strong>：在指定 commit 暫停，讓我們手動修改該 commit 的內容</li>
<li><strong>3-way merge 自動 dedup</strong>：當後續 commit 被 replay 時，git 比較三個版本（base / theirs / mine），發現該檔案變更已在 mine 裡，就自動跳過</li>
</ol>
<h3 id="為什麼是這個方法">為什麼是這個方法</h3>
<p>其他可能方案的成本：</p>
<ul>
<li><strong><code>git cherry-pick</code> 後手動解衝突</strong>：cherry-pick 會把整個 commit（包含所有檔案）複製到 A 後面，然後再手動移除不需要的變更。多出一個 commit，且流程較長</li>
<li><strong>用 <code>git format-patch</code> 提取單一檔案</strong>：format-patch 提取的是整個 commit 的 patch，無法只選擇某個檔案。需要手動編輯 patch 檔，失敗風險高</li>
<li><strong>直接 <code>git add -p</code> 重新 commit</strong>：需要回到 A 後重新手動 commit 一遍，工作量大且容易遺漏</li>
</ul>
<p>這個方法的優勢：<strong>自動化程度最高</strong> — 只需在 A 和 C 時暫停，git 會在 replay C 時自動判斷哪些變更要跳過，無需手動識別衝突或編輯 patch。</p>
<hr>
<h2 id="步驟">步驟</h2>
<h3 id="1-建立備份-tag">1. 建立備份 tag</h3>
<p>歷史改寫前先綁 tag，這樣如果出現預期外的結果可以快速復原。</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">git tag backup-before-rebase HEAD</span></span></code></pre></div><h3 id="2-進入-interactive-rebase把-a-跟-c-都標成-edit">2. 進入 interactive rebase，把 A 跟 C 都標成 edit</h3>
<p>從 A 的父 commit 開始 rebase，並把 A 跟 C 都標為 <code>edit</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"><span class="c1"># 用環境變數注入「自動把 pick 改成 edit」的 sequence editor</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nv">GIT_SEQUENCE_EDITOR</span><span class="o">=</span><span class="s1">&#39;sed -i.bak \
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="s1">  -e &#34;s/^pick \(&lt;A短hash&gt;\)/edit \1/&#34; \
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="s1">  -e &#34;s/^pick \(&lt;C短hash&gt;\)/edit \1/&#34;&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="se"></span><span class="nv">GIT_EDITOR</span><span class="o">=</span><span class="nb">true</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>git rebase -i &lt;A短hash&gt;^</span></span></code></pre></div><blockquote>
<p>macOS 的 <code>sed -i</code> 需要加空字串引數（<code>-i ''</code>）或像上面用 <code>-i.bak</code> 留 backup 檔。
Linux 的 <code>sed -i</code> 不需要。
如果更放心用編輯器手動操作，可以拿掉 <code>GIT_EDITOR=true</code>，讓 rebase 開你慣用的編輯器手動把兩行的 <code>pick</code> 改成 <code>edit</code>。</p></blockquote>
<p>執行後 git 會在 A 暫停。</p>
<h3 id="3-在-a把-c-中該檔案的版本拉進來amend">3. 在 A：把 C 中該檔案的版本拉進來，amend</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"><span class="c1"># 把 C 那個 commit 對該檔案的最終內容 checkout 到工作區</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git checkout &lt;C短hash&gt; -- path/to/file.dart
</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"># 加入並 amend 到 A</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">git add path/to/file.dart
</span></span><span class="line"><span class="ln">6</span><span class="cl">git commit --amend --no-edit
</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">git rebase --continue</span></span></code></pre></div><blockquote>
<p><code>git checkout &lt;commit&gt; -- &lt;path&gt;</code> 會把指定 commit 的該檔案版本放進工作區。
因為 A 是 C 的祖先，C 的版本就是「A 的版本 + C 的 diff」，等於把 C 對該檔案的變更搬到 A。</p></blockquote>
<h3 id="4-在-c確認-git-自動跳過該檔案的變更">4. 在 C：確認 git 自動跳過該檔案的變更</h3>
<p>rebase 繼續後會 replay B、然後在 C 暫停（因為我們也把它標成 edit）。
此時該檔案對 C 的變更應該已被 git 自動跳過，驗證一下：</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">git show HEAD --stat
</span></span><span class="line"><span class="ln">2</span><span class="cl">git diff HEAD~ HEAD -- path/to/file.dart</span></span></code></pre></div><p>第一個指令的檔案清單<strong>不應該再出現</strong> <code>path/to/file.dart</code>，第二個指令應該是空輸出。</p>
<p>驗證無誤後，git 已自動完成跳過，直接繼續：</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">git rebase --continue</span></span></code></pre></div><blockquote>
<p><strong>3-way merge 為什麼會自動跳過？</strong>
Replay C 時 git 用 3-way merge：</p>
<ul>
<li><strong>base</strong>（C 的原始父 commit）：該檔案沒變</li>
<li><strong>theirs</strong>（C 原始版本）：該檔案有 X 變更</li>
<li><strong>mine</strong>（amend 後的 A 接續而來的目前 HEAD）：該檔案已經有 X 變更</li>
</ul>
<p>mine 跟 theirs 的最終狀態一致 → git 認定變更已套用，replay 後的 C 對該檔案就是 no-op。</p></blockquote>
<h3 id="5-驗證最終樹狀態跟備份一致">5. 驗證最終樹狀態跟備份一致</h3>
<p>最關鍵的 sanity check：<strong>內容不應該變，只是 commit 邊界移動</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">git diff backup-before-rebase HEAD</span></span></code></pre></div><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">git reset --hard backup-before-rebase</span></span></code></pre></div><p>確認沒問題後刪 tag：</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">git tag -d backup-before-rebase</span></span></code></pre></div><hr>
<h2 id="完整指令摘要">完整指令摘要</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 0. 備份</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">git tag backup-before-rebase HEAD
</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"># 1. Rebase，把 A 與 C 都標 edit</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="nv">GIT_SEQUENCE_EDITOR</span><span class="o">=</span><span class="s1">&#39;sed -i.bak \
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s1">  -e &#34;s/^pick \(&lt;A短hash&gt;\)/edit \1/&#34; \
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s1">  -e &#34;s/^pick \(&lt;C短hash&gt;\)/edit \1/&#34;&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="se"></span><span class="nv">GIT_EDITOR</span><span class="o">=</span><span class="nb">true</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="se"></span>git rebase -i &lt;A短hash&gt;^
</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"># 2. 在 A：拉檔案、amend、繼續</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">git checkout &lt;C短hash&gt; -- path/to/file.dart
</span></span><span class="line"><span class="ln">13</span><span class="cl">git add path/to/file.dart
</span></span><span class="line"><span class="ln">14</span><span class="cl">git commit --amend --no-edit
</span></span><span class="line"><span class="ln">15</span><span class="cl">git rebase --continue
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="c1"># 3. 在 C：驗證、繼續（不需要動手）</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">git show HEAD --stat
</span></span><span class="line"><span class="ln">19</span><span class="cl">git rebase --continue
</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"># 4. 驗證樹一致</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">git diff backup-before-rebase HEAD   <span class="c1"># 應為空</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"># 5. 清理</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">git tag -d backup-before-rebase</span></span></code></pre></div><hr>
<h2 id="衍伸當變更區段在-a-跟-c-重疊">衍伸：當變更區段在 A 跟 C 重疊</h2>
<p>如果 A 跟 C 對該檔案動的是<strong>同一個區段</strong>（不是這個範例的 non-overlapping），
3-way merge 會跳出衝突，需要手動編輯。流程：</p>





<pre tabindex="0"><code class="language-mermaid" data-lang="mermaid">flowchart LR
   A[&#34;在 A amend 完&#34;] --&gt; B[&#34;replay 到 C 衝突&#34;]
   B --&gt; C[&#34;手動編輯衝突檔&#34;]
   C --&gt; D[&#34;git add + git rebase --continue&#34;]

   style B fill:#e53e3e,color:#fff,stroke:#c53030
   style C fill:#dd6b20,color:#fff,stroke:#c05621</code></pre><p><strong>衝突解決原則</strong>：保留 A 已經帶過去的版本（也就是 C 想再套一次但其實一樣的內容），
讓 C 對該檔案的這次 replay 變成 no-op。</p>
<hr>
<h2 id="注意事項">注意事項</h2>
<ul>
<li><strong>改寫已 push 的歷史需要 force push</strong>：用 <code>git push --force-with-lease</code> 比 <code>--force</code> 安全，
別人有新 commit 推上去時會被擋住，避免覆寫</li>
<li><strong>沒 push 的 commit 改起來無風險</strong>：怎麼操作都只影響本地</li>
<li><strong>改寫 main / master 是禁忌</strong>，這個技術只適用於 feature branch</li>
<li><strong>codegen 檔案</strong>：如果 <code>.freezed.dart</code> / <code>.g.dart</code> 等是被 gitignore 的，重組 source commit 後本地需要重跑 build_runner。如果 codegen 也在版控，建議連同 source 一起搬，否則 source 跟 codegen 對不齊</li>
<li><strong>Sequence editor 自動腳本</strong>搞不定的話，拿掉 <code>GIT_EDITOR=true</code>，讓 rebase 開你慣用的編輯器手動改 <code>pick</code> → <code>edit</code>，更直觀</li>
<li><strong>驗證樹一致性</strong>是這個工作流程的安全網。每次重組完一定要 <code>git diff backup HEAD</code> 跑一次</li>
</ul>
]]></content:encoded></item><item><title>Gradle Configuration 時序陷阱：afterEvaluate、evaluationDependsOn、finalized properties</title><link>https://tarrragon.github.io/blog/work-log/gradle-configuration-%E6%99%82%E5%BA%8F%E9%99%B7%E9%98%B1afterevaluateevaluationdependsonfinalized-properties/</link><pubDate>Fri, 17 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/gradle-configuration-%E6%99%82%E5%BA%8F%E9%99%B7%E9%98%B1afterevaluateevaluationdependsonfinalized-properties/</guid><description>&lt;h2 id="三種典型錯誤都源自同一個問題">三種典型錯誤都源自同一個問題&lt;/h2>
&lt;p>這些錯誤表面訊息不同，但根本原因都是「callback 註冊得太晚，或屬性被賦值得太晚」：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>錯誤訊息&lt;/th>
 &lt;th>實際含義&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>Cannot run Project.afterEvaluate(Closure) when the project is already evaluated&lt;/code>&lt;/td>
 &lt;td>對象已 evaluate 完，註冊 callback 失敗&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>The value for property 'languageVersion' is final and cannot be changed any further&lt;/code>&lt;/td>
 &lt;td>屬性已被 finalize，後續賦值失敗&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>覆寫了 plugin 設定但沒生效&lt;/td>
 &lt;td>覆寫時機早於 plugin，被 plugin 蓋回去&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>想正確治理這些情境，必須先理解 Gradle configuration 的時序模型。&lt;/p>
&lt;hr>
&lt;h2 id="gradle-configuration-階段的時序">Gradle Configuration 階段的時序&lt;/h2>
&lt;h3 id="單一-project-的-evaluate-流程">單一 project 的 evaluate 流程&lt;/h3>





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

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





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





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





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

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





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





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





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





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





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





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





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





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





<pre tabindex="0"><code class="language-mermaid" data-lang="mermaid">flowchart TD
    A[出現錯誤] --&gt; B{訊息關鍵字}
    B --&gt;|already evaluated| C[註冊 callback 太晚]
    B --&gt;|is final| D[賦值屬性太晚]
    B --&gt;|覆寫沒生效| E[覆寫時機太早]
    C --&gt; F[在註冊前跳過已 evaluate 的 project]
    D --&gt; G[把賦值搬到更早的時機點]
    E --&gt; H[把覆寫搬到 afterEvaluate]</code></pre><p>三個解法方向完全相反：</p>
<ul>
<li><strong>太晚</strong> → 提前</li>
<li><strong>太早</strong> → 延後</li>
</ul>
<p>所以看到錯誤時第一件事是判斷<strong>時機太早還是太晚</strong>，而不是試圖繞過屬性狀態。</p>
<hr>
<h2 id="判斷時機太早還是太晚的速查">判斷「時機太早還是太晚」的速查</h2>
<table>
  <thead>
      <tr>
          <th>現象</th>
          <th>時機狀態</th>
          <th>該往哪搬</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>callback 註冊失敗（already evaluated）</td>
          <td>太晚</td>
          <td>提前，或跳過已 evaluate 的對象</td>
      </tr>
      <tr>
          <td>屬性賦值失敗（is final）</td>
          <td>太晚</td>
          <td>提前到屬性 finalize 之前的 hook</td>
      </tr>
      <tr>
          <td>我設的值被蓋掉</td>
          <td>太早</td>
          <td>延後到對方設定之後（通常是 afterEvaluate）</td>
      </tr>
      <tr>
          <td>task 上設了值但沒生效</td>
          <td>取決於 plugin</td>
          <td>看 plugin 有沒有從 extension 同步的機制</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>Gradle 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>SSH Key 設定筆記（macOS / Linux / Windows）</title><link>https://tarrragon.github.io/blog/work-log/ssh-key-%E8%A8%AD%E5%AE%9A%E7%AD%86%E8%A8%98macos-/-linux-/-windows/</link><pubDate>Thu, 05 Mar 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/ssh-key-%E8%A8%AD%E5%AE%9A%E7%AD%86%E8%A8%98macos-/-linux-/-windows/</guid><description>&lt;h2 id="0-產生金鑰如果還沒有的話">0. 產生金鑰（如果還沒有的話）&lt;/h2>
&lt;p>目前推薦使用 &lt;strong>Ed25519&lt;/strong> 演算法，相比 RSA 更安全、金鑰更短、驗證速度更快：&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">ssh-keygen -t ed25519 -C &lt;span class="s2">&amp;#34;your_email@example.com&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;blockquote>
&lt;p>若需相容較舊的系統（不支援 Ed25519），可退而使用 RSA-4096：&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">ssh-keygen -t rsa -b &lt;span class="m">4096&lt;/span> -C &lt;span class="s2">&amp;#34;your_email@example.com&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;/blockquote>
&lt;p>產生時會提示設定 &lt;strong>passphrase（密碼短語）&lt;/strong>，強烈建議設定。即使私鑰外洩，攻擊者仍需要密碼才能使用。&lt;/p>
&lt;hr>
&lt;h2 id="1-寫入金鑰檔案">1. 寫入金鑰檔案&lt;/h2>
&lt;h3 id="macos--linux">macOS / Linux&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">cat &amp;gt; ~/.ssh/&amp;lt;key_name&amp;gt; &lt;span class="s">&amp;lt;&amp;lt; &amp;#39;EOF&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="s">（貼上金鑰內容）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="s">EOF&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;blockquote>
&lt;p>&lt;code>'EOF'&lt;/code> 加單引號 → 防止 shell 解析內容中的特殊字元（如 &lt;code>$&lt;/code>、&lt;code>`&lt;/code>）&lt;/p>&lt;/blockquote>
&lt;h3 id="windowspowershell">Windows（PowerShell）&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-powershell" data-lang="powershell">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nb">New-Item&lt;/span> &lt;span class="n">-Path&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$env:USERPROFILE&lt;/span>&lt;span class="s2">\.ssh&amp;#34;&lt;/span> &lt;span class="n">-ItemType&lt;/span> &lt;span class="n">Directory&lt;/span> &lt;span class="n">-Force&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="nb">Set-Content&lt;/span> &lt;span class="n">-Path&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$env:USERPROFILE&lt;/span>&lt;span class="s2">\.ssh\&amp;lt;key_name&amp;gt;&amp;#34;&lt;/span> &lt;span class="n">-Value&lt;/span> &lt;span class="sh">@&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 class="sh">（貼上金鑰內容）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="sh">&amp;#34;@&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;blockquote>
&lt;p>Windows 的 SSH 金鑰預設路徑為 &lt;code>C:\Users\&amp;lt;使用者&amp;gt;\.ssh\&lt;/code>&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="2-設定權限">2. 設定權限&lt;/h2>
&lt;h3 id="macos--linux-1">macOS / Linux&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">chmod &lt;span class="m">600&lt;/span> ~/.ssh/&amp;lt;key_name&amp;gt;&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;blockquote>
&lt;p>&lt;code>chmod 600&lt;/code> → 僅擁有者可讀寫，SSH 要求私鑰權限不可過於開放，否則會拒絕使用。&lt;/p>&lt;/blockquote>
&lt;h3 id="windowspowershell以系統管理員執行">Windows（PowerShell，以系統管理員執行）&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-powershell" data-lang="powershell">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="n">icacls&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$env:USERPROFILE&lt;/span>&lt;span class="s2">\.ssh\&amp;lt;key_name&amp;gt;&amp;#34;&lt;/span> &lt;span class="p">/&lt;/span>&lt;span class="n">inheritance&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nb">r &lt;/span>&lt;span class="p">/&lt;/span>&lt;span class="n">grant&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nb">r &lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">$(&lt;/span>&lt;span class="nv">$env:USERNAME&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="s2">:(R)&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;blockquote>
&lt;p>Windows 需透過 &lt;code>icacls&lt;/code> 移除繼承權限，並限制為只有當前使用者可讀取。
若權限過於開放，OpenSSH 同樣會拒絕載入金鑰。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="3-加入-ssh-agent">3. 加入 SSH Agent&lt;/h2>
&lt;h3 id="macos">macOS&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">&lt;span class="c1"># 啟動 agent（通常 macOS 已自動啟動）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="nb">eval&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">$(&lt;/span>ssh-agent -s&lt;span class="k">)&lt;/span>&lt;span class="s2">&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"># 加入金鑰，並存入 Keychain 避免重開機後失效&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">ssh-add --apple-use-keychain ~/.ssh/&amp;lt;key_name&amp;gt;&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>若要讓金鑰在每次登入時自動載入，可在 &lt;code>~/.ssh/config&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">Host *
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> AddKeysToAgent yes
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> UseKeychain yes
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> IdentityFile ~/.ssh/&amp;lt;key_name&amp;gt;&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="linux">Linux&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">&lt;span class="c1"># 啟動 agent&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="nb">eval&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">$(&lt;/span>ssh-agent -s&lt;span class="k">)&lt;/span>&lt;span class="s2">&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"># 加入金鑰&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">ssh-add ~/.ssh/&amp;lt;key_name&amp;gt;&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;blockquote>
&lt;p>Linux 重開機後 agent 會重置。可將 &lt;code>eval &amp;quot;$(ssh-agent -s)&amp;quot;&lt;/code> 加入 &lt;code>~/.bashrc&lt;/code> 或 &lt;code>~/.zshrc&lt;/code> 讓它自動啟動。&lt;/p>&lt;/blockquote>
&lt;h3 id="windowspowershell以系統管理員執行-1">Windows（PowerShell，以系統管理員執行）&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-powershell" data-lang="powershell">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c"># 啟用 ssh-agent 服務（預設為停用）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="nb">Get-Service&lt;/span> &lt;span class="nb">ssh-agent&lt;/span> &lt;span class="p">|&lt;/span> &lt;span class="nb">Set-Service&lt;/span> &lt;span class="n">-StartupType&lt;/span> &lt;span class="n">Automatic&lt;/span> &lt;span class="n">-PassThru&lt;/span> &lt;span class="p">|&lt;/span> &lt;span class="nb">Start-Service&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="c"># 加入金鑰&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="nb">ssh-add&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$env:USERPROFILE&lt;/span>&lt;span class="s2">\.ssh\&amp;lt;key_name&amp;gt;&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;blockquote>
&lt;p>Windows 的 &lt;code>ssh-agent&lt;/code> 是系統服務，啟用後重開機也會自動執行，不需額外設定。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="4-測試連線">4. 測試連線&lt;/h2>
&lt;p>三個平台指令相同：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">ssh -i ~/.ssh/&amp;lt;key_name&amp;gt; -T git@&amp;lt;host&amp;gt;&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;blockquote>
&lt;p>Windows 請在 PowerShell 或 Git Bash 中執行，路徑會自動對應到 &lt;code>$env:USERPROFILE\.ssh\&lt;/code>。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="備註">備註&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>項目&lt;/th>
 &lt;th>macOS&lt;/th>
 &lt;th>Linux&lt;/th>
 &lt;th>Windows&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>金鑰路徑&lt;/td>
 &lt;td>&lt;code>~/.ssh/&lt;/code>&lt;/td>
 &lt;td>&lt;code>~/.ssh/&lt;/code>&lt;/td>
 &lt;td>&lt;code>C:\Users\&amp;lt;使用者&amp;gt;\.ssh\&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>權限設定&lt;/td>
 &lt;td>&lt;code>chmod 600&lt;/code>&lt;/td>
 &lt;td>&lt;code>chmod 600&lt;/code>&lt;/td>
 &lt;td>&lt;code>icacls&lt;/code> 移除繼承&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Agent 持久化&lt;/td>
 &lt;td>Keychain&lt;/td>
 &lt;td>需加入 shell rc&lt;/td>
 &lt;td>系統服務，自動持久&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>預裝 SSH&lt;/td>
 &lt;td>是&lt;/td>
 &lt;td>大多數發行版已預裝&lt;/td>
 &lt;td>Windows 10 1809+ 內建 OpenSSH&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="安全性建議">安全性建議&lt;/h2>
&lt;h3 id="優先使用-ed25519">優先使用 Ed25519&lt;/h3>
&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>&lt;strong>Ed25519&lt;/strong>&lt;/td>
 &lt;td>256 bit&lt;/td>
 &lt;td>高&lt;/td>
 &lt;td>最快&lt;/td>
 &lt;td>2014 年後的 OpenSSH 6.5+&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>RSA-4096&lt;/td>
 &lt;td>4096 bit&lt;/td>
 &lt;td>高&lt;/td>
 &lt;td>較慢&lt;/td>
 &lt;td>最廣泛，適合舊系統&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>ECDSA&lt;/td>
 &lt;td>256/384/521 bit&lt;/td>
 &lt;td>中高&lt;/td>
 &lt;td>快&lt;/td>
 &lt;td>已被 Ed25519 取代&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>DSA&lt;/td>
 &lt;td>1024 bit&lt;/td>
 &lt;td>低&lt;/td>
 &lt;td>-&lt;/td>
 &lt;td>已棄用，OpenSSH 7.0+ 預設停用&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="為私鑰設定-passphrase">為私鑰設定 Passphrase&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">&lt;span class="c1"># 為已存在的金鑰補設或更換 passphrase&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">ssh-keygen -p -f ~/.ssh/&amp;lt;key_name&amp;gt;&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>即使私鑰檔案被他人取得，沒有 passphrase 就無法使用。搭配 SSH Agent 後只需輸入一次，不影響日常使用體驗。&lt;/p></description><content:encoded><![CDATA[<h2 id="0-產生金鑰如果還沒有的話">0. 產生金鑰（如果還沒有的話）</h2>
<p>目前推薦使用 <strong>Ed25519</strong> 演算法，相比 RSA 更安全、金鑰更短、驗證速度更快：</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">ssh-keygen -t ed25519 -C <span class="s2">&#34;your_email@example.com&#34;</span></span></span></code></pre></div><blockquote>
<p>若需相容較舊的系統（不支援 Ed25519），可退而使用 RSA-4096：</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">ssh-keygen -t rsa -b <span class="m">4096</span> -C <span class="s2">&#34;your_email@example.com&#34;</span></span></span></code></pre></div></blockquote>
<p>產生時會提示設定 <strong>passphrase（密碼短語）</strong>，強烈建議設定。即使私鑰外洩，攻擊者仍需要密碼才能使用。</p>
<hr>
<h2 id="1-寫入金鑰檔案">1. 寫入金鑰檔案</h2>
<h3 id="macos--linux">macOS / Linux</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">cat &gt; ~/.ssh/&lt;key_name&gt; <span class="s">&lt;&lt; &#39;EOF&#39;
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="s">（貼上金鑰內容）
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="s">EOF</span></span></span></code></pre></div><blockquote>
<p><code>'EOF'</code> 加單引號 → 防止 shell 解析內容中的特殊字元（如 <code>$</code>、<code>`</code>）</p></blockquote>
<h3 id="windowspowershell">Windows（PowerShell）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="ln">1</span><span class="cl"><span class="nb">New-Item</span> <span class="n">-Path</span> <span class="s2">&#34;</span><span class="nv">$env:USERPROFILE</span><span class="s2">\.ssh&#34;</span> <span class="n">-ItemType</span> <span class="n">Directory</span> <span class="n">-Force</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">Set-Content</span> <span class="n">-Path</span> <span class="s2">&#34;</span><span class="nv">$env:USERPROFILE</span><span class="s2">\.ssh\&lt;key_name&gt;&#34;</span> <span class="n">-Value</span> <span class="sh">@&#34;
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="sh">（貼上金鑰內容）
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="sh">&#34;@</span></span></span></code></pre></div><blockquote>
<p>Windows 的 SSH 金鑰預設路徑為 <code>C:\Users\&lt;使用者&gt;\.ssh\</code></p></blockquote>
<hr>
<h2 id="2-設定權限">2. 設定權限</h2>
<h3 id="macos--linux-1">macOS / Linux</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">chmod <span class="m">600</span> ~/.ssh/&lt;key_name&gt;</span></span></code></pre></div><blockquote>
<p><code>chmod 600</code> → 僅擁有者可讀寫，SSH 要求私鑰權限不可過於開放，否則會拒絕使用。</p></blockquote>
<h3 id="windowspowershell以系統管理員執行">Windows（PowerShell，以系統管理員執行）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">icacls</span> <span class="s2">&#34;</span><span class="nv">$env:USERPROFILE</span><span class="s2">\.ssh\&lt;key_name&gt;&#34;</span> <span class="p">/</span><span class="n">inheritance</span><span class="err">:</span><span class="nb">r </span><span class="p">/</span><span class="n">grant</span><span class="err">:</span><span class="nb">r </span><span class="s2">&#34;</span><span class="p">$(</span><span class="nv">$env:USERNAME</span><span class="p">)</span><span class="s2">:(R)&#34;</span></span></span></code></pre></div><blockquote>
<p>Windows 需透過 <code>icacls</code> 移除繼承權限，並限制為只有當前使用者可讀取。
若權限過於開放，OpenSSH 同樣會拒絕載入金鑰。</p></blockquote>
<hr>
<h2 id="3-加入-ssh-agent">3. 加入 SSH Agent</h2>
<h3 id="macos">macOS</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"><span class="c1"># 啟動 agent（通常 macOS 已自動啟動）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">eval</span> <span class="s2">&#34;</span><span class="k">$(</span>ssh-agent -s<span class="k">)</span><span class="s2">&#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"># 加入金鑰，並存入 Keychain 避免重開機後失效</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">ssh-add --apple-use-keychain ~/.ssh/&lt;key_name&gt;</span></span></code></pre></div><p>若要讓金鑰在每次登入時自動載入，可在 <code>~/.ssh/config</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">Host *
</span></span><span class="line"><span class="ln">2</span><span class="cl">  AddKeysToAgent yes
</span></span><span class="line"><span class="ln">3</span><span class="cl">  UseKeychain yes
</span></span><span class="line"><span class="ln">4</span><span class="cl">  IdentityFile ~/.ssh/&lt;key_name&gt;</span></span></code></pre></div><h3 id="linux">Linux</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"><span class="c1"># 啟動 agent</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">eval</span> <span class="s2">&#34;</span><span class="k">$(</span>ssh-agent -s<span class="k">)</span><span class="s2">&#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"># 加入金鑰</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">ssh-add ~/.ssh/&lt;key_name&gt;</span></span></code></pre></div><blockquote>
<p>Linux 重開機後 agent 會重置。可將 <code>eval &quot;$(ssh-agent -s)&quot;</code> 加入 <code>~/.bashrc</code> 或 <code>~/.zshrc</code> 讓它自動啟動。</p></blockquote>
<h3 id="windowspowershell以系統管理員執行-1">Windows（PowerShell，以系統管理員執行）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-powershell" data-lang="powershell"><span class="line"><span class="ln">1</span><span class="cl"><span class="c"># 啟用 ssh-agent 服務（預設為停用）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">Get-Service</span> <span class="nb">ssh-agent</span> <span class="p">|</span> <span class="nb">Set-Service</span> <span class="n">-StartupType</span> <span class="n">Automatic</span> <span class="n">-PassThru</span> <span class="p">|</span> <span class="nb">Start-Service</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="c"># 加入金鑰</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nb">ssh-add</span> <span class="s2">&#34;</span><span class="nv">$env:USERPROFILE</span><span class="s2">\.ssh\&lt;key_name&gt;&#34;</span></span></span></code></pre></div><blockquote>
<p>Windows 的 <code>ssh-agent</code> 是系統服務，啟用後重開機也會自動執行，不需額外設定。</p></blockquote>
<hr>
<h2 id="4-測試連線">4. 測試連線</h2>
<p>三個平台指令相同：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">ssh -i ~/.ssh/&lt;key_name&gt; -T git@&lt;host&gt;</span></span></code></pre></div><blockquote>
<p>Windows 請在 PowerShell 或 Git Bash 中執行，路徑會自動對應到 <code>$env:USERPROFILE\.ssh\</code>。</p></blockquote>
<hr>
<h2 id="備註">備註</h2>
<table>
  <thead>
      <tr>
          <th>項目</th>
          <th>macOS</th>
          <th>Linux</th>
          <th>Windows</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>金鑰路徑</td>
          <td><code>~/.ssh/</code></td>
          <td><code>~/.ssh/</code></td>
          <td><code>C:\Users\&lt;使用者&gt;\.ssh\</code></td>
      </tr>
      <tr>
          <td>權限設定</td>
          <td><code>chmod 600</code></td>
          <td><code>chmod 600</code></td>
          <td><code>icacls</code> 移除繼承</td>
      </tr>
      <tr>
          <td>Agent 持久化</td>
          <td>Keychain</td>
          <td>需加入 shell rc</td>
          <td>系統服務，自動持久</td>
      </tr>
      <tr>
          <td>預裝 SSH</td>
          <td>是</td>
          <td>大多數發行版已預裝</td>
          <td>Windows 10 1809+ 內建 OpenSSH</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="安全性建議">安全性建議</h2>
<h3 id="優先使用-ed25519">優先使用 Ed25519</h3>
<table>
  <thead>
      <tr>
          <th>演算法</th>
          <th>金鑰長度</th>
          <th>安全性</th>
          <th>效能</th>
          <th>相容性</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Ed25519</strong></td>
          <td>256 bit</td>
          <td>高</td>
          <td>最快</td>
          <td>2014 年後的 OpenSSH 6.5+</td>
      </tr>
      <tr>
          <td>RSA-4096</td>
          <td>4096 bit</td>
          <td>高</td>
          <td>較慢</td>
          <td>最廣泛，適合舊系統</td>
      </tr>
      <tr>
          <td>ECDSA</td>
          <td>256/384/521 bit</td>
          <td>中高</td>
          <td>快</td>
          <td>已被 Ed25519 取代</td>
      </tr>
      <tr>
          <td>DSA</td>
          <td>1024 bit</td>
          <td>低</td>
          <td>-</td>
          <td>已棄用，OpenSSH 7.0+ 預設停用</td>
      </tr>
  </tbody>
</table>
<h3 id="為私鑰設定-passphrase">為私鑰設定 Passphrase</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"><span class="c1"># 為已存在的金鑰補設或更換 passphrase</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">ssh-keygen -p -f ~/.ssh/&lt;key_name&gt;</span></span></code></pre></div><p>即使私鑰檔案被他人取得，沒有 passphrase 就無法使用。搭配 SSH Agent 後只需輸入一次，不影響日常使用體驗。</p>
<h3 id="定期輪換金鑰">定期輪換金鑰</h3>
<p>建議每 1-2 年輪換一次 SSH 金鑰。可在金鑰名稱中加入年份作為提醒，例如 <code>id_ed25519_2026</code>。</p>
<h3 id="其他注意事項">其他注意事項</h3>
<ul>
<li><strong>不要將私鑰上傳到雲端同步服務</strong>（如 iCloud、Google Drive、OneDrive），除非經過加密</li>
<li><strong>不要在多台機器之間複製同一把私鑰</strong>，應為每台機器各自產生獨立金鑰</li>
<li><strong>避免使用已棄用的 DSA 金鑰</strong>，部分新版 OpenSSH 已預設拒絕 DSA</li>
<li><strong><code>~/.ssh/</code> 目錄本身權限應為 <code>700</code></strong>，<code>authorized_keys</code> 應為 <code>600</code></li>
</ul>
<hr>
<h2 id="常見問題排查">常見問題排查</h2>
<h3 id="permission-denied-publickey">Permission denied (publickey)</h3>
<ol>
<li>確認私鑰權限：<code>chmod 600 ~/.ssh/&lt;key_name&gt;</code></li>
<li>確認 <code>~/.ssh/</code> 目錄權限：<code>chmod 700 ~/.ssh</code></li>
<li>確認上傳的是 <strong>公鑰</strong>（<code>.pub</code>）而非私鑰</li>
<li>使用 verbose 模式查看詳細錯誤：<code>ssh -vvv user@host</code></li>
</ol>
<h3 id="agent-中沒有金鑰">Agent 中沒有金鑰</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"><span class="c1"># 確認 agent 是否正在運行</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">ssh-add -l
</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"># 如果顯示 &#34;Could not open a connection to your authentication agent&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nb">eval</span> <span class="s2">&#34;</span><span class="k">$(</span>ssh-agent -s<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">ssh-add ~/.ssh/&lt;key_name&gt;</span></span></code></pre></div><h3 id="金鑰類型被伺服器拒絕">金鑰類型被伺服器拒絕</h3>
<p>部分較新的伺服器已停用 DSA 和較短的 RSA 金鑰。檢查方式：</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">ssh -vvv user@host 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="p">|</span> grep <span class="s2">&#34;Offering&#34;</span></span></span></code></pre></div><p>如果你的金鑰類型不在伺服器接受的清單中，需要重新產生 Ed25519 金鑰。</p>
]]></content:encoded></item><item><title>Git：修復後面的 commit 意外覆蓋前面 commit 的變更</title><link>https://tarrragon.github.io/blog/work-log/git%E4%BF%AE%E5%BE%A9%E5%BE%8C%E9%9D%A2%E7%9A%84-commit-%E6%84%8F%E5%A4%96%E8%A6%86%E8%93%8B%E5%89%8D%E9%9D%A2-commit-%E7%9A%84%E8%AE%8A%E6%9B%B4/</link><pubDate>Tue, 24 Feb 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/git%E4%BF%AE%E5%BE%A9%E5%BE%8C%E9%9D%A2%E7%9A%84-commit-%E6%84%8F%E5%A4%96%E8%A6%86%E8%93%8B%E5%89%8D%E9%9D%A2-commit-%E7%9A%84%E8%AE%8A%E6%9B%B4/</guid><description>&lt;h2 id="問題情境">問題情境&lt;/h2>
&lt;p>使用 &lt;code>git commit --fixup&lt;/code> + &lt;code>git rebase --autosquash&lt;/code> 修改歷史 commit 後，
修改內容被後續的 commit 覆蓋，導致變更未生效。&lt;/p>
&lt;h3 id="範例">範例&lt;/h3>





&lt;pre tabindex="0">&lt;code class="language-mermaid" data-lang="mermaid">gitGraph
 commit id: &amp;#34;A (refactor)&amp;#34; type: HIGHLIGHT
 commit id: &amp;#34;B (fix)&amp;#34;
 commit id: &amp;#34;C (fix)&amp;#34;
 commit id: &amp;#34;D (feat)&amp;#34; type: REVERSE&lt;/code>&lt;/pre>&lt;blockquote>
&lt;p>HIGHLIGHT = 要修改的目標 commit（A）
REVERSE = 意外包含同一檔案變更的 commit（D）&lt;/p>&lt;/blockquote>
&lt;ul>
&lt;li>&lt;strong>目標&lt;/strong>：透過 fixup 修改 commit A，移除 &lt;code>table_service.dart&lt;/code> 中的 try-catch&lt;/li>
&lt;li>&lt;strong>問題&lt;/strong>：commit D 在開發時意外 stage 了 &lt;code>table_service.dart&lt;/code> 的變更，導致 rebase 後 commit D 重新套用了舊的內容，覆蓋了 commit A 的修改&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="解決方案從-commit-中移除不該包含的檔案">解決方案：從 commit 中移除不該包含的檔案&lt;/h2>
&lt;h3 id="前置確認">前置確認&lt;/h3>
&lt;p>先確認哪些 commit 修改了目標檔案：&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">git log --oneline -- lib/data/services/table/table_service.dart&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>輸出類似：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">de60cc9 feat: 追加多語系 ← 不該包含此檔案
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">b55e504 refactor: 各 Service 實作 ← 預期的修改&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>確認 commit D 確實不該包含該檔案後，進行修復。&lt;/p>
&lt;h3 id="步驟">步驟&lt;/h3>
&lt;h4 id="1-暫存目前的工作變更如果有的話">1. 暫存目前的工作變更（如果有的話）&lt;/h4>





&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 stash&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h4 id="2-啟動-interactive-rebase將目標-commit-標記為-edit">2. 啟動 interactive rebase，將目標 commit 標記為 edit&lt;/h4>





&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="nv">GIT_SEQUENCE_EDITOR&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;sed -i &amp;#39;&amp;#39; &amp;#39;1s/^pick/edit/&amp;#39;&amp;#34;&lt;/span> git rebase -i &amp;lt;目標commit&amp;gt;~1&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;blockquote>
&lt;p>&lt;strong>說明&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>&lt;code>&amp;lt;目標commit&amp;gt;~1&lt;/code> 表示從目標 commit 的&lt;strong>前一個&lt;/strong> commit 開始 rebase&lt;/li>
&lt;li>&lt;code>GIT_SEQUENCE_EDITOR=&amp;quot;sed -i '' '1s/^pick/edit/'&amp;quot;&lt;/code> 自動將第一行（目標 commit）從 &lt;code>pick&lt;/code> 改為 &lt;code>edit&lt;/code>，避免手動編輯&lt;/li>
&lt;li>macOS 的 &lt;code>sed -i&lt;/code> 需要 &lt;code>''&lt;/code> 參數，Linux 則不需要&lt;/li>
&lt;/ul>&lt;/blockquote>
&lt;p>以本例來說：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nv">GIT_SEQUENCE_EDITOR&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;sed -i &amp;#39;&amp;#39; &amp;#39;1s/^pick/edit/&amp;#39;&amp;#34;&lt;/span> git rebase -i de60cc9~1&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>執行後 Git 會停在 &lt;code>de60cc9&lt;/code>，等待你修改。&lt;/p>
&lt;p>此時的 commit 狀態：&lt;/p>





&lt;pre tabindex="0">&lt;code class="language-mermaid" data-lang="mermaid">gitGraph
 commit id: &amp;#34;A (refactor)&amp;#34;
 commit id: &amp;#34;B (fix)&amp;#34;
 commit id: &amp;#34;C (fix)&amp;#34;
 commit id: &amp;#34;D (feat)&amp;#34; type: REVERSE tag: &amp;#34;HEAD (edit)&amp;#34;&lt;/code>&lt;/pre>&lt;h4 id="3-將目標檔案還原到前一個-commit的狀態">3. 將目標檔案還原到「前一個 commit」的狀態&lt;/h4>





&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 checkout HEAD~1 -- &amp;lt;檔案路徑&amp;gt;&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>以本例來說：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">git checkout HEAD~1 -- lib/data/services/table/table_service.dart&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;blockquote>
&lt;p>&lt;strong>說明&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>&lt;code>HEAD~1&lt;/code> 是目標 commit 的前一個 commit&lt;/li>
&lt;li>這會把檔案還原到 commit D &lt;strong>之前&lt;/strong>的狀態，等於「撤銷 commit D 對這個檔案的修改」&lt;/li>
&lt;li>還原後檔案會自動被加入暫存區（staged）&lt;/li>
&lt;/ul>&lt;/blockquote>
&lt;p>此時可以用 &lt;code>git status&lt;/code> 確認狀態，應該會看到：&lt;/p></description><content:encoded><![CDATA[<h2 id="問題情境">問題情境</h2>
<p>使用 <code>git commit --fixup</code> + <code>git rebase --autosquash</code> 修改歷史 commit 後，
修改內容被後續的 commit 覆蓋，導致變更未生效。</p>
<h3 id="範例">範例</h3>





<pre tabindex="0"><code class="language-mermaid" data-lang="mermaid">gitGraph
   commit id: &#34;A (refactor)&#34; type: HIGHLIGHT
   commit id: &#34;B (fix)&#34;
   commit id: &#34;C (fix)&#34;
   commit id: &#34;D (feat)&#34; type: REVERSE</code></pre><blockquote>
<p>HIGHLIGHT = 要修改的目標 commit（A）
REVERSE = 意外包含同一檔案變更的 commit（D）</p></blockquote>
<ul>
<li><strong>目標</strong>：透過 fixup 修改 commit A，移除 <code>table_service.dart</code> 中的 try-catch</li>
<li><strong>問題</strong>：commit D 在開發時意外 stage 了 <code>table_service.dart</code> 的變更，導致 rebase 後 commit D 重新套用了舊的內容，覆蓋了 commit A 的修改</li>
</ul>
<hr>
<h2 id="解決方案從-commit-中移除不該包含的檔案">解決方案：從 commit 中移除不該包含的檔案</h2>
<h3 id="前置確認">前置確認</h3>
<p>先確認哪些 commit 修改了目標檔案：</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">git log --oneline -- lib/data/services/table/table_service.dart</span></span></code></pre></div><p>輸出類似：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">de60cc9 feat: 追加多語系           ← 不該包含此檔案
</span></span><span class="line"><span class="ln">2</span><span class="cl">b55e504 refactor: 各 Service 實作   ← 預期的修改</span></span></code></pre></div><p>確認 commit D 確實不該包含該檔案後，進行修復。</p>
<h3 id="步驟">步驟</h3>
<h4 id="1-暫存目前的工作變更如果有的話">1. 暫存目前的工作變更（如果有的話）</h4>





<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 stash</span></span></code></pre></div><h4 id="2-啟動-interactive-rebase將目標-commit-標記為-edit">2. 啟動 interactive rebase，將目標 commit 標記為 edit</h4>





<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="nv">GIT_SEQUENCE_EDITOR</span><span class="o">=</span><span class="s2">&#34;sed -i &#39;&#39; &#39;1s/^pick/edit/&#39;&#34;</span> git rebase -i &lt;目標commit&gt;~1</span></span></code></pre></div><blockquote>
<p><strong>說明</strong>：</p>
<ul>
<li><code>&lt;目標commit&gt;~1</code> 表示從目標 commit 的<strong>前一個</strong> commit 開始 rebase</li>
<li><code>GIT_SEQUENCE_EDITOR=&quot;sed -i '' '1s/^pick/edit/'&quot;</code> 自動將第一行（目標 commit）從 <code>pick</code> 改為 <code>edit</code>，避免手動編輯</li>
<li>macOS 的 <code>sed -i</code> 需要 <code>''</code> 參數，Linux 則不需要</li>
</ul></blockquote>
<p>以本例來說：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="nv">GIT_SEQUENCE_EDITOR</span><span class="o">=</span><span class="s2">&#34;sed -i &#39;&#39; &#39;1s/^pick/edit/&#39;&#34;</span> git rebase -i de60cc9~1</span></span></code></pre></div><p>執行後 Git 會停在 <code>de60cc9</code>，等待你修改。</p>
<p>此時的 commit 狀態：</p>





<pre tabindex="0"><code class="language-mermaid" data-lang="mermaid">gitGraph
   commit id: &#34;A (refactor)&#34;
   commit id: &#34;B (fix)&#34;
   commit id: &#34;C (fix)&#34;
   commit id: &#34;D (feat)&#34; type: REVERSE tag: &#34;HEAD (edit)&#34;</code></pre><h4 id="3-將目標檔案還原到前一個-commit的狀態">3. 將目標檔案還原到「前一個 commit」的狀態</h4>





<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 checkout HEAD~1 -- &lt;檔案路徑&gt;</span></span></code></pre></div><p>以本例來說：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git checkout HEAD~1 -- lib/data/services/table/table_service.dart</span></span></code></pre></div><blockquote>
<p><strong>說明</strong>：</p>
<ul>
<li><code>HEAD~1</code> 是目標 commit 的前一個 commit</li>
<li>這會把檔案還原到 commit D <strong>之前</strong>的狀態，等於「撤銷 commit D 對這個檔案的修改」</li>
<li>還原後檔案會自動被加入暫存區（staged）</li>
</ul></blockquote>
<p>此時可以用 <code>git status</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">interactive rebase in progress; onto &lt;hash&gt;
</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">Changes to be committed:
</span></span><span class="line"><span class="ln">4</span><span class="cl">  (use &#34;git restore --staged &lt;file&gt;...&#34; to unstage)
</span></span><span class="line"><span class="ln">5</span><span class="cl">        modified:   lib/data/services/table/table_service.dart</span></span></code></pre></div><h4 id="4-修改-commit-並繼續-rebase">4. 修改 commit 並繼續 rebase</h4>





<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 commit --amend --no-edit <span class="o">&amp;&amp;</span> git rebase --continue</span></span></code></pre></div><blockquote>
<p><strong>說明</strong>：</p>
<ul>
<li><code>--amend</code> 修改當前 commit（即 de60cc9）</li>
<li><code>--no-edit</code> 保留原本的 commit message 不變</li>
<li><code>git rebase --continue</code> 繼續處理後續的 commit</li>
</ul></blockquote>
<p>完成後的 commit 狀態：</p>





<pre tabindex="0"><code class="language-mermaid" data-lang="mermaid">gitGraph
   commit id: &#34;A (refactor)&#34; type: HIGHLIGHT
   commit id: &#34;B (fix)&#34;
   commit id: &#34;C (fix)&#34;
   commit id: &#34;D&#39; (feat)&#34; tag: &#34;HEAD&#34;</code></pre><blockquote>
<p>D 變為 D&rsquo;（新的 hash），不再包含 <code>table_service.dart</code> 的變更。</p></blockquote>
<h4 id="5-恢復暫存的工作變更如果有的話">5. 恢復暫存的工作變更（如果有的話）</h4>





<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 stash pop</span></span></code></pre></div><h3 id="驗證">驗證</h3>
<p>確認目標 commit 不再包含該檔案的修改：</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">git show &lt;新的commit hash&gt; --stat</span></span></code></pre></div><p>輸出的修改清單中不應出現 <code>table_service.dart</code>。</p>
<hr>
<h2 id="完整指令摘要">完整指令摘要</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 0. 暫存工作區</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">git stash
</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"># 1. 進入 interactive rebase，自動標記目標 commit 為 edit</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="nv">GIT_SEQUENCE_EDITOR</span><span class="o">=</span><span class="s2">&#34;sed -i &#39;&#39; &#39;1s/^pick/edit/&#39;&#34;</span> git rebase -i &lt;目標commit&gt;~1
</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"># 2. 還原該檔案到 commit 之前的狀態</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">git checkout HEAD~1 -- &lt;檔案路徑&gt;
</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"># 3. 修改 commit 並繼續 rebase</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">git commit --amend --no-edit <span class="o">&amp;&amp;</span> git rebase --continue
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"># 4. 恢復工作區</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">git stash pop
</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"># 5. 驗證</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">git show HEAD --stat</span></span></code></pre></div><hr>
<h2 id="衍伸搭配-fixup-的完整工作流程">衍伸：搭配 fixup 的完整工作流程</h2>
<p>當你需要<strong>修改歷史 commit A 的內容</strong>，但<strong>後面的 commit D 又意外包含同一個檔案的修改</strong>時：</p>
<h3 id="正確操作順序">正確操作順序</h3>





<pre tabindex="0"><code class="language-mermaid" data-lang="mermaid">flowchart LR
   Step1[&#34;① 清理 commit D\n移除不該包含的檔案&#34;] --&gt; Step2[&#34;② 修改 commit A\nfixup + autosquash&#34;]

   style Step1 fill:#e53e3e,color:#fff,stroke:#c53030
   style Step2 fill:#38a169,color:#fff,stroke:#2f855a</code></pre><blockquote>
<p>如果先做 fixup 再處理 commit D，fixup 的修改會被 commit D 覆蓋。
所以<strong>一定要先清理後面的 commit，再修改前面的 commit</strong>。</p></blockquote>
<h3 id="fixup--autosquash-參考指令">fixup + autosquash 參考指令</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"><span class="c1"># 建立 fixup commit（指向要修改的目標 commit）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git add &lt;修改的檔案&gt;
</span></span><span class="line"><span class="ln">3</span><span class="cl">git commit --fixup<span class="o">=</span>&lt;目標commit hash&gt;
</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="c1"># 執行 autosquash rebase（自動合併 fixup commit）</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="nv">GIT_SEQUENCE_EDITOR</span><span class="o">=</span><span class="nb">true</span> git rebase -i --autosquash &lt;目標commit&gt;~1</span></span></code></pre></div><blockquote>
<p><strong>說明</strong>：</p>
<ul>
<li><code>--fixup=&lt;hash&gt;</code> 會建立一個以 <code>fixup!</code> 為前綴的 commit</li>
<li><code>--autosquash</code> 會自動將 fixup commit 排到目標 commit 後面並標記為 fixup</li>
<li><code>GIT_SEQUENCE_EDITOR=true</code> 跳過編輯器，直接執行（因為 autosquash 已經排好了）</li>
</ul></blockquote>
<hr>
<h2 id="注意事項">注意事項</h2>
<ul>
<li>這些操作會<strong>改寫 git 歷史</strong>，只適用於尚未 push 到遠端的 commit（或你有權 force push 的分支）</li>
<li>操作前建議用 <code>git log --oneline -10</code> 確認目前的 commit 順序</li>
<li>如果 rebase 過程中遇到衝突，用 <code>git status</code> 查看衝突檔案，手動解決後執行 <code>git add</code> + <code>git rebase --continue</code></li>
<li>如果想放棄 rebase，可以用 <code>git rebase --abort</code> 回到操作前的狀態</li>
</ul>
]]></content:encoded></item><item><title>Git Filter-Repo 使用說明</title><link>https://tarrragon.github.io/blog/work-log/git-filter-repo-%E4%BD%BF%E7%94%A8%E8%AA%AA%E6%98%8E/</link><pubDate>Tue, 20 Jan 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/git-filter-repo-%E4%BD%BF%E7%94%A8%E8%AA%AA%E6%98%8E/</guid><description>&lt;p>&lt;code>git filter-repo&lt;/code> 是一個強大的工具，用於重寫 Git 歷史記錄。它比 &lt;code>git filter-branch&lt;/code> 更快、更安全，是官方推薦的替代方案。&lt;/p>
&lt;h2 id="目錄">目錄&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="#%e5%ae%89%e8%a3%9d">安裝&lt;/a>&lt;/li>
&lt;li>&lt;a href="#%e5%9f%ba%e6%9c%ac%e6%a6%82%e5%bf%b5">基本概念&lt;/a>&lt;/li>
&lt;li>&lt;a href="#%e5%b8%b8%e7%94%a8%e6%93%8d%e4%bd%9c">常用操作&lt;/a>
&lt;ul>
&lt;li>&lt;a href="#%e7%a7%bb%e9%99%a4%e6%aa%94%e6%a1%88%e6%88%96%e8%b3%87%e6%96%99%e5%a4%be">移除檔案或資料夾&lt;/a>&lt;/li>
&lt;li>&lt;a href="#%e5%8f%aa%e4%bf%9d%e7%95%99%e7%89%b9%e5%ae%9a%e8%b7%af%e5%be%91">只保留特定路徑&lt;/a>&lt;/li>
&lt;li>&lt;a href="#%e9%87%8d%e6%96%b0%e5%91%bd%e5%90%8d%e6%aa%94%e6%a1%88%e6%88%96%e8%b3%87%e6%96%99%e5%a4%be">重新命名檔案或資料夾&lt;/a>&lt;/li>
&lt;li>&lt;a href="#%e4%bf%ae%e6%94%b9-commit-%e8%a8%8a%e6%81%af">修改 commit 訊息&lt;/a>&lt;/li>
&lt;li>&lt;a href="#%e4%bf%ae%e6%94%b9%e4%bd%9c%e8%80%85%e8%b3%87%e8%a8%8a">修改作者資訊&lt;/a>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;a href="#%e9%80%b2%e9%9a%8e%e6%93%8d%e4%bd%9c">進階操作&lt;/a>&lt;/li>
&lt;li>&lt;a href="#%e6%b3%a8%e6%84%8f%e4%ba%8b%e9%a0%85">注意事項&lt;/a>&lt;/li>
&lt;li>&lt;a href="#%e5%b8%b8%e8%a6%8b%e5%95%8f%e9%a1%8c">常見問題&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="安裝">安裝&lt;/h2>
&lt;h3 id="macos">macOS&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">&lt;span class="c1"># 使用 Homebrew&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">brew install git-filter-repo
&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"># 或使用 pip&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">pip3 install git-filter-repo&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="linux">Linux&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">&lt;span class="c1"># Ubuntu/Debian&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">sudo apt install git-filter-repo
&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"># 或使用 pip&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">pip3 install git-filter-repo&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="windows">Windows&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">&lt;span class="c1"># 使用 pip&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">pip install git-filter-repo&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="基本概念">基本概念&lt;/h2>
&lt;h3 id="什麼時候使用-git-filter-repo">什麼時候使用 git filter-repo？&lt;/h3>
&lt;ul>
&lt;li>從歷史記錄中移除敏感資訊（密碼、API 金鑰等）&lt;/li>
&lt;li>移除不小心 commit 的大型檔案&lt;/li>
&lt;li>將子目錄拆分成獨立的 repository&lt;/li>
&lt;li>合併多個 repository&lt;/li>
&lt;li>批量修改 commit 作者資訊&lt;/li>
&lt;/ul>
&lt;h3 id="重要提醒">重要提醒&lt;/h3>
&lt;p>注意：&lt;code>git filter-repo&lt;/code> 會&lt;strong>重寫 Git 歷史&lt;/strong>，這意味著：&lt;/p>
&lt;ol>
&lt;li>所有 commit hash 都會改變&lt;/li>
&lt;li>需要 force push 到遠端&lt;/li>
&lt;li>其他協作者需要重新 clone 或 reset&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="常用操作">常用操作&lt;/h2>
&lt;h3 id="移除檔案或資料夾">移除檔案或資料夾&lt;/h3>
&lt;h4 id="移除單一檔案">移除單一檔案&lt;/h4>





&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 filter-repo --invert-paths --path path/to/file.txt --force&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h4 id="移除資料夾">移除資料夾&lt;/h4>





&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 filter-repo --invert-paths --path path/to/folder --force&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h4 id="移除多個路徑">移除多個路徑&lt;/h4>





&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 filter-repo --invert-paths --path .env --path secrets/ --path config/credentials.json --force&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h4 id="使用-glob-模式移除">使用 glob 模式移除&lt;/h4>





&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"># 移除所有 .log 檔案&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">git filter-repo --invert-paths --path-glob &lt;span class="s1">&amp;#39;*.log&amp;#39;&lt;/span> --force
&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"># 移除所有 node_modules 資料夾&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">git filter-repo --invert-paths --path-glob &lt;span class="s1">&amp;#39;**/node_modules/*&amp;#39;&lt;/span> --force&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h4 id="使用正規表達式移除">使用正規表達式移除&lt;/h4>





&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"># 移除所有 .env 開頭的檔案&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">git filter-repo --invert-paths --path-regex &lt;span class="s1">&amp;#39;^\.env.*&amp;#39;&lt;/span> --force&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="只保留特定路徑">只保留特定路徑&lt;/h3>
&lt;p>將 repository 縮減為只包含特定資料夾（適用於拆分專案）：&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"># 只保留 src 資料夾&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">git filter-repo --path src --force
&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"># 保留多個路徑&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">git filter-repo --path src --path docs --path README.md --force&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="重新命名檔案或資料夾">重新命名檔案或資料夾&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 將 old-name 重新命名為 new-name&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">git filter-repo --path-rename old-name:new-name --force
&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"># 將資料夾移動到子目錄&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">git filter-repo --path-rename src:app/src --force
&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">git filter-repo --to-subdirectory-filter my-subdir --force&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="修改-commit-訊息">修改 commit 訊息&lt;/h3>
&lt;p>建立一個 Python 腳本 &lt;code>message-callback.py&lt;/code>：&lt;/p></description><content:encoded><![CDATA[<p><code>git filter-repo</code> 是一個強大的工具，用於重寫 Git 歷史記錄。它比 <code>git filter-branch</code> 更快、更安全，是官方推薦的替代方案。</p>
<h2 id="目錄">目錄</h2>
<ul>
<li><a href="#%e5%ae%89%e8%a3%9d">安裝</a></li>
<li><a href="#%e5%9f%ba%e6%9c%ac%e6%a6%82%e5%bf%b5">基本概念</a></li>
<li><a href="#%e5%b8%b8%e7%94%a8%e6%93%8d%e4%bd%9c">常用操作</a>
<ul>
<li><a href="#%e7%a7%bb%e9%99%a4%e6%aa%94%e6%a1%88%e6%88%96%e8%b3%87%e6%96%99%e5%a4%be">移除檔案或資料夾</a></li>
<li><a href="#%e5%8f%aa%e4%bf%9d%e7%95%99%e7%89%b9%e5%ae%9a%e8%b7%af%e5%be%91">只保留特定路徑</a></li>
<li><a href="#%e9%87%8d%e6%96%b0%e5%91%bd%e5%90%8d%e6%aa%94%e6%a1%88%e6%88%96%e8%b3%87%e6%96%99%e5%a4%be">重新命名檔案或資料夾</a></li>
<li><a href="#%e4%bf%ae%e6%94%b9-commit-%e8%a8%8a%e6%81%af">修改 commit 訊息</a></li>
<li><a href="#%e4%bf%ae%e6%94%b9%e4%bd%9c%e8%80%85%e8%b3%87%e8%a8%8a">修改作者資訊</a></li>
</ul>
</li>
<li><a href="#%e9%80%b2%e9%9a%8e%e6%93%8d%e4%bd%9c">進階操作</a></li>
<li><a href="#%e6%b3%a8%e6%84%8f%e4%ba%8b%e9%a0%85">注意事項</a></li>
<li><a href="#%e5%b8%b8%e8%a6%8b%e5%95%8f%e9%a1%8c">常見問題</a></li>
</ul>
<hr>
<h2 id="安裝">安裝</h2>
<h3 id="macos">macOS</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"><span class="c1"># 使用 Homebrew</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">brew install git-filter-repo
</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"># 或使用 pip</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">pip3 install git-filter-repo</span></span></code></pre></div><h3 id="linux">Linux</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"><span class="c1"># Ubuntu/Debian</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">sudo apt install git-filter-repo
</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"># 或使用 pip</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">pip3 install git-filter-repo</span></span></code></pre></div><h3 id="windows">Windows</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"><span class="c1"># 使用 pip</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">pip install git-filter-repo</span></span></code></pre></div><hr>
<h2 id="基本概念">基本概念</h2>
<h3 id="什麼時候使用-git-filter-repo">什麼時候使用 git filter-repo？</h3>
<ul>
<li>從歷史記錄中移除敏感資訊（密碼、API 金鑰等）</li>
<li>移除不小心 commit 的大型檔案</li>
<li>將子目錄拆分成獨立的 repository</li>
<li>合併多個 repository</li>
<li>批量修改 commit 作者資訊</li>
</ul>
<h3 id="重要提醒">重要提醒</h3>
<p>注意：<code>git filter-repo</code> 會<strong>重寫 Git 歷史</strong>，這意味著：</p>
<ol>
<li>所有 commit hash 都會改變</li>
<li>需要 force push 到遠端</li>
<li>其他協作者需要重新 clone 或 reset</li>
</ol>
<hr>
<h2 id="常用操作">常用操作</h2>
<h3 id="移除檔案或資料夾">移除檔案或資料夾</h3>
<h4 id="移除單一檔案">移除單一檔案</h4>





<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 filter-repo --invert-paths --path path/to/file.txt --force</span></span></code></pre></div><h4 id="移除資料夾">移除資料夾</h4>





<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 filter-repo --invert-paths --path path/to/folder --force</span></span></code></pre></div><h4 id="移除多個路徑">移除多個路徑</h4>





<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 filter-repo --invert-paths --path .env --path secrets/ --path config/credentials.json --force</span></span></code></pre></div><h4 id="使用-glob-模式移除">使用 glob 模式移除</h4>





<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"># 移除所有 .log 檔案</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git filter-repo --invert-paths --path-glob <span class="s1">&#39;*.log&#39;</span> --force
</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"># 移除所有 node_modules 資料夾</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">git filter-repo --invert-paths --path-glob <span class="s1">&#39;**/node_modules/*&#39;</span> --force</span></span></code></pre></div><h4 id="使用正規表達式移除">使用正規表達式移除</h4>





<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"># 移除所有 .env 開頭的檔案</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git filter-repo --invert-paths --path-regex <span class="s1">&#39;^\.env.*&#39;</span> --force</span></span></code></pre></div><h3 id="只保留特定路徑">只保留特定路徑</h3>
<p>將 repository 縮減為只包含特定資料夾（適用於拆分專案）：</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"># 只保留 src 資料夾</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git filter-repo --path src --force
</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">git filter-repo --path src --path docs --path README.md --force</span></span></code></pre></div><h3 id="重新命名檔案或資料夾">重新命名檔案或資料夾</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 將 old-name 重新命名為 new-name</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git filter-repo --path-rename old-name:new-name --force
</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">git filter-repo --path-rename src:app/src --force
</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">git filter-repo --to-subdirectory-filter my-subdir --force</span></span></code></pre></div><h3 id="修改-commit-訊息">修改 commit 訊息</h3>
<p>建立一個 Python 腳本 <code>message-callback.py</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># message-callback.py</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kn">import</span> <span class="nn">re</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="k">def</span> <span class="nf">message_callback</span><span class="p">(</span><span class="n">message</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="c1"># 將 &#34;bug&#34; 替換為 &#34;fix&#34;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="k">return</span> <span class="n">re</span><span class="o">.</span><span class="n">sub</span><span class="p">(</span><span class="sa">b</span><span class="s1">&#39;bug&#39;</span><span class="p">,</span> <span class="sa">b</span><span class="s1">&#39;fix&#39;</span><span class="p">,</span> <span class="n">message</span><span class="p">)</span></span></span></code></pre></div><p>執行：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git filter-repo --message-callback <span class="s1">&#39;
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="s1">    return message.replace(b&#34;bug&#34;, b&#34;fix&#34;)
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="s1">&#39;</span> --force</span></span></code></pre></div><p>或使用外部檔案：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git filter-repo --message-callback <span class="s2">&#34;</span><span class="k">$(</span>cat message-callback.py<span class="k">)</span><span class="s2">&#34;</span> --force</span></span></code></pre></div><h3 id="修改作者資訊">修改作者資訊</h3>
<h4 id="使用-mailmap-檔案">使用 mailmap 檔案</h4>
<p>建立 <code>.mailmap</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">New Name &lt;new@email.com&gt; Old Name &lt;old@email.com&gt;</span></span></code></pre></div><p>執行：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git filter-repo --mailmap .mailmap --force</span></span></code></pre></div><h4 id="使用-callback-函數">使用 callback 函數</h4>





<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 filter-repo --name-callback <span class="s1">&#39;
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="s1">    return name.replace(b&#34;OldName&#34;, b&#34;NewName&#34;)
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="s1">&#39;</span> --email-callback <span class="s1">&#39;
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="s1">    return email.replace(b&#34;old@email.com&#34;, b&#34;new@email.com&#34;)
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="s1">&#39;</span> --force</span></span></code></pre></div><hr>
<h2 id="進階操作">進階操作</h2>
<h3 id="移除大型檔案">移除大型檔案</h3>
<p>找出大型檔案：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git filter-repo --analyze
</span></span><span class="line"><span class="ln">2</span><span class="cl">cat .git/filter-repo/analysis/blob-shas-and-paths.txt <span class="p">|</span> head -20</span></span></code></pre></div><p>移除超過特定大小的檔案：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git filter-repo --strip-blobs-bigger-than 10M --force</span></span></code></pre></div><h3 id="替換敏感內容">替換敏感內容</h3>
<p>建立替換規則檔案 <code>replacements.txt</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">regex</span><span class="o">:</span><span class="nx">password</span><span class="o">=</span><span class="p">.</span><span class="o">*=</span><span class="p">=&gt;</span><span class="nx">password</span><span class="o">=</span><span class="nx">REDACTED</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">literal</span><span class="o">:</span><span class="nx">my</span><span class="o">-</span><span class="nx">secret</span><span class="o">-</span><span class="nx">api</span><span class="o">-</span><span class="nx">key</span><span class="o">==&gt;</span><span class="nx">API_KEY_REMOVED</span></span></span></code></pre></div><p>執行：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git filter-repo --replace-text replacements.txt --force</span></span></code></pre></div><h3 id="只處理部分歷史">只處理部分歷史</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 只處理最近的 commit</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git filter-repo --refs HEAD~10..HEAD --path sensitive-file --invert-paths --force
</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">git filter-repo --refs main --path old-folder --invert-paths --force</span></span></code></pre></div><h3 id="保留備份">保留備份</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 在操作前建立備份分支</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git branch backup-before-filter
</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"># 或 clone 一份完整備份</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">git clone --mirror original-repo backup-repo</span></span></code></pre></div><hr>
<h2 id="注意事項">注意事項</h2>
<h3 id="操作前檢查清單">操作前檢查清單</h3>
<ul>
<li><input disabled="" type="checkbox"> 確保工作目錄是乾淨的（<code>git status</code> 無未 commit 的變更）</li>
<li><input disabled="" type="checkbox"> 建立備份（branch 或完整 clone）</li>
<li><input disabled="" type="checkbox"> 確認沒有其他人正在使用這個 repository</li>
<li><input disabled="" type="checkbox"> 了解 force push 的影響</li>
</ul>
<h3 id="remote-會被移除">Remote 會被移除</h3>
<p><code>git filter-repo</code> 執行後會<strong>自動移除 <code>origin</code> remote</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">NOTICE: Removing <span class="s1">&#39;origin&#39;</span> remote<span class="p">;</span> see <span class="s1">&#39;Why is my origin removed?&#39;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">        in the manual <span class="k">if</span> you want to push back there.</span></span></code></pre></div><h4 id="為什麼要移除-remote">為什麼要移除 Remote？</h4>
<p>這是 <code>git filter-repo</code> 的<strong>安全機制設計</strong>，目的是保護你和你的團隊：</p>
<ol>
<li>
<p><strong>防止意外推送</strong>：重寫歷史後，所有 commit hash 都會改變。如果你不小心直接執行 <code>git push</code>，會把重寫後的歷史推送到遠端，可能覆蓋其他人的工作，造成嚴重問題。</p>
</li>
<li>
<p><strong>強迫你停下來思考</strong>：移除 remote 後，你必須：</p>
<ul>
<li>確認是否真的要推送重寫後的歷史</li>
<li>手動重新加入 remote</li>
<li>明確使用 <code>--force</code> 參數推送</li>
</ul>
</li>
<li>
<p><strong>給你機會通知團隊</strong>：在重新加入 remote 和 force push 之前，你有機會先通知其他協作者，讓他們做好準備。</p>
</li>
</ol>
<h4 id="如何處理">如何處理</h4>
<p>執行完 <code>git filter-repo</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"><span class="c1"># 1. 重新加入 remote</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git remote add origin &lt;repository-url&gt;
</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"># 2. 確認 remote 已加入</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">git remote -v
</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"># 3. Force push（確認團隊已知情後再執行）</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">git push origin --force --all</span></span></code></pre></div><h3 id="force-push">Force Push</h3>
<p>重寫歷史後需要 force push：</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"># Push 所有分支</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git push origin --force --all
</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"># Push 所有 tags</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">git push origin --force --tags</span></span></code></pre></div><h3 id="通知協作者">通知協作者</h3>
<p>其他協作者需要執行以下操作來同步：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 方法一：重新 clone（推薦）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git clone &lt;repository-url&gt;
</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"># 方法二：強制重設（注意：會丟失本地未 push 的變更）</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">git fetch --all
</span></span><span class="line"><span class="ln">6</span><span class="cl">git reset --hard origin/&lt;branch-name&gt;</span></span></code></pre></div><hr>
<h2 id="常見問題">常見問題</h2>
<h3 id="q-執行時出現-refusing-to-run-without-fresh-clone-錯誤">Q: 執行時出現 &ldquo;Refusing to run without fresh clone&rdquo; 錯誤</h3>
<p>這是安全機制，使用 <code>--force</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">git filter-repo --invert-paths --path file.txt --force</span></span></code></pre></div><h3 id="q-如何還原操作">Q: 如何還原操作？</h3>
<p>如果有備份分支：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git checkout backup-before-filter
</span></span><span class="line"><span class="ln">2</span><span class="cl">git branch -D main
</span></span><span class="line"><span class="ln">3</span><span class="cl">git checkout -b main</span></span></code></pre></div><p>如果有備份 repository：</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">git remote add backup /path/to/backup-repo
</span></span><span class="line"><span class="ln">2</span><span class="cl">git fetch backup
</span></span><span class="line"><span class="ln">3</span><span class="cl">git reset --hard backup/main</span></span></code></pre></div><h3 id="q-為什麼我的-repository-大小沒有變小">Q: 為什麼我的 repository 大小沒有變小？</h3>
<p>執行以下命令來清理：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git reflog expire --expire<span class="o">=</span>now --all
</span></span><span class="line"><span class="ln">2</span><span class="cl">git gc --prune<span class="o">=</span>now --aggressive</span></span></code></pre></div><h3 id="q-可以只影響特定分支嗎">Q: 可以只影響特定分支嗎？</h3>
<p>可以，使用 <code>--refs</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">git filter-repo --invert-paths --path file.txt --refs main --force</span></span></code></pre></div><h3 id="q-如何預覽變更而不實際執行">Q: 如何預覽變更而不實際執行？</h3>
<p>使用 <code>--dry-run</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">git filter-repo --invert-paths --path file.txt --dry-run</span></span></code></pre></div><hr>
<h2 id="參考資源">參考資源</h2>
<ul>
<li><a href="https://github.com/newren/git-filter-repo">官方文件</a></li>
<li><a href="https://htmlpreview.github.io/?https://github.com/newren/git-filter-repo/blob/docs/html/git-filter-repo.html">官方手冊</a></li>
<li><a href="https://github.com/newren/git-filter-repo/blob/main/Documentation/converting-from-filter-branch.md">常見使用案例</a></li>
</ul>
]]></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>