<?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>Riverpod on Tarragon</title><link>https://tarrragon.github.io/blog/tags/riverpod/</link><description>Recent content in Riverpod on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Thu, 16 Jul 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/riverpod/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>StreamProvider 包 repository watch stream — broadcast、初始值、dispose 實作點</title><link>https://tarrragon.github.io/blog/work-log/flutter_streamprovider_wraps_repository_watch/</link><pubDate>Thu, 16 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_streamprovider_wraps_repository_watch/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：書庫管理 App 的 repository 原本是純 &lt;code>Future&lt;/code> pull 介面，衍生視圖靠補償刷新（背景在 &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>）。決策定向後要落地：repository 補 &lt;code>watchBooks()&lt;/code> Stream 出口、用 &lt;code>StreamProvider&lt;/code> 接進 Riverpod。
&lt;strong>本篇範圍&lt;/strong>：落地時要答對的三個實作點，每一個的預設答案都會靜默失效。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="分層落點">分層落點&lt;/h2>
&lt;p>實作橫跨三層、每層只說自己那層的語言（歸屬判準的推導見 &lt;a href="https://tarrragon.github.io/blog/ddd/observation-outlet-responsibility-split/" data-link-title="觀測出口的職責三分" data-link-desc="repository 要補「資料變了」的推送能力、卻不確定 Stream 介面放 domain 算不算洩漏時使用。歸屬判準是介面用什麼語言表達、不是需求來自誰：契約歸 domain、變更偵測歸 infrastructure、框架訂閱歸組裝層。">觀測出口的職責三分&lt;/a>）：&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>domain 契約&lt;/td>
 &lt;td>介面方法 &lt;code>Stream&amp;lt;List&amp;lt;Book&amp;gt;&amp;gt; watchBooks()&lt;/code>&lt;/td>
 &lt;td>&lt;code>dart:async&lt;/code> + domain entity&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>infrastructure&lt;/td>
 &lt;td>&lt;code>StreamController.broadcast()&lt;/code> + 寫入點 emit&lt;/td>
 &lt;td>SQLite、controller 細節&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>DI／presentation&lt;/td>
 &lt;td>&lt;code>watchBooksProvider&lt;/code>（&lt;code>StreamProvider&lt;/code>）&lt;/td>
 &lt;td>Riverpod 型別&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&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="c1">/// 提供書單變更的 Stream 出口，取代衍生視圖各自補償刷新。
&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">/// 約束：僅純 dart:async + domain entity，禁止框架型別進入此介面。
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="n">Stream&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">Book&lt;/span>&lt;span class="o">&amp;gt;&amp;gt;&lt;/span> &lt;span class="n">watchBooks&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="k">throw&lt;/span> &lt;span class="n">UnimplementedError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;watchBooks 未在此 repository 實作&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="實作點一訂閱模型選-broadcast">實作點一：訂閱模型選 broadcast&lt;/h2>
&lt;p>因為書庫清單、統計頁、待補完列表都要同時觀察同一份資料，這個觀測出口有多個訂閱者。&lt;code>StreamController()&lt;/code> 預設建構子是單訂閱、第二個訂閱者出現時直接 throw &lt;code>Bad state&lt;/code>；這個選型的完整分析（含單訂閱在只有一個訂閱者期間完全沉默的潛伏機制）在 &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;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">SQLiteBookRepository&lt;/span> &lt;span class="kd">implements&lt;/span> &lt;span class="n">BookRepository&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="c1">/// 全部寫入方法完成後透過此 controller emit 最新完整書單。
&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">final&lt;/span> &lt;span class="n">StreamController&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">Book&lt;/span>&lt;span class="o">&amp;gt;&amp;gt;&lt;/span> &lt;span class="n">_booksController&lt;/span> &lt;span class="o">=&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="n">StreamController&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">Book&lt;/span>&lt;span class="o">&amp;gt;&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">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="err">@&lt;/span>&lt;span class="n">override&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">Stream&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">Book&lt;/span>&lt;span class="o">&amp;gt;&amp;gt;&lt;/span> &lt;span class="n">watchBooks&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">_booksController&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">stream&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;/code>&lt;/pre>&lt;/div>&lt;p>emit 集中在一個私有方法、掛在每個寫入方法尾端：&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">Future&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="kt">void&lt;/span>&lt;span class="o">&amp;gt;&lt;/span> &lt;span class="n">_emitCurrentBooks&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="kd">async&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">_booksController&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">isClosed&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">return&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// repository 已 close：靜默略過，不讓通知失敗中斷寫入流程
&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="kd">final&lt;/span> &lt;span class="n">books&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">getAllBooks&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="n">_booksController&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">isClosed&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">7&lt;/span>&lt;span class="cl"> &lt;span class="n">_booksController&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">books&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 class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>兩個容易漏的細節：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>委派方法不重複 emit&lt;/strong>。介面上的相容性方法（&lt;code>saveBook&lt;/code> 內部委派 &lt;code>addBook&lt;/code>、&lt;code>deleteBookById&lt;/code> 委派 &lt;code>deleteBook&lt;/code>）走到底層寫入方法時已經 emit 過；在委派層再掛一次會讓一次寫入發兩次通知。emit 的掛載點是「實際執行寫入的方法集合」、不是「介面上所有看起來會寫入的方法」。&lt;/li>
&lt;li>&lt;strong>&lt;code>isClosed&lt;/code> 要查兩次&lt;/strong>。&lt;code>getAllBooks()&lt;/code> 是 async——查詢期間 repository 可能被 close，&lt;code>add&lt;/code> 前不再確認就會對已關閉的 controller 拋例外，而且是從寫入方法的尾端拋出來、污染寫入本身的成功語意。&lt;/li>
&lt;/ol>
&lt;h2 id="實作點二初始值broadcast-不補送歷史">實作點二：初始值——broadcast 不補送歷史&lt;/h2>
&lt;p>broadcast stream 對「訂閱之前發生的事件」直接丟棄。衍生視圖訂閱 &lt;code>watchBooks()&lt;/code> 的當下，上一次 emit 早就過去了——不處理初始值，畫面會停在空清單直到下一次寫入才有資料。&lt;/p>
&lt;p>修法放在組裝層：&lt;code>StreamProvider&lt;/code> 用 &lt;code>async*&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">final&lt;/span> &lt;span class="n">watchBooksProvider&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">StreamProvider&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">Book&lt;/span>&lt;span class="o">&amp;gt;&amp;gt;&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">ref&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">async&lt;/span>&lt;span class="o">*&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">repository&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ref&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">watch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">bookRepositoryProvider&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">yield&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">repository&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">getAllBooks&lt;/span>&lt;span class="p">();&lt;/span> &lt;span class="c1">// 訂閱當下：先 emit 當前完整書單
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kd">yield&lt;/span>&lt;span class="o">*&lt;/span> &lt;span class="n">repository&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">watchBooks&lt;/span>&lt;span class="p">();&lt;/span> &lt;span class="c1">// 之後：轉發 repository 的變更通知
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個「當前值 + 後續變更」的組合就是 RxDart &lt;code>BehaviorSubject&lt;/code> 內建的行為；純 &lt;code>dart:async&lt;/code> 用兩行 &lt;code>yield&lt;/code> 補上，不必為此引依賴。把初始值放組裝層而非機制層也有語意理由：repository 的 stream 誠實地只代表「變更」，「訂閱時要不要先看到當下」是消費端的呈現需求。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：書庫管理 App 的 repository 原本是純 <code>Future</code> pull 介面，衍生視圖靠補償刷新（背景在 <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>）。決策定向後要落地：repository 補 <code>watchBooks()</code> Stream 出口、用 <code>StreamProvider</code> 接進 Riverpod。
<strong>本篇範圍</strong>：落地時要答對的三個實作點，每一個的預設答案都會靜默失效。</p></blockquote>
<hr>
<h2 id="分層落點">分層落點</h2>
<p>實作橫跨三層、每層只說自己那層的語言（歸屬判準的推導見 <a href="/blog/ddd/observation-outlet-responsibility-split/" data-link-title="觀測出口的職責三分" data-link-desc="repository 要補「資料變了」的推送能力、卻不確定 Stream 介面放 domain 算不算洩漏時使用。歸屬判準是介面用什麼語言表達、不是需求來自誰：契約歸 domain、變更偵測歸 infrastructure、框架訂閱歸組裝層。">觀測出口的職責三分</a>）：</p>
<table>
  <thead>
      <tr>
          <th>層</th>
          <th>產出</th>
          <th>允許出現的型別</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>domain 契約</td>
          <td>介面方法 <code>Stream&lt;List&lt;Book&gt;&gt; watchBooks()</code></td>
          <td><code>dart:async</code> + domain entity</td>
      </tr>
      <tr>
          <td>infrastructure</td>
          <td><code>StreamController.broadcast()</code> + 寫入點 emit</td>
          <td>SQLite、controller 細節</td>
      </tr>
      <tr>
          <td>DI／presentation</td>
          <td><code>watchBooksProvider</code>（<code>StreamProvider</code>）</td>
          <td>Riverpod 型別</td>
      </tr>
  </tbody>
</table>
<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">/// 提供書單變更的 Stream 出口，取代衍生視圖各自補償刷新。
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">/// 約束：僅純 dart:async + domain entity，禁止框架型別進入此介面。
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="n">Stream</span><span class="o">&lt;</span><span class="n">List</span><span class="o">&lt;</span><span class="n">Book</span><span class="o">&gt;&gt;</span> <span class="n">watchBooks</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="k">throw</span> <span class="n">UnimplementedError</span><span class="p">(</span><span class="s1">&#39;watchBooks 未在此 repository 實作&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h2 id="實作點一訂閱模型選-broadcast">實作點一：訂閱模型選 broadcast</h2>
<p>因為書庫清單、統計頁、待補完列表都要同時觀察同一份資料，這個觀測出口有多個訂閱者。<code>StreamController()</code> 預設建構子是單訂閱、第二個訂閱者出現時直接 throw <code>Bad state</code>；這個選型的完整分析（含單訂閱在只有一個訂閱者期間完全沉默的潛伏機制）在 <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>





<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">SQLiteBookRepository</span> <span class="kd">implements</span> <span class="n">BookRepository</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="c1">/// 全部寫入方法完成後透過此 controller emit 最新完整書單。
</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">StreamController</span><span class="o">&lt;</span><span class="n">List</span><span class="o">&lt;</span><span class="n">Book</span><span class="o">&gt;&gt;</span> <span class="n">_booksController</span> <span class="o">=</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">      <span class="n">StreamController</span><span class="o">&lt;</span><span class="n">List</span><span class="o">&lt;</span><span class="n">Book</span><span class="o">&gt;&gt;</span><span class="p">.</span><span class="n">broadcast</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="err">@</span><span class="n">override</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="n">Stream</span><span class="o">&lt;</span><span class="n">List</span><span class="o">&lt;</span><span class="n">Book</span><span class="o">&gt;&gt;</span> <span class="n">watchBooks</span><span class="p">()</span> <span class="o">=&gt;</span> <span class="n">_booksController</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></code></pre></div><p>emit 集中在一個私有方法、掛在每個寫入方法尾端：</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">void</span><span class="o">&gt;</span> <span class="n">_emitCurrentBooks</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">if</span> <span class="p">(</span><span class="n">_booksController</span><span class="p">.</span><span class="n">isClosed</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">return</span><span class="p">;</span> <span class="c1">// repository 已 close：靜默略過，不讓通知失敗中斷寫入流程
</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="kd">final</span> <span class="n">books</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">getAllBooks</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="o">!</span><span class="n">_booksController</span><span class="p">.</span><span class="n">isClosed</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="n">_booksController</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="n">books</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 class="p">}</span></span></span></code></pre></div><p>兩個容易漏的細節：</p>
<ol>
<li><strong>委派方法不重複 emit</strong>。介面上的相容性方法（<code>saveBook</code> 內部委派 <code>addBook</code>、<code>deleteBookById</code> 委派 <code>deleteBook</code>）走到底層寫入方法時已經 emit 過；在委派層再掛一次會讓一次寫入發兩次通知。emit 的掛載點是「實際執行寫入的方法集合」、不是「介面上所有看起來會寫入的方法」。</li>
<li><strong><code>isClosed</code> 要查兩次</strong>。<code>getAllBooks()</code> 是 async——查詢期間 repository 可能被 close，<code>add</code> 前不再確認就會對已關閉的 controller 拋例外，而且是從寫入方法的尾端拋出來、污染寫入本身的成功語意。</li>
</ol>
<h2 id="實作點二初始值broadcast-不補送歷史">實作點二：初始值——broadcast 不補送歷史</h2>
<p>broadcast stream 對「訂閱之前發生的事件」直接丟棄。衍生視圖訂閱 <code>watchBooks()</code> 的當下，上一次 emit 早就過去了——不處理初始值，畫面會停在空清單直到下一次寫入才有資料。</p>
<p>修法放在組裝層：<code>StreamProvider</code> 用 <code>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">final</span> <span class="n">watchBooksProvider</span> <span class="o">=</span> <span class="n">StreamProvider</span><span class="o">&lt;</span><span class="n">List</span><span class="o">&lt;</span><span class="n">Book</span><span class="o">&gt;&gt;</span><span class="p">((</span><span class="n">ref</span><span class="p">)</span> <span class="kd">async</span><span class="o">*</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">repository</span> <span class="o">=</span> <span class="n">ref</span><span class="p">.</span><span class="n">watch</span><span class="p">(</span><span class="n">bookRepositoryProvider</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="kd">yield</span> <span class="kd">await</span> <span class="n">repository</span><span class="p">.</span><span class="n">getAllBooks</span><span class="p">();</span> <span class="c1">// 訂閱當下：先 emit 當前完整書單
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span>  <span class="kd">yield</span><span class="o">*</span> <span class="n">repository</span><span class="p">.</span><span class="n">watchBooks</span><span class="p">();</span>       <span class="c1">// 之後：轉發 repository 的變更通知
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="p">});</span></span></span></code></pre></div><p>這個「當前值 + 後續變更」的組合就是 RxDart <code>BehaviorSubject</code> 內建的行為；純 <code>dart:async</code> 用兩行 <code>yield</code> 補上，不必為此引依賴。把初始值放組裝層而非機制層也有語意理由：repository 的 stream 誠實地只代表「變更」，「訂閱時要不要先看到當下」是消費端的呈現需求。</p>
<h2 id="實作點三dispose關閉責任跟著-controller-的持有者">實作點三：dispose——關閉責任跟著 controller 的持有者</h2>
<p>controller 的持有者是 repository，關閉責任就在 repository 的生命週期方法裡：</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">void</span><span class="o">&gt;</span> <span class="n">close</span><span class="p">()</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">await</span> <span class="n">_booksController</span><span class="p">.</span><span class="n">close</span><span class="p">();</span> <span class="c1">// 與資料庫連線一起釋放，避免 controller 洩漏
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></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></code></pre></div><p>配合實作點一的 <code>isClosed</code> 防護，close 之後殘留的寫入呼叫會靜默略過通知、不會炸在使用者的操作路徑上。驗收面用三個測試釘住這組行為：寫入後 stream 收到最新書單、多訂閱者同時收到、close 後不再送出事件。</p>
<h2 id="測試替身要同步這份契約">測試替身要同步這份契約</h2>
<p>repository 有介面就有替身；替身漏掉 <code>watchBooks()</code> 會出現「production 正常、測試環境炸 <code>UnimplementedError</code>」或反過來的錯位。這次落地同步了三類替身、依「既有測試依不依賴多次 emit」給不同深度：</p>
<table>
  <thead>
      <tr>
          <th>替身</th>
          <th>實作深度</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>記憶體版 repository（行為替身）</td>
          <td>等價的 broadcast controller + 寫入點 emit + dispose</td>
          <td>widget 測試要驗「寫入後畫面更新」</td>
      </tr>
      <tr>
          <td>手寫 mock</td>
          <td><code>Stream.value(當前快照)</code> 簡化實作</td>
          <td>既有用法只讀一次、不依賴推送</td>
      </tr>
      <tr>
          <td>codegen mock（Mockito）</td>
          <td>重新產生、stub 回空 stream</td>
          <td><code>implements</code> 不繼承介面預設實作</td>
      </tr>
  </tbody>
</table>
<p>第三列是 Dart 特有的陷阱：mock 類別 <code>implements</code> 介面時<strong>不會</strong>繼承介面上的預設實作，介面加了新方法、所有 codegen mock 都要重新產生，否則消費新方法的測試在執行期才爆。mock 與真實實作的 stream 契約不對齊的後果（測試綠、production throw）在 <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>把 repository stream 接給任何 reactive 框架前，三個問題各給一個明確答案：</p>
<table>
  <thead>
      <tr>
          <th>問題</th>
          <th>本案答案</th>
          <th>不答的預設後果</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>幾個訂閱者？</td>
          <td>多個 → <code>broadcast()</code></td>
          <td>單訂閱：第二個訂閱者執行期 throw</td>
      </tr>
      <tr>
          <td>訂閱當下要有值嗎？</td>
          <td>要 → 組裝層先 <code>yield</code> 當前值</td>
          <td>broadcast 不補歷史：畫面空到下次寫入</td>
      </tr>
      <tr>
          <td>controller 誰關？</td>
          <td>repository 持有、<code>close()</code> 一起關 + <code>isClosed</code> 防護</td>
          <td>洩漏、或 close 後寫入路徑拋例外</td>
      </tr>
  </tbody>
</table>
<p>本文範圍只涵蓋成功路徑。第四個問題——查詢失敗時 stream 該 <code>addError</code> 傳播還是吞掉——這裡沒有處理：<code>_emitCurrentBooks</code> 裡 <code>getAllBooks()</code> 拋例外時目前走 <code>isClosed</code> 防護的外圍、不會 <code>addError</code>，消費端收不到錯誤通知。失敗傳播的設計（要不要讓 <code>StreamProvider</code> 進 <code>AsyncError</code> 狀態、重試策略）是獨立主題；設計時要把 Riverpod 3 的預設行為算進去——失敗的 provider 會被自動重試（<code>ProviderScope</code> 的 <code>retry</code> 參數可關閉或調整間隔），<code>addError</code> 進到 provider 層的後果跟 2.x 不同。</p>
<p>介面歸屬的提醒收在最後：<code>watchBooks()</code> 的需求來自 Riverpod 消費端，但介面簽名只用 <code>dart:async Stream</code> 加 domain entity，所以它屬於 domain repository 介面——歸屬由介面用什麼語言表達決定，需求來自誰只決定介面該不該存在。Riverpod 型別止步於 <code>watchBooksProvider</code>、SQLite 型別止步於實作類，這條線就是三層各自的邊界。</p>
<h2 id="下一步">下一步</h2>
<p>補了觀測出口但還沒搞懂為什麼之前的導航補償和 EventBus 橋接不夠，先讀 <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/ddd/observation-outlet-responsibility-split/" data-link-title="觀測出口的職責三分" data-link-desc="repository 要補「資料變了」的推送能力、卻不確定 Stream 介面放 domain 算不算洩漏時使用。歸屬判準是介面用什麼語言表達、不是需求來自誰：契約歸 domain、變更偵測歸 infrastructure、框架訂閱歸組裝層。">觀測出口的職責三分</a>。單訂閱 vs broadcast 的完整選型分析在 <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>。觀測出口跟 domain event 各管什麼，見 <a href="/blog/ddd/domain-event-vs-state-stream/" data-link-title="domain event 與狀態流" data-link-desc="為了讓某個畫面刷新而補發事件、或監聽端掛著全事件過濾器時使用。事件記錄離散事實、狀態流發布連續觀測——判準是消費者問「發生了什麼」還是「現在是什麼」；載體借用的代價是涵蓋面靠枚舉維持。">domain event 與狀態流</a>。</p>
]]></content:encoded></item><item><title>手寫 dispose() 沒有呼叫者 — Notifier 的依賴與清理都歸 build() 管</title><link>https://tarrragon.github.io/blog/work-log/flutter_notifier_lifecycle_ref_ondispose/</link><pubDate>Thu, 16 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_notifier_lifecycle_ref_ondispose/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：Flutter 書籍管理 App 的掃描器 ViewModel 用建構子接收三個 service、provider 工廠手動 &lt;code>new&lt;/code> 具體類別傳進去；資源清理寫在自訂的 &lt;code>dispose()&lt;/code> 方法裡
&lt;strong>疑問來源&lt;/strong>：這個 &lt;code>dispose()&lt;/code> 誰呼叫？盤點後答案是沒有人——UI 透過 provider 取用 Notifier、從頭到尾沒有拿到過需要它負責釋放的物件
&lt;strong>整理目的&lt;/strong>：記下 Notifier「生與死都歸容器管」的機制、建構端與銷毀端各自的正確掛法
&lt;strong>本文邊界&lt;/strong>：素材是該專案 v0.31.1 的 DI 一致性重構記錄；屬架構調整、不是崩潰事故——風險是結構性的（清理掛在無人呼叫的方法上）&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="機制notifier-的建構與銷毀都不歸使用者管">機制：Notifier 的建構與銷毀都不歸使用者管&lt;/h2>
&lt;p>Riverpod 的 Notifier 生命週期兩頭都由容器控制。&lt;strong>建構端&lt;/strong>：provider 工廠（&lt;code>NotifierProvider(ViewModel.new)&lt;/code>）在容器第一次需要這個狀態時實例化 Notifier、接著呼叫 &lt;code>build()&lt;/code>；&lt;strong>銷毀端&lt;/strong>：容器判定節點該回收時（scope 銷毀、autoDispose 無人監聽）觸發 dispose 流程。使用者的程式碼在這兩頭都沒有控制點——UI 拿到的是 &lt;code>ref.watch(provider.notifier)&lt;/code> 的引用、它不建構也不銷毀。&lt;/p>
&lt;p>在這個前提下，兩種常見寫法都是跟容器搶生命週期控制權：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>建構子注入依賴&lt;/strong>：工廠得手動 &lt;code>new&lt;/code> 依賴傳進建構子——依賴的建構脫離 provider 圖，&lt;code>BookService()&lt;/code> 直接實例化、不經過 &lt;code>bookServiceProvider&lt;/code>，測試 override 摸不到它、依賴的依賴也斷鏈。&lt;/li>
&lt;li>&lt;strong>手寫 &lt;code>dispose()&lt;/code> 方法&lt;/strong>：方法宣告在那裡、等一個呼叫者——但 Notifier 的持有者是容器、容器只認自己的 dispose 流程，UI 沒有任何一處會呼叫這個方法。掛在裡面的 Timer 取消、StreamSubscription 釋放，等於沒掛。&lt;/li>
&lt;/ul>
&lt;h2 id="遷移兩頭都收進-build">遷移：兩頭都收進 build()&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="c1">// 之前：建構子注入 + 手寫 dispose
&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">class&lt;/span> &lt;span class="nc">IsbnScannerViewModel&lt;/span> &lt;span class="kd">extends&lt;/span> &lt;span class="n">Notifier&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">IsbnScannerState&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"> 3&lt;/span>&lt;span class="cl"> &lt;span class="n">IsbnScannerViewModel&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">this&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">_bookService&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="k">this&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">_cameraService&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="k">this&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">_validator&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">BookService&lt;/span> &lt;span class="n">_bookService&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="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="c1">&lt;/span> &lt;span class="kt">void&lt;/span> &lt;span class="n">dispose&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="n">_cancelScanning&lt;/span>&lt;span class="p">();&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"> 7&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="kd">final&lt;/span> &lt;span class="n">isbnScannerViewModelProvider&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">NotifierProvider&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">IsbnScannerViewModel&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">IsbnScannerState&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">10&lt;/span>&lt;span class="cl"> &lt;span class="p">()&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">IsbnScannerViewModel&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">BookService&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="n">CameraService&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="n">IsbnValidationService&lt;/span>&lt;span class="p">()),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>




