<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Flutter 實戰指南 on Tarragon</title><link>https://tarrragon.github.io/blog/flutter/</link><description>Recent content in Flutter 實戰指南 on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 10 Jul 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/flutter/index.xml" rel="self" type="application/rss+xml"/><item><title>Riverpod 的 reactive 邊界</title><link>https://tarrragon.github.io/blog/flutter/riverpod-reactive-boundary/</link><pubDate>Thu, 16 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/flutter/riverpod-reactive-boundary/</guid><description>&lt;p>Riverpod 的 reactive 保證有明確的涵蓋範圍：&lt;strong>provider 圖的內部&lt;/strong>。&lt;code>ref.watch&lt;/code> 建立的依賴、&lt;code>StreamProvider&lt;/code> 的推送、Notifier 的狀態轉換——這些機制在圖上的節點之間運作可靠；圖外的變化（資料庫寫入、外部容器的操作、已 dispose 節點上的寫入）不觸發任何 reactive 行為、也多半不報錯。「有用 Riverpod」跟「會對變化反應」是兩件事，中間差的就是這條邊界。&lt;/p>
&lt;p>本章把邊界拆成四個方向，每個方向由一個實際踩過的 case 支撐。排查判準收成三問：&lt;strong>這個變化是 provider 圖上某個節點的狀態變化嗎？在哪個容器的圖上？節點還活著嗎？&lt;/strong>&lt;/p>
&lt;h2 id="心智模型配方廚房圖">心智模型：配方、廚房、圖&lt;/h2>
&lt;p>provider 的全域宣告是&lt;strong>配方&lt;/strong>——描述狀態怎麼建、怎麼變化；狀態本身活在&lt;strong>容器&lt;/strong>（&lt;code>ProviderContainer&lt;/code>／&lt;code>ProviderScope&lt;/code>）裡，同一份配方在兩個容器裡煮出兩份互不相干的狀態。容器內的 provider 依 &lt;code>ref.watch&lt;/code> 的依賴關係連成&lt;strong>圖&lt;/strong>：節點是 provider 的狀態、邊是 watch 依賴，狀態變化沿著邊傳播、觸發下游重建。&lt;/p>
&lt;p>reactive 的全部機制都建立在這張圖上。四個邊界各是圖的一個面向：圖屬於誰（空間）、圖上有什麼（涵蓋）、圖外的變化怎麼進來（接入）、節點活多久（時間）。&lt;/p>
&lt;h2 id="空間邊界狀態屬於容器不屬於宣告">空間邊界：狀態屬於容器、不屬於宣告&lt;/h2>
&lt;p>&lt;code>main()&lt;/code> 自建 &lt;code>ProviderContainer&lt;/code> 觸發初始化、UI 跑在 &lt;code>runApp&lt;/code> 的 &lt;code>ProviderScope&lt;/code> 裡——兩個容器各持一份狀態，初始化改的是外部容器那份、UI 監聽的是 Scope 那份，App 永遠停在載入畫面。跨容器操作不報錯、只是安靜地作用在預期之外的地方，每段程式碼單獨看都正確。&lt;/p>
&lt;p>判準：&lt;strong>一個 App 裡活著的容器數量應該是一&lt;/strong>，每多一個都要能說出它為什麼必須隔離（測試的 &lt;code>ProviderContainer(overrides:)&lt;/code> 是正當隔離）。全專案搜 &lt;code>ProviderContainer(&lt;/code>、逐一問「它跟 UI 的 Scope 是同一個嗎」。確實需要在 &lt;code>runApp&lt;/code> 前操作 provider 時（如讀取啟動設定），用 &lt;code>UncontrolledProviderScope&lt;/code> 讓兩邊共用同一個容器。完整機制與修法：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_riverpod_dual_container_state_desync/" data-link-title="App 永遠卡在載入畫面 — Riverpod 的 provider 是配方、容器才持有狀態" data-link-desc="main() 自建 ProviderContainer 對它觸發初始化、UI 跑在 runApp 的 ProviderScope 裡——兩個容器各持一份 provider 狀態、互不相通，UI 監聽的那份永遠停在初始值。Riverpod 的全域 provider 宣告只是配方、狀態屬於容器實例；跨容器操作是靜默的無效操作。">App 永遠卡在載入畫面&lt;/a>。&lt;/p>
&lt;h2 id="涵蓋邊界圖上只有-provider-的狀態">涵蓋邊界：圖上只有 provider 的狀態&lt;/h2>
&lt;p>&lt;code>ref.watch(bookRepositoryProvider)&lt;/code> 對單例 &lt;code>Provider&amp;lt;BookRepository&amp;gt;&lt;/code> 建立的依賴永遠不觸發——這個 provider 的狀態是「repository 物件參考」、整個生命週期不變；SQLite 寫入了一百本書，物件參考一動不動。資料庫的變化不在圖上，於是刷新被推到圖外解決：導航返回點補 &lt;code>loadData()&lt;/code>、EventBus 橋接、多個視圖各自維護 load 時機——補償刷新的出現就是涵蓋缺口的訊號。&lt;/p>
&lt;p>判準：要讓畫面對某個變化反應，&lt;strong>那個變化本身必須是圖上某個節點的狀態變化&lt;/strong>。修法方向一致——把資料變更做成一級節點（repository 補 stream 出口、&lt;code>StreamProvider&lt;/code> 包成 provider），視圖回到純 &lt;code>ref.watch&lt;/code>。三段補償演進與判讀訊號表：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_riverpod_reactive_boundary_ref_watch/" data-link-title="加書後統計不刷新 — ref.watch 觀察的是 provider 圖、不是資料庫" data-link-desc="頁面用了 Riverpod 卻在資料寫入後不更新、或發現自己在導航返回點補 loadData()、用 EventBus 事件觸發 reload 時使用。ref.watch 的 reactive 範圍是 provider 圖上的狀態變化；資料庫寫入不在圖上，補償刷新的出現就是這個缺口的訊號。">ref.watch 觀察的是 provider 圖、不是資料庫&lt;/a>。&lt;/p>
&lt;h2 id="接入邊界圖外的變化要立節點才進圖">接入邊界：圖外的變化要立節點才進圖&lt;/h2>
&lt;p>把 repository 的 stream 接成圖上節點、是涵蓋缺口的結構性修法，接入處有自己的實作契約。三個問題各有一個會靜默失效的預設答案：訂閱模型（多個視圖同時聽、&lt;code>StreamController()&lt;/code> 預設單訂閱、第二個訂閱者執行期 throw）、初始值（broadcast 不補送歷史、不處理的話畫面空到下一次寫入）、dispose（controller 的關閉責任跟著持有者走）。三個實作點的完整落地：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_streamprovider_wraps_repository_watch/" data-link-title="StreamProvider 包 repository watch stream — broadcast、初始值、dispose 實作點" data-link-desc="repository 要補 Stream 觀測出口、接給 Riverpod 消費時使用。訂閱模型選 broadcast 還是單訂閱、新訂閱者拿不拿得到當下狀態、controller 誰負責關——每個問題各有一個會靜默失效的預設答案。">StreamProvider 包 repository watch stream&lt;/a>。&lt;/p>
&lt;p>其中訂閱模型的選擇值得單獨記：&lt;code>StreamController()&lt;/code> vs &lt;code>.broadcast()&lt;/code> 是零成本差異，選限制更高的單訂閱版本、限制在只有一個訂閱者期間完全沉默、第二個訂閱者出現才爆。在零成本差異下把「會有多個觀察者」的領域先驗寫死成單訂閱，是設計缺陷、不是需求演化。單訂閱與 broadcast 的行為差異全表（buffer、pause、重新訂閱）：&lt;a href="https://tarrragon.github.io/blog/work-log/dart_stream_controller_single_vs_broadcast/" data-link-title="Dart StreamController：single-subscription vs broadcast 的設計選型問題" data-link-desc="Dart `Bad state: Stream has already been listened to.` 的根因：預設單訂閱在第二個訂閱者出現時才爆。StreamController vs .broadcast() 修復決策、與 Rx / .obs 的比較。">StreamController single vs broadcast&lt;/a>。&lt;/p>
&lt;h2 id="時間邊界節點的生命有兩頭界線">時間邊界：節點的生命有兩頭界線&lt;/h2>
&lt;p>async 函式的每個 &lt;code>await&lt;/code> 都是一個 gap：等待期間使用者可能離開頁面、Notifier 被 dispose，await 回來再碰 &lt;code>ref&lt;/code> 就炸 &lt;code>UnmountedRefException&lt;/code>。修法可機械化——&lt;strong>每個 await 之後、第一次碰 &lt;code>ref&lt;/code> 之前&lt;/strong>檢查 &lt;code>ref.mounted&lt;/code>；而且刻意不抽成 helper：guard 的價值在「它在哪」，明確的檢查點讓 review 用眼睛掃就能驗證每個 gap 有沒有守。長流程、可離開的頁面（匯入、同步、批次處理）風險最高。完整機制與「評估必跑、可決定不重構」的技術債處置：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_unmounted_ref_async_gap/" data-link-title="await 回來的時候、頁面已經關了 — UnmountedRefException 與 16 個不抽象的檢查點" data-link-desc="長 async 流程的每個 await 都是一個 gap：等待期間使用者可能離開、Notifier 被 dispose、回來再寫 state 就炸 UnmountedRefException。修法是每個 gap 後檢查 ref.mounted——而且刻意不抽成 helper：明確的檢查點讓 review 看得見哪個 gap 有守。含「評估必跑、可決定不重構」的技術債處置。">await 回來的時候、頁面已經關了&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Riverpod 的 reactive 保證有明確的涵蓋範圍：<strong>provider 圖的內部</strong>。<code>ref.watch</code> 建立的依賴、<code>StreamProvider</code> 的推送、Notifier 的狀態轉換——這些機制在圖上的節點之間運作可靠；圖外的變化（資料庫寫入、外部容器的操作、已 dispose 節點上的寫入）不觸發任何 reactive 行為、也多半不報錯。「有用 Riverpod」跟「會對變化反應」是兩件事，中間差的就是這條邊界。</p>
<p>本章把邊界拆成四個方向，每個方向由一個實際踩過的 case 支撐。排查判準收成三問：<strong>這個變化是 provider 圖上某個節點的狀態變化嗎？在哪個容器的圖上？節點還活著嗎？</strong></p>
<h2 id="心智模型配方廚房圖">心智模型：配方、廚房、圖</h2>
<p>provider 的全域宣告是<strong>配方</strong>——描述狀態怎麼建、怎麼變化；狀態本身活在<strong>容器</strong>（<code>ProviderContainer</code>／<code>ProviderScope</code>）裡，同一份配方在兩個容器裡煮出兩份互不相干的狀態。容器內的 provider 依 <code>ref.watch</code> 的依賴關係連成<strong>圖</strong>：節點是 provider 的狀態、邊是 watch 依賴，狀態變化沿著邊傳播、觸發下游重建。</p>
<p>reactive 的全部機制都建立在這張圖上。四個邊界各是圖的一個面向：圖屬於誰（空間）、圖上有什麼（涵蓋）、圖外的變化怎麼進來（接入）、節點活多久（時間）。</p>
<h2 id="空間邊界狀態屬於容器不屬於宣告">空間邊界：狀態屬於容器、不屬於宣告</h2>
<p><code>main()</code> 自建 <code>ProviderContainer</code> 觸發初始化、UI 跑在 <code>runApp</code> 的 <code>ProviderScope</code> 裡——兩個容器各持一份狀態，初始化改的是外部容器那份、UI 監聽的是 Scope 那份，App 永遠停在載入畫面。跨容器操作不報錯、只是安靜地作用在預期之外的地方，每段程式碼單獨看都正確。</p>
<p>判準：<strong>一個 App 裡活著的容器數量應該是一</strong>，每多一個都要能說出它為什麼必須隔離（測試的 <code>ProviderContainer(overrides:)</code> 是正當隔離）。全專案搜 <code>ProviderContainer(</code>、逐一問「它跟 UI 的 Scope 是同一個嗎」。確實需要在 <code>runApp</code> 前操作 provider 時（如讀取啟動設定），用 <code>UncontrolledProviderScope</code> 讓兩邊共用同一個容器。完整機制與修法：<a href="/blog/work-log/flutter_riverpod_dual_container_state_desync/" data-link-title="App 永遠卡在載入畫面 — Riverpod 的 provider 是配方、容器才持有狀態" data-link-desc="main() 自建 ProviderContainer 對它觸發初始化、UI 跑在 runApp 的 ProviderScope 裡——兩個容器各持一份 provider 狀態、互不相通，UI 監聽的那份永遠停在初始值。Riverpod 的全域 provider 宣告只是配方、狀態屬於容器實例；跨容器操作是靜默的無效操作。">App 永遠卡在載入畫面</a>。</p>
<h2 id="涵蓋邊界圖上只有-provider-的狀態">涵蓋邊界：圖上只有 provider 的狀態</h2>
<p><code>ref.watch(bookRepositoryProvider)</code> 對單例 <code>Provider&lt;BookRepository&gt;</code> 建立的依賴永遠不觸發——這個 provider 的狀態是「repository 物件參考」、整個生命週期不變；SQLite 寫入了一百本書，物件參考一動不動。資料庫的變化不在圖上，於是刷新被推到圖外解決：導航返回點補 <code>loadData()</code>、EventBus 橋接、多個視圖各自維護 load 時機——補償刷新的出現就是涵蓋缺口的訊號。</p>
<p>判準：要讓畫面對某個變化反應，<strong>那個變化本身必須是圖上某個節點的狀態變化</strong>。修法方向一致——把資料變更做成一級節點（repository 補 stream 出口、<code>StreamProvider</code> 包成 provider），視圖回到純 <code>ref.watch</code>。三段補償演進與判讀訊號表：<a href="/blog/work-log/flutter_riverpod_reactive_boundary_ref_watch/" data-link-title="加書後統計不刷新 — ref.watch 觀察的是 provider 圖、不是資料庫" data-link-desc="頁面用了 Riverpod 卻在資料寫入後不更新、或發現自己在導航返回點補 loadData()、用 EventBus 事件觸發 reload 時使用。ref.watch 的 reactive 範圍是 provider 圖上的狀態變化；資料庫寫入不在圖上，補償刷新的出現就是這個缺口的訊號。">ref.watch 觀察的是 provider 圖、不是資料庫</a>。</p>
<h2 id="接入邊界圖外的變化要立節點才進圖">接入邊界：圖外的變化要立節點才進圖</h2>
<p>把 repository 的 stream 接成圖上節點、是涵蓋缺口的結構性修法，接入處有自己的實作契約。三個問題各有一個會靜默失效的預設答案：訂閱模型（多個視圖同時聽、<code>StreamController()</code> 預設單訂閱、第二個訂閱者執行期 throw）、初始值（broadcast 不補送歷史、不處理的話畫面空到下一次寫入）、dispose（controller 的關閉責任跟著持有者走）。三個實作點的完整落地：<a href="/blog/work-log/flutter_streamprovider_wraps_repository_watch/" data-link-title="StreamProvider 包 repository watch stream — broadcast、初始值、dispose 實作點" data-link-desc="repository 要補 Stream 觀測出口、接給 Riverpod 消費時使用。訂閱模型選 broadcast 還是單訂閱、新訂閱者拿不拿得到當下狀態、controller 誰負責關——每個問題各有一個會靜默失效的預設答案。">StreamProvider 包 repository watch stream</a>。</p>
<p>其中訂閱模型的選擇值得單獨記：<code>StreamController()</code> vs <code>.broadcast()</code> 是零成本差異，選限制更高的單訂閱版本、限制在只有一個訂閱者期間完全沉默、第二個訂閱者出現才爆。在零成本差異下把「會有多個觀察者」的領域先驗寫死成單訂閱，是設計缺陷、不是需求演化。單訂閱與 broadcast 的行為差異全表（buffer、pause、重新訂閱）：<a href="/blog/work-log/dart_stream_controller_single_vs_broadcast/" data-link-title="Dart StreamController：single-subscription vs broadcast 的設計選型問題" data-link-desc="Dart `Bad state: Stream has already been listened to.` 的根因：預設單訂閱在第二個訂閱者出現時才爆。StreamController vs .broadcast() 修復決策、與 Rx / .obs 的比較。">StreamController single vs broadcast</a>。</p>
<h2 id="時間邊界節點的生命有兩頭界線">時間邊界：節點的生命有兩頭界線</h2>
<p>async 函式的每個 <code>await</code> 都是一個 gap：等待期間使用者可能離開頁面、Notifier 被 dispose，await 回來再碰 <code>ref</code> 就炸 <code>UnmountedRefException</code>。修法可機械化——<strong>每個 await 之後、第一次碰 <code>ref</code> 之前</strong>檢查 <code>ref.mounted</code>；而且刻意不抽成 helper：guard 的價值在「它在哪」，明確的檢查點讓 review 用眼睛掃就能驗證每個 gap 有沒有守。長流程、可離開的頁面（匯入、同步、批次處理）風險最高。完整機制與「評估必跑、可決定不重構」的技術債處置：<a href="/blog/work-log/flutter_unmounted_ref_async_gap/" data-link-title="await 回來的時候、頁面已經關了 — UnmountedRefException 與 16 個不抽象的檢查點" data-link-desc="長 async 流程的每個 await 都是一個 gap：等待期間使用者可能離開、Notifier 被 dispose、回來再寫 state 就炸 UnmountedRefException。修法是每個 gap 後檢查 ref.mounted——而且刻意不抽成 helper：明確的檢查點讓 review 看得見哪個 gap 有守。含「評估必跑、可決定不重構」的技術債處置。">await 回來的時候、頁面已經關了</a>。</p>
<p>生命週期的另一頭是 build 期間：widget tree 建置中直接改 provider 狀態同樣是非法時機，觸發點要用 <code>addPostFrameCallback</code> 延到首幀之後。<code>ref</code> 的合法視窗兩頭都有界——await 之後可能太晚、build 之中太早。</p>
<h2 id="排查判準">排查判準</h2>
<p>reactive 失靈時沿三問走，每一問對應一個邊界：</p>
<table>
  <thead>
      <tr>
          <th>問題</th>
          <th>對應邊界</th>
          <th>常見答案與訊號</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>變化在圖上嗎？</td>
          <td>涵蓋、接入</td>
          <td>資料庫寫入不在圖上——<code>ref.watch</code> 對象是單例 <code>Provider&lt;Repository&gt;</code> 時 watch 永不觸發</td>
      </tr>
      <tr>
          <td>在哪個容器的圖上？</td>
          <td>空間</td>
          <td>狀態「永遠是初始值」指向無人操作這份實例——數容器、查操作方作用在哪份</td>
      </tr>
      <tr>
          <td>節點還活著嗎？</td>
          <td>時間</td>
          <td>崩潰 stack 指向 await 之後的 state 寫入——async gap 沒守</td>
      </tr>
  </tbody>
</table>
<p>三問都過、reactive 仍不對時，回到接入層的實作契約查：訂閱模型（<code>Bad state</code> = 單訂閱撞多訂閱）、初始值（畫面空到下次寫入 = broadcast 沒補當前值）、mock 與真實實作的 stream 契約對齊。</p>
<h2 id="邊界">邊界</h2>
<p>本章處理 Riverpod 這個框架的 reactive 機制邊界，是實作層知識。「觀測能力該放哪一層」（契約歸 domain、機制歸 infrastructure、框架訂閱歸組裝層）是理論層的歸屬判準、與框架無關，見 <a href="/blog/ddd/observation-outlet-responsibility-split/" data-link-title="觀測出口的職責三分" data-link-desc="repository 要補「資料變了」的推送能力、卻不確定 Stream 介面放 domain 算不算洩漏時使用。歸屬判準是介面用什麼語言表達、不是需求來自誰：契約歸 domain、變更偵測歸 infrastructure、框架訂閱歸組裝層。">觀測出口的職責三分</a>；「事件與狀態流哪個當通知載體」的選擇也在理論層，見 <a href="/blog/ddd/domain-event-vs-state-stream/" data-link-title="domain event 與狀態流" data-link-desc="為了讓某個畫面刷新而補發事件、或監聽端掛著全事件過濾器時使用。事件記錄離散事實、狀態流發布連續觀測——判準是消費者問「發生了什麼」還是「現在是什麼」；載體借用的代價是涵蓋面靠枚舉維持。">domain event 與狀態流</a>。本章假設載體與分層已定、只管 Riverpod 端怎麼把它接對。</p>
<h2 id="下一步">下一步</h2>
<p>圖的邊界都守住之後，剩下的深化方向各有一篇 case 可讀：容器與作用域的空間問題在 <a href="/blog/work-log/flutter_riverpod_dual_container_state_desync/" data-link-title="App 永遠卡在載入畫面 — Riverpod 的 provider 是配方、容器才持有狀態" data-link-desc="main() 自建 ProviderContainer 對它觸發初始化、UI 跑在 runApp 的 ProviderScope 裡——兩個容器各持一份 provider 狀態、互不相通，UI 監聽的那份永遠停在初始值。Riverpod 的全域 provider 宣告只是配方、狀態屬於容器實例；跨容器操作是靜默的無效操作。">雙容器狀態脫節</a>、涵蓋缺口的補償演進在 <a href="/blog/work-log/flutter_riverpod_reactive_boundary_ref_watch/" data-link-title="加書後統計不刷新 — ref.watch 觀察的是 provider 圖、不是資料庫" data-link-desc="頁面用了 Riverpod 卻在資料寫入後不更新、或發現自己在導航返回點補 loadData()、用 EventBus 事件觸發 reload 時使用。ref.watch 的 reactive 範圍是 provider 圖上的狀態變化；資料庫寫入不在圖上，補償刷新的出現就是這個缺口的訊號。">ref.watch 觀察的是 provider 圖</a>、接入實作在 <a href="/blog/work-log/flutter_streamprovider_wraps_repository_watch/" data-link-title="StreamProvider 包 repository watch stream — broadcast、初始值、dispose 實作點" data-link-desc="repository 要補 Stream 觀測出口、接給 Riverpod 消費時使用。訂閱模型選 broadcast 還是單訂閱、新訂閱者拿不拿得到當下狀態、controller 誰負責關——每個問題各有一個會靜默失效的預設答案。">StreamProvider 包 repository watch stream</a> 與 <a href="/blog/work-log/dart_stream_controller_single_vs_broadcast/" data-link-title="Dart StreamController：single-subscription vs broadcast 的設計選型問題" data-link-desc="Dart `Bad state: Stream has already been listened to.` 的根因：預設單訂閱在第二個訂閱者出現時才爆。StreamController vs .broadcast() 修復決策、與 Rx / .obs 的比較。">StreamController single vs broadcast</a>、生命週期在 <a href="/blog/work-log/flutter_unmounted_ref_async_gap/" data-link-title="await 回來的時候、頁面已經關了 — UnmountedRefException 與 16 個不抽象的檢查點" data-link-desc="長 async 流程的每個 await 都是一個 gap：等待期間使用者可能離開、Notifier 被 dispose、回來再寫 state 就炸 UnmountedRefException。修法是每個 gap 後檢查 ref.mounted——而且刻意不抽成 helper：明確的檢查點讓 review 看得見哪個 gap 有守。含「評估必跑、可決定不重構」的技術債處置。">await 回來的時候、頁面已經關了</a>。理論地基從 <a href="/blog/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 指南的讀側與觀測路線</a> 進。</p>
]]></content:encoded></item><item><title>流程測試基礎設施</title><link>https://tarrragon.github.io/blog/flutter/flow-test-infrastructure/</link><pubDate>Fri, 17 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/flutter/flow-test-infrastructure/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/flow-test/" data-link-title="Flow Test（流程測試）" data-link-desc="在假後端上驅動真實前端服務鏈、斷言散佈於業務旅程各階段的測試形態；與 unit / integration / E2E 的邊界劃分">流程測試&lt;/a>驅動的是跨服務的真實編排，&lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端與流程測試&lt;/a>從策略層回答了「什麼時候值得建假後端、流程測試驗證什麼」；本章處理拿著策略在 Dart/Flutter 生態落地時碰到的實作限制。四個限制形成一條建置鏈：前一個的答案決定後一個的形態，跳著解會繞遠路。&lt;/p>
&lt;h2 id="閘門-spike編排的宿主能不能在-headless-環境立起來">閘門 spike：編排的宿主能不能在 headless 環境立起來&lt;/h2>
&lt;p>流程測試要驅動的是控制器裡的真實編排——順序約束、防護動作、收尾同步——而 Flutter 的控制器在 &lt;code>onInit&lt;/code> 常帶平台耦合（platform channel 訂閱、相機偵測、外接裝置 plugin）。這些耦合在 headless 測試環境全部失效。&lt;/p>
&lt;p>開工前先做一條最小測試 spike：能否建構控制器並呼叫編排入口。spike 的答案決定整個套件形態——立得起來就直接驅動真實編排（零漂移），立不起來要先評估重構接縫的成本。&lt;/p>
&lt;p>平台耦合的中和工具在 Dart 生態有固定的對應：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>耦合類型&lt;/th>
 &lt;th>中和手段&lt;/th>
 &lt;th>時序要求&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>EventChannel（plugin 建構子就訂閱）&lt;/td>
 &lt;td>&lt;code>setMockStreamHandler&lt;/code> 掛空 handler&lt;/td>
 &lt;td>在&lt;strong>建構 plugin 物件之前&lt;/strong>掛好&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>MethodChannel（查詢類呼叫）&lt;/td>
 &lt;td>&lt;code>setMockMethodCallHandler&lt;/code> 回空結果&lt;/td>
 &lt;td>在&lt;strong>呼叫發生之前&lt;/strong>掛好&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>服務級平台依賴（掃碼、藍牙）&lt;/td>
 &lt;td>手寫 no-op 子類覆寫碰平台的成員&lt;/td>
 &lt;td>在 DI 容器註冊子類取代原服務&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>addPostFrameCallback&lt;/code> 承載的初始化&lt;/td>
 &lt;td>測試裡手動呼叫同一個方法&lt;/td>
 &lt;td>控制器建構後、斷言前&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>時序欄位承載的是這張表最容易踩的陷阱：EventChannel 的訂閱發生在 plugin 建構子裡，晚一步掛 mock，訂閱已經對著真實 channel 成立、之後補掛不會回溯生效——症狀是測試偶發卡在等事件。MethodChannel 的容錯高一些（呼叫當下才查 handler），但同樣要在第一次呼叫前就位。no-op 子類優於 mock 框架的場景：要中和的成員少（覆寫列表一目瞭然）、其餘行為要保留真實。流程測試的精神是假件越少，測試的證言越可信——這裡指的是平台耦合層的假件（與被驗編排無關的雜訊），被測邊界本身的假後端是另一回事，它的設計判準與忠實性把關見&lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端&lt;/a>。&lt;/p>
