<?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>Dart on Tarragon</title><link>https://tarrragon.github.io/blog/tags/dart/</link><description>Recent content in Dart on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Tue, 30 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/dart/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>為什麼這個場景適合用高階函式？以 Flutter 設定更新為例，比較 typedef 改寫前後</title><link>https://tarrragon.github.io/blog/work-log/dart_hof_typedef_readability/</link><pubDate>Mon, 01 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/dart_hof_typedef_readability/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>核心議題&lt;/strong>：高階函式是特定場景的自然解 — 當「流程固定、變化點單一且開放」時，把變化點抽成函式參數最省。要不要用它，由場景特徵決定。本文先論證這個場景為何適合 HOF，再比較同一 pattern 的兩種表達（裸函式型別 vs &lt;code>typedef&lt;/code>）各自的優缺點。
&lt;strong>案例骨幹&lt;/strong>：&lt;code>SettingsController.update(transform)&lt;/code> — 9 個設定欄位共用同一條「取值→算新值→去重→通知」流程，唯一的變化是「改哪個欄位」。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="1-案例一個收函式的設定更新方法">1. 案例：一個收函式的設定更新方法&lt;/h2>
&lt;p>設定有 9 個欄位（字型、顏色、描邊、時間格式、目標螢幕、開機啟動…）。每個欄位變更都要走同一串流程：取當前設定 → 算出新設定 → 比對是否確實改變 → 賦回並通知 UI 重繪。把這串流程封裝成一個方法：&lt;/p>





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





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





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





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





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





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





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





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





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





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





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





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Popup.hint() {
</span></span><span class="line"><span class="ln">2</span><span class="cl">  try {
</span></span><span class="line"><span class="ln">3</span><span class="cl">    BotToast.showNotification(...)   ← 同步返回，立刻離開 try
</span></span><span class="line"><span class="ln">4</span><span class="cl">      └─ 內部排到下一個 frame 或 microtask {  ← 之後才跑
</span></span><span class="line"><span class="ln">5</span><span class="cl">           ...拋 LateInitializationError...   ← try-catch 已經出 scope
</span></span><span class="line"><span class="ln">6</span><span class="cl">         }
</span></span><span class="line"><span class="ln">7</span><span class="cl">  } catch (e) { ... }
</span></span><span class="line"><span class="ln">8</span><span class="cl">}</span></span></code></pre></div><p>辨識 async unhandled error 的訊號是 stack trace 裡有 <code>===== asynchronous gap =====</code> — 它代表錯誤穿越了一個 async 邊界。從 caller frame 來看「沒人在 stack 上」，錯誤會上溯到 zone 的 uncaught error handler；root zone 把它印到 stderr，或讓 flutter_test runner 當作 test failure。</p>
<p><code>async</code> 函式內的 try-catch 是常見混淆點：寫成 <code>try { await x; } catch (e)</code> 時，try-catch <strong>能</strong>接住 <code>await</code> 的 future rejection（<code>await</code> 把 async error rewire 成 sync throw）。但對沒 await 的 fire-and-forget 排程（直接呼叫一個會內部 schedule microtask 的 API），try-catch 的覆蓋範圍止於同步路徑。</p>
<h3 id="風險fire-and-forget-api-的-error-路徑跨-async-邊界">風險：fire-and-forget API 的 error 路徑跨 async 邊界</h3>
<p>BotToast、analytics、Toast、SnackBar 這類 API 通常<strong>同步返回</strong>（讓 caller 不必 await），內部排到下一個 frame 或 microtask 做 UI 工作。caller 看到的是同步呼叫，但錯誤可能從 async 邊界後跑出來。caller 端的 sync try-catch 看起來罩住了，實際接不到。</p>
<hr>
<h2 id="4-接管機制runzonedguarded-同時罩-sync-與-async">4. 接管機制：runZonedGuarded 同時罩 sync 與 async</h2>
<p>接 async unhandled error 要用 zone-aware 機制。<code>runZonedGuarded(body, onError)</code> 建立一個子 zone，<strong>任何在這個 zone 內 schedule 的 async work，錯誤都會冒泡到 <code>onError</code></strong> — 不管錯誤穿越幾層 microtask、Timer、Stream。它同時也 cover 同步拋錯，可以取代 try-catch 包住整個 best-effort 邊界：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// toast 是 best-effort：BotToast 需要 widget tree (BotToastInit)，
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1">// 在非 UI 環境（unit test、isolate）顯示失敗時保留 log、不向 caller 傳遞錯誤。
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1">// 用 runZonedGuarded 因為 BotToast 部分錯誤從 async gap 後拋出，sync try-catch 接不到。
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span><span class="n">runZonedGuarded</span><span class="p">(()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="n">BotToast</span><span class="p">.</span><span class="n">showNotification</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nl">title:</span> <span class="p">(</span><span class="n">_</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">Text</span><span class="p">(</span><span class="n">message</span><span class="p">,</span> <span class="nl">style:</span> <span class="n">AppTheme</span><span class="p">.</span><span class="n">whiteTextButtonStyle</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nl">backgroundColor:</span> <span class="n">contentColor</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nl">duration:</span> <span class="kd">const</span> <span class="n">Duration</span><span class="p">(</span><span class="nl">seconds:</span> <span class="m">2</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nl">animationDuration:</span> <span class="kd">const</span> <span class="n">Duration</span><span class="p">(</span><span class="nl">milliseconds:</span> <span class="m">300</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nl">animationReverseDuration:</span> <span class="kd">const</span> <span class="n">Duration</span><span class="p">(</span><span class="nl">milliseconds:</span> <span class="m">300</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="p">);</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">},</span> <span class="p">(</span><span class="n">error</span><span class="p">,</span> <span class="n">stack</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="n">kDebugMode</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="n">debugPrint</span><span class="p">(</span><span class="s1">&#39;[Popup.hint][fallback] BotToast 不可用，僅記 log：</span><span class="si">$</span><span class="n">error</span><span class="s1">&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>機制重點：同一個 <code>onError</code> 同時接住同步的 <code>Failed assertion</code> 與 async 的 <code>LateInitializationError</code> — sync 與 async 兩條失敗路徑收斂到單一 fallback handler，不需要為兩條各寫一套錯誤處理。</p>
<hr>
<h2 id="5-runzonedguarded-的責任邊界">5. runZonedGuarded 的責任邊界</h2>
<p><code>runZonedGuarded</code> 把整個邊界的錯誤導向 fallback handler，責任範圍要劃清楚：</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>行為</th>
          <th>設計意涵</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>async work 自己處理掉錯誤（try-catch 或 <code>.catchError</code>）</td>
          <td>接不到</td>
          <td>zone 看不到已被吞的錯誤；要 zone 接，內層別自吞</td>
      </tr>
      <tr>
          <td><code>onError</code> handler 自己拋錯</td>
          <td>上溯到 parent zone</td>
          <td>handler 要簡短可靠；fallback 自己掛是上層責任</td>
      </tr>
      <tr>
          <td>同步拋錯</td>
          <td>也會被接住</td>
          <td>zone 同時 cover sync 與 async，可取代 try-catch</td>
      </tr>
      <tr>
          <td>zone 內建立的 Timer / Stream</td>
          <td>屬於這個 zone</td>
          <td>spawn 出的 async 物件「記得」自己屬於哪個 zone</td>
      </tr>
  </tbody>
</table>
<p><strong>zone ≠ thread</strong>。Dart 是單線程的，zone 只是邏輯標籤、不涉及並發。它<strong>只改變錯誤的去向、不會 cancel 已 schedule 的 work</strong>。</p>
<h3 id="注意事項何時不該用">注意事項：何時不該用</h3>
<p>Zone 歸屬以 schedule 時的 zone 為準、不是執行時 — async 物件「屬於」schedule 它的那個 zone。這個規則讓跨 zone 操作 Timer、Stream 的行為偏離直覺。實務上最常見的觸發場景是 <code>WidgetsFlutterBinding.ensureInitialized()</code> 在 root zone 註冊了 framework binding 後、才用 <code>runZonedGuarded</code> 包 <code>runApp</code>，binding 內部 callback 已綁在 root zone、外層 zone 接不到。<a href="https://docs.flutter.dev/release/breaking-changes/zone-errors">Flutter 官方明確建議</a> <code>ensureInitialized()</code> 跟 <code>runApp()</code> 都在同一個 <code>runZonedGuarded</code> 內。</p>
<p>zone 適合包「整個邊界」：整個 isolate entry、整個 best-effort UI 工作、整個 background task。<strong>不適合包關鍵 transaction logic</strong> — 那是 try-catch + Future error handling 的責任，zone 是 fallback 收斂層、不是主要錯誤處理。</p>
<hr>
<h2 id="6-fallback-訊息設計可識別的-signature">6. Fallback 訊息設計：可識別的 signature</h2>
<p>Fallback path 跑通之後，留在 console 的訊息會被讀到很多次（每次 test 都會跑）。<strong>訊息措辭要與設計意圖一致</strong>，否則讀者每次都要花心力辨識「這是設計內降級、還是真的 bug」。</p>
<h3 id="風險fallback-長得像-error">風險：fallback 長得像 error</h3>
<p>直覺寫法 <code>debugPrint('toast 顯示失敗：$error')</code> 加上 framework 的 assert stack，字面看起來就是個 error。讀者第一眼會緊張、要花心力比對程式才能確認「這是設計內路徑」。test 跑很多次、每次都付一次辨識成本。</p>
<h3 id="三條設計原則">三條設計原則</h3>
<p><strong>Fallback path 要有可識別的 signature</strong>（標籤、prefix、特定字眼）、長得不像 error。對人類讀者，prefix 是視覺上一眼識別「設計內路徑」；對工具，<code>grep -v &quot;\[fallback\]&quot;</code> 可快速剔除 test 輸出裡的預期降級訊息。</p>
<p><strong>字眼要表達因果與處置</strong>：「BotToast 不可用，僅記 log」比「顯示失敗」更完整 — 前者說了為什麼降級、後者只描述現象。寫 fallback 訊息要回答兩個問題：為什麼進這條路徑、降級到哪。</p>
<p><strong>主程式不該感知測試框架</strong>：主程式 import <code>dart:io</code>、查 <code>Platform.environment['FLUTTER_TEST']</code> 等於「主程式對自己被 test 跑」有意識 — 這違反「主程式不該知道 test 存在」的原則，test 框架是 caller 的事、不是 callee 的事。違反後續成本：app 行為依賴環境變數時，QA / staging / production 的環境一致性會多一條檢查線。</p>
<h3 id="三個候選方案在原則上的取捨">三個候選方案在原則上的取捨</h3>
<p>下列三個方案分別在「signature 識別度」「主程式對 test 框架感知」「dev 可見性」三條原則上做不同取捨：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>A. 標籤化（<code>[fallback]</code> prefix）</th>
          <th>B. 偵測 <code>FLUTTER_TEST</code> 環境 silent</th>
          <th>C. 完全靜默</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>改動大小</td>
          <td>+1 行</td>
          <td>~10 行 + 新 import</td>
          <td>−1 行</td>
      </tr>
      <tr>
          <td>test 輸出乾淨度</td>
          <td>仍有訊息，但 prefix 一眼識別</td>
          <td>完全乾淨</td>
          <td>完全乾淨</td>
      </tr>
      <tr>
          <td>dev app 跑時可見性</td>
          <td>保留</td>
          <td>保留</td>
          <td>失去</td>
      </tr>
      <tr>
          <td>主程式對 test 框架的感知</td>
          <td>無</td>
          <td>有（import dart:io 查 env）</td>
          <td>無</td>
      </tr>
      <tr>
          <td>grep 友善度</td>
          <td>好（<code>[fallback]</code> prefix）</td>
          <td>—</td>
          <td>—</td>
      </tr>
      <tr>
          <td>BotToast 真壞時 debug 難度</td>
          <td>容易（訊號 + 標籤）</td>
          <td>中（test 看不到、要切環境）</td>
          <td>難（無線索）</td>
      </tr>
  </tbody>
</table>
<h3 id="為什麼選-a">為什麼選 A</h3>
<p>保留 dev 訊號（BotToast 在 dev app 真的壞時 console 仍會印） + 主程式對 test 框架無感知 + prefix 雙贏（人類視覺辨識 + grep 過濾）。方案 C 完全靜默會失去保險、dev 環境真壞時看不見；方案 B 雖然 test 輸出乾淨，代價是違反設計原則。</p>
<hr>
<h2 id="7-設計副產物修主程式對缺依賴的容錯">7. 設計副產物：修主程式對缺依賴的容錯</h2>
<p><code>Popup.hint</code> 對「沒有 widget tree」連環倒，這個失敗不只 unit test 會遇到 — isolate 內、background task 內、任何非 UI 環境都會炸。修 test 順手把主程式對缺依賴的容錯加上，是合理副產物：unit test 是觸發訊號、主程式被觸發後變得更能適應多元 caller 環境，這個改動的受益面大於原本 test 暴露的那個情境。</p>
<p><strong>主程式變 robust 的價值大於「讓 test 過」</strong>。修主程式對 caller 環境的容錯時要分辨「容錯」與「掩蓋」的界線：log 仍要留、fallback signature 仍要可識別（Fallback 訊息設計段），錯誤完全靜默會讓 dev app 真壞掉時也看不見。</p>
<hr>
<h2 id="適用範圍">適用範圍</h2>
<p><code>runZonedGuarded</code> 適用情境：</p>
<ul>
<li><strong>Fire-and-forget 的 UI 通知</strong>：Toast、SnackBar、analytics 上報；這些是 best-effort，caller 連環倒不合理。</li>
<li><strong>Isolate entry point</strong>：spawn 出來的 isolate 沒有預設 error handler，包一層 zone 才不會靜默掛掉。</li>
<li><strong>Background task / Timer 包裝</strong>：long-running periodic job 內部錯誤不該炸掉整個 process。</li>
<li><strong>flutter_test 內掛 Stream / Future 驗證</strong>：把測試體包進 zone 才能完整接 async 拋出的東西。</li>
</ul>
<p>「Type system 看不到的 runtime contract」適用任何用 service locator / DI 容器、framework state、late init 的 Flutter 專案。Test 是這些 runtime contract 的事實驗證者 — analyze 過了不代表這些契約沒破，test 跑到才會炸。</p>
<hr>
<h2 id="參考資料">參考資料</h2>
<ul>
<li><a href="https://api.dart.dev/stable/dart-async/runZonedGuarded.html">Dart <code>runZonedGuarded</code> API</a></li>
<li><a href="https://dart.dev/articles/archive/zones">Dart Zone 概念與 zone-local variables</a></li>
<li><a href="https://docs.flutter.dev/release/breaking-changes/zone-errors">Flutter Zone mismatch breaking change</a> — <code>ensureInitialized()</code> 與 <code>runApp()</code> 必須同 zone</li>
<li><a href="https://api.flutter.dev/flutter/flutter_test/FlutterTest-library.html"><code>flutter_test</code> async error 處理機制</a></li>
<li>同主題本站文章：<a href="../dart_test_getx_cross_file_state_pollution/">Dart test 的跨檔案 GetX 狀態污染</a> — 另一種「test 環境組裝不完整」的 case</li>
</ul>
]]></content:encoded></item><item><title>三 MCP 工作流與 Dart 實測：cbm / codegraph / serena 的職責分工與三刀流</title><link>https://tarrragon.github.io/blog/record/%E4%B8%89-mcp-%E5%B7%A5%E4%BD%9C%E6%B5%81%E8%88%87-dart-%E5%AF%A6%E6%B8%ACcbm-/-codegraph-/-serena-%E7%9A%84%E8%81%B7%E8%B2%AC%E5%88%86%E5%B7%A5%E8%88%87%E4%B8%89%E5%88%80%E6%B5%81/</link><pubDate>Mon, 25 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/record/%E4%B8%89-mcp-%E5%B7%A5%E4%BD%9C%E6%B5%81%E8%88%87-dart-%E5%AF%A6%E6%B8%ACcbm-/-codegraph-/-serena-%E7%9A%84%E8%81%B7%E8%B2%AC%E5%88%86%E5%B7%A5%E8%88%87%E4%B8%89%E5%88%80%E6%B5%81/</guid><description>&lt;h2 id="為什麼需要對照為什麼選-dart">為什麼需要對照、為什麼選 Dart&lt;/h2>
&lt;p>評估 code intelligence MCP 不能只看 README benchmark：每個工具的 benchmark 都選自己擅長的 codebase 跟語言，readme 數字只能參考、不能直接套到自家 stack。&lt;/p>
&lt;p>這次選一個 Dart 商業專案做對照場域有兩個理由：&lt;/p>
&lt;ul>
&lt;li>Dart 是三個工具的「中間地帶」——&lt;a href="https://tarrragon.github.io/blog/record/codebase-memory-mcp155-%E8%AA%9E%E8%A8%80-tree-sitter-%E7%9F%A5%E8%AD%98%E5%9C%96%E8%AD%9C-mcp-%E7%9A%84%E8%83%BD%E5%8A%9B%E8%88%87%E9%82%8A%E7%95%8C/">cbm&lt;/a> 不在 hybrid resolution 名單、&lt;a href="https://tarrragon.github.io/blog/record/codegraph%E7%94%A8-tree-sitter-per-language-query-%E6%92%90%E8%B5%B7-19-%E8%AA%9E%E8%A8%80-call-graph-%E7%9A%84-mcp/">codegraph&lt;/a> 列為 full support、&lt;a href="https://tarrragon.github.io/blog/record/serena%E6%8A%8A-lsp-%E5%8C%85%E6%88%90-agent-first-mcp-%E7%9A%84-symbol-level-%E7%B7%A8%E8%BC%AF%E6%96%B9%E6%A1%88/">serena&lt;/a> 借 &lt;code>dart analysis_server&lt;/code> 有完整 LSP。三條技術路線在同一語言上的能力差距會被最大化。&lt;/li>
&lt;li>Dart 大量用 extension type、generic、factory pattern，這些是 type-inferred dispatch 的高發場景，能逼出每個工具的真實精度差。&lt;/li>
&lt;/ul>
&lt;p>在 Go / TypeScript 上跑同樣對照，結論會反過來——cbm 的 hybrid resolution 在那裡會接近 LSP 精度，三刀流的必要性會降低。所以這篇結論限定「LSP 成熟但 cbm 不在 hybrid resolution 名單」的語言。&lt;/p>
&lt;h2 id="本質差異tree-sitter-syntactic-vs-lsp-type-aware">本質差異：tree-sitter syntactic vs LSP type-aware&lt;/h2>
&lt;p>三個工具在 Dart 上的能力差距，根源是兩條技術路線的本質落差：&lt;/p>
&lt;p>&lt;strong>tree-sitter syntactic&lt;/strong>：只看語法結構。看到 &lt;code>a.b()&lt;/code> 知道有個 method call、不知道 &lt;code>a&lt;/code> 是什麼型別、不知道 &lt;code>b()&lt;/code> 連到哪個 declaration。對 receiver 是 literal 或顯式型別宣告的 callsite 可以解、對 local variable / parameter / 推斷型別的 callsite 會漏。&lt;/p>
&lt;p>&lt;strong>LSP type-aware&lt;/strong>：走 language server 內建的型別推斷引擎。跟 IDE 用同一套後端、能解出 &lt;code>a&lt;/code> 的真實型別、再從 type declaration 找到對應的 method。所以 reference 是型別精確的。&lt;/p>
&lt;p>cbm 的 hybrid type resolution（限 Go / C / C++ / TS / JS）是把 LSP 的型別解析算法 clean-room 重寫進 binary、所以那幾個語言上 cbm 等於有 LSP 級精度但沒 LSP 依賴。Dart 沒得到這個待遇，所以 cbm 在 Dart 上只剩純 syntactic 結構抽取。&lt;/p>
&lt;p>判讀訊號：看一個工具對某語言的能力強弱，問「&lt;strong>它在這語言上做型別解析嗎？&lt;/strong>」——做的話接近 LSP，不做的話只是個結構抽取器。&lt;/p>
&lt;p>這個 framework 建立後、下節展開到 9 個維度的設計對照。&lt;/p>
&lt;h2 id="三個工具的設計差異對照">三個工具的設計差異對照&lt;/h2>
&lt;p>三個工具雖然都是「code intelligence MCP」，設計取向互補：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>&lt;a href="https://tarrragon.github.io/blog/record/codebase-memory-mcp155-%E8%AA%9E%E8%A8%80-tree-sitter-%E7%9F%A5%E8%AD%98%E5%9C%96%E8%AD%9C-mcp-%E7%9A%84%E8%83%BD%E5%8A%9B%E8%88%87%E9%82%8A%E7%95%8C/">cbm&lt;/a>&lt;/th>
 &lt;th>&lt;a href="https://tarrragon.github.io/blog/record/codegraph%E7%94%A8-tree-sitter-per-language-query-%E6%92%90%E8%B5%B7-19-%E8%AA%9E%E8%A8%80-call-graph-%E7%9A%84-mcp/">codegraph&lt;/a>&lt;/th>
 &lt;th>&lt;a href="https://tarrragon.github.io/blog/record/serena%E6%8A%8A-lsp-%E5%8C%85%E6%88%90-agent-first-mcp-%E7%9A%84-symbol-level-%E7%B7%A8%E8%BC%AF%E6%96%B9%E6%A1%88/">serena&lt;/a>&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>解析後端&lt;/td>
 &lt;td>tree-sitter + 自寫 type resolver&lt;/td>
 &lt;td>tree-sitter + per-language query&lt;/td>
 &lt;td>LSP（per-language server）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>語言覆蓋&lt;/td>
 &lt;td>155（vendored grammar）&lt;/td>
 &lt;td>19+（每語言寫 query）&lt;/td>
 &lt;td>視 LSP 支援度（40+）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>持久化&lt;/td>
 &lt;td>SQLite + WAL（可 zstd 匯出為 team artifact）&lt;/td>
 &lt;td>SQLite + FTS5&lt;/td>
 &lt;td>per-session、不持久化&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sync 機制&lt;/td>
 &lt;td>背景 git polling&lt;/td>
 &lt;td>native OS file watcher 2s debounce&lt;/td>
 &lt;td>session warm-up&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Type resolution&lt;/td>
 &lt;td>Go / C / C++ / TS / JS 有 hybrid、其他語言只有 syntactic&lt;/td>
 &lt;td>tree-sitter syntactic 為主、聲稱對部分 dynamic dispatch 有解&lt;/td>
 &lt;td>完整 LSP 型別解析&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>跨 service&lt;/td>
 &lt;td>first-class HTTP_CALLS edge + channel&lt;/td>
 &lt;td>route definition 識別、不做 client URL → server route 比對&lt;/td>
 &lt;td>無&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>概念性自然語言搜尋&lt;/td>
 &lt;td>11-signal scoring + camel split&lt;/td>
 &lt;td>symbol pattern match&lt;/td>
 &lt;td>無&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Symbol-level 編輯&lt;/td>
 &lt;td>無（純讀）&lt;/td>
 &lt;td>無（純讀）&lt;/td>
 &lt;td>完整（replace_symbol_body / rename）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>編譯 diagnostic&lt;/td>
 &lt;td>無&lt;/td>
 &lt;td>無&lt;/td>
 &lt;td>有（&lt;code>get_diagnostics_for_file&lt;/code>）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這張表的判讀重點：&lt;strong>三者擅長的事不重疊&lt;/strong>。cbm 強在「找東西」、codegraph 強在「日常 call graph + auto-sync」、serena 強在「型別精確 reference + 編輯出口」。&lt;/p></description><content:encoded><![CDATA[<h2 id="為什麼需要對照為什麼選-dart">為什麼需要對照、為什麼選 Dart</h2>
<p>評估 code intelligence MCP 不能只看 README benchmark：每個工具的 benchmark 都選自己擅長的 codebase 跟語言，readme 數字只能參考、不能直接套到自家 stack。</p>
<p>這次選一個 Dart 商業專案做對照場域有兩個理由：</p>
<ul>
<li>Dart 是三個工具的「中間地帶」——<a href="/blog/record/codebase-memory-mcp155-%E8%AA%9E%E8%A8%80-tree-sitter-%E7%9F%A5%E8%AD%98%E5%9C%96%E8%AD%9C-mcp-%E7%9A%84%E8%83%BD%E5%8A%9B%E8%88%87%E9%82%8A%E7%95%8C/">cbm</a> 不在 hybrid resolution 名單、<a href="/blog/record/codegraph%E7%94%A8-tree-sitter-per-language-query-%E6%92%90%E8%B5%B7-19-%E8%AA%9E%E8%A8%80-call-graph-%E7%9A%84-mcp/">codegraph</a> 列為 full support、<a href="/blog/record/serena%E6%8A%8A-lsp-%E5%8C%85%E6%88%90-agent-first-mcp-%E7%9A%84-symbol-level-%E7%B7%A8%E8%BC%AF%E6%96%B9%E6%A1%88/">serena</a> 借 <code>dart analysis_server</code> 有完整 LSP。三條技術路線在同一語言上的能力差距會被最大化。</li>
<li>Dart 大量用 extension type、generic、factory pattern，這些是 type-inferred dispatch 的高發場景，能逼出每個工具的真實精度差。</li>
</ul>
<p>在 Go / TypeScript 上跑同樣對照，結論會反過來——cbm 的 hybrid resolution 在那裡會接近 LSP 精度，三刀流的必要性會降低。所以這篇結論限定「LSP 成熟但 cbm 不在 hybrid resolution 名單」的語言。</p>
<h2 id="本質差異tree-sitter-syntactic-vs-lsp-type-aware">本質差異：tree-sitter syntactic vs LSP type-aware</h2>
<p>三個工具在 Dart 上的能力差距，根源是兩條技術路線的本質落差：</p>
<p><strong>tree-sitter syntactic</strong>：只看語法結構。看到 <code>a.b()</code> 知道有個 method call、不知道 <code>a</code> 是什麼型別、不知道 <code>b()</code> 連到哪個 declaration。對 receiver 是 literal 或顯式型別宣告的 callsite 可以解、對 local variable / parameter / 推斷型別的 callsite 會漏。</p>
<p><strong>LSP type-aware</strong>：走 language server 內建的型別推斷引擎。跟 IDE 用同一套後端、能解出 <code>a</code> 的真實型別、再從 type declaration 找到對應的 method。所以 reference 是型別精確的。</p>
<p>cbm 的 hybrid type resolution（限 Go / C / C++ / TS / JS）是把 LSP 的型別解析算法 clean-room 重寫進 binary、所以那幾個語言上 cbm 等於有 LSP 級精度但沒 LSP 依賴。Dart 沒得到這個待遇，所以 cbm 在 Dart 上只剩純 syntactic 結構抽取。</p>
<p>判讀訊號：看一個工具對某語言的能力強弱，問「<strong>它在這語言上做型別解析嗎？</strong>」——做的話接近 LSP，不做的話只是個結構抽取器。</p>
<p>這個 framework 建立後、下節展開到 9 個維度的設計對照。</p>
<h2 id="三個工具的設計差異對照">三個工具的設計差異對照</h2>
<p>三個工具雖然都是「code intelligence MCP」，設計取向互補：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th><a href="/blog/record/codebase-memory-mcp155-%E8%AA%9E%E8%A8%80-tree-sitter-%E7%9F%A5%E8%AD%98%E5%9C%96%E8%AD%9C-mcp-%E7%9A%84%E8%83%BD%E5%8A%9B%E8%88%87%E9%82%8A%E7%95%8C/">cbm</a></th>
          <th><a href="/blog/record/codegraph%E7%94%A8-tree-sitter-per-language-query-%E6%92%90%E8%B5%B7-19-%E8%AA%9E%E8%A8%80-call-graph-%E7%9A%84-mcp/">codegraph</a></th>
          <th><a href="/blog/record/serena%E6%8A%8A-lsp-%E5%8C%85%E6%88%90-agent-first-mcp-%E7%9A%84-symbol-level-%E7%B7%A8%E8%BC%AF%E6%96%B9%E6%A1%88/">serena</a></th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>解析後端</td>
          <td>tree-sitter + 自寫 type resolver</td>
          <td>tree-sitter + per-language query</td>
          <td>LSP（per-language server）</td>
      </tr>
      <tr>
          <td>語言覆蓋</td>
          <td>155（vendored grammar）</td>
          <td>19+（每語言寫 query）</td>
          <td>視 LSP 支援度（40+）</td>
      </tr>
      <tr>
          <td>持久化</td>
          <td>SQLite + WAL（可 zstd 匯出為 team artifact）</td>
          <td>SQLite + FTS5</td>
          <td>per-session、不持久化</td>
      </tr>
      <tr>
          <td>Sync 機制</td>
          <td>背景 git polling</td>
          <td>native OS file watcher 2s debounce</td>
          <td>session warm-up</td>
      </tr>
      <tr>
          <td>Type resolution</td>
          <td>Go / C / C++ / TS / JS 有 hybrid、其他語言只有 syntactic</td>
          <td>tree-sitter syntactic 為主、聲稱對部分 dynamic dispatch 有解</td>
          <td>完整 LSP 型別解析</td>
      </tr>
      <tr>
          <td>跨 service</td>
          <td>first-class HTTP_CALLS edge + channel</td>
          <td>route definition 識別、不做 client URL → server route 比對</td>
          <td>無</td>
      </tr>
      <tr>
          <td>概念性自然語言搜尋</td>
          <td>11-signal scoring + camel split</td>
          <td>symbol pattern match</td>
          <td>無</td>
      </tr>
      <tr>
          <td>Symbol-level 編輯</td>
          <td>無（純讀）</td>
          <td>無（純讀）</td>
          <td>完整（replace_symbol_body / rename）</td>
      </tr>
      <tr>
          <td>編譯 diagnostic</td>
          <td>無</td>
          <td>無</td>
          <td>有（<code>get_diagnostics_for_file</code>）</td>
      </tr>
  </tbody>
</table>
<p>這張表的判讀重點：<strong>三者擅長的事不重疊</strong>。cbm 強在「找東西」、codegraph 強在「日常 call graph + auto-sync」、serena 強在「型別精確 reference + 編輯出口」。</p>
<p>對照表的維度很多、但實務上踩到事故的多半集中在三個維度，把它們各自展開：</p>
<p><strong>Type resolution 決定 caller 數字的可信度</strong>。Dart / Swift / Kotlin 這類「LSP 完整、但 cbm 走純 syntactic 路線」的語言上、tree-sitter 工具回的 caller 數字是 lower bound（實際值通常更高）。<code>samplePrice.multiplyByRate(...)</code> 這種 type-inferred receiver 是這層差距的主戰場。判讀訊號：對熱門 class 跑同一 query、若 tree-sitter 工具 caller 數比 LSP 工具低過半、type-inferred dispatch 在這語言是主流模式、tree-sitter 結果只能當 starting point。</p>
<p><strong>Sync 機制決定「邊改邊問」是否可用</strong>。codegraph 的 native OS file watcher + 2s debounce 最貼近 IDE、cbm 的背景 git polling 有秒級至分級延遲、serena 的 session warm-up 是「啟動時等一次、之後即時」。事故型態：在 codegraph 改完檔案立刻問 caller 多半 OK、在 cbm 立刻問會拿到 stale graph。判讀訊號：問完 query 對結果存疑時、先檢查工具的 sync 狀態（cbm 跑 <code>index_status</code>、codegraph 跑 <code>codegraph_status</code>、serena 直接重 query）。</p>
<p><strong>持久化模式決定跨 session 的累積成本</strong>。cbm / codegraph 寫 SQLite、跨 session 重用；serena per-session、每次 spawn LSP warm up。對「短任務反覆 ad-hoc 查詢」cbm / codegraph 邊際成本更低、對「會做 symbol-level edit 跟 diagnostic」serena 的 per-session warm up 是必要 cost。判讀訊號：第一次 query 慢、之後快——LSP indexing warm up、正常；每次 query 都慢——LSP 可能因記憶體不足重啟、需排查。</p>
<p>下面的實測是這張表在 Dart 上的數字驗證。</p>
<h2 id="dart-實測對照同題不同工具">Dart 實測對照：同題不同工具</h2>
<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">專案類型：Dart 商業專案（POS / 零售領域）
</span></span><span class="line"><span class="ln">2</span><span class="cl">Branch：refactor/money-value-object
</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">  cbm:        3,038 nodes,  6,355 edges（Dart 沒 CALLS edge）
</span></span><span class="line"><span class="ln">5</span><span class="cl">  codegraph:  6,244 nodes, 12,223 edges（含 CALLS edge）
</span></span><span class="line"><span class="ln">6</span><span class="cl">  serena:     per-session、無索引統計</span></span></code></pre></div><p>cbm 跟 codegraph 的 nodes 約 2x、edges 約 2x，差異關鍵不在 nodes（cbm 缺 import / enum_member 等次要 node）、而在「<strong>有沒有 CALLS edge</strong>」——這直接決定 caller / impact 類查詢能不能用。</p>
<blockquote>
<p><strong>實測數字的適用範圍</strong>：本節的所有 callsite / caller / impact 數字（含查詢 1-5）都是<strong>單一 Dart 商業專案的內部 baseline</strong>、不保證跨專案重現。Dart 上 type-inferred receiver 比例高的專案會放大三個工具的差距、比例低的專案會縮小差距。換到 Swift / Kotlin / Rust 等語言上、絕對數字會不同但「tree-sitter syntactic vs LSP type-aware」的差距方向通常一致。讀者要套用結論時、先在自家 repo 跑一遍同題對照、看自己的數字落差。</p></blockquote>
<h3 id="查詢-1誰呼叫了-moneymultiplybyrate">查詢 1：誰呼叫了 <code>Money.multiplyByRate</code></h3>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>結果</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>cbm</td>
          <td>0（hybrid resolution 不含 Dart）</td>
      </tr>
      <tr>
          <td>codegraph</td>
          <td>3 caller symbols（4 個檔案中漏 product.dart 的 3 個 callsite）</td>
      </tr>
      <tr>
          <td>serena</td>
          <td>4 個檔案、9 個 callsite</td>
      </tr>
  </tbody>
</table>
<p>codegraph 漏掉的 3 個 callsite 共同特徵：</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">// lib/data/models/product/product.dart
</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">Money</span> <span class="n">samplePrice</span> <span class="o">=</span> <span class="p">...;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">samplePrice</span><span class="p">.</span><span class="n">multiplyByRate</span><span class="p">(</span><span class="n">Decimal</span><span class="p">.</span><span class="n">parse</span><span class="p">(</span><span class="s1">&#39;0.9&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">samplePrice</span><span class="p">.</span><span class="n">multiplyByRate</span><span class="p">(</span><span class="n">Decimal</span><span class="p">.</span><span class="n">parse</span><span class="p">(</span><span class="s1">&#39;0.6&#39;</span><span class="p">));</span></span></span></code></pre></div><p><code>samplePrice</code> 是 local variable、要型別推斷才知道是 <code>Money</code>。tree-sitter 看到的只是 <code>&lt;identifier&gt;.multiplyByRate(...)</code>、解不出 dispatch target。</p>
<p>serena 透過 <code>dart analysis_server</code> 拿到完整型別資訊、知道 <code>samplePrice</code> 宣告是 <code>Money</code>、能精確 dispatch。</p>
<h3 id="查詢-2誰呼叫了-localesymbolconfigformatamount">查詢 2：誰呼叫了 <code>LocaleSymbolConfig.formatAmount</code></h3>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>結果</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>cbm</td>
          <td>0</td>
      </tr>
      <tr>
          <td>codegraph</td>
          <td>30（<code>--limit 30</code>，預設 20 截斷）</td>
      </tr>
      <tr>
          <td>serena</td>
          <td>5 個檔案、21 個 callsite</td>
      </tr>
  </tbody>
</table>
<p>這題 codegraph 跟 serena 的差距比較小——<code>formatAmount</code> 在很多地方是用顯式 receiver 呼叫（如 <code>LocaleSymbolConfig.cny.formatAmount(...)</code>），tree-sitter 對顯式 receiver 解得到。</p>
<p>兩邊數字的差異主因是 <strong>caller symbol 數 vs callsite 數</strong>的計數單位差：</p>
<ul>
<li>codegraph 算 caller symbol（一個 method 內呼叫幾次都算 1）</li>
<li>serena 算 callsite</li>
</ul>
<p>寫實測 baseline 時這個單位要寫死、否則 3 vs 9 看起來像精度差距、實際上一部分只是計數規則不同。</p>
<h3 id="查詢-3money-符號的內部結構">查詢 3：<code>Money</code> 符號的內部結構</h3>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>結果</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>cbm</td>
          <td>只認得 File / Module、extension type 子結構抽不到</td>
      </tr>
      <tr>
          <td>codegraph</td>
          <td>認得 class 但 extension type 支援度未驗證</td>
      </tr>
      <tr>
          <td>serena</td>
          <td>Namespace kind、3 個 Field、16 個 Method、3 個 Property 都附行號</td>
      </tr>
  </tbody>
</table>
<p>Dart <code>extension type</code> 是相對新的特性、tree-sitter grammar 對它的支援深度不一。serena 走 LSP 直接拿到 <code>dart analysis_server</code> 對 extension type 的完整解析。</p>
<p>對需要「列出某 class / extension 所有 member」的場景、serena 是 Dart 上 LSP 級精度最可信的選項（其他 MCP 在 Dart extension type 上做不到完整 member 列舉）。</p>
<h3 id="查詢-4概念性搜尋金額顯示相關函式">查詢 4：概念性搜尋「金額顯示」相關函式</h3>
<p>對「我不知道精確名稱、只記得功能類別」這種 query：</p>
<table>
  <thead>
      <tr>
          <th>名次</th>
          <th>cbm（11-signal scoring）</th>
          <th>codegraph_search</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1-4</td>
          <td>4 個 <code>formatAmount</code> 實作（兩邊一致）</td>
          <td>4 個 <code>formatAmount</code> 實作（兩邊一致）</td>
      </tr>
      <tr>
          <td>5</td>
          <td><code>externalDisplayMain</code></td>
          <td><code>displayCategories</code></td>
      </tr>
      <tr>
          <td>6</td>
          <td><code>connectExternalDisplay</code></td>
          <td><code>displayTags</code></td>
      </tr>
      <tr>
          <td>7</td>
          <td><code>_buildQuantityDisplay</code></td>
          <td><code>displayName</code></td>
      </tr>
      <tr>
          <td>8</td>
          <td><code>connectExternalDisplay</code>（另一個）</td>
          <td><code>displayCover</code></td>
      </tr>
      <tr>
          <td>9</td>
          <td><code>getBalanceDisplay</code></td>
          <td><code>displayName</code>（另一個）</td>
      </tr>
      <tr>
          <td>10</td>
          <td><code>_buildPriceDisplay</code></td>
          <td><code>displayName</code>（另一個）</td>
      </tr>
  </tbody>
</table>
<p>前 4 名兩邊都抓到核心 <code>formatAmount</code> 實作，第 5 名後分歧明顯：</p>
<ul>
<li>cbm 補進的 <code>getBalanceDisplay</code> / <code>_buildPriceDisplay</code> / <code>connectExternalDisplay</code> 都跟「金額顯示」概念相關（顯示金額 / 顯示餘額 / 外接顯示器）</li>
<li>codegraph 補進的 <code>displayName</code> / <code>displayTags</code> 只是符號名含 &ldquo;display&rdquo; 子字串、跟金額無關</li>
</ul>
<p>差異來源是 cbm 的 11-signal scoring + <code>cbm_camel_split</code> 對 camelCase 切詞做語意切分（<code>getMoneyField</code> → <code>get</code> + <code>money</code> + <code>field</code>）。codegraph 的 search 是 symbol pattern match、沒對自然語言 query 做語意處理。</p>
<p>這題的判讀很關鍵——<strong>cbm 在「找東西」的角色不能被 codegraph 取代</strong>。即使 codegraph 在 Dart 上有可用的 call graph、它的 search 仍然贏不了 cbm 的概念性 query。</p>
<h3 id="查詢-5money-的-impact-範圍--cross-symbol-trace">查詢 5：<code>Money</code> 的 impact 範圍 / cross-symbol trace</h3>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>結果</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>cbm</td>
          <td>無 impact 概念、回不出</td>
      </tr>
      <tr>
          <td>codegraph</td>
          <td>5 個 affected symbol、全在 MoneyFieldRenderer 一檔</td>
      </tr>
      <tr>
          <td>serena</td>
          <td>走 <code>find_referencing_symbols</code> 跨 4 個檔案找完整 reference</td>
      </tr>
  </tbody>
</table>
<p>Money 是該專案大量使用的 value object、實際被使用的檔案橫跨 receipt_data 實作、settlement、cart_item、order_dto 等業務模組。codegraph 只回 1 個檔案 5 個 symbol、嚴重低估 blast radius。</p>
<p>漏掉的原因跟查詢 1 同源——<code>something.multiplyByRate(...)</code>、<code>Money</code> 在 factory 內被隱式構造這些都不在 tree-sitter 能解的範圍。MoneyFieldRenderer 之所以被抓到、是因為它的 field 顯式宣告為 <code>Money</code>，這是少數 tree-sitter syntactic 能抓的場合。</p>
<p>對 cross-symbol trace：</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">codegraph_trace(from: &#34;Money/multiplyByRate&#34;, to: &#34;ProductSpecification&#34;)
</span></span><span class="line"><span class="ln">2</span><span class="cl">→ &#34;No direct path&#34;、建議跳到 dynamic dispatch</span></span></code></pre></div><p>graph 上根本沒這條 edge（漏掉的 product.dart 那 3 個 callsite 正是這條 trace 的關鍵跳）、所以 trace 直接失敗。</p>
<p>判讀訊號：<strong>重要 refactor 不能單看 codegraph 的 impact 數字</strong>。要走 serena <code>find_referencing_symbols</code> 二次確認；對 cbm 不在 hybrid resolution 名單的語言、blast radius 必須用 LSP 工具驗證。</p>
<h2 id="三刀流工作流">三刀流工作流</h2>
<p>實測結論：cbm / codegraph / serena 各有不可替代的角色，組合使用才是 Dart 主力專案的合理 stack。</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">找東西（不知道精確名稱、概念性 query）
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  → cbm search_graph(query=&#34;...&#34;)           ← 11-signal scoring 對概念性 query 最強
</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">知道精確名稱、找 caller / callee
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  → codegraph_callers / codegraph_callees   ← auto-sync 2s 反應最快
</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">  發現結果可能不完整（type-inferred dispatch 多的場合）
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  → serena find_referencing_symbols         ← LSP 完整精度補位
</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">重要 refactor 確認 blast radius
</span></span><span class="line"><span class="ln">11</span><span class="cl">  → serena find_referencing_symbols         ← 不能單靠 codegraph_impact
</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">  → serena replace_symbol_body / rename     ← symbol-level atomic edit
</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">跨 service HTTP/RPC 鏈接（若 monorepo 含 client + server）
</span></span><span class="line"><span class="ln">17</span><span class="cl">  → cbm HTTP_CALLS edge                     ← 三個工具中只有 cbm 有這層</span></span></code></pre></div><p>幾個關鍵的判讀原則：</p>
<p><strong>入口跟出口要分清楚</strong>：cbm 是「廣度索引 + 模糊搜尋」的入口、拿到 qualified name 後轉給 serena 做精確查詢與編輯。codegraph 補在中間、做日常結構查詢。</p>
<p><strong>重要 refactor 必走 serena 補位</strong>：codegraph 的 caller / impact 在 Dart 上系統性偏低、不能單看數字判斷影響範圍。決定 rename 或大幅修改 method 之前、用 serena 跑一次 <code>find_referencing_symbols</code> 對齊。</p>
<p><strong>Hook 不要打架</strong>：cbm 會寫 PreToolUse hook 攔截 Grep / Glob / Read / Search（README 描述只擋前兩者、實裝版本含 Read / Search）、codegraph / serena 都不寫 hook。同時用三個工具時、注意 cbm hook 是否誤判把正常的 markdown grep 也擋掉（實測有 false positive）。</p>
<h2 id="對其他語言-stack-怎麼變化">對其他語言 stack 怎麼變化</h2>
<p>這個三刀流結論限定 Dart。不同語言 stack 的真實壓力不一樣、推薦組合也跟著變——把幾個常見 stack 各自展開。</p>
<h3 id="go--typescript--c--c-主力">Go / TypeScript / C / C++ 主力</h3>
<p>這層是 cbm 的甜蜜點：hybrid type resolution 涵蓋這四個語族、CALLS edge 抽得到、cbm 的 caller / blast radius 精度接近 LSP。實務影響是「cbm 在 Dart 上需要 codegraph + serena 補位」的場景大幅縮小——cbm 自己就能處理 caller / impact、加上它原本就強的 11-signal 概念搜尋跟跨 service HTTP_CALLS，等於一個工具撐住「找東西」「caller / impact」「cross-service」三層。</p>
<p>serena 在這個 stack 仍是 symbol-level edit 跟 compile diagnostic 的關鍵來源——cbm 純讀、沒 rename / replace_symbol_body、沒 LSP 診斷整合。所以合理組合是「cbm + serena 雙刀流」、codegraph 的角色被 cbm 取代掉。判讀訊號：在自家 repo 跑 cbm <code>trace_call_path</code> 對 5 個熱門 class、若 caller 數跟 serena 的 <code>find_referencing_symbols</code> 對得上、codegraph 確實可以省下。</p>
<h3 id="swift--kotlin--rust-主力">Swift / Kotlin / Rust 主力</h3>
<p>這層跟 Dart 場景結構接近：serena 透過 sourcekit-lsp / kotlin-language-server / rust-analyzer 能拿到完整型別解析、cbm 不在 hybrid resolution 名單只剩純 syntactic。所以「三刀流」的論證仍適用。</p>
<p>但 codegraph 在這三個語言的 query 品質要實測——19+ 列表內這幾個都列為 supported、實際解析深度因語言成熟度而異。Swift 特別容易踩坑的點是 Objective-C interop（dispatch table 跨語言）跟 protocol extension 的型別推斷、Kotlin 則是 reified generics 跟 inline function、Rust 是 trait method 跟 macro 展開後的 callsite。判讀訊號：對自家專案最常用的 dispatch pattern 寫一個 minimal example、跑 codegraph callers、看抓不抓得到。</p>
<h3 id="python-主力">Python 主力</h3>
<p>三個工具的 Python 支援都成熟、但著力點不同：cbm 對 Python 有完整 hybrid resolution、codegraph 對 Python 是核心支援語言之一（VS Code benchmark 在它的 7 codebase 列表內）、serena 透過 pyright / pylsp 拿型別資訊。</p>
<p>Python 的特殊壓力是 dynamic dispatch（duck typing / monkey patching / metaclass / <strong>getattr</strong>）——這層任何 static 工具都會漏。判讀訊號：對自家 codebase 跑「找 X class 的所有 method 呼叫」、若大量真實 callsite 在 type annotation 缺失的位置、所有工具都只能給 lower bound。實務組合多半雙刀（codegraph + serena）夠用、cbm 對 Python 的不可替代價值在 cross-service HTTP_CALLS（Django / FastAPI 跨 service 場景）。</p>
<h3 id="冷門語言--dslliquid--pascal--svelte-template-等">冷門語言 / DSL（Liquid / Pascal / Svelte template 等）</h3>
<p>這層 serena 多半沒 LSP 可借（除非自備 server）、cbm 純 syntactic（hybrid 名單外）、codegraph 是少數仍有 query 的工具——但 query 品質要看 codegraph 對該語言投入多深、Pascal / Delphi / Liquid 這類列表末段的支援度可能只到 symbol 抽取、callsite 不一定有。</p>
<p>實務上對這層語言、退回 <code>grep + codegraph</code> 比強推三刀流合理——caller / impact 用 codegraph 試、不夠就 grep 補、別期待 LSP 級精度。判讀訊號：若 codegraph status 顯示 indexed file 多但 edges 數明顯偏低（&lt; 1 條 edge per file）、call graph 多半沒抽起來、視同純 syntactic 工具用。</p>
<h3 id="共通的評估方法">共通的評估方法</h3>
<p>無論哪個 stack、第一次裝 MCP 前在自家 repo 跑「找重要 class / function 的所有 caller」這個基準題、把不同工具的數字並列比較、再決定組合。README benchmark 是行銷數字、自家 stack 跑出的數字才是真實 baseline。</p>
<h2 id="評估新-mcp-工具的-checklist">評估新 MCP 工具的 checklist</h2>
<p>從這次踩三個（含一個跳過實裝的 GitNexus）的經驗回推、未來評估新 code intelligence MCP 要先確認：</p>
<p><strong>License</strong>：商業專案要 MIT / Apache 2.0 / BSD。PolyForm Noncommercial 之類限制商業使用的 license 直接刷掉。這條最便宜、最早做、最少人記得做。</p>
<p><strong>目標語言的 call graph 支援</strong>：README 寫「full support」要實測。tree-sitter wrapper 通常只到「結構抽得到」、沒到「call edge 抽得到」。同樣是「有 CALLS edge」、有 type-inferred dispatch 的 syntactic 工具跟有完整 LSP 的差距可能 2-3x callsite 數。</p>
<p><strong>MCP tool 數量不等於能力</strong>：14 個 tool 不一定贏過 10 個。看 caller / impact / find_referencing_symbols 這類核心功能有沒有、品質好不好、勝過 tool 多寡。</p>
<p><strong>是否會自動改 <code>~/.claude/</code> 設定</strong>：大多會。先看 install script 動了哪些檔案、能不能還原、uninstall 是否徹底（cbm uninstall 不清 hook 是踩過的坑）。</p>
<p><strong>是否有 CLI 模式</strong>：有的話本 session 就能實測、不必等 Claude Code 重啟載入 MCP。CLI mode 對「驗證 baseline」特別重要——拿 CLI 結果當 ground truth、再對 MCP 結果做差異比對。</p>
<p><strong>Auto-sync 機制</strong>：file watcher / git polling / 純手動 reindex 差異很大。「邊改邊問」工作流對 sync 延遲很敏感、選錯會踩到 stale graph 的事故。</p>
<h2 id="結論">結論</h2>
<p>對 Dart 主力專案：<strong>三刀流（cbm + codegraph + serena）是合理 stack</strong>。三者擅長的事不重疊、互相補位有明確角色：</p>
<ul>
<li><a href="/blog/record/codebase-memory-mcp155-%E8%AA%9E%E8%A8%80-tree-sitter-%E7%9F%A5%E8%AD%98%E5%9C%96%E8%AD%9C-mcp-%E7%9A%84%E8%83%BD%E5%8A%9B%E8%88%87%E9%82%8A%E7%95%8C/">cbm</a>：概念性搜尋入口、跨 service HTTP/RPC 鏈接</li>
<li><a href="/blog/record/codegraph%E7%94%A8-tree-sitter-per-language-query-%E6%92%90%E8%B5%B7-19-%E8%AA%9E%E8%A8%80-call-graph-%E7%9A%84-mcp/">codegraph</a>：日常 80% 的結構查詢、auto-sync 反應最快</li>
<li><a href="/blog/record/serena%E6%8A%8A-lsp-%E5%8C%85%E6%88%90-agent-first-mcp-%E7%9A%84-symbol-level-%E7%B7%A8%E8%BC%AF%E6%96%B9%E6%A1%88/">serena</a>：型別精確 reference、symbol-level atomic edit、編譯 diagnostic</li>
</ul>
<p>對其他語言 stack、cbm 進入 hybrid resolution 名單後組合會收斂、但 serena 的 symbol edit 跟 diagnostic 角色仍不可取代。</p>
<p>評估方法的更普遍結論：<strong>README benchmark 只是起點、要在自己的 stack 上跑同樣的基準題才算數</strong>。每個工具的 benchmark 都選自己擅長的語言跟 codebase、跨語言遷移結論需要重新驗證。用 5 個查詢做 baseline、把 CLI 數字當 ground truth、再對 MCP 結果做差異對比、是現階段最低成本的工具評估法。</p>
]]></content:encoded></item><item><title>Freezed 的三層結構解剖：with、_$、以及更好懂的替代路徑</title><link>https://tarrragon.github.io/blog/work-log/freezed-%E7%9A%84%E4%B8%89%E5%B1%A4%E7%B5%90%E6%A7%8B%E8%A7%A3%E5%89%96with_%E4%BB%A5%E5%8F%8A%E6%9B%B4%E5%A5%BD%E6%87%82%E7%9A%84%E6%9B%BF%E4%BB%A3%E8%B7%AF%E5%BE%91/</link><pubDate>Mon, 11 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/freezed-%E7%9A%84%E4%B8%89%E5%B1%A4%E7%B5%90%E6%A7%8B%E8%A7%A3%E5%89%96with_%E4%BB%A5%E5%8F%8A%E6%9B%B4%E5%A5%BD%E6%87%82%E7%9A%84%E6%9B%BF%E4%BB%A3%E8%B7%AF%E5%BE%91/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：實作營運端報表 API、寫了一個 freezed model
&lt;strong>疑問來源&lt;/strong>：&lt;code>abstract class PeriodReportRow with _$PeriodReportRow implements ReportAmountsView&lt;/code> 這一行包含太多陌生語法
&lt;strong>整理目的&lt;/strong>：把「為什麼長這樣」與「是否有更好懂做法」的脈絡記錄下來、避免下次又從零開始查
&lt;strong>本文邊界&lt;/strong>：這是一篇 work-log，目標是回溯一次具體實作中的理解成本；它不取代 freezed 官方文件，也不把某個專案的模型分層當成通用規則。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="事件起點">事件起點&lt;/h2>
&lt;p>今天在某個營運端 Flutter 專案新增週期彙總報表 API，這份報表和既有的單次作業報表共用呈現邏輯、各自有獨立的 DTO。為了讓兩個 DTO 共用 sections builder、抽了一個 &lt;code>ReportAmountsView&lt;/code> 介面、讓兩邊的 &lt;code>*Row&lt;/code> 都 &lt;code>implements&lt;/code> 它。&lt;/p>
&lt;p>寫完後盯著這行程式碼看了一下：&lt;/p>





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





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





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





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





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





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





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





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





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




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




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




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




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





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





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





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





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">class</span> <span class="nc">ReportAmounts</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kd">const</span> <span class="n">ReportAmounts</span><span class="p">({</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="kd">required</span> <span class="k">this</span><span class="p">.</span><span class="n">primaryOrderCount</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="kd">required</span> <span class="k">this</span><span class="p">.</span><span class="n">primaryTurnover</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="kd">required</span> <span class="k">this</span><span class="p">.</span><span class="n">grossAmount</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="c1">// ...其餘報表需要的金額欄位
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span>  <span class="p">});</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="kd">final</span> <span class="kt">int</span> <span class="n">primaryOrderCount</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="kd">final</span> <span class="n">Decimal</span> <span class="n">primaryTurnover</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="kd">final</span> <span class="n">Decimal</span> <span class="n">grossAmount</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="n">extension</span> <span class="n">SingleRunReportRowAmounts</span> <span class="n">on</span> <span class="n">SingleRunReportRow</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">  <span class="n">ReportAmounts</span> <span class="n">toAmounts</span><span class="p">()</span> <span class="o">=&gt;</span> <span class="n">ReportAmounts</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="nl">primaryOrderCount:</span> <span class="n">primaryOrderCount</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="nl">primaryTurnover:</span> <span class="n">primaryTurnover</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="nl">grossAmount:</span> <span class="n">grossAmount</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">      <span class="p">);</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="n">extension</span> <span class="n">PeriodReportRowAmounts</span> <span class="n">on</span> <span class="n">PeriodReportRow</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">  <span class="n">ReportAmounts</span> <span class="n">toAmounts</span><span class="p">()</span> <span class="o">=&gt;</span> <span class="n">ReportAmounts</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">        <span class="nl">primaryOrderCount:</span> <span class="n">primaryOrderCount</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">        <span class="nl">primaryTurnover:</span> <span class="n">primaryTurnover</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">        <span class="nl">grossAmount:</span> <span class="n">grossAmount</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">      <span class="p">);</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 mapping 看起來重複，但它是有價值的重複：它明確標出「哪些 API 欄位被投影成報表金額」。後端欄位名稱或語意改變時，維護者會在 mapper 裡看到轉換邊界，而不是在一個 18-getter interface 裡推理兩個 DTO 為什麼剛好長得一樣。</p>
<h3 id="重新判斷">重新判斷</h3>
<p>以好懂與好維護為核心，方案 3 比方案 1 更穩。它多寫一個 <code>ReportAmounts</code> 和兩份 mapping，但把複雜度放在比較合理的位置：DTO 層接 API，projection 層接報表語意，builder 層只處理畫面 / 呈現 sections。</p>
<p>方案 1 可以短期保留，因為它型別安全、改動小、和既有 Freezed 寫法一致。但若這段程式會長期被不同人維護，或未來還會增加其他 report row，應把 <code>ReportAmountsView</code> 換成明確的 <code>ReportAmounts</code> 投影模型。</p>
<p>實作落地時還有一個命名細節：如果已經從「共用介面」改成「中間投影模型」，檔名也應從 <code>report_amounts_view.dart</code> 改成 <code>report_amounts.dart</code>。否則程式碼雖然改成 projection，讀者仍會被舊的 View 命名帶回「DTO 實作介面」的心智模型。</p>
<h3 id="實作後驗證">實作後驗證</h3>
<p>這輪實作已經把 <code>ReportAmountsView</code> 移除，改成 <code>ReportAmounts</code> 投影模型與兩個 <code>toAmounts()</code> extension。局部 <code>flutter analyze</code> 對修改檔案通過，並補了 <code>report_amounts_test.dart</code> 驗證兩種報表 row 的共同金額欄位投影正確。</p>
<p>這個驗證證明 projection 邊界在型別與欄位對應上可行，但它還沒有驗證呈現版面或實際 API response 的完整結果。後續若報表內容有差異，應回到 sections builder 或 API 欄位語意，而不是回頭讓 DTO 重新實作共用介面。</p>
<hr>
<h2 id="規劃有沒有瑕疵">規劃有沒有瑕疵</h2>
<p>整體判斷：<strong>使用 Freezed 本身不是瑕疵，但共用 builder 的抽象位置值得調整</strong>。</p>
<h3 id="1-工具選擇是一致性-vs-適配度的取捨">1. 工具選擇是「一致性 vs 適配度」的取捨</h3>
<p>這類專案統一使用 freezed 的收益：</p>
<ul>
<li><strong>一致性</strong>：所有 model 一樣寫，接手者不用學兩套</li>
<li><strong>未雨綢繆</strong>：今天 DTO 不需要 <code>copyWith</code>、明天可能要（例如做 optimistic update 時要短暫修改 DTO）</li>
<li><strong>降低決策成本</strong>：不用每個 model 問「這個需要 copyWith 嗎？」</li>
</ul>
<p>成本：</p>
<ul>
<li><strong>DTO 上「邊際過剩」</strong>：用不到的功能也產出來、多花 build_runner 時間</li>
<li><strong>抽象洩漏</strong>：使用者必須懂 <code>_$</code> / <code>part</code> / mixin</li>
</ul>
<p>這個取捨<strong>沒標準答案</strong>、看團隊規模和維護週期。若系統長期維護、多人接手、既有專案已經採用 Freezed、而 build_runner 成本可接受，一致性的價值通常會高於 DTO 上的邊際過剩。</p>
<h3 id="2-dto-與-domain-model-兩層分離仍然合理">2. DTO 與 domain model 兩層分離仍然合理</h3>
<p>不在「用了 freezed」、而在於——<strong>是否需要 DTO 與 domain model 兩層分離</strong>？</p>
<p>這類專案結構：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">SingleRunReportRow（DTO、貼著 API）
</span></span><span class="line"><span class="ln">2</span><span class="cl">       ↓ service 轉換
</span></span><span class="line"><span class="ln">3</span><span class="cl">ReportSummary（domain、貼著 UI / 呈現）</span></span></code></pre></div><p>兩層是分開的。這個分層有成本：</p>
<ul>
<li>多寫一個 model</li>
<li>多寫一份轉換邏輯</li>
<li>多一份要維護</li>
</ul>
<p>但價值：</p>
<ul>
<li>後端改 API 欄位名 → 只動 DTO 層、domain 不受影響</li>
<li>UI 要新增顯示邏輯 → 只動 domain 層、DTO 不受影響</li>
<li>呈現報表的格式可以脫離 API 變化</li>
</ul>
<p>對長期維護、資料語意敏感的營運系統，這層分離通常值得；對短期 prototype，這層分離的維護成本可能高於收益。</p>
<h3 id="3-共用-builder-的抽象位置可能放太早">3. 共用 builder 的抽象位置可能放太早</h3>
<p><code>ReportAmountsView</code> 把報表需要的共同欄位直接壓到 API DTO 上，這是目前寫法最需要檢討的地方。更清楚的分層是：DTO 先完整接住後端 row，再由 mapper 投影成 <code>ReportAmounts</code>，最後由 sections builder 使用這個報表模型。</p>
<p>這個調整不會否定 Freezed，也不會否定 DTO / domain 分層。它只是把「共同報表金額」從 API DTO interface 移到報表投影層，讓型別關係更接近讀者真正要理解的資料流。</p>
<h3 id="一個反向思考">一個反向思考</h3>
<p>如果<strong>沒有 freezed</strong>、會怎麼做？</p>
<p>我猜會：</p>
<ol>
<li>DTO 只用 <code>json_serializable</code>（最輕量）</li>
<li>domain model 手寫（反正欄位通常比 DTO 少）</li>
<li>用 immutable 慣例但不強制（<code>final</code> 欄位 + 沒有 setter）</li>
</ol>
<p>這樣寫出來會比現在<strong>少一層拆分但多一些手寫樣板</strong>。誰好誰壞、看 trade-off 什麼：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>用 freezed</th>
          <th>不用 freezed</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>寫起來</td>
          <td>短</td>
          <td>長</td>
      </tr>
      <tr>
          <td>讀起來</td>
          <td>多層、要懂 mixin</td>
          <td>直白</td>
      </tr>
      <tr>
          <td>改起來</td>
          <td>改一處</td>
          <td>改多處</td>
      </tr>
      <tr>
          <td>學習門檻</td>
          <td>高</td>
          <td>低</td>
      </tr>
      <tr>
          <td>出錯機率</td>
          <td>欄位同步漏改風險低，但有工具鏈風險</td>
          <td>手寫易漏改</td>
      </tr>
      <tr>
          <td>Build 時間</td>
          <td>增加 build_runner 成本</td>
          <td>沒影響</td>
      </tr>
      <tr>
          <td>Debug 體驗</td>
          <td>IDE 跳轉差</td>
          <td>直接看到</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="結論">結論</h2>
<ol>
<li><strong>「拆」是 Freezed 在 Dart 現有 codegen surface 下的工程妥協</strong>：它用三層結構換掉大量手寫樣板</li>
<li><strong><code>with _$Foo</code> 和 <code>part</code> 是漏出的實作細節</strong>：使用者需要理解 library、mixin、factory redirect，才能讀懂 Freezed 生成模型</li>
<li><strong>不同 model 層吃到的 Freezed 價值不同</strong>：DTO 常吃到 immutable / JSON / 欄位同步，domain model 更容易吃到 <code>copyWith</code> / union / 狀態轉移能力；統一用法換來的一致性，在長期維護的專案上可能值得</li>
<li><strong>Dart macros 不是可期待的解法路線</strong>：官方已停止 macros 工作，後續改善更可能來自 data features、build_runner 或 augmentations</li>
<li><strong>真正要檢討的是分層邊界</strong>：DTO 與 domain model 分離是否值得，比 <code>with _$Foo</code> 本身更接近架構決策</li>
<li><strong>目前 <code>implements ReportAmountsView</code> 可行但不一定最好懂</strong>：若核心目標是長期維護，<code>ReportAmounts</code> 投影模型通常比讓 API DTO 直接實作共用介面更清楚；落地時連檔名也要改成 projection 命名，避免舊抽象殘留</li>
</ol>
<p>換個角度說：當你寫 <code>with _$PeriodReportRow</code> 時，你是在接受一個 codegen 工具的心智模型，用它補上資料類型在手寫 Dart 裡會產生的大量樣板。</p>
<hr>
<h2 id="附錄今日實作中相關的設計決策">附錄：今日實作中相關的設計決策</h2>
<p>這次新增週期彙總報表 API 時，面對的關鍵設計選擇是「沿用既有 row、還是新增一個獨立 row」。</p>
<p>當下選擇了新增，然後抽 <code>ReportAmountsView</code> 介面共用 sections builder。這個決策當時在 A/B/C 三個選項裡合理，但重新用「好懂、好維護」作為錨點審查後，應該補上第四個選項：</p>
<table>
  <thead>
      <tr>
          <th>選項</th>
          <th>優點</th>
          <th>缺點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>A. 沿用既有 row、把獨有欄位改 optional</td>
          <td>共用一個 model、少寫 18 個欄位</td>
          <td>兩個語意完全不同的東西放在一起、型別會說謊</td>
      </tr>
      <tr>
          <td>B. 新增獨立 row、各自獨立</td>
          <td>語意清楚、各自演化</td>
          <td>報表 sections builder 可能重複</td>
      </tr>
      <tr>
          <td>C. 新增 + 抽 <code>ReportAmountsView</code> 介面共用 builder</td>
          <td>兼顧 A 的 DRY + B 的清楚</td>
          <td>多一個 interface 檔案、需理解 Freezed <code>implements</code> 用法</td>
      </tr>
      <tr>
          <td>D. 新增 + 投影成 <code>ReportAmounts</code></td>
          <td>DTO 與報表語意分層清楚</td>
          <td>多一個投影 model 與兩份 mapping</td>
      </tr>
  </tbody>
</table>
<p>選項 A 的主要問題是型別會說謊。既有 row 有單次作業、操作者、時間等語意，新的 row 是跨作業週期彙總；把兩種欄位塞進同一個 row，會讓 optional 欄位承擔太多語意分支。</p>
<p>選項 B 的主要問題是同步成本。它最容易讀，但如果兩種報表的 sections 幾乎一致，後續調整顯示項目時就要維護兩份相似邏輯。</p>
<p>選項 C 是當下採用的路徑。<code>ReportAmountsView</code> 只覆蓋「金額部分」、操作者 / 作業週期 / 日期等識別欄位刻意留給各自的 row 自管，避免介面變成 god interface；但它也讓 API DTO 直接承擔報表共用介面，讀者必須理解 Freezed 的門面類、generated mixin 與具體生成類。</p>
<p>選項 D 是重新審查後更好的候選。它保留兩種報表 row 各自獨立，也保留 sections builder 共用，但把共用點移到 <code>ReportAmounts</code> 這個報表投影模型。這樣多寫的 mapping 是刻意暴露資料轉換邊界，而不是無效樣板。</p>
<p>因此，本文更新後的判斷是：<strong>當下選 C 可以理解，但若要讓程式碼更好懂、更好維護，實作上應改成 D</strong>。</p>
<hr>
<h2 id="參考資料">參考資料</h2>
<ul>
<li><a href="https://pub.dev/packages/freezed">freezed 套件</a></li>
<li><a href="https://dart.dev/language/mixins">Dart language tour - Mixins</a></li>
<li><a href="https://dart.dev/language/libraries">Dart language tour - Libraries and imports</a></li>
<li><a href="https://dart.dev/blog/an-update-on-dart-macros-data-serialization">Dart Blog - An update on Dart macros &amp; data serialization</a></li>
<li><a href="https://dart.dev/language/records">Dart Records</a></li>
<li><a href="../freezed/">既有的 freezed 選型評估筆記</a></li>
</ul>
]]></content:encoded></item><item><title>Dart test 的跨檔案 GetX 狀態污染：flaky 真因不是 fail 訊息上的那個 test</title><link>https://tarrragon.github.io/blog/work-log/dart-test-%E7%9A%84%E8%B7%A8%E6%AA%94%E6%A1%88-getx-%E7%8B%80%E6%85%8B%E6%B1%A1%E6%9F%93flaky-%E7%9C%9F%E5%9B%A0%E4%B8%8D%E6%98%AF-fail-%E8%A8%8A%E6%81%AF%E4%B8%8A%E7%9A%84%E9%82%A3%E5%80%8B-test/</link><pubDate>Thu, 07 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/dart-test-%E7%9A%84%E8%B7%A8%E6%AA%94%E6%A1%88-getx-%E7%8B%80%E6%85%8B%E6%B1%A1%E6%9F%93flaky-%E7%9C%9F%E5%9B%A0%E4%B8%8D%E6%98%AF-fail-%E8%A8%8A%E6%81%AF%E4%B8%8A%E7%9A%84%E9%82%A3%E5%80%8B-test/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>事故類型&lt;/strong>：cross-file 狀態污染、dart test runner 同 process 共用 GetX
&lt;strong>症狀&lt;/strong>：&lt;code>flutter test&lt;/code> 約 50% 機率隨機失敗、每次失敗的 test 不固定；單獨跑該 test file 100% 通過
&lt;strong>根因&lt;/strong>：dart test runner 在同 process 內跑多個 test file 共用 GetX 容器；前面 file 的 setUp 留下殘留（測試 mode 旗標、未 dispose 的 controller、stream subscription）污染後面 file 的測試環境&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="事故場景">事故場景&lt;/h2>
&lt;h3 id="表面症狀">表面症狀&lt;/h3>
&lt;p>跑 &lt;code>flutter test&lt;/code> 全 suite，Run 1 fail、Run 2 pass、Run 3 pass、Run 4 fail、Run 5 fail。看到的失敗訊息類似：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">00:27 +125: PrintCenter 廚房印表機管理 kitchenPrinter 向後兼容取第一台 - did not complete [E]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">00:27 +125: PrintCenter 廚房印表機管理 重複呼叫 initFakeKitchenPrinters 會清除舊的 - did not complete [E]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">00:27 +125: Some tests failed.&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>訊息直接點名 &lt;code>PrintCenter 廚房印表機管理&lt;/code> group 的兩個 test「did not complete」。直覺反應：那兩個 test 有問題、去看那個 file。&lt;/p>
&lt;h3 id="第一次診斷與失敗的修法">第一次診斷與失敗的修法&lt;/h3>
&lt;p>打開 &lt;code>online_order_print_handler_test.dart&lt;/code>，看到 &lt;code>PrintCenter 廚房印表機管理&lt;/code> group 的 setUp 沒做 &lt;code>Get.reset()&lt;/code>、純粹依賴 outer setUp 的 &lt;code>Get.reset()&lt;/code>。判斷可能是 outer setUp 的 &lt;code>OnlineOrderPrintHandler.onInit&lt;/code> 在這個 group 留下副作用（stream subscription 之類），於是給這個 group 加自己的 reset：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="n">group&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;PrintCenter 廚房印表機管理&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="n">late&lt;/span> &lt;span class="n">PrintCenter&lt;/span> &lt;span class="n">printCenter&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="n">setUp&lt;/span>&lt;span class="p">(()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="n">Get&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">reset&lt;/span>&lt;span class="p">();&lt;/span> &lt;span class="c1">// ← 加這行隔離 outer setUp 的副作用
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="n">printCenter&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">PrintCenter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">FakePrinterAdapter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;main&amp;#39;&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="n">Get&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">put&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">printCenter&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="n">tearDown&lt;/span>&lt;span class="p">(()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="n">Get&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">reset&lt;/span>&lt;span class="p">();&lt;/span> &lt;span class="c1">// ← 加這行確保不殘留
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>跑 5 次：Run 1 fail、Run 2 pass、Run 3 pass、Run 4 fail、Run 5 fail——&lt;strong>flakiness 比例沒改變&lt;/strong>。&lt;/p>
&lt;p>修錯了。&lt;/p>
&lt;h3 id="重新診斷看-n--1-計數的真正位置">重新診斷：看 &lt;code>+N -1&lt;/code> 計數的真正位置&lt;/h3>
&lt;p>把 fail 輸出存進檔案、仔細看 progress line 的 &lt;code>+N -1&lt;/code> 部分：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">00:08 +125 -1: ... auto_service_config_test.dart: ...
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">00:08 +126 -1: ... settle_page_order_object_test.dart: SettlePage.orderObject reactivity searchedOrder 變更：badge 立即更新（list 與 selected 都沒命中時）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">00:08 +127 -1: ... auto_service_config_test.dart: ...&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>-1&lt;/code> 在第 126 個 test 才第一次出現——失敗的不是 print handler，是中間夾的 &lt;strong>widget test&lt;/strong>。再看另一次 fail：&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>事故類型</strong>：cross-file 狀態污染、dart test runner 同 process 共用 GetX
<strong>症狀</strong>：<code>flutter test</code> 約 50% 機率隨機失敗、每次失敗的 test 不固定；單獨跑該 test file 100% 通過
<strong>根因</strong>：dart test runner 在同 process 內跑多個 test file 共用 GetX 容器；前面 file 的 setUp 留下殘留（測試 mode 旗標、未 dispose 的 controller、stream subscription）污染後面 file 的測試環境</p></blockquote>
<hr>
<h2 id="事故場景">事故場景</h2>
<h3 id="表面症狀">表面症狀</h3>
<p>跑 <code>flutter test</code> 全 suite，Run 1 fail、Run 2 pass、Run 3 pass、Run 4 fail、Run 5 fail。看到的失敗訊息類似：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">00:27 +125: PrintCenter 廚房印表機管理 kitchenPrinter 向後兼容取第一台 - did not complete [E]
</span></span><span class="line"><span class="ln">2</span><span class="cl">00:27 +125: PrintCenter 廚房印表機管理 重複呼叫 initFakeKitchenPrinters 會清除舊的 - did not complete [E]
</span></span><span class="line"><span class="ln">3</span><span class="cl">00:27 +125: Some tests failed.</span></span></code></pre></div><p>訊息直接點名 <code>PrintCenter 廚房印表機管理</code> group 的兩個 test「did not complete」。直覺反應：那兩個 test 有問題、去看那個 file。</p>
<h3 id="第一次診斷與失敗的修法">第一次診斷與失敗的修法</h3>
<p>打開 <code>online_order_print_handler_test.dart</code>，看到 <code>PrintCenter 廚房印表機管理</code> group 的 setUp 沒做 <code>Get.reset()</code>、純粹依賴 outer setUp 的 <code>Get.reset()</code>。判斷可能是 outer setUp 的 <code>OnlineOrderPrintHandler.onInit</code> 在這個 group 留下副作用（stream subscription 之類），於是給這個 group 加自己的 reset：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">group</span><span class="p">(</span><span class="s1">&#39;PrintCenter 廚房印表機管理&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="n">late</span> <span class="n">PrintCenter</span> <span class="n">printCenter</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="n">setUp</span><span class="p">(()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="n">Get</span><span class="p">.</span><span class="n">reset</span><span class="p">();</span>  <span class="c1">// ← 加這行隔離 outer setUp 的副作用
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span>    <span class="n">printCenter</span> <span class="o">=</span> <span class="n">PrintCenter</span><span class="p">(</span><span class="n">FakePrinterAdapter</span><span class="p">(</span><span class="s1">&#39;main&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="n">Get</span><span class="p">.</span><span class="n">put</span><span class="p">(</span><span class="n">printCenter</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="n">tearDown</span><span class="p">(()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="n">Get</span><span class="p">.</span><span class="n">reset</span><span class="p">();</span>  <span class="c1">// ← 加這行確保不殘留
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"></span>  <span class="p">});</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>跑 5 次：Run 1 fail、Run 2 pass、Run 3 pass、Run 4 fail、Run 5 fail——<strong>flakiness 比例沒改變</strong>。</p>
<p>修錯了。</p>
<h3 id="重新診斷看-n--1-計數的真正位置">重新診斷：看 <code>+N -1</code> 計數的真正位置</h3>
<p>把 fail 輸出存進檔案、仔細看 progress line 的 <code>+N -1</code> 部分：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">00:08 +125 -1: ... auto_service_config_test.dart: ...
</span></span><span class="line"><span class="ln">2</span><span class="cl">00:08 +126 -1: ... settle_page_order_object_test.dart: SettlePage.orderObject reactivity searchedOrder 變更：badge 立即更新（list 與 selected 都沒命中時）
</span></span><span class="line"><span class="ln">3</span><span class="cl">00:08 +127 -1: ... auto_service_config_test.dart: ...</span></span></code></pre></div><p><code>-1</code> 在第 126 個 test 才第一次出現——失敗的不是 print handler，是中間夾的 <strong>widget test</strong>。再看另一次 fail：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">00:09 +124 -1: ... settle_page_order_object_test.dart: SettlePage.orderObject reactivity orderList[i] 替換：badge 從「已完成」立即變「退貨」</span></span></code></pre></div><p>不同 run 失敗的 test 不一樣，但都是 <code>settle_page_order_object_test.dart</code> 的不同 case。print handler 的 <code>did not complete</code> 是被牽連、不是源頭。</p>
<h3 id="確認-root-cause單獨跑全綠">確認 root cause：單獨跑全綠</h3>
<p>把 widget test 單獨重複跑 8 次：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">for</span> i in <span class="m">1</span> <span class="m">2</span> <span class="m">3</span> <span class="m">4</span> <span class="m">5</span> <span class="m">6</span> <span class="m">7</span> 8<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  flutter <span class="nb">test</span> test/widgets/settle_page_order_object_test.dart 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="p">|</span> tail -1
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="k">done</span></span></span></code></pre></div><p>8/8 全綠。<strong>單獨跑沒問題、混進全 suite 跑就 flaky</strong>——這是 cross-file pollution 的固定特徵。</p>
<hr>
<h2 id="為什麼-did-not-complete-訊息會誤導">為什麼 <code>did not complete</code> 訊息會誤導</h2>
<p>dart test runner 的失敗訊息設計上有個盲點：</p>
<ul>
<li><code>+N</code> 是累計通過數</li>
<li><code>-N</code> 是累計失敗數</li>
<li><code>did not complete</code> 是某個 test 還沒跑完整體就終止了（process 退出 / 超時 / 前面有未捕捉錯誤導致 runner 提前結束）</li>
</ul>
<p>當前面有 test 失敗、後面的 test 沒機會跑、這些後面的 test 會印 <code>did not complete</code>——但<strong>它們本身沒問題</strong>。看到 <code>did not complete</code> 直覺會想「這個 test 卡住了」、但真實意思更接近「這個 test 還沒跑、上游已掛」。</p>
<p>正確的診斷流程：</p>
<ol>
<li>找 <code>-N</code> 第一次出現的位置（<code>-1</code> 表示第一個失敗）</li>
<li>對照那一行的 test 名稱、那才是真正失敗的源頭</li>
<li><code>did not complete</code> 出現的 test 通常只是受牽連</li>
</ol>
<p>我第一次掉的坑：直接讀 <code>did not complete</code> 的 test 名、跳過了「往前找 <code>-1</code> 第一次出現」這步。</p>
<hr>
<h2 id="為什麼-cross-file-會污染dart-test-runner-與-getx-的不對齊">為什麼 cross-file 會污染：dart test runner 與 GetX 的不對齊</h2>
<h3 id="dart-test-runner-的執行模型">dart test runner 的執行模型</h3>
<p><code>flutter test</code>（背後是 <code>dart test</code>）跑全 suite 時不一定 1 file = 1 isolate。預設行為：</p>
<ul>
<li>多個 test file 可能共用同一個 isolate / Dart VM</li>
<li>共用 isolate 等於共用所有 process-scoped state（static field、singleton、未 GC 的全域物件）</li>
</ul>
<p>並發策略受 <code>--concurrency</code> 與 platform 影響、行為不固定，但「共用 process」是日常常見現象。</p>
<h3 id="getx-的-state-是-process-scoped">GetX 的 state 是 process-scoped</h3>
<p>GetX 的 <code>Get.put</code> / <code>Get.find</code> 把 instance 放進一個 process-global 容器。<code>Get.reset()</code> 清空容器、但有些東西不會被 reset：</p>
<ul>
<li><code>Get.testMode</code> 是 static field、<code>reset()</code> 不動它</li>
<li>如果 instance 在 onInit 內 subscribe 了 stream（例如 <code>BroadcastReceiveService.messages.listen</code>）、<code>Get.reset()</code> 移除 instance reference 但 <strong>subscription 不會自動 cancel</strong></li>
<li>StreamController / Timer / Future.delayed 在 GetX 容器外仍然活著</li>
</ul>
<h3 id="實際發生的污染鏈">實際發生的污染鏈</h3>
<p>跑全 suite 時，假設執行順序是：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">1. test/services/online_order/...      ← 最前面
</span></span><span class="line"><span class="ln">2</span><span class="cl">2. test/widgets/settle_page_order_...   ← 中間
</span></span><span class="line"><span class="ln">3</span><span class="cl">3. test/services/auth_service_config... ← 後面</span></span></code></pre></div><p>第 1 個 file 的 setUp 若有 <code>Get.put(SomeService())</code>，service 在 onInit 內訂閱了 stream，就算 tearDown 跑了 <code>Get.reset()</code>、那條 stream subscription 仍 active。第 2 個 file 開始跑時：</p>
<ul>
<li>它的 setUp 也呼叫 <code>Get.put(...)</code>、放進去的物件可能是 <strong>完全不同類型</strong> ——但 GetX 容器內可能還有上一輪殘留的物件</li>
<li>第 2 個 file 的 widget test 進入 widget tree、Obx 訂閱、各種 reactive 路徑啟動</li>
<li>上一輪殘留的 stream / timer 此時 fire、進到不該觸及的 state</li>
</ul>
<p>整個 race 在「殘留事件何時 fire vs widget test 何時 expect」之間，所以 flakiness 是 ~50% 而不是 100%。</p>
<hr>
<h2 id="解法setup-開頭主動-reset">解法：setUp 開頭主動 reset</h2>
<p>對任何用 GetX 的 test，setUp 最開頭就該 reset、不要依賴上一個 file 的 tearDown 跑乾淨：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">setUp</span><span class="p">(()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="c1">// 同 process 內跑全 suite 時其他 test file 可能在 GetX 容器留殘留
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"></span>  <span class="c1">// （Get.testMode、未 dispose 的 controller、未 cancel 的 stream subscription），
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span>  <span class="c1">// setUp 開頭主動 reset 切斷 cross-file 污染
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span>  <span class="n">Get</span><span class="p">.</span><span class="n">reset</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="n">Get</span><span class="p">.</span><span class="n">testMode</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="c1">// ... 之後再 Get.put 自己需要的東西
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span><span class="p">});</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">tearDown</span><span class="p">(()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="n">Get</span><span class="p">.</span><span class="n">reset</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>把這個 pattern 加到所有 widget test 與 controller test 的 setUp 之後，全 suite 連跑 5 次：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Run 1: All tests passed!
</span></span><span class="line"><span class="ln">2</span><span class="cl">Run 2: All tests passed!
</span></span><span class="line"><span class="ln">3</span><span class="cl">Run 3: All tests passed!
</span></span><span class="line"><span class="ln">4</span><span class="cl">Run 4: All tests passed!
</span></span><span class="line"><span class="ln">5</span><span class="cl">Run 5: All tests passed!</span></span></code></pre></div><p>5/5 全綠，flakiness 消失。</p>
<h3 id="為什麼-teardown-的-reset-不夠">為什麼 tearDown 的 reset 不夠</h3>
<p>理論上 tearDown 已經 <code>Get.reset()</code> 了，下個 test 的 setUp 看到的應該是乾淨容器——但這個推理在「同 file 內」成立、跨 file 不成立：</p>
<ul>
<li>跨 file 之間 dart test runner 在 file 邊界做的事是不確定的（可能整個 isolate 重啟、也可能只是切換 group）</li>
<li>即使前一個 file 的 tearDown 跑完，跨 file 的某個 microtask / timer callback 仍可能在後一個 file 的 setUp 之前 fire</li>
<li>用 setUp 開頭的 reset 等於再保險一次、把這個邊界內的不確定性吃掉</li>
</ul>
<hr>
<h2 id="除錯思維flaky-test-的固定診斷流程">除錯思維：flaky test 的固定診斷流程</h2>





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





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">The following StateError was thrown building Obx(...):
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">Bad state: Stream has already been listened to.&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>第一反應通常是「我哪裡寫錯了 / 是不是哪邊忘了 cancel」。檢查程式碼會發現新訂閱者寫得沒問題，副螢幕的訂閱也沒問題——&lt;strong>問題在底層 stream 的型別契約：整個生命週期內只允許被 listen 一次&lt;/strong>。&lt;/p>
&lt;p>這是 &lt;code>StreamController()&lt;/code> 預設建構子的契約：建立的是 single-subscription stream、生命週期內最多承載&lt;strong>一個&lt;/strong> listener。副螢幕第一個訂閱後佔據了唯一的 listener 位置；新加第二個訂閱者直接違反契約、執行期 throw。&lt;/p>
&lt;p>更深一層的觀察是設計層面的不一致：業務需求一直具備廣播語義（多個視角同步呈現）、技術選型卻是「單一管線」的工具。需求初期只有一個訂閱者讓限制沒有可見的影響、但限制一直存在於型別契約裡。第二個訂閱者只是觸發條件、不是根因。&lt;/p>
&lt;hr>
&lt;h2 id="兩種-streamcontroller-的核心差異">兩種 StreamController 的核心差異&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>&lt;code>StreamController()&lt;/code>（單訂閱）&lt;/th>
 &lt;th>&lt;code>StreamController.broadcast()&lt;/code>&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>同時 listener 數&lt;/td>
 &lt;td>至多 1 個&lt;/td>
 &lt;td>任意&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>第二個 &lt;code>.listen()&lt;/code>&lt;/td>
 &lt;td>throw &lt;code>Bad state&lt;/code>&lt;/td>
 &lt;td>OK&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>listener cancel 後重新 listen&lt;/td>
 &lt;td>throw &lt;code>Bad state&lt;/code>&lt;/td>
 &lt;td>OK&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>無 listener 時 add 的事件&lt;/td>
 &lt;td>&lt;strong>buffer&lt;/strong>，listener 出現時補送&lt;/td>
 &lt;td>&lt;strong>直接丟棄&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>listener &lt;code>pause()&lt;/code> 行為&lt;/td>
 &lt;td>整個 stream 暫停（上游也卡）&lt;/td>
 &lt;td>對其他 listener 無影響&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>適用語義&lt;/td>
 &lt;td>資料管線（單一消費者）&lt;/td>
 &lt;td>事件佈告欄（多消費者）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="三組行為差異的程式碼驗證">三組行為差異的程式碼驗證&lt;/h2>
&lt;h3 id="1-重複監聽">1. 重複監聽&lt;/h3>





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">The following StateError was thrown building Obx(...):
</span></span><span class="line"><span class="ln">2</span><span class="cl">Bad state: Stream has already been listened to.</span></span></code></pre></div><p>第一反應通常是「我哪裡寫錯了 / 是不是哪邊忘了 cancel」。檢查程式碼會發現新訂閱者寫得沒問題，副螢幕的訂閱也沒問題——<strong>問題在底層 stream 的型別契約：整個生命週期內只允許被 listen 一次</strong>。</p>
<p>這是 <code>StreamController()</code> 預設建構子的契約：建立的是 single-subscription stream、生命週期內最多承載<strong>一個</strong> listener。副螢幕第一個訂閱後佔據了唯一的 listener 位置；新加第二個訂閱者直接違反契約、執行期 throw。</p>
<p>更深一層的觀察是設計層面的不一致：業務需求一直具備廣播語義（多個視角同步呈現）、技術選型卻是「單一管線」的工具。需求初期只有一個訂閱者讓限制沒有可見的影響、但限制一直存在於型別契約裡。第二個訂閱者只是觸發條件、不是根因。</p>
<hr>
<h2 id="兩種-streamcontroller-的核心差異">兩種 StreamController 的核心差異</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th><code>StreamController()</code>（單訂閱）</th>
          <th><code>StreamController.broadcast()</code></th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>同時 listener 數</td>
          <td>至多 1 個</td>
          <td>任意</td>
      </tr>
      <tr>
          <td>第二個 <code>.listen()</code></td>
          <td>throw <code>Bad state</code></td>
          <td>OK</td>
      </tr>
      <tr>
          <td>listener cancel 後重新 listen</td>
          <td>throw <code>Bad state</code></td>
          <td>OK</td>
      </tr>
      <tr>
          <td>無 listener 時 add 的事件</td>
          <td><strong>buffer</strong>，listener 出現時補送</td>
          <td><strong>直接丟棄</strong></td>
      </tr>
      <tr>
          <td>listener <code>pause()</code> 行為</td>
          <td>整個 stream 暫停（上游也卡）</td>
          <td>對其他 listener 無影響</td>
      </tr>
      <tr>
          <td>適用語義</td>
          <td>資料管線（單一消費者）</td>
          <td>事件佈告欄（多消費者）</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="三組行為差異的程式碼驗證">三組行為差異的程式碼驗證</h2>
<h3 id="1-重複監聽">1. 重複監聽</h3>





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





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





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




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





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





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





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





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




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





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





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





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





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





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





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





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





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





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">真實路徑： 接收訂單 → 組裝收據 → 列印中心 → 表格列印/文字列印 → 印表機底層
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">測試路徑： 接收訂單 → 中斷（手動構造結果，後面都沒跑）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">後來補了模擬元件：接收訂單 → 模擬收據產生器 → 列印中心 → 模擬印表機 → 底層
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> ↑
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> 但模擬的收據只有純文字行
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> → 文字列印有被覆蓋，表格列印沒有
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl"> → Bug 3, 4 仍然隱藏&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這次的問題在測試覆蓋的路徑深度。&lt;/p>
&lt;hr>
&lt;h2 id="回顧這次遇到的五個事故">回顧：這次遇到的五個事故&lt;/h2>
&lt;h3 id="1-測試的是手動構造的結果不是程式的行為">1. 測試的是「手動構造的結果」，不是「程式的行為」&lt;/h3>
&lt;p>&lt;strong>怎麼發現的：&lt;/strong> 實機上兩台廚房印表機只有一台收到品項，另一台完全沒動。但測試裡「品項分派邏輯」的測試案例是通過的。&lt;/p>
&lt;p>&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="n">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;品名長度奇數分配到第一台&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="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">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">OnlineOrderPrintResult&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">itemPrinterMapping:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s1">&amp;#39;item-1&amp;#39;&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;kitchen-2&amp;#39;&lt;/span>&lt;span class="p">},&lt;/span> &lt;span class="c1">// 寫死的值
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="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">record&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">applyPrintResult&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">result&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">record&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">kitchenItemPrintJobs&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;item-1&amp;#39;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">printerId&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;kitchen-2&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="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個測試驗證的是「把結果存進去再讀出來，資料有沒有一致」，但品項分派的程式碼從頭到尾沒有被執行過。測試名稱寫的是分派邏輯，實際測的是資料儲存。&lt;/p>
&lt;p>&lt;strong>怎麼修的：&lt;/strong> 改成從入口方法開始呼叫，讓品項分派的邏輯實際跑一遍。跑完之後 Bug 2 就出現了——分派邏輯的 fallback 條件寫錯，所有品項都被送到同一台。&lt;/p>
&lt;hr>
&lt;h3 id="2-只測了子類別自己的方法沒測從父類別繼承的方法">2. 只測了子類別自己的方法，沒測從父類別繼承的方法&lt;/h3>
&lt;p>&lt;strong>怎麼發現的：&lt;/strong> 實機上廚房印表機列印全部失敗，log 顯示內部元件未初始化的錯誤，但測試裡模擬印表機的初始化和列印測試都是通過的。&lt;/p>
&lt;p>&lt;strong>怎麼找到原因的：&lt;/strong> 比對測試和實際程式碼的呼叫路徑。測試裡呼叫的是模擬印表機自己覆寫的「送出資料」方法（改成什麼都不做），但實際列印時上層呼叫的是從父類別繼承的「組裝列印指令」方法，這個方法內部依賴一個需要初始化的元件。測試覆蓋到的方法，和實際執行路徑走到的方法不是同一個。&lt;/p>
&lt;p>&lt;strong>怎麼修的：&lt;/strong> 在測試中加入對繼承方法的測試——初始化後呼叫「組裝列印指令」確認不報錯，以及未初始化時呼叫確認會拋出錯誤。同時修正模擬印表機的初始化方法，補上內部元件的建立。&lt;/p>
&lt;hr>
&lt;h3 id="3-斷言只檢查有沒有沒檢查對不對">3. 斷言只檢查「有沒有」，沒檢查「對不對」&lt;/h3>
&lt;p>&lt;strong>怎麼發現的：&lt;/strong> 修完 Bug 1 和 2 之後重跑測試，整合測試通過了。但看 log 發現廚房 1 和廚房 2 的列印結果都是 &lt;code>false&lt;/code>（失敗），和測試通過的結果矛盾。&lt;/p>
&lt;p>&lt;strong>怎麼找到原因的：&lt;/strong> 回頭看斷言，發現寫的是 &lt;code>containsKey&lt;/code>：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">result&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">kitchenResults&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">containsKey&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;kitchen-1&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">isTrue&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個斷言只檢查「有沒有這台印表機的結果」，不管結果是成功還是失敗。列印在 try-catch 裡失敗後回傳 &lt;code>false&lt;/code>，但 key 存在，所以斷言通過。&lt;/p>
&lt;p>&lt;strong>怎麼修的：&lt;/strong> 改成直接檢查值 &lt;code>expect(result.kitchenResults['kitchen-1'], isTrue)&lt;/code>。改完之後測試立刻失敗，顯示列印結果確實是 &lt;code>false&lt;/code>。這才發現測試環境缺少收據產生器的依賴，列印路徑在組裝收據的步驟就斷了，被 try-catch 吞掉回傳 &lt;code>false&lt;/code>。&lt;/p>
&lt;hr>
&lt;h3 id="4-模擬元件的回傳資料只覆蓋了部分分支">4. 模擬元件的回傳資料只覆蓋了部分分支&lt;/h3>
&lt;p>&lt;strong>怎麼發現的：&lt;/strong> 修完 Bug 1、2，也修正了斷言（坑 3）之後，測試全過，列印結果也都是 &lt;code>true&lt;/code>。但上實機測試時，廚房印表機仍然全部失敗，log 顯示「欄位寬度總和必須等於 12」。&lt;/p>
&lt;p>&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="kd">class&lt;/span> &lt;span class="nc">FakeReceiptBuilderService&lt;/span> &lt;span class="kd">extends&lt;/span> &lt;span class="n">ReceiptBuilderService&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">Future&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">List&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">ReceiptLine&lt;/span>&lt;span class="o">&amp;gt;&amp;gt;&lt;/span> &lt;span class="n">buildReceiptLines&lt;/span>&lt;span class="p">(...)&lt;/span> &lt;span class="kd">async&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">ReceiptLine&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">singleLine&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">title&lt;/span>&lt;span class="p">)];&lt;/span> &lt;span class="c1">// 只有標題
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="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;/p></description><content:encoded><![CDATA[<blockquote>
<p>2026-03，開發線上點單多廚房印表機列印功能。
寫了 28 個測試，全部通過，上實機後陸續發現四個 Bug。</p></blockquote>
<hr>
<h2 id="發生了什麼事">發生了什麼事</h2>
<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">收到追加點單
</span></span><span class="line"><span class="ln">2</span><span class="cl">  → 把品項分派到對應的廚房印表機
</span></span><span class="line"><span class="ln">3</span><span class="cl">  → 組裝收據內容（標題、品名+數量、備註等）
</span></span><span class="line"><span class="ln">4</span><span class="cl">  → 逐行列印
</span></span><span class="line"><span class="ln">5</span><span class="cl">    → 單行文字（標題、桌號）
</span></span><span class="line"><span class="ln">6</span><span class="cl">    → 多欄表格（品名 + 數量）</span></span></code></pre></div><p>上實機後，四個 Bug 依序出現——因為它們在同一條執行路徑的不同深度，每修一個，程式才能走到下一個：</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">Bug 1（印表機內部元件未初始化）
</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">Bug 2（品項分派邏輯錯誤，全部送到同一台）
</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">Bug 3（多欄表格的欄位寬度不符合規定）
</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">Bug 4（空行觸發第三方 library 的越界錯誤）</span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>Bug</th>
          <th>出了什麼事</th>
          <th>測試沒抓到的原因</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Bug 1: 印表機元件未初始化</td>
          <td>模擬印表機的初始化漏掉了內部元件</td>
          <td>只測了「送出資料」，沒測「組裝列印指令」這個步驟</td>
      </tr>
      <tr>
          <td>Bug 2: 品項全分到同一台</td>
          <td>分派邏輯找到第一台可用的印表機就全部送過去</td>
          <td>手動構造預期結果，分派邏輯沒有被執行</td>
      </tr>
      <tr>
          <td>Bug 3: 欄位寬度錯誤</td>
          <td>欄位比例（3:1=4）不符合 library 要求的總和 12</td>
          <td>模擬的收據內容只有純文字行，沒有多欄表格行</td>
      </tr>
      <tr>
          <td>Bug 4: 空行越界錯誤</td>
          <td>第三方 library 沒有處理空字串</td>
          <td>被 Bug 3 擋住，程式從未執行到這一行</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="為什麼一次只能發現一個-bug">為什麼一次只能發現一個 Bug</h2>
