<?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>Async on Tarragon</title><link>https://tarrragon.github.io/blog/tags/async/</link><description>Recent content in Async on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Tue, 26 May 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/async/index.xml" rel="self" type="application/rss+xml"/><item><title>寫測試時 sync try-catch 接不到 BotToast 的 async 錯誤：fire-and-forget API 的接管設計</title><link>https://tarrragon.github.io/blog/work-log/%E5%AF%AB%E6%B8%AC%E8%A9%A6%E6%99%82-sync-try-catch-%E6%8E%A5%E4%B8%8D%E5%88%B0-bottoast-%E7%9A%84-async-%E9%8C%AF%E8%AA%A4fire-and-forget-api-%E7%9A%84%E6%8E%A5%E7%AE%A1%E8%A8%AD%E8%A8%88/</link><pubDate>Tue, 26 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/%E5%AF%AB%E6%B8%AC%E8%A9%A6%E6%99%82-sync-try-catch-%E6%8E%A5%E4%B8%8D%E5%88%B0-bottoast-%E7%9A%84-async-%E9%8C%AF%E8%AA%A4fire-and-forget-api-%E7%9A%84%E6%8E%A5%E7%AE%A1%E8%A8%AD%E8%A8%88/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>核心議題&lt;/strong>：寫測試時觸及 type system 看不到的 runtime contract — service locator 的注入契約、widget tree 的 framework state、async error 的 try-catch 邊界。三類都要 runtime 才會炸、test 跑到才會曝光。
&lt;strong>案例骨幹&lt;/strong>：&lt;code>Popup.hint&lt;/code> 同一條呼叫路徑同時持有 sync 與 async 兩條失敗路徑（缺 service 注入、BotToast 同步 assert、BotToast 從 async gap 後拋 &lt;code>LateInitializationError&lt;/code>）。用 &lt;code>runZonedGuarded&lt;/code> 把兩條路徑收斂到同一個 fallback handler、用 fallback signature 設計讓訊息不被誤判為 error。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="1-type-system-看不到的-runtime-contract">1. Type system 看不到的 runtime contract&lt;/h2>
&lt;p>&lt;code>flutter analyze&lt;/code>（與一般的 type checker）的責任是檢查宣告與名稱層的契約 — 型別一致、import 能解析、識別字能對到符號。它驗證的是「靜態可決定的事」：missing import、undefined method、type mismatch 都會在 compile 前被攔下。&lt;/p>
&lt;p>它&lt;strong>看不到&lt;/strong>的是 runtime 才成立的契約，這正是寫測試最容易暴露的盲區：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Service locator 的注入契約&lt;/strong>：GetX 的 &lt;code>Get.find&amp;lt;T&amp;gt;()&lt;/code>、&lt;code>get_it&lt;/code> 的 &lt;code>GetIt.I&amp;lt;T&amp;gt;()&lt;/code>、Provider 的 &lt;code>Provider.of&amp;lt;T&amp;gt;()&lt;/code> 都是 runtime 查找機制（Map lookup 或 widget tree 上溯，視實作而定）。「呼叫前 T 必須先註冊或在 ancestor 提供」是執行期前置條件，型別系統看不見。&lt;/li>
&lt;li>&lt;strong>Framework state 的存在前提&lt;/strong>：BotToast 需要 widget tree 上有 &lt;code>BotToastInit&lt;/code>、Navigator 需要 &lt;code>MaterialApp&lt;/code> 包著。這是 framework 的執行期狀態，不是型別。&lt;/li>
&lt;li>&lt;strong>&lt;code>late&lt;/code> 變數的跨呼叫順序契約&lt;/strong>：宣告對了不代表用對了。analyzer 對單一檔案內某些 unsafe pattern 能出警告，但「A 函式必須在 B 函式前被呼叫」這類跨呼叫順序契約，型別系統看不見。&lt;/li>
&lt;/ul>
&lt;p>這個邊界對「寫測試」的意涵：test setUp 不只是準備資料，更是補上 type system 看不到的 runtime contract — 注入哪些 service、提供哪些 framework state、控制哪些 init 順序。&lt;strong>主程式裡那些「靠 widget tree」「靠 service locator」「靠 framework lifecycle」的契約，每一條都對應到 test setUp 的一個責任&lt;/strong>。&lt;/p>
&lt;hr>
&lt;h2 id="2-案例一條呼叫路徑觸及三類邊界">2. 案例：一條呼叫路徑觸及三類邊界&lt;/h2>
&lt;p>下面以 &lt;code>Popup.hint&lt;/code> 對 &lt;code>BotToast.showNotification&lt;/code> 的呼叫為例。寫一個跑 &lt;code>AuthService.afterLogin&lt;/code> 的 unit test 時，這條呼叫一次觸及 runtime contract 段列的三類邊界：service locator 注入缺失、widget tree 缺 &lt;code>BotToastInit&lt;/code>、&lt;code>late&lt;/code> 變數在 async 排程後讀取。三組訊號攤開：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>訊號&lt;/th>
 &lt;th>性質&lt;/th>
 &lt;th>sync try-catch 能接？&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>&amp;quot;LogService&amp;quot; not found.&lt;/code> 從 &lt;code>Get.find&amp;lt;LogService&amp;gt;()&lt;/code> 拋出&lt;/td>
 &lt;td>同步（service locator 查無注入）&lt;/td>
 &lt;td>能，但這層該補 setUp 而非包 try&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>Failed assertion: '_key.currentState != null'&lt;/code> 在 &lt;code>BotToast.showNotification&lt;/code> 入口&lt;/td>
 &lt;td>同步（widget tree 缺 &lt;code>BotToastInit&lt;/code> 入口 assert）&lt;/td>
 &lt;td>能&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>LateInitializationError: Local 'cancelFunc' has not been initialized.&lt;/code> 出現在 &lt;code>===== asynchronous gap =====&lt;/code> 之後&lt;/td>
 &lt;td>async + 跨呼叫順序契約破裂（&lt;code>late cancelFunc&lt;/code> 預期在某次 init 之後才讀、但 BotToast 排到下一 frame 時順序對不上）&lt;/td>
 &lt;td>不能&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>第一條的修法是 setUp 補注入。第二條的同步 assert 單獨看，sync try-catch 接得住。但它跟第三條 async error 是&lt;strong>同一個 API 的兩種失敗模式&lt;/strong> — 包 sync try-catch 只罩到同步那條、async 那條仍漏。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>核心議題</strong>：寫測試時觸及 type system 看不到的 runtime contract — service locator 的注入契約、widget tree 的 framework state、async error 的 try-catch 邊界。三類都要 runtime 才會炸、test 跑到才會曝光。