&lt;p>這套 channel mock 加 no-op 子類的組合（後文稱 harness）一旦成立就收斂為共用 bootstrap 方法——之後每條流程測試的邊際成本只剩劇本本身。完整 case 與程式碼範例：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_headless_controller_test_bootstrap/" data-link-title="讓 UI 控制器在 headless 測試立起來：platform channel mock、no-op 子類與 postFrameCallback 的手工補位" data-link-desc="流程測試要驅動真實編排，而編排住在 UI 控制器裡——能不能在無畫面的測試環境把控制器立起來，決定整個測試套件的形態。先用 spike 驗證閘門、再逐項中和平台耦合，讓控制器在 headless 環境可建構。">讓 UI 控制器在 headless 測試立起來&lt;/a>。&lt;/p>
&lt;h2 id="兩種測試的共存binding-的檔案級-isolate-隔離">兩種測試的共存：binding 的檔案級 isolate 隔離&lt;/h2>
&lt;p>流程測試需要 &lt;code>TestWidgetsFlutterBinding&lt;/code>（mock channel、立控制器），真實後端驗證測試需要真實網路——兩者互斥。&lt;code>TestWidgetsFlutterBinding.ensureInitialized()&lt;/code> 的副作用之一是把 &lt;code>dart:io&lt;/code> 的 &lt;code>HttpClient&lt;/code> 換成一律回 400 的假件，且這個副作用是程序級全域、沒有乾淨的關閉開關。&lt;/p>
&lt;p>共存機制不需要額外設計：&lt;code>flutter test&lt;/code> 讓每個測試檔案跑在獨立 isolate，而 isolate 之間記憶體不共享——binding 換掉的全域物件只在自己的 isolate 內生效，副作用因此以檔案為邊界。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>&lt;/th>
 &lt;th>流程測試檔&lt;/th>
 &lt;th>真實後端驗證檔&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>binding&lt;/td>
 &lt;td>&lt;code>ensureInitialized()&lt;/code>&lt;/td>
 &lt;td>不初始化&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>HttpClient&lt;/td>
 &lt;td>假件（走假後端 adapter）&lt;/td>
 &lt;td>真實（需要）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>隔離邊界&lt;/td>
 &lt;td>檔案自己的 isolate&lt;/td>
 &lt;td>檔案自己的 isolate&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>硬約束：真實後端驗證測試的檔案&lt;strong>不可 import 流程測試的 harness&lt;/strong>——harness 為了 mock channel 第一步就是 &lt;code>ensureInitialized&lt;/code>，import 進來即使不直接呼叫，任何共用 helper 順手初始化都會中招。違反這條約束的症狀是「穩定 400、無網路痕跡」——症狀與原因之間距離太遠，值得寫進檔頭。&lt;/p>