&lt;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">// 之後：依賴在 build() 內 ref.watch、清理在 ref.onDispose 註冊
&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">class&lt;/span> &lt;span class="nc">IsbnScannerViewModel&lt;/span> &lt;span class="kd">extends&lt;/span> &lt;span class="n">Notifier&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">IsbnScannerState&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"> 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">BookService&lt;/span> &lt;span class="n">_bookService&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="n">late&lt;/span> &lt;span class="kd">final&lt;/span> &lt;span class="n">CameraService&lt;/span> &lt;span class="n">_cameraService&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">late&lt;/span> &lt;span class="kd">final&lt;/span> &lt;span class="n">IsbnValidationService&lt;/span> &lt;span class="n">_isbnValidationService&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="err">@&lt;/span>&lt;span class="n">override&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">IsbnScannerState&lt;/span> &lt;span class="n">build&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="n">_bookService&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ref&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">watch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">bookServiceProvider&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="n">_cameraService&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ref&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">watch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">cameraServiceProvider&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">_isbnValidationService&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ref&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">watch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">isbnValidationServiceProvider&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="n">ref&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">onDispose&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">_cancelScanning&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">13&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="k">return&lt;/span> &lt;span class="n">IsbnScannerState&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">initial&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="kd">final&lt;/span> &lt;span class="n">isbnScannerViewModelProvider&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">NotifierProvider&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">IsbnScannerViewModel&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">IsbnScannerState&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">18&lt;/span>&lt;span class="cl"> &lt;span class="n">IsbnScannerViewModel&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">new&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">19&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>ViewModel.new&lt;/code> 一行、依賴全部經 provider 圖取得（可 override、可追蹤）、清理掛在容器一定會走的 hook 上。&lt;code>ref.onDispose&lt;/code> 跟手寫方法的差別就是「誰保證執行」——前者由容器的 dispose 流程保證、後者由一個不存在的呼叫者保證。&lt;/p>
&lt;h2 id="兩個附帶的工程紀律">兩個附帶的工程紀律&lt;/h2>
&lt;p>這次重構的執行記錄留了兩件跟主題無關、但可轉移的事：&lt;/p>
&lt;p>&lt;strong>Pre-existing 失敗要用 baseline 重跑確認&lt;/strong>。改完後跑測試、一個多語系版型測試在小螢幕溢位失敗。判定它跟本次改動無關的方式是機械的：&lt;code>git stash&lt;/code> 還原變更、在 baseline 重跑同一個測試、同樣失敗——確認是既有問題、記錄待追蹤、不混進本次範圍。「看起來無關」是猜測、baseline 重跑是證據。&lt;/p>
&lt;p>&lt;strong>Worktree 的 dart analyze 要先 pub get&lt;/strong>。在 git worktree 裡跑 &lt;code>dart analyze&lt;/code>、它會向上解析到主 repo 的舊版 &lt;code>package_config&lt;/code> 造成假錯誤；先在 worktree 內 &lt;code>flutter pub get&lt;/code> 產生本地設定檔才能得到真結果。&lt;/p>
&lt;h2 id="判讀徵兆">判讀徵兆&lt;/h2>
&lt;ul>
&lt;li>Notifier / ViewModel 類別裡有自訂的 &lt;code>dispose()&lt;/code> 或 &lt;code>close()&lt;/code> 方法——先找呼叫者，找不到就是本文的形態：清理掛在無人呼叫的方法上、改掛 &lt;code>ref.onDispose&lt;/code>&lt;/li>
&lt;li>provider 工廠裡出現 &lt;code>new&lt;/code> 具體類別（&lt;code>SomeService()&lt;/code>）傳進建構子——依賴脫離 provider 圖、測試 override 失效，改成 &lt;code>build()&lt;/code> 內 &lt;code>ref.watch&lt;/code>&lt;/li>
&lt;li>工廠寫法不是 &lt;code>ViewModel.new&lt;/code> 一行——通常代表建構子還揹著依賴&lt;/li>
&lt;li>改動後測試失敗、懷疑是既有問題——stash 還原跑 baseline、用同樣失敗證明、不用直覺判定&lt;/li>
&lt;/ul>
&lt;h2 id="相關閱讀">相關閱讀&lt;/h2>
&lt;ul>
&lt;li>生命週期的另一頭：&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;code>ref&lt;/code> 使用&lt;/li>
&lt;li>provider 圖的整體邊界：&lt;a href="https://tarrragon.github.io/blog/flutter/riverpod-reactive-boundary/" data-link-title="Riverpod 的 reactive 邊界" data-link-desc="頁面用了 Riverpod 卻對某些變化沒反應、或 reactive 行為在特定時機炸掉時使用。Riverpod 的 reactive 保證只覆蓋 provider 圖的內部——排查沿著圖的邊界走：變化在圖上嗎、在哪個容器的圖上、節點還活著嗎。">Riverpod 的 reactive 邊界&lt;/a>——依賴經 &lt;code>ref.watch&lt;/code> 取得才在圖上、正是本文遷移的理由&lt;/li>
&lt;li>DI 的概念地基：&lt;a href="https://tarrragon.github.io/blog/ddd/knowledge-cards/dependency-injection/" data-link-title="Dependency Injection" data-link-desc="物件的依賴該由誰提供、測試怎麼換掉真實依賴時使用。依賴注入把「建構依賴」跟「使用依賴」分成兩個責任——使用方宣告需要什麼、提供方在組裝時決定給什麼。">Dependency Injection&lt;/a>——建構與使用分成兩個責任、Notifier 的工廠與 build() 是這個分工的框架版&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：Flutter 書籍管理 App 的掃描器 ViewModel 用建構子接收三個 service、provider 工廠手動 <code>new</code> 具體類別傳進去；資源清理寫在自訂的 <code>dispose()</code> 方法裡
<strong>疑問來源</strong>：這個 <code>dispose()</code> 誰呼叫？盤點後答案是沒有人——UI 透過 provider 取用 Notifier、從頭到尾沒有拿到過需要它負責釋放的物件
<strong>整理目的</strong>：記下 Notifier「生與死都歸容器管」的機制、建構端與銷毀端各自的正確掛法
<strong>本文邊界</strong>：素材是該專案 v0.31.1 的 DI 一致性重構記錄；屬架構調整、不是崩潰事故——風險是結構性的（清理掛在無人呼叫的方法上）</p></blockquote>
<hr>
<h2 id="機制notifier-的建構與銷毀都不歸使用者管">機制：Notifier 的建構與銷毀都不歸使用者管</h2>
<p>Riverpod 的 Notifier 生命週期兩頭都由容器控制。<strong>建構端</strong>：provider 工廠（<code>NotifierProvider(ViewModel.new)</code>）在容器第一次需要這個狀態時實例化 Notifier、接著呼叫 <code>build()</code>；<strong>銷毀端</strong>：容器判定節點該回收時（scope 銷毀、autoDispose 無人監聽）觸發 dispose 流程。使用者的程式碼在這兩頭都沒有控制點——UI 拿到的是 <code>ref.watch(provider.notifier)</code> 的引用、它不建構也不銷毀。</p>
<p>在這個前提下，兩種常見寫法都是跟容器搶生命週期控制權：</p>
<ul>
<li><strong>建構子注入依賴</strong>：工廠得手動 <code>new</code> 依賴傳進建構子——依賴的建構脫離 provider 圖，<code>BookService()</code> 直接實例化、不經過 <code>bookServiceProvider</code>，測試 override 摸不到它、依賴的依賴也斷鏈。</li>
<li><strong>手寫 <code>dispose()</code> 方法</strong>：方法宣告在那裡、等一個呼叫者——但 Notifier 的持有者是容器、容器只認自己的 dispose 流程，UI 沒有任何一處會呼叫這個方法。掛在裡面的 Timer 取消、StreamSubscription 釋放，等於沒掛。</li>
</ul>
<h2 id="遷移兩頭都收進-build">遷移：兩頭都收進 build()</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">// 之前：建構子注入 + 手寫 dispose
</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">IsbnScannerViewModel</span> <span class="kd">extends</span> <span class="n">Notifier</span><span class="o">&lt;</span><span class="n">IsbnScannerState</span><span class="o">&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="n">IsbnScannerViewModel</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="n">_bookService</span><span class="p">,</span> <span class="k">this</span><span class="p">.</span><span class="n">_cameraService</span><span class="p">,</span> <span class="k">this</span><span class="p">.</span><span class="n">_validator</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">BookService</span> <span class="n">_bookService</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="kt">void</span> <span class="n">dispose</span><span class="p">()</span> <span class="p">{</span> <span class="n">_cancelScanning</span><span class="p">();</span> <span class="p">}</span>   <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="n">isbnScannerViewModelProvider</span> <span class="o">=</span> <span class="n">NotifierProvider</span><span class="o">&lt;</span><span class="n">IsbnScannerViewModel</span><span class="p">,</span> <span class="n">IsbnScannerState</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="p">()</span> <span class="o">=&gt;</span> <span class="n">IsbnScannerViewModel</span><span class="p">(</span><span class="n">BookService</span><span class="p">(),</span> <span class="n">CameraService</span><span class="p">(),</span> <span class="n">IsbnValidationService</span><span class="p">()),</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">);</span></span></span></code></pre></div>