<strong>案例骨幹</strong>：<code>Popup.hint</code> 同一條呼叫路徑同時持有 sync 與 async 兩條失敗路徑（缺 service 注入、BotToast 同步 assert、BotToast 從 async gap 後拋 <code>LateInitializationError</code>）。用 <code>runZonedGuarded</code> 把兩條路徑收斂到同一個 fallback handler、用 fallback signature 設計讓訊息不被誤判為 error。</p></blockquote>
<hr>
<h2 id="1-type-system-看不到的-runtime-contract">1. Type system 看不到的 runtime contract</h2>
<p><code>flutter analyze</code>（與一般的 type checker）的責任是檢查宣告與名稱層的契約 — 型別一致、import 能解析、識別字能對到符號。它驗證的是「靜態可決定的事」：missing import、undefined method、type mismatch 都會在 compile 前被攔下。</p>
<p>它<strong>看不到</strong>的是 runtime 才成立的契約，這正是寫測試最容易暴露的盲區：</p>
<ul>
<li><strong>Service locator 的注入契約</strong>：GetX 的 <code>Get.find&lt;T&gt;()</code>、<code>get_it</code> 的 <code>GetIt.I&lt;T&gt;()</code>、Provider 的 <code>Provider.of&lt;T&gt;()</code> 都是 runtime 查找機制（Map lookup 或 widget tree 上溯，視實作而定）。「呼叫前 T 必須先註冊或在 ancestor 提供」是執行期前置條件，型別系統看不見。</li>
<li><strong>Framework state 的存在前提</strong>：BotToast 需要 widget tree 上有 <code>BotToastInit</code>、Navigator 需要 <code>MaterialApp</code> 包著。這是 framework 的執行期狀態，不是型別。</li>
<li><strong><code>late</code> 變數的跨呼叫順序契約</strong>：宣告對了不代表用對了。analyzer 對單一檔案內某些 unsafe pattern 能出警告，但「A 函式必須在 B 函式前被呼叫」這類跨呼叫順序契約，型別系統看不見。</li>
</ul>
<p>這個邊界對「寫測試」的意涵：test setUp 不只是準備資料，更是補上 type system 看不到的 runtime contract — 注入哪些 service、提供哪些 framework state、控制哪些 init 順序。<strong>主程式裡那些「靠 widget tree」「靠 service locator」「靠 framework lifecycle」的契約，每一條都對應到 test setUp 的一個責任</strong>。</p>
<hr>
<h2 id="2-案例一條呼叫路徑觸及三類邊界">2. 案例：一條呼叫路徑觸及三類邊界</h2>
<p>下面以 <code>Popup.hint</code> 對 <code>BotToast.showNotification</code> 的呼叫為例。寫一個跑 <code>AuthService.afterLogin</code> 的 unit test 時，這條呼叫一次觸及 runtime contract 段列的三類邊界：service locator 注入缺失、widget tree 缺 <code>BotToastInit</code>、<code>late</code> 變數在 async 排程後讀取。三組訊號攤開：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>性質</th>
          <th>sync try-catch 能接？</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>&quot;LogService&quot; not found.</code> 從 <code>Get.find&lt;LogService&gt;()</code> 拋出</td>
          <td>同步（service locator 查無注入）</td>
          <td>能，但這層該補 setUp 而非包 try</td>
      </tr>
      <tr>
          <td><code>Failed assertion: '_key.currentState != null'</code> 在 <code>BotToast.showNotification</code> 入口</td>
          <td>同步（widget tree 缺 <code>BotToastInit</code> 入口 assert）</td>
          <td>能</td>
      </tr>
      <tr>
          <td><code>LateInitializationError: Local 'cancelFunc' has not been initialized.</code> 出現在 <code>===== asynchronous gap =====</code> 之後</td>
          <td>async + 跨呼叫順序契約破裂（<code>late cancelFunc</code> 預期在某次 init 之後才讀、但 BotToast 排到下一 frame 時順序對不上）</td>
          <td>不能</td>
      </tr>
  </tbody>
</table>
<p>第一條的修法是 setUp 補注入。第二條的同步 assert 單獨看，sync try-catch 接得住。但它跟第三條 async error 是<strong>同一個 API 的兩種失敗模式</strong> — 包 sync try-catch 只罩到同步那條、async 那條仍漏。</p>
<p>結論：要兩條都接到，需要一個同時 cover sync 與 async 的接管機制。</p>
<hr>
<h2 id="3-sync-try-catch-與-async-error-的邊界">3. Sync try-catch 與 async error 的邊界</h2>
<p>Sync <code>try-catch</code> 的作用範圍是同步調用棧：try block 執行期間棧上拋的錯誤會被接住。一旦執行流程穿越 async 邊界（Future、Timer、microtask 排程），原 try-catch 已經出 scope：</p>





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





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