&lt;p>真實後端驗證檔裡繞開產品 DI 的做法：手組最小可用的 &lt;code>Dio&lt;/code>，請求與解析仍走產品的 API client 與模型（型別化解析層共用、不手寫 JSON）。完整機制與程式碼：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_test_binding_blocks_real_network/" data-link-title="TestWidgetsFlutterBinding 會擋掉真實網路：真實後端測試與流程測試的檔案級隔離" data-link-desc="flutter_test 的 binding 初始化後會把 HttpClient 換成回 400 的假件——需要真實網路的後端驗證測試不可初始化 binding，也因此不可 import 任何會初始化 binding 的 harness。靠測試檔案各自跑在獨立 isolate 的特性，兩種測試在同一個目錄共存。">TestWidgetsFlutterBinding 會擋掉真實網路&lt;/a>。&lt;/p>
&lt;h2 id="輸出雜訊治理預期環境狀態的正確處理路徑">輸出雜訊治理：預期環境狀態的正確處理路徑&lt;/h2>
&lt;p>harness 立起後，測試輸出可能固定印出幾行「錯誤長相」的文字——相機偵測的 &lt;code>MissingPluginException&lt;/code>、toast 套件的 assert fallback。這些在生產環境是正確的防護路徑，在測試環境是必然觸發的假警報。假警報訓練人忽略輸出，新的真警報混在裡面就被同一個心理過濾器吃掉。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/testing/knowledge-cards/flow-test/" data-link-title="Flow Test（流程測試）" data-link-desc="在假後端上驅動真實前端服務鏈、斷言散佈於業務旅程各階段的測試形態；與 unit / integration / E2E 的邊界劃分">流程測試</a>驅動的是跨服務的真實編排，<a href="/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端與流程測試</a>從策略層回答了「什麼時候值得建假後端、流程測試驗證什麼」；本章處理拿著策略在 Dart/Flutter 生態落地時碰到的實作限制。四個限制形成一條建置鏈：前一個的答案決定後一個的形態，跳著解會繞遠路。</p>
<h2 id="閘門-spike編排的宿主能不能在-headless-環境立起來">閘門 spike：編排的宿主能不能在 headless 環境立起來</h2>
<p>流程測試要驅動的是控制器裡的真實編排——順序約束、防護動作、收尾同步——而 Flutter 的控制器在 <code>onInit</code> 常帶平台耦合（platform channel 訂閱、相機偵測、外接裝置 plugin）。這些耦合在 headless 測試環境全部失效。</p>
<p>開工前先做一條最小測試 spike：能否建構控制器並呼叫編排入口。spike 的答案決定整個套件形態——立得起來就直接驅動真實編排（零漂移），立不起來要先評估重構接縫的成本。</p>
<p>平台耦合的中和工具在 Dart 生態有固定的對應：</p>
<table>
  <thead>
      <tr>
          <th>耦合類型</th>
          <th>中和手段</th>
          <th>時序要求</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>EventChannel（plugin 建構子就訂閱）</td>
          <td><code>setMockStreamHandler</code> 掛空 handler</td>
          <td>在<strong>建構 plugin 物件之前</strong>掛好</td>
      </tr>
      <tr>
          <td>MethodChannel（查詢類呼叫）</td>
          <td><code>setMockMethodCallHandler</code> 回空結果</td>
          <td>在<strong>呼叫發生之前</strong>掛好</td>
      </tr>
      <tr>
          <td>服務級平台依賴（掃碼、藍牙）</td>
          <td>手寫 no-op 子類覆寫碰平台的成員</td>
          <td>在 DI 容器註冊子類取代原服務</td>
      </tr>
      <tr>
          <td><code>addPostFrameCallback</code> 承載的初始化</td>
          <td>測試裡手動呼叫同一個方法</td>
          <td>控制器建構後、斷言前</td>
      </tr>
  </tbody>