<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">// 之後：依賴在 build() 內 ref.watch、清理在 ref.onDispose 註冊
</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">IsbnScannerViewModel</span> <span class="kd">extends</span> <span class="n">Notifier</span><span class="o">&lt;</span><span class="n">IsbnScannerState</span><span class="o">&gt;</span> <span class="p">{</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">BookService</span> <span class="n">_bookService</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="n">late</span> <span class="kd">final</span> <span class="n">CameraService</span> <span class="n">_cameraService</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="n">late</span> <span class="kd">final</span> <span class="n">IsbnValidationService</span> <span class="n">_isbnValidationService</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="err">@</span><span class="n">override</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="n">IsbnScannerState</span> <span class="n">build</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="n">_bookService</span> <span class="o">=</span> <span class="n">ref</span><span class="p">.</span><span class="n">watch</span><span class="p">(</span><span class="n">bookServiceProvider</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="n">_cameraService</span> <span class="o">=</span> <span class="n">ref</span><span class="p">.</span><span class="n">watch</span><span class="p">(</span><span class="n">cameraServiceProvider</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="n">_isbnValidationService</span> <span class="o">=</span> <span class="n">ref</span><span class="p">.</span><span class="n">watch</span><span class="p">(</span><span class="n">isbnValidationServiceProvider</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="n">ref</span><span class="p">.</span><span class="n">onDispose</span><span class="p">(</span><span class="n">_cancelScanning</span><span class="p">);</span>   <span class="c1">// 容器銷毀節點時執行
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"></span>    <span class="k">return</span> <span class="n">IsbnScannerState</span><span class="p">.</span><span class="n">initial</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="kd">final</span> <span class="n">isbnScannerViewModelProvider</span> <span class="o">=</span> <span class="n">NotifierProvider</span><span class="o">&lt;</span><span class="n">IsbnScannerViewModel</span><span class="p">,</span> <span class="n">IsbnScannerState</span><span class="o">&gt;</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">  <span class="n">IsbnScannerViewModel</span><span class="p">.</span><span class="k">new</span><span class="p">,</span>          <span class="c1">// 工廠只負責實例化、不碰依賴
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="c1"></span><span class="p">);</span></span></span></code></pre></div><p>改完之後的責任分佈：工廠回到 <code>ViewModel.new</code> 一行、依賴全部經 provider 圖取得（可 override、可追蹤）、清理掛在容器一定會走的 hook 上。<code>ref.onDispose</code> 跟手寫方法的差別就是「誰保證執行」——前者由容器的 dispose 流程保證、後者由一個不存在的呼叫者保證。</p>
<h2 id="兩個附帶的工程紀律">兩個附帶的工程紀律</h2>
<p>這次重構的執行記錄留了兩件跟主題無關、但可轉移的事：</p>
<p><strong>Pre-existing 失敗要用 baseline 重跑確認</strong>。改完後跑測試、一個多語系版型測試在小螢幕溢位失敗。判定它跟本次改動無關的方式是機械的：<code>git stash</code> 還原變更、在 baseline 重跑同一個測試、同樣失敗——確認是既有問題、記錄待追蹤、不混進本次範圍。「看起來無關」是猜測、baseline 重跑是證據。</p>
<p><strong>Worktree 的 dart analyze 要先 pub get</strong>。在 git worktree 裡跑 <code>dart analyze</code>、它會向上解析到主 repo 的舊版 <code>package_config</code> 造成假錯誤；先在 worktree 內 <code>flutter pub get</code> 產生本地設定檔才能得到真結果。</p>
<h2 id="判讀徵兆">判讀徵兆</h2>
<ul>
<li>Notifier / ViewModel 類別裡有自訂的 <code>dispose()</code> 或 <code>close()</code> 方法——先找呼叫者，找不到就是本文的形態：清理掛在無人呼叫的方法上、改掛 <code>ref.onDispose</code></li>
<li>provider 工廠裡出現 <code>new</code> 具體類別（<code>SomeService()</code>）傳進建構子——依賴脫離 provider 圖、測試 override 失效，改成 <code>build()</code> 內 <code>ref.watch</code></li>
<li>工廠寫法不是 <code>ViewModel.new</code> 一行——通常代表建構子還揹著依賴</li>
<li>改動後測試失敗、懷疑是既有問題——stash 還原跑 baseline、用同樣失敗證明、不用直覺判定</li>
</ul>
<h2 id="相關閱讀">相關閱讀</h2>
<ul>
<li>生命週期的另一頭：<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>——本文管銷毀時的清理、那篇管銷毀後的 <code>ref</code> 使用</li>
<li>provider 圖的整體邊界：<a href="/blog/flutter/riverpod-reactive-boundary/" data-link-title="Riverpod 的 reactive 邊界" data-link-desc="頁面用了 Riverpod 卻對某些變化沒反應、或 reactive 行為在特定時機炸掉時使用。Riverpod 的 reactive 保證只覆蓋 provider 圖的內部——排查沿著圖的邊界走：變化在圖上嗎、在哪個容器的圖上、節點還活著嗎。">Riverpod 的 reactive 邊界</a>——依賴經 <code>ref.watch</code> 取得才在圖上、正是本文遷移的理由</li>
<li>DI 的概念地基：<a href="/blog/ddd/knowledge-cards/dependency-injection/" data-link-title="Dependency Injection" data-link-desc="物件的依賴該由誰提供、測試怎麼換掉真實依賴時使用。依賴注入把「建構依賴」跟「使用依賴」分成兩個責任——使用方宣告需要什麼、提供方在組裝時決定給什麼。">Dependency Injection</a>——建構與使用分成兩個責任、Notifier 的工廠與 build() 是這個分工的框架版</li>
</ul>
]]></content:encoded></item><item><title>加書後統計不刷新 — ref.watch 觀察的是 provider 圖、不是資料庫</title><link>https://tarrragon.github.io/blog/work-log/flutter_riverpod_reactive_boundary_ref_watch/</link><pubDate>Thu, 16 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_riverpod_reactive_boundary_ref_watch/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：書庫管理 App 的資料管理頁顯示書庫統計（總書數、待補完書籍數）。從這頁進入搜尋或掃描流程加了書、返回後統計停在舊值；退回首頁再重新進入、數字才更新。
&lt;strong>疑問來源&lt;/strong>：ViewModel 明明用 Riverpod、&lt;code>build()&lt;/code> 裡也有 &lt;code>ref.watch&lt;/code>，為什麼資料變了畫面不動？&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="核心認知refwatch-的觀察範圍">核心認知：ref.watch 的觀察範圍&lt;/h2>
&lt;p>&lt;code>ref.watch&lt;/code> 建立的是「這個 provider 的&lt;strong>狀態&lt;/strong>變化時、重新執行我」的依賴——它觀察的是 provider 圖上的節點，範圍到 provider 的狀態為止。provider 背後的資料庫發生了什麼，不在這張圖上。&lt;/p>
&lt;p>出問題的 ViewModel 依賴長這樣：&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">final&lt;/span> &lt;span class="n">bookRepositoryProvider&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Provider&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">BookRepository&lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">ref&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="k">return&lt;/span> &lt;span class="n">SQLiteBookRepository&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="p">});&lt;/span>
&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 class="kd">class&lt;/span> &lt;span class="nc">DataManagementViewModel&lt;/span> &lt;span class="kd">extends&lt;/span> &lt;span class="n">Notifier&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">DataManagementState&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"> 6&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"> 7&lt;/span>&lt;span class="cl"> &lt;span class="n">DataManagementState&lt;/span> &lt;span class="n">build&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 這個 watch 只在 bookRepositoryProvider「換了一個 repository 實例」時觸發 rebuild
&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="kd">final&lt;/span> &lt;span class="n">repository&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ref&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">watch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">bookRepositoryProvider&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="c1">// ...
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>bookRepositoryProvider&lt;/code> 是單例 &lt;code>Provider&amp;lt;BookRepository&amp;gt;&lt;/code>：它的狀態是「那個 repository 物件本身」、整個 App 生命週期不會變。所以這個 &lt;code>ref.watch&lt;/code> 建立的依賴&lt;strong>永遠不會觸發&lt;/strong>——SQLite 寫入了一百本書，provider 的狀態（物件參考）一動不動。&lt;/p>
&lt;p>畫面「有用 Riverpod」跟畫面「會對資料變更反應」是兩件事。要成立後者，「資料變更」本身必須是 provider 圖上的一個節點。&lt;/p>
&lt;h2 id="三段補償演進">三段補償演進&lt;/h2>
&lt;p>這個缺口在專案裡先後被三種方式處理過。前兩段是補償——在 reactive 邊界外面用別的機制把刷新縫回來；第三段才把缺口本身補上。&lt;/p>
&lt;h3 id="第一段導航返回點補償">第一段：導航返回點補償&lt;/h3>
&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">Future&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="kt">void&lt;/span>&lt;span class="o">&amp;gt;&lt;/span> &lt;span class="n">_navigateAndRefresh&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">BuildContext&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">String&lt;/span> &lt;span class="n">route&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">async&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kd">await&lt;/span> &lt;span class="n">Navigator&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pushNamed&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">route&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="c1">// pop 返回後重新載入統計
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">ref&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">read&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">dataManagementViewModelProvider&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">notifier&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="n">loadData&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>它解掉了當下的 bug、也暴露了補償的形狀：&lt;strong>涵蓋面靠枚舉&lt;/strong>。頁面上每個會導向「可能寫入資料的流程」的入口（匯入捷徑、搜尋、掃描）都要記得包這個 helper；漏一個入口就漏一條刷新路徑。而且它只涵蓋「本頁導航出去再回來」——資料在別的路徑變更（背景匯入、其他頁面操作）時，這頁照樣過期。&lt;/p>
&lt;h3 id="第二段eventbus-橋接">第二段：EventBus 橋接&lt;/h3>
&lt;p>第二段把既有的 domain event 系統接過來：用 &lt;code>StreamProvider&lt;/code> 包 EventBus、ViewModel &lt;code>ref.listen&lt;/code> 監聽，任何事件進來就 &lt;code>loadData()&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">final&lt;/span> &lt;span class="n">dataManagementEventStreamProvider&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">StreamProvider&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">DomainEvent&lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">ref&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="k">return&lt;/span> &lt;span class="n">ref&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">watch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">eventBusProvider&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="n">on&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">DomainEvent&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">3&lt;/span>&lt;span class="cl">&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>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">// ViewModel build() 內
&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">ref&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">dataManagementEventStreamProvider&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">__&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">loadData&lt;/span>&lt;span class="p">());&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這一段看起來把「資料變更」接進 provider 圖了——但接進來的節點是&lt;strong>業務事件流&lt;/strong>、不是資料狀態。兩個新問題浮出來：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>涵蓋面仍靠枚舉，只是枚舉對象換了&lt;/strong>。導航補償要枚舉入口、事件橋接要枚舉「每條寫入路徑都有發事件」。實際盤點發現搜尋加書與掃描加書路徑沒有發布任何 domain event——這兩條路的刷新斷鏈，導航補償被迫保留、兩套機制並存。&lt;/li>
&lt;li>&lt;strong>domain event 被借用成 UI 刷新訊號&lt;/strong>。事件系統的設計語意是「記錄發生了什麼」（跨 domain 通知、日誌、審計）；拿它當刷新訊號後，「要不要發這個事件」開始被「某頁要不要刷新」綁架。監聽端也只能全事件監聽再考慮過濾——任何無關事件都觸發一次重新查詢。這條界線的完整推導在 &lt;a href="https://tarrragon.github.io/blog/ddd/domain-event-vs-state-stream/" data-link-title="domain event 與狀態流" data-link-desc="為了讓某個畫面刷新而補發事件、或監聽端掛著全事件過濾器時使用。事件記錄離散事實、狀態流發布連續觀測——判準是消費者問「發生了什麼」還是「現在是什麼」；載體借用的代價是涵蓋面靠枚舉維持。">domain event 與狀態流&lt;/a>。&lt;/li>
&lt;/ol>
&lt;h3 id="第三段repository-補觀測出口">第三段：repository 補觀測出口&lt;/h3>
&lt;p>第三段修的是缺口本身：repository 介面新增 &lt;code>Stream&amp;lt;List&amp;lt;Book&amp;gt;&amp;gt; watchBooks()&lt;/code>、實作在每個寫入方法尾端 emit 最新書單、DI 層用 &lt;code>StreamProvider&lt;/code> 包裝成 provider 圖上的節點。&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">final&lt;/span> &lt;span class="n">watchBooksProvider&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">StreamProvider&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">Book&lt;/span>&lt;span class="o">&amp;gt;&amp;gt;&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">ref&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">async&lt;/span>&lt;span class="o">*&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">repository&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ref&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">watch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">bookRepositoryProvider&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">yield&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">repository&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">getAllBooks&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="kd">yield&lt;/span>&lt;span class="o">*&lt;/span> &lt;span class="n">repository&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">watchBooks&lt;/span>&lt;span class="p">();&lt;/span> &lt;span class="c1">// 後續變更
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>從此「資料變更」是 provider 圖上的一級節點：任何衍生視圖 &lt;code>ref.watch(watchBooksProvider)&lt;/code> 就取得 reactive 更新，統計頁、書庫清單、待補完列表全部走同一條觀察路徑。涵蓋面從「枚舉入口／枚舉事件」變成「寫入方法的集合」——新增加書路徑時，寫入必然經過 repository 的寫入方法，emit 自動涵蓋，沒有「記得補」這個動作。實作細節（broadcast、初始值、dispose）在 &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;h2 id="判準補償刷新是缺口訊號">判準：補償刷新是缺口訊號&lt;/h2>
&lt;p>三段演進收斂成一條可操作的判讀：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>訊號&lt;/th>
 &lt;th>判讀&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>導航返回點出現 &lt;code>loadData()&lt;/code> / &lt;code>refresh()&lt;/code> 補償&lt;/td>
 &lt;td>「資料變更」不在 provider 圖上、有人在圖外手動縫刷新&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>為了讓某頁刷新而監聽 domain event&lt;/td>
 &lt;td>業務事件被借用為狀態通知、涵蓋面靠「記得發事件」維持&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>同一份資料有多個視圖、各自維護 load 時機&lt;/td>
 &lt;td>缺一個共同的觀測節點、每個視圖都在重複解同一題&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>ref.watch&lt;/code> 對象是單例 &lt;code>Provider&amp;lt;Repository&amp;gt;&lt;/code>&lt;/td>
 &lt;td>這個 watch 永不觸發 rebuild、reactive 是名義上的&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>每個訊號的修法都指向同一個方向：讓資料變更成為 provider 圖上的節點（&lt;code>StreamProvider&lt;/code> 包 repository 的 stream 出口、或 Notifier 持有狀態），視圖回到純 &lt;code>ref.watch&lt;/code>。輪詢式定時重抓（每 N 秒或 App resume 時重新查詢）是另一個合法選項，適用於新鮮度容忍度高、寫入頻率低的場景（管理後台的統計卡、低優先級的快取）。本文判準只處理「需要即時反應」的情境——多個視圖要看同一份即時資料、補償刷新已經散在各處時，輪詢的涵蓋面跟導航補償一樣靠枚舉維持，觀測出口才是結構性解。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：書庫管理 App 的資料管理頁顯示書庫統計（總書數、待補完書籍數）。從這頁進入搜尋或掃描流程加了書、返回後統計停在舊值；退回首頁再重新進入、數字才更新。