<p>四個 Bug 都在同一條執行路徑上，只是深度不同。程式走到第一個錯誤就中斷了，後面的都被遮蔽。</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">真實路徑：  接收訂單 → 組裝收據 → 列印中心 → 表格列印/文字列印 → 印表機底層
</span></span><span class="line"><span class="ln">2</span><span class="cl">測試路徑：  接收訂單 → 中斷（手動構造結果，後面都沒跑）
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl">後來補了模擬元件：接收訂單 → 模擬收據產生器 → 列印中心 → 模擬印表機 → 底層
</span></span><span class="line"><span class="ln">5</span><span class="cl">                                  ↑
</span></span><span class="line"><span class="ln">6</span><span class="cl">                           但模擬的收據只有純文字行
</span></span><span class="line"><span class="ln">7</span><span class="cl">                           → 文字列印有被覆蓋，表格列印沒有
</span></span><span class="line"><span class="ln">8</span><span class="cl">                           → Bug 3, 4 仍然隱藏</span></span></code></pre></div><p>這次的問題在測試覆蓋的路徑深度。</p>
<hr>
<h2 id="回顧這次遇到的五個事故">回顧：這次遇到的五個事故</h2>
<h3 id="1-測試的是手動構造的結果不是程式的行為">1. 測試的是「手動構造的結果」，不是「程式的行為」</h3>
<p><strong>怎麼發現的：</strong> 實機上兩台廚房印表機只有一台收到品項，另一台完全沒動。但測試裡「品項分派邏輯」的測試案例是通過的。</p>
<p><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="n">test</span><span class="p">(</span><span class="s1">&#39;品名長度奇數分配到第一台&#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="kd">final</span> <span class="n">result</span> <span class="o">=</span> <span class="n">OnlineOrderPrintResult</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nl">itemPrinterMapping:</span> <span class="p">{</span><span class="s1">&#39;item-1&#39;</span><span class="o">:</span> <span class="s1">&#39;kitchen-2&#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="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="n">record</span><span class="p">.</span><span class="n">applyPrintResult</span><span class="p">(</span><span class="n">result</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">record</span><span class="p">.</span><span class="n">kitchenItemPrintJobs</span><span class="p">[</span><span class="s1">&#39;item-1&#39;</span><span class="p">]</span><span class="o">!</span><span class="p">.</span><span class="n">printerId</span><span class="p">,</span> <span class="s1">&#39;kitchen-2&#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></code></pre></div><p>這個測試驗證的是「把結果存進去再讀出來，資料有沒有一致」，但品項分派的程式碼從頭到尾沒有被執行過。測試名稱寫的是分派邏輯，實際測的是資料儲存。</p>
<p><strong>怎麼修的：</strong> 改成從入口方法開始呼叫，讓品項分派的邏輯實際跑一遍。跑完之後 Bug 2 就出現了——分派邏輯的 fallback 條件寫錯，所有品項都被送到同一台。</p>
<hr>
<h3 id="2-只測了子類別自己的方法沒測從父類別繼承的方法">2. 只測了子類別自己的方法，沒測從父類別繼承的方法</h3>
<p><strong>怎麼發現的：</strong> 實機上廚房印表機列印全部失敗，log 顯示內部元件未初始化的錯誤，但測試裡模擬印表機的初始化和列印測試都是通過的。</p>
<p><strong>怎麼找到原因的：</strong> 比對測試和實際程式碼的呼叫路徑。測試裡呼叫的是模擬印表機自己覆寫的「送出資料」方法（改成什麼都不做），但實際列印時上層呼叫的是從父類別繼承的「組裝列印指令」方法，這個方法內部依賴一個需要初始化的元件。測試覆蓋到的方法，和實際執行路徑走到的方法不是同一個。</p>
<p><strong>怎麼修的：</strong> 在測試中加入對繼承方法的測試——初始化後呼叫「組裝列印指令」確認不報錯，以及未初始化時呼叫確認會拋出錯誤。同時修正模擬印表機的初始化方法，補上內部元件的建立。</p>
<hr>
<h3 id="3-斷言只檢查有沒有沒檢查對不對">3. 斷言只檢查「有沒有」，沒檢查「對不對」</h3>
<p><strong>怎麼發現的：</strong> 修完 Bug 1 和 2 之後重跑測試，整合測試通過了。但看 log 發現廚房 1 和廚房 2 的列印結果都是 <code>false</code>（失敗），和測試通過的結果矛盾。</p>
<p><strong>怎麼找到原因的：</strong> 回頭看斷言，發現寫的是 <code>containsKey</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">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">kitchenResults</span><span class="p">.</span><span class="n">containsKey</span><span class="p">(</span><span class="s1">&#39;kitchen-1&#39;</span><span class="p">),</span> <span class="n">isTrue</span><span class="p">);</span></span></span></code></pre></div><p>這個斷言只檢查「有沒有這台印表機的結果」，不管結果是成功還是失敗。列印在 try-catch 裡失敗後回傳 <code>false</code>，但 key 存在，所以斷言通過。</p>
<p><strong>怎麼修的：</strong> 改成直接檢查值 <code>expect(result.kitchenResults['kitchen-1'], isTrue)</code>。改完之後測試立刻失敗，顯示列印結果確實是 <code>false</code>。這才發現測試環境缺少收據產生器的依賴，列印路徑在組裝收據的步驟就斷了，被 try-catch 吞掉回傳 <code>false</code>。</p>
<hr>
<h3 id="4-模擬元件的回傳資料只覆蓋了部分分支">4. 模擬元件的回傳資料只覆蓋了部分分支</h3>
<p><strong>怎麼發現的：</strong> 修完 Bug 1、2，也修正了斷言（坑 3）之後，測試全過，列印結果也都是 <code>true</code>。但上實機測試時，廚房印表機仍然全部失敗，log 顯示「欄位寬度總和必須等於 12」。</p>
<p><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="kd">class</span> <span class="nc">FakeReceiptBuilderService</span> <span class="kd">extends</span> <span class="n">ReceiptBuilderService</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="n">Future</span><span class="o">&lt;</span><span class="n">List</span><span class="o">&lt;</span><span class="n">ReceiptLine</span><span class="o">&gt;&gt;</span> <span class="n">buildReceiptLines</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="k">return</span> <span class="p">[</span><span class="n">ReceiptLine</span><span class="p">.</span><span class="n">singleLine</span><span class="p">(</span><span class="n">data</span><span class="p">.</span><span class="n">title</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="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>純文字走的是「文字列印」，多欄表格走的是「表格列印」——這是兩條不同的分支。模擬的資料只觸發了文字列印，表格列印從未被測試執行過。</p>
<ul>
<li>純文字列印有被覆蓋 → 沒問題</li>
<li>多欄表格列印沒有被觸發 → Bug 3 仍然隱藏</li>
<li>空行列印沒有被觸發 → Bug 4 仍然隱藏</li>
</ul>
<p><strong>怎麼修的：</strong> 在印表機的表格列印方法中加入自動正規化，將欄位比例換算為符合 library 要求的總和 12。這是適配層的修復，所有收據模板都不需要修改。</p>
<hr>
<h3 id="5-第三方-library-的地雷前面都做對了才踩到">5. 第三方 library 的地雷——前面都做對了才踩到</h3>
<p><strong>怎麼發現的：</strong> 修完 Bug 3 之後再上實機，廚房印表機仍然失敗，但錯誤訊息不同了——從「欄位寬度總和必須等於 12」變成「RangeError: Valid value range is empty: 0」。</p>
<p><strong>怎麼找到原因的：</strong> 從 log 看到列印標題和桌號成功（兩次資料送出），在第三行（空行）就失敗了。追蹤到第三方 library 的原始碼，發現它在解析文字時會取第一個字元來判斷是否為中文字，但沒有處理空字串的情況，直接對空字串取 <code>text[0]</code> 導致越界。</p>
<p>這個問題一直存在，但之前 Bug 3 擋在前面（程式在表格列印就失敗了，走不到後面的空行列印），前三個 Bug 都修好之後，執行路徑才真正打通，觸發了這個潛在問題。</p>
<p>從另一個角度看，能走到 Bug 4 代表前面的修復都是有效的。</p>
<p><strong>怎麼修的：</strong> 在呼叫 library 之前加了空字串的前置檢查，遇到空字串時改用換行指令代替，繞過 library 的問題。</p>
<hr>
<h3 id="6-try-catch-的範圍太大把程式碼-bug-和硬體故障混在一起處理">6. try-catch 的範圍太大，把程式碼 bug 和硬體故障混在一起處理</h3>
<p><strong>怎麼發現的：</strong> 回顧整個除錯過程，Bug 1、3、4 都有一個共同特徵——錯誤被 try-catch 吞掉，回傳 <code>false</code>，沒有任何明顯的異常。在測試中，缺少依賴的情況也被同樣的 try-catch 吞掉，測試照樣通過。</p>
<p><strong>怎麼找到原因的：</strong> 看列印方法的 catch 區塊：</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">Future</span><span class="o">&lt;</span><span class="kt">bool</span><span class="o">&gt;</span> <span class="n">_printKitchenReceipt</span><span class="p">(...)</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="c1">// 組裝收據資料
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span>    <span class="c1">// 組裝收據行
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span>    <span class="c1">// 呼叫印表機列印
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span>    <span class="k">return</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="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="n">e</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">debugPrint</span><span class="p">(</span><span class="s1">&#39;failed: </span><span class="si">$</span><span class="n">e</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="k">return</span> <span class="kc">false</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 class="p">}</span></span></span></code></pre></div><p><code>catch (e)</code> 攔截了所有錯誤，不區分類型。但這裡面混了兩種性質不同的錯誤：</p>
<ul>
<li><strong>印表機故障</strong>（連線逾時、無紙、裝置離線）→ 執行期的預期狀況，應該攔截，回傳失敗讓 UI 顯示重印按鈕</li>
<li><strong>程式碼 bug</strong>（未初始化的元件、欄位寬度不合法、空字串越界）→ 開發階段就該被發現的問題，不應該被靜默吞掉</li>
</ul>
<p>四個 Bug 裡有三個屬於後者，全部被同一個 <code>catch (e)</code> 攔住，在開發和測試階段都沒有任何異常跡象。</p>
<p><strong>怎麼修的：</strong> 做了三件事：</p>
<ol>
<li>定義 <code>PrinterException</code>，專門代表印表機硬體/連線錯誤</li>
<li>在列印中心（IO 邊界）把印表機拋出的 <code>Exception</code> 包成 <code>PrinterException</code>，但不攔截 <code>Error</code>（程式碼 bug）</li>
<li>列印方法改為 <code>on PrinterException catch</code>，只處理印表機故障</li>
</ol>
<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="c1">// 改動前：所有錯誤都被吞掉
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">try</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">lines</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">receiptBuilder</span><span class="p">.</span><span class="n">buildReceiptLines</span><span class="p">(</span><span class="n">data</span><span class="p">,</span> <span class="n">template</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">await</span> <span class="n">printCenter</span><span class="p">.</span><span class="n">printReceiptLines</span><span class="p">(</span><span class="nl">lines:</span> <span class="n">lines</span><span class="p">,</span> <span class="nl">printer:</span> <span class="n">printer</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="k">return</span> <span class="kc">true</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="n">e</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="k">return</span> <span class="kc">false</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="c1">// 改動後：資料準備不在 try 裡，只攔截印表機錯誤
</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">lines</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">receiptBuilder</span><span class="p">.</span><span class="n">buildReceiptLines</span><span class="p">(</span><span class="n">data</span><span class="p">,</span> <span class="n">template</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></span><span class="line"><span class="ln">13</span><span class="cl"><span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="kd">await</span> <span class="n">printCenter</span><span class="p">.</span><span class="n">printReceiptLines</span><span class="p">(</span><span class="nl">lines:</span> <span class="n">lines</span><span class="p">,</span> <span class="nl">printer:</span> <span class="n">printer</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="kc">true</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="p">}</span> <span class="n">on</span> <span class="n">PrinterException</span> <span class="k">catch</span> <span class="p">(</span><span class="n">e</span><span class="p">)</span> <span class="p">{</span>  <span class="c1">// ← 只攔截印表機故障
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="c1"></span>  <span class="k">return</span> <span class="kc">false</span><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><p>改完之後，測試裡缺少依賴的情況不再被吞掉——之前有一組測試預期列印結果是 <code>false</code>（因為缺收據產生器被 catch 吞掉），現在補上依賴後預期改為 <code>true</code>，測試驗證的是真正的列印行為。</p>
<hr>
<h2 id="從這次經驗歸納的測試方法">從這次經驗歸納的測試方法</h2>
<h3 id="一從呼叫路徑出發而非從程式碼結構出發">一、從呼叫路徑出發，而非從程式碼結構出發</h3>
<p>這次犯的最大錯誤是按照「這個 class 有哪些方法」來分配測試，結果每個方法各自通過，但串在一起就出問題。後來改成按「使用者操作觸發了什麼路徑」來規劃：</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">─────────                    ──────────────
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">追加點餐送出     →  handler.printAppendedOrder
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">                      → _buildItemPrinterMapping  ← 分派邏輯
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">                      → buildReceiptLines          ← 收據組裝
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">                      → printReceiptLines           ← 實際列印
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">                      → printText / printRow        ← 印表機操作
</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">點擊重印按鈕     →  retryItemKitchenPrint
</span></span><span class="line"><span class="ln">10</span><span class="cl">                      → printAppendedOrder(kitchenItemIds: {itemId})</span></span></code></pre></div><p>按路徑規劃之後，每個測試案例都會走過完整的鏈路，中間環節的問題自然會被觸發。</p>
<h3 id="二整合測試與單元測試的分工">二、整合測試與單元測試的分工</h3>
<p>這次的六個坑裡，有四個（坑 1、3、4、6）屬於「元件之間銜接」的問題，單元測試各自通過但串接失敗。回頭看分工：</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">─────────────────────────────────────────────────────────
</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">能抓到什麼 Bug？  演算法邏輯錯誤                 初始化遺漏、依賴缺失、
</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">本案例中          KitchenPrinterConfig           printAppendedOrder +
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">                  .handlesProduct                PrintCenter + FakePrinter +
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">                  → 匹配邏輯正確                 ReceiptBuilderService
</span></span><span class="line"><span class="ln">10</span><span class="cl">                                                → 端到端路徑正確</span></span></code></pre></div><p>這次的經驗是：功能涉及多個元件協作時，只有單元測試是不夠的。整合測試才能抓到元件之間的銜接問題。</p>
<h3 id="三替-try-catch-設計專門的測試">三、替 try-catch 設計專門的測試</h3>
<p>try-catch 在這次經驗裡反覆出現——Bug 1、3、4 被它吞掉，坑 3 的斷言因為它而失效，坑 6 則是根本性的設計問題。</p>
<p>回顧後歸納的三個對策：</p>
<ul>
<li><strong>斷言成功路徑的值</strong>：不只檢查「沒拋錯」，要檢查回傳值是 <code>true</code>。坑 3 就是因為只檢查 key 存在，沒檢查值</li>
<li><strong>提供完整的依賴</strong>：讓 try 區塊能完整執行，而非依賴 catch 來「通過」測試。坑 3 的根因就是缺少收據產生器</li>
<li><strong>寫專門的失敗測試</strong>：故意製造失敗條件（如模擬印表機拋出 <code>PrinterException</code>），驗證錯誤處理行為符合預期</li>
</ul>
<h3 id="四fake--mock-的設計原則">四、Fake / Mock 的設計原則</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">                    Fake（假實作）               Mock（模擬物件）
</span></span><span class="line"><span class="ln">2</span><span class="cl">─────────────────────────────────────────────────────────
</span></span><span class="line"><span class="ln">3</span><span class="cl">適用場景          需要跑通完整路徑               只需驗證互動次數/參數
</span></span><span class="line"><span class="ln">4</span><span class="cl">本案例            FakeReceiptBuilderService      不適用（需要驗證端到端結果）
</span></span><span class="line"><span class="ln">5</span><span class="cl">                  FakePrinterAdapter</span></span></code></pre></div><p>這次 <code>FakePrinterAdapter</code> 的設計漏掉了父類別繼承方法依賴的內部狀態（坑 2），<code>FakeReceiptBuilderService</code> 的回傳資料只覆蓋了部分分支（坑 4）。後來整理出設計 Fake 時的確認項目：</p>
<ul>
<li>繼承/實作的方法中，有哪些是上層呼叫者實際會用到的？</li>
<li>這些方法依賴哪些內部狀態（如 <code>late</code> 變數）？</li>
<li>Fake 的初始化是否正確建立了這些內部狀態？</li>
<li>Fake 回傳的資料是否足以讓下游所有分支都被觸發？</li>
</ul>
<hr>
<h2 id="之後可以改善的地方">之後可以改善的地方</h2>
<h3 id="寫測試時">寫測試時</h3>
<ul>
<li>測試應從入口方法開始驅動，讓中間的邏輯實際執行，避免手動構造中間結果</li>
<li>使用模擬子類別時，確認上層實際呼叫到的繼承方法也有被測試覆蓋</li>
<li>斷言驗證值本身，而非只驗證存在性（<code>containsKey</code> → 直接檢查值）</li>
<li>設計模擬元件的回傳資料時，先確認下游有哪些分支，確認回傳資料能觸發這些分支</li>
<li>回傳資料中加入邊界值——空字串、空列表等</li>
</ul>
<h3 id="修-bug-時">修 Bug 時</h3>
<ul>
<li>修完後確認有測試會實際走到修改的程式碼，否則測試通過不代表修改生效</li>
<li>沿著執行路徑往下看——之前被擋住的程式碼現在可以執行了，那些區段可能存在未發現的問題</li>
<li>如果修改讓新的資料流入第三方 library，檢查那些資料是否有 edge case</li>
</ul>
<h3 id="設計-try-catch-時">設計 try-catch 時</h3>
<ul>
<li>區分「預期的執行期錯誤」和「程式碼 bug」，只攔截前者</li>
<li>定義專用的 exception 類型（如 <code>PrinterException</code>），在 IO 邊界包裝，上層只 catch 這個類型</li>
<li>資料準備、邏輯運算等步驟不要放在 try-catch 裡面，讓錯誤直接拋出</li>
</ul>
<h3 id="自我檢查清單">自我檢查清單</h3>
<p>寫完測試後可以對照的問題：</p>
<ol>
<li>這個測試有走過真實的呼叫路徑嗎？還是只測了資料搬運？</li>
<li>斷言是驗證「值」還是只驗證「存在」？</li>
<li>所有依賴都有提供嗎？缺少的依賴會不會被 try-catch 吞掉？</li>
<li>模擬子類別覆寫的方法之外，繼承的方法有被測到嗎？</li>
<li>模擬元件回傳的資料有觸發下游的所有分支嗎？</li>
<li>邊界值（空字串、空列表、null）有出現在測試資料中嗎？</li>
<li>try-catch 的範圍是否只包含 IO 操作？資料準備和邏輯運算是否在 try 外面？</li>
<li>有寫反向測試（故意觸發錯誤）來確認理解了 Bug 的根因嗎？</li>
</ol>
<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">28 tests
</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">無廚房印表機時的基本行為（4 tests）
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  └── 基本的 handler 行為，不需要廚房印表機
</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">OnlineOrderRecord 模型（7 tests）
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  └── 單元測試：狀態管理、applyPrintResult
</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">FakePrinterAdapter（6 tests）
</span></span><span class="line"><span class="ln">10</span><span class="cl">  ├── init / sendBytes — 基本功能
</span></span><span class="line"><span class="ln">11</span><span class="cl">  ├── printText after init — 驗證內部元件初始化（抓 Bug 1）
</span></span><span class="line"><span class="ln">12</span><span class="cl">  └── printText without init — 反向驗證
</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">KitchenPrinterConfig（2 tests）
</span></span><span class="line"><span class="ln">15</span><span class="cl">  └── 單元測試：品名匹配邏輯
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl">品項分派邏輯 — 整合測試（4 tests）     ← 全部重寫
</span></span><span class="line"><span class="ln">18</span><span class="cl">  ├── 2 台空 mapping → odd/even 分配     抓 Bug 2
</span></span><span class="line"><span class="ln">19</span><span class="cl">  ├── 1 台空 mapping → fallback
</span></span><span class="line"><span class="ln">20</span><span class="cl">  ├── 明確 mapping 優先匹配
</span></span><span class="line"><span class="ln">21</span><span class="cl">  └── kitchenItemIds 篩選
</span></span><span class="line"><span class="ln">22</span><span class="cl">  （全部透過 printAppendedOrder 驅動，有 FakeReceiptBuilderService）
</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">PrintCenter 廚房印表機管理（5 tests）
</span></span><span class="line"><span class="ln">25</span><span class="cl">  └── 註冊、移除、初始化、向後兼容</span></span></code></pre></div><hr>
<h2 id="最終的修復">最終的修復</h2>
<table>
  <thead>
      <tr>
          <th>Bug</th>
          <th>修復方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Bug 1</td>
          <td>模擬印表機的初始化方法補上內部元件的建立</td>
      </tr>
      <tr>
          <td>Bug 2</td>
          <td>品項分派的 fallback 邏輯改為：唯一一台無對應表 → 全部給它，多台 → 依規則分配</td>
      </tr>
      <tr>
          <td>Bug 3</td>
          <td>多欄表格列印前，自動將欄位比例正規化為符合 library 要求的總和 12</td>
      </tr>
      <tr>
          <td>Bug 4</td>
          <td>文字列印前加入空字串檢查，遇到空字串改用換行指令繞過 library 的問題</td>
      </tr>
      <tr>
          <td>設計改善</td>
          <td>定義 <code>PrinterException</code>，列印方法改為只攔截印表機故障，程式碼 bug 不再被靜默吞掉</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item></channel></rss>