</table>
<p>時序欄位承載的是這張表最容易踩的陷阱：EventChannel 的訂閱發生在 plugin 建構子裡，晚一步掛 mock，訂閱已經對著真實 channel 成立、之後補掛不會回溯生效——症狀是測試偶發卡在等事件。MethodChannel 的容錯高一些（呼叫當下才查 handler），但同樣要在第一次呼叫前就位。no-op 子類優於 mock 框架的場景：要中和的成員少（覆寫列表一目瞭然）、其餘行為要保留真實。流程測試的精神是假件越少，測試的證言越可信——這裡指的是平台耦合層的假件（與被驗編排無關的雜訊），被測邊界本身的假後端是另一回事，它的設計判準與忠實性把關見<a href="/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端</a>。</p>
<p>這套 channel mock 加 no-op 子類的組合（後文稱 harness）一旦成立就收斂為共用 bootstrap 方法——之後每條流程測試的邊際成本只剩劇本本身。完整 case 與程式碼範例：<a href="/blog/work-log/flutter_headless_controller_test_bootstrap/" data-link-title="讓 UI 控制器在 headless 測試立起來：platform channel mock、no-op 子類與 postFrameCallback 的手工補位" data-link-desc="流程測試要驅動真實編排，而編排住在 UI 控制器裡——能不能在無畫面的測試環境把控制器立起來，決定整個測試套件的形態。先用 spike 驗證閘門、再逐項中和平台耦合，讓控制器在 headless 環境可建構。">讓 UI 控制器在 headless 測試立起來</a>。</p>
<h2 id="兩種測試的共存binding-的檔案級-isolate-隔離">兩種測試的共存：binding 的檔案級 isolate 隔離</h2>
<p>流程測試需要 <code>TestWidgetsFlutterBinding</code>（mock channel、立控制器），真實後端驗證測試需要真實網路——兩者互斥。<code>TestWidgetsFlutterBinding.ensureInitialized()</code> 的副作用之一是把 <code>dart:io</code> 的 <code>HttpClient</code> 換成一律回 400 的假件，且這個副作用是程序級全域、沒有乾淨的關閉開關。</p>
<p>共存機制不需要額外設計：<code>flutter test</code> 讓每個測試檔案跑在獨立 isolate，而 isolate 之間記憶體不共享——binding 換掉的全域物件只在自己的 isolate 內生效，副作用因此以檔案為邊界。</p>
<table>
  <thead>
      <tr>
          <th></th>
          <th>流程測試檔</th>
          <th>真實後端驗證檔</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>binding</td>
          <td><code>ensureInitialized()</code></td>
          <td>不初始化</td>
      </tr>
      <tr>
          <td>HttpClient</td>
          <td>假件（走假後端 adapter）</td>
          <td>真實（需要）</td>
      </tr>
      <tr>
          <td>隔離邊界</td>
          <td>檔案自己的 isolate</td>
          <td>檔案自己的 isolate</td>
      </tr>
  </tbody>
</table>
<p>硬約束：真實後端驗證測試的檔案<strong>不可 import 流程測試的 harness</strong>——harness 為了 mock channel 第一步就是 <code>ensureInitialized</code>，import 進來即使不直接呼叫，任何共用 helper 順手初始化都會中招。違反這條約束的症狀是「穩定 400、無網路痕跡」——症狀與原因之間距離太遠，值得寫進檔頭。</p>
<p>真實後端驗證檔裡繞開產品 DI 的做法：手組最小可用的 <code>Dio</code>，請求與解析仍走產品的 API client 與模型（型別化解析層共用、不手寫 JSON）。完整機制與程式碼：<a href="/blog/work-log/flutter_test_binding_blocks_real_network/" data-link-title="TestWidgetsFlutterBinding 會擋掉真實網路：真實後端測試與流程測試的檔案級隔離" data-link-desc="flutter_test 的 binding 初始化後會把 HttpClient 換成回 400 的假件——需要真實網路的後端驗證測試不可初始化 binding，也因此不可 import 任何會初始化 binding 的 harness。靠測試檔案各自跑在獨立 isolate 的特性，兩種測試在同一個目錄共存。">TestWidgetsFlutterBinding 會擋掉真實網路</a>。</p>
<h2 id="輸出雜訊治理預期環境狀態的正確處理路徑">輸出雜訊治理：預期環境狀態的正確處理路徑</h2>
<p>harness 立起後，測試輸出可能固定印出幾行「錯誤長相」的文字——相機偵測的 <code>MissingPluginException</code>、toast 套件的 assert fallback。這些在生產環境是正確的防護路徑，在測試環境是必然觸發的假警報。假警報訓練人忽略輸出，新的真警報混在裡面就被同一個心理過濾器吃掉。</p>
<p>治理原則：測試環境 100% 必然成立的觸發條件，用前置判斷或 harness mock 讓它走正常路徑，讓例外接管只剩真正的例外。</p>
<table>
  <thead>
      <tr>
          <th>雜訊類型</th>
          <th>修法</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>平台 plugin 不存在導致的 <code>MissingPluginException</code></td>
          <td>harness 的 channel mock 回空結果（上一節已掛的 mock 同時解決）</td>
      </tr>
      <tr>
          <td>headless 環境沒有 widget tree 導致的 assert fallback</td>
          <td>顯示前前置判斷 <code>rootElement != null</code>，無畫面時跳過顯示</td>
      </tr>
  </tbody>