<strong>疑問來源</strong>：ViewModel 明明用 Riverpod、<code>build()</code> 裡也有 <code>ref.watch</code>，為什麼資料變了畫面不動？</p></blockquote>
<hr>
<h2 id="核心認知refwatch-的觀察範圍">核心認知：ref.watch 的觀察範圍</h2>
<p><code>ref.watch</code> 建立的是「這個 provider 的<strong>狀態</strong>變化時、重新執行我」的依賴——它觀察的是 provider 圖上的節點，範圍到 provider 的狀態為止。provider 背後的資料庫發生了什麼，不在這張圖上。</p>
<p>出問題的 ViewModel 依賴長這樣：</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">bookRepositoryProvider</span> <span class="o">=</span> <span class="n">Provider</span><span class="o">&lt;</span><span class="n">BookRepository</span><span class="o">&gt;</span><span class="p">((</span><span class="n">ref</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="k">return</span> <span class="n">SQLiteBookRepository</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><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="kd">class</span> <span class="nc">DataManagementViewModel</span> <span class="kd">extends</span> <span class="n">Notifier</span><span class="o">&lt;</span><span class="n">DataManagementState</span><span class="o">&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="err">@</span><span class="n">override</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="n">DataManagementState</span> <span class="n">build</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="c1">// 這個 watch 只在 bookRepositoryProvider「換了一個 repository 實例」時觸發 rebuild
</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">repository</span> <span class="o">=</span> <span class="n">ref</span><span class="p">.</span><span class="n">watch</span><span class="p">(</span><span class="n">bookRepositoryProvider</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="c1">// ...
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span>  <span class="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><code>bookRepositoryProvider</code> 是單例 <code>Provider&lt;BookRepository&gt;</code>：它的狀態是「那個 repository 物件本身」、整個 App 生命週期不會變。所以這個 <code>ref.watch</code> 建立的依賴<strong>永遠不會觸發</strong>——SQLite 寫入了一百本書，provider 的狀態（物件參考）一動不動。</p>
<p>畫面「有用 Riverpod」跟畫面「會對資料變更反應」是兩件事。要成立後者，「資料變更」本身必須是 provider 圖上的一個節點。</p>
<h2 id="三段補償演進">三段補償演進</h2>
<p>這個缺口在專案裡先後被三種方式處理過。前兩段是補償——在 reactive 邊界外面用別的機制把刷新縫回來；第三段才把缺口本身補上。</p>
<h3 id="第一段導航返回點補償">第一段：導航返回點補償</h3>
<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">Future</span><span class="o">&lt;</span><span class="kt">void</span><span class="o">&gt;</span> <span class="n">_navigateAndRefresh</span><span class="p">(</span><span class="n">BuildContext</span> <span class="n">context</span><span class="p">,</span> <span class="kt">String</span> <span class="n">route</span><span class="p">)</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">await</span> <span class="n">Navigator</span><span class="p">.</span><span class="n">pushNamed</span><span class="p">(</span><span class="n">context</span><span class="p">,</span> <span class="n">route</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="c1">// pop 返回後重新載入統計
</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">ref</span><span class="p">.</span><span class="n">read</span><span class="p">(</span><span class="n">dataManagementViewModelProvider</span><span class="p">.</span><span class="n">notifier</span><span class="p">).</span><span class="n">loadData</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>它解掉了當下的 bug、也暴露了補償的形狀：<strong>涵蓋面靠枚舉</strong>。頁面上每個會導向「可能寫入資料的流程」的入口（匯入捷徑、搜尋、掃描）都要記得包這個 helper；漏一個入口就漏一條刷新路徑。而且它只涵蓋「本頁導航出去再回來」——資料在別的路徑變更（背景匯入、其他頁面操作）時，這頁照樣過期。</p>
<h3 id="第二段eventbus-橋接">第二段：EventBus 橋接</h3>
<p>第二段把既有的 domain event 系統接過來：用 <code>StreamProvider</code> 包 EventBus、ViewModel <code>ref.listen</code> 監聽，任何事件進來就 <code>loadData()</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">final</span> <span class="n">dataManagementEventStreamProvider</span> <span class="o">=</span> <span class="n">StreamProvider</span><span class="o">&lt;</span><span class="n">DomainEvent</span><span class="o">&gt;</span><span class="p">((</span><span class="n">ref</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="k">return</span> <span class="n">ref</span><span class="p">.</span><span class="n">watch</span><span class="p">(</span><span class="n">eventBusProvider</span><span class="p">).</span><span class="n">on</span><span class="o">&lt;</span><span class="n">DomainEvent</span><span class="o">&gt;</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">});</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">// ViewModel build() 內
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="n">ref</span><span class="p">.</span><span class="n">listen</span><span class="p">(</span><span class="n">dataManagementEventStreamProvider</span><span class="p">,</span> <span class="p">(</span><span class="n">_</span><span class="p">,</span> <span class="n">__</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">loadData</span><span class="p">());</span></span></span></code></pre></div><p>這一段看起來把「資料變更」接進 provider 圖了——但接進來的節點是<strong>業務事件流</strong>、不是資料狀態。兩個新問題浮出來：</p>
<ol>
<li><strong>涵蓋面仍靠枚舉，只是枚舉對象換了</strong>。導航補償要枚舉入口、事件橋接要枚舉「每條寫入路徑都有發事件」。實際盤點發現搜尋加書與掃描加書路徑沒有發布任何 domain event——這兩條路的刷新斷鏈，導航補償被迫保留、兩套機制並存。</li>
<li><strong>domain event 被借用成 UI 刷新訊號</strong>。事件系統的設計語意是「記錄發生了什麼」（跨 domain 通知、日誌、審計）；拿它當刷新訊號後，「要不要發這個事件」開始被「某頁要不要刷新」綁架。監聽端也只能全事件監聽再考慮過濾——任何無關事件都觸發一次重新查詢。這條界線的完整推導在 <a href="/blog/ddd/domain-event-vs-state-stream/" data-link-title="domain event 與狀態流" data-link-desc="為了讓某個畫面刷新而補發事件、或監聽端掛著全事件過濾器時使用。事件記錄離散事實、狀態流發布連續觀測——判準是消費者問「發生了什麼」還是「現在是什麼」；載體借用的代價是涵蓋面靠枚舉維持。">domain event 與狀態流</a>。</li>
</ol>
<h3 id="第三段repository-補觀測出口">第三段：repository 補觀測出口</h3>
<p>第三段修的是缺口本身：repository 介面新增 <code>Stream&lt;List&lt;Book&gt;&gt; watchBooks()</code>、實作在每個寫入方法尾端 emit 最新書單、DI 層用 <code>StreamProvider</code> 包裝成 provider 圖上的節點。</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">watchBooksProvider</span> <span class="o">=</span> <span class="n">StreamProvider</span><span class="o">&lt;</span><span class="n">List</span><span class="o">&lt;</span><span class="n">Book</span><span class="o">&gt;&gt;</span><span class="p">((</span><span class="n">ref</span><span class="p">)</span> <span class="kd">async</span><span class="o">*</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">repository</span> <span class="o">=</span> <span class="n">ref</span><span class="p">.</span><span class="n">watch</span><span class="p">(</span><span class="n">bookRepositoryProvider</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="kd">yield</span> <span class="kd">await</span> <span class="n">repository</span><span class="p">.</span><span class="n">getAllBooks</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">yield</span><span class="o">*</span> <span class="n">repository</span><span class="p">.</span><span class="n">watchBooks</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="p">});</span></span></span></code></pre></div><p>從此「資料變更」是 provider 圖上的一級節點：任何衍生視圖 <code>ref.watch(watchBooksProvider)</code> 就取得 reactive 更新，統計頁、書庫清單、待補完列表全部走同一條觀察路徑。涵蓋面從「枚舉入口／枚舉事件」變成「寫入方法的集合」——新增加書路徑時，寫入必然經過 repository 的寫入方法，emit 自動涵蓋，沒有「記得補」這個動作。實作細節（broadcast、初始值、dispose）在 <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>
<h2 id="判準補償刷新是缺口訊號">判準：補償刷新是缺口訊號</h2>
<p>三段演進收斂成一條可操作的判讀：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>判讀</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>導航返回點出現 <code>loadData()</code> / <code>refresh()</code> 補償</td>
          <td>「資料變更」不在 provider 圖上、有人在圖外手動縫刷新</td>
      </tr>
      <tr>
          <td>為了讓某頁刷新而監聽 domain event</td>
          <td>業務事件被借用為狀態通知、涵蓋面靠「記得發事件」維持</td>
      </tr>
      <tr>
          <td>同一份資料有多個視圖、各自維護 load 時機</td>
          <td>缺一個共同的觀測節點、每個視圖都在重複解同一題</td>
      </tr>
      <tr>
          <td><code>ref.watch</code> 對象是單例 <code>Provider&lt;Repository&gt;</code></td>
          <td>這個 watch 永不觸發 rebuild、reactive 是名義上的</td>
      </tr>
  </tbody>
</table>
<p>每個訊號的修法都指向同一個方向：讓資料變更成為 provider 圖上的節點（<code>StreamProvider</code> 包 repository 的 stream 出口、或 Notifier 持有狀態），視圖回到純 <code>ref.watch</code>。輪詢式定時重抓（每 N 秒或 App resume 時重新查詢）是另一個合法選項，適用於新鮮度容忍度高、寫入頻率低的場景（管理後台的統計卡、低優先級的快取）。本文判準只處理「需要即時反應」的情境——多個視圖要看同一份即時資料、補償刷新已經散在各處時，輪詢的涵蓋面跟導航補償一樣靠枚舉維持，觀測出口才是結構性解。</p>
<h2 id="介面歸屬需求來自誰不決定介面放哪">介面歸屬：需求來自誰、不決定介面放哪</h2>
<p><code>watchBooks()</code> 的需求完全來自 presentation 層——是 Riverpod 想觀察資料變更、domain 自己沒有這個需要。直覺會說「誰需要就放誰那層」，把 Stream 出口做在 infrastructure 或 presentation 的某個 service 裡。</p>
<p>實際的歸屬判準是<strong>介面用什麼語言表達</strong>、不是需求來自誰：</p>
<table>
  <thead>
      <tr>
          <th>介面簽名裡的型別</th>
          <th>歸屬判定</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>dart:async</code> 的 <code>Stream&lt;T&gt;</code></td>
          <td>語言標準庫、與 <code>Future&lt;T&gt;</code> 地位等同、不算洩漏</td>
      </tr>
      <tr>
          <td>domain entity（<code>Book</code>）</td>
          <td>domain 自有語言、不算洩漏</td>
      </tr>
      <tr>
          <td>Riverpod 型別（<code>StreamProvider</code>）</td>
          <td>框架語言、進 domain 介面就是洩漏</td>
      </tr>
      <tr>
          <td>SQLite 型別（<code>Database</code>）</td>
          <td>infrastructure 語言、同上</td>
      </tr>
  </tbody>
</table>
<p><code>Stream&lt;List&lt;Book&gt;&gt; watchBooks()</code> 全句只用語言標準庫加 domain entity——它是 <code>getAllBooks()</code> 的 push 版本、放 domain repository 介面語意自然。需求從消費者出發決定介面<strong>該不該存在</strong>；介面<strong>放哪一層</strong>由表達語言決定。這條判準的完整推導（含機制層與組裝層的歸屬）在 <a href="/blog/ddd/observation-outlet-responsibility-split/" data-link-title="觀測出口的職責三分" data-link-desc="repository 要補「資料變了」的推送能力、卻不確定 Stream 介面放 domain 算不算洩漏時使用。歸屬判準是介面用什麼語言表達、不是需求來自誰：契約歸 domain、變更偵測歸 infrastructure、框架訂閱歸組裝層。">觀測出口的職責三分</a>。</p>
<h2 id="下一步">下一步</h2>
<ul>
<li>契約／機制／組裝三層歸屬的完整判準：<a href="/blog/ddd/observation-outlet-responsibility-split/" data-link-title="觀測出口的職責三分" data-link-desc="repository 要補「資料變了」的推送能力、卻不確定 Stream 介面放 domain 算不算洩漏時使用。歸屬判準是介面用什麼語言表達、不是需求來自誰：契約歸 domain、變更偵測歸 infrastructure、框架訂閱歸組裝層。">觀測出口的職責三分</a></li>
<li>事件與狀態流的語意分界（為什麼 EventBus 橋接是越權）：<a href="/blog/ddd/domain-event-vs-state-stream/" data-link-title="domain event 與狀態流" data-link-desc="為了讓某個畫面刷新而補發事件、或監聽端掛著全事件過濾器時使用。事件記錄離散事實、狀態流發布連續觀測——判準是消費者問「發生了什麼」還是「現在是什麼」；載體借用的代價是涵蓋面靠枚舉維持。">domain event 與狀態流</a></li>
<li>watchBooks 落地的三個實作點（broadcast、初始值、dispose）：<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></li>
<li>provider 圖與容器的關係（狀態屬於容器、宣告只是配方）：<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></li>
<li>同一事故的 UX 分析角度（happy-path-only 資料版）：<a href="/blog/ux-design/cases/back-navigation-stale-statistics/" data-link-title="U.C6 加書後返回不刷新統計 — 只設計了進入時載入" data-link-desc="Flutter app 資料管理頁的書籍統計只在 initState 載入一次，從頁面進入新增流程加書後 pop 返回，統計停留在舊值（仍顯示無書目），要回首頁再進入才更新。根因是 happy-path-only 反模式的資料版本：設計了「進入時載入」，沒設計「資料變更時的轉移」">U.C6 加書後返回不刷新統計</a></li>
</ul>
]]></content:encoded></item><item><title>測試全綠、功能失聯：五個 runtime 問題與組裝層的接線缺口</title><link>https://tarrragon.github.io/blog/work-log/flutter_composition_root_wiring_gap/</link><pubDate>Mon, 13 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_composition_root_wiring_gap/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：Flutter 書籍管理 App 的一個版本完成 113 張票、單元測試 100% 通過、收尾驗收通過；實機測試（Android 實體機）找出五個問題——其中三個是「功能做完了、使用者到不了」
&lt;strong>疑問來源&lt;/strong>：規格審查、測試、版本收尾三道防線都在運作，為什麼五個問題全數漏網？
&lt;strong>整理目的&lt;/strong>：記下佔位實作讓測試綠燈的機制、反向追溯提案與 use case 文件的結果、以及修補時「規格層／測試層／發版層」的分層落點
&lt;strong>本文邊界&lt;/strong>：素材是該專案 v0.38.1 修復批次的分析記錄；DDD 觀念層的判準另見 &lt;a href="https://tarrragon.github.io/blog/ddd/composition-root-reachability/" data-link-title="組裝層的可達性" data-link-desc="行為測試全綠、功能在實機上沒有入口的失效形態出現時使用。mock 換掉的正是組裝，組裝完成與否在行為測試裡沒有證言；把可達性當成組裝層的不變式，在測試、發版與設計文件各給一個強制點。">組裝層的可達性&lt;/a>&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="五個問題三種佔位兩種平台差異">五個問題、三種佔位、兩種平台差異&lt;/h2>
&lt;p>實機日誌與使用者操作對出五個問題，斷裂點分成兩組：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>問題&lt;/th>
 &lt;th>現象&lt;/th>
 &lt;th>斷裂點&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Tag 管理崩壞&lt;/td>
 &lt;td>provider 佔位 throw「requires override」在 production 被觸發，畫面連鎖報錯&lt;/td>
 &lt;td>DI 組裝&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>掃描／匯入失聯&lt;/td>
 &lt;td>首頁按鈕顯示「功能開發中」提示，路由表把 &lt;code>/scan&lt;/code>、&lt;code>/import&lt;/code> 指向 ComingSoon 佔位頁——掃描與匯入的 MVVM 全套均已完成&lt;/td>
 &lt;td>路由表 + UI callback&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>資料管理頁按鈕沒反應&lt;/td>
 &lt;td>四顆按鈕的 onPressed 全是空實作&lt;/td>
 &lt;td>UI callback&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>啟動框架警告&lt;/td>
 &lt;td>binding 在 root zone 初始化、runApp 在 runZonedGuarded 子 zone，非同步例外可能逃出攔截&lt;/td>
 &lt;td>框架初始化順序（平台層）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>開庫失敗&lt;/td>
 &lt;td>Android 上 &lt;code>PRAGMA journal_mode = WAL&lt;/code> 以 execSQL 執行被拒、資料庫開啟直接失敗、全部持久化功能不可用&lt;/td>
 &lt;td>SQLite Android 語意（平台層）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>前三個是同一種形狀：功能單元全部存在、對應測試全部通過，斷的是「把功能接到入口」的那一段——DI 容器沒接上真實依賴、路由表沒指向真實頁面、按鈕沒接上導航。後兩個是另一種形狀：程式碼在 host 測試環境行為正確，在目標平台的語意下失效。&lt;/p>
&lt;h2 id="佔位讓測試綠燈的三層共振">佔位讓測試綠燈的三層共振&lt;/h2>
&lt;p>單一防線失效不足以讓五個問題全數漏網，三層機制疊在一起才做到：&lt;/p>
&lt;p>第一層在規格。use case 的成功保證寫的是功能行為——「成功匯入 X 本書籍」「實體書籍立即新增到書庫」——入口是否接上不在任何驗收條款裡。文件描述了使用者「點擊匯入按鈕」，但按鈕、路由、頁面這條鏈由誰負責接、接完長什麼樣，設計文件裡沒有一個字。&lt;/p>
&lt;p>第二層在測試設計。從 use case 推導出的測試落在 unit 與 widget 層，用 &lt;code>ProviderScope(overrides: [...])&lt;/code> 注入 mock。override 是 Riverpod 給的正當測試 seam——它讓 domain 與 ViewModel 可以脫離 infrastructure 單獨驗證，這是分層架構承諾的兌現。代價在 seam 的另一面：override 換掉的正是 production 的組裝——組裝完沒完成，這套測試從頭到尾無人作證。&lt;/p>
&lt;p>第三層在佔位本身。ComingSoon 頁是合法 widget、空 onPressed 是合法函式、throw 佔位的 provider 在 override 之下永遠不會被解析——佔位不觸發任何紅燈，測試斷言的是 mock 環境下的行為，佔位在測試的視野之外。三層疊加的結果：佔位通過了全部以 mock 為基礎的驗收，一路走到使用者手上。&lt;/p>
&lt;h3 id="override-的雙面性">override 的雙面性&lt;/h3>
&lt;p>override 同時是解藥跟盲點，而且是同一個機制。判讀訊號有一條可操作的分界——override 出現在個別測試裡是正當用法；整個專案找不到任何一個「無 override 環境解析 provider」的測試，才是組裝層裸奔的訊號。這個專案屬於後者：&lt;code>grep&lt;/code> 全部測試，production 等效環境（真實路由表、零 override 的 ProviderScope）的案例數是零。mock 遮蔽的另一種病因——替身的協定語意與真實體不符、而非組裝缺席——見 &lt;a href="https://tarrragon.github.io/blog/work-log/testing_three_layer_strategy/" data-link-title="192 個測試全過、實機全壞：Mock 遮蔽真實行為的三層測試策略" data-link-desc="unit test 全綠、實機部署後功能整片壞掉。mock-only 策略的結構盲區（text vs binary frame、缺 auth handshake、ANSI 多樣性被 FakeWebSocketChannel 遮蔽），以及分層測試各抓什麼、各遮蔽什麼。">192 個測試全過、實機全壞&lt;/a>。&lt;/p>
&lt;h2 id="反向追溯設計文件裡找不到入口">反向追溯：設計文件裡找不到「入口」&lt;/h2>
&lt;p>修復批次先做了一件事：拿五個問題反向追溯提案（PROP）、use case（UC）、規格（SPEC），確認每個問題在設計文件裡的對應條目長什麼樣。結果分成兩型：&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>Tag provider 佔位&lt;/td>
 &lt;td>提案定義了七個 CRUD 方法與 UI 形態&lt;/td>
 &lt;td>有功能定義、驗收不含可達性&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>路由佔位&lt;/td>
 &lt;td>UC 寫了「使用者點擊按鈕」&lt;/td>
 &lt;td>有行為描述、接線無人認領&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>空 onPressed&lt;/td>
 &lt;td>UC 寫了「進入資料管理頁面、點擊匯出」&lt;/td>
 &lt;td>有操作描述、callback 實作不在驗收內&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Zone 警告&lt;/td>
 &lt;td>全文無對應&lt;/td>
 &lt;td>平台層細節，設計文件構不到&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>PRAGMA 失敗&lt;/td>
 &lt;td>全文無對應&lt;/td>
 &lt;td>平台層細節，設計文件構不到&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>追溯給出的結論：提案端寫滿了能力（tag 管理要有七個 CRUD）、use case 端寫滿了行為（使用者點擊匯入按鈕），追到「按鈕由誰放上畫面、路由由誰指向真頁面」時，兩類文件都翻不到答案——三個佔位問題全部落在這條縫裡。&lt;/p>
&lt;h3 id="前置條件被當成免責條款">前置條件被當成免責條款&lt;/h3>
&lt;p>追溯裡有一個細節要單獨展開。匯出功能的 use case 前置條件寫著「書庫中存在至少一本書」——這條前置條件已經標出了一個狀態分支：書庫是空的時候會怎樣？匯出按鈕在哪？沒有任何文件回答。實機上使用者的書庫是空的（開庫失敗的下游效應），畫面走了空狀態分支、匯出按鈕只存在於正常狀態分支，使用者的回報是「匯出功能不見了」。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：Flutter 書籍管理 App 的一個版本完成 113 張票、單元測試 100% 通過、收尾驗收通過；實機測試（Android 實體機）找出五個問題——其中三個是「功能做完了、使用者到不了」
<strong>疑問來源</strong>：規格審查、測試、版本收尾三道防線都在運作，為什麼五個問題全數漏網？
<strong>整理目的</strong>：記下佔位實作讓測試綠燈的機制、反向追溯提案與 use case 文件的結果、以及修補時「規格層／測試層／發版層」的分層落點
<strong>本文邊界</strong>：素材是該專案 v0.38.1 修復批次的分析記錄；DDD 觀念層的判準另見 <a href="/blog/ddd/composition-root-reachability/" data-link-title="組裝層的可達性" data-link-desc="行為測試全綠、功能在實機上沒有入口的失效形態出現時使用。mock 換掉的正是組裝，組裝完成與否在行為測試裡沒有證言；把可達性當成組裝層的不變式，在測試、發版與設計文件各給一個強制點。">組裝層的可達性</a></p></blockquote>
<hr>
<h2 id="五個問題三種佔位兩種平台差異">五個問題、三種佔位、兩種平台差異</h2>
<p>實機日誌與使用者操作對出五個問題，斷裂點分成兩組：</p>
<table>
  <thead>
      <tr>
          <th>問題</th>
          <th>現象</th>
          <th>斷裂點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Tag 管理崩壞</td>
          <td>provider 佔位 throw「requires override」在 production 被觸發，畫面連鎖報錯</td>
          <td>DI 組裝</td>
      </tr>
      <tr>
          <td>掃描／匯入失聯</td>
          <td>首頁按鈕顯示「功能開發中」提示，路由表把 <code>/scan</code>、<code>/import</code> 指向 ComingSoon 佔位頁——掃描與匯入的 MVVM 全套均已完成</td>
          <td>路由表 + UI callback</td>
      </tr>
      <tr>
          <td>資料管理頁按鈕沒反應</td>
          <td>四顆按鈕的 onPressed 全是空實作</td>
          <td>UI callback</td>
      </tr>
      <tr>
          <td>啟動框架警告</td>
          <td>binding 在 root zone 初始化、runApp 在 runZonedGuarded 子 zone，非同步例外可能逃出攔截</td>
          <td>框架初始化順序（平台層）</td>
      </tr>
      <tr>
          <td>開庫失敗</td>
          <td>Android 上 <code>PRAGMA journal_mode = WAL</code> 以 execSQL 執行被拒、資料庫開啟直接失敗、全部持久化功能不可用</td>
          <td>SQLite Android 語意（平台層）</td>
      </tr>
  </tbody>
</table>
<p>前三個是同一種形狀：功能單元全部存在、對應測試全部通過，斷的是「把功能接到入口」的那一段——DI 容器沒接上真實依賴、路由表沒指向真實頁面、按鈕沒接上導航。後兩個是另一種形狀：程式碼在 host 測試環境行為正確，在目標平台的語意下失效。</p>
<h2 id="佔位讓測試綠燈的三層共振">佔位讓測試綠燈的三層共振</h2>
<p>單一防線失效不足以讓五個問題全數漏網，三層機制疊在一起才做到：</p>
<p>第一層在規格。use case 的成功保證寫的是功能行為——「成功匯入 X 本書籍」「實體書籍立即新增到書庫」——入口是否接上不在任何驗收條款裡。文件描述了使用者「點擊匯入按鈕」，但按鈕、路由、頁面這條鏈由誰負責接、接完長什麼樣，設計文件裡沒有一個字。</p>
<p>第二層在測試設計。從 use case 推導出的測試落在 unit 與 widget 層，用 <code>ProviderScope(overrides: [...])</code> 注入 mock。override 是 Riverpod 給的正當測試 seam——它讓 domain 與 ViewModel 可以脫離 infrastructure 單獨驗證，這是分層架構承諾的兌現。代價在 seam 的另一面：override 換掉的正是 production 的組裝——組裝完沒完成，這套測試從頭到尾無人作證。</p>
<p>第三層在佔位本身。ComingSoon 頁是合法 widget、空 onPressed 是合法函式、throw 佔位的 provider 在 override 之下永遠不會被解析——佔位不觸發任何紅燈，測試斷言的是 mock 環境下的行為，佔位在測試的視野之外。三層疊加的結果：佔位通過了全部以 mock 為基礎的驗收，一路走到使用者手上。</p>
<h3 id="override-的雙面性">override 的雙面性</h3>
<p>override 同時是解藥跟盲點，而且是同一個機制。判讀訊號有一條可操作的分界——override 出現在個別測試裡是正當用法；整個專案找不到任何一個「無 override 環境解析 provider」的測試，才是組裝層裸奔的訊號。這個專案屬於後者：<code>grep</code> 全部測試，production 等效環境（真實路由表、零 override 的 ProviderScope）的案例數是零。mock 遮蔽的另一種病因——替身的協定語意與真實體不符、而非組裝缺席——見 <a href="/blog/work-log/testing_three_layer_strategy/" data-link-title="192 個測試全過、實機全壞：Mock 遮蔽真實行為的三層測試策略" data-link-desc="unit test 全綠、實機部署後功能整片壞掉。mock-only 策略的結構盲區（text vs binary frame、缺 auth handshake、ANSI 多樣性被 FakeWebSocketChannel 遮蔽），以及分層測試各抓什麼、各遮蔽什麼。">192 個測試全過、實機全壞</a>。</p>
<h2 id="反向追溯設計文件裡找不到入口">反向追溯：設計文件裡找不到「入口」</h2>
<p>修復批次先做了一件事：拿五個問題反向追溯提案（PROP）、use case（UC）、規格（SPEC），確認每個問題在設計文件裡的對應條目長什麼樣。結果分成兩型：</p>
<table>
  <thead>
      <tr>
          <th>問題</th>
          <th>設計文件對應</th>
          <th>缺口型態</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Tag provider 佔位</td>
          <td>提案定義了七個 CRUD 方法與 UI 形態</td>
          <td>有功能定義、驗收不含可達性</td>
      </tr>
      <tr>
          <td>路由佔位</td>
          <td>UC 寫了「使用者點擊按鈕」</td>
          <td>有行為描述、接線無人認領</td>
      </tr>
      <tr>
          <td>空 onPressed</td>
          <td>UC 寫了「進入資料管理頁面、點擊匯出」</td>
          <td>有操作描述、callback 實作不在驗收內</td>
      </tr>
      <tr>
          <td>Zone 警告</td>
          <td>全文無對應</td>
          <td>平台層細節，設計文件構不到</td>
      </tr>
      <tr>
          <td>PRAGMA 失敗</td>
          <td>全文無對應</td>
          <td>平台層細節，設計文件構不到</td>
      </tr>
  </tbody>
</table>
<p>追溯給出的結論：提案端寫滿了能力（tag 管理要有七個 CRUD）、use case 端寫滿了行為（使用者點擊匯入按鈕），追到「按鈕由誰放上畫面、路由由誰指向真頁面」時，兩類文件都翻不到答案——三個佔位問題全部落在這條縫裡。</p>
<h3 id="前置條件被當成免責條款">前置條件被當成免責條款</h3>
<p>追溯裡有一個細節要單獨展開。匯出功能的 use case 前置條件寫著「書庫中存在至少一本書」——這條前置條件已經標出了一個狀態分支：書庫是空的時候會怎樣？匯出按鈕在哪？沒有任何文件回答。實機上使用者的書庫是空的（開庫失敗的下游效應），畫面走了空狀態分支、匯出按鈕只存在於正常狀態分支，使用者的回報是「匯出功能不見了」。</p>
<p>前置條件的每一條都隱含一個「不滿足時會怎樣」的分支。把前置條件當成限定範圍的免責條款用，分支就無人設計；把它當成狀態枚舉的線索用，空狀態的畫面就會在設計期被逼著給出答案。</p>
<h2 id="修補的分層落點">修補的分層落點</h2>
<p>五個問題各自的修法：三個接線問題把真實依賴、真實頁面、導航 callback 接回入口；zone 警告把 binding 初始化移進 runZonedGuarded、與 runApp 收在同一個 zone；PRAGMA 失敗把 journal mode 設定改走 rawQuery（回傳結果列的查詢路徑）。修掉之外，修補按層放了三組防線，涵蓋範圍超出「把五個問題修掉」本身：</p>
<p><strong>規格層</strong>：use case 文件補上「端到端可達性」成功保證條款（路由指向真實頁面、callback 已接線、provider 在無 override 環境可解析），並新增 use case 撰寫檢核：名詞可定位、路徑連通、狀態完備、環境差異——問句全文與判定方式見 <a href="/blog/ddd/composition-root-reachability/" data-link-title="組裝層的可達性" data-link-desc="行為測試全綠、功能在實機上沒有入口的失效形態出現時使用。mock 換掉的正是組裝，組裝完成與否在行為測試裡沒有證言；把可達性當成組裝層的不變式，在測試、發版與設計文件各給一個強制點。">組裝層的可達性</a>。</p>
<p><strong>測試層</strong>：區分行為測試與接線測試。行為測試維持 mock override（驗功能邏輯）；接線測試零 override、用真實路由表與真實依賴，只驗「port 有沒有插上 adapter」——本案是 local-first 單機 app、零 override 做得到全量；有遠端依賴的專案，零 override 的範圍是組裝路徑、最外圈 infrastructure 在邊界替換。修復前先補了十三個案例，其中十一個對目標行為斷言、修復前確定性紅燈——修復票以這批測試變綠為驗收點。</p>
<p><strong>發版層</strong>：兩道互補的防線。佔位掃描進發版前置檢查——ComingSoon、UnimplementedError、空 onPressed 都是靜態可 grep 的，掃到就警告；實機冒煙清單補掃描構不到的部分——平台語意差異只有在目標平台執行才會暴露，清單的三層（啟動健康、use case happy path 走查、平台敏感點）在打 tag 前人工走一輪。</p>
<p>平台層的兩個問題（zone、PRAGMA）在設計文件與 host 測試都無處可防：Android execSQL 拒絕有回傳列的 SQL 這件事，sqflite 的 ffi 測試環境重現不出來。能做的是把「這步只有實機能驗」在設計期標記出來，讓冒煙清單有明確來源——漏網的位置從使用者手上移到發版前的清單上。</p>
<h2 id="判準收束">判準收束</h2>
<ul>
<li><strong>綠燈的證言範圍</strong>：一個測試證明的是它執行環境下的行為。override 環境的綠燈對 production 組裝零證言——證言缺口要由接線測試補、而非把行為測試的 mock 拆掉。</li>
<li><strong>功能完成的定義</strong>：從入口可達才算完成。「MVVM 全套完成」與「使用者用得到」之間隔著組裝層，這段距離在票務上要有自己的驗收條款。</li>
<li><strong>佔位的紀律</strong>：佔位是合法的開發中間態，但它需要一個攔截點（發版掃描）。缺攔截點的佔位會通過所有以 mock 為基礎的驗收，終點站是使用者的回報。</li>
<li><strong>缺口分類先於對策</strong>：這批修復裡，三個接線問題走規格條款加接線測試，zone 與 PRAGMA 走冒煙清單。對五個問題一律用「補測試」回應，補到的只是 host 環境的綠燈——zone 與 PRAGMA 要目標平台實際執行才暴露。</li>
</ul>
]]></content:encoded></item><item><title>1101 行自建測試基礎設施、重構刪掉 82.5% — 過度工程的三種形態</title><link>https://tarrragon.github.io/blog/work-log/flutter_mock_infrastructure_overengineering_deleted/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_mock_infrastructure_overengineering_deleted/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：Flutter 書籍管理 App 的 Widget 測試基礎設施，設計階段產出 783 行的完整規格、實作 1101 行；下一個 Phase 的重構審查判定整套重做，刪到剩 193 行（-82.5%）、其中核心 helper 62 行
&lt;strong>疑問來源&lt;/strong>：一套經過完整設計流程、有架構圖有依賴規範的基礎設施，為什麼是該刪的？審查依據是什麼？
&lt;strong>整理目的&lt;/strong>：把這次刪除拆成三種可辨識的過度工程形態、以及「寫測試工具之前」的檢查點
&lt;strong>本文邊界&lt;/strong>：素材是該專案 v0.7.0 的 Phase 1 設計文件與 Phase 4 重構記錄——同一個東西的誕生與死亡、對照完整&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="被刪掉的是什麼">被刪掉的是什麼&lt;/h2>
&lt;p>四個檔案、一個目錄：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>檔案&lt;/th>
 &lt;th>行數&lt;/th>
 &lt;th>職責&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>lib/mocks/widget_test_mocks.dart&lt;/code>&lt;/td>
 &lt;td>389&lt;/td>
 &lt;td>Mock 架構（含 &lt;code>MockBook&lt;/code>、Mock Provider 群）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>lib/helpers/multi_language_widget_test_helper.dart&lt;/code>&lt;/td>
 &lt;td>388&lt;/td>
 &lt;td>多語系測試工具（Mutex 狀態鎖、記憶體洩漏防護、三類溢位檢測）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>lib/helpers/widget_test_helper.dart&lt;/code>&lt;/td>
 &lt;td>193&lt;/td>
 &lt;td>自建測試環境建立工具&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>lib/helpers/test_logger.dart&lt;/code>&lt;/td>
 &lt;td>131&lt;/td>
 &lt;td>自製測試日誌系統&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>取代它們的是 62 行的 helper 加標準 Riverpod 模式：&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">WidgetTestHelper&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">createFullTestApp&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">const&lt;/span> &lt;span class="n">LibraryDisplayPage&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="p">[&lt;/span>&lt;span class="n">libraryDisplayViewModelProvider&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">overrideWith&lt;/span>&lt;span class="p">(()&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="n">LibraryDisplayViewModelForTest&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">LibraryDisplayState&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>
&lt;h2 id="形態一重新發明框架已有的輪子">形態一：重新發明框架已有的輪子&lt;/h2>
&lt;p>整套 Mock Provider 架構要解的問題——「測試時把真實依賴換成受控替身」——Riverpod 內建的 &lt;code>overrideWith&lt;/code> 一行就是官方解。389 行的 Mock 架構等於是在框架旁邊蓋了一座平行的依賴注入系統，每個新測試都得學它、每次框架升級它都可能斷。&lt;/p>
&lt;p>寫測試工具前的第一個檢查點就是這條：&lt;strong>先問「這個框架 / 生態的標準做法是什麼」、再問「標準做法哪裡不夠」&lt;/strong>。找不出第二題的答案，自建工具的每一行都是純負債——它不是產品碼、卻要跟產品碼一樣被維護。&lt;/p>
&lt;h2 id="形態二解決不存在的問題">形態二：解決不存在的問題&lt;/h2>
&lt;p>388 行的多語系測試 helper 是精緻度的巔峰：Mutex 狀態鎖、記憶體洩漏防護機制、三類版面溢位檢測（RenderFlex、文字截斷、螢幕邊界）。每個機能單獨看都「很完備」——但當時的 Widget 測試需求是「讓測試能編譯、能跑」，多語系切換測試根本還不在任何 use case 裡。重構記錄的判語：「解決一個不存在的問題」。&lt;/p>
&lt;p>這個形態跟&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_async_query_overdesign_oscillation/" data-link-title="同一個子系統膨脹兩次：異步查詢系統的過度設計震盪" data-link-desc="過度設計會復發、且兩輪的機制不同：設計期的膨脹來自想像的需求（別層已處理的重試、用不到的優先級佇列），迭代期的膨脹來自不刪的舊版本（三個實作並存、狀態多處追蹤）。偽需求的檢驗法是問「這個能力已經有別層在做嗎」。">異步查詢系統的第一輪膨脹&lt;/a>同構——設計期用「系統該有的完備性」取代「操作需要的能力」。測試工具的完備性想像還更容易失控，因為它不受產品需求審查：沒有 PM 會問「為什麼測試 helper 需要 Mutex」。&lt;/p>
&lt;h2 id="形態三mock-了不需要-mock-的東西放在不該放的地方">形態三：mock 了不需要 mock 的東西、放在不該放的地方&lt;/h2>
&lt;p>兩個架構層級的錯：&lt;/p>
&lt;p>&lt;strong>&lt;code>MockBook&lt;/code> 取代真 domain entity。&lt;/strong> mock 的正當對象是有副作用、慢、或不可控的依賴（網路、資料庫、時間）；&lt;code>Book&lt;/code> 是純資料物件、建構它比 mock 它便宜——真 entity 直接用就好。mock 純物件的代價是雙重維護：entity 加欄位、MockBook 也要加，兩者漂移時測試守的是一個不存在的形狀。&lt;/p>
&lt;p>&lt;strong>mock 放在 &lt;code>lib/&lt;/code> 而不是 &lt;code>test/&lt;/code>。&lt;/strong> 測試碼進了生產依賴圖——而且這不是手滑，Phase 1 設計文件把 &lt;code>test/ → lib/mocks/ → lib/core/&lt;/code> 畫成正式的單向依賴規範。方向本身「乾淨」，但前提就錯了：&lt;code>lib/&lt;/code> 的語意是「會被打包進 App 的程式碼」，mock 不屬於那裡。&lt;/p>
&lt;h2 id="精緻的設計文件不是價值證明">精緻的設計文件不是價值證明&lt;/h2>
&lt;p>這個 case 最值得記的一點：被刪掉的系統有 783 行設計文件、有架構圖、有依賴方向規範、有驗收條件——&lt;strong>整個設計流程認真地規劃了一個不需要存在的東西&lt;/strong>。精緻度讓它更難殺：看起來像紮實的工程產出，審查者要先推翻「這麼認真的東西應該有價值」的直覺才下得了手。&lt;/p>
&lt;p>同專案的另一個記錄（同步 domain 架構評分 A- 但不能編譯）是同一課的另一面：&lt;strong>評價「做得好不好」之前，先評價「該不該做」&lt;/strong>。Phase 4 重構記錄把這個順序寫成了方法論建議——「優先考慮刪除：先問『這個真的需要嗎？』再問『如何改善？』」。&lt;/p>
&lt;h2 id="判讀徵兆">判讀徵兆&lt;/h2>
&lt;ul>
&lt;li>mock 類別 mock 的是純資料物件（entity / value object）——真物件更便宜、直接用&lt;/li>
&lt;li>測試 helper 出現 framework 級機能（並發鎖、洩漏防護、自製 logger）——問是哪個測試需求逼出來的、指不出來就是完備性想像&lt;/li>
&lt;li>&lt;code>lib/&lt;/code> 底下出現 &lt;code>mocks/&lt;/code> 或 &lt;code>test&lt;/code> 字樣的目錄——測試碼在生產依賴圖裡&lt;/li>
&lt;li>helper 的行數超過用它的測試——工具比問題大&lt;/li>
&lt;li>「用這套工具寫測試」需要先讀文件——標準模式的優勢正是新人零學習成本&lt;/li>
&lt;/ul>
&lt;h2 id="相關閱讀">相關閱讀&lt;/h2>
&lt;ul>
&lt;li>同機制的產品側版本：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_async_query_overdesign_oscillation/" data-link-title="同一個子系統膨脹兩次：異步查詢系統的過度設計震盪" data-link-desc="過度設計會復發、且兩輪的機制不同：設計期的膨脹來自想像的需求（別層已處理的重試、用不到的優先級佇列），迭代期的膨脹來自不刪的舊版本（三個實作並存、狀態多處追蹤）。偽需求的檢驗法是問「這個能力已經有別層在做嗎」。">異步查詢系統的過度設計震盪&lt;/a>——設計期完備性想像的兩個現場&lt;/li>
&lt;li>mock 該放哪、多少才夠：&lt;a href="https://tarrragon.github.io/blog/work-log/testing_three_layer_strategy/" data-link-title="192 個測試全過、實機全壞：Mock 遮蔽真實行為的三層測試策略" data-link-desc="unit test 全綠、實機部署後功能整片壞掉。mock-only 策略的結構盲區（text vs binary frame、缺 auth handshake、ANSI 多樣性被 FakeWebSocketChannel 遮蔽），以及分層測試各抓什麼、各遮蔽什麼。">192 個測試全過、實機全壞&lt;/a>——那篇談 mock 過多遮蔽真實、本文談 mock 系統本身過重，同一個「mock 是手段不是資產」的兩面&lt;/li>
&lt;li>原則層：&lt;a href="https://tarrragon.github.io/blog/report/decide-later-as-valid-option/" data-link-title="「現在不決定」是合法選項：context 不足時延後決策" data-link-desc="被問到時不一定要立刻答 — 「先補 context、回頭再決」是合法選項、卻常被當「拖延」忽略。LLM / agent 預設「問了就要立刻答」是錯誤前提：使用者有權延後到 context 補齊、推薦時應主動標出「也可選『先 X 再回來決』」。本卡是 #58 篩選三問、#74 決策呈現的時間軸延伸。">#77「現在不決定」是合法選項&lt;/a>——多語系測試工具的正確處置是等需求出現、不是先蓋起來放&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：Flutter 書籍管理 App 的 Widget 測試基礎設施，設計階段產出 783 行的完整規格、實作 1101 行；下一個 Phase 的重構審查判定整套重做，刪到剩 193 行（-82.5%）、其中核心 helper 62 行
<strong>疑問來源</strong>：一套經過完整設計流程、有架構圖有依賴規範的基礎設施，為什麼是該刪的？審查依據是什麼？
<strong>整理目的</strong>：把這次刪除拆成三種可辨識的過度工程形態、以及「寫測試工具之前」的檢查點
<strong>本文邊界</strong>：素材是該專案 v0.7.0 的 Phase 1 設計文件與 Phase 4 重構記錄——同一個東西的誕生與死亡、對照完整</p></blockquote>
<hr>
<h2 id="被刪掉的是什麼">被刪掉的是什麼</h2>
<p>四個檔案、一個目錄：</p>
<table>
  <thead>
      <tr>
          <th>檔案</th>
          <th>行數</th>
          <th>職責</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>lib/mocks/widget_test_mocks.dart</code></td>
          <td>389</td>
          <td>Mock 架構（含 <code>MockBook</code>、Mock Provider 群）</td>
      </tr>
      <tr>
          <td><code>lib/helpers/multi_language_widget_test_helper.dart</code></td>
          <td>388</td>
          <td>多語系測試工具（Mutex 狀態鎖、記憶體洩漏防護、三類溢位檢測）</td>
      </tr>
      <tr>
          <td><code>lib/helpers/widget_test_helper.dart</code></td>
          <td>193</td>
          <td>自建測試環境建立工具</td>
      </tr>
      <tr>
          <td><code>lib/helpers/test_logger.dart</code></td>
          <td>131</td>
          <td>自製測試日誌系統</td>
      </tr>
  </tbody>
</table>
<p>取代它們的是 62 行的 helper 加標準 Riverpod 模式：</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">WidgetTestHelper</span><span class="p">.</span><span class="n">createFullTestApp</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">LibraryDisplayPage</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="p">[</span><span class="n">libraryDisplayViewModelProvider</span><span class="p">.</span><span class="n">overrideWith</span><span class="p">(()</span> <span class="o">=&gt;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">      <span class="n">LibraryDisplayViewModelForTest</span><span class="p">(</span><span class="n">LibraryDisplayState</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>
<h2 id="形態一重新發明框架已有的輪子">形態一：重新發明框架已有的輪子</h2>
<p>整套 Mock Provider 架構要解的問題——「測試時把真實依賴換成受控替身」——Riverpod 內建的 <code>overrideWith</code> 一行就是官方解。389 行的 Mock 架構等於是在框架旁邊蓋了一座平行的依賴注入系統，每個新測試都得學它、每次框架升級它都可能斷。</p>
<p>寫測試工具前的第一個檢查點就是這條：<strong>先問「這個框架 / 生態的標準做法是什麼」、再問「標準做法哪裡不夠」</strong>。找不出第二題的答案，自建工具的每一行都是純負債——它不是產品碼、卻要跟產品碼一樣被維護。</p>
<h2 id="形態二解決不存在的問題">形態二：解決不存在的問題</h2>
<p>388 行的多語系測試 helper 是精緻度的巔峰：Mutex 狀態鎖、記憶體洩漏防護機制、三類版面溢位檢測（RenderFlex、文字截斷、螢幕邊界）。每個機能單獨看都「很完備」——但當時的 Widget 測試需求是「讓測試能編譯、能跑」，多語系切換測試根本還不在任何 use case 裡。重構記錄的判語：「解決一個不存在的問題」。</p>
<p>這個形態跟<a href="/blog/work-log/flutter_async_query_overdesign_oscillation/" data-link-title="同一個子系統膨脹兩次：異步查詢系統的過度設計震盪" data-link-desc="過度設計會復發、且兩輪的機制不同：設計期的膨脹來自想像的需求（別層已處理的重試、用不到的優先級佇列），迭代期的膨脹來自不刪的舊版本（三個實作並存、狀態多處追蹤）。偽需求的檢驗法是問「這個能力已經有別層在做嗎」。">異步查詢系統的第一輪膨脹</a>同構——設計期用「系統該有的完備性」取代「操作需要的能力」。測試工具的完備性想像還更容易失控，因為它不受產品需求審查：沒有 PM 會問「為什麼測試 helper 需要 Mutex」。</p>
<h2 id="形態三mock-了不需要-mock-的東西放在不該放的地方">形態三：mock 了不需要 mock 的東西、放在不該放的地方</h2>
<p>兩個架構層級的錯：</p>
<p><strong><code>MockBook</code> 取代真 domain entity。</strong> mock 的正當對象是有副作用、慢、或不可控的依賴（網路、資料庫、時間）；<code>Book</code> 是純資料物件、建構它比 mock 它便宜——真 entity 直接用就好。mock 純物件的代價是雙重維護：entity 加欄位、MockBook 也要加，兩者漂移時測試守的是一個不存在的形狀。</p>
<p><strong>mock 放在 <code>lib/</code> 而不是 <code>test/</code>。</strong> 測試碼進了生產依賴圖——而且這不是手滑，Phase 1 設計文件把 <code>test/ → lib/mocks/ → lib/core/</code> 畫成正式的單向依賴規範。方向本身「乾淨」，但前提就錯了：<code>lib/</code> 的語意是「會被打包進 App 的程式碼」，mock 不屬於那裡。</p>
<h2 id="精緻的設計文件不是價值證明">精緻的設計文件不是價值證明</h2>
<p>這個 case 最值得記的一點：被刪掉的系統有 783 行設計文件、有架構圖、有依賴方向規範、有驗收條件——<strong>整個設計流程認真地規劃了一個不需要存在的東西</strong>。精緻度讓它更難殺：看起來像紮實的工程產出，審查者要先推翻「這麼認真的東西應該有價值」的直覺才下得了手。</p>
<p>同專案的另一個記錄（同步 domain 架構評分 A- 但不能編譯）是同一課的另一面：<strong>評價「做得好不好」之前，先評價「該不該做」</strong>。Phase 4 重構記錄把這個順序寫成了方法論建議——「優先考慮刪除：先問『這個真的需要嗎？』再問『如何改善？』」。</p>
<h2 id="判讀徵兆">判讀徵兆</h2>
<ul>
<li>mock 類別 mock 的是純資料物件（entity / value object）——真物件更便宜、直接用</li>
<li>測試 helper 出現 framework 級機能（並發鎖、洩漏防護、自製 logger）——問是哪個測試需求逼出來的、指不出來就是完備性想像</li>
<li><code>lib/</code> 底下出現 <code>mocks/</code> 或 <code>test</code> 字樣的目錄——測試碼在生產依賴圖裡</li>
<li>helper 的行數超過用它的測試——工具比問題大</li>
<li>「用這套工具寫測試」需要先讀文件——標準模式的優勢正是新人零學習成本</li>
</ul>
<h2 id="相關閱讀">相關閱讀</h2>
<ul>
<li>同機制的產品側版本：<a href="/blog/work-log/flutter_async_query_overdesign_oscillation/" data-link-title="同一個子系統膨脹兩次：異步查詢系統的過度設計震盪" data-link-desc="過度設計會復發、且兩輪的機制不同：設計期的膨脹來自想像的需求（別層已處理的重試、用不到的優先級佇列），迭代期的膨脹來自不刪的舊版本（三個實作並存、狀態多處追蹤）。偽需求的檢驗法是問「這個能力已經有別層在做嗎」。">異步查詢系統的過度設計震盪</a>——設計期完備性想像的兩個現場</li>
<li>mock 該放哪、多少才夠：<a href="/blog/work-log/testing_three_layer_strategy/" data-link-title="192 個測試全過、實機全壞：Mock 遮蔽真實行為的三層測試策略" data-link-desc="unit test 全綠、實機部署後功能整片壞掉。mock-only 策略的結構盲區（text vs binary frame、缺 auth handshake、ANSI 多樣性被 FakeWebSocketChannel 遮蔽），以及分層測試各抓什麼、各遮蔽什麼。">192 個測試全過、實機全壞</a>——那篇談 mock 過多遮蔽真實、本文談 mock 系統本身過重，同一個「mock 是手段不是資產」的兩面</li>
<li>原則層：<a href="/blog/report/decide-later-as-valid-option/" data-link-title="「現在不決定」是合法選項：context 不足時延後決策" data-link-desc="被問到時不一定要立刻答 — 「先補 context、回頭再決」是合法選項、卻常被當「拖延」忽略。LLM / agent 預設「問了就要立刻答」是錯誤前提：使用者有權延後到 context 補齊、推薦時應主動標出「也可選『先 X 再回來決』」。本卡是 #58 篩選三問、#74 決策呈現的時間軸延伸。">#77「現在不決定」是合法選項</a>——多語系測試工具的正確處置是等需求出現、不是先蓋起來放</li>
</ul>
]]></content:encoded></item><item><title>App 永遠卡在載入畫面 — Riverpod 的 provider 是配方、容器才持有狀態</title><link>https://tarrragon.github.io/blog/work-log/flutter_riverpod_dual_container_state_desync/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_riverpod_dual_container_state_desync/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：Flutter App 啟動後永遠停在載入畫面。初始化的 provider 狀態永遠是 &lt;code>notStarted&lt;/code>、初始化流程從未執行——但初始化邏輯的單元測試是綠的
&lt;strong>疑問來源&lt;/strong>：&lt;code>main()&lt;/code> 裡明明呼叫了 &lt;code>initialize()&lt;/code>，為什麼 UI 看到的狀態完全沒動？
&lt;strong>整理目的&lt;/strong>：記下 Riverpod「provider 宣告全域、狀態屬於容器」的心智模型、以及跨容器操作靜默失效的機制
&lt;strong>本文邊界&lt;/strong>：素材是該專案 v0.10.1 的修復記錄（Riverpod 2.x 時期）；「配方 vs 廚房」的心智模型跨版本成立&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="症狀與現場">症狀與現場&lt;/h2>
&lt;p>出問題的 &lt;code>main()&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">final&lt;/span> &lt;span class="n">container&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ProviderContainer&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">container&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">read&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">appInitializationProvider&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">notifier&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="n">initialize&lt;/span>&lt;span class="p">();&lt;/span> &lt;span class="c1">// 對外部容器操作
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="n">runApp&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ProviderScope&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">child:&lt;/span> &lt;span class="n">BookLibraryApp&lt;/span>&lt;span class="p">()));&lt;/span> &lt;span class="o">//&lt;/span> &lt;span class="n">UI&lt;/span> &lt;span class="err">在另一個容器裡&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>兩行各自都「正確」：&lt;code>initialize()&lt;/code> 真的被呼叫、真的執行完；&lt;code>ProviderScope&lt;/code> 裡的 UI 真的在監聽 &lt;code>appInitializationProvider&lt;/code>。但 UI 的狀態永遠是 &lt;code>notStarted&lt;/code>——因為&lt;strong>它們操作的是兩份不同的狀態&lt;/strong>。&lt;/p>
&lt;h2 id="機制provider-宣告是配方容器持有狀態">機制：provider 宣告是配方、容器持有狀態&lt;/h2>
&lt;p>Riverpod 的 provider 宣告寫在全域：&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">final&lt;/span> &lt;span class="n">appInitializationProvider&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">NotifierProvider&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="p">...&lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="p">(...);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>全域宣告製造了「狀態也是全域」的錯覺。實際上這個全域物件只是&lt;strong>配方&lt;/strong>——描述「這個狀態怎麼建、怎麼變化」。狀態本身活在容器裡：&lt;code>ProviderContainer()&lt;/code> 建一個容器、&lt;code>ProviderScope&lt;/code> 在 widget tree 裡也建一個容器（它內部就是包了一個 container）。同一份配方在兩個容器裡各煮出一份互不相干的狀態。&lt;/p>
&lt;p>於是 &lt;code>container.read(...).initialize()&lt;/code> 改的是外部容器那份狀態——改成功了、沒有任何錯誤；UI 監聽的是 Scope 容器那份——從頭到尾沒人動它。&lt;strong>跨容器操作不會報錯、它只是安靜地作用在你以為之外的地方&lt;/strong>，這是這類 bug 難查的原因：每一段程式碼單獨看都在正常工作。&lt;/p>
&lt;h2 id="修法把觸發點移進唯一的容器">修法：把觸發點移進唯一的容器&lt;/h2>
&lt;p>修復選的方案是移除外部容器、讓初始化在 widget tree 內觸發：&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">// main.dart：只負責 runApp
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kt">void&lt;/span> &lt;span class="n">main&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="n">WidgetsFlutterBinding&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">ensureInitialized&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="n">runApp&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">const&lt;/span> &lt;span class="n">ProviderScope&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">child:&lt;/span> &lt;span class="n">BookLibraryApp&lt;/span>&lt;span class="p">()));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="c1">// _AppWrapper：偵測未初始化、在首幀後觸發
&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="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">initState&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">status&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="n">AppInitializationStatus&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">notStarted&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="n">WidgetsBinding&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">instance&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">addPostFrameCallback&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">_&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">10&lt;/span>&lt;span class="cl"> &lt;span class="n">ref&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">read&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">appInitializationProvider&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">notifier&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="n">initialize&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>兩個細節。&lt;code>addPostFrameCallback&lt;/code> 把觸發延到首幀之後——build 期間直接改 provider 狀態是另一類錯誤（widget tree 建置中修改 provider 會炸）。觸發條件掛在 &lt;code>notStarted&lt;/code> 狀態上，讓「誰負責啟動初始化」有唯一答案：看到未初始化的第一個 wrapper。&lt;/p>
&lt;p>如果情境真的需要在 &lt;code>runApp&lt;/code> 之前操作 provider（例如讀取啟動設定），正解是讓兩邊共用同一個容器——自建的 container 透過 &lt;code>UncontrolledProviderScope&lt;/code> 傳進 widget tree，而不是各建一個。判準收成一句：&lt;strong>一個 App 裡活著的容器數量應該是一、每多一個都要能說出它為什麼必須隔離&lt;/strong>（測試裡的 &lt;code>ProviderContainer(overrides: ...)&lt;/code> 就是正當的隔離）。&lt;/p>
&lt;h2 id="為什麼單元測試沒抓到">為什麼單元測試沒抓到&lt;/h2>
&lt;p>初始化 Notifier 的單元測試是綠的——它驗證「這份配方煮出來的狀態會正確走完初始化」，在測試自己的容器裡完全成立。bug 不在配方、在&lt;strong>佈線&lt;/strong>：兩個容器的存在是 &lt;code>main()&lt;/code> 的組裝問題，單元測試的邊界不含組裝。這類 bug 的守備範圍在整合層——一條「啟動 App、斷言最終離開載入畫面」的 widget 測試就能攔住。修復記錄還留了一個測試環境的絆腳石：初始化流程裡的 &lt;code>Future.delayed&lt;/code> 計時器跟 widget test 的 pending-timer 檢查不相容，這也是當時整合測試缺席的原因之一——計時器類的延遲在可測性上要優先考慮可注入的 clock 或可等待的 Future。&lt;/p>
&lt;h2 id="判讀徵兆">判讀徵兆&lt;/h2>
&lt;ul>
&lt;li>「狀態改了但 UI 沒反應」且雙方程式碼各自看都正確——先數容器：全專案搜 &lt;code>ProviderContainer(&lt;/code>，每一個實例都問「它跟 UI 的 Scope 是同一個嗎」&lt;/li>
&lt;li>&lt;code>main()&lt;/code> 裡出現 &lt;code>ProviderContainer()&lt;/code> 又出現 &lt;code>ProviderScope&lt;/code>——幾乎就是本文的 bug 形態&lt;/li>
&lt;li>provider 狀態「永遠是初始值」——比「狀態錯誤」更指向無人操作這份實例、該查操作方作用在哪個容器&lt;/li>
&lt;/ul>
&lt;h2 id="相關閱讀">相關閱讀&lt;/h2>
&lt;ul>
&lt;li>同屬狀態管理框架的跨界 bug：&lt;a href="https://tarrragon.github.io/blog/work-log/dart_test_getx_cross_file_state_pollution/" data-link-title="Dart test 的跨檔案 GetX 狀態污染：flaky 真因不是 fail 訊息上的那個 test" data-link-desc="`flutter test` 整套跑隨機 fail、單獨跑該 file 卻 100% 過。根因是 dart test runner 同 process 內 GetX state 跨 file 污染，fail 位置看 `&amp;#43;N -1` 累計而非訊息標示的 test。">Dart test 的跨檔案 GetX 狀態污染&lt;/a>——GetX 的問題是全域單例讓狀態「太共享」、Riverpod 這裡是容器隔離讓狀態「太不共享」，兩篇合看是狀態作用域的兩個失敗方向&lt;/li>
&lt;li>靜默失效的同構：&lt;a href="https://tarrragon.github.io/blog/report/lint-scope-must-be-explicit-fact/" data-link-title="檢查規則的作用域要顯式列舉：零 error 可能是沒被檢查" data-link-desc="新增與既有受檢目錄同類的內容目錄時、或工具鏈長期零 error 卻累積出違規時使用。規則的作用域由路徑常數決定、該常數常同時被多個檢查共用，擴作用域會連帶擴語意；作用域是獨立於規則內容的 fact，驗收方式是先確認新規則對已知違規報錯。">#221 檢查規則的作用域要顯式列舉&lt;/a>——作用在錯的作用域、不產生任何錯誤訊號，症狀在遠處浮現&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：Flutter App 啟動後永遠停在載入畫面。初始化的 provider 狀態永遠是 <code>notStarted</code>、初始化流程從未執行——但初始化邏輯的單元測試是綠的
<strong>疑問來源</strong>：<code>main()</code> 裡明明呼叫了 <code>initialize()</code>，為什麼 UI 看到的狀態完全沒動？
<strong>整理目的</strong>：記下 Riverpod「provider 宣告全域、狀態屬於容器」的心智模型、以及跨容器操作靜默失效的機制
<strong>本文邊界</strong>：素材是該專案 v0.10.1 的修復記錄（Riverpod 2.x 時期）；「配方 vs 廚房」的心智模型跨版本成立</p></blockquote>
<hr>
<h2 id="症狀與現場">症狀與現場</h2>
<p>出問題的 <code>main()</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">final</span> <span class="n">container</span> <span class="o">=</span> <span class="n">ProviderContainer</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">container</span><span class="p">.</span><span class="n">read</span><span class="p">(</span><span class="n">appInitializationProvider</span><span class="p">.</span><span class="n">notifier</span><span class="p">).</span><span class="n">initialize</span><span class="p">();</span>  <span class="c1">// 對外部容器操作
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">runApp</span><span class="p">(</span><span class="n">ProviderScope</span><span class="p">(</span><span class="nl">child:</span> <span class="n">BookLibraryApp</span><span class="p">()));</span>                    <span class="o">//</span> <span class="n">UI</span> <span class="err">在另一個容器裡</span></span></span></code></pre></div><p>兩行各自都「正確」：<code>initialize()</code> 真的被呼叫、真的執行完；<code>ProviderScope</code> 裡的 UI 真的在監聽 <code>appInitializationProvider</code>。但 UI 的狀態永遠是 <code>notStarted</code>——因為<strong>它們操作的是兩份不同的狀態</strong>。</p>
<h2 id="機制provider-宣告是配方容器持有狀態">機制：provider 宣告是配方、容器持有狀態</h2>
<p>Riverpod 的 provider 宣告寫在全域：</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">appInitializationProvider</span> <span class="o">=</span> <span class="n">NotifierProvider</span><span class="o">&lt;</span><span class="p">...</span><span class="o">&gt;</span><span class="p">(...);</span></span></span></code></pre></div><p>全域宣告製造了「狀態也是全域」的錯覺。實際上這個全域物件只是<strong>配方</strong>——描述「這個狀態怎麼建、怎麼變化」。狀態本身活在容器裡：<code>ProviderContainer()</code> 建一個容器、<code>ProviderScope</code> 在 widget tree 裡也建一個容器（它內部就是包了一個 container）。同一份配方在兩個容器裡各煮出一份互不相干的狀態。</p>
<p>於是 <code>container.read(...).initialize()</code> 改的是外部容器那份狀態——改成功了、沒有任何錯誤；UI 監聽的是 Scope 容器那份——從頭到尾沒人動它。<strong>跨容器操作不會報錯、它只是安靜地作用在你以為之外的地方</strong>，這是這類 bug 難查的原因：每一段程式碼單獨看都在正常工作。</p>
<h2 id="修法把觸發點移進唯一的容器">修法：把觸發點移進唯一的容器</h2>
<p>修復選的方案是移除外部容器、讓初始化在 widget tree 內觸發：</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">// main.dart：只負責 runApp
</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">main</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="n">WidgetsFlutterBinding</span><span class="p">.</span><span class="n">ensureInitialized</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="n">runApp</span><span class="p">(</span><span class="kd">const</span> <span class="n">ProviderScope</span><span class="p">(</span><span class="nl">child:</span> <span class="n">BookLibraryApp</span><span class="p">()));</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1">// _AppWrapper：偵測未初始化、在首幀後觸發
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span><span class="k">if</span> <span class="p">(</span><span class="n">initState</span><span class="p">.</span><span class="n">status</span> <span class="o">==</span> <span class="n">AppInitializationStatus</span><span class="p">.</span><span class="n">notStarted</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="n">WidgetsBinding</span><span class="p">.</span><span class="n">instance</span><span class="p">.</span><span class="n">addPostFrameCallback</span><span class="p">((</span><span class="n">_</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="n">ref</span><span class="p">.</span><span class="n">read</span><span class="p">(</span><span class="n">appInitializationProvider</span><span class="p">.</span><span class="n">notifier</span><span class="p">).</span><span class="n">initialize</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></span></code></pre></div><p>兩個細節。<code>addPostFrameCallback</code> 把觸發延到首幀之後——build 期間直接改 provider 狀態是另一類錯誤（widget tree 建置中修改 provider 會炸）。觸發條件掛在 <code>notStarted</code> 狀態上，讓「誰負責啟動初始化」有唯一答案：看到未初始化的第一個 wrapper。</p>
<p>如果情境真的需要在 <code>runApp</code> 之前操作 provider（例如讀取啟動設定），正解是讓兩邊共用同一個容器——自建的 container 透過 <code>UncontrolledProviderScope</code> 傳進 widget tree，而不是各建一個。判準收成一句：<strong>一個 App 裡活著的容器數量應該是一、每多一個都要能說出它為什麼必須隔離</strong>（測試裡的 <code>ProviderContainer(overrides: ...)</code> 就是正當的隔離）。</p>
<h2 id="為什麼單元測試沒抓到">為什麼單元測試沒抓到</h2>
<p>初始化 Notifier 的單元測試是綠的——它驗證「這份配方煮出來的狀態會正確走完初始化」，在測試自己的容器裡完全成立。bug 不在配方、在<strong>佈線</strong>：兩個容器的存在是 <code>main()</code> 的組裝問題，單元測試的邊界不含組裝。這類 bug 的守備範圍在整合層——一條「啟動 App、斷言最終離開載入畫面」的 widget 測試就能攔住。修復記錄還留了一個測試環境的絆腳石：初始化流程裡的 <code>Future.delayed</code> 計時器跟 widget test 的 pending-timer 檢查不相容，這也是當時整合測試缺席的原因之一——計時器類的延遲在可測性上要優先考慮可注入的 clock 或可等待的 Future。</p>
<h2 id="判讀徵兆">判讀徵兆</h2>
<ul>
<li>「狀態改了但 UI 沒反應」且雙方程式碼各自看都正確——先數容器：全專案搜 <code>ProviderContainer(</code>，每一個實例都問「它跟 UI 的 Scope 是同一個嗎」</li>
<li><code>main()</code> 裡出現 <code>ProviderContainer()</code> 又出現 <code>ProviderScope</code>——幾乎就是本文的 bug 形態</li>
<li>provider 狀態「永遠是初始值」——比「狀態錯誤」更指向無人操作這份實例、該查操作方作用在哪個容器</li>
</ul>
<h2 id="相關閱讀">相關閱讀</h2>
<ul>
<li>同屬狀態管理框架的跨界 bug：<a href="/blog/work-log/dart_test_getx_cross_file_state_pollution/" data-link-title="Dart test 的跨檔案 GetX 狀態污染：flaky 真因不是 fail 訊息上的那個 test" data-link-desc="`flutter test` 整套跑隨機 fail、單獨跑該 file 卻 100% 過。根因是 dart test runner 同 process 內 GetX state 跨 file 污染，fail 位置看 `&#43;N -1` 累計而非訊息標示的 test。">Dart test 的跨檔案 GetX 狀態污染</a>——GetX 的問題是全域單例讓狀態「太共享」、Riverpod 這裡是容器隔離讓狀態「太不共享」，兩篇合看是狀態作用域的兩個失敗方向</li>
<li>靜默失效的同構：<a href="/blog/report/lint-scope-must-be-explicit-fact/" data-link-title="檢查規則的作用域要顯式列舉：零 error 可能是沒被檢查" data-link-desc="新增與既有受檢目錄同類的內容目錄時、或工具鏈長期零 error 卻累積出違規時使用。規則的作用域由路徑常數決定、該常數常同時被多個檢查共用，擴作用域會連帶擴語意；作用域是獨立於規則內容的 fact，驗收方式是先確認新規則對已知違規報錯。">#221 檢查規則的作用域要顯式列舉</a>——作用在錯的作用域、不產生任何錯誤訊號，症狀在遠處浮現</li>
</ul>
]]></content:encoded></item><item><title>await 回來的時候、頁面已經關了 — UnmountedRefException 與 16 個不抽象的檢查點</title><link>https://tarrragon.github.io/blog/work-log/flutter_unmounted_ref_async_gap/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_unmounted_ref_async_gap/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：Flutter 書籍管理 App 的批量匯入——一條會跑很久的 async 流程。使用者中途離開頁面，流程裡下一個 &lt;code>state = ...&lt;/code> 拋出 &lt;code>UnmountedRefException&lt;/code>：ViewModel 已經被 dispose、&lt;code>ref&lt;/code> 不能再用
&lt;strong>疑問來源&lt;/strong>：修復加了 16 個 &lt;code>if (!ref.mounted) return;&lt;/code>——這麼多重複的檢查、不該抽成一個 helper 嗎？重構評估的答案是不該，為什麼？
&lt;strong>整理目的&lt;/strong>：記下 async gap 的生命週期機制、檢查點的擺放規則、以及「重複但刻意不抽象」的判斷
&lt;strong>本文邊界&lt;/strong>：素材是該專案 v0.25.1 的修復與 Phase 4 重構評估記錄；&lt;code>ref.mounted&lt;/code> 是 Riverpod 的 API、「await 前後是兩個世界」的機制跨框架成立&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="機制await-前後是兩個世界">機制：await 前後是兩個世界&lt;/h2>
&lt;p>同步程式碼裡「this 活著」是全程成立的前提；async 函式裡這個前提&lt;strong>在每個 &lt;code>await&lt;/code> 處斷開一次&lt;/strong>。批量匯入的流程長這樣：逐本書處理、每本有儲存的 await、批次間有讓出控制權的 await——每一個 await 都把控制權交還 event loop，而 event loop 上可能正排著「使用者按了返回、頁面 dispose、Notifier 跟著 dispose」。&lt;/p>
&lt;p>await 回來之後的程式碼，跑在一個「自己可能已經死了」的世界裡。讀區域變數沒事、但碰 &lt;code>ref&lt;/code>（寫 state、讀 provider）就是對已釋放資源的操作——Riverpod 用 &lt;code>UnmountedRefException&lt;/code> 把這個錯誤變成显式的炸點（比靜默寫入無人監聽的狀態誠實）。修法就是在每次 await 之後、要碰 &lt;code>ref&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">await&lt;/span> &lt;span class="n">_saveBook&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">book&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">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="n">ref&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">mounted&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">return&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// await 後、碰 ref 前
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="n">state&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">state&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">processedBooks:&lt;/span> &lt;span class="n">progress&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">processed&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>檢查點的擺放規則可以機械化：&lt;strong>每個 await 之後、第一次碰 &lt;code>ref&lt;/code> 之前&lt;/strong>。這次修復落了 16 個檢查點——對應這條流程的 16 個 async gap，漏任何一個就留下一條「使用者恰好在那個瞬間離開」的崩潰路徑。&lt;/p>
&lt;h2 id="刻意不抽象重複但每個重複都有座標">刻意不抽象：重複、但每個重複都有座標&lt;/h2>
&lt;p>16 個一模一樣的 &lt;code>if (!ref.mounted) return;&lt;/code> 天然引來「抽成 helper」的重構衝動。Phase 4 評估把它列為候選、然後&lt;strong>否決&lt;/strong>，理由三條照錄：&lt;/p>
&lt;blockquote>
&lt;ol>
&lt;li>抽取為 helper 會增加複雜度；2. 明確的檢查點有助於程式碼審查；3. 這是 Flutter 社群的推薦實踐。&lt;/li>
&lt;/ol>&lt;/blockquote>
&lt;p>第二條是核心。這種 guard 的價值不在「做了什麼」（一行 return）、在「&lt;strong>它在哪&lt;/strong>」——review 一段 async 流程時，審查的問題是「每個 gap 之後有沒有守」，明確的檢查點讓這件事用眼睛掃就能驗證；包進 helper（或用 zone、攔截器之類的魔法）之後，「哪個 gap 有守」變成要追實作才知道。重複的成本（16 行樣板）換明確性的收益，這筆帳在 guard 這類「位置即語意」的程式碼上是划算的——跟 &lt;a href="https://tarrragon.github.io/blog/work-log/flutter_duplicate_service_fake_coverage/" data-link-title="兩個 domain 各自實作同一個 API service — 100% 覆蓋率的假象" data-link-desc="同名 service 在多個 domain 各自實作時，覆蓋率數字會失去意義：每份實作各測各的、mock 各有介面，統一的行為從未被測過。重複實作是上游訊號——規劃文件沒抽出跨 domain 的共同技術需求；單檔品質審查看不到跨檔重複。">DRY 在別處的正確性&lt;/a>不矛盾：那裡重複的是&lt;strong>行為&lt;/strong>（兩份 API 實作會分歧）、這裡重複的是&lt;strong>哨位&lt;/strong>（每個位置本來就該有一個）。&lt;/p>
&lt;p>第三條也值得停一秒：社群標準模式的地位跟 &lt;a href="https://tarrragon.github.io/blog/work-log/flutter_mock_infrastructure_overengineering_deleted/" data-link-title="1101 行自建測試基礎設施、重構刪掉 82.5% — 過度工程的三種形態" data-link-desc="自建測試基礎設施前先問框架的標準做法是什麼：mock 純資料物件、helper 帶並發鎖與記憶體洩漏防護、mock 放進 lib/ 進生產依賴圖，三種形態都在重新發明 Riverpod overrideWith 一行就有的東西。精緻的設計文件不是價值證明——它可以精心規劃一個不需要存在的系統。">mock 基礎設施那篇&lt;/a>的教訓相通——生態已收斂的做法、自建「更優雅」的抽象前先想清楚要解的是誰的問題。&lt;/p>
&lt;h2 id="評估必跑可決定不重構">評估必跑、可決定不重構&lt;/h2>
&lt;p>這份記錄的第二個看點是流程形態。Phase 4 重構評估&lt;strong>必須執行&lt;/strong>、但結論可以是「不重構」：本次評分 B、識別出兩筆技術債（88 行的 &lt;code>_executeImport&lt;/code>、重複五次的錯誤處理模式）、決策是「品質達標、債務記錄、排入下個重構週期」。&lt;/p>
&lt;p>這個形態把兩件常被混在一起的事分開了：&lt;strong>評估的義務&lt;/strong>（每次改動後都要看一眼品質）跟&lt;strong>執行的判斷&lt;/strong>（現在修還是排程修）。混在一起的版本要嘛「評估完就得修」（每張票膨脹）、要嘛「不修就不評估」（債務靜默累積）。而這裡的技術債記錄不是垃圾桶——TD-012（88 行函式）在下一個版本真的被清償了、清償的過程就是&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_function_decomposition_split_vs_keep/" data-link-title="88 行拆成 13 個函式、90 行決定不拆 — 函式長度是症狀、職責才是診斷" data-link-desc="同一個團隊、同一條 5-10 行規範、兩個 90 行上下的函式，一個拆一個保留、兩個決定都對。判準在行數之外：職責混雜（初始化/迴圈/儲存/統計擠一起、巢狀五層）拆之，完整業務流程（步驟多但答案只有一個）留之。含 _ImportProgress 收斂參數列與測試耦合行為的守護。">函式分解那篇&lt;/a>的拆分 case。從識別、記錄、排程到清償的完整鏈條，是「記錄技術債」這個動作有意義的前提。&lt;/p>
&lt;h2 id="判讀徵兆">判讀徵兆&lt;/h2>
&lt;ul>
&lt;li>崩潰 stack 指向 async 函式裡 await 之後的 state 寫入 / ref 操作——async gap 沒守，順著函式把每個 await 之後補檢查&lt;/li>
&lt;li>流程越長、頁面越可離開（匯入、同步、批次處理），gap 風險越高——這類 ViewModel 寫完先數 await、對照數 mounted 檢查&lt;/li>
&lt;li>想把 guard 抽成 helper——先問「review 時需不需要看見每個哨位」；位置即語意的程式碼、明確勝過 DRY&lt;/li>
&lt;li>重構評估產出「不重構」卻沒有債務記錄——評估白跑了；「不修」的合法形式是「記錄 + 排程」&lt;/li>
&lt;/ul>
&lt;h2 id="相關閱讀">相關閱讀&lt;/h2>
&lt;ul>
&lt;li>債務清償的下文：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_function_decomposition_split_vs_keep/" data-link-title="88 行拆成 13 個函式、90 行決定不拆 — 函式長度是症狀、職責才是診斷" data-link-desc="同一個團隊、同一條 5-10 行規範、兩個 90 行上下的函式，一個拆一個保留、兩個決定都對。判準在行數之外：職責混雜（初始化/迴圈/儲存/統計擠一起、巢狀五層）拆之，完整業務流程（步驟多但答案只有一個）留之。含 _ImportProgress 收斂參數列與測試耦合行為的守護。">88 行拆成 13 個函式&lt;/a>——本文識別的 TD-012 在下個版本的清償實錄&lt;/li>
&lt;li>同框架的生命週期家族：&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 宣告只是配方、狀態屬於容器實例；跨容器操作是靜默的無效操作。">雙容器狀態脫節&lt;/a>——那篇是容器的空間邊界、本文是 Notifier 的時間邊界&lt;/li>
&lt;li>build 階段的對偶：全域錯誤處理器在 widget tree 建置中改 provider 的崩潰（v0.9 的 microtask 延後修法）——await 之後太晚、build 之中太早，&lt;code>ref&lt;/code> 的合法視窗兩頭都有界&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：Flutter 書籍管理 App 的批量匯入——一條會跑很久的 async 流程。使用者中途離開頁面，流程裡下一個 <code>state = ...</code> 拋出 <code>UnmountedRefException</code>：ViewModel 已經被 dispose、<code>ref</code> 不能再用
<strong>疑問來源</strong>：修復加了 16 個 <code>if (!ref.mounted) return;</code>——這麼多重複的檢查、不該抽成一個 helper 嗎？重構評估的答案是不該，為什麼？
<strong>整理目的</strong>：記下 async gap 的生命週期機制、檢查點的擺放規則、以及「重複但刻意不抽象」的判斷
<strong>本文邊界</strong>：素材是該專案 v0.25.1 的修復與 Phase 4 重構評估記錄；<code>ref.mounted</code> 是 Riverpod 的 API、「await 前後是兩個世界」的機制跨框架成立</p></blockquote>
<hr>
<h2 id="機制await-前後是兩個世界">機制：await 前後是兩個世界</h2>
<p>同步程式碼裡「this 活著」是全程成立的前提；async 函式裡這個前提<strong>在每個 <code>await</code> 處斷開一次</strong>。批量匯入的流程長這樣：逐本書處理、每本有儲存的 await、批次間有讓出控制權的 await——每一個 await 都把控制權交還 event loop，而 event loop 上可能正排著「使用者按了返回、頁面 dispose、Notifier 跟著 dispose」。</p>
<p>await 回來之後的程式碼，跑在一個「自己可能已經死了」的世界裡。讀區域變數沒事、但碰 <code>ref</code>（寫 state、讀 provider）就是對已釋放資源的操作——Riverpod 用 <code>UnmountedRefException</code> 把這個錯誤變成显式的炸點（比靜默寫入無人監聽的狀態誠實）。修法就是在每次 await 之後、要碰 <code>ref</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">await</span> <span class="n">_saveBook</span><span class="p">(</span><span class="n">book</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">ref</span><span class="p">.</span><span class="n">mounted</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>          <span class="c1">// await 後、碰 ref 前
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="n">state</span> <span class="o">=</span> <span class="n">state</span><span class="p">.</span><span class="n">copyWith</span><span class="p">(</span><span class="nl">processedBooks:</span> <span class="n">progress</span><span class="p">.</span><span class="n">processed</span><span class="p">);</span></span></span></code></pre></div><p>檢查點的擺放規則可以機械化：<strong>每個 await 之後、第一次碰 <code>ref</code> 之前</strong>。這次修復落了 16 個檢查點——對應這條流程的 16 個 async gap，漏任何一個就留下一條「使用者恰好在那個瞬間離開」的崩潰路徑。</p>
<h2 id="刻意不抽象重複但每個重複都有座標">刻意不抽象：重複、但每個重複都有座標</h2>
<p>16 個一模一樣的 <code>if (!ref.mounted) return;</code> 天然引來「抽成 helper」的重構衝動。Phase 4 評估把它列為候選、然後<strong>否決</strong>，理由三條照錄：</p>
<blockquote>
<ol>
<li>抽取為 helper 會增加複雜度；2. 明確的檢查點有助於程式碼審查；3. 這是 Flutter 社群的推薦實踐。</li>
</ol></blockquote>
<p>第二條是核心。這種 guard 的價值不在「做了什麼」（一行 return）、在「<strong>它在哪</strong>」——review 一段 async 流程時，審查的問題是「每個 gap 之後有沒有守」，明確的檢查點讓這件事用眼睛掃就能驗證；包進 helper（或用 zone、攔截器之類的魔法）之後，「哪個 gap 有守」變成要追實作才知道。重複的成本（16 行樣板）換明確性的收益，這筆帳在 guard 這類「位置即語意」的程式碼上是划算的——跟 <a href="/blog/work-log/flutter_duplicate_service_fake_coverage/" data-link-title="兩個 domain 各自實作同一個 API service — 100% 覆蓋率的假象" data-link-desc="同名 service 在多個 domain 各自實作時，覆蓋率數字會失去意義：每份實作各測各的、mock 各有介面，統一的行為從未被測過。重複實作是上游訊號——規劃文件沒抽出跨 domain 的共同技術需求；單檔品質審查看不到跨檔重複。">DRY 在別處的正確性</a>不矛盾：那裡重複的是<strong>行為</strong>（兩份 API 實作會分歧）、這裡重複的是<strong>哨位</strong>（每個位置本來就該有一個）。</p>
<p>第三條也值得停一秒：社群標準模式的地位跟 <a href="/blog/work-log/flutter_mock_infrastructure_overengineering_deleted/" data-link-title="1101 行自建測試基礎設施、重構刪掉 82.5% — 過度工程的三種形態" data-link-desc="自建測試基礎設施前先問框架的標準做法是什麼：mock 純資料物件、helper 帶並發鎖與記憶體洩漏防護、mock 放進 lib/ 進生產依賴圖，三種形態都在重新發明 Riverpod overrideWith 一行就有的東西。精緻的設計文件不是價值證明——它可以精心規劃一個不需要存在的系統。">mock 基礎設施那篇</a>的教訓相通——生態已收斂的做法、自建「更優雅」的抽象前先想清楚要解的是誰的問題。</p>
<h2 id="評估必跑可決定不重構">評估必跑、可決定不重構</h2>
<p>這份記錄的第二個看點是流程形態。Phase 4 重構評估<strong>必須執行</strong>、但結論可以是「不重構」：本次評分 B、識別出兩筆技術債（88 行的 <code>_executeImport</code>、重複五次的錯誤處理模式）、決策是「品質達標、債務記錄、排入下個重構週期」。</p>
<p>這個形態把兩件常被混在一起的事分開了：<strong>評估的義務</strong>（每次改動後都要看一眼品質）跟<strong>執行的判斷</strong>（現在修還是排程修）。混在一起的版本要嘛「評估完就得修」（每張票膨脹）、要嘛「不修就不評估」（債務靜默累積）。而這裡的技術債記錄不是垃圾桶——TD-012（88 行函式）在下一個版本真的被清償了、清償的過程就是<a href="/blog/work-log/flutter_function_decomposition_split_vs_keep/" data-link-title="88 行拆成 13 個函式、90 行決定不拆 — 函式長度是症狀、職責才是診斷" data-link-desc="同一個團隊、同一條 5-10 行規範、兩個 90 行上下的函式，一個拆一個保留、兩個決定都對。判準在行數之外：職責混雜（初始化/迴圈/儲存/統計擠一起、巢狀五層）拆之，完整業務流程（步驟多但答案只有一個）留之。含 _ImportProgress 收斂參數列與測試耦合行為的守護。">函式分解那篇</a>的拆分 case。從識別、記錄、排程到清償的完整鏈條，是「記錄技術債」這個動作有意義的前提。</p>
<h2 id="判讀徵兆">判讀徵兆</h2>
<ul>
<li>崩潰 stack 指向 async 函式裡 await 之後的 state 寫入 / ref 操作——async gap 沒守，順著函式把每個 await 之後補檢查</li>
<li>流程越長、頁面越可離開（匯入、同步、批次處理），gap 風險越高——這類 ViewModel 寫完先數 await、對照數 mounted 檢查</li>
<li>想把 guard 抽成 helper——先問「review 時需不需要看見每個哨位」；位置即語意的程式碼、明確勝過 DRY</li>
<li>重構評估產出「不重構」卻沒有債務記錄——評估白跑了；「不修」的合法形式是「記錄 + 排程」</li>
</ul>
<h2 id="相關閱讀">相關閱讀</h2>
<ul>
<li>債務清償的下文：<a href="/blog/work-log/flutter_function_decomposition_split_vs_keep/" data-link-title="88 行拆成 13 個函式、90 行決定不拆 — 函式長度是症狀、職責才是診斷" data-link-desc="同一個團隊、同一條 5-10 行規範、兩個 90 行上下的函式，一個拆一個保留、兩個決定都對。判準在行數之外：職責混雜（初始化/迴圈/儲存/統計擠一起、巢狀五層）拆之，完整業務流程（步驟多但答案只有一個）留之。含 _ImportProgress 收斂參數列與測試耦合行為的守護。">88 行拆成 13 個函式</a>——本文識別的 TD-012 在下個版本的清償實錄</li>
<li>同框架的生命週期家族：<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>——那篇是容器的空間邊界、本文是 Notifier 的時間邊界</li>
<li>build 階段的對偶：全域錯誤處理器在 widget tree 建置中改 provider 的崩潰（v0.9 的 microtask 延後修法）——await 之後太晚、build 之中太早，<code>ref</code> 的合法視窗兩頭都有界</li>
</ul>
]]></content:encoded></item></channel></rss>