</table>
<p>前置判斷放在 <code>runZonedGuarded</code> 的 zone 內而非外——極端環境（binding 未初始化）連 <code>WidgetsBinding.instance</code> 都會拋，放 zone 內讓它落入兜底。分工因此變乾淨：前置判斷處理已知的環境狀態，zone 兜底處理未知的失敗。完整案例與判準：<a href="/blog/work-log/flutter_test_noise_expected_paths/" data-link-title="測試輸出的雜訊治理：預期的環境狀態不該走例外路徑" data-link-desc="測試輸出長期印著兩行「已知無害」的錯誤——相機偵測 MissingPluginException、toast 套件的 assert fallback。已知雜訊會訓練人忽略輸出，新警報混在裡面就被過濾掉。修法是把「預期的環境狀態」變成前置判斷（channel mock 回空、無畫面早退），讓例外路徑只剩真正的例外。">測試輸出的雜訊治理</a>。</p>
<h2 id="假後端的回應序列化物件--tojson不是手寫-json">假後端的回應序列化：物件 → toJson，不是手寫 JSON</h2>
<p>有狀態假後端的回應資料從哪裡來，是 Dart 生態特有的選型：freezed 模型天生雙向（<code>fromJson</code>/<code>toJson</code>），假後端持有模型物件、出口一律 <code>toJson()</code>，服務層走的反序列化路徑與生產環境完全相同。</p>
<p>手寫 JSON 樣板跳過這個閉環的前半段——樣板對不對靠人眼比對 API 文件。實際案例：同一批測試裡的 raw 寫法重踩了產品早已內建處理的分頁包裝（信封解析層的知識被重複實作在測試 helper 裡），改走產品的 API client 與模型後，helper 連同它代表的重複知識一起刪除。</p>
<p>判準一句話：<strong>回應形狀的知識只該存在一份</strong>。測試裡出現 <code>data['data']</code> 之類手挖回應欄位的 helper，就是重複知識的訊號。</p>
<p>假後端的狀態演變用 <code>copyWith</code> 在 handler 裡宣告式完成，一個 handler 對應一條已證實的後端行為。toJson 閉環的忠實性由配對的<a href="/blog/testing/03-protocol-integration-test/real-backend-verification/" data-link-title="真實後端驗證測試" data-link-desc="服務無法本機啟動、只有共用測試環境（staging）時，把對真實後端的行為驗證寫成常駐測試：離線降級為跳過、憑證失效必須紅燈，讓假後端固化的行為假設有地方對真實後端驗證">真實後端驗證測試</a>把關——模型的 <code>fromJson</code>/<code>toJson</code> 若與真實後端不對稱，會在那裡先於產品爆出來。完整做法與對照組事故：<a href="/blog/work-log/flutter_fake_backend_real_model_serialization/" data-link-title="有狀態假後端用真實模型序列化回應：手寫 JSON fixture 會重踩產品已解決的問題" data-link-desc="流程測試的假後端持有 freezed 模型物件、以 toJson 序列化回應，讓服務層走完整的反序列化鏈。對照組是手寫 JSON fixture——同一批測試裡的 raw 寫法重踩了一次產品早已內建處理的分頁包裝，證明「回應形狀的知識」應該只存在一份。">有狀態假後端用真實模型序列化回應</a>。</p>
<h2 id="建置順序">建置順序</h2>
<p>四個限制的處理有先後依賴：</p>
<ol>
<li><strong>spike</strong>：一條最小測試驗證控制器能否在 headless 環境建構並呼叫編排入口</li>
<li><strong>harness 收斂</strong>：spike 過了之後，把 channel mock + no-op 子類 + postFrameCallback 補位收進共用 bootstrap</li>
<li><strong>檔案隔離規劃</strong>：真實後端驗證測試另開檔案、不 import harness、不初始化 binding</li>
<li><strong>雜訊治理</strong>：harness 的 channel mock 順便解決大部分雜訊；剩餘的 headless 環境假警報加前置判斷</li>
<li><strong>假後端序列化</strong>：以 freezed 模型持有狀態、toJson 出口，seed builder 集中維護初始狀態</li>
<li><strong>第一條劇本</strong>：走完一段跨服務業務旅程，斷言散佈在各階段的可觀察結果上</li>
</ol>
<p>步驟 1 決定後續全部形態——立不起來就回到策略層重新評估（<a href="/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端與流程測試</a>的「可測性閘門」段）。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>策略層的完整判準（什麼時候值得建假後端）→ <a href="/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端與流程測試</a></li>
<li>配對的真實後端驗證（假後端行為漂移的防線）→ <a href="/blog/testing/03-protocol-integration-test/real-backend-verification/" data-link-title="真實後端驗證測試" data-link-desc="服務無法本機啟動、只有共用測試環境（staging）時，把對真實後端的行為驗證寫成常駐測試：離線降級為跳過、憑證失效必須紅燈，讓假後端固化的行為假設有地方對真實後端驗證">真實後端驗證測試</a></li>
<li>憑證的存放與 CI 注入 → <a href="/blog/testing/03-protocol-integration-test/credential-management/" data-link-title="測試憑證管理" data-link-desc="測試環境的帳號密碼放在哪裡、CI 怎麼拿到、怎麼防止對生產環境執行 — 存放策略的適用前提與失效偵測">測試憑證管理</a></li>
<li>各限制的完整 case：<a href="/blog/work-log/flutter_headless_controller_test_bootstrap/" data-link-title="讓 UI 控制器在 headless 測試立起來：platform channel mock、no-op 子類與 postFrameCallback 的手工補位" data-link-desc="流程測試要驅動真實編排，而編排住在 UI 控制器裡——能不能在無畫面的測試環境把控制器立起來，決定整個測試套件的形態。先用 spike 驗證閘門、再逐項中和平台耦合，讓控制器在 headless 環境可建構。">headless 控制器</a>、<a href="/blog/work-log/flutter_test_binding_blocks_real_network/" data-link-title="TestWidgetsFlutterBinding 會擋掉真實網路：真實後端測試與流程測試的檔案級隔離" data-link-desc="flutter_test 的 binding 初始化後會把 HttpClient 換成回 400 的假件——需要真實網路的後端驗證測試不可初始化 binding，也因此不可 import 任何會初始化 binding 的 harness。靠測試檔案各自跑在獨立 isolate 的特性，兩種測試在同一個目錄共存。">binding 互斥</a>、<a href="/blog/work-log/flutter_test_noise_expected_paths/" data-link-title="測試輸出的雜訊治理：預期的環境狀態不該走例外路徑" data-link-desc="測試輸出長期印著兩行「已知無害」的錯誤——相機偵測 MissingPluginException、toast 套件的 assert fallback。已知雜訊會訓練人忽略輸出，新警報混在裡面就被過濾掉。修法是把「預期的環境狀態」變成前置判斷（channel mock 回空、無畫面早退），讓例外路徑只剩真正的例外。">雜訊治理</a>、<a href="/blog/work-log/flutter_fake_backend_real_model_serialization/" data-link-title="有狀態假後端用真實模型序列化回應：手寫 JSON fixture 會重踩產品已解決的問題" data-link-desc="流程測試的假後端持有 freezed 模型物件、以 toJson 序列化回應，讓服務層走完整的反序列化鏈。對照組是手寫 JSON fixture——同一批測試裡的 raw 寫法重踩了一次產品早已內建處理的分頁包裝，證明「回應形狀的知識」應該只存在一份。">假後端序列化</a></li>
</ul>
]]></content:encoded></item><item><title>值物件的 Dart 實作路徑</title><link>https://tarrragon.github.io/blog/flutter/value-object-dart-implementation/</link><pubDate>Mon, 20 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/flutter/value-object-dart-implementation/</guid><description>&lt;p>值物件在實作層的責任是把一個領域值裝進專用型別、讓型別開放的運算限縮成領域有意義的封閉集合。金額只該加減、乘數量、乘倍率；識別碼只該比對與傳遞；日期範圍只該判包含與交疊。底層的通用型別（數字、字串）開放的運算遠多於這個集合，差集裡的每個運算都是一個等著被誤用的 API——把值物件建起來，就是把差集從型別上關掉。&lt;/p>
&lt;p>「這個領域值該不該升級成值物件」的判定屬於理論層，判準是同一性語意與語意封閉、與語言無關，見 &lt;a href="https://tarrragon.github.io/blog/ddd/entity-vs-value-object/" data-link-title="entity 與 value object 的判準" data-link-desc="同一個業務概念該建成 entity 還是 value object：判準是「操作需不需要 identity-based 回寫」、而不是概念重要性或有沒有 id 可填。含判準隨生命週期重問的交棒時機、value object 的語意封閉、枚舉分層。">entity 與 value object 的判準&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/value-object/" data-link-title="Value Object" data-link-desc="判斷一個概念該用內容比對還是身份追蹤時使用。value object 的同一性由內容定義——內容相等就是同一個、替換實例對系統沒有影響。">value object&lt;/a> 卡。本章接手判定之後的問題：在 Dart 裡，同一個「值物件」有三種實作載體，成本結構與適用情境各異——選哪條由這個值的欄位數、要不要 runtime 身份、以及專案對產生器的容忍度決定。&lt;/p>
&lt;h2 id="判斷什麼領域值值得升級">判斷：什麼領域值值得升級&lt;/h2>
&lt;p>值得升級的訊號是一個領域概念以裸的通用型別跨模組流通、而它的合法運算明顯少於底層型別。金額用 &lt;code>double&lt;/code> 或 &lt;code>Decimal&lt;/code>、識別碼用 &lt;code>String&lt;/code>、數量用 &lt;code>int&lt;/code>——這些型別在模組之間傳遞時，型別系統對「金額乘金額」「識別碼相加」這類無意義運算全部放行，因為它們在底層型別的世界裡都合法。合法運算集合小於底層集合、且裸型別已經跨越模組邊界，包一層的價值就成立。&lt;/p>
&lt;p>這裡有兩個常被壓成一個的獨立問題。精度是底層表示的問題——浮點數累加金額會把誤差堆到分位，換一個高精度數字型別就解決。語意是運算邊界的問題——換完精度型別後，「任何人都能對這個值做任意運算」原封不動。解掉第一個問題的當下第二個問題完整存在，而它要等夠多「拿金額亂算」的路徑累積後才顯形。把兩者混為一談的後果是換完 &lt;code>Decimal&lt;/code> 就宣告收工、語意缺口留在原地。&lt;/p>
&lt;p>反過來，不是每個領域值都值得升級。合法運算集合幾乎等於底層型別的集合時（一個真的就是任意整數的計數器），封閉沒有差集可關、專用型別只是多一層轉換。裸型別從不跨越模組邊界、只在單一函式內部短暫存在時，誤用的窗口太小、升級的維護成本收不回。判準操作化成一句話：盤點這個概念的合法運算清單、跟底層型別的運算集合做差集，差集非空且裸型別在模組間流動，才動手。&lt;/p>
&lt;h2 id="實作路徑的成本結構">實作路徑的成本結構&lt;/h2>
&lt;p>三種載體對應兩條軸：這個值是單一底層值還是多欄位複合值、以及需不需要 runtime 的型別身份。單一底層值（金額包一個數字、識別碼包一個字串）走 extension type 最省；多欄位複合值（地址、日期範圍、含幣別的金額）需要一個真正的物件裝多個欄位，走 class 路徑，class 又分手寫與 freezed 產生兩種。runtime 身份的需求橫切這兩軸——需要 &lt;code>is Money&lt;/code> 在執行期為真、或需要反射看得到型別時，只有 class 路徑滿足。&lt;/p>
&lt;p>Dart 3 的 record 是多欄位載體、還自帶結構相等，看起來像多欄位複合值的第四條路徑，但它不入選值物件的載體、原因在型別語意。record 是結構型別而非名目型別：兩個欄位形狀相同的概念（&lt;code>(String, String)&lt;/code> 的地址與姓名）在型別系統裡是同一個 record 型別、拿不到 &lt;code>DateRange&lt;/code> 這種領域名字，也就換不到「傳錯型別編譯期就擋」的保護。record 沒有建構子、無處掛不變式檢查，也無法限縮 API——任何拿到它的程式碼都能讀所有欄位、組任意新值。值物件要的名目身份、建構期不變式、封閉介面，record 結構上三個都不給。它適合的是函式的匿名多回傳與臨時分組，不是需要領域約束的值物件。&lt;/p>
&lt;h3 id="手寫-immutable-class-與-copywith">手寫 immutable class 與 copyWith&lt;/h3>
&lt;p>手寫 immutable class 是最直接的載體：所有欄位 &lt;code>final&lt;/code>、建構子帶不變式檢查、要「改」就造一個新實例。多欄位複合值在這條路徑上用 &lt;a href="https://tarrragon.github.io/blog/flutter/knowledge-cards/copywith/" data-link-title="copyWith" data-link-desc="物件的逐欄位覆寫方法在什麼時候是正確工具、什麼時候是逃生口時使用。copyWith 對資料袋語意清晰、對有領域方法的 entity 是繞過不變式的逃生口。">copyWith&lt;/a> 做逐欄位覆寫——傳要改的欄位、其餘保留原值、回傳新實例，這對欄位組合全部合法的值語意清晰。成本在 boilerplate：&lt;code>==&lt;/code> 與 &lt;code>hashCode&lt;/code> 要手寫且要涵蓋所有參與相等性的欄位、&lt;code>copyWith&lt;/code> 每加一個欄位就要同步一行，漏一個欄位的相等性比對是安靜的 bug。&lt;/p>
&lt;p>這條路徑的邊界在 copyWith 的適用範圍。對欄位組合全部合法的資料袋與純值物件，copyWith 是正確工具；對有領域方法、欄位之間有不變式約束的型別，全欄位 public 的 copyWith 是繞過領域方法的逃生口——領域方法從「唯一變更路徑」降級成「建議路徑」。判準是型別有沒有「不允許任意組合的欄位」，有的話那些欄位就不該讓 copyWith public 可寫。完整機制見 &lt;a href="https://tarrragon.github.io/blog/work-log/dart_copywith_entity_escape_hatch/" data-link-title="copyWith 是逃生口，不是設計 — 從一個測試 bug 追到 entity 稽核軌跡的洞" data-link-desc="copyWith 對純資料載體是正確工具，對有領域方法的 entity 是繞過不變式的逃生口。從一個 3 字元 ID 觸發的例外，追出同族語意錯誤、被繞過的領域方法、以及從未被強制的註解約束。">copyWith 是逃生口，不是設計&lt;/a>。&lt;/p>
&lt;h3 id="freezed-產生器">freezed 產生器&lt;/h3>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/flutter/knowledge-cards/freezed/" data-link-title="freezed" data-link-desc="Dart 的 immutable data class 程式碼生成器。freezed 自動產生 copyWith、equals、toString、sealed union——它是 Dart 生態把 copyWith 推成預設路徑的主要推力。">freezed&lt;/a> 是把手寫 class 的 boilerplate 壓到接近零的產生器：標記 &lt;code>@freezed&lt;/code> 後自動產生 copyWith、&lt;code>==&lt;/code> / &lt;code>hashCode&lt;/code>、&lt;code>toString()&lt;/code>、以及 sealed union。多欄位複合值需要完整相等性與序列化、又不想手工維護每個欄位的同步時，freezed 是這條路徑的主流選擇；它的 sealed union 在枚舉分層上另有價值——exhaustive switch 讓「忘記決定新成員歸哪類」在編譯期就走不通。&lt;/p>
&lt;p>成本結構有兩面。一面是工具依賴：freezed 走 &lt;code>build_runner&lt;/code> 產生程式碼，專案要接受產生器的建置步驟與產物管理。另一面是預設路徑的一視同仁——freezed 不區分資料袋和有領域方法的 entity，每個被標記的 class 都得到全欄位 public copyWith，包含狀態欄位與稽核欄位。規範說「請走領域方法」、工具預設給全欄位 copyWith，兩者衝突時預設會贏。在 entity 上使用 freezed 需要額外收窄：把 copyWith 改 private、或從參數列移除受約束的欄位。結構細節見 &lt;a href="https://tarrragon.github.io/blog/work-log/dart_freezed_anatomy/" data-link-title="Freezed 的三層結構解剖：with、_$、以及更好懂的替代路徑" data-link-desc="freezed `class X with _$X implements Y` 的分層結構解剖：`with` 與 `_$` 各自的角色、沒有 freezed 怎麼手做、中間投影物件 vs DTO 直接 implements 的維護取捨。">Freezed 三層結構解剖&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>值物件在實作層的責任是把一個領域值裝進專用型別、讓型別開放的運算限縮成領域有意義的封閉集合。金額只該加減、乘數量、乘倍率；識別碼只該比對與傳遞；日期範圍只該判包含與交疊。底層的通用型別（數字、字串）開放的運算遠多於這個集合，差集裡的每個運算都是一個等著被誤用的 API——把值物件建起來，就是把差集從型別上關掉。</p>
<p>「這個領域值該不該升級成值物件」的判定屬於理論層，判準是同一性語意與語意封閉、與語言無關，見 <a href="/blog/ddd/entity-vs-value-object/" data-link-title="entity 與 value object 的判準" data-link-desc="同一個業務概念該建成 entity 還是 value object：判準是「操作需不需要 identity-based 回寫」、而不是概念重要性或有沒有 id 可填。含判準隨生命週期重問的交棒時機、value object 的語意封閉、枚舉分層。">entity 與 value object 的判準</a> 與 <a href="/blog/ddd/knowledge-cards/value-object/" data-link-title="Value Object" data-link-desc="判斷一個概念該用內容比對還是身份追蹤時使用。value object 的同一性由內容定義——內容相等就是同一個、替換實例對系統沒有影響。">value object</a> 卡。本章接手判定之後的問題：在 Dart 裡，同一個「值物件」有三種實作載體，成本結構與適用情境各異——選哪條由這個值的欄位數、要不要 runtime 身份、以及專案對產生器的容忍度決定。</p>
<h2 id="判斷什麼領域值值得升級">判斷：什麼領域值值得升級</h2>
<p>值得升級的訊號是一個領域概念以裸的通用型別跨模組流通、而它的合法運算明顯少於底層型別。金額用 <code>double</code> 或 <code>Decimal</code>、識別碼用 <code>String</code>、數量用 <code>int</code>——這些型別在模組之間傳遞時，型別系統對「金額乘金額」「識別碼相加」這類無意義運算全部放行，因為它們在底層型別的世界裡都合法。合法運算集合小於底層集合、且裸型別已經跨越模組邊界，包一層的價值就成立。</p>
<p>這裡有兩個常被壓成一個的獨立問題。精度是底層表示的問題——浮點數累加金額會把誤差堆到分位，換一個高精度數字型別就解決。語意是運算邊界的問題——換完精度型別後，「任何人都能對這個值做任意運算」原封不動。解掉第一個問題的當下第二個問題完整存在，而它要等夠多「拿金額亂算」的路徑累積後才顯形。把兩者混為一談的後果是換完 <code>Decimal</code> 就宣告收工、語意缺口留在原地。</p>
<p>反過來，不是每個領域值都值得升級。合法運算集合幾乎等於底層型別的集合時（一個真的就是任意整數的計數器），封閉沒有差集可關、專用型別只是多一層轉換。裸型別從不跨越模組邊界、只在單一函式內部短暫存在時，誤用的窗口太小、升級的維護成本收不回。判準操作化成一句話：盤點這個概念的合法運算清單、跟底層型別的運算集合做差集，差集非空且裸型別在模組間流動，才動手。</p>
<h2 id="實作路徑的成本結構">實作路徑的成本結構</h2>
<p>三種載體對應兩條軸：這個值是單一底層值還是多欄位複合值、以及需不需要 runtime 的型別身份。單一底層值（金額包一個數字、識別碼包一個字串）走 extension type 最省；多欄位複合值（地址、日期範圍、含幣別的金額）需要一個真正的物件裝多個欄位，走 class 路徑，class 又分手寫與 freezed 產生兩種。runtime 身份的需求橫切這兩軸——需要 <code>is Money</code> 在執行期為真、或需要反射看得到型別時，只有 class 路徑滿足。</p>
<p>Dart 3 的 record 是多欄位載體、還自帶結構相等，看起來像多欄位複合值的第四條路徑，但它不入選值物件的載體、原因在型別語意。record 是結構型別而非名目型別：兩個欄位形狀相同的概念（<code>(String, String)</code> 的地址與姓名）在型別系統裡是同一個 record 型別、拿不到 <code>DateRange</code> 這種領域名字，也就換不到「傳錯型別編譯期就擋」的保護。record 沒有建構子、無處掛不變式檢查，也無法限縮 API——任何拿到它的程式碼都能讀所有欄位、組任意新值。值物件要的名目身份、建構期不變式、封閉介面，record 結構上三個都不給。它適合的是函式的匿名多回傳與臨時分組，不是需要領域約束的值物件。</p>
<h3 id="手寫-immutable-class-與-copywith">手寫 immutable class 與 copyWith</h3>
<p>手寫 immutable class 是最直接的載體：所有欄位 <code>final</code>、建構子帶不變式檢查、要「改」就造一個新實例。多欄位複合值在這條路徑上用 <a href="/blog/flutter/knowledge-cards/copywith/" data-link-title="copyWith" data-link-desc="物件的逐欄位覆寫方法在什麼時候是正確工具、什麼時候是逃生口時使用。copyWith 對資料袋語意清晰、對有領域方法的 entity 是繞過不變式的逃生口。">copyWith</a> 做逐欄位覆寫——傳要改的欄位、其餘保留原值、回傳新實例，這對欄位組合全部合法的值語意清晰。成本在 boilerplate：<code>==</code> 與 <code>hashCode</code> 要手寫且要涵蓋所有參與相等性的欄位、<code>copyWith</code> 每加一個欄位就要同步一行，漏一個欄位的相等性比對是安靜的 bug。</p>
<p>這條路徑的邊界在 copyWith 的適用範圍。對欄位組合全部合法的資料袋與純值物件，copyWith 是正確工具；對有領域方法、欄位之間有不變式約束的型別，全欄位 public 的 copyWith 是繞過領域方法的逃生口——領域方法從「唯一變更路徑」降級成「建議路徑」。判準是型別有沒有「不允許任意組合的欄位」，有的話那些欄位就不該讓 copyWith public 可寫。完整機制見 <a href="/blog/work-log/dart_copywith_entity_escape_hatch/" data-link-title="copyWith 是逃生口，不是設計 — 從一個測試 bug 追到 entity 稽核軌跡的洞" data-link-desc="copyWith 對純資料載體是正確工具，對有領域方法的 entity 是繞過不變式的逃生口。從一個 3 字元 ID 觸發的例外，追出同族語意錯誤、被繞過的領域方法、以及從未被強制的註解約束。">copyWith 是逃生口，不是設計</a>。</p>
<h3 id="freezed-產生器">freezed 產生器</h3>
<p><a href="/blog/flutter/knowledge-cards/freezed/" data-link-title="freezed" data-link-desc="Dart 的 immutable data class 程式碼生成器。freezed 自動產生 copyWith、equals、toString、sealed union——它是 Dart 生態把 copyWith 推成預設路徑的主要推力。">freezed</a> 是把手寫 class 的 boilerplate 壓到接近零的產生器：標記 <code>@freezed</code> 後自動產生 copyWith、<code>==</code> / <code>hashCode</code>、<code>toString()</code>、以及 sealed union。多欄位複合值需要完整相等性與序列化、又不想手工維護每個欄位的同步時，freezed 是這條路徑的主流選擇；它的 sealed union 在枚舉分層上另有價值——exhaustive switch 讓「忘記決定新成員歸哪類」在編譯期就走不通。</p>
<p>成本結構有兩面。一面是工具依賴：freezed 走 <code>build_runner</code> 產生程式碼，專案要接受產生器的建置步驟與產物管理。另一面是預設路徑的一視同仁——freezed 不區分資料袋和有領域方法的 entity，每個被標記的 class 都得到全欄位 public copyWith，包含狀態欄位與稽核欄位。規範說「請走領域方法」、工具預設給全欄位 copyWith，兩者衝突時預設會贏。在 entity 上使用 freezed 需要額外收窄：把 copyWith 改 private、或從參數列移除受約束的欄位。結構細節見 <a href="/blog/work-log/dart_freezed_anatomy/" data-link-title="Freezed 的三層結構解剖：with、_$、以及更好懂的替代路徑" data-link-desc="freezed `class X with _$X implements Y` 的分層結構解剖：`with` 與 `_$` 各自的角色、沒有 freezed 怎麼手做、中間投影物件 vs DTO 直接 implements 的維護取捨。">Freezed 三層結構解剖</a>。</p>
<h3 id="extension-type-零成本包裝">extension type 零成本包裝</h3>
<p><a href="/blog/flutter/knowledge-cards/extension-type/" data-link-title="Extension Type" data-link-desc="Dart 3 的零成本包裝型別——runtime 不存在額外物件、只在編譯期提供型別安全。用在 value object 的語意封閉需要零 overhead 時。">extension type</a> 是 Dart 3.3 起提供的載體，把單一底層值包成一個新名字、限縮可用的 API、而 runtime 不存在額外物件——編譯後就是底層型別本身，所有約束活在編譯期。單一底層值的語意封閉、又在高頻路徑上流通（金額在每筆訂單明細累加、識別碼在每次查詢傳遞）時，這條路徑用零 runtime 開銷換到型別安全。它跟 class 路徑是互斥的實作選擇：class 走 runtime、有型別身份也有 overhead；extension type 走編譯期、零 overhead 但 runtime 透明，<code>is</code> 與 <code>as</code> 看到的是底層型別。</p>
<p>用 extension type 時要在設計期定 subtype 決策——它是底層型別的 subtype（寬鬆：可隱式 upcast 回底層、封裝邊界弱）還是獨立型別（嚴格：只能顯式拆封、每個銜接點都要拆）。金額的做法是 <code>implements Object</code> 而只有 Object：既有的格式化入口 <code>formatAmount(Object)</code> 能直接吃它、不必改簽名；同時它不是數字型別的 subtype，於是不能被傳進任何收數字型別的參數，裸運算沒有回來的路。這條路徑不搭 copyWith——extension type 沒有 runtime 物件可以逐欄位覆寫，逐欄位覆寫語意只在 class 路徑上成立。</p>
<p>三條路徑的選型錨點收在下表，每一列的成本結構與適用情境在上面各自的段落展開：</p>
<table>
  <thead>
      <tr>
          <th>載體</th>
          <th>適用的值形狀</th>
          <th>成本結構</th>
          <th>runtime 身份</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>手寫 immutable class</td>
          <td>多欄位複合值</td>
          <td>手寫 <code>==</code> / <code>hashCode</code> / copyWith</td>
          <td>有</td>
      </tr>
      <tr>
          <td>freezed</td>
          <td>多欄位複合值、要完整相等</td>
          <td>build_runner 依賴、預設全欄位 copyWith</td>
          <td>有</td>
      </tr>
      <tr>
          <td>extension type</td>
          <td>單一底層值、高頻流通</td>
          <td>零 runtime 開銷、runtime 透明</td>
          <td>無</td>
      </tr>
  </tbody>
</table>
<h2 id="從原始型別遷移過去">從原始型別遷移過去</h2>
<p>值物件多半在裸型別的誤用累積之後才補上去，於是實作值物件常常等於一次型別遷移。一個金額欄位的遷移軌跡把兩個獨立問題各解一段：最初是浮點數、累加誤差堆到分位；第一段換成高精度數字型別、精度問題解決；金額仍然是裸的通用數字、任何拿到它的程式碼都能做任意運算，於是第二段把它包進 extension type，開放的運算限於領域有意義的集合。運算列表本身就是領域規則的宣告：金額加減可以、乘數量可以、乘倍率可以（刻意跟數量分開簽名），金額乘金額不存在、因為介面沒開放。想對它做底層型別的任意運算，得先顯式呼叫拆封方法，那一行拆封程式碼就是 code review 的攔截點。這條軌跡的完整素材見 <a href="/blog/work-log/dart_money_extension_type_migration/" data-link-title="金額型別的三段遷移：double、Decimal、再到 Money extension type" data-link-desc="金額欄位從 double 換 Decimal 只解決精度、沒解決「任何人都能對它做無意義運算」；用 Dart extension type 包成 Money 之後，型別系統只開放領域有意義的運算。含 implements Object 的 subtype 設計、以及大規模型別遷移前先寫 characterization test 鎖行為的做法。">金額型別的三段遷移</a>。</p>
<p>遷移動的是全專案的欄位，安全網是 characterization test——遷移前對著舊實作寫、鎖住當前輸出（包含當前的邊界行為，例如找零算出負數時歸零），遷移後全綠就證明型別替換沒有帶入行為變化。它跟一般測試的差別在斷言的性質：它驗證「行為不變」、不驗證「行為正確」。正確性是另一批測試的事——把兩個問題混在同一批測試裡，遷移期間的紅燈就分不清是「換壞了」還是「本來就錯」。做法展開見 <a href="/blog/work-log/flutter_characterization_test_migration_safety_net/" data-link-title="測「不變」、不測「正確」 — characterization test 當遷移安全網" data-link-desc="大規模型別遷移前，對著舊實作寫一批鎖住現有行為的測試——包括看起來像 bug 的邊界怪癖也照鎖，遷移後全綠證明「換底沒改行為」。正確性是另一批測試的職責、混在一起紅燈就無法歸因。附測試環境的原生依賴三個斷點（FFI、plugin、late init）與替身解法。">測「不變」、不測「正確」：characterization test</a>。</p>
<h2 id="取值出口封裝的對象是運算不是取值">取值出口：封裝的對象是運算、不是取值</h2>
<p>值物件封住的是「任意運算」、不是「取原始值」本身。基礎設施邊界對原始值有正當需求——快取需要 key、資料庫需要 column 值、序列化需要原始表示、格式化銜接層需要底層型別。正確做法是給原始值一個語意明確的官方出口，而不是禁止取值逼下游硬撬。金額的拆封方法 <code>toDecimal()</code> 就是這種出口，註解直接寫明供哪種場合使用；序列化給 <code>toDbValue()</code> 這類名字說明用途的方法；UI 顯示給 <code>displayValue</code>。有官方出口的世界裡「誰在拆封」是可 grep 的（搜出口方法名就是完整清單），封裝邊界是明示的。</p>
<p>把取值本身當違規會來回撞牆。一個 App 的識別碼型別的封裝政策擺盪過兩輪：先追「零個外部取值」把公開介面只留 <code>toString()</code>，撞上基礎設施層的正當需求後又把取值 getter 加回來、改名叫「相容性介面」。兩個極端各自的撞牆點是同一個病的兩面——完全封裝逼正當消費把 <code>toString()</code> 當取值 API 用（語意寄生，<code>toString()</code> 哪天為除錯改格式、快取 key 就靜默換一批），零封裝則讓 ISBN 校驗、ID 格式這些不變式失去強制點。穩態在中間：原始值有官方出口、出口有語意、邊界寫進決策記錄。擺盪的完整軌跡見 <a href="/blog/work-log/flutter_value_object_encapsulation_oscillation/" data-link-title="Value Object 的封裝擺盪：從全移除、完全封裝、到加回 .value getter" data-link-desc="VO 的封裝邊界在兩個極端之間來回——純字串（零封裝）跟完全封裝（禁止取原始值）各有成立的理由、也各自撞牆。穩態是給原始值一個有語意的官方出口，而不是把「取原始值」本身當違規。含 176 個編譯錯誤的工作量低估、以及「相容性介面」作為理想撤退訊號的判讀。">Value Object 的封裝擺盪</a>，出口設計的理論層展開見 <a href="/blog/ddd/construction-path-design/" data-link-title="建構路徑設計" data-link-desc="工廠表達力不足時缺陷如何被逃生口吸收——逃生口讓正確的修法變不必要、以語意錯誤在下游復發。含原始值官方出口的穩態邊界、封裝擺盪的判讀。">建構路徑設計</a>。</p>
<h2 id="邊界">邊界</h2>
<p>本章處理值物件在 Dart 的實作載體選擇，是實作層知識。三個上游判定不在本章：一個型別該不該模型化成領域模型（入口判準見 <a href="/blog/ddd/data-bag-vs-domain-model/" data-link-title="資料袋與領域模型" data-link-desc="判斷一個型別該是一袋欄位還是有行為的領域模型：判準是「有沒有不允許任意組合的欄位」。含判準用錯時規則退化成建議的機制、以及資料袋起步後升級的演化訊號。">資料袋與領域模型</a>）、模型化之後該用 entity 還是 value object（同一性判準見 <a href="/blog/ddd/entity-vs-value-object/" data-link-title="entity 與 value object 的判準" data-link-desc="同一個業務概念該建成 entity 還是 value object：判準是「操作需不需要 identity-based 回寫」、而不是概念重要性或有沒有 id 可填。含判準隨生命週期重問的交棒時機、value object 的語意封閉、枚舉分層。">entity 與 value object 的判準</a>）、以及約束該落在文件層、型別層還是執行層（見 <a href="/blog/ddd/invariant-enforcement-layers/" data-link-title="不變式的強制層次" data-link-desc="業務約束落在文件層、型別層、執行層的差異與代價：違反時是靜默、編譯失敗還是當場拒絕。含不變式被撞時「需求違規 vs 約束錯形」的分辨、存在條件與輸入品質的分層邊界。">不變式的強制層次</a>）。值物件把封閉做在型別層，防的是無心誤用——刻意用反射或 dynamic 拆封仍然繞得過，威脅模型是「防止意外」而不是「防止刻意」。</p>
<p>型別層防護的另一半責任落在 entity 而非 value object：entity 的同一性由身份定義、有生命週期、變更要走領域方法，那條路徑的收窄（copyWith 逃生口、稽核軌跡凍結）跟本章的值物件封閉是相鄰但不同的問題，路由到 <a href="/blog/ddd/state-transition-and-audit-trail/" data-link-title="狀態轉換與稽核軌跡" data-link-desc="領域方法作為唯一變更路徑：判準是「變更有沒有需要一起完成的伴隨動作」。含唯一路徑與建議路徑的分界、稽核軌跡出洞的靜默機制與凍結作為稽核端點。">狀態轉換與稽核軌跡</a>。</p>
<h2 id="下一步">下一步</h2>
<p>三條實作路徑各有 case 可深讀：copyWith 的適用邊界在 <a href="/blog/work-log/dart_copywith_entity_escape_hatch/" data-link-title="copyWith 是逃生口，不是設計 — 從一個測試 bug 追到 entity 稽核軌跡的洞" data-link-desc="copyWith 對純資料載體是正確工具，對有領域方法的 entity 是繞過不變式的逃生口。從一個 3 字元 ID 觸發的例外，追出同族語意錯誤、被繞過的領域方法、以及從未被強制的註解約束。">copyWith 是逃生口，不是設計</a>、freezed 的結構在 <a href="/blog/work-log/dart_freezed_anatomy/" data-link-title="Freezed 的三層結構解剖：with、_$、以及更好懂的替代路徑" data-link-desc="freezed `class X with _$X implements Y` 的分層結構解剖：`with` 與 `_$` 各自的角色、沒有 freezed 怎麼手做、中間投影物件 vs DTO 直接 implements 的維護取捨。">Freezed 三層結構解剖</a>、extension type 的 subtype 決策與遷移在 <a href="/blog/work-log/dart_money_extension_type_migration/" data-link-title="金額型別的三段遷移：double、Decimal、再到 Money extension type" data-link-desc="金額欄位從 double 換 Decimal 只解決精度、沒解決「任何人都能對它做無意義運算」；用 Dart extension type 包成 Money 之後，型別系統只開放領域有意義的運算。含 implements Object 的 subtype 設計、以及大規模型別遷移前先寫 characterization test 鎖行為的做法。">金額型別的三段遷移</a>。取值出口的封裝邊界在 <a href="/blog/work-log/flutter_value_object_encapsulation_oscillation/" data-link-title="Value Object 的封裝擺盪：從全移除、完全封裝、到加回 .value getter" data-link-desc="VO 的封裝邊界在兩個極端之間來回——純字串（零封裝）跟完全封裝（禁止取原始值）各有成立的理由、也各自撞牆。穩態是給原始值一個有語意的官方出口，而不是把「取原始值」本身當違規。含 176 個編譯錯誤的工作量低估、以及「相容性介面」作為理想撤退訊號的判讀。">Value Object 的封裝擺盪</a>、遷移安全網在 <a href="/blog/work-log/flutter_characterization_test_migration_safety_net/" data-link-title="測「不變」、不測「正確」 — characterization test 當遷移安全網" data-link-desc="大規模型別遷移前，對著舊實作寫一批鎖住現有行為的測試——包括看起來像 bug 的邊界怪癖也照鎖，遷移後全綠證明「換底沒改行為」。正確性是另一批測試的職責、混在一起紅燈就無法歸因。附測試環境的原生依賴三個斷點（FFI、plugin、late init）與替身解法。">characterization test</a>。理論地基從 <a href="/blog/ddd/" data-link-title="DDD 領域驅動設計指南" data-link-desc="領域模型的理論與判準層：一袋欄位還是領域模型、什麼時候值得建 entity、不變式該落在哪一層強制、狀態轉換怎麼留下稽核軌跡、建構路徑怎麼設計。語言無關，實作限制路由到各語言模組。">DDD 指南的模型設計主梯</a> 進，值物件在其中的位置是 <a href="/blog/ddd/entity-vs-value-object/" data-link-title="entity 與 value object 的判準" data-link-desc="同一個業務概念該建成 entity 還是 value object：判準是「操作需不需要 identity-based 回寫」、而不是概念重要性或有沒有 id 可填。含判準隨生命週期重問的交棒時機、value object 的語意封閉、枚舉分層。">entity 與 value object 的判準</a> 的語意封閉段。</p>
]]></content:encoded></item></channel></rss>