<?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>Debugging on Tarragon</title><link>https://tarrragon.github.io/blog/tags/debugging/</link><description>Recent content in Debugging on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Thu, 02 Jul 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/debugging/index.xml" rel="self" type="application/rss+xml"/><item><title>診斷心法：讀權威狀態，不靠肉眼猜表象</title><link>https://tarrragon.github.io/blog/linux/debug/diagnosis-read-authoritative-state/</link><pubDate>Thu, 02 Jul 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/linux/debug/diagnosis-read-authoritative-state/</guid><description>&lt;p>診斷一個 Linux 問題時，第一個動作不是猜「這看起來像什麼」，而是問「這件事的權威狀態在哪裡、我怎麼去讀它」。畫面上的現象、終端機捲過的輸出、一個視窗長什麼樣，都是表象；表象會騙人。真正能定案的是系統裡記錄這件事的那個權威來源——程式自己的 log、服務註冊表、核心與 systemd 的狀態、資源用量。把判斷建立在權威狀態上，而不是肉眼看到的樣子，是快速且不猜錯的除錯的核心。&lt;/p>
&lt;p>這篇講的是一套判讀紀律，不是某個特定工具。後面幾篇（&lt;a href="../ssh-and-terminal-troubleshooting/">遠端連線與終端機問題&lt;/a>、&lt;a href="../machine-unreachable/">機器連不到或起不來&lt;/a>、&lt;a href="../process-service-state-diagnosis/">程序、服務與狀態怎麼判&lt;/a>）是這套紀律在各種具體情境的應用。&lt;/p>
&lt;h2 id="表象會騙人一個判斷被畫面帶偏兩次的實例">表象會騙人：一個判斷被畫面帶偏兩次的實例&lt;/h2>
&lt;p>一個具體案例最能說明為什麼不能靠肉眼。在一次桌面 shell（畫桌面 UI 的圖形程式，不是 bash/zsh 那種命令列 shell）的除錯裡，畫面中央出現一個「輸入密碼」的覆蓋層，配著時鐘、天氣、通知的整片儀表板。第一眼的判斷很自然：螢幕被鎖住了。&lt;/p>
&lt;p>接著幾個看似合理的檢查反而把判斷帶得更偏：&lt;code>loginctl&lt;/code> 查不到這個 session 的 &lt;code>LockedHint&lt;/code>、&lt;code>pgrep&lt;/code> 找不到任何獨立的鎖屏程式、那個 shell 的 CLI 也沒有 lock 指令。三個訊號湊起來，得出一個「更正」的結論：這不是真的鎖，只是一個長得像鎖屏的儀表板面板。&lt;/p>
&lt;p>這個「更正」是錯的。真正定案是靠讀那個 shell &lt;strong>自己寫的 log&lt;/strong>：log 裡明明白白有鎖屏模組被載入、有 idle 計時器在數秒數、時間到就觸發鎖定。它是一個真的螢幕鎖，走的是 Wayland 的 session-lock 協議。&lt;/p>
&lt;p>為什麼前面三個檢查會誤導？因為它們讀的是&lt;strong>錯的權威來源&lt;/strong>。&lt;code>loginctl&lt;/code> 的 &lt;code>LockedHint&lt;/code> 是 logind（systemd 的登入管理）那一層的鎖定狀態，而這個鎖走的是 Wayland 合成器（compositor，負責把視窗合成到螢幕、管輸入輸出的核心程式）那一層的協議，兩者是獨立機制——查 logind 對合成器層的鎖天生查不到，不是「沒鎖」，是查錯地方。&lt;code>pgrep&lt;/code> 找不到獨立程式，是因為鎖屏畫面由 shell 主程式在自己的行程內畫，本來就沒有另一個可執行檔可抓。真正記錄「有沒有鎖、為什麼鎖」的權威來源，是那個 shell 的 log；讀到它，一次就定案。&lt;/p>
&lt;p>肉眼加上讀錯層的檢查，猜錯了兩次；讀對權威來源，一次就對。教訓不是「那些工具沒用」，是&lt;strong>要先確認你讀的是不是這件事的權威狀態&lt;/strong>。&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;th>讀它的工具&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>某程式的行為&lt;/td>
 &lt;td>那個程式自己的 log 檔&lt;/td>
 &lt;td>程式的 log 路徑、&lt;code>journalctl -u &amp;lt;服務&amp;gt;&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>服務由誰提供&lt;/td>
 &lt;td>D-Bus / socket 的服務註冊&lt;/td>
 &lt;td>&lt;code>busctl&lt;/code>、&lt;code>ss&lt;/code>、&lt;code>lsof&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>登入 / 鎖定狀態&lt;/td>
 &lt;td>logind&lt;/td>
 &lt;td>&lt;code>loginctl show-session&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>服務有沒有在跑&lt;/td>
 &lt;td>systemd unit 狀態&lt;/td>
 &lt;td>&lt;code>systemctl status&lt;/code>、&lt;code>systemctl is-active&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>程式有沒有活著&lt;/td>
 &lt;td>行程表（比對正確的 comm 名）&lt;/td>
 &lt;td>&lt;code>pgrep -x&lt;/code>、&lt;code>ps&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>網路通不通&lt;/td>
 &lt;td>介面 / 路由 / 鄰居表&lt;/td>
 &lt;td>&lt;code>ip -brief a&lt;/code>、&lt;code>ip neigh&lt;/code>、&lt;code>ss&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>磁碟 / 記憶體&lt;/td>
 &lt;td>檔案系統與記憶體用量&lt;/td>
 &lt;td>&lt;code>df -h&lt;/code>、&lt;code>du -sh&lt;/code>、&lt;code>free&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>核心 / 硬體 / 被殺行程&lt;/td>
 &lt;td>kernel ring buffer&lt;/td>
 &lt;td>&lt;code>dmesg&lt;/code>、&lt;code>journalctl -k&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>程式 log 沉默時的 syscall&lt;/td>
 &lt;td>系統呼叫層&lt;/td>
 &lt;td>&lt;code>strace -f -e trace=file&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這張表的用法不是背它，是養成一個反射：看到現象先問「這件事的權威狀態記在哪張表裡」，再去讀那張表，而不是從畫面推測。下面幾個常見的判錯，都是讀了表象而不是權威來源。&lt;/p>
&lt;h3 id="讀對權威來源但查詢條件要對">讀對權威來源、但查詢條件要對&lt;/h3>
&lt;p>有時權威來源對了，還是會被誤導——因為查詢的條件寫錯。判程式活著沒，行程表是對的權威、&lt;code>pgrep&lt;/code> 是對的工具，但你得比對它&lt;strong>實際的行程名&lt;/strong>：一個程式可能以 symlink 的短名在跑，用你以為的名字 &lt;code>pgrep&lt;/code> 就掃不到、誤判成掛了。判服務由誰提供，權威是服務註冊表而非畫面（送一則通知看畫面有沒有跳不可靠——沒跳可能是勿擾吃掉或根本沒送出）。這兩類的具體查法（&lt;code>pgrep -x&lt;/code>、&lt;code>busctl&lt;/code> 查 D-Bus 擁有者）見 &lt;a href="../process-service-state-diagnosis/">程序、服務與狀態怎麼判&lt;/a>。重點是：權威來源對，還要問對地方、用對條件。&lt;/p>
&lt;h3 id="卡住是資源問題還是相容問題先看資源別先怪相容性">卡住是資源問題還是相容問題：先看資源，別先怪相容性&lt;/h3>
&lt;p>一個耗時的操作中途停住時，很容易直接跳到「是不是這個平台不相容 / 這個東西在這台機器上跑不起來」。但這個結論成本很高（可能讓你放棄一條其實可行的路），而它的權威狀態很好查。一次原始碼編譯跑到一半停住，第一個該看的是資源：&lt;code>df -h&lt;/code> 看磁碟是不是滿了、記憶體是不是被吃光——一次實際的案例就是主機磁碟寫滿把編譯中途打斷，清出空間後同一份原始碼接著編就過，跟平台相容性完全無關。先讀資源狀態排除掉最廉價的解釋，再去懷疑相容性這種昂貴的結論。&lt;/p>
&lt;h2 id="讀程式自己的-log從症狀往上游找">讀程式自己的 log：從症狀往上游找&lt;/h2>
&lt;p>當現象是「某個程式行為不對」，它自己的 log 幾乎總是比終端機捲過的畫面更接近真相。很多程式在終端機只印一段摘要，卻同時把詳細執行紀錄寫進一個 log 檔或系統日誌；當畫面上的訊息不足以定位時，那份 log 裡往往就有明確答案。&lt;/p>
&lt;p>找 log 的常見去處：程式自己的 log 檔（常在 &lt;code>~/.local/state/&amp;lt;程式&amp;gt;/&lt;/code> 或 &lt;code>~/.cache/&amp;lt;程式&amp;gt;/&lt;/code> 底下）、systemd 服務的 &lt;code>journalctl -u &amp;lt;服務名&amp;gt;&lt;/code>、或程式啟動時印出的 log 路徑。找到之後，關鍵是&lt;strong>用症狀當關鍵字往上游搜&lt;/strong>——&lt;code>grep -iE 'error|fail|not found|does not exist' &amp;lt;log&amp;gt;&lt;/code> 挑出異常行，或在 &lt;code>less&lt;/code> 裡用 &lt;code>?pattern&lt;/code> 往回找「第一個」異常（不是停在最後一個下游錯）。一個指令因為前面某個檔案不存在而失敗，終端機可能只報一個看似無關的下游錯誤，但 log 裡會有那句 &lt;code>File does not exist&lt;/code> 直指源頭。一個實際案例：某 shell 換了配色卻沒生效，畫面上什麼錯都沒有，是它的 log 裡一句「讀取 scheme 檔失敗：檔案不存在」點出根因——原來那個檔在 shell 啟動當下還沒被建出來。畫面沉默，log 說話。&lt;/p>
&lt;p>這一層跟 &lt;a href="../../install/observable-bootstrap/">可除錯的 bootstrap&lt;/a> 是一體兩面：那篇談怎麼讓你自己寫的腳本&lt;strong>產生&lt;/strong>一份可診斷的 log，這裡談除錯時怎麼&lt;strong>去讀&lt;/strong>程式自己的 log。兩邊的共同紀律是：不要只盯著終端機捲動，去找那份持久的、詳細的權威紀錄。&lt;/p></description><content:encoded><![CDATA[<p>診斷一個 Linux 問題時，第一個動作不是猜「這看起來像什麼」，而是問「這件事的權威狀態在哪裡、我怎麼去讀它」。畫面上的現象、終端機捲過的輸出、一個視窗長什麼樣，都是表象；表象會騙人。真正能定案的是系統裡記錄這件事的那個權威來源——程式自己的 log、服務註冊表、核心與 systemd 的狀態、資源用量。把判斷建立在權威狀態上，而不是肉眼看到的樣子，是快速且不猜錯的除錯的核心。</p>
<p>這篇講的是一套判讀紀律，不是某個特定工具。後面幾篇（<a href="../ssh-and-terminal-troubleshooting/">遠端連線與終端機問題</a>、<a href="../machine-unreachable/">機器連不到或起不來</a>、<a href="../process-service-state-diagnosis/">程序、服務與狀態怎麼判</a>）是這套紀律在各種具體情境的應用。</p>
<h2 id="表象會騙人一個判斷被畫面帶偏兩次的實例">表象會騙人：一個判斷被畫面帶偏兩次的實例</h2>
<p>一個具體案例最能說明為什麼不能靠肉眼。在一次桌面 shell（畫桌面 UI 的圖形程式，不是 bash/zsh 那種命令列 shell）的除錯裡，畫面中央出現一個「輸入密碼」的覆蓋層，配著時鐘、天氣、通知的整片儀表板。第一眼的判斷很自然：螢幕被鎖住了。</p>
<p>接著幾個看似合理的檢查反而把判斷帶得更偏：<code>loginctl</code> 查不到這個 session 的 <code>LockedHint</code>、<code>pgrep</code> 找不到任何獨立的鎖屏程式、那個 shell 的 CLI 也沒有 lock 指令。三個訊號湊起來，得出一個「更正」的結論：這不是真的鎖，只是一個長得像鎖屏的儀表板面板。</p>
<p>這個「更正」是錯的。真正定案是靠讀那個 shell <strong>自己寫的 log</strong>：log 裡明明白白有鎖屏模組被載入、有 idle 計時器在數秒數、時間到就觸發鎖定。它是一個真的螢幕鎖，走的是 Wayland 的 session-lock 協議。</p>
<p>為什麼前面三個檢查會誤導？因為它們讀的是<strong>錯的權威來源</strong>。<code>loginctl</code> 的 <code>LockedHint</code> 是 logind（systemd 的登入管理）那一層的鎖定狀態，而這個鎖走的是 Wayland 合成器（compositor，負責把視窗合成到螢幕、管輸入輸出的核心程式）那一層的協議，兩者是獨立機制——查 logind 對合成器層的鎖天生查不到，不是「沒鎖」，是查錯地方。<code>pgrep</code> 找不到獨立程式，是因為鎖屏畫面由 shell 主程式在自己的行程內畫，本來就沒有另一個可執行檔可抓。真正記錄「有沒有鎖、為什麼鎖」的權威來源，是那個 shell 的 log；讀到它，一次就定案。</p>
<p>肉眼加上讀錯層的檢查，猜錯了兩次；讀對權威來源，一次就對。教訓不是「那些工具沒用」，是<strong>要先確認你讀的是不是這件事的權威狀態</strong>。</p>
<h2 id="每種問題都有它的權威狀態來源">每種問題都有它的權威狀態來源</h2>
<p>除錯的第一步，是為眼前的現象找到記錄它的權威來源。不同類別的問題，權威來源不同：</p>
<table>
  <thead>
      <tr>
          <th>問題類別</th>
          <th>權威狀態來源</th>
          <th>讀它的工具</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>某程式的行為</td>
          <td>那個程式自己的 log 檔</td>
          <td>程式的 log 路徑、<code>journalctl -u &lt;服務&gt;</code></td>
      </tr>
      <tr>
          <td>服務由誰提供</td>
          <td>D-Bus / socket 的服務註冊</td>
          <td><code>busctl</code>、<code>ss</code>、<code>lsof</code></td>
      </tr>
      <tr>
          <td>登入 / 鎖定狀態</td>
          <td>logind</td>
          <td><code>loginctl show-session</code></td>
      </tr>
      <tr>
          <td>服務有沒有在跑</td>
          <td>systemd unit 狀態</td>
          <td><code>systemctl status</code>、<code>systemctl is-active</code></td>
      </tr>
      <tr>
          <td>程式有沒有活著</td>
          <td>行程表（比對正確的 comm 名）</td>
          <td><code>pgrep -x</code>、<code>ps</code></td>
      </tr>
      <tr>
          <td>網路通不通</td>
          <td>介面 / 路由 / 鄰居表</td>
          <td><code>ip -brief a</code>、<code>ip neigh</code>、<code>ss</code></td>
      </tr>
      <tr>
          <td>磁碟 / 記憶體</td>
          <td>檔案系統與記憶體用量</td>
          <td><code>df -h</code>、<code>du -sh</code>、<code>free</code></td>
      </tr>
      <tr>
          <td>核心 / 硬體 / 被殺行程</td>
          <td>kernel ring buffer</td>
          <td><code>dmesg</code>、<code>journalctl -k</code></td>
      </tr>
      <tr>
          <td>程式 log 沉默時的 syscall</td>
          <td>系統呼叫層</td>
          <td><code>strace -f -e trace=file</code></td>
      </tr>
  </tbody>
</table>
<p>這張表的用法不是背它，是養成一個反射：看到現象先問「這件事的權威狀態記在哪張表裡」，再去讀那張表，而不是從畫面推測。下面幾個常見的判錯，都是讀了表象而不是權威來源。</p>
<h3 id="讀對權威來源但查詢條件要對">讀對權威來源、但查詢條件要對</h3>
<p>有時權威來源對了，還是會被誤導——因為查詢的條件寫錯。判程式活著沒，行程表是對的權威、<code>pgrep</code> 是對的工具，但你得比對它<strong>實際的行程名</strong>：一個程式可能以 symlink 的短名在跑，用你以為的名字 <code>pgrep</code> 就掃不到、誤判成掛了。判服務由誰提供，權威是服務註冊表而非畫面（送一則通知看畫面有沒有跳不可靠——沒跳可能是勿擾吃掉或根本沒送出）。這兩類的具體查法（<code>pgrep -x</code>、<code>busctl</code> 查 D-Bus 擁有者）見 <a href="../process-service-state-diagnosis/">程序、服務與狀態怎麼判</a>。重點是：權威來源對，還要問對地方、用對條件。</p>
<h3 id="卡住是資源問題還是相容問題先看資源別先怪相容性">卡住是資源問題還是相容問題：先看資源，別先怪相容性</h3>
<p>一個耗時的操作中途停住時，很容易直接跳到「是不是這個平台不相容 / 這個東西在這台機器上跑不起來」。但這個結論成本很高（可能讓你放棄一條其實可行的路），而它的權威狀態很好查。一次原始碼編譯跑到一半停住，第一個該看的是資源：<code>df -h</code> 看磁碟是不是滿了、記憶體是不是被吃光——一次實際的案例就是主機磁碟寫滿把編譯中途打斷，清出空間後同一份原始碼接著編就過，跟平台相容性完全無關。先讀資源狀態排除掉最廉價的解釋，再去懷疑相容性這種昂貴的結論。</p>
<h2 id="讀程式自己的-log從症狀往上游找">讀程式自己的 log：從症狀往上游找</h2>
<p>當現象是「某個程式行為不對」，它自己的 log 幾乎總是比終端機捲過的畫面更接近真相。很多程式在終端機只印一段摘要，卻同時把詳細執行紀錄寫進一個 log 檔或系統日誌；當畫面上的訊息不足以定位時，那份 log 裡往往就有明確答案。</p>
<p>找 log 的常見去處：程式自己的 log 檔（常在 <code>~/.local/state/&lt;程式&gt;/</code> 或 <code>~/.cache/&lt;程式&gt;/</code> 底下）、systemd 服務的 <code>journalctl -u &lt;服務名&gt;</code>、或程式啟動時印出的 log 路徑。找到之後，關鍵是<strong>用症狀當關鍵字往上游搜</strong>——<code>grep -iE 'error|fail|not found|does not exist' &lt;log&gt;</code> 挑出異常行，或在 <code>less</code> 裡用 <code>?pattern</code> 往回找「第一個」異常（不是停在最後一個下游錯）。一個指令因為前面某個檔案不存在而失敗，終端機可能只報一個看似無關的下游錯誤，但 log 裡會有那句 <code>File does not exist</code> 直指源頭。一個實際案例：某 shell 換了配色卻沒生效，畫面上什麼錯都沒有，是它的 log 裡一句「讀取 scheme 檔失敗：檔案不存在」點出根因——原來那個檔在 shell 啟動當下還沒被建出來。畫面沉默，log 說話。</p>
<p>這一層跟 <a href="../../install/observable-bootstrap/">可除錯的 bootstrap</a> 是一體兩面：那篇談怎麼讓你自己寫的腳本<strong>產生</strong>一份可診斷的 log，這裡談除錯時怎麼<strong>去讀</strong>程式自己的 log。兩邊的共同紀律是：不要只盯著終端機捲動，去找那份持久的、詳細的權威紀錄。</p>
<h2 id="遠端除錯反而逼出好紀律">遠端除錯反而逼出好紀律</h2>
<p>透過 SSH 遠端除錯時，你看不到那台機器的畫面——這個限制反而是好事。看不到畫面，你就沒得靠肉眼猜，只能去讀權威狀態：查 log、查服務註冊、查行程表、查資源。很多在本地會犯的「看畫面就下結論」的錯，在遠端因為根本沒畫面可看而自動被避開。</p>
<p>反過來說，在本地（或看得到畫面的 VM）除錯時，畫面的存在是個誘惑：它讓你以為看到了就懂了。前面那個鎖屏誤判，正是發生在「看得到畫面」的情境——畫面上的密碼框太有說服力，反而蓋過了去讀 log 的動作。把遠端那套「沒有畫面、只信權威狀態」的紀律，也用在本地，就不會被畫面帶偏。</p>
<h2 id="判讀紀律四步">判讀紀律：四步</h2>
<p>把上面的東西收成一套每次都能跑的流程：</p>
<ol>
<li><strong>描述症狀</strong>：現象是什麼，先講清楚，不要在這步就急著下結論（「畫面出現密碼框」，不是「螢幕鎖了」）。</li>
<li><strong>定位權威來源</strong>：這件事的權威狀態記在哪——log、服務註冊、logind / systemd、行程表、資源用量（用上面那張表對照）。</li>
<li><strong>用對的工具讀它</strong>：讀那個權威來源，不是讀畫面、不是讀終端機捲過的殘影。</li>
<li><strong>權威跟表象矛盾時，信權威</strong>：如果讀到的權威狀態跟你肉眼的第一印象打架，信權威狀態、回頭修正第一印象——那個矛盾點通常就是你原本會猜錯的地方。</li>
</ol>
<p>這套流程的價值不在任何單一工具，在於它讓你的判斷有一個可回溯的依據，而不是一串越猜越偏的直覺。</p>
<h2 id="下一步">下一步</h2>
<ul>
<li>這套心法在遠端連線與終端機情境的應用，見 <a href="../ssh-and-terminal-troubleshooting/">遠端連線與終端機問題</a>。</li>
<li>機器連不到、或根本起不來時怎麼從權威狀態往下查，見 <a href="../machine-unreachable/">機器連不到或起不來</a>。</li>
<li>程序在不在、服務歸誰、狀態怎麼判的具體招式，見 <a href="../process-service-state-diagnosis/">程序、服務與狀態怎麼判</a>。</li>
<li>怎麼讓你自己的 bootstrap 腳本產生可讀的 log，見 <a href="../../install/observable-bootstrap/">可除錯的 bootstrap</a>。</li>
</ul>
]]></content:encoded></item><item><title>Linux 除錯與診斷</title><link>https://tarrragon.github.io/blog/linux/debug/</link><pubDate>Thu, 02 Jul 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/linux/debug/</guid><description>&lt;p>這個系列處理機器裝好、能連入之後出問題時怎麼判。核心是一套判讀紀律：先讀權威狀態，不靠肉眼猜表象——因為 Linux 上一個現象看起來像 A 卻常常是 B，看畫面就下結論容易猜錯。系列特別涵蓋遠端使用與本地除錯兩種情境，因為遠端看不到畫面，反而逼出「只信權威狀態」的好紀律。&lt;/p>
&lt;p>內容來自一次完整的 Arch Linux / Hyprland VM 實測與除錯：SSH 連不上、終端機噴亂碼、虛擬機開不起來、鎖屏狀態判錯、服務歸屬搞混——每個卡關點都被記錄下來，蒸餾成可重用的判讀路由，不綁特定發行版。&lt;/p>
&lt;h2 id="從哪篇開始">從哪篇開始&lt;/h2>
&lt;p>先讀 &lt;a href="diagnosis-read-authoritative-state/">診斷心法&lt;/a> 建立判讀紀律（讀權威狀態、四步流程），再依症狀進對應情境。&lt;/p>
&lt;h2 id="文章">文章&lt;/h2>
&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;a href="diagnosis-read-authoritative-state/">診斷心法：讀權威狀態，不靠肉眼猜表象&lt;/a>&lt;/td>
 &lt;td>貫穿所有除錯的判讀紀律：每種問題的權威狀態來源、讀程式自己的 log、四步流程&lt;/td>
 &lt;td>一個現象看起來像 A 卻可能是 B，怎麼不猜錯&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="ssh-and-terminal-troubleshooting/">遠端連線與終端機問題&lt;/a>&lt;/td>
 &lt;td>SSH 斷線後終端機噴亂碼、遠端打字亂碼（locale/terminfo）、從 SSH 操控圖形桌面&lt;/td>
 &lt;td>連上了但終端機或 session 狀態不對怎麼修&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="machine-unreachable/">機器連不到或起不來&lt;/a>&lt;/td>
 &lt;td>SSH 突然連不上（ARP 診斷）、虛擬機開不起來（guest vs 宿主側）、磁碟滿的連鎖&lt;/td>
 &lt;td>一台機器連不到或開不了機，從哪一層往下查&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="process-service-state-diagnosis/">程序、服務與狀態怎麼判&lt;/a>&lt;/td>
 &lt;td>程式活著沒（pgrep 陷阱）、服務由誰提供（busctl）、session 鎖沒鎖、多工器 session 存活&lt;/td>
 &lt;td>判某個東西的狀態時，該讀哪個權威來源&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="service-failure-monitoring/">服務掛了怎麼自動知道&lt;/a>&lt;/td>
 &lt;td>從手動 systemctl 到 OnFailure 主動告警、先重啟才告警、hung 偵測、canary、機器死掉的體外心跳&lt;/td>
 &lt;td>不想肉眼盯服務死活，怎麼自動監控並推播&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="ntfy-push-notification-service/">ntfy：推送通知服務&lt;/a>&lt;/td>
 &lt;td>ntfy 的 pub-sub 模型、開源 vs 標準、公共站 vs 自架、topic 就是密碼的安全模型、同類對照&lt;/td>
 &lt;td>用 ntfy 推告警、想搞懂它是什麼、該不該自架&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="依症狀的讀法">依症狀的讀法&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>連不上、開不了機&lt;/strong>：機器 SSH 連不到、或虛擬機開不起來 → &lt;a href="machine-unreachable/">機器連不到或起不來&lt;/a>。&lt;/li>
&lt;li>&lt;strong>終端機行為怪&lt;/strong>：SSH 斷線後終端機噴亂碼、遠端打字亂碼、要從 SSH 操控圖形桌面 → &lt;a href="ssh-and-terminal-troubleshooting/">遠端連線與終端機問題&lt;/a>。&lt;/li>
&lt;li>&lt;strong>某個狀態判不準&lt;/strong>：程式活著沒、服務歸誰、鎖沒鎖、session 還在不在 → &lt;a href="process-service-state-diagnosis/">程序、服務與狀態怎麼判&lt;/a>。&lt;/li>
&lt;li>&lt;strong>不想手動盯服務死活&lt;/strong>：想讓 service 掛掉時主動推播、或擔心整台機器當掉沒人知道 → &lt;a href="service-failure-monitoring/">服務掛了怎麼自動知道&lt;/a>。&lt;/li>
&lt;li>&lt;strong>想建立通用紀律&lt;/strong>：想要一套適用各種症狀的「不猜錯」判讀方法 → &lt;a href="diagnosis-read-authoritative-state/">診斷心法&lt;/a>。&lt;/li>
&lt;/ul>
&lt;h2 id="跟其他模組的交叉引用">跟其他模組的交叉引用&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="../install/">Linux 安裝與機器初始化&lt;/a>——本系列的上游；把機器裝好、連入之後才輪到除錯。其中 &lt;a href="../install/observable-bootstrap/">可除錯的 bootstrap&lt;/a> 談怎麼讓腳本產生可診斷的 log，與診斷心法的「讀程式自己的 log」一體兩面。&lt;/li>
&lt;li>&lt;a href="../tools/">Linux 工具選單&lt;/a>——除錯要用的工具（CLI / 圖形桌面 / 遠端）有哪些選擇。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/linux/dotfile/07-desktop-maintenance/log-reading-diagnostic-tools/" data-link-title="日誌判讀與診斷工具" data-link-desc="知道桌面出了問題但不確定原因時回來讀 — journalctl、dmesg、hyprctl、systemctl 的使用方式和常見 log pattern">模組七：日誌判讀與診斷工具&lt;/a>——桌面環境層的日誌判讀，與這裡的通用診斷紀律呼應。&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個系列處理機器裝好、能連入之後出問題時怎麼判。核心是一套判讀紀律：先讀權威狀態，不靠肉眼猜表象——因為 Linux 上一個現象看起來像 A 卻常常是 B，看畫面就下結論容易猜錯。系列特別涵蓋遠端使用與本地除錯兩種情境，因為遠端看不到畫面，反而逼出「只信權威狀態」的好紀律。</p>
<p>內容來自一次完整的 Arch Linux / Hyprland VM 實測與除錯：SSH 連不上、終端機噴亂碼、虛擬機開不起來、鎖屏狀態判錯、服務歸屬搞混——每個卡關點都被記錄下來，蒸餾成可重用的判讀路由，不綁特定發行版。</p>
<h2 id="從哪篇開始">從哪篇開始</h2>
<p>先讀 <a href="diagnosis-read-authoritative-state/">診斷心法</a> 建立判讀紀律（讀權威狀態、四步流程），再依症狀進對應情境。</p>
<h2 id="文章">文章</h2>
<table>
  <thead>
      <tr>
          <th>文章</th>
          <th>主題</th>
          <th>回答什麼問題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="diagnosis-read-authoritative-state/">診斷心法：讀權威狀態，不靠肉眼猜表象</a></td>
          <td>貫穿所有除錯的判讀紀律：每種問題的權威狀態來源、讀程式自己的 log、四步流程</td>
          <td>一個現象看起來像 A 卻可能是 B，怎麼不猜錯</td>
      </tr>
      <tr>
          <td><a href="ssh-and-terminal-troubleshooting/">遠端連線與終端機問題</a></td>
          <td>SSH 斷線後終端機噴亂碼、遠端打字亂碼（locale/terminfo）、從 SSH 操控圖形桌面</td>
          <td>連上了但終端機或 session 狀態不對怎麼修</td>
      </tr>
      <tr>
          <td><a href="machine-unreachable/">機器連不到或起不來</a></td>
          <td>SSH 突然連不上（ARP 診斷）、虛擬機開不起來（guest vs 宿主側）、磁碟滿的連鎖</td>
          <td>一台機器連不到或開不了機，從哪一層往下查</td>
      </tr>
      <tr>
          <td><a href="process-service-state-diagnosis/">程序、服務與狀態怎麼判</a></td>
          <td>程式活著沒（pgrep 陷阱）、服務由誰提供（busctl）、session 鎖沒鎖、多工器 session 存活</td>
          <td>判某個東西的狀態時，該讀哪個權威來源</td>
      </tr>
      <tr>
          <td><a href="service-failure-monitoring/">服務掛了怎麼自動知道</a></td>
          <td>從手動 systemctl 到 OnFailure 主動告警、先重啟才告警、hung 偵測、canary、機器死掉的體外心跳</td>
          <td>不想肉眼盯服務死活，怎麼自動監控並推播</td>
      </tr>
      <tr>
          <td><a href="ntfy-push-notification-service/">ntfy：推送通知服務</a></td>
          <td>ntfy 的 pub-sub 模型、開源 vs 標準、公共站 vs 自架、topic 就是密碼的安全模型、同類對照</td>
          <td>用 ntfy 推告警、想搞懂它是什麼、該不該自架</td>
      </tr>
  </tbody>
</table>
<h2 id="依症狀的讀法">依症狀的讀法</h2>
<ul>
<li><strong>連不上、開不了機</strong>：機器 SSH 連不到、或虛擬機開不起來 → <a href="machine-unreachable/">機器連不到或起不來</a>。</li>
<li><strong>終端機行為怪</strong>：SSH 斷線後終端機噴亂碼、遠端打字亂碼、要從 SSH 操控圖形桌面 → <a href="ssh-and-terminal-troubleshooting/">遠端連線與終端機問題</a>。</li>
<li><strong>某個狀態判不準</strong>：程式活著沒、服務歸誰、鎖沒鎖、session 還在不在 → <a href="process-service-state-diagnosis/">程序、服務與狀態怎麼判</a>。</li>
<li><strong>不想手動盯服務死活</strong>：想讓 service 掛掉時主動推播、或擔心整台機器當掉沒人知道 → <a href="service-failure-monitoring/">服務掛了怎麼自動知道</a>。</li>
<li><strong>想建立通用紀律</strong>：想要一套適用各種症狀的「不猜錯」判讀方法 → <a href="diagnosis-read-authoritative-state/">診斷心法</a>。</li>
</ul>
<h2 id="跟其他模組的交叉引用">跟其他模組的交叉引用</h2>
<ul>
<li><a href="../install/">Linux 安裝與機器初始化</a>——本系列的上游；把機器裝好、連入之後才輪到除錯。其中 <a href="../install/observable-bootstrap/">可除錯的 bootstrap</a> 談怎麼讓腳本產生可診斷的 log，與診斷心法的「讀程式自己的 log」一體兩面。</li>
<li><a href="../tools/">Linux 工具選單</a>——除錯要用的工具（CLI / 圖形桌面 / 遠端）有哪些選擇。</li>
<li><a href="/blog/linux/dotfile/07-desktop-maintenance/log-reading-diagnostic-tools/" data-link-title="日誌判讀與診斷工具" data-link-desc="知道桌面出了問題但不確定原因時回來讀 — journalctl、dmesg、hyprctl、systemctl 的使用方式和常見 log pattern">模組七：日誌判讀與診斷工具</a>——桌面環境層的日誌判讀，與這裡的通用診斷紀律呼應。</li>
</ul>
]]></content:encoded></item><item><title>遠端連線與終端機問題</title><link>https://tarrragon.github.io/blog/linux/debug/ssh-and-terminal-troubleshooting/</link><pubDate>Thu, 02 Jul 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/linux/debug/ssh-and-terminal-troubleshooting/</guid><description>&lt;p>遠端操作 Linux 時，很多問題出在「你的終端機」與「遠端 session」之間那條連線的狀態，而不在遠端那台機器本身。終端機被上一個程式留在奇怪的模式、字元編碼與終端機能力沒對上、或你想從一條純文字的 SSH 連線去驅動一個需要實體螢幕的圖形桌面——這些問題的共同點是：現象發生在連線的某一層，判斷對是哪一層，修復就很直接。&lt;/p>
&lt;p>SSH「連不上」本身（&lt;code>Permission denied&lt;/code>、&lt;code>Host key verification failed&lt;/code>、&lt;code>Connection refused&lt;/code>）的判讀與修復，見 &lt;a href="../../install/ssh-keyless-bootstrap/">外部連入與無 key 的 bootstrap 路徑&lt;/a> 的重連段落。這篇處理的是「連上了、但終端機或 session 的狀態不對」的那些情況。&lt;/p>
&lt;h2 id="ssh-斷線後本機終端機噴亂碼狂跳字元">SSH 斷線後本機終端機噴亂碼、狂跳字元&lt;/h2>
&lt;p>一個嚇人但無害的情況：SSH 連線被中斷後，你本機的終端機開始瘋狂輸出像 &lt;code>&amp;lt;35;80;24M&lt;/code> 這樣的序列，尤其在你移動滑鼠時狂跳。這不是遠端機器在打字，是&lt;strong>你本機的終端機被卡在滑鼠回報模式&lt;/strong>。&lt;/p>
&lt;p>判讀關鍵在「什麼時候噴」：如果那串亂碼只在你移動滑鼠時出現、而且形如 &lt;code>數字;數字M&lt;/code>，那就是滑鼠座標回報。成因是遠端跑的某個全螢幕程式（TUI、編輯器、終端機多工器）啟動時對終端機開了滑鼠追蹤模式，SSH 被硬斷時它來不及送出「關閉滑鼠模式」的序列就死了，於是你本機終端機還停在回報模式，滑鼠一動就把游標座標當輸入送進來。&lt;/p>
&lt;p>修復是重置終端機的模式，跟遠端機器無關：&lt;/p>
&lt;ul>
&lt;li>最快：開一個新的終端機分頁 / 視窗。模式是「那個終端機 session」的狀態，新視窗是乾淨的。&lt;/li>
&lt;li>救現有視窗：先把滑鼠移開別動（洪流會停），盲打 &lt;code>reset&lt;/code> 再 Enter，送出終端機重置。&lt;/li>
&lt;li>若 &lt;code>reset&lt;/code> 沒清掉，補送關閉滑鼠回報的序列：&lt;code>printf '\033[?1000l\033[?1002l\033[?1003l\033[?1006l'&lt;/code>。&lt;/li>
&lt;/ul>
&lt;p>同一類的還有「alternate screen 沒還原」——遠端的全螢幕程式異常結束時，本機終端機可能卡在替代畫面緩衝區，看起來像畫面清空或凍結。&lt;code>reset&lt;/code> 同樣能救。歸納起來：&lt;strong>SSH 被硬斷後本機終端機行為異常，先懷疑「對端程式來不及還原終端機模式」，用 &lt;code>reset&lt;/code> 或開新視窗處理本機終端機狀態，不必急著重連遠端。&lt;/strong>&lt;/p>
&lt;h2 id="遠端打字變亂碼重複位置錯亂">遠端打字變亂碼、重複、位置錯亂&lt;/h2>
&lt;p>連上遠端後，如果互動式輸入變得不對——打一個字出現好幾個、游標位置錯亂、畫面重繪殘影——通常是兩層問題之一，判讀方式是分開排除。&lt;/p>
&lt;p>第一層是&lt;strong>字元編碼（locale）&lt;/strong>。從某些本機（例如 macOS）SSH 進 Linux 時，本機會把 &lt;code>LC_CTYPE&lt;/code> 之類的變數帶過去；如果遠端沒有對應的 locale、就會退回 POSIX/C locale，讓終端機的行編輯（ZLE、readline）對多位元組字元的寬度判斷出錯，表現為輸入重複或錯位。判斷方式是在遠端 &lt;code>locale&lt;/code> 看目前值、&lt;code>locale -a&lt;/code> 看有沒有裝對應的 UTF-8 locale。修法是在遠端明確設好 &lt;code>LANG&lt;/code> / &lt;code>LC_CTYPE&lt;/code> 到一個實際存在的 UTF-8 locale，而不是讓它繼承一個遠端不認得的值。&lt;/p>
&lt;p>第二層是&lt;strong>終端機能力資料庫（terminfo）&lt;/strong>。你本機終端機的 &lt;code>TERM&lt;/code> 值（例如某些新終端機用 &lt;code>xterm-ghostty&lt;/code> 之類的自訂值）如果在遠端沒有對應的 terminfo 條目，遠端程式就不知道怎麼正確地清行、移動游標、重繪，畫面就會亂。判斷方式是在遠端 &lt;code>echo $TERM&lt;/code> 看值、&lt;code>infocmp $TERM&lt;/code> 看遠端認不認得。修法是把本機的 terminfo 條目送過去讓遠端安裝：&lt;code>infocmp -x $TERM | ssh &amp;lt;遠端&amp;gt; 'tic -x -'&lt;/code>。&lt;/p>
&lt;p>先分清是 locale 還是 terminfo，兩者症狀相似但修法不同：locale 是編碼寬度、terminfo 是繪製指令。查 &lt;code>locale&lt;/code> 跟查 &lt;code>$TERM&lt;/code> + &lt;code>infocmp&lt;/code> 就能分開。&lt;/p>
&lt;h2 id="從-ssh-操控遠端的圖形桌面">從 SSH 操控遠端的圖形桌面&lt;/h2>
&lt;p>想從一條純文字的 SSH 連線去操作遠端的 Wayland 圖形桌面（例如啟動應用、截圖、送 IPC 指令）時，會撞到兩類界線，判斷對是哪一類就知道怎麼繞。&lt;/p>
&lt;p>第一類是&lt;strong>圖形程式需要知道連到哪個顯示&lt;/strong>。SSH 進來的 shell 預設沒有圖形環境的環境變數，直接跑圖形程式會找不到 display。要對著遠端那個已經在跑的 Wayland session 操作，得補上它的環境變數：&lt;code>XDG_RUNTIME_DIR&lt;/code>（通常 &lt;code>/run/user/&amp;lt;uid&amp;gt;&lt;/code>）、&lt;code>WAYLAND_DISPLAY&lt;/code>（socket 名，如 &lt;code>wayland-1&lt;/code>）、必要時還有該 compositor 的 instance 變數與 &lt;code>DBUS_SESSION_BUS_ADDRESS&lt;/code>。這些值怎麼撈：socket 名用 &lt;code>ls /run/user/$(id -u)/wayland-*&lt;/code> 看；其餘變數直接從那個圖形 session 既有行程的環境複製最準——&lt;code>cat /proc/&amp;lt;compositor-pid&amp;gt;/environ | tr '\0' '\n' | grep -E 'WAYLAND_DISPLAY|XDG_RUNTIME_DIR|DBUS_SESSION|_INSTANCE_'&lt;/code>（&lt;code>&amp;lt;compositor-pid&amp;gt;&lt;/code> 用 &lt;code>pgrep -x Hyprland&lt;/code> 之類找）。撈到後 &lt;code>export&lt;/code> 進當前 SSH shell，這條連線就能對遠端的圖形 session 下指令、&lt;code>grim&lt;/code> 截圖。&lt;/p>
&lt;p>第二類是&lt;strong>有些東西必須從實體圖形終端機（VT，即 &lt;code>Ctrl+Alt+F1&lt;/code>~&lt;code>F6&lt;/code> 切換的那些文字主控台）啟動，SSH 的 pty 起不來&lt;/strong>。Wayland 的合成器（compositor，畫桌面、把視窗合成到螢幕、管輸入輸出的核心程式，如 Hyprland）需要一個真正的圖形 VT 上的登入 session，拿到 DRM master（對顯示卡的獨佔繪圖控制權）與 logind seat（一組綁在一起的實體螢幕／鍵鼠裝置）才能啟動；從 SSH 的 pty 起它的&lt;strong>預設 backend&lt;/strong> 會直接失敗（例如報 backend 建立失敗），因為預設 backend 要的 DRM master 與 seat 在 SSH 這條連線上不存在。判讀訊號：合成器一啟動就報 seat / DRM / backend 相關的錯，而你是從 SSH 起的——那就是這個界線。（例外：合成器多半有 headless backend，例如設 &lt;code>WLR_BACKENDS=headless&lt;/code> 就不要 DRM master、不需 VT，專給 CI、雲端、自動化測試用；nested（跑在另一個 Wayland session 裡）也不需要。所以精確說是「預設 backend 需要圖形 VT」，不是「合成器一定起不來」。）&lt;/p></description><content:encoded><![CDATA[<p>遠端操作 Linux 時，很多問題出在「你的終端機」與「遠端 session」之間那條連線的狀態，而不在遠端那台機器本身。終端機被上一個程式留在奇怪的模式、字元編碼與終端機能力沒對上、或你想從一條純文字的 SSH 連線去驅動一個需要實體螢幕的圖形桌面——這些問題的共同點是：現象發生在連線的某一層，判斷對是哪一層，修復就很直接。</p>
<p>SSH「連不上」本身（<code>Permission denied</code>、<code>Host key verification failed</code>、<code>Connection refused</code>）的判讀與修復，見 <a href="../../install/ssh-keyless-bootstrap/">外部連入與無 key 的 bootstrap 路徑</a> 的重連段落。這篇處理的是「連上了、但終端機或 session 的狀態不對」的那些情況。</p>
<h2 id="ssh-斷線後本機終端機噴亂碼狂跳字元">SSH 斷線後本機終端機噴亂碼、狂跳字元</h2>
<p>一個嚇人但無害的情況：SSH 連線被中斷後，你本機的終端機開始瘋狂輸出像 <code>&lt;35;80;24M</code> 這樣的序列，尤其在你移動滑鼠時狂跳。這不是遠端機器在打字，是<strong>你本機的終端機被卡在滑鼠回報模式</strong>。</p>
<p>判讀關鍵在「什麼時候噴」：如果那串亂碼只在你移動滑鼠時出現、而且形如 <code>數字;數字M</code>，那就是滑鼠座標回報。成因是遠端跑的某個全螢幕程式（TUI、編輯器、終端機多工器）啟動時對終端機開了滑鼠追蹤模式，SSH 被硬斷時它來不及送出「關閉滑鼠模式」的序列就死了，於是你本機終端機還停在回報模式，滑鼠一動就把游標座標當輸入送進來。</p>
<p>修復是重置終端機的模式，跟遠端機器無關：</p>
<ul>
<li>最快：開一個新的終端機分頁 / 視窗。模式是「那個終端機 session」的狀態，新視窗是乾淨的。</li>
<li>救現有視窗：先把滑鼠移開別動（洪流會停），盲打 <code>reset</code> 再 Enter，送出終端機重置。</li>
<li>若 <code>reset</code> 沒清掉，補送關閉滑鼠回報的序列：<code>printf '\033[?1000l\033[?1002l\033[?1003l\033[?1006l'</code>。</li>
</ul>
<p>同一類的還有「alternate screen 沒還原」——遠端的全螢幕程式異常結束時，本機終端機可能卡在替代畫面緩衝區，看起來像畫面清空或凍結。<code>reset</code> 同樣能救。歸納起來：<strong>SSH 被硬斷後本機終端機行為異常，先懷疑「對端程式來不及還原終端機模式」，用 <code>reset</code> 或開新視窗處理本機終端機狀態，不必急著重連遠端。</strong></p>
<h2 id="遠端打字變亂碼重複位置錯亂">遠端打字變亂碼、重複、位置錯亂</h2>
<p>連上遠端後，如果互動式輸入變得不對——打一個字出現好幾個、游標位置錯亂、畫面重繪殘影——通常是兩層問題之一，判讀方式是分開排除。</p>
<p>第一層是<strong>字元編碼（locale）</strong>。從某些本機（例如 macOS）SSH 進 Linux 時，本機會把 <code>LC_CTYPE</code> 之類的變數帶過去；如果遠端沒有對應的 locale、就會退回 POSIX/C locale，讓終端機的行編輯（ZLE、readline）對多位元組字元的寬度判斷出錯，表現為輸入重複或錯位。判斷方式是在遠端 <code>locale</code> 看目前值、<code>locale -a</code> 看有沒有裝對應的 UTF-8 locale。修法是在遠端明確設好 <code>LANG</code> / <code>LC_CTYPE</code> 到一個實際存在的 UTF-8 locale，而不是讓它繼承一個遠端不認得的值。</p>
<p>第二層是<strong>終端機能力資料庫（terminfo）</strong>。你本機終端機的 <code>TERM</code> 值（例如某些新終端機用 <code>xterm-ghostty</code> 之類的自訂值）如果在遠端沒有對應的 terminfo 條目，遠端程式就不知道怎麼正確地清行、移動游標、重繪，畫面就會亂。判斷方式是在遠端 <code>echo $TERM</code> 看值、<code>infocmp $TERM</code> 看遠端認不認得。修法是把本機的 terminfo 條目送過去讓遠端安裝：<code>infocmp -x $TERM | ssh &lt;遠端&gt; 'tic -x -'</code>。</p>
<p>先分清是 locale 還是 terminfo，兩者症狀相似但修法不同：locale 是編碼寬度、terminfo 是繪製指令。查 <code>locale</code> 跟查 <code>$TERM</code> + <code>infocmp</code> 就能分開。</p>
<h2 id="從-ssh-操控遠端的圖形桌面">從 SSH 操控遠端的圖形桌面</h2>
<p>想從一條純文字的 SSH 連線去操作遠端的 Wayland 圖形桌面（例如啟動應用、截圖、送 IPC 指令）時，會撞到兩類界線，判斷對是哪一類就知道怎麼繞。</p>
<p>第一類是<strong>圖形程式需要知道連到哪個顯示</strong>。SSH 進來的 shell 預設沒有圖形環境的環境變數，直接跑圖形程式會找不到 display。要對著遠端那個已經在跑的 Wayland session 操作，得補上它的環境變數：<code>XDG_RUNTIME_DIR</code>（通常 <code>/run/user/&lt;uid&gt;</code>）、<code>WAYLAND_DISPLAY</code>（socket 名，如 <code>wayland-1</code>）、必要時還有該 compositor 的 instance 變數與 <code>DBUS_SESSION_BUS_ADDRESS</code>。這些值怎麼撈：socket 名用 <code>ls /run/user/$(id -u)/wayland-*</code> 看；其餘變數直接從那個圖形 session 既有行程的環境複製最準——<code>cat /proc/&lt;compositor-pid&gt;/environ | tr '\0' '\n' | grep -E 'WAYLAND_DISPLAY|XDG_RUNTIME_DIR|DBUS_SESSION|_INSTANCE_'</code>（<code>&lt;compositor-pid&gt;</code> 用 <code>pgrep -x Hyprland</code> 之類找）。撈到後 <code>export</code> 進當前 SSH shell，這條連線就能對遠端的圖形 session 下指令、<code>grim</code> 截圖。</p>
<p>第二類是<strong>有些東西必須從實體圖形終端機（VT，即 <code>Ctrl+Alt+F1</code>~<code>F6</code> 切換的那些文字主控台）啟動，SSH 的 pty 起不來</strong>。Wayland 的合成器（compositor，畫桌面、把視窗合成到螢幕、管輸入輸出的核心程式，如 Hyprland）需要一個真正的圖形 VT 上的登入 session，拿到 DRM master（對顯示卡的獨佔繪圖控制權）與 logind seat（一組綁在一起的實體螢幕／鍵鼠裝置）才能啟動；從 SSH 的 pty 起它的<strong>預設 backend</strong> 會直接失敗（例如報 backend 建立失敗），因為預設 backend 要的 DRM master 與 seat 在 SSH 這條連線上不存在。判讀訊號：合成器一啟動就報 seat / DRM / backend 相關的錯，而你是從 SSH 起的——那就是這個界線。（例外：合成器多半有 headless backend，例如設 <code>WLR_BACKENDS=headless</code> 就不要 DRM master、不需 VT，專給 CI、雲端、自動化測試用；nested（跑在另一個 Wayland session 裡）也不需要。所以精確說是「預設 backend 需要圖形 VT」，不是「合成器一定起不來」。）</p>
<p>繞法是回到那台機器的實體圖形終端機去啟動 compositor，但「回到 VT」這件事也可以從 SSH 遠端做：</p>
<ul>
<li><code>sudo chvt &lt;N&gt;</code> 從 SSH 切換那台機器目前顯示的虛擬終端機到第 N 個，比在虛擬機視窗裡跟宿主的 <code>Ctrl+Alt+Fn</code> 快捷鍵搏鬥穩定。</li>
<li>切過去卻是空白、沒有登入提示，通常是那個 VT 上沒有 getty 在跑：<code>sudo systemctl start getty@tty&lt;N&gt;</code>（開機時 <code>enabled</code> 但 <code>inactive</code> 是常見狀態，logind 的 autovt 沒觸發）。</li>
<li><code>sudo fgconsole</code> 確認目前是哪個 VT 在前景。</li>
</ul>
<p>還有一個容易混淆的點：一台虛擬機可能同時有「序列主控台」跟「圖形顯示」兩個獨立輸出。在 guest 內 <code>chvt</code> 只切圖形那側，序列主控台看到的畫面不會變。如果你在虛擬機軟體裡看的是序列主控台，圖形桌面得切到顯示輸出那個 view 才看得到。判讀：切了 VT 但畫面沒反應，先確認你正在看的是哪個輸出。</p>
<h2 id="判讀路由">判讀路由</h2>
<p>遠端 / 終端機問題的分流：</p>
<ul>
<li>本機終端機噴亂碼、只在動滑鼠時噴 → 滑鼠回報模式沒關（本機終端機狀態），<code>reset</code> 或開新視窗。</li>
<li>遠端打字重複 / 錯位 → 分 locale（查 <code>locale</code>）與 terminfo（查 <code>$TERM</code> + <code>infocmp</code>）。</li>
<li>圖形程式在 SSH 下找不到 display → 補 <code>WAYLAND_DISPLAY</code> / <code>XDG_RUNTIME_DIR</code> 等環境變數。</li>
<li>compositor 從 SSH 起不來、報 seat/DRM 錯 → 它需要實體 VT，用 <code>chvt</code> + <code>getty@tty&lt;N&gt;</code> 回到圖形 VT 啟動。</li>
<li>SSH 連不上（拒絕 / host key / refused）→ 見 <a href="../../install/ssh-keyless-bootstrap/">外部連入與無 key 的 bootstrap 路徑</a>。</li>
</ul>
<p>這幾種分流的共同底線是先讀權威狀態（<code>locale</code>、<code>$TERM</code>、runtime 目錄、<code>loginctl</code>、<code>fgconsole</code>）再下判斷；背後的方法論見 <a href="../diagnosis-read-authoritative-state/">診斷心法</a>。</p>
]]></content:encoded></item><item><title>機器連不到或起不來</title><link>https://tarrragon.github.io/blog/linux/debug/machine-unreachable/</link><pubDate>Thu, 02 Jul 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/linux/debug/machine-unreachable/</guid><description>&lt;p>一台原本能連的機器突然連不上，或一台虛擬機根本開不起來，判讀的方向是「從你這端往那台機器，一層一層確認哪裡斷了」，而不是反覆重試同一個連線動作。連線失敗是最終症狀，真正斷掉的可能是網路、可能是那台機器的某個服務沒起來、可能是虛擬機的宿主側出問題、也可能是一個把上面全部拖下水的共同根因：磁碟滿。這篇從網路層與宿主側的權威狀態切入，把「連不上」拆成可定位的環節。&lt;/p>
&lt;h2 id="遠端機器突然連不上先分清是哪一段斷">遠端機器突然連不上：先分清是哪一段斷&lt;/h2>
&lt;p>一台昨天還能 SSH 的機器今天連不上，第一步是確認「網路層通不通」，跟「SSH 服務在不在」分開。連線在 TCP 就 timeout（連 port 22 卡住沒回應），多半是網路層或機器沒在跑；連線有回應但被拒（&lt;code>Connection refused&lt;/code>），是網路通、但那台機器上沒有服務在聽 port 22。&lt;/p>
&lt;p>對虛擬機或同網段的機器，一個很有用的權威來源是&lt;strong>鄰居表&lt;/strong>（IP 對 MAC 的對應）。要填起來需要對方在鏈路層有回應，所以它直接反映「對方在不在」。用 &lt;code>ip neigh&lt;/code> 看目標 IP 的條目——優先用 &lt;code>ip neigh&lt;/code> 而不是 &lt;code>arp -a&lt;/code>，因為 &lt;code>ip&lt;/code>（iproute2）在現代最小系統一定有，&lt;code>arp&lt;/code>（net-tools）常常沒裝、跑了會 command not found 反而誤導。如果狀態是 &lt;code>INCOMPLETE&lt;/code>（&lt;code>arp -a&lt;/code> 顯示的是 &lt;code>incomplete&lt;/code>），代表這個 IP 在鏈路層上根本沒有機器回應——不是 SSH 的問題，是那台機器的網路沒起來、或根本沒在跑。一個實際案例：一台虛擬機 SSH timeout，鄰居表顯示整個網段的 guest 位址全是 incomplete、只有閘道（宿主那側的橋接介面）是好的——這就定位到「宿主的橋沒問題，但橋的另一頭沒有 VM 在講話」，方向立刻從「調 SSH」轉到「去看 VM 的網路或開機狀態」。&lt;/p>
&lt;p>定位到「機器在跑但網路沒起來」後，去那台機器的主控台（不是 SSH，SSH 正是連不上的東西）確認：&lt;code>ip -brief a&lt;/code> 看有沒有拿到 IP、&lt;code>systemctl status &amp;lt;網路服務&amp;gt;&lt;/code>（&lt;code>dhcpcd&lt;/code> / &lt;code>systemd-networkd&lt;/code>）看網路服務起了沒，需要時 &lt;code>sudo systemctl restart &amp;lt;網路服務&amp;gt;&lt;/code> 重拉。IP 回來、鄰居表的條目從 incomplete 變成有 MAC，就通了。&lt;/p>
&lt;p>還有一個常見誤區是 IP 變了。SSH 的別名、金鑰、&lt;code>known_hosts&lt;/code> 都綁在特定機器身分上；換機器 / 重裝 / DHCP 重配後 IP 或 host key 變了，用舊別名會連錯或被 host key 檢查擋。這條的判讀與修法（&lt;code>ssh user@新IP&lt;/code> 直連、&lt;code>ssh-keygen -R&lt;/code>）見 &lt;a href="../../install/ssh-keyless-bootstrap/">外部連入與無 key 的 bootstrap 路徑&lt;/a>。&lt;/p>
&lt;h2 id="網路通但域名解析不了">網路通、但域名解析不了&lt;/h2>
&lt;p>有一種故障看起來像「網路壞了」，其實是 DNS 解析斷了：能連 IP、卻連不上任何用域名的東西——&lt;code>ping 8.8.8.8&lt;/code> 通、但 &lt;code>ping google.com&lt;/code>、&lt;code>pacman -Sy&lt;/code>、&lt;code>curl https://...&lt;/code> 全失敗。判讀要跟前面「網路沒起來」分開，因為網路層是通的，斷的是「域名 → IP」這一步。權威檢查：&lt;code>ping &amp;lt;IP&amp;gt;&lt;/code> 通而 &lt;code>ping &amp;lt;域名&amp;gt;&lt;/code> 不通、或 &lt;code>getent hosts &amp;lt;域名&amp;gt;&lt;/code>（&lt;code>resolvectl query &amp;lt;域名&amp;gt;&lt;/code> 若有 systemd-resolved）解不出位址，就定位到 DNS。常見成因是 &lt;code>/etc/resolv.conf&lt;/code> 沒有可用的 nameserver（新裝或網路重設後沒填），或負責 DNS 的服務沒起來。修：確認 &lt;code>/etc/resolv.conf&lt;/code> 有一行 &lt;code>nameserver&lt;/code>（如 &lt;code>nameserver 1.1.1.1&lt;/code>）、&lt;code>systemctl status systemd-resolved&lt;/code>（若用它）。這一層在剛裝好的最小系統特別常撞到——&lt;code>ip -brief a&lt;/code> 明明有 IP，&lt;code>pacman&lt;/code> 或 bootstrap 卻抓不到套件，看起來像「網路好好的卻裝不了東西」，根因是 DNS 沒設。&lt;/p>
&lt;h2 id="虛擬機開不起來分清-guest-內部還是宿主側">虛擬機開不起來：分清 guest 內部還是宿主側&lt;/h2>
&lt;p>虛擬機開機失敗時，關鍵判斷是「錯誤來自 guest 內部（作業系統層）還是宿主側（虛擬化軟體 / QEMU 層）」。宿主側的錯誤訊息通常來自虛擬機軟體本身、在 guest 還沒開始開機前就跳出來，跟 guest 裡裝了什麼無關。&lt;/p>
&lt;p>一個實例是 QEMU 報「找不到某個 ROM 檔」（例如 &lt;code>efi-virtio.rom&lt;/code>）而拒絕啟動。第一反應可能是「檔案不見了要重裝」，但正確的第一步是&lt;strong>去確認那個檔在不在&lt;/strong>——實際去虛擬機軟體的安裝目錄裡找（&lt;code>find &amp;lt;安裝目錄&amp;gt; -name '&amp;lt;rom名&amp;gt;'&lt;/code>），會發現 ROM 檔明明就在。既然檔案在，「找不到」就不是缺檔，是 QEMU 執行時&lt;strong>在它預期的路徑下找不到&lt;/strong>——成因隨宿主 OS 不同。&lt;strong>在 macOS + UTM 宿主上&lt;/strong>，最常見的是 Gatekeeper app translocation：帶隔離屬性的 app 被搬到一個隨機唯讀路徑跑，讓 QEMU 解析資源的相對路徑失效，明明存在的檔案在那個執行路徑下就找不到。&lt;strong>在 Linux 宿主上&lt;/strong>（沒有 translocation 這回事），同樣的「找不到 ROM」通常是缺對應套件（&lt;code>ovmf&lt;/code> / &lt;code>ipxe-roms&lt;/code> / &lt;code>edk2-ovmf&lt;/code>）、libvirt XML 指的 ROM 路徑錯、或檔案權限不對——一樣先確認檔在哪、QEMU 是用哪個路徑去找。&lt;/p>
&lt;p>另外兩個常見的「VM 起不來」故障也順手一起排除，它們不會特定產生「找不到 ROM」但常伴隨出現：上一次崩潰殘留的 helper 行程卡著（&lt;code>pgrep -af 'qemu|&amp;lt;虛擬機軟體名&amp;gt;'&lt;/code> 找，沒清乾淨會佔住資源），以及宿主磁碟滿（&lt;code>df -h&lt;/code>，啟動要寫暫存 / 狀態檔）。多數情況下，完全退出虛擬機軟體（連殘留 helper 一起清）+ 清出宿主磁碟空間 + 重新啟動，就恢復了。&lt;/p></description><content:encoded><![CDATA[<p>一台原本能連的機器突然連不上，或一台虛擬機根本開不起來，判讀的方向是「從你這端往那台機器，一層一層確認哪裡斷了」，而不是反覆重試同一個連線動作。連線失敗是最終症狀，真正斷掉的可能是網路、可能是那台機器的某個服務沒起來、可能是虛擬機的宿主側出問題、也可能是一個把上面全部拖下水的共同根因：磁碟滿。這篇從網路層與宿主側的權威狀態切入，把「連不上」拆成可定位的環節。</p>
<h2 id="遠端機器突然連不上先分清是哪一段斷">遠端機器突然連不上：先分清是哪一段斷</h2>
<p>一台昨天還能 SSH 的機器今天連不上，第一步是確認「網路層通不通」，跟「SSH 服務在不在」分開。連線在 TCP 就 timeout（連 port 22 卡住沒回應），多半是網路層或機器沒在跑；連線有回應但被拒（<code>Connection refused</code>），是網路通、但那台機器上沒有服務在聽 port 22。</p>
<p>對虛擬機或同網段的機器，一個很有用的權威來源是<strong>鄰居表</strong>（IP 對 MAC 的對應）。要填起來需要對方在鏈路層有回應，所以它直接反映「對方在不在」。用 <code>ip neigh</code> 看目標 IP 的條目——優先用 <code>ip neigh</code> 而不是 <code>arp -a</code>，因為 <code>ip</code>（iproute2）在現代最小系統一定有，<code>arp</code>（net-tools）常常沒裝、跑了會 command not found 反而誤導。如果狀態是 <code>INCOMPLETE</code>（<code>arp -a</code> 顯示的是 <code>incomplete</code>），代表這個 IP 在鏈路層上根本沒有機器回應——不是 SSH 的問題，是那台機器的網路沒起來、或根本沒在跑。一個實際案例：一台虛擬機 SSH timeout，鄰居表顯示整個網段的 guest 位址全是 incomplete、只有閘道（宿主那側的橋接介面）是好的——這就定位到「宿主的橋沒問題，但橋的另一頭沒有 VM 在講話」，方向立刻從「調 SSH」轉到「去看 VM 的網路或開機狀態」。</p>
<p>定位到「機器在跑但網路沒起來」後，去那台機器的主控台（不是 SSH，SSH 正是連不上的東西）確認：<code>ip -brief a</code> 看有沒有拿到 IP、<code>systemctl status &lt;網路服務&gt;</code>（<code>dhcpcd</code> / <code>systemd-networkd</code>）看網路服務起了沒，需要時 <code>sudo systemctl restart &lt;網路服務&gt;</code> 重拉。IP 回來、鄰居表的條目從 incomplete 變成有 MAC，就通了。</p>
<p>還有一個常見誤區是 IP 變了。SSH 的別名、金鑰、<code>known_hosts</code> 都綁在特定機器身分上；換機器 / 重裝 / DHCP 重配後 IP 或 host key 變了，用舊別名會連錯或被 host key 檢查擋。這條的判讀與修法（<code>ssh user@新IP</code> 直連、<code>ssh-keygen -R</code>）見 <a href="../../install/ssh-keyless-bootstrap/">外部連入與無 key 的 bootstrap 路徑</a>。</p>
<h2 id="網路通但域名解析不了">網路通、但域名解析不了</h2>
<p>有一種故障看起來像「網路壞了」，其實是 DNS 解析斷了：能連 IP、卻連不上任何用域名的東西——<code>ping 8.8.8.8</code> 通、但 <code>ping google.com</code>、<code>pacman -Sy</code>、<code>curl https://...</code> 全失敗。判讀要跟前面「網路沒起來」分開，因為網路層是通的，斷的是「域名 → IP」這一步。權威檢查：<code>ping &lt;IP&gt;</code> 通而 <code>ping &lt;域名&gt;</code> 不通、或 <code>getent hosts &lt;域名&gt;</code>（<code>resolvectl query &lt;域名&gt;</code> 若有 systemd-resolved）解不出位址，就定位到 DNS。常見成因是 <code>/etc/resolv.conf</code> 沒有可用的 nameserver（新裝或網路重設後沒填），或負責 DNS 的服務沒起來。修：確認 <code>/etc/resolv.conf</code> 有一行 <code>nameserver</code>（如 <code>nameserver 1.1.1.1</code>）、<code>systemctl status systemd-resolved</code>（若用它）。這一層在剛裝好的最小系統特別常撞到——<code>ip -brief a</code> 明明有 IP，<code>pacman</code> 或 bootstrap 卻抓不到套件，看起來像「網路好好的卻裝不了東西」，根因是 DNS 沒設。</p>
<h2 id="虛擬機開不起來分清-guest-內部還是宿主側">虛擬機開不起來：分清 guest 內部還是宿主側</h2>
<p>虛擬機開機失敗時，關鍵判斷是「錯誤來自 guest 內部（作業系統層）還是宿主側（虛擬化軟體 / QEMU 層）」。宿主側的錯誤訊息通常來自虛擬機軟體本身、在 guest 還沒開始開機前就跳出來，跟 guest 裡裝了什麼無關。</p>
<p>一個實例是 QEMU 報「找不到某個 ROM 檔」（例如 <code>efi-virtio.rom</code>）而拒絕啟動。第一反應可能是「檔案不見了要重裝」，但正確的第一步是<strong>去確認那個檔在不在</strong>——實際去虛擬機軟體的安裝目錄裡找（<code>find &lt;安裝目錄&gt; -name '&lt;rom名&gt;'</code>），會發現 ROM 檔明明就在。既然檔案在，「找不到」就不是缺檔，是 QEMU 執行時<strong>在它預期的路徑下找不到</strong>——成因隨宿主 OS 不同。<strong>在 macOS + UTM 宿主上</strong>，最常見的是 Gatekeeper app translocation：帶隔離屬性的 app 被搬到一個隨機唯讀路徑跑，讓 QEMU 解析資源的相對路徑失效，明明存在的檔案在那個執行路徑下就找不到。<strong>在 Linux 宿主上</strong>（沒有 translocation 這回事），同樣的「找不到 ROM」通常是缺對應套件（<code>ovmf</code> / <code>ipxe-roms</code> / <code>edk2-ovmf</code>）、libvirt XML 指的 ROM 路徑錯、或檔案權限不對——一樣先確認檔在哪、QEMU 是用哪個路徑去找。</p>
<p>另外兩個常見的「VM 起不來」故障也順手一起排除，它們不會特定產生「找不到 ROM」但常伴隨出現：上一次崩潰殘留的 helper 行程卡著（<code>pgrep -af 'qemu|&lt;虛擬機軟體名&gt;'</code> 找，沒清乾淨會佔住資源），以及宿主磁碟滿（<code>df -h</code>，啟動要寫暫存 / 狀態檔）。多數情況下，完全退出虛擬機軟體（連殘留 helper 一起清）+ 清出宿主磁碟空間 + 重新啟動，就恢復了。</p>
<p>判讀通則：<strong>虛擬機開不起來，先讀錯誤訊息判斷是 guest 還是宿主側；宿主側報「找不到某資源」而資源其實存在時，往「QEMU 是用哪個路徑找、那條路徑對不對」查（macOS 是 translocation、Linux 是缺套件 / 路徑 / 權限），再順手排除殘留行程與磁碟滿，而不是急著重裝。</strong></p>
<h2 id="磁碟滿是連鎖故障的共同根因">磁碟滿是連鎖故障的共同根因</h2>
<p>很多看起來各自獨立的故障，共同根因是磁碟滿。磁碟一滿，寫入就會失敗，而系統裡太多東西依賴寫入：SSH session 可能因為寫不了而被斷、正在跑的編譯 / 安裝會中途失敗、log 寫不進去、虛擬機狀態檔存不下導致連不上或開不起來。所以當你在短時間內撞到「連線斷了 + 某個任務失敗 + 服務怪怪的」這種一串症狀時，<code>df -h</code> 應該是很早就要做的檢查——一個廉價的檢查就可能一次解釋掉全部。</p>
<p>這裡有一個容易搞錯的點：<strong>清錯了地方</strong>。宿主跟 guest 是兩個獨立的檔案系統；虛擬機的宿主磁碟滿，跟 guest 內部磁碟滿，是兩件事。如果你 SSH 進 guest 裡 <code>df</code> 看到還有空間就以為沒事，但真正滿的是宿主的磁碟，那問題不會解決。判讀時要分清這串故障是「哪一台機器的哪個檔案系統」滿了——在宿主上 <code>df -h</code> 看宿主、在 guest 裡 <code>df -h</code> 看 guest，兩邊都要確認。清空間也要清在對的那一側。</p>
<h2 id="判讀路由">判讀路由</h2>
<ul>
<li>SSH timeout（TCP 卡住）→ 網路層或機器沒跑，查 <code>ip neigh</code>（<code>INCOMPLETE</code> = 對方沒回應）→ 去主控台看 <code>ip -brief a</code> / 網路服務。</li>
<li><code>Connection refused</code> → 網路通、但沒有服務在聽 → 去機器上確認 sshd 起了沒。</li>
<li>能 ping IP、不能用域名（<code>pacman</code> / <code>curl</code> 失敗）→ DNS 解析問題，查 <code>/etc/resolv.conf</code> 有沒有 nameserver、<code>systemd-resolved</code> 起了沒，不是網路層斷。</li>
<li>連錯 / host key 被擋 → IP 或身分變了，見 <a href="../../install/ssh-keyless-bootstrap/">外部連入與無 key 的 bootstrap 路徑</a>。</li>
<li>虛擬機開不起來、宿主側報「找不到資源」但資源在 → 主因查路徑隔離，再排除殘留行程（<code>pgrep -af 'qemu\|...'</code>）/ 磁碟。</li>
<li>一串症狀同時發生 → 早點 <code>df -h</code>，宿主與 guest 兩側都查，磁碟滿常是共同根因。</li>
</ul>
<p>連不上只是最終症狀，真正的定位靠網路表、服務狀態、資源用量這些權威來源一層層往回推——完整的判讀紀律見 <a href="../diagnosis-read-authoritative-state/">診斷心法</a>。</p>
]]></content:encoded></item><item><title>程序、服務與狀態怎麼判</title><link>https://tarrragon.github.io/blog/linux/debug/process-service-state-diagnosis/</link><pubDate>Thu, 02 Jul 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/linux/debug/process-service-state-diagnosis/</guid><description>&lt;p>判斷「某個東西現在是什麼狀態」——程式活著沒、服務由誰提供、螢幕鎖了沒、session 還在不在——是除錯裡最常做、也最常判錯的一步。判錯多半不是工具不對，是問錯了來源：用一個猜的名字去掃行程、用畫面有沒有反應去推服務狀態、用畫面上有沒有某個元素去斷定 session 狀態。這篇把幾個常見的狀態判斷，對到它們各自的權威來源與正確工具。&lt;/p>
&lt;p>底層的心法（讀權威狀態、不靠肉眼）見 &lt;a href="../diagnosis-read-authoritative-state/">診斷心法&lt;/a>，這篇是它在「程序 / 服務 / 狀態」這一類的具體招式。&lt;/p>
&lt;h2 id="程式活著沒比對正確的行程名">程式活著沒：比對正確的行程名&lt;/h2>
&lt;p>判斷一個程式在不在，行程表是權威來源，&lt;code>pgrep&lt;/code> / &lt;code>ps&lt;/code> 是對的工具，但成敗在於&lt;strong>比對正確的行程名&lt;/strong>（comm，行程表裡記的執行檔短名，可從 &lt;code>/proc/&amp;lt;pid&amp;gt;/comm&lt;/code> 看）。一個實際的坑：某個桌面 shell（畫桌面 UI 的圖形程式，不是 bash/zsh 那種命令列 shell）的可執行檔叫 &lt;code>quickshell&lt;/code>，但透過名為 &lt;code>qs&lt;/code> 的 symlink 啟動時，它在行程表裡的 comm 是 &lt;code>qs&lt;/code>。這時 &lt;code>pgrep quickshell&lt;/code> 找不到，很容易誤判成程式掛了、甚至誤觸「重啟」而引發更大的問題，實際上它以 &lt;code>qs&lt;/code> 這個名字好好跑著。&lt;/p>
&lt;p>可靠的做法：&lt;/p>
&lt;ul>
&lt;li>先確認實際的 comm 名：&lt;code>ps -eo pid,comm | grep -i &amp;lt;關鍵字&amp;gt;&lt;/code>，或看你啟動它的實際指令。&lt;/li>
&lt;li>用精確比對：&lt;code>pgrep -x &amp;lt;comm&amp;gt;&lt;/code>（&lt;code>-x&lt;/code> 要求完全相符），或 &lt;code>pgrep -af &amp;lt;pattern&amp;gt;&lt;/code> 連完整命令列一起比對，避免被 symlink 名 / 縮寫名騙。&lt;/li>
&lt;li>另一個 comm 的坑：kernel 把 comm 截在 15 字元（&lt;code>TASK_COMM_LEN&lt;/code>），名字超過 15 字的程式用 &lt;code>pgrep -x &amp;lt;完整長名&amp;gt;&lt;/code> 反而 miss——這時改用 &lt;code>pgrep -af &amp;lt;pattern&amp;gt;&lt;/code> 比對完整命令列。&lt;/li>
&lt;li>別用一個「你以為的名字」掃過去就下生死結論——行程表沒騙你，是查詢條件寫錯。&lt;/li>
&lt;/ul>
&lt;h3 id="進程活著--內部子系統活著">進程活著 ≠ 內部子系統活著&lt;/h3>
&lt;p>比對到了正確的 comm、&lt;code>pgrep&lt;/code> 也有輸出，只證明「這個進程存在」，不證明「它內部在正常運作」。有一類故障是進程好端端活著（&lt;code>pgrep&lt;/code> 找得到、STAT 是正常的 &lt;code>S&lt;/code>、在 &lt;code>poll&lt;/code> 等事件、CPU 不高），但它內部某個子系統已經 wedged——例如一個圖形 shell 的 QML scene 因為上游錯誤（渲染 pipeline 建失敗之類）某個物件沒建起來變 null，於是負責互動的模組全部失效。表現是 bar 還畫得出來、卻點不動，keybind 叫不出東西，但焦點視窗打字正常。這時 &lt;code>pgrep&lt;/code> 會騙你說「在跑」。&lt;/p>
&lt;p>這種情況權威來源不是行程表，是&lt;strong>程式自己的 log&lt;/strong>，而且這種 log 常常不在 &lt;code>journalctl&lt;/code>、也不在你猜的路徑，要用該程式專屬的 log 指令（例如某桌面 shell 的 &lt;code>&amp;lt;shell&amp;gt; -l&lt;/code>）。log 裡的 &lt;code>TypeError: Cannot read property 'X' of null&lt;/code> 這類訊息，才是「進程活著但子系統死了」的定案證據。另一個更精準的活性探針是程式的 &lt;strong>IPC 回不回真實狀態&lt;/strong>：正常時查詢會回傳資料、子系統死掉時回空——這比「進程在不在」可靠得多。判「進程活著到底有沒有在運作」時，讀它自己的 log 與 IPC，不是看 &lt;code>pgrep&lt;/code> 有沒有輸出。桌面 shell 的具體案例與恢復（讀 &lt;code>caelestia shell -l&lt;/code> 抓到 null 根因、重啟重建 scene）見 &lt;a href="https://tarrragon.github.io/blog/linux/dotfile/07-desktop-maintenance/common-failures-recovery/" data-link-title="常見故障場景與恢復操作" data-link-desc="Hyprland 黑屏、waybar 消失、畫面凍結、記憶體爆掉或 config 寫錯導致進不了桌面時，按症狀查恢復操作">常見故障場景與恢復操作&lt;/a> 的「畫得出來但互動死掉」場景。&lt;/p>
&lt;h2 id="服務由誰提供問註冊表">服務由誰提供：問註冊表&lt;/h2>
&lt;p>「某個系統服務現在由哪個程式在提供」，權威來源是服務註冊，不是畫面。桌面服務多半註冊在 &lt;strong>D-Bus&lt;/strong>（Linux 桌面的行程間訊息匯流排）上：一個服務用一個名字掛在上面，而&lt;strong>同一個名字同一時間只能被一個行程擁有&lt;/strong>。以桌面通知為例，&lt;code>org.freedesktop.Notifications&lt;/code> 這個 D-Bus 名同一時間只有一個擁有者——兩個通知 daemon（例如 mako 跟某個桌面 shell 內建的通知服務）不能共存，誰先註冊誰佔著，後者只能等前者退出。&lt;/p>
&lt;p>想知道現在是誰接管，查註冊表而不是送一則通知看畫面：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 查 org.freedesktop.Notifications 目前被哪個連線擁有&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="nv">owner&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="k">$(&lt;/span>busctl --user call org.freedesktop.DBus /org/freedesktop/DBus &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> org.freedesktop.DBus GetNameOwner s org.freedesktop.Notifications &lt;span class="p">|&lt;/span> awk &lt;span class="s1">&amp;#39;{print $2}&amp;#39;&lt;/span> &lt;span class="p">|&lt;/span> tr -d &lt;span class="s1">&amp;#39;&amp;#34;&amp;#39;&lt;/span>&lt;span class="k">)&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"># 把那個連線換算成 PID，再看行程名&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="nv">pid&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="k">$(&lt;/span>busctl --user call org.freedesktop.DBus /org/freedesktop/DBus &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> org.freedesktop.DBus GetConnectionUnixProcessID s &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$owner&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="p">|&lt;/span> awk &lt;span class="s1">&amp;#39;{print $2}&amp;#39;&lt;/span>&lt;span class="k">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">ps -o &lt;span class="nv">comm&lt;/span>&lt;span class="o">=&lt;/span> -p &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$pid&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>停掉舊 daemon 前擁有者是舊的、停掉後換成新的，就確認接管成功。這比「送通知看畫面有沒有跳」可靠——畫面沒跳可能是勿擾模式吃掉、可能根本沒送出，畫面反應不等於服務歸屬。切換兩個搶同一服務名的 daemon 時，這也解釋了為什麼「新的裝了卻沒作用」：舊的還佔著名字，新的靜默註冊失敗（通常只在它的 log 留一行 warning），得先停掉舊的。&lt;/p></description><content:encoded><![CDATA[<p>判斷「某個東西現在是什麼狀態」——程式活著沒、服務由誰提供、螢幕鎖了沒、session 還在不在——是除錯裡最常做、也最常判錯的一步。判錯多半不是工具不對，是問錯了來源：用一個猜的名字去掃行程、用畫面有沒有反應去推服務狀態、用畫面上有沒有某個元素去斷定 session 狀態。這篇把幾個常見的狀態判斷，對到它們各自的權威來源與正確工具。</p>
<p>底層的心法（讀權威狀態、不靠肉眼）見 <a href="../diagnosis-read-authoritative-state/">診斷心法</a>，這篇是它在「程序 / 服務 / 狀態」這一類的具體招式。</p>
<h2 id="程式活著沒比對正確的行程名">程式活著沒：比對正確的行程名</h2>
<p>判斷一個程式在不在，行程表是權威來源，<code>pgrep</code> / <code>ps</code> 是對的工具，但成敗在於<strong>比對正確的行程名</strong>（comm，行程表裡記的執行檔短名，可從 <code>/proc/&lt;pid&gt;/comm</code> 看）。一個實際的坑：某個桌面 shell（畫桌面 UI 的圖形程式，不是 bash/zsh 那種命令列 shell）的可執行檔叫 <code>quickshell</code>，但透過名為 <code>qs</code> 的 symlink 啟動時，它在行程表裡的 comm 是 <code>qs</code>。這時 <code>pgrep quickshell</code> 找不到，很容易誤判成程式掛了、甚至誤觸「重啟」而引發更大的問題，實際上它以 <code>qs</code> 這個名字好好跑著。</p>
<p>可靠的做法：</p>
<ul>
<li>先確認實際的 comm 名：<code>ps -eo pid,comm | grep -i &lt;關鍵字&gt;</code>，或看你啟動它的實際指令。</li>
<li>用精確比對：<code>pgrep -x &lt;comm&gt;</code>（<code>-x</code> 要求完全相符），或 <code>pgrep -af &lt;pattern&gt;</code> 連完整命令列一起比對，避免被 symlink 名 / 縮寫名騙。</li>
<li>另一個 comm 的坑：kernel 把 comm 截在 15 字元（<code>TASK_COMM_LEN</code>），名字超過 15 字的程式用 <code>pgrep -x &lt;完整長名&gt;</code> 反而 miss——這時改用 <code>pgrep -af &lt;pattern&gt;</code> 比對完整命令列。</li>
<li>別用一個「你以為的名字」掃過去就下生死結論——行程表沒騙你，是查詢條件寫錯。</li>
</ul>
<h3 id="進程活著--內部子系統活著">進程活著 ≠ 內部子系統活著</h3>
<p>比對到了正確的 comm、<code>pgrep</code> 也有輸出，只證明「這個進程存在」，不證明「它內部在正常運作」。有一類故障是進程好端端活著（<code>pgrep</code> 找得到、STAT 是正常的 <code>S</code>、在 <code>poll</code> 等事件、CPU 不高），但它內部某個子系統已經 wedged——例如一個圖形 shell 的 QML scene 因為上游錯誤（渲染 pipeline 建失敗之類）某個物件沒建起來變 null，於是負責互動的模組全部失效。表現是 bar 還畫得出來、卻點不動，keybind 叫不出東西，但焦點視窗打字正常。這時 <code>pgrep</code> 會騙你說「在跑」。</p>
<p>這種情況權威來源不是行程表，是<strong>程式自己的 log</strong>，而且這種 log 常常不在 <code>journalctl</code>、也不在你猜的路徑，要用該程式專屬的 log 指令（例如某桌面 shell 的 <code>&lt;shell&gt; -l</code>）。log 裡的 <code>TypeError: Cannot read property 'X' of null</code> 這類訊息，才是「進程活著但子系統死了」的定案證據。另一個更精準的活性探針是程式的 <strong>IPC 回不回真實狀態</strong>：正常時查詢會回傳資料、子系統死掉時回空——這比「進程在不在」可靠得多。判「進程活著到底有沒有在運作」時，讀它自己的 log 與 IPC，不是看 <code>pgrep</code> 有沒有輸出。桌面 shell 的具體案例與恢復（讀 <code>caelestia shell -l</code> 抓到 null 根因、重啟重建 scene）見 <a href="/blog/linux/dotfile/07-desktop-maintenance/common-failures-recovery/" data-link-title="常見故障場景與恢復操作" data-link-desc="Hyprland 黑屏、waybar 消失、畫面凍結、記憶體爆掉或 config 寫錯導致進不了桌面時，按症狀查恢復操作">常見故障場景與恢復操作</a> 的「畫得出來但互動死掉」場景。</p>
<h2 id="服務由誰提供問註冊表">服務由誰提供：問註冊表</h2>
<p>「某個系統服務現在由哪個程式在提供」，權威來源是服務註冊，不是畫面。桌面服務多半註冊在 <strong>D-Bus</strong>（Linux 桌面的行程間訊息匯流排）上：一個服務用一個名字掛在上面，而<strong>同一個名字同一時間只能被一個行程擁有</strong>。以桌面通知為例，<code>org.freedesktop.Notifications</code> 這個 D-Bus 名同一時間只有一個擁有者——兩個通知 daemon（例如 mako 跟某個桌面 shell 內建的通知服務）不能共存，誰先註冊誰佔著，後者只能等前者退出。</p>
<p>想知道現在是誰接管，查註冊表而不是送一則通知看畫面：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 查 org.freedesktop.Notifications 目前被哪個連線擁有</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nv">owner</span><span class="o">=</span><span class="k">$(</span>busctl --user call org.freedesktop.DBus /org/freedesktop/DBus <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  org.freedesktop.DBus GetNameOwner s org.freedesktop.Notifications <span class="p">|</span> awk <span class="s1">&#39;{print $2}&#39;</span> <span class="p">|</span> tr -d <span class="s1">&#39;&#34;&#39;</span><span class="k">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 把那個連線換算成 PID，再看行程名</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nv">pid</span><span class="o">=</span><span class="k">$(</span>busctl --user call org.freedesktop.DBus /org/freedesktop/DBus <span class="se">\
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="se"></span>  org.freedesktop.DBus GetConnectionUnixProcessID s <span class="s2">&#34;</span><span class="nv">$owner</span><span class="s2">&#34;</span> <span class="p">|</span> awk <span class="s1">&#39;{print $2}&#39;</span><span class="k">)</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">ps -o <span class="nv">comm</span><span class="o">=</span> -p <span class="s2">&#34;</span><span class="nv">$pid</span><span class="s2">&#34;</span></span></span></code></pre></div><p>停掉舊 daemon 前擁有者是舊的、停掉後換成新的，就確認接管成功。這比「送通知看畫面有沒有跳」可靠——畫面沒跳可能是勿擾模式吃掉、可能根本沒送出，畫面反應不等於服務歸屬。切換兩個搶同一服務名的 daemon 時，這也解釋了為什麼「新的裝了卻沒作用」：舊的還佔著名字，新的靜默註冊失敗（通常只在它的 log 留一行 warning），得先停掉舊的。</p>
<h2 id="桌面-session-有沒有被鎖認清是哪一層的鎖">桌面 session 有沒有被鎖：認清是哪一層的鎖</h2>
<p>判斷一個圖形 session 有沒有被鎖，最容易被畫面帶偏，因為「畫面上有密碼框」很有說服力、卻不等於 session 真的被鎖（現代桌面 shell 的儀表板常內嵌鎖屏樣式的 widget）。而且鎖有不同層，查錯層會得到誤導的答案。</p>
<p>關鍵是分清兩種鎖：</p>
<ul>
<li><strong>logind 層的鎖</strong>：systemd 登入管理的 session 鎖，權威狀態是 <code>loginctl show-session &lt;id&gt; -p LockedHint</code>。</li>
<li><strong>Wayland 合成器層的鎖</strong>：走 <code>ext-session-lock</code> 協議、由<strong>合成器</strong>（compositor，Wayland 下負責把各視窗合成到螢幕、管輸入輸出的核心程式，約當 X11 時代的視窗管理器加顯示伺服器；Hyprland、Sway 等都是）管的鎖，跟 logind 是獨立機制。這種鎖 <code>loginctl</code> 的 <code>LockedHint</code> <strong>查不到</strong>——不是沒鎖，是查錯層。（用 GNOME / KDE 的鎖屏走的機制不同，以下的 <code>ext-session-lock</code> 判法與復原針對 wlroots 系的 Wayland 合成器。）</li>
</ul>
<p>所以「<code>loginctl</code> 沒有 <code>LockedHint</code>、<code>pgrep</code> 找不到獨立鎖屏程式」不足以斷定「沒鎖」：合成器層的鎖不歸 logind、而鎖屏畫面可能由 shell 主程式在自己行程內畫（沒有獨立可執行檔可抓）。這種情況真正的權威來源是那個 shell 自己的 log（有沒有載入鎖屏模組、idle 計時器有沒有觸發鎖定），或直接看 compositor 的 session-lock 狀態。判鎖看合成器 / shell 的 log，不是 <code>loginctl</code>、更不是畫面有沒有密碼框。</p>
<h3 id="鎖屏程式死掉造成的死局與復原">鎖屏程式死掉造成的死局與復原</h3>
<p><code>ext-session-lock</code> 有一個安全設計：持鎖的鎖屏程式若在鎖定狀態下崩潰 / 被中止，compositor <strong>會保持鎖定</strong>、不會因為鎖屏程式沒了就解鎖（否則殺掉鎖屏程式就成了繞過鎖的漏洞）。表現是畫面卡在「鎖屏程式已死」的安全提示。復原要從另一個 VT 或 SSH 用 <code>hyprctl keyword misc:allow_session_lock_restore 1</code> 允許新鎖屏 client 接管、再 <code>hyprctl dispatch exec hyprlock</code> 起一個接管後輸密碼解鎖。完整機制、兩層鎖的關係、各 compositor 的差異，見 <a href="/blog/linux/dotfile/knowledge-cards/session-lock/" data-link-title="Wayland Session Lock（鎖屏安全狀態）" data-link-desc="hyprlock / swaylock 畫面卡住、pkill 後進不了桌面、或要在 VM / 自動化環境測試鎖屏時回來讀">Wayland Session Lock 卡</a>。</p>
<p>診斷紀律：<strong>測鎖屏、或 <code>pkill</code> 一個持鎖的鎖屏程式時，要預期它把 session 卡在鎖定——這是協議的安全設計，不是 bug。</strong> 自動化 / 無人值守流程尤其要避免在持鎖狀態下殺鎖屏程式。</p>
<h2 id="終端機多工器的-session-還在不在">終端機多工器的 session 還在不在</h2>
<p>用 zellij / tmux 這類多工器跑遠端長任務時，判斷「重連後那個 session 還在不在」的權威來源是多工器自己的 session 列表，不是「我 SSH 斷了所以應該還在吧」的假設。<code>zellij ls</code>（或 <code>tmux ls</code>）會列出 session 與狀態：多工器是常駐在遠端的程序，SSH 斷不影響它，所以只要那台機器沒重開，<code>attach</code> 就能接回去；但如果機器重開過、或那個 session 因為資源不足（例如磁碟滿觸發的連鎖）被殺，列表會顯示它已 <code>EXITED</code> / 不存在，這種接不回去。</p>
<p>這裡有個順序上的紀律：<strong>當一個 session 可能已經死掉、而它裡面跑的任務有你在意的產出時，先確認產出有沒有被安全保存，再處理 session。</strong> 例如任務是在改 git repo，先 <code>git -C &lt;repo&gt; status</code> 跟 <code>git log @{u}..</code>（本地有、遠端沒有的 commit）確認有沒有沒推送的東西、把該推的推掉，再去 <code>zellij delete</code> 清死 session。搞反順序、先清了 session，可能連帶失去唯一還記得那些改動的地方。權威狀態（git 的推送狀態、多工器的 session 列表）先讀清楚，再動手。</p>
<h2 id="判讀路由">判讀路由</h2>
<ul>
<li>判程式活著 → <code>pgrep -x &lt;正確 comm&gt;</code> / <code>pgrep -af &lt;pattern&gt;</code>，先確認實際 comm 名，別用猜的名字。</li>
<li>判進程活著但「有沒有在運作」→ 讀程式自己的 log（可能要用它專屬的 log 指令、不在 journalctl）+ 它的 IPC 回不回真實狀態，不是看 <code>pgrep</code> 有輸出就當正常。</li>
<li>判服務歸誰 → <code>busctl</code> 查 D-Bus name 擁有者 → 換算 PID → comm，不看畫面反應。</li>
<li>判 session 鎖沒鎖 → 分清 logind 層（<code>loginctl LockedHint</code>）vs 合成器層（<code>ext-session-lock</code>，看 compositor / shell log），不看畫面有沒有密碼框。</li>
<li>鎖屏程式死掉卡住 → <code>allow_session_lock_restore</code> + 重起鎖屏程式接管解鎖。</li>
<li>判多工器 session 存活 → <code>zellij ls</code> / <code>tmux ls</code>；可能已死且有在意的產出時，先確認產出已保存 / 已推送再清 session。</li>
</ul>
<p>判不準時，<a href="../diagnosis-read-authoritative-state/">診斷心法</a> 的四步（描述症狀、定位權威來源、用對工具讀、矛盾時信權威）是通用的回退。</p>
]]></content:encoded></item><item><title>服務掛了怎麼自動知道：從肉眼盯到主動告警</title><link>https://tarrragon.github.io/blog/linux/debug/service-failure-monitoring/</link><pubDate>Thu, 02 Jul 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/linux/debug/service-failure-monitoring/</guid><description>&lt;p>服務掛了不需要用肉眼盯——systemd 本來就在追蹤每個 unit 的狀態，你要做的是把「讀權威狀態」這件事自動化，並在狀態變成失敗時主動推播給自己。這篇跟本系列其他篇的差別在時機：診斷是出事後回頭找根因，監控是讓系統在出事的當下就告訴你。兩者共用同一個地基——權威狀態。診斷是手動讀一次權威狀態，監控是訂閱權威狀態的變化、變壞就推播。&lt;/p>
&lt;p>理解這個框架後，監控就不是「裝一套很重的東西」，而是分層選擇：從 systemd 內建的失敗鉤子（不裝任何額外服務），到推播管道，到「整台機器死掉」的體外心跳，到完整的指標儀表板。多數人只需要前一兩層。&lt;/p>
&lt;h2 id="你現在手動在做的事要被取代的基線">你現在手動在做的事（要被取代的基線）&lt;/h2>
&lt;p>在自動化之前，先認清手動版本——這也是所有告警底層讀的同一個權威來源：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">systemctl --failed &lt;span class="c1"># 現在有哪些 unit 處於 failed（開機後系統怪怪的先掃這個）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">systemctl is-failed &amp;lt;unit&amp;gt; &lt;span class="c1"># 單一 unit 明確判失敗（比 is-active 直接）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">journalctl -u &amp;lt;unit&amp;gt; -f &lt;span class="c1"># 即時跟一個 unit 的 log&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>systemctl --failed&lt;/code> 就是「服務死活」的權威清單。手動版的問題不是不準，是你得記得去看。下面每一層都是把「記得去看」換成「壞了它來找你」。&lt;/p>
&lt;h2 id="第一層systemd-原生-onfailure-鉤子不裝額外服務">第一層：systemd 原生 &lt;code>OnFailure&lt;/code> 鉤子（不裝額外服務）&lt;/h2>
&lt;p>systemd 每個 unit 進入 failed 狀態時，可以自動觸發另一個 unit。這是最正統、零額外依賴的做法——告警邏輯就寫成一個普通的 systemd service。它由三塊組成：一個負責送通知的處理器 unit、一個實際送出的腳本、以及在你要監控的 unit 上掛一行 &lt;code>OnFailure=&lt;/code>。&lt;/p>
&lt;p>&lt;strong>通知處理器&lt;/strong>是一個 template unit（&lt;code>@&lt;/code> 表示可帶參數），參數 &lt;code>%i&lt;/code> 會是失敗的那個 unit 名：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ini" data-lang="ini">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># /etc/systemd/system/alert@.service&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">[Unit]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="na">Description&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">Alert on failure of %i&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">[Service]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="na">Type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">oneshot&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="na">ExecStart&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">/usr/local/bin/notify-failure %i&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>送出腳本&lt;/strong>負責把「哪個 unit、在哪台機、什麼時候」推出去。這裡有個實測踩到的坑：在 systemd service 的執行環境下，&lt;code>hostname&lt;/code> 指令可能回傳空字串，要改用 &lt;code>uname -n&lt;/code> 或讀 &lt;code>/etc/hostname&lt;/code> 才穩：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="cp">#!/bin/bash
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="cp">&lt;/span>&lt;span class="c1"># /usr/local/bin/notify-failure （記得 chmod +x）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="nv">unit&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$1&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="c1"># 只在「真正放棄」時告警：OnFailure 每次失敗都觸發（含 auto-restart 中途，見下節實測），&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"># auto-restart 中途 ActiveState 是 activating、撞重試上限才進 failed。gate 掉中途避免洗告警。&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="nv">state&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">$(&lt;/span>systemctl show &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$unit&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> -p ActiveState --value&lt;span class="k">)&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="o">[&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$state&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="o">=&lt;/span> failed &lt;span class="o">]&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="nb">exit&lt;/span> &lt;span class="m">0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="nv">host&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">$(&lt;/span>uname -n&lt;span class="k">)&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="c1"># 不要用 hostname，systemd 環境下可能回空&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="nv">ts&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">$(&lt;/span>date -Is&lt;span class="k">)&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="nv">topic&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;你的私密topic&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">curl -fsS &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> -H &lt;span class="s2">&amp;#34;Title: &lt;/span>&lt;span class="nv">$host&lt;/span>&lt;span class="s2">: &lt;/span>&lt;span class="nv">$unit&lt;/span>&lt;span class="s2"> failed&amp;#34;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> -d &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$unit&lt;/span>&lt;span class="s2"> 於 &lt;/span>&lt;span class="nv">$ts&lt;/span>&lt;span class="s2"> 進入 failed&amp;#34;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="s2">&amp;#34;https://ntfy.sh/&lt;/span>&lt;span class="nv">$topic&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>在要監控的 unit 掛上鉤子&lt;/strong>。針對單一 unit，加一行：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ini" data-lang="ini">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">[Unit]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="na">OnFailure&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">alert@%n.service # %n 是本 unit 的全名，會展開成 alert@&amp;lt;本unit&amp;gt;.service&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>要&lt;strong>一次套用到所有 service&lt;/strong>，用 top-level drop-in（放在 &lt;code>service.d/&lt;/code> 這個型別目錄下的設定會套用到每個 &lt;code>.service&lt;/code>）：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ini" data-lang="ini">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># /etc/systemd/system/service.d/onfailure.conf&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">[Unit]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="na">OnFailure&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">alert@%n.service&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>改完 &lt;code>sudo systemctl daemon-reload&lt;/code>。&lt;strong>一個必須注意的遞迴陷阱&lt;/strong>：全域 drop-in 也會套到 &lt;code>alert@&lt;/code> 自己，它若失敗會觸發自己。給 &lt;code>alert@.service&lt;/code> 一個清空 &lt;code>OnFailure=&lt;/code> 的 override（&lt;code>[Unit]&lt;/code> 段寫 &lt;code>OnFailure=&lt;/code>）擋掉。&lt;/p>
&lt;p>這條鏈是實測驗證過的：故意讓一個 &lt;code>ExecStart=/bin/false&lt;/code> 的測試 service 失敗，systemd log 出現 &lt;code>Triggering OnFailure= dependencies&lt;/code>、&lt;code>alert@&lt;/code> 處理器被觸發跑完、&lt;code>curl&lt;/code> 推到 ntfy 回 HTTP 200——通知確實送出，全程沒有肉眼介入。&lt;/p>
&lt;h3 id="先自動重啟放棄了才吵你">先自動重啟、放棄了才吵你&lt;/h3>
&lt;p>多數暫時性失敗（一次連線抖動、一個 race）自己重試就好，不值得半夜叫醒你。把「自動復原」跟「告警」分兩段：讓 systemd 先重啟幾次，撐過重試上限才真的算放棄。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ini" data-lang="ini">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">[Service]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="na">Restart&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">on-failure&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="na">RestartSec&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">5&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">[Unit]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="na">StartLimitBurst&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">3 # 重試 3 次&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="na">StartLimitIntervalSec&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">60 # 60 秒內都失敗才進 failed（start-limit-hit）&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>這裡有個實測踩到、跟直覺相反的坑&lt;/strong>：&lt;code>OnFailure&lt;/code> 不是「放棄才觸發」，而是&lt;strong>每一次失敗都觸發&lt;/strong>——包含 &lt;code>Restart=on-failure&lt;/code> 的每次 auto-restart 中途。實測一個反覆 crash 的服務（重試 3 次後放棄）觸發了 &lt;strong>4 次&lt;/strong> &lt;code>OnFailure&lt;/code>（3 次 auto-restart + 1 次最終 &lt;code>start-limit-hit&lt;/code>）。所以只靠 &lt;code>Restart=&lt;/code> + &lt;code>StartLimit=&lt;/code> 這段 config，你會被每次瞬斷洗告警。&lt;/p></description><content:encoded><![CDATA[<p>服務掛了不需要用肉眼盯——systemd 本來就在追蹤每個 unit 的狀態，你要做的是把「讀權威狀態」這件事自動化，並在狀態變成失敗時主動推播給自己。這篇跟本系列其他篇的差別在時機：診斷是出事後回頭找根因，監控是讓系統在出事的當下就告訴你。兩者共用同一個地基——權威狀態。診斷是手動讀一次權威狀態，監控是訂閱權威狀態的變化、變壞就推播。</p>
<p>理解這個框架後，監控就不是「裝一套很重的東西」，而是分層選擇：從 systemd 內建的失敗鉤子（不裝任何額外服務），到推播管道，到「整台機器死掉」的體外心跳，到完整的指標儀表板。多數人只需要前一兩層。</p>
<h2 id="你現在手動在做的事要被取代的基線">你現在手動在做的事（要被取代的基線）</h2>
<p>在自動化之前，先認清手動版本——這也是所有告警底層讀的同一個權威來源：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">systemctl --failed          <span class="c1"># 現在有哪些 unit 處於 failed（開機後系統怪怪的先掃這個）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">systemctl is-failed &lt;unit&gt;  <span class="c1"># 單一 unit 明確判失敗（比 is-active 直接）</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">journalctl -u &lt;unit&gt; -f     <span class="c1"># 即時跟一個 unit 的 log</span></span></span></code></pre></div><p><code>systemctl --failed</code> 就是「服務死活」的權威清單。手動版的問題不是不準，是你得記得去看。下面每一層都是把「記得去看」換成「壞了它來找你」。</p>
<h2 id="第一層systemd-原生-onfailure-鉤子不裝額外服務">第一層：systemd 原生 <code>OnFailure</code> 鉤子（不裝額外服務）</h2>
<p>systemd 每個 unit 進入 failed 狀態時，可以自動觸發另一個 unit。這是最正統、零額外依賴的做法——告警邏輯就寫成一個普通的 systemd service。它由三塊組成：一個負責送通知的處理器 unit、一個實際送出的腳本、以及在你要監控的 unit 上掛一行 <code>OnFailure=</code>。</p>
<p><strong>通知處理器</strong>是一個 template unit（<code>@</code> 表示可帶參數），參數 <code>%i</code> 會是失敗的那個 unit 名：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># /etc/systemd/system/alert@.service</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">[Unit]</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">Description</span><span class="o">=</span><span class="s">Alert on failure of %i</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="k">[Service]</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="na">Type</span><span class="o">=</span><span class="s">oneshot</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="na">ExecStart</span><span class="o">=</span><span class="s">/usr/local/bin/notify-failure %i</span></span></span></code></pre></div><p><strong>送出腳本</strong>負責把「哪個 unit、在哪台機、什麼時候」推出去。這裡有個實測踩到的坑：在 systemd service 的執行環境下，<code>hostname</code> 指令可能回傳空字串，要改用 <code>uname -n</code> 或讀 <code>/etc/hostname</code> 才穩：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="cp">#!/bin/bash
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="cp"></span><span class="c1"># /usr/local/bin/notify-failure   （記得 chmod +x）</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="nv">unit</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"># 只在「真正放棄」時告警：OnFailure 每次失敗都觸發（含 auto-restart 中途，見下節實測），</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"># auto-restart 中途 ActiveState 是 activating、撞重試上限才進 failed。gate 掉中途避免洗告警。</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="nv">state</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>systemctl show <span class="s2">&#34;</span><span class="nv">$unit</span><span class="s2">&#34;</span> -p ActiveState --value<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="o">[</span> <span class="s2">&#34;</span><span class="nv">$state</span><span class="s2">&#34;</span> <span class="o">=</span> failed <span class="o">]</span> <span class="o">||</span> <span class="nb">exit</span> <span class="m">0</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="nv">host</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>uname -n<span class="k">)</span><span class="s2">&#34;</span>                     <span class="c1"># 不要用 hostname，systemd 環境下可能回空</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="nv">ts</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>date -Is<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="nv">topic</span><span class="o">=</span><span class="s2">&#34;你的私密topic&#34;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">curl -fsS <span class="se">\
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="se"></span>  -H <span class="s2">&#34;Title: </span><span class="nv">$host</span><span class="s2">: </span><span class="nv">$unit</span><span class="s2"> failed&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="se"></span>  -d <span class="s2">&#34;</span><span class="nv">$unit</span><span class="s2"> 於 </span><span class="nv">$ts</span><span class="s2"> 進入 failed&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="se"></span>  <span class="s2">&#34;https://ntfy.sh/</span><span class="nv">$topic</span><span class="s2">&#34;</span></span></span></code></pre></div><p><strong>在要監控的 unit 掛上鉤子</strong>。針對單一 unit，加一行：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">[Unit]</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">OnFailure</span><span class="o">=</span><span class="s">alert@%n.service    # %n 是本 unit 的全名，會展開成 alert@&lt;本unit&gt;.service</span></span></span></code></pre></div><p>要<strong>一次套用到所有 service</strong>，用 top-level drop-in（放在 <code>service.d/</code> 這個型別目錄下的設定會套用到每個 <code>.service</code>）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># /etc/systemd/system/service.d/onfailure.conf</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">[Unit]</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">OnFailure</span><span class="o">=</span><span class="s">alert@%n.service</span></span></span></code></pre></div><p>改完 <code>sudo systemctl daemon-reload</code>。<strong>一個必須注意的遞迴陷阱</strong>：全域 drop-in 也會套到 <code>alert@</code> 自己，它若失敗會觸發自己。給 <code>alert@.service</code> 一個清空 <code>OnFailure=</code> 的 override（<code>[Unit]</code> 段寫 <code>OnFailure=</code>）擋掉。</p>
<p>這條鏈是實測驗證過的：故意讓一個 <code>ExecStart=/bin/false</code> 的測試 service 失敗，systemd log 出現 <code>Triggering OnFailure= dependencies</code>、<code>alert@</code> 處理器被觸發跑完、<code>curl</code> 推到 ntfy 回 HTTP 200——通知確實送出，全程沒有肉眼介入。</p>
<h3 id="先自動重啟放棄了才吵你">先自動重啟、放棄了才吵你</h3>
<p>多數暫時性失敗（一次連線抖動、一個 race）自己重試就好，不值得半夜叫醒你。把「自動復原」跟「告警」分兩段：讓 systemd 先重啟幾次，撐過重試上限才真的算放棄。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">[Service]</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="na">Restart</span><span class="o">=</span><span class="s">on-failure</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">RestartSec</span><span class="o">=</span><span class="s">5</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="k">[Unit]</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="na">StartLimitBurst</span><span class="o">=</span><span class="s">3          # 重試 3 次</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="na">StartLimitIntervalSec</span><span class="o">=</span><span class="s">60   # 60 秒內都失敗才進 failed（start-limit-hit）</span></span></span></code></pre></div><p><strong>這裡有個實測踩到、跟直覺相反的坑</strong>：<code>OnFailure</code> 不是「放棄才觸發」，而是<strong>每一次失敗都觸發</strong>——包含 <code>Restart=on-failure</code> 的每次 auto-restart 中途。實測一個反覆 crash 的服務（重試 3 次後放棄）觸發了 <strong>4 次</strong> <code>OnFailure</code>（3 次 auto-restart + 1 次最終 <code>start-limit-hit</code>）。所以只靠 <code>Restart=</code> + <code>StartLimit=</code> 這段 config，你會被每次瞬斷洗告警。</p>
<p>真正做到「只在放棄才吵」，靠的是上面送出腳本開頭那道 gate：<code>systemctl show &lt;unit&gt; -p ActiveState</code> 在 auto-restart 中途是 <code>activating</code>、撞上限進 failed 才是 <code>failed</code>，腳本只在 <code>failed</code> 才送。加上 gate 後同一個 crash 測試從 4 次告警降到 1 次（只剩最終放棄那次）。config 負責「重試幾次」，handler 的 gate 負責「只在終局告警」——兩段合起來才是完整的「先重啟、放棄才吵」。</p>
<h3 id="抓進程活著但沒在做事外部健康探針">抓「進程活著但沒在做事」：外部健康探針</h3>
<p><code>OnFailure</code> 抓的是「進程狀態變了」——crash、exit、被 kill。但服務可能<strong>進程還在、卻沒在做事</strong>：hung、deadlock、內部子系統壞掉。這種 systemd 看它還 <code>active</code>、不會觸發任何告警——正是<a href="../process-service-state-diagnosis/">「進程活著 ≠ 在運作」</a>那條，搬到監控場景。</p>
<p>要抓這種，得從外面<strong>主動戳它、看它回不回應</strong>：一個 timer 定時對服務發一個健康請求（HTTP 服務就 curl 它的 <code>/health</code>）並設逾時；戳不動、逾時失敗，就讓「那個檢查」自己 failed，一樣走 <code>OnFailure</code> 告警。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># health-check.service（oneshot）+ 一個每 2 分鐘跑的 .timer</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">[Service]</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">Type</span><span class="o">=</span><span class="s">oneshot</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="na">ExecStart</span><span class="o">=</span><span class="s">/usr/bin/curl -fsS --max-time 5 http://127.0.0.1:8899/health</span></span></span></code></pre></div><p>實測對照最清楚：讓一個健康服務卡在 <code>sleep</code>（進程還在、單執行緒不再回應），<code>systemctl is-active</code> 仍顯示 <code>active</code>——systemd 沒察覺；但這個外部探針 curl <code>/health</code> 5 秒逾時、check 失敗、告警發出。<strong>systemd 抓進程死、外部探針抓進程活著但 hung，兩層互補、缺一漏一種。</strong></p>
<h3 id="canary先證明告警管線本身是好的">canary：先證明告警管線本身是好的</h3>
<p>監控最怕的失效模式是「出事時才發現它早就不會叫了」。防這個的辦法是養一隻 <strong>canary</strong>——一個你可控的假服務，專門用來確認整條管線是活的。它一物兩用：</p>
<ul>
<li><strong>驗證管線</strong>：故意弄掛它，看「失敗 → OnFailure → 推送」真的一路通到你手機，不必拿 sshd 這種真服務去冒險。</li>
<li><strong>當活性訊號</strong>：它自己若無故失敗告警，等於告訴你告警系統本身還在運作。</li>
</ul>
<p>做法是一個極簡 HTTP 服務（stdlib 就夠、不必框架），留幾個測試入口：<code>/health</code> 正常回、<code>/crash</code> 故意退出（測 <code>OnFailure</code>）、<code>/hang</code> 進程活著但不回應（測外部探針）。這樣任何時候都能一鍵重驗監控沒有默默失效。</p>
<h2 id="第二層推去哪裡關鍵是能離開這台機器">第二層：推去哪裡（關鍵是能離開這台機器）</h2>
<p>處理器腳本裡那一段 <code>curl</code> 可以換成任何管道：</p>
<ul>
<li><strong>ntfy</strong>（<code>ntfy.sh</code> 或自架）：一行 <code>curl</code> 推到手機，最省事，上面的例子就是。它怎麼運作、公共站 vs 自架、以及「topic 名稱就是唯一的密碼」這個安全模型，見 <a href="../ntfy-push-notification-service/">ntfy：推送通知服務</a>。</li>
<li><strong>email</strong>：要先設好一個 MTA（如 <code>msmtp</code>），腳本改成 <code>mail</code> / <code>sendmail</code>。</li>
<li><strong>Telegram bot、Apprise</strong>（一個工具打多個目標）等。</li>
</ul>
<p>判準只有一條：<strong>告警要送到機器外</strong>。送桌面 <code>notify-send</code> 只有你正盯著螢幕時才有用；送手機或 email，離開座位、人在外面也收得到。一台跑正事的機器，告警管道應該落在它之外。</p>
<h2 id="第三層整台機器死掉怎麼辦監控自己的盲點">第三層：整台機器死掉怎麼辦（監控自己的盲點）</h2>
<p><code>OnFailure</code> 有個根本限制：<strong>它靠 systemd 觸發，機器整台掛了（當機、斷電、kernel panic），systemd 自己都沒了，發不出任何告警。</strong> 這是所有「機器自己監控自己」方案的共同盲點——它報得了服務的死，報不了自己這台的死。</p>
<p>覆蓋這一層要反過來做：讓機器定時對一個<strong>體外</strong>的服務「報平安」，平安訊號一停，由那個體外服務替你告警。這叫 dead-man&rsquo;s switch（心跳監控）。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># /etc/systemd/system/heartbeat.service</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">[Service]</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="na">Type</span><span class="o">=</span><span class="s">oneshot</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="na">ExecStart</span><span class="o">=</span><span class="s">curl -fsS https://hc-ping.com/&lt;你的-uuid&gt;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># 搭配一個 heartbeat.timer，OnUnitActiveSec=5min 定時打</span></span></span></code></pre></div><p>心跳超過設定時間沒到，healthchecks.io（或自架的 Uptime Kuma）就通知你。<strong>體內的監控管不了自己這台的死亡，一定要有體外的一隻眼睛</strong>——這跟本系列 <a href="../machine-unreachable/">機器連不到或起不來</a> 是同一個問題的兩面：那篇是機器已經不回應時從外面怎麼查，心跳是讓「不回應」這件事本身自動觸發告警。</p>
<h2 id="第四層要指標趨勢門檻不只是-updown">第四層：要指標、趨勢、門檻（不只是 up/down）</h2>
<p>當你要的不只是「掛了沒」，而是 CPU、記憶體、磁碟、延遲的趨勢與門檻告警（例如磁碟用量超過 80% 就先警告，接上本系列反覆出現的「磁碟滿連鎖」），就進到完整監控堆疊：</p>
<table>
  <thead>
      <tr>
          <th>工具</th>
          <th>定位</th>
          <th>什麼時候選它</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Netdata</td>
          <td>開箱即用、自帶大量預設告警</td>
          <td>單機、想要圖表 + 門檻告警、最不想設定</td>
      </tr>
      <tr>
          <td>Monit</td>
          <td>輕量、每服務健康檢查 + 自動動作</td>
          <td>要「掛了自動跑一段修復腳本」、超出 systemd <code>Restart=</code> 能表達的邏輯</td>
      </tr>
      <tr>
          <td>Prometheus + Alertmanager</td>
          <td>指標抓取 + 告警規則引擎</td>
          <td>多台機器、要歷史數據與可擴展的告警規則</td>
      </tr>
      <tr>
          <td>Uptime Kuma</td>
          <td>自架的 up/down + 心跳面板</td>
          <td>想要一個面板統一看多台/多服務、也能當第三層的心跳接收端</td>
      </tr>
  </tbody>
</table>
<p>這一層不是每個人都需要。單機、只想知道某個服務死活，第一層就夠；要看趨勢、跨機、設門檻，才值得付這層的設定與維運成本。</p>
<h2 id="先確認有沒有沒有就從最簡單開始">先確認有沒有，沒有就從最簡單開始</h2>
<p>監控最好在出事之前就建好，不是等第一次沒人發現的當機才想到。有兩個時機該主動確認這台機器有沒有在監控自己：<strong>裝好一台新機器時</strong>，跟<strong>發現自己反覆在除同一個服務的失敗時</strong>。確認的方式就是讀權威狀態：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">systemctl --failed                      <span class="c1"># 現在有沒有 failed 的</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">systemctl show sshd -p OnFailure        <span class="c1"># 關鍵服務有沒有掛告警鉤子</span></span></span></code></pre></div><p>沒有任何監控的話，<strong>從最簡單那層開始建，別一開始就上重的</strong>：第一層的 <code>OnFailure</code> + ntfy 就能讓「服務掛了」主動找上你，零額外 daemon、幾個檔案就設好。遠端機器至少把 sshd 掛上——它掛了你就失聯，是最該先監控的一個。等你真的需要趨勢圖、跨機、或告警內容不能經過第三方時，再往自架 ntfy（帳號 + ACL）跟完整監控堆疊爬。多數單機、個人用的情境，停在第一層就夠。</p>
<h2 id="依情境選">依情境選</h2>
<p>把上面四層對回你實際要監控的東西：</p>
<ul>
<li><strong>某個 service 掛了想被通知</strong> → 第一層 <code>OnFailure</code> drop-in + ntfy。不裝額外 daemon，最貼近 systemd。</li>
<li><strong>希望先自動重啟、救不回來才告警</strong> → 第一層再加 <code>Restart=on-failure</code> + <code>StartLimit*</code>。</li>
<li><strong>怕整台機器當掉沒人知道</strong> → 第三層心跳 / dead-man switch。這層體內方案覆蓋不到，必須體外。</li>
<li><strong>要看資源趨勢、跨多台、設門檻告警</strong> → 第四層，單機用 Netdata、多機用 Prometheus 堆疊。</li>
</ul>
<p>判準是先分清你要監控的層級：<strong>單一 service 的死活、整台機器的死活、還是資源的趨勢</strong>——三種對應不同層，別拿其中一種去蓋另一種。最常見的誤區是以為體內的 <code>OnFailure</code> 能報自己這台的當機，那正是它的盲點。</p>
<h2 id="下一步">下一步</h2>
<ul>
<li>告警把你叫來之後，怎麼判那個服務到底是什麼狀態（failed、restart loop、還是活著但子系統 wedged）→ <a href="../process-service-state-diagnosis/">程序、服務與狀態怎麼判</a>。</li>
<li>機器完全不回應、心跳斷掉之後從外面怎麼查 → <a href="../machine-unreachable/">機器連不到或起不來</a>。</li>
<li>底層那套「讀權威狀態、不靠肉眼猜」的判讀紀律 → <a href="../diagnosis-read-authoritative-state/">診斷心法</a>。</li>
</ul>
]]></content:encoded></item><item><title>同一個元件在三種互動狀態下顯示位置不同的 root cause</title><link>https://tarrragon.github.io/blog/report/component-tristate-root-cause/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/component-tristate-root-cause/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>元件位置 = 定位基準（anchor）+ 相對基準的偏移。元件「跟著狀態飄」不是元件本身的問題、是它的 anchor 隨狀態在動。&lt;/strong> Debug 時把元件位置拆成「找錨點 → 算偏移」兩層、確認哪一層在隨狀態變化。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼狀態化錯位的根因不在元件本身">為什麼狀態化錯位的根因不在元件本身&lt;/h2>
&lt;h3 id="商業邏輯">商業邏輯&lt;/h3>
&lt;p>CSS 計算元件位置時，元件總是「相對某個 reference」 — block flow 是「上一個 sibling 的下緣」、absolute 是 offset parent、grid item 是 grid container。&lt;strong>這個 reference 才是元件位置的決定因素&lt;/strong>。&lt;/p>
&lt;p>當 reference 在不同狀態下尺寸或位置變動，元件被動跟著動 — 看起來是元件「自己飄」，根因卻在 reference。&lt;/p>
&lt;h3 id="三層拆解-debug-法">三層拆解 debug 法&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>層&lt;/th>
 &lt;th>問題&lt;/th>
 &lt;th>修法&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>1. 元件本身&lt;/td>
 &lt;td>元件 CSS 規則錯了？&lt;/td>
 &lt;td>看元件的 computed style&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>2. 元件的 reference&lt;/td>
 &lt;td>reference 在動嗎？尺寸隨狀態變動？&lt;/td>
 &lt;td>量 reference 在每個狀態下的 bounding rect&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>3. Reference 的 reference&lt;/td>
 &lt;td>上一層也在動嗎？&lt;/td>
 &lt;td>一層一層往上追&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>多數狀態化錯位的根因在第 2 或第 3 層、不在第 1 層。&lt;/p>
&lt;hr>
&lt;h2 id="這次任務的實際情境">這次任務的實際情境&lt;/h2>
&lt;h3 id="觀察">觀察&lt;/h3>
&lt;p>新加 scope UI（搜尋範圍 radio group）後出現三個狀態的位置不一致：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>狀態&lt;/th>
 &lt;th>scope UI 位置&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>初始載入（pagefind 還沒 mount）&lt;/td>
 &lt;td>緊接 H1 下方&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>點擊 input（focus、空查詢）&lt;/td>
 &lt;td>在 input 與 results 區之間（如預期）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>輸入查詢（results 載入後）&lt;/td>
 &lt;td>跑到所有結果的最下方&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="判讀">判讀&lt;/h3>
&lt;p>第一輪猜測：scope UI 自己的 CSS 在不同狀態下不同 — 用 playwright 看 computed style，發現三狀態下 scope 的 grid-row 都是 3、CSS 屬性沒變。&lt;/p>
&lt;p>第二輪：用 playwright &lt;code>getBoundingClientRect()&lt;/code> 量 scope 的位置，發現 y 座標確實在三狀態下不同。&lt;/p>
&lt;p>第三輪：往上一層看「scope 的 grid container 是誰、container 的 grid template 在不同狀態下變了嗎」。發現 search-shell 的 grid template-rows 是 &lt;code>auto&lt;/code>、自動依子元素內容撐開。&lt;/p>
&lt;p>關鍵發現：&lt;strong>&lt;code>.pagefind-ui__drawer&lt;/code> 不是 &lt;code>.pagefind-ui&lt;/code> 的直接子節點 — 它在 &lt;code>&amp;lt;form&amp;gt;&lt;/code> 內&lt;/strong>。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">.pagefind-ui (display: contents)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">└── form.pagefind-ui__form (grid-row: 2)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> └── div.pagefind-ui__drawer (grid-row: 4 設了沒生效)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>於是：&lt;/p>
&lt;ul>
&lt;li>初始：form 只含 input、row 2 矮、scope 在 row 3 緊接 row 2 下。&lt;/li>
&lt;li>輸入後：form 含 input + drawer（187 個結果）、row 2 撐到全頁高。grid-row 4 比 row 2 後 — 但 drawer 被 form 包住、整個 form 在 row 2 — scope（row 3）在 form 之後 = 結果之後。&lt;/li>
&lt;/ul>
&lt;p>scope 的 anchor（grid container 的 row 排列）在 form 撐開時改變 — anchor 在動、scope 跟著動。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>元件位置 = 定位基準（anchor）+ 相對基準的偏移。元件「跟著狀態飄」不是元件本身的問題、是它的 anchor 隨狀態在動。</strong> Debug 時把元件位置拆成「找錨點 → 算偏移」兩層、確認哪一層在隨狀態變化。</p>
<hr>
<h2 id="為什麼狀態化錯位的根因不在元件本身">為什麼狀態化錯位的根因不在元件本身</h2>
<h3 id="商業邏輯">商業邏輯</h3>
<p>CSS 計算元件位置時，元件總是「相對某個 reference」 — block flow 是「上一個 sibling 的下緣」、absolute 是 offset parent、grid item 是 grid container。<strong>這個 reference 才是元件位置的決定因素</strong>。</p>
<p>當 reference 在不同狀態下尺寸或位置變動，元件被動跟著動 — 看起來是元件「自己飄」，根因卻在 reference。</p>
<h3 id="三層拆解-debug-法">三層拆解 debug 法</h3>
<table>
  <thead>
      <tr>
          <th>層</th>
          <th>問題</th>
          <th>修法</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1. 元件本身</td>
          <td>元件 CSS 規則錯了？</td>
          <td>看元件的 computed style</td>
      </tr>
      <tr>
          <td>2. 元件的 reference</td>
          <td>reference 在動嗎？尺寸隨狀態變動？</td>
          <td>量 reference 在每個狀態下的 bounding rect</td>
      </tr>
      <tr>
          <td>3. Reference 的 reference</td>
          <td>上一層也在動嗎？</td>
          <td>一層一層往上追</td>
      </tr>
  </tbody>
</table>
<p>多數狀態化錯位的根因在第 2 或第 3 層、不在第 1 層。</p>
<hr>
<h2 id="這次任務的實際情境">這次任務的實際情境</h2>
<h3 id="觀察">觀察</h3>
<p>新加 scope UI（搜尋範圍 radio group）後出現三個狀態的位置不一致：</p>
<table>
  <thead>
      <tr>
          <th>狀態</th>
          <th>scope UI 位置</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>初始載入（pagefind 還沒 mount）</td>
          <td>緊接 H1 下方</td>
      </tr>
      <tr>
          <td>點擊 input（focus、空查詢）</td>
          <td>在 input 與 results 區之間（如預期）</td>
      </tr>
      <tr>
          <td>輸入查詢（results 載入後）</td>
          <td>跑到所有結果的最下方</td>
      </tr>
  </tbody>
</table>
<h3 id="判讀">判讀</h3>
<p>第一輪猜測：scope UI 自己的 CSS 在不同狀態下不同 — 用 playwright 看 computed style，發現三狀態下 scope 的 grid-row 都是 3、CSS 屬性沒變。</p>
<p>第二輪：用 playwright <code>getBoundingClientRect()</code> 量 scope 的位置，發現 y 座標確實在三狀態下不同。</p>
<p>第三輪：往上一層看「scope 的 grid container 是誰、container 的 grid template 在不同狀態下變了嗎」。發現 search-shell 的 grid template-rows 是 <code>auto</code>、自動依子元素內容撐開。</p>
<p>關鍵發現：<strong><code>.pagefind-ui__drawer</code> 不是 <code>.pagefind-ui</code> 的直接子節點 — 它在 <code>&lt;form&gt;</code> 內</strong>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">.pagefind-ui (display: contents)
</span></span><span class="line"><span class="ln">2</span><span class="cl">└── form.pagefind-ui__form (grid-row: 2)
</span></span><span class="line"><span class="ln">3</span><span class="cl">    └── div.pagefind-ui__drawer (grid-row: 4 設了沒生效)</span></span></code></pre></div><p>於是：</p>
<ul>
<li>初始：form 只含 input、row 2 矮、scope 在 row 3 緊接 row 2 下。</li>
<li>輸入後：form 含 input + drawer（187 個結果）、row 2 撐到全頁高。grid-row 4 比 row 2 後 — 但 drawer 被 form 包住、整個 form 在 row 2 — scope（row 3）在 form 之後 = 結果之後。</li>
</ul>
<p>scope 的 anchor（grid container 的 row 排列）在 form 撐開時改變 — anchor 在動、scope 跟著動。</p>
<h3 id="執行">執行</h3>
<p>確認 anchor 問題後改用 absolute 定位：scope 浮在 form 之上、drawer 用 margin-top 讓位。scope 的 anchor 改為 <code>.search-shell</code> 的 <code>position: relative</code>、不再依賴 form 的尺寸。三狀態下位置一致。</p>
<hr>
<h2 id="拆解-anchor-的四個工具">拆解 anchor 的四個工具</h2>
<h3 id="1-找元件的-reference">1. 找元件的 reference</h3>
<table>
  <thead>
      <tr>
          <th>元件的 position</th>
          <th>Reference</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>static（預設）</td>
          <td>上一個 sibling 的下緣 / 父 container</td>
      </tr>
      <tr>
          <td>relative</td>
          <td>元件原本在 flow 中的位置</td>
      </tr>
      <tr>
          <td>absolute</td>
          <td>最近的 positioned ancestor</td>
      </tr>
      <tr>
          <td>fixed</td>
          <td>viewport</td>
      </tr>
      <tr>
          <td>sticky</td>
          <td>滾動容器</td>
      </tr>
      <tr>
          <td>Grid item</td>
          <td>Grid container 的 cell</td>
      </tr>
      <tr>
          <td>Flex item</td>
          <td>Flex container 的軸線</td>
      </tr>
  </tbody>
</table>
<h3 id="2-用-getboundingclientrect-量">2. 用 <code>getBoundingClientRect</code> 量</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">const</span> <span class="nx">el</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.search-scope&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="nx">el</span><span class="p">.</span><span class="nx">getBoundingClientRect</span><span class="p">());</span></span></span></code></pre></div><p>在三個狀態下分別量、比對 y 座標。差異對應到「reference 在動」。</p>
<h3 id="3-往上追-ancestor-chain">3. 往上追 ancestor chain</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">let</span> <span class="nx">parents</span> <span class="o">=</span> <span class="p">[];</span> <span class="kd">let</span> <span class="nx">el</span> <span class="o">=</span> <span class="nx">target</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">while</span> <span class="p">(</span><span class="nx">el</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">parents</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="nx">el</span><span class="p">.</span><span class="nx">tagName</span> <span class="o">+</span> <span class="s1">&#39;.&#39;</span> <span class="o">+</span> <span class="nx">el</span><span class="p">.</span><span class="nx">className</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nx">el</span> <span class="o">=</span> <span class="nx">el</span><span class="p">.</span><span class="nx">parentElement</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="nx">parents</span><span class="p">);</span></span></span></code></pre></div><p>找出 reference 是誰、reference 的 reference 是誰、一層一層追到「不會動」的元素。</p>
<h3 id="4-computed-style-vs-dom-tree-一起看">4. Computed style vs DOM tree 一起看</h3>
<p>CSS 規則在 computed style 顯示為「我設了什麼」、DOM tree 顯示「實際巢狀關係」。兩者一起看才知道規則為什麼沒生效。</p>
<hr>
<h2 id="內在屬性比較三種定位策略對狀態化錯位的抵抗">內在屬性比較：三種定位策略對狀態化錯位的抵抗</h2>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>Anchor 穩定性</th>
          <th>狀態化飄移風險</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Static / block flow</td>
          <td>低 — 任何前置元素變動都影響</td>
          <td>高 — sibling 撐高就被推下去</td>
      </tr>
      <tr>
          <td>Grid / Flex item</td>
          <td>中 — 跟 container 設計綁定</td>
          <td>中 — container row 撐開時跟著動</td>
      </tr>
      <tr>
          <td>Absolute（自定義 offset parent）</td>
          <td>高 — anchor 是固定 ancestor</td>
          <td>低 — anchor 不變則元件不動</td>
      </tr>
      <tr>
          <td>Fixed</td>
          <td>最高 — anchor 是 viewport</td>
          <td>不會因內容變動飄移、但會因捲動變化</td>
      </tr>
  </tbody>
</table>
<p>當一個元件需要在多種狀態下保持固定位置 — 優先 absolute（搭配明確的 offset parent）。</p>
<hr>
<h2 id="設計取捨對抗狀態化飄移的定位策略">設計取捨：對抗狀態化飄移的定位策略</h2>
<p>四種做法、各自機會成本不同。這個專案選 A（absolute + 自定義 offset parent）當預設、其他做法在特定情境合理。</p>
<h3 id="aabsolute--穩定-offset-parent這個專案的預設">A：Absolute + 穩定 offset parent（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：元件 <code>position: absolute</code>、選定一個尺寸不隨狀態變動的 ancestor 作為 offset parent</li>
<li><strong>選 A 的理由</strong>：anchor 不變則元件不動、跨所有互動狀態位置一致</li>
<li><strong>適合</strong>：需要在多狀態下保持固定位置的元件</li>
<li><strong>代價</strong>：跳出 layout flow、附近元件需要手動讓位（margin spacer）</li>
</ul>
<h3 id="bgrid--flex-item">B：Grid / Flex item</h3>
<ul>
<li><strong>機制</strong>：把元件當 grid / flex container 的子項、用 grid-row / flex-order 排</li>
<li><strong>跟 A 的取捨</strong>：B 自然 reflow、A 完全 anchor-driven；B 在 container 內容隨狀態撐開時、grid 排序跟著重算</li>
<li><strong>B 比 A 好的情境</strong>：container 尺寸不隨狀態變動的場景（純 layout、內容靜態）</li>
</ul>
<h3 id="cstatic--block-flow預設-layout">C：Static / block flow（預設 layout）</h3>
<ul>
<li><strong>機制</strong>：不設 position、跟 sibling 自然排</li>
<li><strong>跟 A/B 的取捨</strong>：C 最簡單、A/B 主動處理 anchor；C 完全受前置 sibling 影響、狀態化飄移風險最高</li>
<li><strong>C 才合理的情境</strong>：頁面內容極穩定、無狀態切換 — 否則第 N 個元素位置受前 N-1 個元素影響</li>
</ul>
<h3 id="dfixed相對-viewport">D：Fixed（相對 viewport）</h3>
<ul>
<li><strong>機制</strong>：<code>position: fixed</code>、anchor 是 viewport</li>
<li><strong>跟 A 的取捨</strong>：D 永遠在 viewport 同位置、A 跟著內容；D 對「導航類元件」合理、對「內容相關元件」不合理</li>
<li><strong>D 比 A 好的情境</strong>：永遠可見的功能元件（toolbar、scroll-to-top button）</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>可能的根因</th>
          <th>第一個該嘗試的動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>元件位置在不同互動狀態下不同</td>
          <td>Anchor 隨狀態變動</td>
          <td>用 playwright 量三個狀態下的 bounding rect</td>
      </tr>
      <tr>
          <td>Computed style 三狀態下都一樣、但位置不同</td>
          <td>Reference 元素的尺寸在動</td>
          <td>量 reference 元素的尺寸、確認哪個狀態下變大</td>
      </tr>
      <tr>
          <td>改元件 CSS 一個狀態好了、另一個壞</td>
          <td>用了 reference-dependent layout</td>
          <td>改用 absolute、選擇穩定的 offset parent</td>
      </tr>
      <tr>
          <td>元件初始正確、互動後跑掉</td>
          <td>Reference 因 reactivity 撐開</td>
          <td>找出該 reference、用 absolute 跳出其影響</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：元件「會飄」不是元件的個性、是它依賴的東西在飄。先找飄的源頭，不要追著元件改。</p>
]]></content:encoded></item><item><title>從色塊 placeholder 開始的漸進式 UI 除錯</title><link>https://tarrragon.github.io/blog/report/placeholder-driven-ui-debug/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/placeholder-driven-ui-debug/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>UI 除錯的最小可驗證單位是「一個有明顯邊界的色塊」。&lt;/strong> 版型用色塊先驗證 grid / flex / absolute 是否如預期排在該在的位置，確定後再串實際內容。一次組裝完整 UI 在版型錯時 debug 困難 — 顏色、字型、邊距、padding 全部一起出問題、根因混雜難辨。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼色塊比實際內容更適合-debug">為什麼色塊比實際內容更適合 debug&lt;/h2>
&lt;h3 id="商業邏輯">商業邏輯&lt;/h3>
&lt;p>UI 由「位置、尺寸、視覺樣式、互動」四層組成。Debug 時要分層處理 — 一次只解一層、解完再下一層。&lt;/p>
&lt;p>色塊把後三層都拿掉、只留「位置與尺寸」 — 看到的就是 layout 規則的純粹結果。實際內容把所有層混在一起、看到的位置可能受字型 advance、line-height、margin collapse 等多重因素影響、難以歸因。&lt;/p>
&lt;h3 id="漸進式組裝順序">漸進式組裝順序&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>階段&lt;/th>
 &lt;th>內容&lt;/th>
 &lt;th>驗證重點&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>1&lt;/td>
 &lt;td>色塊（紅 / 藍背景、固定 width / height）&lt;/td>
 &lt;td>grid / flex / absolute 排對位置嗎？&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>2&lt;/td>
 &lt;td>加 placeholder 文字&lt;/td>
 &lt;td>文字尺寸符合預期嗎？換行行為對嗎？&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>3&lt;/td>
 &lt;td>加 padding / border / 圓角等視覺樣式&lt;/td>
 &lt;td>視覺樣式不破壞 layout 嗎？&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>4&lt;/td>
 &lt;td>換上實際內容 / 接上資料&lt;/td>
 &lt;td>動態內容變動時 layout 還對嗎？&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>5&lt;/td>
 &lt;td>加互動（hover / click / focus）&lt;/td>
 &lt;td>互動狀態下 layout 還對嗎？&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>每階段獨立驗證、有問題就停在那階段修。&lt;/p>
&lt;hr>
&lt;h2 id="這次任務的實際應用">這次任務的實際應用&lt;/h2>
&lt;h3 id="觀察">觀察&lt;/h3>
&lt;p>要驗證搜尋頁的版型：「左側 filter sidebar + 右側中央內容（H1、search input、results）」。&lt;/p>
&lt;p>第一次嘗試：直接把 Pagefind UI 組起來、調 CSS。結果版型錯時不知道是哪層問題 — 是 grid 排序錯？是 sidebar 寬度錯？是 padding 推到位置不對？&lt;/p>
&lt;h3 id="判讀">判讀&lt;/h3>
&lt;p>退回最小可驗證單位：把 filter 整個換成一個寫死寬度的紅色色塊：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-html" data-lang="html">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">aside&lt;/span> &lt;span class="na">class&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;search-filter-debug&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> filter 區（先寫死寬度與底色驗證版型）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">aside&lt;/span>&lt;span class="p">&amp;gt;&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-css" data-lang="css">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">.&lt;/span>&lt;span class="nc">search-filter-debug&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">width&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">400&lt;/span>&lt;span class="kt">px&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">background&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">red&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">min-height&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">240&lt;/span>&lt;span class="kt">px&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="k">position&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">absolute&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="c">/* ... */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>紅色背景一眼看出色塊在哪 — 確認了：&lt;/p>
&lt;ul>
&lt;li>色塊在 main 左外側（符合）&lt;/li>
&lt;li>色塊頂端對齊 H1（符合）&lt;/li>
&lt;li>寬度 400px、與 main 間距 2rem（符合）&lt;/li>
&lt;/ul>
&lt;p>版型驗證後再換上實際 filter UI。&lt;/p>
&lt;h3 id="執行的迭代步驟">執行的迭代步驟&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>步驟&lt;/th>
 &lt;th>動作&lt;/th>
 &lt;th>驗證&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>1&lt;/td>
 &lt;td>紅色色塊代替 filter&lt;/td>
 &lt;td>layout 對嗎？看色塊的位置&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>2&lt;/td>
 &lt;td>色塊頂端對齊 results 頂端（用 padding-top）&lt;/td>
 &lt;td>對齊基準對嗎？看頂緣連線&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>3&lt;/td>
 &lt;td>確認多 viewport 下色塊行為&lt;/td>
 &lt;td>響應式 OK 嗎？拉視窗&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>4&lt;/td>
 &lt;td>拿掉色塊、JS 把 pagefind filter 搬進來&lt;/td>
 &lt;td>真實內容套上後位置一致嗎？&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>5&lt;/td>
 &lt;td>細部視覺調整（邊框、間距）&lt;/td>
 &lt;td>視覺樣式 OK 嗎？&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>每步只驗證一件事、有問題就停。&lt;/p>
&lt;hr>
&lt;h2 id="內在屬性比較兩種除錯起點">內在屬性比較：兩種除錯起點&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>起點&lt;/th>
 &lt;th>Debug 難度&lt;/th>
 &lt;th>修復速度&lt;/th>
 &lt;th>適用情境&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>一次組裝完整 UI&lt;/td>
 &lt;td>高 — 多層問題交織&lt;/td>
 &lt;td>慢 — 不知該改哪層&lt;/td>
 &lt;td>UI 簡單、一次到位有把握&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>從色塊漸進組裝&lt;/td>
 &lt;td>低 — 每階段問題單純&lt;/td>
 &lt;td>快 — 一次解一個&lt;/td>
 &lt;td>複雜 layout、多元件協作&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>漸進的成本是「多寫一個過渡版本」、收益是「debug 範圍縮到最小」&lt;/strong>。多元件 layout 永遠選漸進。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>UI 除錯的最小可驗證單位是「一個有明顯邊界的色塊」。</strong> 版型用色塊先驗證 grid / flex / absolute 是否如預期排在該在的位置，確定後再串實際內容。一次組裝完整 UI 在版型錯時 debug 困難 — 顏色、字型、邊距、padding 全部一起出問題、根因混雜難辨。</p>
<hr>
<h2 id="為什麼色塊比實際內容更適合-debug">為什麼色塊比實際內容更適合 debug</h2>
<h3 id="商業邏輯">商業邏輯</h3>
<p>UI 由「位置、尺寸、視覺樣式、互動」四層組成。Debug 時要分層處理 — 一次只解一層、解完再下一層。</p>
<p>色塊把後三層都拿掉、只留「位置與尺寸」 — 看到的就是 layout 規則的純粹結果。實際內容把所有層混在一起、看到的位置可能受字型 advance、line-height、margin collapse 等多重因素影響、難以歸因。</p>
<h3 id="漸進式組裝順序">漸進式組裝順序</h3>
<table>
  <thead>
      <tr>
          <th>階段</th>
          <th>內容</th>
          <th>驗證重點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td>色塊（紅 / 藍背景、固定 width / height）</td>
          <td>grid / flex / absolute 排對位置嗎？</td>
      </tr>
      <tr>
          <td>2</td>
          <td>加 placeholder 文字</td>
          <td>文字尺寸符合預期嗎？換行行為對嗎？</td>
      </tr>
      <tr>
          <td>3</td>
          <td>加 padding / border / 圓角等視覺樣式</td>
          <td>視覺樣式不破壞 layout 嗎？</td>
      </tr>
      <tr>
          <td>4</td>
          <td>換上實際內容 / 接上資料</td>
          <td>動態內容變動時 layout 還對嗎？</td>
      </tr>
      <tr>
          <td>5</td>
          <td>加互動（hover / click / focus）</td>
          <td>互動狀態下 layout 還對嗎？</td>
      </tr>
  </tbody>
</table>
<p>每階段獨立驗證、有問題就停在那階段修。</p>
<hr>
<h2 id="這次任務的實際應用">這次任務的實際應用</h2>
<h3 id="觀察">觀察</h3>
<p>要驗證搜尋頁的版型：「左側 filter sidebar + 右側中央內容（H1、search input、results）」。</p>
<p>第一次嘗試：直接把 Pagefind UI 組起來、調 CSS。結果版型錯時不知道是哪層問題 — 是 grid 排序錯？是 sidebar 寬度錯？是 padding 推到位置不對？</p>
<h3 id="判讀">判讀</h3>
<p>退回最小可驗證單位：把 filter 整個換成一個寫死寬度的紅色色塊：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">aside</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;search-filter-debug&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  filter 區（先寫死寬度與底色驗證版型）
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">&lt;/</span><span class="nt">aside</span><span class="p">&gt;</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">.</span><span class="nc">search-filter-debug</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="k">width</span><span class="p">:</span> <span class="mi">400</span><span class="kt">px</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">background</span><span class="p">:</span> <span class="kc">red</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="k">min-height</span><span class="p">:</span> <span class="mi">240</span><span class="kt">px</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="k">position</span><span class="p">:</span> <span class="kc">absolute</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="c">/* ... */</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>紅色背景一眼看出色塊在哪 — 確認了：</p>
<ul>
<li>色塊在 main 左外側（符合）</li>
<li>色塊頂端對齊 H1（符合）</li>
<li>寬度 400px、與 main 間距 2rem（符合）</li>
</ul>
<p>版型驗證後再換上實際 filter UI。</p>
<h3 id="執行的迭代步驟">執行的迭代步驟</h3>
<table>
  <thead>
      <tr>
          <th>步驟</th>
          <th>動作</th>
          <th>驗證</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td>紅色色塊代替 filter</td>
          <td>layout 對嗎？看色塊的位置</td>
      </tr>
      <tr>
          <td>2</td>
          <td>色塊頂端對齊 results 頂端（用 padding-top）</td>
          <td>對齊基準對嗎？看頂緣連線</td>
      </tr>
      <tr>
          <td>3</td>
          <td>確認多 viewport 下色塊行為</td>
          <td>響應式 OK 嗎？拉視窗</td>
      </tr>
      <tr>
          <td>4</td>
          <td>拿掉色塊、JS 把 pagefind filter 搬進來</td>
          <td>真實內容套上後位置一致嗎？</td>
      </tr>
      <tr>
          <td>5</td>
          <td>細部視覺調整（邊框、間距）</td>
          <td>視覺樣式 OK 嗎？</td>
      </tr>
  </tbody>
</table>
<p>每步只驗證一件事、有問題就停。</p>
<hr>
<h2 id="內在屬性比較兩種除錯起點">內在屬性比較：兩種除錯起點</h2>
<table>
  <thead>
      <tr>
          <th>起點</th>
          <th>Debug 難度</th>
          <th>修復速度</th>
          <th>適用情境</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>一次組裝完整 UI</td>
          <td>高 — 多層問題交織</td>
          <td>慢 — 不知該改哪層</td>
          <td>UI 簡單、一次到位有把握</td>
      </tr>
      <tr>
          <td>從色塊漸進組裝</td>
          <td>低 — 每階段問題單純</td>
          <td>快 — 一次解一個</td>
          <td>複雜 layout、多元件協作</td>
      </tr>
  </tbody>
</table>
<p><strong>漸進的成本是「多寫一個過渡版本」、收益是「debug 範圍縮到最小」</strong>。多元件 layout 永遠選漸進。</p>
<hr>
<h2 id="色塊的設計要點">色塊的設計要點</h2>
<h3 id="1-顏色明顯易於辨識">1. 顏色明顯、易於辨識</h3>
<p>紅色、洋紅、亮藍 — 跟頁面其他元素差異大。debug 完拿掉、不影響正式設計。</p>
<h3 id="2-邊界清楚">2. 邊界清楚</h3>
<p>寫死 width / height / min-height、不要讓色塊「自適應」 — 自適應時看不出色塊本身有沒有按預期擺放（可能是它縮成 0 還是真的擺對位置）。</p>
<h3 id="3-內含可辨識的標籤">3. 內含可辨識的標籤</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">aside</span><span class="p">&gt;</span>filter 區（先寫死寬度與底色驗證版型）<span class="p">&lt;/</span><span class="nt">aside</span><span class="p">&gt;</span></span></span></code></pre></div><p>文字標明這是什麼、目前是「驗證版型」狀態 — 不會被誤認為正式設計。</p>
<h3 id="4-拆解成最小可驗證的單位">4. 拆解成最小可驗證的單位</h3>
<p>要驗證「左欄 + 右欄」就用兩個色塊。不要在第一階段就加 filter 內容、search input 等元件 — 那些是後續階段。</p>
<hr>
<h2 id="設計取捨ui-debug-的起點選擇">設計取捨：UI debug 的起點選擇</h2>
<p>四種做法、各自機會成本不同。這個專案選 A（色塊漸進組裝）當預設、其他做法在特定情境合理。</p>
<h3 id="a色塊-placeholder-漸進組裝這個專案的預設">A：色塊 placeholder 漸進組裝（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：先用寫死寬度的彩色色塊代替每個區塊、確認 layout 後再加內容、再加樣式</li>
<li><strong>選 A 的理由</strong>：每階段只解一個問題、debug 範圍縮到最小</li>
<li><strong>適合</strong>：複雜 layout、多元件協作、不確定 layout 規則對不對</li>
<li><strong>代價</strong>：多一個過渡版本、總時間略長（但 debug 時間短得多）</li>
</ul>
<h3 id="b一次組裝完整-ui">B：一次組裝完整 UI</h3>
<ul>
<li><strong>機制</strong>：直接把 layout + 內容 + 樣式全部寫好、看結果</li>
<li><strong>跟 A 的取捨</strong>：B 一次到位（如果一次對）、A 漸進；B 在版型錯時 debug 困難（多層問題交織）</li>
<li><strong>B 比 A 好的情境</strong>：簡單 layout（&lt; 3 元件、無複雜共存）、有 100% 把握一次到位</li>
</ul>
<h3 id="c用-wireframe-工具figma--sketch">C：用 wireframe 工具（Figma / Sketch）</h3>
<ul>
<li><strong>機制</strong>：先在設計工具畫 wireframe、確認設計後再進實作</li>
<li><strong>跟 A 的取捨</strong>：C 在設計階段確認、A 在實作階段確認；C 適合「設計尚未確定」、A 適合「設計確定但實作有 layout 風險」</li>
<li><strong>C 比 A 好的情境</strong>：設計階段 — 還沒進實作、不確定要做什麼</li>
</ul>
<h3 id="d直接用真實內容-debug-版型">D：直接用真實內容 debug 版型</h3>
<ul>
<li><strong>機制</strong>：拿真實 pagefind UI / 文章內容當 debug 對象</li>
<li><strong>成本特別高的原因</strong>：內容自帶字型 / padding / margin、跟版型問題混在一起、debug 從哪下手都可能錯</li>
<li><strong>D 是反模式</strong>：真實內容適合驗證、不適合 debug — 內容自帶字型 / padding / margin、跟版型問題混在一起、debug 從哪下手都可能錯</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>對應的階段問題</th>
          <th>第一個該嘗試的動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Layout 一試就錯、不知改哪</td>
          <td>沒做色塊驗證、多層問題交織</td>
          <td>退回色塊 placeholder、單獨驗證 layout</td>
      </tr>
      <tr>
          <td>改 padding 視覺對了、互動後又壞</td>
          <td>樣式調整跑在 layout 確認之前</td>
          <td>退回最簡 layout、確認穩定後再加樣式</td>
      </tr>
      <tr>
          <td>真實內容套上後位置變了</td>
          <td>內容尺寸跟色塊預設不一樣</td>
          <td>量真實內容尺寸、回頭調 layout 規則或固定容器尺寸</td>
      </tr>
      <tr>
          <td>Debug 時間遠超估算</td>
          <td>起點選錯（從複雜 UI 開始）</td>
          <td>退到色塊重來、會比繼續調快</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：UI 除錯的速度跟「起點的簡單度」成正比。從色塊出發、永遠比從完整 UI 出發快。</p>
<p>跟 <a href="../verification-timeline-checkpoints/">#68 驗收的時間軸</a> 的關係：placeholder 漸進是 Checkpoint 2「開發中」的具體做法 — 每階段只引入一個變數、邏輯錯誤跟視覺錯誤能即時 catch。跳階段（直接寫真實內容 + 完整樣式）= 把開發中 checkpoint collapse 成單次驗收、漏掉的失敗會推到 ship 前 / ship 後。</p>
]]></content:encoded></item><item><title>在開發循環裡早一點用 playwright 看真實結果</title><link>https://tarrragon.github.io/blog/report/playwright-early-in-loop/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/playwright-early-in-loop/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>Playwright 不是最後手段、是縮短診斷迴圈的工具。&lt;/strong> 當靜態 CSS 推理 + 視覺截圖溝通的循環失敗 ≥ 2 次、就應該停止推理、改用 playwright &lt;code>browser_evaluate&lt;/code> 直接讀 live DOM 與 computed style。早一點用 = 試錯次數更少、心智負擔更輕。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼推理迴圈有極限">為什麼推理迴圈有極限&lt;/h2>
&lt;h3 id="商業邏輯">商業邏輯&lt;/h3>
&lt;p>CSS 行為由「規則 + DOM tree + 樣式繼承 + 框架渲染」四個變數共同決定。靜態推理只能基於假設的 DOM tree — 假設錯了、推理就錯。視覺截圖溝通只能傳達「結果是什麼」、無法傳達「為什麼是這個結果」。&lt;/p>
&lt;p>Playwright 的 &lt;code>browser_evaluate&lt;/code> 直接執行 JS 在 live page、返回真實的 DOM tree、computed style、bounding rect — &lt;strong>把「四個變數」全部變成已知&lt;/strong>。&lt;/p>
&lt;h3 id="推理-vs-量測的成本曲線">推理 vs 量測的成本曲線&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>方法&lt;/th>
 &lt;th>第 1 次嘗試&lt;/th>
 &lt;th>第 2 次&lt;/th>
 &lt;th>第 3 次以上&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>靜態推理 + 截圖&lt;/td>
 &lt;td>快 — 假設正確時一次到位&lt;/td>
 &lt;td>慢 — 假設錯了得重來&lt;/td>
 &lt;td>越來越慢 — 假設錯誤累積&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Playwright 量測&lt;/td>
 &lt;td>中 — 起 server、寫 evaluate&lt;/td>
 &lt;td>快 — server 已在跑&lt;/td>
 &lt;td>快 — 重用 setup&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>第 1 次推理快、後續成本爆炸；playwright 起步慢、後續穩定。&lt;strong>門檻在第 2 次&lt;/strong>。&lt;/p>
&lt;hr>
&lt;h2 id="這次任務的實際情境">這次任務的實際情境&lt;/h2>
&lt;h3 id="觀察">觀察&lt;/h3>
&lt;p>要把 search scope UI 放在「搜尋輸入框與結果之間」。&lt;/p>
&lt;p>第一輪：基於 class name 推測 DOM tree、用 grid + display:contents 設 grid-row 排序。第二輪：發現 scope 跑到頁尾、嘗試調 grid-template-rows。第三輪：嘗試 absolute 定位但時機不對。第四輪：使用者說「思路錯了」、要我換方向。&lt;/p>
&lt;h3 id="判讀">判讀&lt;/h3>
&lt;p>四輪推理都基於同一個假設：&lt;code>drawer&lt;/code> 是 &lt;code>.pagefind-ui&lt;/code> 的直接子節點、跟 &lt;code>form&lt;/code> 並列。實際用 playwright 一查：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">drawer&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__drawer&amp;#39;&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">let&lt;/span> &lt;span class="nx">parents&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[];&lt;/span> &lt;span class="kd">let&lt;/span> &lt;span class="nx">el&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">drawer&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">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">el&lt;/span> &lt;span class="o">!==&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">body&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="nx">parents&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">push&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">tagName&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="s1">&amp;#39;.&amp;#39;&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">className&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="nx">el&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">parentElement&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>返回：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">DIV.pagefind-ui__drawer
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">FORM.pagefind-ui__form ← drawer 在 form 內！
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">DIV.pagefind-ui&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>假設錯了 — drawer 是 form 的 child、不是 sibling。grid 規則無論怎麼寫都不會生效，因為 drawer 跟 form 共用同一個 grid cell。&lt;/p>
&lt;p>四輪推理 ≈ 30 分鐘。Playwright 一次查清楚 ≈ 2 分鐘。&lt;/p>
&lt;h3 id="執行">執行&lt;/h3>
&lt;p>確認 DOM 結構後：grid 不適合這個場景、改用 absolute + drawer margin-top spacer。一次到位。&lt;/p>
&lt;hr>
&lt;h2 id="playwright-在開發循環的三個位置">Playwright 在開發循環的三個位置&lt;/h2>
&lt;h3 id="1-假設驗證">1. 假設驗證&lt;/h3>
&lt;p>寫 CSS 規則前先量 DOM、確認結構符合假設。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kr">async&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="p">({&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">parents&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">[].&lt;/span>&lt;span class="nx">slice&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">call&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelectorAll&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.target&amp;#39;&lt;/span>&lt;span class="p">)).&lt;/span>&lt;span class="nx">map&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span> &lt;span class="p">=&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="kd">let&lt;/span> &lt;span class="nx">chain&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[];&lt;/span> &lt;span class="kd">let&lt;/span> &lt;span class="nx">n&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">el&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">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">n&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">chain&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">push&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">n&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">tagName&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="s1">&amp;#39;.&amp;#39;&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="nx">n&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">className&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nx">n&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">n&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">parentElement&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">chain&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="p">})&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="2-行為驗證">2. 行為驗證&lt;/h3>
&lt;p>Layout 規則寫完後驗證實際結果。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kr">async&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="p">({&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">rect&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.target&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">getBoundingClientRect&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">computed&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">getComputedStyle&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.target&amp;#39;&lt;/span>&lt;span class="p">)).&lt;/span>&lt;span class="nx">gridRow&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="p">})&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="3-互動驗證">3. 互動驗證&lt;/h3>
&lt;p>驗證使用者互動後的狀態。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kr">async&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">input&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.search-input&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">input&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">value&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s1">&amp;#39;pre&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">input&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">dispatchEvent&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">new&lt;/span> &lt;span class="nx">Event&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;input&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">bubbles&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kc">true&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="kr">await&lt;/span> &lt;span class="k">new&lt;/span> &lt;span class="nb">Promise&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="nx">setTimeout&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">1000&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">return&lt;/span> &lt;span class="nb">Array&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">from&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelectorAll&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.result&amp;#39;&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="p">.&lt;/span>&lt;span class="nx">filter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="nx">getComputedStyle&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">display&lt;/span> &lt;span class="o">!==&lt;/span> &lt;span class="s1">&amp;#39;none&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl"> &lt;span class="p">.&lt;/span>&lt;span class="nx">map&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">textContent&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">slice&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">50&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="內在屬性比較四種-debug-方法">內在屬性比較：四種 debug 方法&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>方法&lt;/th>
 &lt;th>取得資訊量&lt;/th>
 &lt;th>重複成本&lt;/th>
 &lt;th>可寫成測試&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>靜態 CSS 推理&lt;/td>
 &lt;td>低 — 全是假設&lt;/td>
 &lt;td>高 — 每次重思考&lt;/td>
 &lt;td>否&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>視覺截圖溝通&lt;/td>
 &lt;td>中 — 只有結果&lt;/td>
 &lt;td>中 — 截圖 / 描述慢&lt;/td>
 &lt;td>否&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>瀏覽器 DevTools&lt;/td>
 &lt;td>高 — DOM + computed&lt;/td>
 &lt;td>中 — 每次手點&lt;/td>
 &lt;td>否&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Playwright &lt;code>browser_evaluate&lt;/code>&lt;/td>
 &lt;td>最高 — 程式化任意查詢&lt;/td>
 &lt;td>低 — 改 query 重跑&lt;/td>
 &lt;td>是 — 同樣 query 可寫測試&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>選擇順序：&lt;strong>簡單 layout 用 DevTools；複雜 / 反覆 debug 用 playwright；推理只在第 1 次試錯前&lt;/strong>。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>Playwright 不是最後手段、是縮短診斷迴圈的工具。</strong> 當靜態 CSS 推理 + 視覺截圖溝通的循環失敗 ≥ 2 次、就應該停止推理、改用 playwright <code>browser_evaluate</code> 直接讀 live DOM 與 computed style。早一點用 = 試錯次數更少、心智負擔更輕。</p>
<hr>
<h2 id="為什麼推理迴圈有極限">為什麼推理迴圈有極限</h2>
<h3 id="商業邏輯">商業邏輯</h3>
<p>CSS 行為由「規則 + DOM tree + 樣式繼承 + 框架渲染」四個變數共同決定。靜態推理只能基於假設的 DOM tree — 假設錯了、推理就錯。視覺截圖溝通只能傳達「結果是什麼」、無法傳達「為什麼是這個結果」。</p>
<p>Playwright 的 <code>browser_evaluate</code> 直接執行 JS 在 live page、返回真實的 DOM tree、computed style、bounding rect — <strong>把「四個變數」全部變成已知</strong>。</p>
<h3 id="推理-vs-量測的成本曲線">推理 vs 量測的成本曲線</h3>
<table>
  <thead>
      <tr>
          <th>方法</th>
          <th>第 1 次嘗試</th>
          <th>第 2 次</th>
          <th>第 3 次以上</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>靜態推理 + 截圖</td>
          <td>快 — 假設正確時一次到位</td>
          <td>慢 — 假設錯了得重來</td>
          <td>越來越慢 — 假設錯誤累積</td>
      </tr>
      <tr>
          <td>Playwright 量測</td>
          <td>中 — 起 server、寫 evaluate</td>
          <td>快 — server 已在跑</td>
          <td>快 — 重用 setup</td>
      </tr>
  </tbody>
</table>
<p>第 1 次推理快、後續成本爆炸；playwright 起步慢、後續穩定。<strong>門檻在第 2 次</strong>。</p>
<hr>
<h2 id="這次任務的實際情境">這次任務的實際情境</h2>
<h3 id="觀察">觀察</h3>
<p>要把 search scope UI 放在「搜尋輸入框與結果之間」。</p>
<p>第一輪：基於 class name 推測 DOM tree、用 grid + display:contents 設 grid-row 排序。第二輪：發現 scope 跑到頁尾、嘗試調 grid-template-rows。第三輪：嘗試 absolute 定位但時機不對。第四輪：使用者說「思路錯了」、要我換方向。</p>
<h3 id="判讀">判讀</h3>
<p>四輪推理都基於同一個假設：<code>drawer</code> 是 <code>.pagefind-ui</code> 的直接子節點、跟 <code>form</code> 並列。實際用 playwright 一查：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">const</span> <span class="nx">drawer</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__drawer&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kd">let</span> <span class="nx">parents</span> <span class="o">=</span> <span class="p">[];</span> <span class="kd">let</span> <span class="nx">el</span> <span class="o">=</span> <span class="nx">drawer</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="k">while</span> <span class="p">(</span><span class="nx">el</span> <span class="o">&amp;&amp;</span> <span class="nx">el</span> <span class="o">!==</span> <span class="nb">document</span><span class="p">.</span><span class="nx">body</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nx">parents</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="nx">el</span><span class="p">.</span><span class="nx">tagName</span> <span class="o">+</span> <span class="s1">&#39;.&#39;</span> <span class="o">+</span> <span class="nx">el</span><span class="p">.</span><span class="nx">className</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="nx">el</span> <span class="o">=</span> <span class="nx">el</span><span class="p">.</span><span class="nx">parentElement</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>返回：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">DIV.pagefind-ui__drawer
</span></span><span class="line"><span class="ln">2</span><span class="cl">FORM.pagefind-ui__form    ← drawer 在 form 內！
</span></span><span class="line"><span class="ln">3</span><span class="cl">DIV.pagefind-ui</span></span></code></pre></div><p>假設錯了 — drawer 是 form 的 child、不是 sibling。grid 規則無論怎麼寫都不會生效，因為 drawer 跟 form 共用同一個 grid cell。</p>
<p>四輪推理 ≈ 30 分鐘。Playwright 一次查清楚 ≈ 2 分鐘。</p>
<h3 id="執行">執行</h3>
<p>確認 DOM 結構後：grid 不適合這個場景、改用 absolute + drawer margin-top spacer。一次到位。</p>
<hr>
<h2 id="playwright-在開發循環的三個位置">Playwright 在開發循環的三個位置</h2>
<h3 id="1-假設驗證">1. 假設驗證</h3>
<p>寫 CSS 規則前先量 DOM、確認結構符合假設。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">({</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nx">parents</span><span class="o">:</span> <span class="p">[].</span><span class="nx">slice</span><span class="p">.</span><span class="nx">call</span><span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.target&#39;</span><span class="p">)).</span><span class="nx">map</span><span class="p">(</span><span class="nx">el</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="kd">let</span> <span class="nx">chain</span> <span class="o">=</span> <span class="p">[];</span> <span class="kd">let</span> <span class="nx">n</span> <span class="o">=</span> <span class="nx">el</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="k">while</span> <span class="p">(</span><span class="nx">n</span><span class="p">)</span> <span class="p">{</span> <span class="nx">chain</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="nx">n</span><span class="p">.</span><span class="nx">tagName</span> <span class="o">+</span> <span class="s1">&#39;.&#39;</span> <span class="o">+</span> <span class="nx">n</span><span class="p">.</span><span class="nx">className</span><span class="p">);</span> <span class="nx">n</span> <span class="o">=</span> <span class="nx">n</span><span class="p">.</span><span class="nx">parentElement</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="k">return</span> <span class="nx">chain</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="p">})</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">})</span></span></span></code></pre></div><h3 id="2-行為驗證">2. 行為驗證</h3>
<p>Layout 規則寫完後驗證實際結果。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">({</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nx">rect</span><span class="o">:</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.target&#39;</span><span class="p">).</span><span class="nx">getBoundingClientRect</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">computed</span><span class="o">:</span> <span class="nx">getComputedStyle</span><span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.target&#39;</span><span class="p">)).</span><span class="nx">gridRow</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">})</span></span></span></code></pre></div><h3 id="3-互動驗證">3. 互動驗證</h3>
<p>驗證使用者互動後的狀態。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kr">const</span> <span class="nx">input</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.search-input&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">input</span><span class="p">.</span><span class="nx">value</span> <span class="o">=</span> <span class="s1">&#39;pre&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nx">input</span><span class="p">.</span><span class="nx">dispatchEvent</span><span class="p">(</span><span class="k">new</span> <span class="nx">Event</span><span class="p">(</span><span class="s1">&#39;input&#39;</span><span class="p">,</span> <span class="p">{</span> <span class="nx">bubbles</span><span class="o">:</span> <span class="kc">true</span> <span class="p">}));</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="kr">await</span> <span class="k">new</span> <span class="nb">Promise</span><span class="p">(</span><span class="nx">r</span> <span class="p">=&gt;</span> <span class="nx">setTimeout</span><span class="p">(</span><span class="nx">r</span><span class="p">,</span> <span class="mi">1000</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="k">return</span> <span class="nb">Array</span><span class="p">.</span><span class="nx">from</span><span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.result&#39;</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="p">.</span><span class="nx">filter</span><span class="p">(</span><span class="nx">el</span> <span class="p">=&gt;</span> <span class="nx">getComputedStyle</span><span class="p">(</span><span class="nx">el</span><span class="p">).</span><span class="nx">display</span> <span class="o">!==</span> <span class="s1">&#39;none&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="p">.</span><span class="nx">map</span><span class="p">(</span><span class="nx">el</span> <span class="p">=&gt;</span> <span class="nx">el</span><span class="p">.</span><span class="nx">textContent</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">50</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><hr>
<h2 id="內在屬性比較四種-debug-方法">內在屬性比較：四種 debug 方法</h2>
<table>
  <thead>
      <tr>
          <th>方法</th>
          <th>取得資訊量</th>
          <th>重複成本</th>
          <th>可寫成測試</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>靜態 CSS 推理</td>
          <td>低 — 全是假設</td>
          <td>高 — 每次重思考</td>
          <td>否</td>
      </tr>
      <tr>
          <td>視覺截圖溝通</td>
          <td>中 — 只有結果</td>
          <td>中 — 截圖 / 描述慢</td>
          <td>否</td>
      </tr>
      <tr>
          <td>瀏覽器 DevTools</td>
          <td>高 — DOM + computed</td>
          <td>中 — 每次手點</td>
          <td>否</td>
      </tr>
      <tr>
          <td>Playwright <code>browser_evaluate</code></td>
          <td>最高 — 程式化任意查詢</td>
          <td>低 — 改 query 重跑</td>
          <td>是 — 同樣 query 可寫測試</td>
      </tr>
  </tbody>
</table>
<p>選擇順序：<strong>簡單 layout 用 DevTools；複雜 / 反覆 debug 用 playwright；推理只在第 1 次試錯前</strong>。</p>
<hr>
<h2 id="引入-playwright-的最低門檻">引入 playwright 的最低門檻</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 啟動本地 server（任何方式）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">python3 -m http.server <span class="m">8000</span> --directory public
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 或專案有 hugo</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">hugo server</span></span></code></pre></div><p>Playwright MCP 提供：</p>
<ul>
<li><code>browser_navigate(url)</code> — 開頁</li>
<li><code>browser_evaluate(fn)</code> — 執行 JS 拿結果</li>
<li><code>browser_take_screenshot()</code> — 截圖</li>
<li><code>browser_snapshot()</code> — accessibility tree</li>
</ul>
<p>寫一個 evaluate fn ≈ 30 行 JS，比反覆推理快得多。</p>
<hr>
<h2 id="設計取捨css--dom-debug-工具選擇">設計取捨：CSS / DOM debug 工具選擇</h2>
<p>四種做法、各自機會成本不同。這個專案在推理 ≥ 2 次失敗後選 A（playwright <code>browser_evaluate</code>）當預設、其他做法在特定情境合理。</p>
<blockquote>
<p>本篇是 <a href="../two-occurrence-threshold/">#42 2 次門檻</a> 抽象原則在「debug 工具切換」這個面向的應用。</p></blockquote>
<h3 id="aplaywright-browser_evaluate-程式化讀-live-dom這個專案的預設">A：Playwright <code>browser_evaluate</code> 程式化讀 live DOM（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：起 server、用 <code>browser_evaluate</code> 寫 JS query 讀 DOM tree / computed style / bounding rect</li>
<li><strong>選 A 的理由</strong>：取得資訊量最大、可重跑、可寫成測試</li>
<li><strong>適合</strong>：推理失敗 ≥ 2 次、複雜或反覆 debug 的情境</li>
<li><strong>代價</strong>：起步成本中（需要 server + 寫 evaluate）</li>
</ul>
<h3 id="b靜態-css-推理--視覺截圖溝通">B：靜態 CSS 推理 + 視覺截圖溝通</h3>
<ul>
<li><strong>機制</strong>：純看 CSS 與假設的 DOM 推測、用截圖跟使用者溝通</li>
<li><strong>跟 A 的取捨</strong>：B 起步成本 0、A 起步成本中；但 B 第 2 次以後成本爆炸（每輪都基於前輪錯誤假設）</li>
<li><strong>B 比 A 好的情境</strong>：第 1 次嘗試、預估假設正確機率高（簡單修改）</li>
</ul>
<h3 id="c瀏覽器-devtools-手動查">C：瀏覽器 DevTools 手動查</h3>
<ul>
<li><strong>機制</strong>：開 DevTools 切 Elements / Computed / Layout 面板手動探索</li>
<li><strong>跟 A 的取捨</strong>：C 不需 server / playwright setup、但每次手點切面板慢、不能寫成測試</li>
<li><strong>C 比 A 好的情境</strong>：一次性確認、不需要重複 query 同樣資訊</li>
</ul>
<h3 id="d寫成-playwright-測試固化">D：寫成 playwright 測試固化</h3>
<ul>
<li><strong>機制</strong>：把 debug 過程寫成 playwright 測試、未來自動跑</li>
<li><strong>跟 A 的取捨</strong>：D 是 A 的延伸 — 第 2 次 debug 同個版型時、值得固化（<a href="../layout-tests-with-playwright/">#15 layout tests</a>）</li>
<li><strong>D 比 A 好的情境</strong>：版型 bug 出現第 2 次以上、值得寫測試防止回歸</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>工具切換時機</th>
          <th>第一個該寫的 evaluate</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>推理 ≥ 2 次失敗</td>
          <td>切到 playwright</td>
          <td>量目標元素的 ancestor chain</td>
      </tr>
      <tr>
          <td>Layout 在某些狀態下錯、其他狀態下對</td>
          <td>切到 playwright</td>
          <td>量該元素在不同狀態下的 bounding rect</td>
      </tr>
      <tr>
          <td>改 CSS 不生效、specificity 看起來對</td>
          <td>切到 playwright</td>
          <td>量 computed style 看真正套到的值</td>
      </tr>
      <tr>
          <td>動態 DOM 結構不確定</td>
          <td>切到 playwright</td>
          <td>列出目標 container 的子節點</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：縮短診斷迴圈的工具該早一點用、不該等到推理徹底失敗。第 2 次推理失敗就切換、別等第 5 次。</p>
<p>延伸應用：playwright 也用來查「資料層 vs 視覺層的層錯位」 — 見 <a href="../view-layer-filter-vs-source-layer/">#55 Filter 與 Source 的抽象層錯位</a> 用 <code>browser_evaluate</code> 量 source 真實 cardinality 與分批機制。</p>
]]></content:encoded></item><item><title>同方向反覆失敗的轉折點</title><link>https://tarrragon.github.io/blog/report/failure-direction-pivot-point/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/failure-direction-pivot-point/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>第 2 次同方向失敗、停下來回報「假設可能錯了、要不要換思路」。&lt;/strong> 失敗 ≥ 2 次大多是底層假設有問題、不是執行細節有問題。繼續沿同一方向加碼（換更複雜的 selector、加 &lt;code>!important&lt;/code>、再寫一層 polyfill）只會放大原本的問題。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼第-2-次是轉折點">為什麼第 2 次是轉折點&lt;/h2>
&lt;h3 id="商業邏輯">商業邏輯&lt;/h3>
&lt;p>第 1 次失敗常是執行細節（typo、特定 syntax、瀏覽器 cache）— 修正後可能就過。&lt;/p>
&lt;p>第 2 次失敗、用同樣的方法但更小心、還是失敗 — 這個訊號的重量遠大於兩次失敗的相加。它說的是「我以為的問題不在這層、根本問題在別處」。&lt;/p>
&lt;p>第 3 次以上的失敗、加上「再試一次更小心」的努力、產生的副作用會超過解決的問題：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>嘗試次數&lt;/th>
 &lt;th>心理狀態&lt;/th>
 &lt;th>行動模式&lt;/th>
 &lt;th>可能副作用&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>1&lt;/td>
 &lt;td>信心足&lt;/td>
 &lt;td>直接做&lt;/td>
 &lt;td>無&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>2&lt;/td>
 &lt;td>信心動搖&lt;/td>
 &lt;td>加碼（更複雜的 selector / important）&lt;/td>
 &lt;td>可控&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>3&lt;/td>
 &lt;td>焦慮&lt;/td>
 &lt;td>全面反擊（layers + important + polyfill）&lt;/td>
 &lt;td>大 — 改動範圍擴張&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>4+&lt;/td>
 &lt;td>沉沒成本綁住&lt;/td>
 &lt;td>不肯放棄已寫的&lt;/td>
 &lt;td>嚴重 — 為前面的錯買單&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>第 2 次是還能優雅切換方向的最後機會。&lt;/p>
&lt;hr>
&lt;h2 id="這次任務的實際情境">這次任務的實際情境&lt;/h2>
&lt;h3 id="觀察">觀察&lt;/h3>
&lt;p>要把 search scope UI 放在「搜尋框與結果之間」。我嘗試了：&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>1&lt;/td>
 &lt;td>Display: contents 串接 + grid-row 排序&lt;/td>
 &lt;td>失敗 — scope 跑到頁尾&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>2&lt;/td>
 &lt;td>加 &lt;code>!important&lt;/code> 強化 grid-row&lt;/td>
 &lt;td>失敗 — 沒改善&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>3&lt;/td>
 &lt;td>Specificity 雙寫（&lt;code>.x.x&lt;/code>）&lt;/td>
 &lt;td>失敗 — 沒改善&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>4&lt;/td>
 &lt;td>加更多 display: contents 層&lt;/td>
 &lt;td>失敗 — 同樣結果&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>5（被使用者制止）&lt;/td>
 &lt;td>「思路錯了、換方向」&lt;/td>
 &lt;td>改用 absolute 定位、一次成功&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>四次失敗都基於同一假設：「drawer 是 &lt;code>.pagefind-ui&lt;/code> 的直接子節點」。實際 drawer 在 form 內。&lt;/p>
&lt;h3 id="判讀">判讀&lt;/h3>
&lt;p>第 2 次失敗時就應該停下來檢查假設、不該再往同方向加碼。&lt;/p>
&lt;p>正確流程：第 1 次失敗修細節；第 2 次失敗用 playwright 量 DOM 確認假設；發現假設錯就立刻換方向、不要為前面的努力買單。&lt;/p>
&lt;h3 id="執行失敗計數與行動">執行：失敗計數與行動&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>失敗次數&lt;/th>
 &lt;th>行動&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>第 1 次&lt;/td>
 &lt;td>修細節（typo、cache、syntax）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>第 2 次&lt;/td>
 &lt;td>&lt;strong>停下來&lt;/strong> — 用工具驗證底層假設（DOM tree、computed style、framework 行為）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>第 2 次驗證後&lt;/td>
 &lt;td>假設對 → 繼續修；假設錯 → 換方向、不為前面買單&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>關鍵是第 2 次的「停」 — 把行動從「執行更努力」切換到「驗證假設」。&lt;/p>
&lt;hr>
&lt;h2 id="內在屬性比較四種失敗應對">內在屬性比較：四種失敗應對&lt;/h2>
&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;/td>
 &lt;td>1 次&lt;/td>
 &lt;td>低 — 假設沒問題的話通常成功&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>停下來驗證假設&lt;/td>
 &lt;td>2 次&lt;/td>
 &lt;td>低 — 確認方向是否正確&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>加碼（important / 雙寫 / polyfill）&lt;/td>
 &lt;td>不適用&lt;/td>
 &lt;td>高 — 假設錯時放大問題&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>換方向（重新設計實作）&lt;/td>
 &lt;td>2 次後驗證假設錯&lt;/td>
 &lt;td>中 — 一次性成本、後續穩定&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>選擇規則：&lt;strong>第 1 次修細節、第 2 次驗證、第 2 次後驗證假設決定繼續或換方向&lt;/strong>。不該有第 3 次同方向加碼。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>第 2 次同方向失敗、停下來回報「假設可能錯了、要不要換思路」。</strong> 失敗 ≥ 2 次大多是底層假設有問題、不是執行細節有問題。繼續沿同一方向加碼（換更複雜的 selector、加 <code>!important</code>、再寫一層 polyfill）只會放大原本的問題。</p>
<hr>
<h2 id="為什麼第-2-次是轉折點">為什麼第 2 次是轉折點</h2>
<h3 id="商業邏輯">商業邏輯</h3>
<p>第 1 次失敗常是執行細節（typo、特定 syntax、瀏覽器 cache）— 修正後可能就過。</p>
<p>第 2 次失敗、用同樣的方法但更小心、還是失敗 — 這個訊號的重量遠大於兩次失敗的相加。它說的是「我以為的問題不在這層、根本問題在別處」。</p>
<p>第 3 次以上的失敗、加上「再試一次更小心」的努力、產生的副作用會超過解決的問題：</p>
<table>
  <thead>
      <tr>
          <th>嘗試次數</th>
          <th>心理狀態</th>
          <th>行動模式</th>
          <th>可能副作用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td>信心足</td>
          <td>直接做</td>
          <td>無</td>
      </tr>
      <tr>
          <td>2</td>
          <td>信心動搖</td>
          <td>加碼（更複雜的 selector / important）</td>
          <td>可控</td>
      </tr>
      <tr>
          <td>3</td>
          <td>焦慮</td>
          <td>全面反擊（layers + important + polyfill）</td>
          <td>大 — 改動範圍擴張</td>
      </tr>
      <tr>
          <td>4+</td>
          <td>沉沒成本綁住</td>
          <td>不肯放棄已寫的</td>
          <td>嚴重 — 為前面的錯買單</td>
      </tr>
  </tbody>
</table>
<p>第 2 次是還能優雅切換方向的最後機會。</p>
<hr>
<h2 id="這次任務的實際情境">這次任務的實際情境</h2>
<h3 id="觀察">觀察</h3>
<p>要把 search scope UI 放在「搜尋框與結果之間」。我嘗試了：</p>
<table>
  <thead>
      <tr>
          <th>嘗試</th>
          <th>方向</th>
          <th>結果</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td>Display: contents 串接 + grid-row 排序</td>
          <td>失敗 — scope 跑到頁尾</td>
      </tr>
      <tr>
          <td>2</td>
          <td>加 <code>!important</code> 強化 grid-row</td>
          <td>失敗 — 沒改善</td>
      </tr>
      <tr>
          <td>3</td>
          <td>Specificity 雙寫（<code>.x.x</code>）</td>
          <td>失敗 — 沒改善</td>
      </tr>
      <tr>
          <td>4</td>
          <td>加更多 display: contents 層</td>
          <td>失敗 — 同樣結果</td>
      </tr>
      <tr>
          <td>5（被使用者制止）</td>
          <td>「思路錯了、換方向」</td>
          <td>改用 absolute 定位、一次成功</td>
      </tr>
  </tbody>
</table>
<p>四次失敗都基於同一假設：「drawer 是 <code>.pagefind-ui</code> 的直接子節點」。實際 drawer 在 form 內。</p>
<h3 id="判讀">判讀</h3>
<p>第 2 次失敗時就應該停下來檢查假設、不該再往同方向加碼。</p>
<p>正確流程：第 1 次失敗修細節；第 2 次失敗用 playwright 量 DOM 確認假設；發現假設錯就立刻換方向、不要為前面的努力買單。</p>
<h3 id="執行失敗計數與行動">執行：失敗計數與行動</h3>
<table>
  <thead>
      <tr>
          <th>失敗次數</th>
          <th>行動</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>第 1 次</td>
          <td>修細節（typo、cache、syntax）</td>
      </tr>
      <tr>
          <td>第 2 次</td>
          <td><strong>停下來</strong> — 用工具驗證底層假設（DOM tree、computed style、framework 行為）</td>
      </tr>
      <tr>
          <td>第 2 次驗證後</td>
          <td>假設對 → 繼續修；假設錯 → 換方向、不為前面買單</td>
      </tr>
  </tbody>
</table>
<p>關鍵是第 2 次的「停」 — 把行動從「執行更努力」切換到「驗證假設」。</p>
<hr>
<h2 id="內在屬性比較四種失敗應對">內在屬性比較：四種失敗應對</h2>
<table>
  <thead>
      <tr>
          <th>應對</th>
          <th>適用次數</th>
          <th>風險</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>修細節再試</td>
          <td>1 次</td>
          <td>低 — 假設沒問題的話通常成功</td>
      </tr>
      <tr>
          <td>停下來驗證假設</td>
          <td>2 次</td>
          <td>低 — 確認方向是否正確</td>
      </tr>
      <tr>
          <td>加碼（important / 雙寫 / polyfill）</td>
          <td>不適用</td>
          <td>高 — 假設錯時放大問題</td>
      </tr>
      <tr>
          <td>換方向（重新設計實作）</td>
          <td>2 次後驗證假設錯</td>
          <td>中 — 一次性成本、後續穩定</td>
      </tr>
  </tbody>
</table>
<p>選擇規則：<strong>第 1 次修細節、第 2 次驗證、第 2 次後驗證假設決定繼續或換方向</strong>。不該有第 3 次同方向加碼。</p>
<hr>
<h2 id="假設驗證的具體方法">假設驗證的具體方法</h2>
<h3 id="1-用工具讀真實狀態">1. 用工具讀真實狀態</h3>
<table>
  <thead>
      <tr>
          <th>假設類型</th>
          <th>驗證工具</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>DOM 結構</td>
          <td>playwright <code>browser_evaluate</code> 讀 ancestor chain</td>
      </tr>
      <tr>
          <td>Computed style</td>
          <td>playwright <code>getComputedStyle</code></td>
      </tr>
      <tr>
          <td>元素位置</td>
          <td>playwright <code>getBoundingClientRect</code></td>
      </tr>
      <tr>
          <td>Framework 行為</td>
          <td>讀框架 source、看 reconcile 條件</td>
      </tr>
  </tbody>
</table>
<h3 id="2-反問如果假設錯了會怎樣">2. 反問「如果假設錯了會怎樣」</h3>
<table>
  <thead>
      <tr>
          <th>假設</th>
          <th>如果錯了</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Drawer 是 form 的 sibling</td>
          <td>那 grid-row 完全無效（drawer 跟 form 共用 cell）</td>
      </tr>
      <tr>
          <td>Specificity 30 是上限</td>
          <td>那 layers 才是解、不是雙寫</td>
      </tr>
  </tbody>
</table>
<p>「如果錯了會怎樣」的答案是「跟我看到的失敗一致」 → 假設可能錯。</p>
<h3 id="3-對外回報">3. 對外回報</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">我嘗試了兩次 [方向 X]、結果都 [現象 Y]。
</span></span><span class="line"><span class="ln">2</span><span class="cl">我的假設是 [假設 Z]、但驗證 [假設 Z] 似乎不成立。
</span></span><span class="line"><span class="ln">3</span><span class="cl">要不要換 [方向 W]、或是有什麼資訊我沒看到？</span></span></code></pre></div><p>對外回報 = 把問題放到使用者視野、避免繼續單方面加碼。</p>
<hr>
<h2 id="設計取捨失敗應對的策略">設計取捨：失敗應對的策略</h2>
<p>四種做法、各自機會成本不同。這個專案選 A（第 2 次失敗驗證假設）當預設、其他做法在特定情境合理。</p>
<blockquote>
<p>本篇是 <a href="../two-occurrence-threshold/">#42 2 次門檻</a> 抽象原則在「同方向失敗」這個面向的應用。</p></blockquote>
<h3 id="a第-2-次失敗停下驗證假設這個專案的預設">A：第 2 次失敗停下驗證假設（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：第 1 次修細節再試；第 2 次失敗 → 用工具驗證底層假設（DOM tree、computed style、framework 行為）；驗證錯就換方向</li>
<li><strong>選 A 的理由</strong>：早一點切換、雙方時間都省；2 次失敗的證據量足以判斷「路徑問題」</li>
<li><strong>適合</strong>：所有除錯情境</li>
<li><strong>代價</strong>：第 2 次後的「停下」需要心理紀律（克服繼續加碼的衝動）</li>
</ul>
<h3 id="b第-4-5-次才停沉沒成本綁住">B：第 4-5 次才停（沉沒成本綁住）</h3>
<ul>
<li><strong>機制</strong>：繼續加碼直到使用者制止</li>
<li><strong>跟 A 的取捨</strong>：B 給更多嘗試空間、A 早決；B 在沉沒成本累積後更難切換</li>
<li><strong>B 是反模式</strong>：沉沒成本是認知偏誤、不是合理應對 — 「再試一次更小心」的衝動是訊號、不是解法</li>
</ul>
<h3 id="c第-1-次失敗就換方向過度反應">C：第 1 次失敗就換方向（過度反應）</h3>
<ul>
<li><strong>機制</strong>：每次失敗都假設方向錯、立即換</li>
<li><strong>跟 A 的取捨</strong>：C 太敏感、A 適度；C 在「修細節就能過」的場景過度切換</li>
<li><strong>C 才合理的情境</strong>：嘗試成本極高（每次失敗 = 半天工作）— 即使單次失敗、也值得停下重新評估</li>
</ul>
<h3 id="d永不換方向">D：永不換方向</h3>
<ul>
<li><strong>機制</strong>：認定方向對、無限加碼</li>
<li><strong>D 是反模式</strong>：方向錯時無法收斂、最後產生脆弱的 patchwork</li>
<li><strong>看起來吸引人的原因</strong>：心理上不想承認方向錯、繼續加碼比放棄好受</li>
<li><strong>實際發生的代價</strong>：失敗訊號被忽略、產生脆弱的 patchwork、修復成本指數放大</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該觸發的行動</th>
          <th>第一個該做的事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>第 2 次同方向失敗</td>
          <td>停下來驗證假設</td>
          <td>用 playwright / DevTools 量真實狀態</td>
      </tr>
      <tr>
          <td>加 <code>!important</code> 解 specificity</td>
          <td>停 — 切換到 layers 思路</td>
          <td>評估用 CSS Layers</td>
      </tr>
      <tr>
          <td>加第 2 條 polyfill 補跨瀏覽器</td>
          <td>停 — 評估值不值得繼續</td>
          <td>報告成本、問使用者意願</td>
      </tr>
      <tr>
          <td>用 imperative JS 補宣告式 layout</td>
          <td>停 — 切換到 CSS-first 思路</td>
          <td>評估能否用 grid / flex 解決</td>
      </tr>
      <tr>
          <td>內心 OS：「再試一次更小心」</td>
          <td>停 — 這是沉沒成本綁住的訊號</td>
          <td>對外回報、邀請換方向</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：第 2 次失敗的最佳行動是「驗證假設」、不是「再試一次」。早一點切換方向、節省的是雙方時間。</p>
<p>「再試一次」是當下便利的選項（不需要重新分析）、「驗證假設換方向」是對齊正確性的選項 — 這個反相關見 <a href="../ease-of-writing-vs-intent-alignment/">#67 寫作便利度跟意圖對齊反相關</a>。</p>
]]></content:encoded></item><item><title>驗證方法的選擇時機</title><link>https://tarrragon.github.io/blog/report/verification-method-timing/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/verification-method-timing/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>驗證工具的引入時機不該等推理徹底失敗。&lt;/strong> 靜態 CSS 推理或視覺截圖溝通連續失敗 ≥ 2 次、立刻主動提「我們啟個 server、我用 playwright 看 live DOM」 — 工具的價值是縮短診斷迴圈、不是最後手段。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼要主動提工具">為什麼要主動提工具&lt;/h2>
&lt;h3 id="商業邏輯">商業邏輯&lt;/h3>
&lt;p>執行者堅持靠推理 = 把使用者拖進「截圖 - 反饋 - 再試」的長循環。每輪都消耗使用者時間（看截圖、描述問題、回應）— 對使用者是負擔。&lt;/p>
&lt;p>主動提工具切換 = 把循環從「視覺溝通」改成「程式量測」。執行者直接讀 live DOM、診斷一輪到位、使用者只需要在最終確認。&lt;/p>
&lt;p>主動提的成本是「打一句話建議」、收益是「省 N 輪截圖溝通」。&lt;/p>
&lt;hr>
&lt;h2 id="這次任務的實際情境">這次任務的實際情境&lt;/h2>
&lt;h3 id="觀察">觀察&lt;/h3>
&lt;p>&lt;code>drawer 在 form 內、不是 sibling&lt;/code> 這個假設錯誤、靠推理 + 截圖溝通走了多輪：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>輪&lt;/th>
 &lt;th>溝通方式&lt;/th>
 &lt;th>結果&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>1&lt;/td>
 &lt;td>推理 + 寫 CSS + 使用者截圖回報&lt;/td>
 &lt;td>失敗、看不出根因&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>2&lt;/td>
 &lt;td>改 CSS + 使用者截圖回報&lt;/td>
 &lt;td>失敗、累積錯誤假設&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>3&lt;/td>
 &lt;td>加更多覆寫 + 使用者截圖回報&lt;/td>
 &lt;td>失敗、使用者「思路錯了」&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>4&lt;/td>
 &lt;td>「我啟個 server 看看」&lt;/td>
 &lt;td>立刻發現 drawer 在 form 內&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>第 4 輪用 playwright &lt;code>browser_evaluate&lt;/code> 讀 ancestor chain — 一個 query、一個答案、兩分鐘解。前三輪 ≈ 30 分鐘。&lt;/p>
&lt;h3 id="判讀">判讀&lt;/h3>
&lt;p>第 2 輪失敗時就應該主動提：&lt;/p>
&lt;blockquote>
&lt;p>「我嘗試了兩次都失敗、根因可能在我對 DOM 結構的假設。要不要啟個 server、我用 playwright 直接讀 live DOM 確認？這樣比繼續用截圖溝通快。」&lt;/p>&lt;/blockquote>
&lt;p>使用者啟 server、我跑 query、一輪解。&lt;/p>
&lt;h3 id="執行主動提工具的-protocol">執行：主動提工具的 protocol&lt;/h3>
&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>推理連續失敗 ≥ 2 次&lt;/td>
 &lt;td>playwright &lt;code>browser_evaluate&lt;/code> 讀 live DOM&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>不確定元素的真實位置&lt;/td>
 &lt;td>&lt;code>getBoundingClientRect&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>不確定 computed style 套到什麼值&lt;/td>
 &lt;td>&lt;code>getComputedStyle&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>不確定 framework 渲染後的 DOM&lt;/td>
 &lt;td>playwright snapshot&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>不確定跨 viewport 行為&lt;/td>
 &lt;td>playwright 切換 viewport 重測&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="工具引入的成本與價值">工具引入的成本與價值&lt;/h2>
&lt;h3 id="內在屬性比較">內在屬性比較&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>方法&lt;/th>
 &lt;th>起步成本&lt;/th>
 &lt;th>每輪成本&lt;/th>
 &lt;th>涵蓋&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>推理 + 截圖&lt;/td>
 &lt;td>0&lt;/td>
 &lt;td>高 — 截圖、描述、再試&lt;/td>
 &lt;td>有限 — 看截圖看不到 DOM&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>瀏覽器 DevTools 手動查&lt;/td>
 &lt;td>0&lt;/td>
 &lt;td>中 — 切面板、讀&lt;/td>
 &lt;td>中 — 互動成本高&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Playwright &lt;code>browser_evaluate&lt;/code>&lt;/td>
 &lt;td>中 — 起 server&lt;/td>
 &lt;td>低 — 寫一段 evaluate&lt;/td>
 &lt;td>高 — 任意 JS query&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Playwright 寫成測試&lt;/td>
 &lt;td>中 — 起 server + 寫測試&lt;/td>
 &lt;td>0 — 自動跑&lt;/td>
 &lt;td>高 + 持續&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>「起步成本」是一次性、「每輪成本」是重複的。第 2 輪以後、playwright 的 ROI 已經正向。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>驗證工具的引入時機不該等推理徹底失敗。</strong> 靜態 CSS 推理或視覺截圖溝通連續失敗 ≥ 2 次、立刻主動提「我們啟個 server、我用 playwright 看 live DOM」 — 工具的價值是縮短診斷迴圈、不是最後手段。</p>
<hr>
<h2 id="為什麼要主動提工具">為什麼要主動提工具</h2>
<h3 id="商業邏輯">商業邏輯</h3>
<p>執行者堅持靠推理 = 把使用者拖進「截圖 - 反饋 - 再試」的長循環。每輪都消耗使用者時間（看截圖、描述問題、回應）— 對使用者是負擔。</p>
<p>主動提工具切換 = 把循環從「視覺溝通」改成「程式量測」。執行者直接讀 live DOM、診斷一輪到位、使用者只需要在最終確認。</p>
<p>主動提的成本是「打一句話建議」、收益是「省 N 輪截圖溝通」。</p>
<hr>
<h2 id="這次任務的實際情境">這次任務的實際情境</h2>
<h3 id="觀察">觀察</h3>
<p><code>drawer 在 form 內、不是 sibling</code> 這個假設錯誤、靠推理 + 截圖溝通走了多輪：</p>
<table>
  <thead>
      <tr>
          <th>輪</th>
          <th>溝通方式</th>
          <th>結果</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td>推理 + 寫 CSS + 使用者截圖回報</td>
          <td>失敗、看不出根因</td>
      </tr>
      <tr>
          <td>2</td>
          <td>改 CSS + 使用者截圖回報</td>
          <td>失敗、累積錯誤假設</td>
      </tr>
      <tr>
          <td>3</td>
          <td>加更多覆寫 + 使用者截圖回報</td>
          <td>失敗、使用者「思路錯了」</td>
      </tr>
      <tr>
          <td>4</td>
          <td>「我啟個 server 看看」</td>
          <td>立刻發現 drawer 在 form 內</td>
      </tr>
  </tbody>
</table>
<p>第 4 輪用 playwright <code>browser_evaluate</code> 讀 ancestor chain — 一個 query、一個答案、兩分鐘解。前三輪 ≈ 30 分鐘。</p>
<h3 id="判讀">判讀</h3>
<p>第 2 輪失敗時就應該主動提：</p>
<blockquote>
<p>「我嘗試了兩次都失敗、根因可能在我對 DOM 結構的假設。要不要啟個 server、我用 playwright 直接讀 live DOM 確認？這樣比繼續用截圖溝通快。」</p></blockquote>
<p>使用者啟 server、我跑 query、一輪解。</p>
<h3 id="執行主動提工具的-protocol">執行：主動提工具的 protocol</h3>
<p>驗證工具該在這些時機主動提：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>應該提的工具</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>推理連續失敗 ≥ 2 次</td>
          <td>playwright <code>browser_evaluate</code> 讀 live DOM</td>
      </tr>
      <tr>
          <td>不確定元素的真實位置</td>
          <td><code>getBoundingClientRect</code></td>
      </tr>
      <tr>
          <td>不確定 computed style 套到什麼值</td>
          <td><code>getComputedStyle</code></td>
      </tr>
      <tr>
          <td>不確定 framework 渲染後的 DOM</td>
          <td>playwright snapshot</td>
      </tr>
      <tr>
          <td>不確定跨 viewport 行為</td>
          <td>playwright 切換 viewport 重測</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="工具引入的成本與價值">工具引入的成本與價值</h2>
<h3 id="內在屬性比較">內在屬性比較</h3>
<table>
  <thead>
      <tr>
          <th>方法</th>
          <th>起步成本</th>
          <th>每輪成本</th>
          <th>涵蓋</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>推理 + 截圖</td>
          <td>0</td>
          <td>高 — 截圖、描述、再試</td>
          <td>有限 — 看截圖看不到 DOM</td>
      </tr>
      <tr>
          <td>瀏覽器 DevTools 手動查</td>
          <td>0</td>
          <td>中 — 切面板、讀</td>
          <td>中 — 互動成本高</td>
      </tr>
      <tr>
          <td>Playwright <code>browser_evaluate</code></td>
          <td>中 — 起 server</td>
          <td>低 — 寫一段 evaluate</td>
          <td>高 — 任意 JS query</td>
      </tr>
      <tr>
          <td>Playwright 寫成測試</td>
          <td>中 — 起 server + 寫測試</td>
          <td>0 — 自動跑</td>
          <td>高 + 持續</td>
      </tr>
  </tbody>
</table>
<p>「起步成本」是一次性、「每輪成本」是重複的。第 2 輪以後、playwright 的 ROI 已經正向。</p>
<hr>
<h2 id="主動提的具體話術">主動提的具體話術</h2>
<h3 id="較差的提法">較差的提法</h3>
<blockquote>
<p>「要不要試試 playwright」</p></blockquote>
<p>模糊、使用者不一定知道為什麼要試、可能答「先這樣吧」。</p>
<h3 id="較好的提法">較好的提法</h3>
<blockquote>
<p>「我嘗試了兩次都失敗、根因可能不在 CSS、在我對 DOM 結構的假設。
要不要啟個 server（<code>python3 -m http.server 8000</code> 在 public/）、
我用 playwright <code>browser_evaluate</code> 直接讀 ancestor chain 確認？
這樣比繼續用截圖快很多。」</p></blockquote>
<p>說明：</p>
<ul>
<li><strong>為什麼提</strong>：兩次失敗、推理迴圈成本超過工具迴圈</li>
<li><strong>要使用者做什麼</strong>：啟 server、給一行指令</li>
<li><strong>我會做什麼</strong>：用 playwright evaluate 讀</li>
<li><strong>預期收益</strong>：縮短迴圈</li>
</ul>
<p>使用者明確知道 trade-off、決定簡單。</p>
<hr>
<h2 id="工具迴圈的標準流程">工具迴圈的標準流程</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">1. 使用者啟 server（python3 -m http.server / hugo server）
</span></span><span class="line"><span class="ln">2</span><span class="cl">2. 執行者 navigate 到目標頁面
</span></span><span class="line"><span class="ln">3</span><span class="cl">3. 執行者寫 evaluate fn 讀真實狀態
</span></span><span class="line"><span class="ln">4</span><span class="cl">4. 執行者根據結果定位根因
</span></span><span class="line"><span class="ln">5</span><span class="cl">5. 執行者改 CSS / JS
</span></span><span class="line"><span class="ln">6</span><span class="cl">6. 執行者再 evaluate 驗證修復
</span></span><span class="line"><span class="ln">7</span><span class="cl">7. 使用者目視最後確認（可選）</span></span></code></pre></div><p>整個流程多數步驟在執行者這邊、使用者只在頭尾參與 — 對使用者負擔輕。</p>
<hr>
<h2 id="設計取捨驗證工具引入的時機">設計取捨：驗證工具引入的時機</h2>
<p>四種做法、各自機會成本不同。這個專案選 A（推理 ≥ 2 次失敗主動提）當預設、其他做法在特定情境合理。</p>
<blockquote>
<p>本篇是 <a href="../two-occurrence-threshold/">#42 2 次門檻</a> 抽象原則在「驗證工具切換」這個面向的應用。</p></blockquote>
<h3 id="a推理--2-次失敗主動提工具切換這個專案的預設">A：推理 ≥ 2 次失敗主動提工具切換（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：靜態推理連續失敗 2 次、立刻提「啟個 server、我用 playwright 看 live DOM」+ 附啟用步驟與預期收益</li>
<li><strong>選 A 的理由</strong>：對使用者透明（看到 trade-off）、縮短診斷迴圈</li>
<li><strong>適合</strong>：CSS / DOM 行為跟預期不符的除錯</li>
<li><strong>代價</strong>：執行者要主動辨識「推理迴圈成本」與「工具迴圈成本」的交叉點</li>
</ul>
<h3 id="b等使用者要求才用工具">B：等使用者要求才用工具</h3>
<ul>
<li><strong>機制</strong>：執行者繼續推理、使用者覺得太慢時提</li>
<li><strong>跟 A 的取捨</strong>：B 對使用者更被動、A 主動；B 在使用者不知道有 playwright 選項時、會一直繼續</li>
<li><strong>B 才合理的情境</strong>：使用者明確表達「想用推理練習」、把工具切換當成放棄</li>
</ul>
<h3 id="c全程靜態推理不用工具">C：全程靜態推理、不用工具</h3>
<ul>
<li><strong>機制</strong>：堅持推理到底</li>
<li><strong>C 是反模式</strong>：推理迴圈成本累積、最後可能需要 4-5 輪才解決</li>
<li><strong>看起來吸引人的原因</strong>：覺得用工具是「能力不足」、想撐到自己想出來</li>
<li><strong>實際發生的代價</strong>：時間成本指數放大（每輪推理基於前輪錯假設）、最後還是要切工具</li>
</ul>
<h3 id="d一開始就用-playwright不嘗試推理">D：一開始就用 playwright、不嘗試推理</h3>
<ul>
<li><strong>機制</strong>：跳過推理、直接用工具量</li>
<li><strong>跟 A 的取捨</strong>：D 跳過推理階段省去 2 次嘗試、但前期 setup 成本投入比例較高（簡單問題不值得）</li>
<li><strong>D 比 A 好的情境</strong>：問題明確需要 live DOM 才能診斷（例如「framework 渲染後的結構」）— 推理本來就無法解</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>應該主動提的工具</th>
          <th>提的話術重點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>推理 + 截圖溝通 ≥ 2 輪</td>
          <td>playwright <code>browser_evaluate</code></td>
          <td>我假設可能錯、用工具讀 live DOM 確認</td>
      </tr>
      <tr>
          <td>修了 CSS 但使用者截圖看起來沒變</td>
          <td>playwright <code>getComputedStyle</code></td>
          <td>確認 CSS 真的套到、不是 cache 問題</td>
      </tr>
      <tr>
          <td>不確定哪個 viewport 下會有問題</td>
          <td>playwright 多 viewport 測</td>
          <td>一次跑多 viewport、找出哪個壞</td>
      </tr>
      <tr>
          <td>互動狀態下行為不一致</td>
          <td>playwright 模擬互動 + 量測</td>
          <td>自動操作、量結果</td>
      </tr>
      <tr>
          <td>修好了想固化規範</td>
          <td>playwright 寫測試</td>
          <td>把這次發現的契約寫成 expect、未來破壞會被抓</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：工具引入時機是「推理迴圈成本超過工具迴圈成本」的點 — 大多在第 2 次推理失敗時。早一點提、雙方都省時間。</p>
<p>跟 <a href="../verification-timeline-checkpoints/">#68 驗收的時間軸</a> 的關係：本卡是「debug 工具切換時機」、#68 是「驗收動作分散在四個時點」 — 兩者共用「動作該分配到哪個時點才有 ROI」這個結構。本卡的「第 2 次推理失敗就切工具」≈ #68 的「ship 前要設計 E2E case」 — 都是「把高 ROI 的動作放在對的時點、不要延後」。</p>
]]></content:encoded></item><item><title>Log 時間真空是 silent hang 訊號、happy log 是 anti-signal</title><link>https://tarrragon.github.io/blog/report/time-vacuum-in-logs-signals-silent-hang/</link><pubDate>Mon, 29 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/time-vacuum-in-logs-signals-silent-hang/</guid><description>&lt;h2 id="論述基礎與限制">論述基礎與限制&lt;/h2>
&lt;p>本卡抽自 blog CI 的 Playwright install step 反覆 timeout 事件。Playwright 1.59 在 Node.js 24.16.0 上 extract-zip silent hang，表面看是「下載太慢 / timeout 太緊」，實際是 upstream regression。limitation：evidence 來自單一 CI 事件，但 silent hang 模式在 Docker build、cron job、database migration 等場景都出現過。&lt;/p>
&lt;p>完整 case study 見 &lt;a href="https://tarrragon.github.io/blog/work-log/ci-step-silent-hang%E6%99%82%E9%96%93%E7%9C%9F%E7%A9%BA%E6%89%8D%E6%98%AF%E8%A8%8A%E8%99%9Fhappy-log-%E5%8F%8D%E8%80%8C%E6%98%AF-anti-signal/" data-link-title="CI step silent hang：時間真空才是訊號、happy log 反而是 anti-signal" data-link-desc="CI step 跑很久才 timeout、最後一行卻是「下載 100% / build succeeded」這種 happy log 時回來。判讀：別急著加 timeout，先算最後一行到 cancel 的時間真空、確認是 silent hang，再用症狀詞查 upstream issue。同方向修法連 fail 2 次就是停手回資料層的訊號。">CI step silent hang&lt;/a>。&lt;/p>
&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>非互動 process 的 log 輸出中，最後一行成功訊息（happy log）到被外部 cancel 之間的大段時間無輸出（時間真空），是 silent hang 的判讀訊號。&lt;/p>
&lt;p>技術人員習慣在 log 裡搜尋 error keyword 找失敗原因。但 silent hang 沒有 error keyword — process 沒 crash，只是不再做任何事。辨識 silent hang 需要轉換訊號類型：從「訊息內容」轉到「訊息時序」。&lt;/p>
&lt;h2 id="情境">情境&lt;/h2>
&lt;p>CI step 跑了 15 分鐘被 timeout cancel。最後一行 log 是「chromium 下載 100% 完成」— 這是 happy log，直覺判斷是「下載慢、timeout 太緊」。加了 cache + bump timeout 到 25 分鐘，仍然頂到上限被 cancel。&lt;/p>
&lt;p>回頭看 detailed log 的 timestamp：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">2026-05-27T09:59:44.110Z | 100% of 170.4 MiB
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">2026-05-27T10:24:15.201Z ##[error]The operation was canceled.&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>24 分 31 秒的時間真空。下載 2 秒完成，之後 process 完全沒有任何 log 輸出直到被 cancel。&lt;/p>
&lt;h2 id="理想做法">理想做法&lt;/h2>
&lt;p>CI step timeout 時，先抓四個 timestamp 判斷是否 silent hang，再決定修法：&lt;/p>
&lt;ol>
&lt;li>Step 開始的 timestamp&lt;/li>
&lt;li>Step 結束（cancel / fail）的 timestamp&lt;/li>
&lt;li>最後一行有意義輸出的 timestamp&lt;/li>
&lt;li>計算 #3 到 #2 之間的時間真空&lt;/li>
&lt;/ol>
&lt;p>真空相對該 step 正常輸出節奏明顯異常（CI extract 類場景通常秒級輸出、真空超過數分鐘即可疑）且最後一行是 happy log → silent hang 嫌疑高 → 用症狀詞查 upstream issue tracker，不是加 timeout。&lt;/p>
&lt;p>三類 timeout 模式的修法不同：&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>進度持續、最後階段到 timeout&lt;/td>
 &lt;td>時間真的不夠&lt;/td>
 &lt;td>bump timeout&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>有失敗訊息之後 timeout&lt;/td>
 &lt;td>code 邏輯錯&lt;/td>
 &lt;td>看訊息修&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>最後一行 happy log 之後大段時間真空&lt;/td>
 &lt;td>silent hang&lt;/td>
 &lt;td>查 upstream issue tracker&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="沒這樣做的麻煩">沒這樣做的麻煩&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>反覆加 timeout&lt;/strong>：每次都「差一點」（頂到上限），每次都以為「timeout 不夠」，實際上 process 永遠不會自己結束&lt;/li>
&lt;li>&lt;strong>Cache 是假瓶頸&lt;/strong>：直覺判斷「下載慢 → 加 cache」，但瓶頸在 extract hang（下載只花 2 秒）&lt;/li>
&lt;li>&lt;strong>False positive 越雕越精緻&lt;/strong>：cache key 調整、timeout 微調、retry 策略 — 每一步單看合理，合起來是把錯誤假設越做越細&lt;/li>
&lt;/ul>
&lt;h2 id="判讀徵兆">判讀徵兆&lt;/h2>
&lt;p>兩個訊號同時出現時，應該先排除 silent hang 再提其他解法：&lt;/p></description><content:encoded><![CDATA[<h2 id="論述基礎與限制">論述基礎與限制</h2>
<p>本卡抽自 blog CI 的 Playwright install step 反覆 timeout 事件。Playwright 1.59 在 Node.js 24.16.0 上 extract-zip silent hang，表面看是「下載太慢 / timeout 太緊」，實際是 upstream regression。limitation：evidence 來自單一 CI 事件，但 silent hang 模式在 Docker build、cron job、database migration 等場景都出現過。</p>
<p>完整 case study 見 <a href="/blog/work-log/ci-step-silent-hang%E6%99%82%E9%96%93%E7%9C%9F%E7%A9%BA%E6%89%8D%E6%98%AF%E8%A8%8A%E8%99%9Fhappy-log-%E5%8F%8D%E8%80%8C%E6%98%AF-anti-signal/" data-link-title="CI step silent hang：時間真空才是訊號、happy log 反而是 anti-signal" data-link-desc="CI step 跑很久才 timeout、最後一行卻是「下載 100% / build succeeded」這種 happy log 時回來。判讀：別急著加 timeout，先算最後一行到 cancel 的時間真空、確認是 silent hang，再用症狀詞查 upstream issue。同方向修法連 fail 2 次就是停手回資料層的訊號。">CI step silent hang</a>。</p>
<h2 id="核心原則">核心原則</h2>
<p>非互動 process 的 log 輸出中，最後一行成功訊息（happy log）到被外部 cancel 之間的大段時間無輸出（時間真空），是 silent hang 的判讀訊號。</p>
<p>技術人員習慣在 log 裡搜尋 error keyword 找失敗原因。但 silent hang 沒有 error keyword — process 沒 crash，只是不再做任何事。辨識 silent hang 需要轉換訊號類型：從「訊息內容」轉到「訊息時序」。</p>
<h2 id="情境">情境</h2>
<p>CI step 跑了 15 分鐘被 timeout cancel。最後一行 log 是「chromium 下載 100% 完成」— 這是 happy log，直覺判斷是「下載慢、timeout 太緊」。加了 cache + bump timeout 到 25 分鐘，仍然頂到上限被 cancel。</p>
<p>回頭看 detailed log 的 timestamp：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">2026-05-27T09:59:44.110Z  | 100% of 170.4 MiB
</span></span><span class="line"><span class="ln">2</span><span class="cl">2026-05-27T10:24:15.201Z  ##[error]The operation was canceled.</span></span></code></pre></div><p>24 分 31 秒的時間真空。下載 2 秒完成，之後 process 完全沒有任何 log 輸出直到被 cancel。</p>
<h2 id="理想做法">理想做法</h2>
<p>CI step timeout 時，先抓四個 timestamp 判斷是否 silent hang，再決定修法：</p>
<ol>
<li>Step 開始的 timestamp</li>
<li>Step 結束（cancel / fail）的 timestamp</li>
<li>最後一行有意義輸出的 timestamp</li>
<li>計算 #3 到 #2 之間的時間真空</li>
</ol>
<p>真空相對該 step 正常輸出節奏明顯異常（CI extract 類場景通常秒級輸出、真空超過數分鐘即可疑）且最後一行是 happy log → silent hang 嫌疑高 → 用症狀詞查 upstream issue tracker，不是加 timeout。</p>
<p>三類 timeout 模式的修法不同：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>根因</th>
          <th>修法</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>進度持續、最後階段到 timeout</td>
          <td>時間真的不夠</td>
          <td>bump timeout</td>
      </tr>
      <tr>
          <td>有失敗訊息之後 timeout</td>
          <td>code 邏輯錯</td>
          <td>看訊息修</td>
      </tr>
      <tr>
          <td>最後一行 happy log 之後大段時間真空</td>
          <td>silent hang</td>
          <td>查 upstream issue tracker</td>
      </tr>
  </tbody>
</table>
<h2 id="沒這樣做的麻煩">沒這樣做的麻煩</h2>
<ul>
<li><strong>反覆加 timeout</strong>：每次都「差一點」（頂到上限），每次都以為「timeout 不夠」，實際上 process 永遠不會自己結束</li>
<li><strong>Cache 是假瓶頸</strong>：直覺判斷「下載慢 → 加 cache」，但瓶頸在 extract hang（下載只花 2 秒）</li>
<li><strong>False positive 越雕越精緻</strong>：cache key 調整、timeout 微調、retry 策略 — 每一步單看合理，合起來是把錯誤假設越做越細</li>
</ul>
<h2 id="判讀徵兆">判讀徵兆</h2>
<p>兩個訊號同時出現時，應該先排除 silent hang 再提其他解法：</p>
<ol>
<li>非互動 process 跑的時間接近或等於 timeout 上限（「頂到上限」模式）</li>
<li>最後一行 log 是成功訊息（下載完成 / build succeeded / tests passed）</li>
</ol>
<p>另一個後設訊號：同方向修法（加 timeout / 加 cache / 加 retry）2 次都仍頂到上限 — 這時候問題幾乎確定不是「時間不夠」。對應 <a href="/blog/report/failure-direction-pivot-point/" data-link-title="同方向反覆失敗的轉折點" data-link-desc="第 2 次同方向失敗就停下來回報「假設可能錯了、要不要換思路」、不要等第 4 次失敗才被使用者打斷。本文展開失敗計數與方向切換的判斷。">#20 同方向反覆失敗的轉折點</a>。</p>
<h2 id="跟其他抽象層原則的關係">跟其他抽象層原則的關係</h2>
<ul>
<li>→ <a href="/blog/report/failure-direction-pivot-point/" data-link-title="同方向反覆失敗的轉折點" data-link-desc="第 2 次同方向失敗就停下來回報「假設可能錯了、要不要換思路」、不要等第 4 次失敗才被使用者打斷。本文展開失敗計數與方向切換的判斷。">#20 同方向反覆失敗的轉折點</a>：本案例是 #20 在 CI timeout 場景的 evidence — 第二次 bump timeout 仍 fail 時就該停下來換思路</li>
<li>→ <a href="/blog/report/single-function-per-article-sop-vs-retrospective/" data-link-title="一篇文章只承擔一種功能：SOP 跟 retrospective 混寫兩邊都做不好" data-link-desc="文章同時塞操作步驟（SOP）和批次驗證紀錄（retrospective）時，機器讀者找不到可執行的步驟、人類讀者不知道哪段是給自己看的。">#199 一篇文章只承擔一種功能</a>：本卡的來源文章原本放在 <code>posts/</code>，實際是 debugging case study，搬到 <code>work-log/</code> 後從中抽出本卡，是 #199 拆分動作的實例</li>
</ul>
]]></content:encoded></item><item><title>CI step silent hang：時間真空才是訊號、happy log 反而是 anti-signal</title><link>https://tarrragon.github.io/blog/work-log/ci-step-silent-hang%E6%99%82%E9%96%93%E7%9C%9F%E7%A9%BA%E6%89%8D%E6%98%AF%E8%A8%8A%E8%99%9Fhappy-log-%E5%8F%8D%E8%80%8C%E6%98%AF-anti-signal/</link><pubDate>Thu, 28 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/ci-step-silent-hang%E6%99%82%E9%96%93%E7%9C%9F%E7%A9%BA%E6%89%8D%E6%98%AF%E8%A8%8A%E8%99%9Fhappy-log-%E5%8F%8D%E8%80%8C%E6%98%AF-anti-signal/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>核心議題&lt;/strong>：CI step 看起來「跑了很久才 timeout」時，要分辨「真的時間不夠」跟「silent hang 占滿時間」 — 兩者修法完全不同。Silent hang 的訊號是「最後一行 happy log 到 cancel 之間有大段時間真空」、不是「最後一行錯誤訊息」。第一次歸因錯誤後、第二次 fail 不該再加 timeout、該停下來重看 detailed log。
&lt;strong>案例骨幹&lt;/strong>：本 blog 的 Playwright CI 一直 timeout、初診「cache 缺失 + timeout 太緊」加了 cache + bump timeout、仍 timeout。重看 detailed log 發現 chromium 下載 2 秒完成、之後 24 分 31 秒&lt;strong>完全沒任何 log&lt;/strong> 才被 cancel — Playwright 1.59 在 Node.js 24.16.0 的 extract-zip regression（&lt;a href="https://github.com/microsoft/playwright/issues/41000">microsoft/playwright#41000&lt;/a>、上游 &lt;a href="https://github.com/nodejs/node/issues/63487">nodejs/node#63487&lt;/a>）。升 Playwright 1.60.0 後該 step 從 25 分鐘卡死降到 22 秒。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="1-silent-hang-是-happy-log-的-anti-signal">1. Silent hang 是 happy log 的 anti-signal&lt;/h2>
&lt;p>CI step timeout 時、第一個本能是看「step 跑了多久」。15 分鐘 timeout 然後被砍、直覺判斷是「時間不夠、bump timeout」。這個直覺對應的失敗模式是「step 真的需要 16 分鐘才能跑完」。&lt;/p>
&lt;p>但有另一種失敗模式長得很像、修法完全不同：&lt;strong>silent hang&lt;/strong> — step 在某個點之後就不再輸出任何 log、process 仍在執行（沒有 crash）、直到外部 timeout 才被砍。表面看跟「時間不夠」一樣（step 跑很久才被 cancel）、但根因是 process 本身卡死、給多少時間都跑不完。&lt;/p>
&lt;p>辨識 silent hang 的關鍵訊號是「最後一行 happy log 到 cancel 訊息之間有大段時間真空」。&lt;strong>「Happy log」指的是看起來成功的訊息&lt;/strong>（例：下載 100% 完成、build succeeded、X tests passed）— 這類訊息特別會誤導判斷、因為它讓人以為任務在進展。Silent hang 開始之前的最後一行通常正是這種 happy log、是正常結束訊號的反面。&lt;/p>
&lt;h3 id="三類-timeout-模式的對照">三類 timeout 模式的對照&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>訊號&lt;/th>
 &lt;th>可能根因&lt;/th>
 &lt;th>修法&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>整個 step 進度持續、最後階段加速到 timeout&lt;/td>
 &lt;td>時間真的不夠&lt;/td>
 &lt;td>bump timeout&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>有失敗訊息（exception / non-zero exit）之後 timeout&lt;/td>
 &lt;td>code 邏輯錯&lt;/td>
 &lt;td>看訊息修&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>最後一行 log 之後有大段時間真空、然後 cancel&lt;/strong>&lt;/td>
 &lt;td>&lt;strong>Silent hang&lt;/strong>、可能 upstream bug&lt;/td>
 &lt;td>&lt;strong>查 upstream issue tracker、不是加 timeout&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>第三種最容易誤判、因為「log 之間沒輸出」沒被當成訊號 — 但&lt;strong>訊息真空本身就是訊號&lt;/strong>。寫 debug log 的人會記得補 error 訊息、但 silent hang 通常發生在工具內部的某個沒輸出 log 的等待點、所以沒有 error 訊息可看。&lt;/p>
&lt;hr>
&lt;h2 id="2-為什麼cache-缺失--bump-timeout的初診是-false-positive">2. 為什麼「cache 缺失 + bump timeout」的初診是 false positive&lt;/h2>
&lt;p>第一次看 CI fail log 時、有三件容易抓到的事：&lt;/p>
&lt;ol>
&lt;li>workflow YAML 裡的 &lt;code>timeout-minutes: 15&lt;/code>&lt;/li>
&lt;li>step 跑了 &lt;code>15m 6s&lt;/code>（幾乎等於 timeout 上限）&lt;/li>
&lt;li>step 名稱是 &lt;code>Install Playwright browsers&lt;/code>（要下載 170 MiB）&lt;/li>
&lt;/ol>
&lt;p>直覺合成的結論：「cache 缺失 + timeout 太緊」。這結論看起來「應該對」 — 因為這兩個都是「Install Playwright browsers」眾所周知的優化點。修法：加 &lt;code>actions/cache&lt;/code> + bump timeout 25 min。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>核心議題</strong>：CI step 看起來「跑了很久才 timeout」時，要分辨「真的時間不夠」跟「silent hang 占滿時間」 — 兩者修法完全不同。Silent hang 的訊號是「最後一行 happy log 到 cancel 之間有大段時間真空」、不是「最後一行錯誤訊息」。第一次歸因錯誤後、第二次 fail 不該再加 timeout、該停下來重看 detailed log。
<strong>案例骨幹</strong>：本 blog 的 Playwright CI 一直 timeout、初診「cache 缺失 + timeout 太緊」加了 cache + bump timeout、仍 timeout。重看 detailed log 發現 chromium 下載 2 秒完成、之後 24 分 31 秒<strong>完全沒任何 log</strong> 才被 cancel — Playwright 1.59 在 Node.js 24.16.0 的 extract-zip regression（<a href="https://github.com/microsoft/playwright/issues/41000">microsoft/playwright#41000</a>、上游 <a href="https://github.com/nodejs/node/issues/63487">nodejs/node#63487</a>）。升 Playwright 1.60.0 後該 step 從 25 分鐘卡死降到 22 秒。</p></blockquote>
<hr>
<h2 id="1-silent-hang-是-happy-log-的-anti-signal">1. Silent hang 是 happy log 的 anti-signal</h2>
<p>CI step timeout 時、第一個本能是看「step 跑了多久」。15 分鐘 timeout 然後被砍、直覺判斷是「時間不夠、bump timeout」。這個直覺對應的失敗模式是「step 真的需要 16 分鐘才能跑完」。</p>
<p>但有另一種失敗模式長得很像、修法完全不同：<strong>silent hang</strong> — step 在某個點之後就不再輸出任何 log、process 仍在執行（沒有 crash）、直到外部 timeout 才被砍。表面看跟「時間不夠」一樣（step 跑很久才被 cancel）、但根因是 process 本身卡死、給多少時間都跑不完。</p>
<p>辨識 silent hang 的關鍵訊號是「最後一行 happy log 到 cancel 訊息之間有大段時間真空」。<strong>「Happy log」指的是看起來成功的訊息</strong>（例：下載 100% 完成、build succeeded、X tests passed）— 這類訊息特別會誤導判斷、因為它讓人以為任務在進展。Silent hang 開始之前的最後一行通常正是這種 happy log、是正常結束訊號的反面。</p>
<h3 id="三類-timeout-模式的對照">三類 timeout 模式的對照</h3>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>可能根因</th>
          <th>修法</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>整個 step 進度持續、最後階段加速到 timeout</td>
          <td>時間真的不夠</td>
          <td>bump timeout</td>
      </tr>
      <tr>
          <td>有失敗訊息（exception / non-zero exit）之後 timeout</td>
          <td>code 邏輯錯</td>
          <td>看訊息修</td>
      </tr>
      <tr>
          <td><strong>最後一行 log 之後有大段時間真空、然後 cancel</strong></td>
          <td><strong>Silent hang</strong>、可能 upstream bug</td>
          <td><strong>查 upstream issue tracker、不是加 timeout</strong></td>
      </tr>
  </tbody>
</table>
<p>第三種最容易誤判、因為「log 之間沒輸出」沒被當成訊號 — 但<strong>訊息真空本身就是訊號</strong>。寫 debug log 的人會記得補 error 訊息、但 silent hang 通常發生在工具內部的某個沒輸出 log 的等待點、所以沒有 error 訊息可看。</p>
<hr>
<h2 id="2-為什麼cache-缺失--bump-timeout的初診是-false-positive">2. 為什麼「cache 缺失 + bump timeout」的初診是 false positive</h2>
<p>第一次看 CI fail log 時、有三件容易抓到的事：</p>
<ol>
<li>workflow YAML 裡的 <code>timeout-minutes: 15</code></li>
<li>step 跑了 <code>15m 6s</code>（幾乎等於 timeout 上限）</li>
<li>step 名稱是 <code>Install Playwright browsers</code>（要下載 170 MiB）</li>
</ol>
<p>直覺合成的結論：「cache 缺失 + timeout 太緊」。這結論看起來「應該對」 — 因為這兩個都是「Install Playwright browsers」眾所周知的優化點。修法：加 <code>actions/cache</code> + bump timeout 25 min。</p>
<p>修完仍 timeout、但這次跑 <code>25m 6s</code>（一樣頂到上限）。</p>
<p><strong>這時的訊號應該是「同樣的 step 在 1.67 倍的 timeout 下仍頂到上限」</strong> — 如果是時間不夠、bump 之後該往中間靠（譬如完成在 18-20 min）；如果一直頂到上限、意思是 step 不會自己結束、是 hang。</p>
<p>但初診時很容易略過這個訊號、轉而繼續想「是不是 cache step 設定有問題？」。這個歸因方向是錯的、因為前置假設「cache 是瓶頸」本身就沒驗證過。</p>
<h3 id="一輪-false-positive-的-anatomy">一輪 false positive 的 anatomy</h3>
<table>
  <thead>
      <tr>
          <th>步驟</th>
          <th>容易做的</th>
          <th>該做的</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>看到 timeout</td>
          <td>假設「時間不夠」</td>
          <td>先區分「時間不夠」vs「silent hang」</td>
      </tr>
      <tr>
          <td>看 high-level log</td>
          <td>假設「下載慢」</td>
          <td>應該看下載前後 timestamp 比對</td>
      </tr>
      <tr>
          <td>提解法</td>
          <td>加 cache + bump timeout</td>
          <td>應該先確認瓶頸真的在下載</td>
      </tr>
      <tr>
          <td>解法仍 fail</td>
          <td>假設「cache 沒 hit」</td>
          <td>應該意識到「同個 step 又頂到上限」是 hang 訊號</td>
      </tr>
  </tbody>
</table>
<p>每一步單看都合理、合起來就是把 false positive 越雕越精緻。這個 anatomy 對任何「初診沒驗證就改」的場景都適用、不限 CI。</p>
<hr>
<h2 id="3-wrap-的-r-在第二次-fail-時是-stop-訊號">3. WRAP 的 R 在第二次 fail 時是 stop 訊號</h2>
<p>WRAP 決策框架的 R（Reality Test）原則是「需要什麼事證才能證明這個方法可行？」。它不只是決策前的檢查、更是<strong>連續失敗後的 stop 訊號</strong>。</p>
<p>第二次 fail 時、繼續同方向加 timeout 是自動駕駛模式。WRAP 在這個位置該提醒的事：</p>
<ul>
<li>「兩次同類修法都沒解、是不是前置假設錯了？」</li>
<li>「我有沒有資料去判斷真正卡哪？」（資料充足度閘門）</li>
<li>「同類問題的 base rate 是什麼？」（基本率思考）</li>
</ul>
<p><strong>Stop 訊號的觸發條件是「同方向修法連續 fail 2 次」、不是「fail 3 次」</strong>。第二次就該回到資料層；第三次已經是浪費 cycle 而且強化錯誤假設。</p>
<p>實際上第二次 fail 後做的對的事是停下來、grep detailed log 的 timestamp 序列、發現「下載完成」跟「cancel」之間有 24 分鐘空白 — 這時才確認是 silent hang。如果第二次沒做這個轉折、第三次大概率是「換更大的 timeout」或「換不同的 cache key」、仍 fail。</p>
<hr>
<h2 id="4-detailed-log-的關鍵讀法找沒輸出的時間段">4. Detailed log 的關鍵讀法：找「沒輸出的時間段」</h2>
<p>CI 平台的 step log 通常很長、人眼掃容易跳過。看 silent hang 嫌疑時、讀法不是順序讀、是抓四個 timestamp：</p>
<ol>
<li><strong>Step 開始的 timestamp</strong>（log header 通常有）</li>
<li><strong>Step 結束（cancel / fail）的 timestamp</strong></li>
<li><strong>最後一行有意義輸出的 timestamp</strong></li>
<li>計算 #3 到 #2 之間的時間真空</li>
</ol>
<p>真空夠大（&gt; 1 分鐘）+ #3 是 happy log = silent hang 嫌疑高。</p>
<p>GitHub Actions 用 <code>gh</code> CLI 的具體做法：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 取某個 step 的所有 log（filter step 名稱）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">gh run view &lt;run-id&gt; --log --job &lt;job-id&gt; <span class="p">|</span> rg <span class="s2">&#34;Install Playwright browsers&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 抓最後幾行看真空尾巴</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">gh run view &lt;run-id&gt; --log --job &lt;job-id&gt; <span class="p">|</span> rg <span class="s2">&#34;Install Playwright browsers&#34;</span> <span class="p">|</span> tail -3</span></span></code></pre></div><p>本案例的最後 3 行（簡化過）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">2026-05-27T09:59:44.110Z  | 100% of 170.4 MiB
</span></span><span class="line"><span class="ln">2</span><span class="cl">2026-05-27T10:24:15.201Z  ##[error]The operation was canceled.</span></span></code></pre></div><p>24 分 31 秒真空、最後一行 happy log 是「下載 100% 完成」 — silent hang 確認。</p>
<p>這個讀法的核心是「<strong>時間真空優先於訊息內容</strong>」。技術人員習慣讀訊息內容找 error keyword、但 silent hang 沒有 error keyword 可找、只有時間真空。轉個訊號類型才看得到。</p>
<hr>
<h2 id="5-upstream-issue-搜尋的優先序">5. Upstream issue 搜尋的優先序</h2>
<p>Silent hang 確認後、下一步通常<strong>不是繼續 reason 根因</strong>、是去查 upstream issue tracker。Silent hang 多半是工具 / 依賴的 bug、而非自己 config 錯 — 因為 config 錯通常有 error message、不會 silent。</p>
<p>查詢策略：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">gh api <span class="s1">&#39;search/issues?q=repo:&lt;upstream&gt;/&lt;repo&gt;+&lt;symptom keywords&gt;+is:issue&amp;per_page=10&amp;sort=updated&#39;</span></span></span></code></pre></div><p>關鍵是 <strong>keyword 選擇用「症狀詞」而不是「猜測詞」</strong>。症狀詞描述讀者實際觀察到的現象（<code>hangs after download</code>、<code>stuck during extract</code>），猜測詞描述讀者推測的根因（<code>slow</code>、<code>timeout</code>、<code>network issue</code>）。猜測詞會找到大量無關 issue；症狀詞通常直接命中。</p>
<p>本案例查詢 <code>playwright install hangs chromium</code> 第二筆結果就是 issue #41000、標題完全匹配「<code>playwright install chromium</code> hangs after download completes on Node.js 24.16.0 (extract-zip)」。Issue 詳情指向上游 <a href="https://github.com/nodejs/node/issues/63487">nodejs/node#63487</a>、給出兩個 workaround（升 Playwright 1.60.0 或 pin Node 24.15.0）。從查詢到確認根因、全程不到 5 分鐘。</p>
<h3 id="為什麼-issue-tracker-該優先於-self-reasoning">為什麼 issue tracker 該優先於 self-reasoning</h3>
<p>技術人員的 instinct 是「自己想出根因」。但 CI silent hang 這類問題、根因通常在工具版本、runtime 版本、OS、container image 的微妙交互、不在自己的 codebase。<strong>Reasoning 找不到的東西、社群 issue tracker 經常已經有人回報過</strong>。</p>
<p>「先 reason 再查」跟「先查再 reason」的取捨：</p>
<table>
  <thead>
      <tr>
          <th>問題範圍</th>
          <th>哪個優先</th>
          <th>為什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>自己 codebase 內的邏輯 bug</td>
          <td>reason</td>
          <td>自己最熟、reasoning 通常較快</td>
      </tr>
      <tr>
          <td>Upstream tool / runtime / OS / container 範圍</td>
          <td>查 issue</td>
          <td>自己沒上游知識、reasoning 容易卡在錯誤前置假設</td>
      </tr>
      <tr>
          <td>兩者交界（自己 config 觸發 upstream bug）</td>
          <td>並行</td>
          <td>先查找 known issue、同時 reason 自己 config</td>
      </tr>
  </tbody>
</table>
<p>Silent hang 預設屬於第二類、應該優先查 issue tracker。</p>
<hr>
<h2 id="6-整合訊號--行動-mapping">6. 整合：訊號 → 行動 mapping</h2>
<p>把本案例的經驗整理成可重用的訊號表：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>行動</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Step timeout 且最後一行是 happy log</td>
          <td>計算 timestamp 真空、確認是否 silent hang</td>
      </tr>
      <tr>
          <td>同方向修法 2 次都 fail</td>
          <td>停止、回到資料層、不再加 timeout / retry</td>
      </tr>
      <tr>
          <td>Silent hang 確認</td>
          <td>用症狀詞查 upstream issue tracker</td>
      </tr>
      <tr>
          <td>Issue 命中且有 workaround</td>
          <td>套 workaround、不要先 reason</td>
      </tr>
      <tr>
          <td>Issue 沒命中</td>
          <td>才回到 self-debug、加 verbose log（<code>DEBUG=</code> env）</td>
      </tr>
  </tbody>
</table>
<p>這張表的順序很重要：每一步的「該做的事」是下一步的「前置條件」。略過任一步、後面的判斷會建立在錯誤假設上。</p>
<hr>
<h2 id="適用範圍">適用範圍</h2>
<p>「Silent log 是 happy log 的 anti-signal」這個原則對所有非互動 process（CI、cron job、background worker、container init）都適用：</p>
<ul>
<li><strong>Docker build 卡住</strong>（特別是 RUN apt-get / npm install / pip install）— 同類 silent hang 模式</li>
<li><strong>CI cache restore 卡住</strong> — 大量小檔案的 cache 操作可能 silent hang</li>
<li><strong>Database migration 卡住</strong> — schema 變更 + 長 transaction 可能 silent hang</li>
<li><strong>任何 process 跑時間接近 timeout 上限被 cancel</strong> — 先檢查是否 silent hang 才提解法</li>
</ul>
<p>「WRAP R 在第二次 fail 時是 stop 訊號」這條原則不限 CI、適用所有「同方向修法重複 fail」的場景：debug、設定調校、效能優化。</p>
<hr>
<h2 id="參考資料">參考資料</h2>
<ul>
<li><a href="https://github.com/microsoft/playwright/issues/41000">microsoft/playwright issue #41000</a> — 本案例的 upstream issue（Playwright 1.57-1.59 在 Node 24.16.0 extract-zip hang）</li>
<li><a href="https://github.com/nodejs/node/issues/63487">nodejs/node issue #63487</a> — Node 24.16 extract-zip / yauzl regression 上游</li>
<li>同 blog 文章：<a href="/blog/skills/wrap-decision/" data-link-title="WRAP 決策框架 — 認知偏誤防護與決策品質" data-link-desc="WRAP 決策框架的 blog 好讀版：用錨點確認、資料充足度、選項擴增、現實檢驗、機會成本、行前預想與絆腳索防止自動駕駛式決策。">WRAP 決策框架的 R 階段操作</a> — Reality Test 詳細用法</li>
</ul>
]]></content:encoded></item><item><title>flutter devices 卡住的訊號：device 數從 N 變 N-1 與 emulator 半活</title><link>https://tarrragon.github.io/blog/work-log/flutter-devices-%E5%8D%A1%E4%BD%8F%E7%9A%84%E8%A8%8A%E8%99%9Fdevice-%E6%95%B8%E5%BE%9E-n-%E8%AE%8A-n-1-%E8%88%87-emulator-%E5%8D%8A%E6%B4%BB/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter-devices-%E5%8D%A1%E4%BD%8F%E7%9A%84%E8%A8%8A%E8%99%9Fdevice-%E6%95%B8%E5%BE%9E-n-%E8%AE%8A-n-1-%E8%88%87-emulator-%E5%8D%8A%E6%B4%BB/</guid><description>&lt;p>&lt;code>flutter devices&lt;/code> 卡住時，最有用的訊號是「device 清單是否穩定」。這次的關鍵訊號是連續兩次掃描從 &lt;code>Found 4 connected devices&lt;/code> 變成 &lt;code>Found 3 connected devices&lt;/code>，再加上 &lt;code>Error -2 retrieving device properties for sdk gphone64 arm64&lt;/code>。這代表 ADB server 看得到某個 emulator entry，但對該 entry 的 property 查詢已經不穩定。&lt;/p>
&lt;p>這類狀態可以稱為 Android emulator 半活（zombie）：emulator host process 還在、ADB 清單仍殘留 device，但 emulator 內的 &lt;code>adbd&lt;/code> 或 Android system 已停止回應。Flutter 在掃描階段會對每個 Android device 查 properties，掃描到這個半活 device 就卡在 timeout。&lt;/p>
&lt;hr>
&lt;h2 id="事故場景">事故場景&lt;/h2>
&lt;p>事故場景的核心是「Flutter 指令看似卡住，其實卡在下游 device property 查詢」。連續跑 &lt;code>flutter devices&lt;/code> 時，輸出長這樣：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">$ flutter devices
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">Found 4 connected devices:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">Error -2 retrieving device properties for sdk gphone64 arm64:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">[卡住]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">$ flutter devices
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">Found 3 connected devices:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">[繼續卡]&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段輸出有兩個值得注意的點：&lt;/p>
&lt;ol>
&lt;li>&lt;code>Error -2 retrieving device properties for sdk gphone64 arm64:&lt;/code> 訊息出現後仍繼續等待，代表 Flutter 沒有在第一個 device 失敗時 fail-fast&lt;/li>
&lt;li>第一次 &lt;code>Found 4&lt;/code>、第二次 &lt;code>Found 3&lt;/code>，代表 device 數在兩次掃描之間自己少了 1&lt;/li>
&lt;/ol>
&lt;p>&lt;code>sdk gphone64 arm64&lt;/code> 是 Android Studio AVD 預設模板（Google Phone 64-bit ARM）建出來的 emulator 顯示名稱、macOS 上跑 Android system image 都會看到這個。&lt;/p>
&lt;h3 id="為什麼計數變化是關鍵徵兆">為什麼計數變化是關鍵徵兆&lt;/h3>
&lt;p>device 數從 4 變 3，代表 ADB 對某個 emulator 的狀態判斷在兩次查詢之間變了。ADB server 內部追蹤每個 device 的狀態（&lt;code>device&lt;/code> / &lt;code>offline&lt;/code> / &lt;code>unauthorized&lt;/code> / &lt;code>no permissions&lt;/code>）；半活 emulator 在第一次掃描時仍被列在 &lt;code>Found 4&lt;/code>，第二次掃描時可能已被標成 offline 或從候選清單移除，所以掉到 &lt;code>Found 3&lt;/code>。&lt;/p>
&lt;p>判讀訊號是「同一條 list 指令連跑兩次，device 數或 device 狀態自己變」。正常穩定狀態下，清單應該保持一致；清單漂移代表 ADB server 對某個 entry 的看法不穩定，下一步要先找出那個 entry，再決定是否重啟 ADB 或 emulator。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼-flutter-devices-會卡住">為什麼 flutter devices 會卡住&lt;/h2>
&lt;p>&lt;code>flutter devices&lt;/code> 的責任是把每個候選 device 補成 Flutter 可用的 target，而不只是印出 &lt;code>adb devices&lt;/code> 的結果。Flutter 對每個 ADB 看得到的 Android device 還要做幾件事：&lt;/p>
&lt;ol>
&lt;li>跑 &lt;code>adb shell getprop ro.product.cpu.abi&lt;/code> 拉 ABI&lt;/li>
&lt;li>跑 &lt;code>adb shell getprop ro.build.version.sdk&lt;/code> 拉 SDK level&lt;/li>
&lt;li>跑 &lt;code>adb shell getprop ro.product.model&lt;/code> 拉裝置型號&lt;/li>
&lt;li>視情況跑 &lt;code>adb shell&lt;/code> 其他指令確認 Flutter 支援度&lt;/li>
&lt;/ol>
&lt;p>這些是同步、序列化、有 timeout 的呼叫；timeout 通常設得相對寬鬆，讓慢一點的真機也能跑通。當其中一個 device 是 zombie 狀態：&lt;/p></description><content:encoded><![CDATA[<p><code>flutter devices</code> 卡住時，最有用的訊號是「device 清單是否穩定」。這次的關鍵訊號是連續兩次掃描從 <code>Found 4 connected devices</code> 變成 <code>Found 3 connected devices</code>，再加上 <code>Error -2 retrieving device properties for sdk gphone64 arm64</code>。這代表 ADB server 看得到某個 emulator entry，但對該 entry 的 property 查詢已經不穩定。</p>
<p>這類狀態可以稱為 Android emulator 半活（zombie）：emulator host process 還在、ADB 清單仍殘留 device，但 emulator 內的 <code>adbd</code> 或 Android system 已停止回應。Flutter 在掃描階段會對每個 Android device 查 properties，掃描到這個半活 device 就卡在 timeout。</p>
<hr>
<h2 id="事故場景">事故場景</h2>
<p>事故場景的核心是「Flutter 指令看似卡住，其實卡在下游 device property 查詢」。連續跑 <code>flutter devices</code> 時，輸出長這樣：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">$ flutter devices
</span></span><span class="line"><span class="ln">2</span><span class="cl">Found 4 connected devices:
</span></span><span class="line"><span class="ln">3</span><span class="cl">Error -2 retrieving device properties for sdk gphone64 arm64:
</span></span><span class="line"><span class="ln">4</span><span class="cl">[卡住]
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl">$ flutter devices
</span></span><span class="line"><span class="ln">7</span><span class="cl">Found 3 connected devices:
</span></span><span class="line"><span class="ln">8</span><span class="cl">[繼續卡]</span></span></code></pre></div><p>這段輸出有兩個值得注意的點：</p>
<ol>
<li><code>Error -2 retrieving device properties for sdk gphone64 arm64:</code> 訊息出現後仍繼續等待，代表 Flutter 沒有在第一個 device 失敗時 fail-fast</li>
<li>第一次 <code>Found 4</code>、第二次 <code>Found 3</code>，代表 device 數在兩次掃描之間自己少了 1</li>
</ol>
<p><code>sdk gphone64 arm64</code> 是 Android Studio AVD 預設模板（Google Phone 64-bit ARM）建出來的 emulator 顯示名稱、macOS 上跑 Android system image 都會看到這個。</p>
<h3 id="為什麼計數變化是關鍵徵兆">為什麼計數變化是關鍵徵兆</h3>
<p>device 數從 4 變 3，代表 ADB 對某個 emulator 的狀態判斷在兩次查詢之間變了。ADB server 內部追蹤每個 device 的狀態（<code>device</code> / <code>offline</code> / <code>unauthorized</code> / <code>no permissions</code>）；半活 emulator 在第一次掃描時仍被列在 <code>Found 4</code>，第二次掃描時可能已被標成 offline 或從候選清單移除，所以掉到 <code>Found 3</code>。</p>
<p>判讀訊號是「同一條 list 指令連跑兩次，device 數或 device 狀態自己變」。正常穩定狀態下，清單應該保持一致；清單漂移代表 ADB server 對某個 entry 的看法不穩定，下一步要先找出那個 entry，再決定是否重啟 ADB 或 emulator。</p>
<hr>
<h2 id="為什麼-flutter-devices-會卡住">為什麼 flutter devices 會卡住</h2>
<p><code>flutter devices</code> 的責任是把每個候選 device 補成 Flutter 可用的 target，而不只是印出 <code>adb devices</code> 的結果。Flutter 對每個 ADB 看得到的 Android device 還要做幾件事：</p>
<ol>
<li>跑 <code>adb shell getprop ro.product.cpu.abi</code> 拉 ABI</li>
<li>跑 <code>adb shell getprop ro.build.version.sdk</code> 拉 SDK level</li>
<li>跑 <code>adb shell getprop ro.product.model</code> 拉裝置型號</li>
<li>視情況跑 <code>adb shell</code> 其他指令確認 Flutter 支援度</li>
</ol>
<p>這些是同步、序列化、有 timeout 的呼叫；timeout 通常設得相對寬鬆，讓慢一點的真機也能跑通。當其中一個 device 是 zombie 狀態：</p>
<ul>
<li><code>adb shell getprop ...</code> 送出後，ADB 把指令轉發給 emulator 內的 <code>adbd</code></li>
<li><code>adbd</code> 收到了但 Android system 沒回應，或 emulator process 整個卡住沒在處理 ADB request</li>
<li>Flutter 端等 timeout、再 retry、再等更長 timeout，看起來就是「整個指令卡住」</li>
</ul>
<p><code>Error -2 retrieving device properties</code> 是其中一次嘗試 timeout 拿到的訊息（<code>-2</code> 是 Dart <code>ProcessException</code> 對應 <code>adb</code> exit code 的內部映射）。Flutter 仍會繼續掃描其他 device，所以使用者看到的是「印出錯誤訊息 + 繼續卡」。</p>
<hr>
<h2 id="為什麼是半活狀態">為什麼是半活狀態</h2>
<p>Android emulator 在 macOS 上的結構大致是：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">qemu-system-aarch64 (host process)
</span></span><span class="line"><span class="ln">2</span><span class="cl">  ├─ Android kernel
</span></span><span class="line"><span class="ln">3</span><span class="cl">  ├─ Android system services
</span></span><span class="line"><span class="ln">4</span><span class="cl">  └─ adbd (在 emulator 內部，跟 host ADB server 對接)</span></span></code></pre></div><p>半活狀態指的是「host process 還在，但 device 內部服務已無法完成 ADB request」。完全正常時 emulator 跑得動、ADB 也通；完全退出時 emulator process 已結束、ADB 清單看不到它。半活介於兩者之間：</p>
<ul>
<li>qemu host process 還在（活著）</li>
<li>emulator 內的某個環節卡住（Android system 沒在 schedule、或 adbd 卡在某個 mutex）</li>
<li>ADB server 還記得有這個 device，尚未穩定 evict</li>
<li>任何 <code>adb shell</code> 指令都打不通</li>
</ul>
<p>常見成因：</p>
<ul>
<li><strong>Quick Boot snapshot 還原失敗或部分還原</strong>——AVD 預設關機是 quick boot（存 snapshot），下次開機從 snapshot 還原；snapshot 跟當前 host kernel / hypervisor 狀態不相容時會半開機</li>
<li><strong>macOS 從 sleep 喚醒後 hypervisor framework 重置</strong>——emulator 是用 Hypervisor.framework，喚醒後虛擬 CPU 可能停在奇怪 state</li>
<li><strong>host 端記憶體壓力導致 emulator 被 swap 嚴重</strong>——表面看起來像卡，其實是在等 page fault</li>
</ul>
<p>這一層的操作目標是恢復工具鏈，而不是追到每個 emulator 內部 race condition。若症狀符合清單漂移與 property 查詢 timeout，先按恢復順序處理；只有反覆發生時，再追 AVD snapshot、system image 或 host 資源壓力。</p>
<hr>
<h2 id="恢復順序從輕到重">恢復順序（從輕到重）</h2>
<p>恢復順序的核心是先重置最小邊界，再逐層擴大。每一步都要重新跑一次 <code>flutter devices</code> 或 <code>adb devices</code>，確認是否已經恢復，避免直接砍掉 emulator 或清資料。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 1. 看 ADB 對每個 device 的狀態</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">adb devices
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># 看到 offline / no device / unauthorized 等異常狀態 → 先鎖定該 device</span></span></span></code></pre></div><p>如果有 device 顯示 <code>offline</code>，或正常列出但實際打不通，先重啟 ADB server：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 2. 重啟 ADB server（只重置 host 端 ADB session）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">adb kill-server <span class="o">&amp;&amp;</span> adb start-server
</span></span><span class="line"><span class="ln">3</span><span class="cl">adb devices
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 多數狀況下，ADB 重啟後對該 device 的查詢會 fail-fast，flutter devices 會恢復</span></span></span></code></pre></div><p>如果 ADB 重啟後仍打不通該 emulator，再處理 emulator process：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 3. 對特定 emulator 發 emu kill（讓它優雅關閉）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">adb -s emulator-5554 emu <span class="nb">kill</span>   <span class="c1"># 把 5554 換成實際 port</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 4. 還在的話，終止 qemu process</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">pkill -f qemu-system-aarch64</span></span></code></pre></div><p>長期修復路由是清掉不穩定的 snapshot。開 Android Studio → <strong>AVD Manager</strong> → 該 emulator 旁邊的小箭頭 → <strong>Cold Boot Now</strong>（避免 Quick Boot）。如果冷啟動後仍反覆壞，選 <strong>Wipe Data</strong> 把 snapshot 與 emulator 內資料整個清掉。</p>
<hr>
<h2 id="通用診斷思維">通用診斷思維</h2>
<p>工具鏈卡住的診斷核心是先區分「上游 CLI 壞掉」還是「下游 target 沒回應」。<code>flutter</code> / <code>adb</code> 指令卡住時，先用清單穩定性與 device 識別碼定位下游狀態，再決定重啟邊界。</p>
<ol>
<li><strong>觀察「同一指令連跑兩次結果是否一致」</strong>：不一致（device 數變、訊息變）等於某層狀態不穩定</li>
<li><strong>訊息裡有 device 識別碼就釘住它</strong>：<code>sdk gphone64 arm64</code>、<code>emulator-5554</code>、序號等都是 ADB 層的識別，可直接拿來 <code>adb -s &lt;id&gt; ...</code> 局部診斷</li>
<li><strong>從外往內排除</strong>：ADB server → 個別 device → emulator process → emulator 內 system，逐層重啟</li>
<li><strong>重啟邊界越大、副作用越大</strong>：<code>adb kill-server</code> 只影響 ADB session（其他 device 連線會斷一下），<code>pkill qemu</code> 直接砍 emulator，<code>Wipe Data</code> 連 emulator 內的資料都清。能用輕量手段解決就停在那層</li>
</ol>
<hr>
<h2 id="操作判準">操作判準</h2>
<ol>
<li><strong>「device 數兩次掃描之間自己變」是 zombie emulator 的關鍵徵兆</strong>：計數變化代表 ADB 內部狀態不穩定</li>
<li><strong><code>Error -2 retrieving device properties</code> 是 property 查詢失敗訊號</strong>：Flutter 仍可能繼續處理其他 device，結果是「印出錯誤訊息但繼續卡」</li>
<li><strong><code>adb kill-server &amp;&amp; adb start-server</code> 是輕量首選</strong>：它只重置 ADB session，不動 emulator 本身，多數狀況下可讓壞 device fail-fast</li>
<li><strong>半活狀態跟 application code 層級不同</strong>：先把工具鏈狀態釐清，再回到剛改的程式碼</li>
</ol>
<hr>
<h2 id="適用範圍">適用範圍</h2>
<p>這個診斷思維不限於 Android emulator：</p>
<ul>
<li>iOS Simulator 卡住時 <code>xcrun simctl list</code> 印不出來——同樣的「指令卡 + 訊息看似 fatal 但 process 仍存在」結構</li>
<li><code>flutter devices</code> 對任何 device（含 iOS、Web、desktop）的查詢都會走類似的「列出 → 逐個 query property」流程、任一層卡都會表現為類似症狀</li>
<li>廣義地說，任何「server 維護一份 client 清單 + 對每個 client 做同步呼叫」的架構（k8s <code>kubectl get pods</code> 對 zombie node、docker <code>docker ps</code> 對掛掉的 container runtime 等）都有同款 failure mode</li>
</ul>
<p>辨認規則一致：<strong>list 指令連跑兩次結果不一致 → 維護清單的 server 對某個 entry 的看法不穩定 → 找出那個 entry 局部處理</strong>。這條規則的邊界是：如果清單穩定但操作失敗，問題更可能在該 target 的權限、版本或 runtime 狀態，需要改走對應工具的細部診斷。</p>
]]></content:encoded></item><item><title>Dart test 的跨檔案 GetX 狀態污染：flaky 真因不是 fail 訊息上的那個 test</title><link>https://tarrragon.github.io/blog/work-log/dart-test-%E7%9A%84%E8%B7%A8%E6%AA%94%E6%A1%88-getx-%E7%8B%80%E6%85%8B%E6%B1%A1%E6%9F%93flaky-%E7%9C%9F%E5%9B%A0%E4%B8%8D%E6%98%AF-fail-%E8%A8%8A%E6%81%AF%E4%B8%8A%E7%9A%84%E9%82%A3%E5%80%8B-test/</link><pubDate>Thu, 07 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/dart-test-%E7%9A%84%E8%B7%A8%E6%AA%94%E6%A1%88-getx-%E7%8B%80%E6%85%8B%E6%B1%A1%E6%9F%93flaky-%E7%9C%9F%E5%9B%A0%E4%B8%8D%E6%98%AF-fail-%E8%A8%8A%E6%81%AF%E4%B8%8A%E7%9A%84%E9%82%A3%E5%80%8B-test/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>事故類型&lt;/strong>：cross-file 狀態污染、dart test runner 同 process 共用 GetX
&lt;strong>症狀&lt;/strong>：&lt;code>flutter test&lt;/code> 約 50% 機率隨機失敗、每次失敗的 test 不固定；單獨跑該 test file 100% 通過
&lt;strong>根因&lt;/strong>：dart test runner 在同 process 內跑多個 test file 共用 GetX 容器；前面 file 的 setUp 留下殘留（測試 mode 旗標、未 dispose 的 controller、stream subscription）污染後面 file 的測試環境&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="事故場景">事故場景&lt;/h2>
&lt;h3 id="表面症狀">表面症狀&lt;/h3>
&lt;p>跑 &lt;code>flutter test&lt;/code> 全 suite，Run 1 fail、Run 2 pass、Run 3 pass、Run 4 fail、Run 5 fail。看到的失敗訊息類似：&lt;/p>





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





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





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





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





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





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





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





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





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





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





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





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





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





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





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





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





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





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




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





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





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





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





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




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





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





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





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





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





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





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





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





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// 每次變化都觸發
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="kd">final</span> <span class="n">disposer</span> <span class="o">=</span> <span class="n">ever</span><span class="p">(</span><span class="n">counter</span><span class="p">,</span> <span class="p">(</span><span class="n">value</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">print</span><span class="p">(</span><span class="s1">&#39;changed to </span><span class="si">$</span><span class="n">value</span><span class="s1">&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1">// debounce — 連續變化只取最後一次
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="n">debounce</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="n">searchText</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="p">(</span><span class="n">value</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">searchAPI</span><span class="p">(</span><span class="n">value</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="nl">time:</span> <span class="n">Duration</span><span class="p">(</span><span class="nl">milliseconds:</span> <span class="m">500</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="p">);</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1">// throttle — 固定間隔最多觸發一次
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"></span><span class="n">interval</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="n">scrollPosition</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="p">(</span><span class="n">value</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">analytics</span><span class="p">(</span><span class="n">value</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">  <span class="nl">time:</span> <span class="n">Duration</span><span class="p">(</span><span class="nl">seconds:</span> <span class="m">1</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="p">);</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="c1">// 只觸發一次後自動移除
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="c1"></span><span class="n">once</span><span class="p">(</span><span class="n">loginState</span><span class="p">,</span> <span class="p">(</span><span class="n">value</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">navigateHome</span><span class="p">());</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="c1">// 監聽多個 Rx，任一變動就觸發
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="c1"></span><span class="n">everAll</span><span class="p">([</span><span class="n">a</span><span class="p">,</span> <span class="n">b</span><span class="p">,</span> <span class="n">c</span><span class="p">],</span> <span class="p">(</span><span class="n">_</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">recompute</span><span class="p">());</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">
</span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="c1">// 手動清理
</span></span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="c1"></span><span class="n">disposer</span><span class="p">.</span><span class="n">dispose</span><span class="p">();</span></span></span></code></pre></div><p>這些 worker 在 <code>GetxController.onInit</code> 裡註冊時會被綁定到 controller 生命週期，controller dispose 時自動清；在 controller 外註冊就要自己 <code>.dispose()</code>。</p>
<h3 id="何時選哪個">何時選哪個</h3>
<h4 id="選-raw-streamcontroller">選 raw <code>StreamController</code></h4>
<ul>
<li>寫<strong>底層 service</strong>（藍牙、socket、sensor、background isolate 通訊）</li>
<li>需要<strong>豐富的 stream operators 鏈</strong>（<code>map</code>/<code>where</code>/<code>buffer</code>/<code>distinct</code>/<code>merge</code>/<code>combineLatest</code>&hellip;）</li>
<li>對外提供的 API 不想綁特定狀態管理框架，要保持框架中立</li>
<li>需要 backpressure / pause-resume 等進階流量控制</li>
<li>跨 isolate 資料傳遞</li>
</ul>
<h4 id="選-rx--obs">選 <code>Rx</code> / <code>.obs</code></h4>
<ul>
<li>寫 <strong>UI state</strong> 或 <strong>application state</strong></li>
<li>已在用 GetX，沿用一致</li>
<li>需要「保留當前值 + 多訂閱者」這個常見組合</li>
<li>想要 widget 自動追蹤，不想手動寫 listen/cancel</li>
<li>service 內部 latest value 與通知的樣板太多次，懶得繼續寫</li>
</ul>
<h3 id="把事故場景改寫成-rx-看看">把事故場景改寫成 Rx 看看</h3>
<p>回到事故場景。如果 service 從一開始就用 reactive value container（如 Rx）來表達它的對外契約，整個問題會以另一種方式消失。</p>
<p><strong>對外契約的轉變</strong>：service 不再「對外發送事件」，而是「對外暴露兩個可被觀察的狀態屬性」——當前完整的商品列表、最後一次變動的品項。訂閱方不需要 <code>listen()</code> 一條 stream，而是直接讀取屬性的當前值，並且系統保證屬性變化時觀察者會被通知。</p>
<p><strong>在這個契約下回頭看每個訂閱方的需求</strong>：</p>
<ul>
<li><strong>副螢幕（鏡像當前商品列表）</strong>：只關心「列表屬性」變動，不在乎是哪一筆變動。它建立一個對列表屬性的觀察，每次變動就重畫</li>
<li><strong>收銀主畫面（最後變更項標記）</strong>：只關心「最後變動屬性」，每次變動就更新高亮哪一行</li>
<li><strong>未來的訂閱方</strong>（KDS、列印、雲端、analytics）：各自選關心的屬性建立觀察</li>
</ul>
<p>兩個訂閱者觀察的是<strong>不同屬性</strong>，互不干擾；同一個屬性也允許多個觀察者（reactive value 天生是廣播語義）。</p>
<p><strong>事故的兩個技術問題在這個契約下自動消失</strong>：</p>
<ol>
<li><strong>single vs broadcast 的選擇問題不存在</strong>——reactive value 沒有「單訂閱版本」，每個觀察者天生並存</li>
<li><strong>進場拿不到歷史事件的問題不存在</strong>——觀察者進場時可以直接讀屬性的「當前值」，不必等下一次變動</li>
</ol>
<p>更深一層的觀察：raw stream 是「以時間軸上的事件為一等公民」的工具，適合「事件本身就是有意義的（log、命令、訊息）」場景；reactive value 是「以狀態為一等公民」的工具，適合「下游關心的是當前是什麼，不是過去發生了什麼」場景。<strong>POS 多視角同步的本質是後者</strong>——副螢幕關心的是「現在購物車裡有什麼」，不是「過去 5 分鐘掃進了哪些商品的時序」。</p>
<p>把這個認知一般化：當業務語義是「多個視角共享當前狀態」時，工具應該是 reactive value（Rx / ValueNotifier / BehaviorSubject）；當業務語義是「事件流的時序」時，工具才是 stream。本案的根因是「業務語義（共享狀態）」跟「工具語義（事件流）」錯配；single-subscription 是錯配關係下第一個被觸發的契約限制、但即使換成 broadcast、仍會在「進場拿不到歷史事件」這個層次暴露語義錯配。</p>
<h3 id="是否該全面改寫成-rx">是否該全面改寫成 Rx</h3>
<p>事故當下不該。理由：</p>
<ol>
<li><strong>scope 控制</strong>：事故修復原則是 minimal change，<code>StreamController()</code> → <code>.broadcast()</code> 一字之差就解決</li>
<li><strong>回歸風險</strong>：把 service 介面從 <code>Stream&lt;T&gt;</code> 改成 <code>Rx&lt;T&gt;</code>，所有訂閱方（副螢幕、UI、未來的 KDS / 雲端同步）都要改 listen 方式</li>
<li><strong>耦合代價</strong>：如果 service 介面原本是 framework-neutral 的（純 dart:async），改 Rx 等於把 GetX 綁進公開 API，未來要換框架成本變高</li>
<li><strong>測試成本</strong>：改 Rx 之後，所有針對該 service 的測試都要改 mock 方式</li>
</ol>
<p>該重構的時機：</p>
<ul>
<li>整個系統已經 implicit 綁 GetX，介面 framework-neutral 的成本沒實質效益</li>
<li>新增 service 時直接用 Rx，舊的 stream-based service 等下次大改一起換</li>
<li>發現自己重複寫「<code>_latest</code> + <code>StreamController.broadcast</code> + getter + emit + close」的樣板太多次，Rx 是現成解</li>
<li>整理技術債的專屬 sprint，可以系統性換掉</li>
</ul>
<p>事故修復應該專注 minimal fix；架構改造是另一張單。</p>
<hr>
<h2 id="除錯思維">除錯思維</h2>
<p><code>Bad state: Stream has already been listened to.</code> 的根因落在 stream 定義端的型別契約、不在訂閱端。檢查順序：</p>
<ol>
<li><strong>這條 stream 是 single-subscription 還是 broadcast？</strong>
<ul>
<li>從定義端確認（<code>StreamController()</code> vs <code>StreamController.broadcast()</code>）、訂閱端只承載限制、看不出契約類型</li>
</ul>
</li>
<li><strong>若是 single、選 single 的理由有書面記錄嗎？</strong>
<ul>
<li>介面註解 / 設計文件有記錄 → 看理由是否仍成立</li>
<li>沒有記錄 → 屬於「用了預設建構子、沒做選擇」、回到當下三軸判斷</li>
</ul>
</li>
<li><strong>多訂閱在語義上合理嗎？</strong>
<ul>
<li>合理 → 改 broadcast、屬於修正型別契約跟業務語義對齊</li>
<li>不合理 → 第二個訂閱者的需求要重新設計（透過第一個 listener 轉送、或拉新 stream）</li>
</ul>
</li>
</ol>
<p>把「這條 stream 該不該支援多訂閱」做為設計階段的明確決策、判斷成本（跑三軸）落在當下、且不依賴未來需求是否實際出現。</p>
<hr>
<h2 id="延伸pos-場景的多訂閱模式">延伸：POS 場景的多訂閱模式</h2>
<p>POS 系統本質上就是「中央交易狀態 + 多視圖/多裝置鏡像」，是 broadcast stream 最自然的應用領域。常見訂閱者：</p>
<table>
  <thead>
      <tr>
          <th>訂閱方</th>
          <th>關心什麼</th>
          <th>訂閱生命週期</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>收銀員主螢幕</td>
          <td>完整購物車、UI 高亮、結帳金額</td>
          <td>收銀頁面開啟期間</td>
      </tr>
      <tr>
          <td>副螢幕（顧客面）</td>
          <td>商品名、單價、總價、找零</td>
          <td>App lifetime</td>
      </tr>
      <tr>
          <td>廚房顯示（KDS）</td>
          <td>已下單品項、出餐順序</td>
          <td>App lifetime</td>
      </tr>
      <tr>
          <td>列印服務</td>
          <td>結帳明細、會員資訊</td>
          <td>觸發式（結帳當下）</td>
      </tr>
      <tr>
          <td>雲端同步</td>
          <td>所有交易事件</td>
          <td>App lifetime</td>
      </tr>
      <tr>
          <td>Analytics</td>
          <td>使用者行為、轉換率</td>
          <td>App lifetime</td>
      </tr>
  </tbody>
</table>
<p>設計階段先假設「會有多個訂閱者」、「未來訂閱者數量會增加」、「每個訂閱者只關心事件的一部分屬性」——這正是 broadcast 的典型語義；之後新功能要訂閱、設計上會自然容納。</p>
<p>對應的設計建議：</p>
<ol>
<li><strong>Service 對外的事件 stream 預設 broadcast</strong>——single-subscription 視為例外、要在介面註解書面說明</li>
<li><strong>事件 payload 設計成 record 或 sealed class</strong>——包含「是什麼變動 + 變動的詳細資料」、讓不同訂閱者各取所需</li>
<li><strong>不要假設訂閱者之間的觸發順序</strong>——broadcast 的 listener 之間沒有保證順序、訂閱者要假設彼此獨立</li>
<li><strong>進場時若需要初始狀態、提供 <code>currentValue</code> getter</strong>——broadcast 不保留歷史、用 explicit getter 補這個缺口</li>
</ol>
<hr>
<h2 id="參考資料">參考資料</h2>
<ul>
<li><a href="https://api.dart.dev/stable/dart-async/StreamController-class.html">Dart <code>StreamController</code> API doc</a></li>
<li><a href="https://api.dart.dev/stable/dart-async/StreamController/StreamController.broadcast.html">Dart <code>StreamController.broadcast</code> constructor</a></li>
<li><a href="https://api.dart.dev/stable/dart-async/Stream/asBroadcastStream.html">Dart <code>Stream.asBroadcastStream</code> method</a></li>
<li><a href="https://dart.dev/tutorials/language/streams">Dart language tour - Asynchronous programming: streams</a></li>
<li><a href="https://pub.dev/documentation/rxdart/latest/rx/BehaviorSubject-class.html">RxDart <code>BehaviorSubject</code> doc</a></li>
</ul>
]]></content:encoded></item><item><title>Failure Pivot Protocol — 失敗 2 次的轉折協議</title><link>https://tarrragon.github.io/blog/skills/requirement-protocol/failure-pivot-protocol/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/skills/requirement-protocol/failure-pivot-protocol/</guid><description>&lt;p>同方向失敗 ≥ 2 次時的轉折協議 — 停下來驗證底層假設、不沿同方向加碼到第 3 次。&lt;/p>
&lt;p>適用：debug 反覆失敗、CSS 規則不生效、JS 改完元素還原、layout 怎麼調都不對。
不適用：第 1 次失敗（修細節即可）；不同方向各自失敗 1 次（不算同方向累積）。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>自包含聲明&lt;/strong>：閱讀本文件不需要先讀其他 reference。本文件涵蓋失敗計數、假設驗證、換方向決策、對外回報模板。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="何時參閱本文件">何時參閱本文件&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>訊號&lt;/th>
 &lt;th>該做的第一件事&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>同方向第 2 次失敗&lt;/td>
 &lt;td>停 — 用工具驗證底層假設&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>內心 OS：「再試一次更小心應該就過」&lt;/td>
 &lt;td>停 — 這是沉沒成本綁住的訊號&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>即將加 &lt;code>!important&lt;/code> 解 specificity&lt;/td>
 &lt;td>停 — 切到 CSS layers 思路&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>即將加第 2 條 polyfill 補跨瀏覽器&lt;/td>
 &lt;td>停 — 先回報成本、問使用者意願&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>即將用 imperative JS 補宣告式 layout&lt;/td>
 &lt;td>停 — 切到 CSS-first 思路&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="為什麼第-2-次是轉折點">為什麼第 2 次是轉折點&lt;/h2>
&lt;p>第 1 次失敗常是執行細節（typo、cache、syntax）— 修了再試通常會過。&lt;/p>
&lt;p>第 2 次失敗、用同樣的方法但更小心、還是失敗 — 訊號的重量遠大於兩次相加。它說的是：&lt;strong>「我以為的問題不在這層、根本問題在別處」&lt;/strong>。&lt;/p>
&lt;p>第 3 次以上、沉沒成本綁住、加碼產生的副作用會超過解決的問題：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>嘗試次數&lt;/th>
 &lt;th>心理狀態&lt;/th>
 &lt;th>行動模式&lt;/th>
 &lt;th>副作用&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>1&lt;/td>
 &lt;td>信心足&lt;/td>
 &lt;td>直接做&lt;/td>
 &lt;td>無&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>2&lt;/td>
 &lt;td>信心動搖&lt;/td>
 &lt;td>加碼（更複雜的 selector / important）&lt;/td>
 &lt;td>可控&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>3&lt;/td>
 &lt;td>焦慮&lt;/td>
 &lt;td>全面反擊（layers + important + polyfill）&lt;/td>
 &lt;td>大 — 改動範圍擴張&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>4+&lt;/td>
 &lt;td>沉沒成本綁住&lt;/td>
 &lt;td>不肯放棄已寫的&lt;/td>
 &lt;td>嚴重 — 為前面的錯買單&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>第 2 次是還能優雅切換方向的最後機會。&lt;/p>
&lt;hr>
&lt;h2 id="失敗計數的協議">失敗計數的協議&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>失敗次數&lt;/th>
 &lt;th>行動&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>第 1 次&lt;/td>
 &lt;td>修細節（typo、cache、syntax）再試&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>第 2 次&lt;/td>
 &lt;td>&lt;strong>停下來&lt;/strong> — 用工具驗證底層假設（DOM tree、computed style、framework 行為）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>第 2 次驗證後&lt;/td>
 &lt;td>假設對 → 繼續修；假設錯 → 換方向、不為前面買單&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>關鍵動作是第 2 次的「停」 — 把行動從「執行更努力」切換到「驗證假設」。&lt;/p>
&lt;hr>
&lt;h2 id="假設驗證的具體方法">假設驗證的具體方法&lt;/h2>
&lt;h3 id="方法-1用工具讀真實狀態">方法 1：用工具讀真實狀態&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>假設類型&lt;/th>
 &lt;th>驗證工具&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>DOM 結構&lt;/td>
 &lt;td>playwright &lt;code>browser_evaluate&lt;/code> 讀 ancestor chain&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Computed style&lt;/td>
 &lt;td>playwright + &lt;code>getComputedStyle()&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>元素位置&lt;/td>
 &lt;td>playwright + &lt;code>getBoundingClientRect()&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Framework 行為&lt;/td>
 &lt;td>讀框架 source、看 reconciliation 條件&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Event 觸發&lt;/td>
 &lt;td>DevTools Event Listeners panel + &lt;code>console.count()&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="方法-2反問如果假設錯了會怎樣">方法 2：反問「如果假設錯了會怎樣」&lt;/h3>
&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>Drawer 是 form 的 sibling&lt;/td>
 &lt;td>那 grid-row 完全無效（drawer 跟 form 共用 grid cell）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Specificity 30 是上限&lt;/td>
 &lt;td>那 layers 才是解、不是雙寫 selector&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>元素永遠存在於 DOM&lt;/td>
 &lt;td>那 framework 重渲染後 querySelector 會回 null&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>「如果錯了會發生什麼」的答案 = 你正在看的失敗現象 → 假設可能錯。&lt;/p></description><content:encoded><![CDATA[<p>同方向失敗 ≥ 2 次時的轉折協議 — 停下來驗證底層假設、不沿同方向加碼到第 3 次。</p>
<p>適用：debug 反覆失敗、CSS 規則不生效、JS 改完元素還原、layout 怎麼調都不對。
不適用：第 1 次失敗（修細節即可）；不同方向各自失敗 1 次（不算同方向累積）。</p>
<blockquote>
<p><strong>自包含聲明</strong>：閱讀本文件不需要先讀其他 reference。本文件涵蓋失敗計數、假設驗證、換方向決策、對外回報模板。</p></blockquote>
<hr>
<h2 id="何時參閱本文件">何時參閱本文件</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的第一件事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>同方向第 2 次失敗</td>
          <td>停 — 用工具驗證底層假設</td>
      </tr>
      <tr>
          <td>內心 OS：「再試一次更小心應該就過」</td>
          <td>停 — 這是沉沒成本綁住的訊號</td>
      </tr>
      <tr>
          <td>即將加 <code>!important</code> 解 specificity</td>
          <td>停 — 切到 CSS layers 思路</td>
      </tr>
      <tr>
          <td>即將加第 2 條 polyfill 補跨瀏覽器</td>
          <td>停 — 先回報成本、問使用者意願</td>
      </tr>
      <tr>
          <td>即將用 imperative JS 補宣告式 layout</td>
          <td>停 — 切到 CSS-first 思路</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="為什麼第-2-次是轉折點">為什麼第 2 次是轉折點</h2>
<p>第 1 次失敗常是執行細節（typo、cache、syntax）— 修了再試通常會過。</p>
<p>第 2 次失敗、用同樣的方法但更小心、還是失敗 — 訊號的重量遠大於兩次相加。它說的是：<strong>「我以為的問題不在這層、根本問題在別處」</strong>。</p>
<p>第 3 次以上、沉沒成本綁住、加碼產生的副作用會超過解決的問題：</p>
<table>
  <thead>
      <tr>
          <th>嘗試次數</th>
          <th>心理狀態</th>
          <th>行動模式</th>
          <th>副作用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td>信心足</td>
          <td>直接做</td>
          <td>無</td>
      </tr>
      <tr>
          <td>2</td>
          <td>信心動搖</td>
          <td>加碼（更複雜的 selector / important）</td>
          <td>可控</td>
      </tr>
      <tr>
          <td>3</td>
          <td>焦慮</td>
          <td>全面反擊（layers + important + polyfill）</td>
          <td>大 — 改動範圍擴張</td>
      </tr>
      <tr>
          <td>4+</td>
          <td>沉沒成本綁住</td>
          <td>不肯放棄已寫的</td>
          <td>嚴重 — 為前面的錯買單</td>
      </tr>
  </tbody>
</table>
<p>第 2 次是還能優雅切換方向的最後機會。</p>
<hr>
<h2 id="失敗計數的協議">失敗計數的協議</h2>
<table>
  <thead>
      <tr>
          <th>失敗次數</th>
          <th>行動</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>第 1 次</td>
          <td>修細節（typo、cache、syntax）再試</td>
      </tr>
      <tr>
          <td>第 2 次</td>
          <td><strong>停下來</strong> — 用工具驗證底層假設（DOM tree、computed style、framework 行為）</td>
      </tr>
      <tr>
          <td>第 2 次驗證後</td>
          <td>假設對 → 繼續修；假設錯 → 換方向、不為前面買單</td>
      </tr>
  </tbody>
</table>
<p>關鍵動作是第 2 次的「停」 — 把行動從「執行更努力」切換到「驗證假設」。</p>
<hr>
<h2 id="假設驗證的具體方法">假設驗證的具體方法</h2>
<h3 id="方法-1用工具讀真實狀態">方法 1：用工具讀真實狀態</h3>
<table>
  <thead>
      <tr>
          <th>假設類型</th>
          <th>驗證工具</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>DOM 結構</td>
          <td>playwright <code>browser_evaluate</code> 讀 ancestor chain</td>
      </tr>
      <tr>
          <td>Computed style</td>
          <td>playwright + <code>getComputedStyle()</code></td>
      </tr>
      <tr>
          <td>元素位置</td>
          <td>playwright + <code>getBoundingClientRect()</code></td>
      </tr>
      <tr>
          <td>Framework 行為</td>
          <td>讀框架 source、看 reconciliation 條件</td>
      </tr>
      <tr>
          <td>Event 觸發</td>
          <td>DevTools Event Listeners panel + <code>console.count()</code></td>
      </tr>
  </tbody>
</table>
<h3 id="方法-2反問如果假設錯了會怎樣">方法 2：反問「如果假設錯了會怎樣」</h3>
<p>這個反思能在沒有工具的情況下測試假設。</p>
<table>
  <thead>
      <tr>
          <th>假設</th>
          <th>如果錯了會發生什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Drawer 是 form 的 sibling</td>
          <td>那 grid-row 完全無效（drawer 跟 form 共用 grid cell）</td>
      </tr>
      <tr>
          <td>Specificity 30 是上限</td>
          <td>那 layers 才是解、不是雙寫 selector</td>
      </tr>
      <tr>
          <td>元素永遠存在於 DOM</td>
          <td>那 framework 重渲染後 querySelector 會回 null</td>
      </tr>
  </tbody>
</table>
<p>「如果錯了會發生什麼」的答案 = 你正在看的失敗現象 → 假設可能錯。</p>
<h3 id="方法-3對外回報模板">方法 3：對外回報模板</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">我嘗試了 [方向 X]：
</span></span><span class="line"><span class="ln">2</span><span class="cl">- 第 1 次：[做法 A] → [現象]
</span></span><span class="line"><span class="ln">3</span><span class="cl">- 第 2 次：[做法 B] → [一樣的現象]
</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">我的底層假設是「[假設 Z]」、但 [方法 1 / 方法 2 的驗證] 顯示 Z 似乎不成立。
</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">要不要換 [方向 W]、或您看到我沒看到的訊息嗎？</span></span></code></pre></div><p>對外回報 = 把卡關放到使用者視野、避免繼續單方面加碼。</p>
<hr>
<h2 id="假設錯了之後換方向--全部重寫">假設錯了之後：換方向 ≠ 全部重寫</h2>
<p>換方向不是「之前的全部丟掉」、是「對抗錯假設的部分丟掉、其他保留」。</p>
<p><strong>範例</strong>：search scope UI 放在「form 與 results 之間」。</p>
<ul>
<li>嘗試 1-4：基於假設「drawer 是 form 的 sibling」、用 grid + display:contents + grid-row 排序 → 全失敗</li>
<li>第 5 次（用 playwright 驗證）：drawer 是 form 的 child、跟 form 共用 grid cell</li>
<li>換方向：不用 grid-row 控制位置（被假設綁住的部分）、改用 absolute + drawer margin-top（不被假設綁住）→ 一次成功</li>
</ul>
<p>換方向後保留：CSS variable 命名、scope 命名、HTML 結構。丟掉：grid-row 規則。<strong>只丟跟錯假設綁定的代碼、不丟所有東西</strong>。</p>
<hr>
<h2 id="wrong-vs-right-對照">Wrong vs Right 對照</h2>
<h3 id="範例-1specificity-戰">範例 1：specificity 戰</h3>
<p><strong>錯</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="c">/* 第 1 次：規則沒生效 */</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">.</span><span class="nc">target</span> <span class="p">{</span> <span class="k">color</span><span class="p">:</span> <span class="kc">red</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c">/* 第 2 次：加 specificity */</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">.</span><span class="nc">parent</span> <span class="p">.</span><span class="nc">target</span> <span class="p">{</span> <span class="k">color</span><span class="p">:</span> <span class="kc">red</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c">/* 第 3 次：再加 */</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">.</span><span class="nc">parent</span> <span class="p">.</span><span class="nc">container</span> <span class="p">.</span><span class="nc">target</span> <span class="p">{</span> <span class="k">color</span><span class="p">:</span> <span class="kc">red</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c">/* 第 4 次：放大絕招 */</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">.</span><span class="nc">parent</span> <span class="p">.</span><span class="nc">container</span> <span class="p">.</span><span class="nc">target</span> <span class="p">{</span> <span class="k">color</span><span class="p">:</span> <span class="kc">red</span> <span class="cp">!important</span><span class="p">;</span> <span class="p">}</span></span></span></code></pre></div><p>四次同方向加碼、根本問題（vendor CSS 用了更高 specificity 或更晚 cascade）沒解。</p>
<p><strong>對</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c">/* 第 1 次：規則沒生效 */</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="p">.</span><span class="nc">target</span> <span class="p">{</span> <span class="k">color</span><span class="p">:</span> <span class="kc">red</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c">/* 第 2 次失敗 → 停下來驗證假設 */</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c">/* DevTools Computed → 看到 vendor 的 .pagefind .target { color: blue } 贏了 */</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c">/* 假設「我的規則該贏」錯 → 換方向：CSS layers */</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="p">@</span><span class="k">layer</span> <span class="nt">vendor</span> <span class="p">{</span> <span class="c">/* @import vendor css here */</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c">/* 我的規則 unlayered → 自動贏所有 layered 規則 */</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">.</span><span class="nc">target</span> <span class="p">{</span> <span class="k">color</span><span class="p">:</span> <span class="kc">red</span><span class="p">;</span> <span class="p">}</span></span></span></code></pre></div><h3 id="範例-2js-改完元素被還原">範例 2：JS 改完元素被還原</h3>
<p><strong>錯</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 第 1 次：改完被還原
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="nx">el</span><span class="p">.</span><span class="nx">textContent</span> <span class="o">=</span> <span class="s1">&#39;custom&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">// 第 2 次：加保護
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="nx">setTimeout</span><span class="p">(()</span> <span class="p">=&gt;</span> <span class="p">{</span> <span class="nx">el</span><span class="p">.</span><span class="nx">textContent</span> <span class="o">=</span> <span class="s1">&#39;custom&#39;</span><span class="p">;</span> <span class="p">},</span> <span class="mi">100</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">// 第 3 次：再加
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="nx">setInterval</span><span class="p">(()</span> <span class="p">=&gt;</span> <span class="p">{</span> <span class="nx">el</span><span class="p">.</span><span class="nx">textContent</span> <span class="o">=</span> <span class="s1">&#39;custom&#39;</span><span class="p">;</span> <span class="p">},</span> <span class="mi">50</span><span class="p">);</span>  <span class="c1">// CPU 100%
</span></span></span></code></pre></div><p><strong>對</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// 第 1 次：改完被還原
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="nx">el</span><span class="p">.</span><span class="nx">textContent</span> <span class="o">=</span> <span class="s1">&#39;custom&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1">// 第 2 次失敗 → 停、驗證假設
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1">// playwright: 看到 framework 每次 state change 重渲染整個子樹
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1">// 假設「我的修改會 stick」錯 → 換方向：把客製 UI 放到 framework 邊界外
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="kr">const</span> <span class="nx">customEl</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">createElement</span><span class="p">(</span><span class="s1">&#39;div&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="nx">customEl</span><span class="p">.</span><span class="nx">textContent</span> <span class="o">=</span> <span class="s1">&#39;custom&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="nx">container</span><span class="p">.</span><span class="nx">appendChild</span><span class="p">(</span><span class="nx">customEl</span><span class="p">);</span>  <span class="c1">// 不在 framework 子樹內、不會被 reconcile
</span></span></span></code></pre></div><hr>
<h2 id="自檢清單dogfooding">自檢清單（dogfooding）</h2>
<p>第 2 次失敗時、用這份清單檢查：</p>
<ul>
<li><input disabled="" type="checkbox"> 我有沒有列出「底層假設是什麼」？</li>
<li><input disabled="" type="checkbox"> 我有沒有用工具或反問驗證假設？</li>
<li><input disabled="" type="checkbox"> 如果假設錯了、有沒有列出替代方向？</li>
<li><input disabled="" type="checkbox"> 對外回報訊息有沒有寫「驗證 X、似乎不成立、要不要換 W」這種句式？</li>
<li><input disabled="" type="checkbox"> 我有沒有避免「再試一次更小心」這種同方向加碼的衝動？</li>
</ul>
<p>任一項打勾失敗 → 停下來補上、再決定下一步。</p>
<hr>
<h2 id="延伸閱讀">延伸閱讀</h2>
<p>對應的事後檢討（在 <code>content/report/</code>）：</p>
<ul>
<li><a href="/blog/report/failure-direction-pivot-point/" data-link-title="同方向反覆失敗的轉折點" data-link-desc="第 2 次同方向失敗就停下來回報「假設可能錯了、要不要換思路」、不要等第 4 次失敗才被使用者打斷。本文展開失敗計數與方向切換的判斷。">failure-direction-pivot-point</a> — 同方向反覆失敗的轉折點</li>
<li><a href="/blog/report/two-occurrence-threshold/" data-link-title="2 次門檻：第一次是運氣、第二次是訊號" data-link-desc="同一個問題出現第 2 次時、就該停下來把處理層級升一階 — 從推理升到量測、從手動驗證升到自動化、從同方向嘗試升到換思路。第 1 次失敗的資訊不足、第 2 次提供「重複出現」的證據、值得付出升級成本。本文是 #11 / #15 / #20 / #23 四篇實作的共同抽象。">two-occurrence-threshold</a> — 2 次門檻的抽象原則（跨工具 / 測試 / 思路 / 溝通四面向）</li>
<li><a href="/blog/report/verification-method-timing/" data-link-title="驗證方法的選擇時機" data-link-desc="靜態 CSS 推理 ≥ 2 次失敗就主動提『啟個 server、用 playwright 看 live DOM 比較快』、不要繼續猜。本文展開驗證工具的引入時機。">verification-method-timing</a> — 驗證方法的選擇時機</li>
</ul>
<hr>
<p><strong>Last Updated</strong>: 2026-04-26
<strong>Version</strong>: 0.1.0</p>
]]></content:encoded></item><item><title>Progressive Verification — 漸進驗證與最小必要範圍</title><link>https://tarrragon.github.io/blog/skills/requirement-protocol/progressive-verification/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/skills/requirement-protocol/progressive-verification/</guid><description>&lt;p>從最小可驗證單位起步、加變數一次只加一個、範圍從窄到寬擴張。&lt;/p>
&lt;p>適用：UI layout debug、對齊問題、selector / MutationObserver root / JS 操作邊界的設計。
不適用：純內部演算法（沒有視覺、沒有範圍選擇）。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>自包含聲明&lt;/strong>：閱讀本文件不需要先讀其他 reference。本文件涵蓋 placeholder 漸進、measurement 完整性、最小必要範圍三個共生原則。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;blockquote>
&lt;p>&lt;strong>Test-First 補充&lt;/strong>：當「漸進」的方式是「寫測試固化」時、必須走 RED → GREEN 兩個訊號才算驗證 — 詳見 &lt;a href="https://tarrragon.github.io/blog/report/test-first-red-before-green/" data-link-title="Test-First：先看到 RED 才相信 GREEN" data-link-desc="一個只看過 GREEN 的測試是「未驗證的訊號」、不是「會抓回歸的測試」。必須先在「該失敗的版本」上看到 RED、再在「該通過的版本」上看到 GREEN — 兩次跑都對、才能相信測試真的 catch 到該 catch 的東西。跳過 RED 等於把驗收標準降到「跑得通」、漏掉「測試自己有沒有 bug」這層。">#69 Test-First：先看到 RED 才相信 GREEN&lt;/a>。沒看過 RED 的測試 = 未驗證的訊號、不能信任。&lt;/p>&lt;/blockquote>
&lt;h2 id="何時參閱本文件">何時參閱本文件&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>訊號&lt;/th>
 &lt;th>該做的第一件事&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>開始 UI layout debug、不知道從哪一步起&lt;/td>
 &lt;td>從色塊 placeholder 起步&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>對齊規則寫了結果歪掉、不知道哪裡錯&lt;/td>
 &lt;td>列方程組、確認每個變數有來源&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>設計 selector / observer / JS 操作的範圍&lt;/td>
 &lt;td>從最小起、有證據再擴張&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>想用 &lt;code>document.querySelectorAll('*')&lt;/code> 或 &lt;code>subtree: true&lt;/code>&lt;/td>
 &lt;td>停 — 範圍可能過寬、補上限制條件&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Layout debug 一次改了 5 個變數、改完不知道哪個生效&lt;/td>
 &lt;td>退回去、一次只動一個&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="為什麼這三個原則合併在一份-reference">為什麼這三個原則合併在一份 reference&lt;/h2>
&lt;p>三個原則服務同一個讀者群體（&lt;strong>正在開始一個新工作、還沒卡關&lt;/strong>）、回答同一類問題（&lt;strong>該從多大的範圍 / 多少變數起步&lt;/strong>）。&lt;/p>
&lt;ul>
&lt;li>Placeholder 漸進 = 視覺面的「一次一個變數」&lt;/li>
&lt;li>Measurement 完整性 = 對齊問題的「方程組必須完整」&lt;/li>
&lt;li>Minimum scope = JS / CSS 範圍的「窄起來再放寬」&lt;/li>
&lt;/ul>
&lt;p>共同精神：&lt;strong>先窄後寬、有證據再擴張&lt;/strong>。「先寬後縮」的問題是分不出哪個寬度是刻意的；「先窄後寬」每次擴張都有原因可追。&lt;/p>
&lt;hr>
&lt;h2 id="原則-1placeholder-漸進除錯">原則 1：Placeholder 漸進除錯&lt;/h2>
&lt;p>UI debug 從色塊起步、加東西一次加一個。&lt;/p>
&lt;h3 id="起步純色塊">起步：純色塊&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-html" data-lang="html">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">div&lt;/span> &lt;span class="na">style&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;width: 200px; height: 100px; background: red; border: 2px solid black;&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&amp;lt;/&lt;/span>&lt;span class="nt">div&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>沒文字、沒樣式、沒互動。&lt;strong>唯一目的&lt;/strong>：確認位置、尺寸、grid / flex / absolute 的定位邏輯對。&lt;/p>
&lt;h3 id="階段順序">階段順序&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>階段&lt;/th>
 &lt;th>加入&lt;/th>
 &lt;th>驗證&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>1&lt;/td>
 &lt;td>純色塊（固定尺寸 + 顯眼邊框）&lt;/td>
 &lt;td>位置、grid cell、stacking 對&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>2&lt;/td>
 &lt;td>占位文字（單行、無樣式）&lt;/td>
 &lt;td>文字基線對、line-height 沒影響&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>3&lt;/td>
 &lt;td>真實內容（多行、含長字串）&lt;/td>
 &lt;td>換行、溢出、文字裁切對&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>4&lt;/td>
 &lt;td>視覺樣式（color、font、padding）&lt;/td>
 &lt;td>視覺層次對&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>5&lt;/td>
 &lt;td>互動行為（hover、click、focus）&lt;/td>
 &lt;td>互動狀態對、focus 不跑掉&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>每階段只引入一個變數、發現問題能立刻定位。&lt;strong>跳階段&lt;/strong> = 失敗時不知道是哪個變數錯。&lt;/p>
&lt;h3 id="典型反例">典型反例&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-html" data-lang="html">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c">&amp;lt;!-- 第 1 步直接寫真實內容 + 完整樣式 --&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">div&lt;/span> &lt;span class="na">class&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;card&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&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">&amp;lt;&lt;/span>&lt;span class="nt">h3&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>Search results&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">h3&lt;/span>&lt;span class="p">&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="p">&amp;lt;&lt;/span>&lt;span class="nt">p&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>Showing {{count}} matches for &amp;#34;{{query}}&amp;#34;&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">p&lt;/span>&lt;span class="p">&amp;gt;&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">&amp;lt;&lt;/span>&lt;span class="nt">ul&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>...&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">ul&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">div&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>CSS 寫了 30 條、結果 &lt;code>.card&lt;/code> 沒在預期位置。是 grid 錯？font-size 影響？margin-collapse？line-height？無法定位。&lt;/p>
&lt;hr>
&lt;h2 id="原則-2measurement-完整性">原則 2：Measurement 完整性&lt;/h2>
&lt;p>對齊問題的本質是線性方程組：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">target_y = anchor_y + offset
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">total_height = h1_height + form_height + gap + scope_height + ...&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>每個變數都要有明確來源 — 任一個未知 → 整組無解。&lt;/p></description><content:encoded><![CDATA[<p>從最小可驗證單位起步、加變數一次只加一個、範圍從窄到寬擴張。</p>
<p>適用：UI layout debug、對齊問題、selector / MutationObserver root / JS 操作邊界的設計。
不適用：純內部演算法（沒有視覺、沒有範圍選擇）。</p>
<blockquote>
<p><strong>自包含聲明</strong>：閱讀本文件不需要先讀其他 reference。本文件涵蓋 placeholder 漸進、measurement 完整性、最小必要範圍三個共生原則。</p></blockquote>
<hr>
<blockquote>
<p><strong>Test-First 補充</strong>：當「漸進」的方式是「寫測試固化」時、必須走 RED → GREEN 兩個訊號才算驗證 — 詳見 <a href="/blog/report/test-first-red-before-green/" data-link-title="Test-First：先看到 RED 才相信 GREEN" data-link-desc="一個只看過 GREEN 的測試是「未驗證的訊號」、不是「會抓回歸的測試」。必須先在「該失敗的版本」上看到 RED、再在「該通過的版本」上看到 GREEN — 兩次跑都對、才能相信測試真的 catch 到該 catch 的東西。跳過 RED 等於把驗收標準降到「跑得通」、漏掉「測試自己有沒有 bug」這層。">#69 Test-First：先看到 RED 才相信 GREEN</a>。沒看過 RED 的測試 = 未驗證的訊號、不能信任。</p></blockquote>
<h2 id="何時參閱本文件">何時參閱本文件</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的第一件事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>開始 UI layout debug、不知道從哪一步起</td>
          <td>從色塊 placeholder 起步</td>
      </tr>
      <tr>
          <td>對齊規則寫了結果歪掉、不知道哪裡錯</td>
          <td>列方程組、確認每個變數有來源</td>
      </tr>
      <tr>
          <td>設計 selector / observer / JS 操作的範圍</td>
          <td>從最小起、有證據再擴張</td>
      </tr>
      <tr>
          <td>想用 <code>document.querySelectorAll('*')</code> 或 <code>subtree: true</code></td>
          <td>停 — 範圍可能過寬、補上限制條件</td>
      </tr>
      <tr>
          <td>Layout debug 一次改了 5 個變數、改完不知道哪個生效</td>
          <td>退回去、一次只動一個</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="為什麼這三個原則合併在一份-reference">為什麼這三個原則合併在一份 reference</h2>
<p>三個原則服務同一個讀者群體（<strong>正在開始一個新工作、還沒卡關</strong>）、回答同一類問題（<strong>該從多大的範圍 / 多少變數起步</strong>）。</p>
<ul>
<li>Placeholder 漸進 = 視覺面的「一次一個變數」</li>
<li>Measurement 完整性 = 對齊問題的「方程組必須完整」</li>
<li>Minimum scope = JS / CSS 範圍的「窄起來再放寬」</li>
</ul>
<p>共同精神：<strong>先窄後寬、有證據再擴張</strong>。「先寬後縮」的問題是分不出哪個寬度是刻意的；「先窄後寬」每次擴張都有原因可追。</p>
<hr>
<h2 id="原則-1placeholder-漸進除錯">原則 1：Placeholder 漸進除錯</h2>
<p>UI debug 從色塊起步、加東西一次加一個。</p>
<h3 id="起步純色塊">起步：純色塊</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">style</span><span class="o">=</span><span class="s">&#34;width: 200px; height: 100px; background: red; border: 2px solid black;&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">div</span><span class="p">&gt;</span></span></span></code></pre></div><p>沒文字、沒樣式、沒互動。<strong>唯一目的</strong>：確認位置、尺寸、grid / flex / absolute 的定位邏輯對。</p>
<h3 id="階段順序">階段順序</h3>
<table>
  <thead>
      <tr>
          <th>階段</th>
          <th>加入</th>
          <th>驗證</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td>純色塊（固定尺寸 + 顯眼邊框）</td>
          <td>位置、grid cell、stacking 對</td>
      </tr>
      <tr>
          <td>2</td>
          <td>占位文字（單行、無樣式）</td>
          <td>文字基線對、line-height 沒影響</td>
      </tr>
      <tr>
          <td>3</td>
          <td>真實內容（多行、含長字串）</td>
          <td>換行、溢出、文字裁切對</td>
      </tr>
      <tr>
          <td>4</td>
          <td>視覺樣式（color、font、padding）</td>
          <td>視覺層次對</td>
      </tr>
      <tr>
          <td>5</td>
          <td>互動行為（hover、click、focus）</td>
          <td>互動狀態對、focus 不跑掉</td>
      </tr>
  </tbody>
</table>
<p>每階段只引入一個變數、發現問題能立刻定位。<strong>跳階段</strong> = 失敗時不知道是哪個變數錯。</p>
<h3 id="典型反例">典型反例</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="c">&lt;!-- 第 1 步直接寫真實內容 + 完整樣式 --&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;card&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="p">&lt;</span><span class="nt">h3</span><span class="p">&gt;</span>Search results<span class="p">&lt;/</span><span class="nt">h3</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="p">&lt;</span><span class="nt">p</span><span class="p">&gt;</span>Showing {{count}} matches for &#34;{{query}}&#34;<span class="p">&lt;/</span><span class="nt">p</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="p">&lt;</span><span class="nt">ul</span><span class="p">&gt;</span>...<span class="p">&lt;/</span><span class="nt">ul</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span></span></span></code></pre></div><p>CSS 寫了 30 條、結果 <code>.card</code> 沒在預期位置。是 grid 錯？font-size 影響？margin-collapse？line-height？無法定位。</p>
<hr>
<h2 id="原則-2measurement-完整性">原則 2：Measurement 完整性</h2>
<p>對齊問題的本質是線性方程組：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">target_y = anchor_y + offset
</span></span><span class="line"><span class="ln">2</span><span class="cl">total_height = h1_height + form_height + gap + scope_height + ...</span></span></code></pre></div><p>每個變數都要有明確來源 — 任一個未知 → 整組無解。</p>
<h3 id="變數來源的三種類型">變數來源的三種類型</h3>
<table>
  <thead>
      <tr>
          <th>類型</th>
          <th>說明</th>
          <th>範例</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Hardcoded</td>
          <td>寫死在 design token / config</td>
          <td><code>--gap: 16px</code>、<code>--h1-height: 48px</code></td>
      </tr>
      <tr>
          <td>Component hook</td>
          <td>框架 / vendor 提供的 API</td>
          <td><code>pagefind.options.height</code>、CSS var</td>
      </tr>
      <tr>
          <td>Runtime measured</td>
          <td>JS 執行時量測（getBoundingClientRect）</td>
          <td><code>form.getBoundingClientRect().height</code></td>
      </tr>
  </tbody>
</table>
<h3 id="反例靠估值補方程式">反例：靠估值補方程式</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="c">/* 假設 form 大概 60px、加 gap 20px、總共 80px */</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">.</span><span class="nc">scope</span> <span class="p">{</span> <span class="k">top</span><span class="p">:</span> <span class="mi">80</span><span class="kt">px</span><span class="p">;</span> <span class="p">}</span></span></span></code></pre></div><p>實際 form 高度是 72px（隨字型 / line-height 變動）→ scope 跑位 8px。</p>
<h3 id="對例每個變數有來源">對例：每個變數有來源</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">const</span> <span class="nx">formHeight</span> <span class="o">=</span> <span class="nx">form</span><span class="p">.</span><span class="nx">getBoundingClientRect</span><span class="p">().</span><span class="nx">height</span><span class="p">;</span>  <span class="c1">// measured
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kr">const</span> <span class="nx">gap</span> <span class="o">=</span> <span class="nb">parseFloat</span><span class="p">(</span><span class="nx">getComputedStyle</span><span class="p">(</span><span class="nx">form</span><span class="p">).</span><span class="nx">marginBottom</span><span class="p">);</span>  <span class="c1">// measured
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="nx">scope</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">top</span> <span class="o">=</span> <span class="sb">`</span><span class="si">${</span><span class="nx">formHeight</span> <span class="o">+</span> <span class="nx">gap</span><span class="si">}</span><span class="sb">px`</span><span class="p">;</span></span></span></code></pre></div><p>或全部用 design token：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">.</span><span class="nc">scope</span> <span class="p">{</span> <span class="k">top</span><span class="p">:</span> <span class="nb">calc</span><span class="p">(</span><span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">form</span><span class="o">-</span><span class="n">height</span><span class="p">)</span> <span class="o">+</span> <span class="nf">var</span><span class="p">(</span><span class="o">--</span><span class="n">gap</span><span class="p">));</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c">/* var 在某處有單一定義、不是分散估值 */</span></span></span></code></pre></div><p>混搭策略要全選同一邊：對齊基準上要嘛全寫死、要嘛全量測、不要 hardcoded + 估值混用。</p>
<hr>
<h2 id="原則-3minimum-necessary-scope">原則 3：Minimum Necessary Scope</h2>
<p>Selector / MutationObserver / JS 操作的範圍從最小起、擴張要有證據。</p>
<h3 id="selector-範圍">Selector 範圍</h3>
<table>
  <thead>
      <tr>
          <th>寬度</th>
          <th>範例</th>
          <th>風險</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>最小（精準）</td>
          <td><code>#search-form .scope-toggle</code></td>
          <td>安全、變化時要更新 selector</td>
      </tr>
      <tr>
          <td>中等</td>
          <td><code>.scope-toggle</code></td>
          <td>可能命中其他頁面的同名元素</td>
      </tr>
      <tr>
          <td>過寬</td>
          <td><code>[class*=&quot;scope&quot;]</code> / <code>* &gt; .toggle</code></td>
          <td>命中無關元素、副作用未知</td>
      </tr>
  </tbody>
</table>
<p>預設用最小、有證據（多個地方確實要 match）再擴張。</p>
<h3 id="mutationobserver-範圍">MutationObserver 範圍</h3>
<p>三個維度：root、options、頻率。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 過寬
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="nx">observer</span><span class="p">.</span><span class="nx">observe</span><span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nx">body</span><span class="p">,</span> <span class="p">{</span> <span class="nx">childList</span><span class="o">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nx">subtree</span><span class="o">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nx">attributes</span><span class="o">:</span> <span class="kc">true</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">// → 監聽整個 page、每個 attribute 變動都觸發、CPU 100%
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">// 最小
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="nx">observer</span><span class="p">.</span><span class="nx">observe</span><span class="p">(</span><span class="nx">searchForm</span><span class="p">,</span> <span class="p">{</span> <span class="nx">childList</span><span class="o">:</span> <span class="kc">true</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1">// → 只監聽 form 直接子節點變動
</span></span></span></code></pre></div><h3 id="js-操作邊界">JS 操作邊界</h3>
<p>改一個元素的範圍從小到大：</p>
<table>
  <thead>
      <tr>
          <th>範圍</th>
          <th>風險</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>改 inline style</td>
          <td>安全、僅自家管的元素</td>
      </tr>
      <tr>
          <td>改 attribute</td>
          <td>中 — framework 可能 reconcile 清掉</td>
      </tr>
      <tr>
          <td>改 textContent</td>
          <td>中 — 同上</td>
      </tr>
      <tr>
          <td>改 innerHTML</td>
          <td>高 — 子節點全重建、event listener 失效</td>
      </tr>
      <tr>
          <td>reparent 整節點</td>
          <td>高但可控 — 整節點搬遷、framework 通常不會還原</td>
      </tr>
  </tbody>
</table>
<p>從「改 inline style」起步、不行才升級。</p>
<hr>
<h2 id="三個原則的共同精神">三個原則的共同精神</h2>
<p><strong>從最小可驗證單位起步、有證據再擴張</strong>：</p>
<ul>
<li>Placeholder：色塊 → 文字 → 樣式（一次加一層）</li>
<li>Measurement：每個變數先確認來源、再寫對齊規則</li>
<li>Scope：最窄的 selector / observer / JS 邊界、要擴張要有具體 case</li>
</ul>
<p>「先寬後縮」的反模式：寫一個包山包海的 selector、之後試著加 <code>:not(...)</code> 排除 → 永遠不知道哪些 match 是刻意的。</p>
<hr>
<h2 id="wrong-vs-right-對照">Wrong vs Right 對照</h2>
<h3 id="範例-1ui-debug-起步">範例 1：UI debug 起步</h3>
<blockquote>
<p>任務：把搜尋結果卡片做成兩欄 grid</p></blockquote>
<p><strong>錯</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c">&lt;!-- 直接寫完整版本 --&gt;</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;results-grid&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="p">&lt;</span><span class="nt">article</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;result-card&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="p">&lt;</span><span class="nt">h3</span><span class="p">&gt;&lt;</span><span class="nt">a</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;...&#34;</span><span class="p">&gt;</span>Title<span class="p">&lt;/</span><span class="nt">a</span><span class="p">&gt;&lt;/</span><span class="nt">h3</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="p">&lt;</span><span class="nt">p</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;excerpt&#34;</span><span class="p">&gt;</span>{{excerpt}}<span class="p">&lt;/</span><span class="nt">p</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;meta&#34;</span><span class="p">&gt;&lt;</span><span class="nt">span</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;tag&#34;</span><span class="p">&gt;</span>tag<span class="p">&lt;/</span><span class="nt">span</span><span class="p">&gt;</span> · <span class="p">&lt;</span><span class="nt">time</span><span class="p">&gt;</span>date<span class="p">&lt;/</span><span class="nt">time</span><span class="p">&gt;&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="p">&lt;/</span><span class="nt">article</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">&lt;</span><span class="nt">style</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">.</span><span class="nc">results-grid</span> <span class="p">{</span> <span class="k">display</span><span class="p">:</span> <span class="k">grid</span><span class="p">;</span> <span class="k">grid-template-columns</span><span class="p">:</span> <span class="mi">1</span><span class="n">fr</span> <span class="mi">1</span><span class="n">fr</span><span class="p">;</span> <span class="k">gap</span><span class="p">:</span> <span class="mi">24</span><span class="kt">px</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">.</span><span class="nc">result-card</span> <span class="p">{</span> <span class="k">padding</span><span class="p">:</span> <span class="mi">16</span><span class="kt">px</span><span class="p">;</span> <span class="k">border</span><span class="p">:</span> <span class="mi">1</span><span class="kt">px</span> <span class="kc">solid</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">.</span><span class="nc">result-card</span> <span class="nt">h3</span> <span class="p">{</span> <span class="k">font-size</span><span class="p">:</span> <span class="mi">18</span><span class="kt">px</span><span class="p">;</span> <span class="k">margin-bottom</span><span class="p">:</span> <span class="mi">8</span><span class="kt">px</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c">/* ... */</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">&lt;/</span><span class="nt">style</span><span class="p">&gt;</span></span></span></code></pre></div><p>跑出來、卡片高度不一致、<code>grid-auto-rows</code> 沒設、第二欄擠到第一欄底下。debug 困難 — 是 grid 設定錯？卡片內容差異？margin-collapse？</p>
<p><strong>對</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="c">&lt;!-- 階段 1：純色塊驗證 grid --&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;results-grid&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="p">&lt;</span><span class="nt">div</span> <span class="na">style</span><span class="o">=</span><span class="s">&#34;height: 100px; background: red;&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="p">&lt;</span><span class="nt">div</span> <span class="na">style</span><span class="o">=</span><span class="s">&#34;height: 100px; background: blue;&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="p">&lt;</span><span class="nt">div</span> <span class="na">style</span><span class="o">=</span><span class="s">&#34;height: 100px; background: green;&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="p">&lt;</span><span class="nt">div</span> <span class="na">style</span><span class="o">=</span><span class="s">&#34;height: 100px; background: yellow;&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span></span></span></code></pre></div><p>確認 grid 兩欄正常後、再進階段 2（加占位文字）。</p>
<h3 id="範例-2mutationobserver-root">範例 2：MutationObserver root</h3>
<blockquote>
<p>任務：當 search results 出現時、注入客製 UI</p></blockquote>
<p><strong>錯</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">new</span> <span class="nx">MutationObserver</span><span class="p">(...).</span><span class="nx">observe</span><span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nx">body</span><span class="p">,</span> <span class="p">{</span> <span class="nx">subtree</span><span class="o">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nx">childList</span><span class="o">:</span> <span class="kc">true</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">// 整個 page 任何變動都觸發、callback 跑 1000+ 次/秒
</span></span></span></code></pre></div><p><strong>對</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">const</span> <span class="nx">container</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__results-area&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">new</span> <span class="nx">MutationObserver</span><span class="p">(...).</span><span class="nx">observe</span><span class="p">(</span><span class="nx">container</span><span class="p">,</span> <span class="p">{</span> <span class="nx">childList</span><span class="o">:</span> <span class="kc">true</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">// 只監聽 results area 的直接子節點變動
</span></span></span></code></pre></div><p>如果之後發現 <code>.pagefind-ui__results-area</code> 內部 nested 變動也要監聽 → 那時再加 <code>subtree: true</code>、加之前能說出「為什麼需要」。</p>
<hr>
<h2 id="自檢清單dogfooding">自檢清單（dogfooding）</h2>
<p>開始一個新工作前：</p>
<ul>
<li><input disabled="" type="checkbox"> UI debug：第 1 階段是不是純色塊（沒文字、沒樣式）？</li>
<li><input disabled="" type="checkbox"> 對齊規則寫之前：是不是每個變數都列出來源（hardcoded / hook / measured）？</li>
<li><input disabled="" type="checkbox"> Selector：起步是不是最精準的版本？</li>
<li><input disabled="" type="checkbox"> MutationObserver：root / options 是不是最窄的？</li>
<li><input disabled="" type="checkbox"> JS 改元素：是不是從「改 inline style」起、不行才升級？</li>
</ul>
<p>任一項打勾失敗 → 退回最小、重新起步。</p>
<hr>
<h2 id="延伸閱讀">延伸閱讀</h2>
<p>對應的事後檢討（在 <code>content/report/</code>）：</p>
<ul>
<li><a href="/blog/report/placeholder-driven-ui-debug/" data-link-title="從色塊 placeholder 開始的漸進式 UI 除錯" data-link-desc="UI 除錯的最小可驗證單位是『一個有顏色的盒子』 — 版型先用色塊確認、內容後填。本文說明為什麼漸進式驗證比一次組裝完整 UI 容易 debug。">placeholder-driven-ui-debug</a> — 從色塊 placeholder 開始的漸進式 UI 除錯</li>
<li><a href="/blog/report/measurement-completeness/" data-link-title="量測值缺一不可：依賴未測量值會錯位" data-link-desc="對齊本質是『同一條基準線在多個元素上重現』 — 任何一個元素的高度沒有確定值、整條線都靠不住。本文展開『把對齊問題當線性方程組』的角度。">measurement-completeness</a> — 量測值缺一不可</li>
<li><a href="/blog/report/minimum-necessary-scope-is-sanity-defense/" data-link-title="最小必要範圍是 sanity 防線：保護行為可預測性" data-link-desc="縮 selector 範圍、observer 範圍、JS 操作範圍 — 不是為了效能、是為了讓行為可預測、不被未來變動打破。本文是 #13 / #14 / #29 三篇實作的共同抽象。">minimum-necessary-scope-is-sanity-defense</a> — 最小必要範圍是 sanity 防線</li>
</ul>
<hr>
<p><strong>Last Updated</strong>: 2026-04-26
<strong>Version</strong>: 0.1.0</p>
]]></content:encoded></item><item><title>Requirement Protocol — 需求確認到實作的對話協議</title><link>https://tarrragon.github.io/blog/skills/requirement-protocol/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/skills/requirement-protocol/</guid><description>&lt;h2 id="這個資料夾是什麼">這個資料夾是什麼&lt;/h2>
&lt;p>&lt;code>requirement-protocol&lt;/code> 是一套對話協議 skill，原生位置在 &lt;a href="https://github.com/tarrragon/blog/tree/main/.claude/skills/requirement-protocol">&lt;code>.claude/skills/requirement-protocol/&lt;/code>&lt;/a> 供 Claude runtime 呼叫；這份是&lt;strong>同內容的文章版本&lt;/strong>，讓人類讀者也能直接在 blog 閱讀。&lt;/p>
&lt;p>把「使用者下指令 → 執行者實作」之間的溝通流程結構化、避免反覆失敗、避免做出使用者沒要的東西、避免在錯誤方向上累積沉沒成本。源頭是 &lt;a href="https://tarrragon.github.io/blog/report/" data-link-title="Report — 開發過程的事後檢討" data-link-desc="blog 開發過程中、把實際遇到的版型 / 整合 / 框架共處等情境、整理成『應該怎麼做、沒這樣做會有什麼麻煩』的事後檢討。每篇皆為正向指引、幫助下一輪同類任務跳過反覆試錯。">&lt;code>content/report/&lt;/code>&lt;/a> 累積的 50+ 篇事後檢討、由本 skill 的五份 reference 萃取對應五個情境的協議步驟。&lt;/p>
&lt;h2 id="閱讀順序">閱讀順序&lt;/h2>
&lt;h3 id="場景-1第一次接觸">場景 1：第一次接觸&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>順序&lt;/th>
 &lt;th>檔案&lt;/th>
 &lt;th>目的&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>1&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/requirement-protocol/skill/" data-link-title="Requirement Protocol — SKILL 入口" data-link-desc="從需求確認到實作的對話協議 SKILL 入口：四大支柱（含 multi-pass refinement）、七大原則速查、六份情境 reference（含篩選類指令、決策呈現五維度）的觸發路由 &amp;#43; 抽象層原則網路。">SKILL.md&lt;/a>&lt;/td>
 &lt;td>三大支柱 + 六大原則速查、觸發路由表&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>2&lt;/td>
 &lt;td>依情境挑一份 reference（見下表）&lt;/td>
 &lt;td>把原則翻譯成可套用的協議步驟、模板與範例&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>3&lt;/td>
 &lt;td>該 reference 結尾的 self-check checklist&lt;/td>
 &lt;td>自評有沒有按協議走&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="場景-2已熟悉協議想直接解決當前任務">場景 2：已熟悉協議、想直接解決當前任務&lt;/h3>
&lt;p>直接依觸發情境跳對應 reference：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>觸發情境&lt;/th>
 &lt;th>reference&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>收到模糊指令（含「對齊」「靠近」「隔離」「不要動」「分開」等）&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/requirement-protocol/clarifying-ambiguous-instructions/" data-link-title="Clarifying Ambiguous Instructions — 模糊指令澄清協議" data-link-desc="requirement-protocol reference：空間 / 相對位置 / 隔離 / 決定權 / 篩選五類模糊指令的澄清模板 &amp;#43; visible 三問判準 &amp;#43; 篩選三問。">clarifying-ambiguous-instructions&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>不確定某個決定該自決還是該先問使用者&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/requirement-protocol/clarifying-ambiguous-instructions/" data-link-title="Clarifying Ambiguous Instructions — 模糊指令澄清協議" data-link-desc="requirement-protocol reference：空間 / 相對位置 / 隔離 / 決定權 / 篩選五類模糊指令的澄清模板 &amp;#43; visible 三問判準 &amp;#43; 篩選三問。">clarifying-ambiguous-instructions&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>同方向失敗 ≥ 2 次、想再試一次更小心&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/requirement-protocol/failure-pivot-protocol/" data-link-title="Failure Pivot Protocol — 失敗 2 次的轉折協議" data-link-desc="requirement-protocol reference：同方向失敗 2 次的轉折協議 — 停下、驗證底層假設、換方向、對外回報模板。">failure-pivot-protocol&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>推理 + 視覺截圖溝通迴圈卡住、不知道該不該換工具&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/requirement-protocol/tool-switching-timing/" data-link-title="Tool Switching Timing — 推理 / DevTools / Playwright 的切換時機" data-link-desc="requirement-protocol reference：四種 debug 工具的 ROI 對照、切換訊號、playwright 三個位置（假設 / 行為 / 互動驗證）。">tool-switching-timing&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>客製需求要對抗多層（vendor CSS、framework、browser default）&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/requirement-protocol/cost-and-checkpoint/" data-link-title="Cost &amp;amp; Checkpoint — 覆寫成本告知與 revert checkpoint" data-link-desc="requirement-protocol reference：對抗多層的覆寫成本告知 &amp;#43; 「先還原 / 先重來」類退出指令的 checkpoint commit 處理。">cost-and-checkpoint&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>收到「先還原 / 先重來 / 換個方向」類指令&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/requirement-protocol/cost-and-checkpoint/" data-link-title="Cost &amp;amp; Checkpoint — 覆寫成本告知與 revert checkpoint" data-link-desc="requirement-protocol reference：對抗多層的覆寫成本告知 &amp;#43; 「先還原 / 先重來」類退出指令的 checkpoint commit 處理。">cost-and-checkpoint&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>開始 UI layout debug、不知道從哪一步起&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/requirement-protocol/progressive-verification/" data-link-title="Progressive Verification — 漸進驗證與最小必要範圍" data-link-desc="requirement-protocol reference：placeholder 漸進除錯 &amp;#43; measurement 完整性 &amp;#43; minimum necessary scope 三原則。">progressive-verification&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>設計 selector / MutationObserver root / JS 操作範圍&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/requirement-protocol/progressive-verification/" data-link-title="Progressive Verification — 漸進驗證與最小必要範圍" data-link-desc="requirement-protocol reference：placeholder 漸進除錯 &amp;#43; measurement 完整性 &amp;#43; minimum necessary scope 三原則。">progressive-verification&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>每份 reference 自包含：讀任一份不需要回頭讀其他 reference。&lt;/p></description><content:encoded><![CDATA[<h2 id="這個資料夾是什麼">這個資料夾是什麼</h2>
<p><code>requirement-protocol</code> 是一套對話協議 skill，原生位置在 <a href="https://github.com/tarrragon/blog/tree/main/.claude/skills/requirement-protocol"><code>.claude/skills/requirement-protocol/</code></a> 供 Claude runtime 呼叫；這份是<strong>同內容的文章版本</strong>，讓人類讀者也能直接在 blog 閱讀。</p>
<p>把「使用者下指令 → 執行者實作」之間的溝通流程結構化、避免反覆失敗、避免做出使用者沒要的東西、避免在錯誤方向上累積沉沒成本。源頭是 <a href="/blog/report/" data-link-title="Report — 開發過程的事後檢討" data-link-desc="blog 開發過程中、把實際遇到的版型 / 整合 / 框架共處等情境、整理成『應該怎麼做、沒這樣做會有什麼麻煩』的事後檢討。每篇皆為正向指引、幫助下一輪同類任務跳過反覆試錯。"><code>content/report/</code></a> 累積的 50+ 篇事後檢討、由本 skill 的五份 reference 萃取對應五個情境的協議步驟。</p>
<h2 id="閱讀順序">閱讀順序</h2>
<h3 id="場景-1第一次接觸">場景 1：第一次接觸</h3>
<table>
  <thead>
      <tr>
          <th>順序</th>
          <th>檔案</th>
          <th>目的</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td><a href="/blog/skills/requirement-protocol/skill/" data-link-title="Requirement Protocol — SKILL 入口" data-link-desc="從需求確認到實作的對話協議 SKILL 入口：四大支柱（含 multi-pass refinement）、七大原則速查、六份情境 reference（含篩選類指令、決策呈現五維度）的觸發路由 &#43; 抽象層原則網路。">SKILL.md</a></td>
          <td>三大支柱 + 六大原則速查、觸發路由表</td>
      </tr>
      <tr>
          <td>2</td>
          <td>依情境挑一份 reference（見下表）</td>
          <td>把原則翻譯成可套用的協議步驟、模板與範例</td>
      </tr>
      <tr>
          <td>3</td>
          <td>該 reference 結尾的 self-check checklist</td>
          <td>自評有沒有按協議走</td>
      </tr>
  </tbody>
</table>
<h3 id="場景-2已熟悉協議想直接解決當前任務">場景 2：已熟悉協議、想直接解決當前任務</h3>
<p>直接依觸發情境跳對應 reference：</p>
<table>
  <thead>
      <tr>
          <th>觸發情境</th>
          <th>reference</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>收到模糊指令（含「對齊」「靠近」「隔離」「不要動」「分開」等）</td>
          <td><a href="/blog/skills/requirement-protocol/clarifying-ambiguous-instructions/" data-link-title="Clarifying Ambiguous Instructions — 模糊指令澄清協議" data-link-desc="requirement-protocol reference：空間 / 相對位置 / 隔離 / 決定權 / 篩選五類模糊指令的澄清模板 &#43; visible 三問判準 &#43; 篩選三問。">clarifying-ambiguous-instructions</a></td>
      </tr>
      <tr>
          <td>不確定某個決定該自決還是該先問使用者</td>
          <td><a href="/blog/skills/requirement-protocol/clarifying-ambiguous-instructions/" data-link-title="Clarifying Ambiguous Instructions — 模糊指令澄清協議" data-link-desc="requirement-protocol reference：空間 / 相對位置 / 隔離 / 決定權 / 篩選五類模糊指令的澄清模板 &#43; visible 三問判準 &#43; 篩選三問。">clarifying-ambiguous-instructions</a></td>
      </tr>
      <tr>
          <td>同方向失敗 ≥ 2 次、想再試一次更小心</td>
          <td><a href="/blog/skills/requirement-protocol/failure-pivot-protocol/" data-link-title="Failure Pivot Protocol — 失敗 2 次的轉折協議" data-link-desc="requirement-protocol reference：同方向失敗 2 次的轉折協議 — 停下、驗證底層假設、換方向、對外回報模板。">failure-pivot-protocol</a></td>
      </tr>
      <tr>
          <td>推理 + 視覺截圖溝通迴圈卡住、不知道該不該換工具</td>
          <td><a href="/blog/skills/requirement-protocol/tool-switching-timing/" data-link-title="Tool Switching Timing — 推理 / DevTools / Playwright 的切換時機" data-link-desc="requirement-protocol reference：四種 debug 工具的 ROI 對照、切換訊號、playwright 三個位置（假設 / 行為 / 互動驗證）。">tool-switching-timing</a></td>
      </tr>
      <tr>
          <td>客製需求要對抗多層（vendor CSS、framework、browser default）</td>
          <td><a href="/blog/skills/requirement-protocol/cost-and-checkpoint/" data-link-title="Cost &amp; Checkpoint — 覆寫成本告知與 revert checkpoint" data-link-desc="requirement-protocol reference：對抗多層的覆寫成本告知 &#43; 「先還原 / 先重來」類退出指令的 checkpoint commit 處理。">cost-and-checkpoint</a></td>
      </tr>
      <tr>
          <td>收到「先還原 / 先重來 / 換個方向」類指令</td>
          <td><a href="/blog/skills/requirement-protocol/cost-and-checkpoint/" data-link-title="Cost &amp; Checkpoint — 覆寫成本告知與 revert checkpoint" data-link-desc="requirement-protocol reference：對抗多層的覆寫成本告知 &#43; 「先還原 / 先重來」類退出指令的 checkpoint commit 處理。">cost-and-checkpoint</a></td>
      </tr>
      <tr>
          <td>開始 UI layout debug、不知道從哪一步起</td>
          <td><a href="/blog/skills/requirement-protocol/progressive-verification/" data-link-title="Progressive Verification — 漸進驗證與最小必要範圍" data-link-desc="requirement-protocol reference：placeholder 漸進除錯 &#43; measurement 完整性 &#43; minimum necessary scope 三原則。">progressive-verification</a></td>
      </tr>
      <tr>
          <td>設計 selector / MutationObserver root / JS 操作範圍</td>
          <td><a href="/blog/skills/requirement-protocol/progressive-verification/" data-link-title="Progressive Verification — 漸進驗證與最小必要範圍" data-link-desc="requirement-protocol reference：placeholder 漸進除錯 &#43; measurement 完整性 &#43; minimum necessary scope 三原則。">progressive-verification</a></td>
      </tr>
  </tbody>
</table>
<p>每份 reference 自包含：讀任一份不需要回頭讀其他 reference。</p>
<h2 id="與-blog-專案其他資料的關係">與 blog 專案其他資料的關係</h2>
<table>
  <thead>
      <tr>
          <th>位置</th>
          <th>角色</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>.claude/skills/requirement-protocol/</code></td>
          <td>實際 skill — Claude runtime 呼叫的檔案來源</td>
      </tr>
      <tr>
          <td><code>content/skills/requirement-protocol/</code>（本處）</td>
          <td>文章版本 — 人類讀者在 blog 閱讀</td>
      </tr>
      <tr>
          <td><a href="/blog/report/" data-link-title="Report — 開發過程的事後檢討" data-link-desc="blog 開發過程中、把實際遇到的版型 / 整合 / 框架共處等情境、整理成『應該怎麼做、沒這樣做會有什麼麻煩』的事後檢討。每篇皆為正向指引、幫助下一輪同類任務跳過反覆試錯。"><code>content/report/</code></a></td>
          <td>50+ 篇事後檢討、本 skill 的素材來源；reference 結尾連回對應篇</td>
      </tr>
      <tr>
          <td><code>.claude/skills/compositional-writing/</code></td>
          <td>寫作方法論 skill — 本 skill 的 references 撰寫品質依此規範</td>
      </tr>
  </tbody>
</table>
<h2 id="last-updated">Last Updated</h2>
<p>2026-04-26 — 初版：v0.1.0 同步、五份 references 對應「模糊指令 / 失敗轉折 / 成本與 checkpoint / 漸進驗證 / 工具切換」五個情境。</p>
]]></content:encoded></item><item><title>Gradle JVM target 除錯復盤：七個節點的策略權衡</title><link>https://tarrragon.github.io/blog/work-log/gradle-jvm-target-%E9%99%A4%E9%8C%AF%E5%BE%A9%E7%9B%A4%E4%B8%83%E5%80%8B%E7%AF%80%E9%BB%9E%E7%9A%84%E7%AD%96%E7%95%A5%E6%AC%8A%E8%A1%A1/</link><pubDate>Fri, 17 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/gradle-jvm-target-%E9%99%A4%E9%8C%AF%E5%BE%A9%E7%9B%A4%E4%B8%83%E5%80%8B%E7%AF%80%E9%BB%9E%E7%9A%84%E7%AD%96%E7%95%A5%E6%AC%8A%E8%A1%A1/</guid><description>&lt;h2 id="為什麼寫這篇">為什麼寫這篇&lt;/h2>
&lt;p>排查 Gradle JVM target inconsistency 時走了七個節點才收斂。這篇復盤每個節點的完整決策流：&lt;/p>
&lt;hr>
&lt;h2 id="節點-a第一次錯誤出現">節點 A：第一次錯誤出現&lt;/h2>
&lt;h3 id="當下看到">當下看到&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">Execution failed for task &amp;#39;:flutter_broadcasts_4m:compileDebugKotlin&amp;#39;.
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&amp;gt; ⛔ Inconsistent JVM Target Compatibility Between Java and Kotlin Tasks
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> detected for tasks &amp;#39;compileDebugJavaWithJavac&amp;#39; (17)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> and &amp;#39;compileDebugKotlin&amp;#39; (1.8).&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="判讀">判讀&lt;/h3>
&lt;p>&lt;strong>這類錯誤在系統中代表什麼&lt;/strong>（商業邏輯）：&lt;/p>
&lt;p>Android 專案的每個 module（主 app 或第三方 plugin）會分別編譯 Java 跟 Kotlin 原始碼，各自產出 JVM bytecode。每個 bytecode 檔案有一個「target version」，決定它能在多舊的 JVM runtime 上執行，以及可以使用哪些語言特性。&lt;/p>
&lt;p>同一個 module 內的 Java 跟 Kotlin 若產出不同 target 的 bytecode，執行時可能觸發 API 相容性問題（例如 Java 17 的 class 呼叫到 Kotlin 1.8 runtime 不存在的方法）。Kotlin 2.2 把這個原本只是 warning 的情境提升為 strict error，直接中止 build。&lt;/p>
&lt;p>所以 &lt;code>Inconsistent JVM Target Compatibility&lt;/code> 這類錯誤的本質是：&lt;strong>某個 module 裡面 Java 跟 Kotlin 編譯產出的 bytecode 不是同一個版本&lt;/strong>。&lt;/p>
&lt;p>&lt;strong>這次訊息具體說了什麼&lt;/strong>（CASE）：&lt;/p>
&lt;ul>
&lt;li>錯誤 task 前綴 &lt;code>:flutter_broadcasts_4m&lt;/code> → 出問題的 module 是這個第三方 plugin&lt;/li>
&lt;li>&lt;code>compileDebugJavaWithJavac (17)&lt;/code> → 這個 module 的 Java 編譯產出 bytecode target = 17&lt;/li>
&lt;li>&lt;code>compileDebugKotlin (1.8)&lt;/code> → 這個 module 的 Kotlin 編譯產出 bytecode target = 1.8&lt;/li>
&lt;li>17 跟 1.8 不同 → 符合上面「module 內不一致」的 pattern&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>從 CASE 推論的事&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>主專案 &lt;code>:app&lt;/code> 已設定 JVM 17，這個 plugin 的 Java 繼承到 17；但 Kotlin 被某處明確設成 1.8&lt;/li>
&lt;li>Kotlin plugin 的預設值會跟 Java 對齊，所以 1.8 是「有人明確寫了」，不是預設&lt;/li>
&lt;li>最有可能的「有人」是 plugin 自己的 &lt;code>build.gradle&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>需要進一步確認才能完整判讀的&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>Kotlin 1.8 具體寫在哪？&lt;code>cat ~/.pub-cache/hosted/pub.dev/flutter_broadcasts_4m-*/android/build.gradle&lt;/code> 可以驗證&lt;/li>
&lt;li>其他 plugin 有沒有同類寫死？這不影響當前這個錯誤的修復，但影響&lt;strong>修復範圍&lt;/strong>的完整性&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>判讀後的問題類別&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>類別：第三方 plugin 內部寫死 JVM target&lt;/li>
&lt;li>主專案的 override 機制沒能覆蓋到 plugin 的內部設定&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>這次判讀的完整度&lt;/strong>：驗證了 plugin 內部寫死（確認過 &lt;code>kotlinOptions { jvmTarget = '1.8' }&lt;/code>），但&lt;strong>沒有擴大搜尋其他 plugin&lt;/strong>。這個不完整後來在節點 D 付出代價。&lt;/p>
&lt;h3 id="可選策略">可選策略&lt;/h3>
&lt;h4 id="a1-等-plugin-升級">A1. 等 plugin 升級&lt;/h4>
&lt;ul>
&lt;li>優點：零維護；無需理解 Gradle 機制&lt;/li>
&lt;li>缺點：決策權不在自己；無法保證 plugin 作者會修&lt;/li>
&lt;/ul>
&lt;h4 id="a2-從-root-專案強制覆寫">A2. 從 root 專案強制覆寫&lt;/h4>
&lt;ul>
&lt;li>優點：決策權自主；影響範圍可控；不需 fork&lt;/li>
&lt;li>缺點：需要理解 Gradle 生命週期&lt;/li>
&lt;/ul>
&lt;h4 id="a3-fork-plugin-修改">A3. Fork plugin 修改&lt;/h4>
&lt;ul>
&lt;li>優點：覆蓋完整；可修改任何細節&lt;/li>
&lt;li>缺點：持續維護成本；升級需 merge;增加依賴來源複雜度&lt;/li>
&lt;/ul>
&lt;h4 id="a4-降-app-回-jvm-18">A4. 降 &lt;code>:app&lt;/code> 回 JVM 1.8&lt;/h4>
&lt;ul>
&lt;li>優點：不需額外配置&lt;/li>
&lt;li>缺點：放棄 Java 17 語言特性；跟 AGP 方向相反&lt;/li>
&lt;/ul>
&lt;h3 id="選擇與理由">選擇與理由&lt;/h3>
&lt;p>&lt;strong>A2&lt;/strong>。A1 放棄決策權；A3 維護成本跟 plugin 重要性不成比例；A4 機會成本太高。&lt;/p></description><content:encoded><![CDATA[<h2 id="為什麼寫這篇">為什麼寫這篇</h2>
<p>排查 Gradle JVM target inconsistency 時走了七個節點才收斂。這篇復盤每個節點的完整決策流：</p>
<hr>
<h2 id="節點-a第一次錯誤出現">節點 A：第一次錯誤出現</h2>
<h3 id="當下看到">當下看到</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Execution failed for task &#39;:flutter_broadcasts_4m:compileDebugKotlin&#39;.
</span></span><span class="line"><span class="ln">2</span><span class="cl">&gt; ⛔ Inconsistent JVM Target Compatibility Between Java and Kotlin Tasks
</span></span><span class="line"><span class="ln">3</span><span class="cl">  detected for tasks &#39;compileDebugJavaWithJavac&#39; (17)
</span></span><span class="line"><span class="ln">4</span><span class="cl">  and &#39;compileDebugKotlin&#39; (1.8).</span></span></code></pre></div><h3 id="判讀">判讀</h3>
<p><strong>這類錯誤在系統中代表什麼</strong>（商業邏輯）：</p>
<p>Android 專案的每個 module（主 app 或第三方 plugin）會分別編譯 Java 跟 Kotlin 原始碼，各自產出 JVM bytecode。每個 bytecode 檔案有一個「target version」，決定它能在多舊的 JVM runtime 上執行，以及可以使用哪些語言特性。</p>
<p>同一個 module 內的 Java 跟 Kotlin 若產出不同 target 的 bytecode，執行時可能觸發 API 相容性問題（例如 Java 17 的 class 呼叫到 Kotlin 1.8 runtime 不存在的方法）。Kotlin 2.2 把這個原本只是 warning 的情境提升為 strict error，直接中止 build。</p>
<p>所以 <code>Inconsistent JVM Target Compatibility</code> 這類錯誤的本質是：<strong>某個 module 裡面 Java 跟 Kotlin 編譯產出的 bytecode 不是同一個版本</strong>。</p>
<p><strong>這次訊息具體說了什麼</strong>（CASE）：</p>
<ul>
<li>錯誤 task 前綴 <code>:flutter_broadcasts_4m</code> → 出問題的 module 是這個第三方 plugin</li>
<li><code>compileDebugJavaWithJavac (17)</code> → 這個 module 的 Java 編譯產出 bytecode target = 17</li>
<li><code>compileDebugKotlin (1.8)</code> → 這個 module 的 Kotlin 編譯產出 bytecode target = 1.8</li>
<li>17 跟 1.8 不同 → 符合上面「module 內不一致」的 pattern</li>
</ul>
<p><strong>從 CASE 推論的事</strong>：</p>
<ul>
<li>主專案 <code>:app</code> 已設定 JVM 17，這個 plugin 的 Java 繼承到 17；但 Kotlin 被某處明確設成 1.8</li>
<li>Kotlin plugin 的預設值會跟 Java 對齊，所以 1.8 是「有人明確寫了」，不是預設</li>
<li>最有可能的「有人」是 plugin 自己的 <code>build.gradle</code></li>
</ul>
<p><strong>需要進一步確認才能完整判讀的</strong>：</p>
<ul>
<li>Kotlin 1.8 具體寫在哪？<code>cat ~/.pub-cache/hosted/pub.dev/flutter_broadcasts_4m-*/android/build.gradle</code> 可以驗證</li>
<li>其他 plugin 有沒有同類寫死？這不影響當前這個錯誤的修復，但影響<strong>修復範圍</strong>的完整性</li>
</ul>
<p><strong>判讀後的問題類別</strong>：</p>
<ul>
<li>類別：第三方 plugin 內部寫死 JVM target</li>
<li>主專案的 override 機制沒能覆蓋到 plugin 的內部設定</li>
</ul>
<p><strong>這次判讀的完整度</strong>：驗證了 plugin 內部寫死（確認過 <code>kotlinOptions { jvmTarget = '1.8' }</code>），但<strong>沒有擴大搜尋其他 plugin</strong>。這個不完整後來在節點 D 付出代價。</p>
<h3 id="可選策略">可選策略</h3>
<h4 id="a1-等-plugin-升級">A1. 等 plugin 升級</h4>
<ul>
<li>優點：零維護；無需理解 Gradle 機制</li>
<li>缺點：決策權不在自己；無法保證 plugin 作者會修</li>
</ul>
<h4 id="a2-從-root-專案強制覆寫">A2. 從 root 專案強制覆寫</h4>
<ul>
<li>優點：決策權自主；影響範圍可控；不需 fork</li>
<li>缺點：需要理解 Gradle 生命週期</li>
</ul>
<h4 id="a3-fork-plugin-修改">A3. Fork plugin 修改</h4>
<ul>
<li>優點：覆蓋完整；可修改任何細節</li>
<li>缺點：持續維護成本；升級需 merge;增加依賴來源複雜度</li>
</ul>
<h4 id="a4-降-app-回-jvm-18">A4. 降 <code>:app</code> 回 JVM 1.8</h4>
<ul>
<li>優點：不需額外配置</li>
<li>缺點：放棄 Java 17 語言特性；跟 AGP 方向相反</li>
</ul>
<h3 id="選擇與理由">選擇與理由</h3>
<p><strong>A2</strong>。A1 放棄決策權；A3 維護成本跟 plugin 重要性不成比例；A4 機會成本太高。</p>
<h3 id="修正動作">修正動作</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-groovy" data-lang="groovy"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">subprojects</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="n">plugins</span><span class="o">.</span><span class="na">withId</span><span class="o">(</span><span class="s2">&#34;com.android.library&#34;</span><span class="o">)</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="n">android</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">            <span class="n">compileOptions</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">                <span class="n">sourceCompatibility</span> <span class="o">=</span> <span class="n">JavaVersion</span><span class="o">.</span><span class="na">VERSION_17</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">                <span class="n">targetCompatibility</span> <span class="o">=</span> <span class="n">JavaVersion</span><span class="o">.</span><span class="na">VERSION_17</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="o">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="o">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="o">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="n">tasks</span><span class="o">.</span><span class="na">withType</span><span class="o">(</span><span class="n">org</span><span class="o">.</span><span class="na">jetbrains</span><span class="o">.</span><span class="na">kotlin</span><span class="o">.</span><span class="na">gradle</span><span class="o">.</span><span class="na">tasks</span><span class="o">.</span><span class="na">KotlinCompile</span><span class="o">).</span><span class="na">configureEach</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="n">kotlinOptions</span> <span class="o">{</span> <span class="n">jvmTarget</span> <span class="o">=</span> <span class="s1">&#39;17&#39;</span> <span class="o">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="o">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="o">}</span></span></span></code></pre></div><h3 id="結果">結果</h3>
<p><code>flutter_broadcasts_4m</code> 過了。</p>
<h3 id="事後檢視">事後檢視</h3>
<p>判讀階段明確知道「需要進一步確認其他 plugin 是否有同類問題」，但沒做。當下沒做的理由是「目前錯誤訊息只指向這一個 plugin」，這個理由把判讀完整性降到最低——<strong>修復只要能讓當前這次 build 過就好</strong>。</p>
<p>若判讀時把「範圍完整性」當成跟「修復正確性」同等的維度：</p>
<ul>
<li>會額外做一次 <code>grep -r &quot;jvmTarget&quot; ~/.pub-cache/hosted/pub.dev/*/android/build.gradle | grep &quot;1.8&quot;</code></li>
<li>會得到一份完整的有同類問題的 plugin 清單</li>
<li>修復策略 A2 就會涵蓋整份清單，不只當前一個</li>
</ul>
<p>這裡不是選錯了策略，是<strong>判讀時把範圍當成「訊息指定的」而非「應該主動探索的」</strong>。</p>
<hr>
<h2 id="節點-b使用者問要不要換-jvm-toolchain">節點 B：使用者問「要不要換 JVM Toolchain」</h2>
<h3 id="當下看到-1">當下看到</h3>
<p>節點 A 修復成功。使用者提出：「既然官方推薦 JVM Toolchain，A2 的 task 級 configureEach 是不是次佳解？」</p>
<h3 id="判讀-1">判讀</h3>
<p>這不是錯誤訊息，是<strong>當前方案跟官方推薦方向的差距</strong>。</p>
<p><strong>這類判斷的商業邏輯</strong>：</p>
<p>Gradle 有兩種層次不同的 JVM 治理機制，判斷「要不要換」之前要先理解它們處理的是不同問題：</p>
<ul>
<li><strong>編譯輸出控制</strong>：決定「編譯出來的 bytecode target 是多少」。影響產出的 <code>.class</code> 檔能在哪個 JVM runtime 上跑，但不管 Gradle 自己用什麼 JDK 執行。</li>
<li><strong>JDK 工具鏈管理</strong>：決定「Gradle 執行編譯器時用哪一版 JDK」。不同 JDK 會影響編譯行為、支援的語言特性、以及一些 bytecode 預設目標。</li>
</ul>
<p>這兩件事可以獨立設定。一個專案可以用 JDK 21 執行 Gradle，但編譯產出 JVM 17 bytecode（為了向下相容）。</p>
<p>所以「要不要換 toolchain」這個問題的本質是：<strong>這兩層治理機制現在各自的解決方式是否對當前需求最佳？</strong></p>
<p><strong>這次的具體選擇空間</strong>（CASE）：</p>
<p>當前方案：<code>tasks.withType(KotlinCompile).configureEach { jvmTarget = '17' }</code> task 級 configureEach</p>
<ul>
<li>處理的問題：編譯輸出控制（bytecode target = 17）</li>
<li>不處理的問題：JDK 工具鏈管理（開發者本機裝什麼 JDK、版本是否一致未控管）</li>
</ul>
<p>Toolchain 方案：<code>kotlin { jvmToolchain(17) }</code> extension 級</p>
<ul>
<li>處理的問題：JDK 工具鏈管理（Gradle 自動下載 JDK 17 執行）</li>
<li>附帶處理：對守規矩的 plugin 也會影響 bytecode target</li>
<li>不處理的問題：硬寫死 <code>jvmTarget = '1.8'</code> 的 plugin（extension 會被 plugin 的 task 設定蓋掉）</li>
</ul>
<p><strong>從 CASE 推論的事</strong>：</p>
<p>這兩個方案<strong>不是替代關係，是不同層次的治理</strong>。task 級覆寫處理「產出」，toolchain 處理「JDK 環境」。兩者可以並存，甚至應該並存。</p>
<p><strong>需要進一步確認</strong>：</p>
<ul>
<li>Toolchain 的 extension 設定是否真會被硬寫死的 plugin 蓋掉？（答案是：會被蓋掉，但節點 B 當下沒驗證）</li>
<li>Toolchain 能在哪些時機點設定？（答案：某些屬性在 plugin apply 的 lazy initializer 時 finalize，此時再設會炸——但這也是節點 B 當下沒驗證）</li>
</ul>
<h3 id="可選策略-1">可選策略</h3>
<h4 id="b1-保持現狀task-級-configureeach">B1. 保持現狀（task 級 configureEach）</h4>
<ul>
<li>優點：已經 work</li>
<li>缺點：偏離官方方向；每位開發者本機 JDK 需自行管理</li>
</ul>
<h4 id="b2-完全換成-toolchain">B2. 完全換成 toolchain</h4>
<ul>
<li>優點：符合官方方向；JDK 自動下載</li>
<li>缺點：無法覆蓋硬寫死 plugin（extension 會被 plugin 的 task 設定蓋）</li>
</ul>
<h4 id="b3-混合toolchain--task-級覆寫">B3. 混合（toolchain + task 級覆寫）</h4>
<ul>
<li>優點：同時享有 toolchain 的 JDK 管理跟 task 級的強制力</li>
<li>缺點：配置面向增加</li>
</ul>
<h3 id="選擇與理由-1">選擇與理由</h3>
<p><strong>B3</strong>。B2 單獨不完整，B1 忽略長期適應性，B3 是功能完整的組合。</p>
<h3 id="結果-1">結果</h3>
<p>Build 炸：<code>languageVersion is final</code>。</p>
<h3 id="事後檢視-1">事後檢視</h3>
<p>判讀階段明確列出了「toolchain 能在哪些時機點設定」這個需要確認的問題，但沒確認就進入策略。<strong>判讀的未完成部分就是節點 C 的失敗來源</strong>。</p>
<p>這次判讀告訴了我們「還缺什麼資訊」，但沒有把「缺的資訊」當成進入下一階段的阻擋條件。若判讀的標準是「所有標示為『需要確認』的事實都要先解答」，節點 C 不會發生。</p>
<p>這一步的本質問題是<strong>把判讀中的不確定性帶入執行階段</strong>。</p>
<hr>
<h2 id="節點-clanguageversion-is-final-錯誤">節點 C：<code>languageVersion is final</code> 錯誤</h2>
<h3 id="當下看到-2">當下看到</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">* Where:
</span></span><span class="line"><span class="ln">2</span><span class="cl">Build file &#39;/Users/mac-eric/project/unipos/android/build.gradle&#39; line: 37
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl">* What went wrong:
</span></span><span class="line"><span class="ln">5</span><span class="cl">&gt; The value for property &#39;languageVersion&#39; is final and cannot be changed any further.</span></span></code></pre></div><h3 id="判讀-2">判讀</h3>
<p><strong>這類錯誤在系統中代表什麼</strong>（商業邏輯）：</p>
<p>Gradle 的許多 configuration 屬性有「生命週期狀態」的概念。一個屬性從建立時可以自由讀寫，但到了某個時機點後會被 <strong>finalize</strong> — 意思是「值從此鎖定，任何後續賦值都會被拒絕」。</p>
<p>Finalize 不是錯誤，是 Gradle 保證 build 可預測性的機制：若某個值已經被使用（被其他 task 讀取、被其他設定依賴），再讓它改變會造成「同一次 build 的上下文裡不同地方看到不同值」的不一致。</p>
<p>觸發 finalize 的時機有很多種，最常見的：</p>
<ul>
<li>其他程式碼讀取了這個屬性</li>
<li>plugin 內部的 lazy initializer 把值固定下來</li>
<li>project evaluation 進入某個階段</li>
</ul>
<p>所以 <code>is final and cannot be changed any further</code> 這類錯誤的本質是：<strong>你現在嘗試賦值的屬性，已經在更早的時機被鎖定了</strong>。問題不在「值本身」，在「賦值的時機」。</p>
<p><strong>這次訊息具體說了什麼</strong>（CASE）：</p>
<ul>
<li>錯誤位置：root <code>build.gradle</code> line 37</li>
<li>line 37 是 <code>kotlin { jvmToolchain(17) }</code> 那行</li>
<li>被鎖定的屬性：<code>languageVersion</code></li>
<li>狀態：已 final，拒絕修改</li>
</ul>
<p><strong>從 CASE 推論的事</strong>：</p>
<ul>
<li><code>jvmToolchain(17)</code> 內部試圖設定多個屬性，其中 <code>languageVersion</code> 已 final</li>
<li>「已 final」表示有更早的動作完成了它的 finalize。可能來源：
<ul>
<li>(a) 某個 plugin 在 apply 階段透過 lazy initializer 把值固定下來</li>
<li>(b) 某個先前的配置（<code>kotlinOptions { }</code> 或類似）把值鎖定</li>
</ul>
</li>
<li>這段在 <code>subprojects {}</code> 內，會對每個 subproject 執行；<strong>可能不是每個 subproject 都觸發</strong>，是某個特定的</li>
</ul>
<p><strong>錯誤訊息沒說但需要推論的</strong>：</p>
<ul>
<li>是<strong>哪個</strong> subproject 觸發？訊息沒指名</li>
<li>為什麼 <code>:app</code> 先前 <code>kotlin { jvmToolchain(17) }</code> 成功，subprojects 內就失敗？</li>
</ul>
<p><strong>判讀後的問題類別</strong>：</p>
<ul>
<li>類別：<strong>時機問題</strong> — 設定 <code>jvmToolchain</code> 的時機晚於某個 plugin 的 <code>languageVersion</code> finalize 時機</li>
<li>對照已 work 的 <code>:app</code>：<code>:app</code> 是在自己的 <code>build.gradle</code> 頂層設 toolchain，時機最早</li>
<li>差異：subprojects 內的 <code>plugins.withId</code> 或 <code>kotlin {}</code> 區塊是 callback，執行時機比 <code>:app</code> 頂層晚</li>
</ul>
<h3 id="可選策略-2">可選策略</h3>
<h4 id="c1-拿掉-subprojects-的-toolchain只留-app">C1. 拿掉 subprojects 的 toolchain，只留 <code>:app</code></h4>
<ul>
<li>優點：<code>:app</code> 的 toolchain 驅動整個 Gradle daemon 的 JDK 環境，子專案繼承；避開 finalize 衝突</li>
<li>缺點：依賴「Gradle daemon 用 global JDK」這個前提</li>
</ul>
<h4 id="c2-改用-afterevaluate-延遲-toolchain-設定">C2. 改用 <code>afterEvaluate</code> 延遲 toolchain 設定</h4>
<ul>
<li>優點：可能繞過 finalize</li>
<li>缺點：afterEvaluate 的時機本身可能更晚，屬性可能更 finalized；且 <code>:app</code> 已 evaluate 的情境會引入另一個問題（未預見）</li>
</ul>
<h4 id="c3-回滾-toolchain完全用-task-級覆寫">C3. 回滾 toolchain，完全用 task 級覆寫</h4>
<ul>
<li>優點：最保守；已驗證 work</li>
<li>缺點：放棄 toolchain 的 JDK 管理；違反節點 B 的初衷</li>
</ul>
<h3 id="選擇與理由-2">選擇與理由</h3>
<p><strong>C1</strong>。判讀中指出「<code>:app</code> 頂層時機最早所以 work」，對應的治理是「只在最早時機點設定」。C1 直接反映這個判讀。</p>
<h3 id="結果-2">結果</h3>
<p><code>flutter_broadcasts_4m</code> 繼續通過，但會遇到下一個 plugin。</p>
<h3 id="事後檢視-2">事後檢視</h3>
<p>C1 選擇正確，但<strong>支持 C1 的關鍵事實（Gradle daemon 使用 global JDK）是節點 C 當下才被建立的</strong>。若節點 B 判讀階段就補上這個事實，B 階段的「B3 設定方式」會直接選「toolchain 只設在 :app」，節點 C 不會發生。</p>
<p>這一步的決策品質問題不在節點 C，在節點 B 的判讀不完整。</p>
<hr>
<h2 id="節點-d第二個-plugin-爆了">節點 D：第二個 plugin 爆了</h2>
<h3 id="當下看到-3">當下看到</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Execution failed for task &#39;:external_display:compileDebugKotlin&#39;.
</span></span><span class="line"><span class="ln">2</span><span class="cl">&gt; detected for tasks &#39;compileDebugJavaWithJavac&#39; (1.8)
</span></span><span class="line"><span class="ln">3</span><span class="cl">  and &#39;compileDebugKotlin&#39; (17).</span></span></code></pre></div><h3 id="判讀-3">判讀</h3>
<p><strong>這類錯誤在系統中代表什麼</strong>（商業邏輯）：</p>
<p>跟節點 A 是同一類錯誤（JVM target 不一致），但要注意<strong>不一致的方向</strong>：「哪一邊高、哪一邊低」決定治理策略。</p>
<p>在覆寫第三方 plugin 的 JVM target 時，每一個 module 有兩個編譯端（Java、Kotlin），每一端都可能被 plugin 寫死或被主專案覆寫。可能的失敗組合是：</p>
<ul>
<li>Java 端被 plugin 拉低，Kotlin 端被主專案拉高 → 要覆寫 Java</li>
<li>Kotlin 端被 plugin 拉低，Java 端被主專案拉高 → 要覆寫 Kotlin</li>
<li>兩端都被 plugin 拉低 → 兩端都要覆寫</li>
</ul>
<p>訊息裡的「低的那端」就是還沒被主專案成功覆寫的那一端，也就是下一步要處理的目標。</p>
<p><strong>這次訊息具體說了什麼</strong>（CASE）：</p>
<ul>
<li>出問題的 module 換了：是 <code>:external_display</code>（不是節點 A 的 <code>:flutter_broadcasts_4m</code>）</li>
<li>方向跟節點 A <strong>相反</strong>：
<ul>
<li>節點 A：Java 17 / Kotlin 1.8（Kotlin 低）</li>
<li>現在：Java 1.8 / Kotlin 17（Java 低）</li>
</ul>
</li>
</ul>
<p><strong>從 CASE 推論的事</strong>：</p>
<ul>
<li>Kotlin 17 表示節點 A 的 <code>KotlinCompile.configureEach { jvmTarget = '17' }</code> 對 <code>:external_display</code> 也生效了 —— 這條 task 級覆寫不限於單一 plugin</li>
<li>Java 1.8 表示節點 A 的 <code>plugins.withId(&quot;com.android.library&quot;) { android { compileOptions = 17 } }</code> <strong>沒對 <code>:external_display</code> 生效</strong></li>
<li>這段覆寫對 <code>:flutter_broadcasts_4m</code> 可能生效（否則 Java 也會是 1.8），也可能是 <code>:flutter_broadcasts_4m</code> 的 Java 本來就是 17 沒被寫死</li>
<li>需要進一步確認 <code>:external_display</code> 的 <code>build.gradle</code>：是不是它自己硬寫了 <code>compileOptions = 1.8</code></li>
</ul>
<p><strong>驗證判讀（實際做了）</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">cat ~/.pub-cache/hosted/pub.dev/external_display-0.4.2+1/android/build.gradle</span></span></code></pre></div><p>確認這個 plugin <strong>兩邊都寫死 1.8</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-groovy" data-lang="groovy"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">compileOptions</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="n">sourceCompatibility</span> <span class="n">JavaVersion</span><span class="o">.</span><span class="na">VERSION_1_8</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="n">targetCompatibility</span> <span class="n">JavaVersion</span><span class="o">.</span><span class="na">VERSION_1_8</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="n">kotlinOptions</span> <span class="o">{</span> <span class="n">jvmTarget</span> <span class="o">=</span> <span class="s1">&#39;1.8&#39;</span> <span class="o">}</span></span></span></code></pre></div><p><strong>需要進一步推論的</strong>：</p>
<ul>
<li>為什麼節點 A 的 <code>plugins.withId { android { compileOptions } }</code> 沒贏過 plugin 的 <code>android { compileOptions = 1.8 }</code>？</li>
<li>猜測：<code>plugins.withId</code> 的 callback 早於 plugin 自己的 <code>android {}</code> 區塊，plugin 後寫所以蓋掉</li>
<li>但這只是猜測，還沒驗證 AGP 的同步機制</li>
</ul>
<p><strong>判讀後的問題類別</strong>：</p>
<ul>
<li>類別：跟節點 A 類似（plugin 寫死），但<strong>覆寫的方向不同</strong>——這次是 Java 端要覆寫</li>
<li>節點 A 的 Kotlin 端有 task 級工具（configureEach）可用</li>
<li>Java 端有沒有對稱的工具？這個判讀<strong>沒有完成</strong></li>
</ul>
<h3 id="可選策略-3">可選策略</h3>
<h4 id="d1-在-taskswithtypejavacompileconfigureeach-設-sourcetarget">D1. 在 <code>tasks.withType(JavaCompile).configureEach</code> 設 source/target</h4>
<ul>
<li>優點：跟節點 A 的 Kotlin 做法結構一致</li>
<li>缺點：假設 AGP 的 JavaCompile 跟 Kotlin plugin 的 KotlinCompile 機制對稱，這個假設沒驗證</li>
</ul>
<h4 id="d2-在-pluginswithid--android--compileoptions---覆寫">D2. 在 <code>plugins.withId { android { compileOptions } }</code> 覆寫</h4>
<ul>
<li>優點：用 extension 而非 task</li>
<li>缺點：這段已經在檔案內且顯然沒生效（plugin 後來的 <code>android {}</code> 蓋掉）</li>
</ul>
<h4 id="d3-用-afterevaluate-改-androidcompileoptions">D3. 用 <code>afterEvaluate</code> 改 <code>android.compileOptions</code></h4>
<ul>
<li>優點：時機晚於 plugin 自己的 <code>android {}</code>，能確實覆蓋</li>
<li>缺點：引入 afterEvaluate 的時序複雜度</li>
</ul>
<h4 id="d4-先查-agp-文件確認-javacompile-是否能用-task-級覆寫">D4. 先查 AGP 文件，確認 JavaCompile 是否能用 task 級覆寫</h4>
<ul>
<li>優點：判讀階段缺失的「Java 端機制」補完，選擇有依據</li>
<li>缺點：查證過程有不確定性</li>
</ul>
<h3 id="選擇與理由-3">選擇與理由</h3>
<p><strong>D1</strong>。理由：跟節點 A 的 Kotlin 做法對稱。</p>
<p><strong>這個選擇的本質問題在判讀階段</strong>。判讀結束時已經留下「Java 端機制未驗證」這個未完成的問題，但策略階段沒把 D4 當成補完判讀的選項，直接用「結構對稱」作為依據跳到 D1。</p>
<h3 id="結果-3">結果</h3>
<p>Build 再爆，<strong>完全一樣的錯誤</strong>。</p>
<h3 id="事後檢視-3">事後檢視</h3>
<p>D1 的失敗根源是<strong>判讀不完整時就進入策略</strong>。這跟節點 B → C 的失敗模式相同：判讀列出了需要確認的事，但沒確認就決定策略。</p>
<p>對稱假設之所以危險，是因為它<strong>用「結構相似」取代了「機制驗證」</strong>。結構相似是判讀層次的現象（訊息結構類似），機制是底層層次的事實（實作者如何設計）。用前者取代後者，判讀就沒有真正進到底層。</p>
<p>當下若把 D4 視為跟 D1 平行的選項，而且讓判讀的未完成問題成為「必須先解」的前提，會直接跳到 D4 → D3 路徑。</p>
<hr>
<h2 id="節點-e決定改用-afterevaluate--extension">節點 E：決定改用 afterEvaluate + extension</h2>
<h3 id="當下看到-4">當下看到</h3>
<p>D1 失敗，確認 AGP 會從 <code>android.compileOptions</code> 同步到 JavaCompile task。要把 Java 端的覆寫改成 extension 級，且要晚於 plugin 自己的 <code>android {}</code>。</p>
<h3 id="判讀-4">判讀</h3>
<p><strong>這類選擇在系統中代表什麼</strong>（商業邏輯）：</p>
<p>Gradle 的 <code>method(Closure)</code> 形式 API（像 <code>afterEvaluate</code>、<code>configure</code>、<code>doLast</code>）都是<strong>兩階段模型</strong>：</p>
<ol>
<li><strong>註冊階段</strong>：呼叫 <code>method(Closure)</code> 時，Gradle 把 closure 記起來，決定「什麼時候執行這個 closure」。這個註冊動作本身會立即執行，若註冊條件不滿足（例如目標物件狀態不對），註冊會直接失敗。</li>
<li><strong>執行階段</strong>：條件觸發時（例如 project evaluate 完成），Gradle 從註冊列表拿出 closure 執行。</li>
</ol>
<p>這兩個階段的失敗模式不同：註冊失敗是呼叫 <code>method</code> 本身拋錯，closure 根本不會執行；執行失敗是 closure 內部拋錯。</p>
<p>所以當我們要對 <code>method(Closure)</code> 形式 API 套用<strong>過濾條件</strong>時，要先問：過濾的對象是誰？</p>
<ul>
<li>若要過濾「延遲執行的內容」 → 條件放 closure 內</li>
<li>若要過濾「註冊動作本身是否該發生」 → 條件放 <code>method</code> 呼叫之前</li>
</ul>
<p>這不是風格偏好，是「過濾發生在不同階段」。</p>
<p><strong>這次的具體選擇空間</strong>（CASE）：</p>
<p>寫法 1：<code>afterEvaluate { if (project.name != 'app') { android { compileOptions } } }</code>
寫法 2：<code>if (project.name != 'app') { afterEvaluate { android { compileOptions } } }</code></p>
<p>表面上兩者「看起來都跳過 <code>:app</code>」。</p>
<p><strong>把商業邏輯套回 CASE 推論</strong>：</p>
<ul>
<li>寫法 1：過濾在 closure 內 → <code>afterEvaluate</code> 本身會對<strong>所有</strong> subproject 呼叫（包括 <code>:app</code>）。若 <code>:app</code> 狀態不滿足註冊條件，註冊階段就失敗</li>
<li>寫法 2：過濾在 <code>afterEvaluate</code> 外 → <code>:app</code> 根本不會觸發註冊呼叫</li>
</ul>
<p>哪種寫法正確，取決於**「註冊階段對 <code>:app</code> 會不會失敗」**。</p>
<p><strong>判讀需要問的關鍵問題</strong>：</p>
<ul>
<li><code>afterEvaluate</code> 的註冊動作會不會失敗？</li>
<li>什麼情況下會失敗？</li>
<li>「project 已 evaluate」是不是其中一種？</li>
<li><code>:app</code> 在當前專案結構下會不會是已 evaluate 狀態？</li>
</ul>
<p><strong>這些問題當下沒問</strong>。判讀停留在「兩種寫法看起來一樣」的表面層次，沒有展開到兩階段模型。</p>
<h3 id="可選策略-4">可選策略</h3>
<h4 id="e1-過濾放-closure-內">E1. 過濾放 closure 內</h4>
<ul>
<li>優點：過濾邏輯跟 closure 放一起；讀起來連貫</li>
<li>缺點：假設 afterEvaluate 方法呼叫不會失敗</li>
</ul>
<h4 id="e2-過濾放-afterevaluate-外">E2. 過濾放 afterEvaluate 外</h4>
<ul>
<li>優點：阻止 afterEvaluate 方法呼叫本身對有問題的 project 觸發</li>
<li>缺點：兩層 if 需要額外理解</li>
</ul>
<h4 id="e3-用-projectstateexecuted-判斷">E3. 用 <code>project.state.executed</code> 判斷</h4>
<ul>
<li>優點：通用解法，不 hardcode 名字</li>
<li>缺點：對這個情境過度設計</li>
</ul>
<h3 id="選擇與理由-4">選擇與理由</h3>
<p><strong>E1</strong>。理由：讀起來連貫。</p>
<p><strong>這個選擇的本質問題</strong>：判讀沒展開「方法呼叫 vs closure 執行」的兩階段，所以權衡時用「可讀性」這個表面維度決定，沒有觸及「哪個寫法能阻止失敗」這個底層維度。</p>
<h3 id="結果-4">結果</h3>
<p>Build 炸：<code>Cannot run Project.afterEvaluate(Closure) when the project is already evaluated.</code></p>
<h3 id="事後檢視-4">事後檢視</h3>
<p>E1 vs E2 的真正差異不是「哪個好讀」，是<strong>過濾哪一個執行階段</strong>：</p>
<ul>
<li>E1 過濾延遲執行的 closure 內容</li>
<li>E2 過濾方法呼叫本身</li>
</ul>
<p>判讀若展開到這個層次，權衡就會變成：「我要過濾的是哪一個階段？」——而這題有明確答案（<code>:app</code> 的失敗發生在方法呼叫階段），所以 E2 是唯一正確選項。</p>
<p>判讀不到這個層次 → 兩個選項在決策者眼中「等價」→ 用次要維度（可讀性）決定。</p>
<hr>
<h2 id="節點-fcannot-run-afterevaluate-when-already-evaluated">節點 F：<code>Cannot run afterEvaluate when already evaluated</code></h2>
<h3 id="當下看到-5">當下看到</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Cannot run Project.afterEvaluate(Closure) when the project is already evaluated.</span></span></code></pre></div><h3 id="判讀-5">判讀</h3>
<p><strong>這類錯誤在系統中代表什麼</strong>（商業邏輯）：</p>
<p>Gradle 的 project 有生命週期：建立 → 配置中 → <strong>evaluate 完成</strong> → 執行 task。一旦 project 走到「evaluate 完成」狀態，有些動作就再也做不了，因為它們的意義依賴於「evaluate 還沒結束」這個前提。</p>
<p><code>afterEvaluate</code> 是一種「訂閱 evaluate 完成事件」的 API：註冊一個 closure，Gradle 承諾在該 project evaluate 完成時呼叫它。</p>
<p>但如果 project <strong>已經</strong> evaluate 完成，這個承諾無法兌現 — 「evaluate 完成」這個事件已經發生過了，不會再發生第二次。此時再註冊訂閱沒有意義，Gradle 直接拋錯。</p>
<p>所以 <code>Cannot run Project.afterEvaluate(Closure) when the project is already evaluated</code> 這類錯誤的本質是：<strong>想訂閱一個已經發生過的事件</strong>。</p>
<p><strong>這次訊息具體說了什麼</strong>（CASE）：</p>
<ul>
<li><code>afterEvaluate(Closure)</code> 這個方法呼叫失敗</li>
<li>失敗原因：目標 project 已經 evaluate 完</li>
<li>位置：root <code>build.gradle</code> line 52（<code>afterEvaluate</code> 那行）</li>
</ul>
<p><strong>從 CASE 推論的事</strong>：</p>
<ul>
<li>「已 evaluate 完的 project」具體是哪個？訊息沒指名，但從上下文推論：</li>
<li>回頭看 root <code>build.gradle</code> 上半部有 <code>subprojects { project.evaluationDependsOn(&quot;:app&quot;) }</code></li>
<li>這行強制 <code>:app</code> 比其他 subproject 先 evaluate</li>
<li>當 <code>subprojects {}</code> 的區塊處理到 <code>:app</code> 時，<code>:app</code> 的 evaluate 已完成 → 對它呼叫 <code>afterEvaluate</code> 失敗</li>
</ul>
<p><strong>完整推論鏈</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">subprojects {} 執行 → 對 :app 呼叫 afterEvaluate(Closure)
</span></span><span class="line"><span class="ln">2</span><span class="cl">→ :app 已 evaluate（因 evaluationDependsOn）→ 訂閱失敗</span></span></code></pre></div><p><strong>判讀後的問題類別</strong>：</p>
<ul>
<li>類別：訂閱了一個已發生的事件（註冊時機晚於事件觸發）</li>
<li>解決方向：阻止註冊動作對該對象觸發</li>
</ul>
<h3 id="可選策略-5">可選策略</h3>
<h4 id="f1-把-projectname--app-提前到-afterevaluate-外">F1. 把 <code>project.name != 'app'</code> 提前到 afterEvaluate 外</h4>
<ul>
<li>優點：直接阻止方法呼叫對 <code>:app</code> 觸發</li>
<li>缺點：hardcode 名字；若 <code>:app</code> 改名需修</li>
</ul>
<h4 id="f2-用-projectstateexecuted-條件">F2. 用 <code>project.state.executed</code> 條件</h4>
<ul>
<li>優點：通用，不依賴名字</li>
<li>缺點：過度設計；<code>:app</code> 本來就不需要 subprojects 邏輯管</li>
</ul>
<h4 id="f3-trycatch-吞掉註冊失敗">F3. <code>try/catch</code> 吞掉註冊失敗</h4>
<ul>
<li>優點：程式碼最少</li>
<li>缺點：anti-pattern，隱藏失敗</li>
</ul>
<h3 id="選擇與理由-5">選擇與理由</h3>
<p><strong>F1</strong>。F3 是反模式；F2 的通用性在此情境無實際收益。</p>
<h3 id="結果-5">結果</h3>
<p>Build 成功。</p>
<h3 id="事後檢視-5">事後檢視</h3>
<p>F1 選擇正確。但這個節點若在 E 階段判讀「方法呼叫 vs closure 執行」兩階段時就識別出來，<strong>F 節點本來不會存在</strong>。F 是 E 判讀不完整的延伸結果。</p>
<hr>
<h2 id="節點-g最終修復">節點 G：最終修復</h2>
<ul>
<li><code>:app/build.gradle</code>：<code>kotlin { jvmToolchain(17) }</code></li>
<li><code>android/settings.gradle</code>：Foojay plugin</li>
<li><code>android/build.gradle</code> subprojects：
<ul>
<li>Java 端 <code>afterEvaluate</code> 改 <code>android.compileOptions</code>（跳過 <code>:app</code>）</li>
<li>Kotlin 端 <code>KotlinCompile.configureEach</code></li>
</ul>
</li>
</ul>
<hr>
<h2 id="把判讀當成獨立階段的意義">把「判讀」當成獨立階段的意義</h2>
<p>回看七個節點中四個失敗節點的<strong>失敗來源</strong>：</p>
<table>
  <thead>
      <tr>
          <th>節點</th>
          <th>失敗類別</th>
          <th>根本來源</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>節點 C</td>
          <td>需要新資訊（toolchain 時機）</td>
          <td>節點 B 判讀留下「需要確認」但沒補</td>
      </tr>
      <tr>
          <td>節點 D1</td>
          <td>對稱假設</td>
          <td>節點 D 判讀用「結構對稱」取代「機制驗證」</td>
      </tr>
      <tr>
          <td>節點 F</td>
          <td>方法呼叫時機</td>
          <td>節點 E 判讀沒展開 API 的兩階段行為</td>
      </tr>
  </tbody>
</table>
<p><strong>三個失敗都源自判讀未完成</strong>。不是策略選錯，是策略階段進入時，判讀本身還帶著未解決的問題。</p>
<p>如果把判讀當成獨立階段，並且<strong>要求判讀階段的所有「需確認」項目在進入策略前都被解答</strong>，這三個失敗都可以避免。</p>
<h3 id="判讀完成的標準">判讀完成的標準</h3>
<p>一個合理的判讀完成標準：</p>
<ol>
<li><strong>字面事實都列出來</strong>：訊息裡出現的 task、file、line、屬性名都提取</li>
<li><strong>推論標示</strong>：哪些是從字面事實推論出來的（而非訊息直接寫的）</li>
<li><strong>未確認的問題列清單</strong>：判讀過程中發現「需要進一步確認」的問題，不迴避</li>
<li><strong>未確認的問題在進入策略前解答</strong>：或明確決定「這個問題可以先忽略，理由是&hellip;」</li>
</ol>
<p>多數失敗不是在策略階段「選錯」，是在判讀跟策略之間<strong>帶著未解問題跨界</strong>。</p>
<hr>
<h2 id="整個過程的決策品質檢視">整個過程的決策品質檢視</h2>
<h3 id="七個節點四次失敗的分類">七個節點四次失敗的分類</h3>
<p><strong>判讀未完成延伸類（三個）</strong>：</p>
<ul>
<li>節點 C（來自 B 的判讀）</li>
<li>節點 D1（來自 D 的判讀）</li>
<li>節點 F（來自 E 的判讀）</li>
</ul>
<p><strong>策略階段發現需要新資訊類（零個）</strong>：</p>
<ul>
<li>所有失敗都可追溯到判讀階段已知的未解問題</li>
</ul>
<p><strong>偶然類（零個）</strong>：</p>
<ul>
<li>本次沒有真正「不可預見」的失敗</li>
</ul>
<h3 id="可複用的三個原則">可複用的三個原則</h3>
<h4 id="原則-1觀察--判讀--策略--執行-是四個獨立階段">原則 1：觀察 → 判讀 → 策略 → 執行 是四個獨立階段</h4>
<p>每個階段的目的不同：</p>
<ul>
<li>觀察：把訊息讀清楚</li>
<li>判讀：從訊息推出問題本質，列出所有已知、已推論、未確認的事實</li>
<li>策略：基於判讀推導選項並權衡</li>
<li>執行：實際動作</li>
</ul>
<p>跳過判讀 → 策略基於不完整資訊；跳過策略 → 執行是直覺反應。</p>
<h4 id="原則-2判讀階段的未解問題是進入策略的阻擋條件">原則 2：判讀階段的未解問題是進入策略的阻擋條件</h4>
<p>判讀中標示「需要確認」的問題，要麼在進入策略前補完，要麼明確決定「可以忽略，理由是&hellip;」。不能帶著未解問題進策略。</p>
<h4 id="原則-3單點成功後擴大觀察範圍">原則 3：單點成功後擴大觀察範圍</h4>
<p>每個節點結束後，判讀應擴展：「還有哪些地方可能有同類問題？」當前修復是否涵蓋全部，還是只涵蓋當前這一個？</p>
<hr>
<h2 id="整體節點地圖">整體節點地圖</h2>





<pre tabindex="0"><code class="language-mermaid" data-lang="mermaid">flowchart TD
    A[節點 A: flutter_broadcasts_4m 1.8] --&gt;|task 級覆寫| B[節點 B: 換 toolchain?]
    B --&gt;|subprojects 套 toolchain| C[節點 C: languageVersion final]
    C --&gt;|只 :app toolchain| D[節點 D: external_display Java 1.8]
    D --&gt;|對稱 task 級 JavaCompile| D1[仍失敗]
    D1 --&gt;|換 afterEvaluate extension| E[節點 E: closure 內過濾 :app]
    E --&gt;|afterEvaluate 炸 :app| F[節點 F: already evaluated]
    F --&gt;|把過濾提前| G[節點 G: 成功]

    style A fill:#e0f0ff
    style G fill:#d0ffd0
    style C fill:#ffe0e0
    style D1 fill:#ffe0e0
    style F fill:#ffe0e0</code></pre><p>三個紅色失敗節點的共同特徵：<strong>前一節點的判讀留下「需要確認」但沒確認就進策略</strong>。決策品質的提升點不在策略選擇，在判讀的完整度與「未解問題不跨界進策略」的紀律。</p>
]]></content:encoded></item><item><title>為什麼 Bug 在合併後才爆：Gradle Cache 掩蓋潛伏問題的邏輯</title><link>https://tarrragon.github.io/blog/work-log/%E7%82%BA%E4%BB%80%E9%BA%BC-bug-%E5%9C%A8%E5%90%88%E4%BD%B5%E5%BE%8C%E6%89%8D%E7%88%86gradle-cache-%E6%8E%A9%E8%93%8B%E6%BD%9B%E4%BC%8F%E5%95%8F%E9%A1%8C%E7%9A%84%E9%82%8F%E8%BC%AF/</link><pubDate>Fri, 17 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/%E7%82%BA%E4%BB%80%E9%BA%BC-bug-%E5%9C%A8%E5%90%88%E4%BD%B5%E5%BE%8C%E6%89%8D%E7%88%86gradle-cache-%E6%8E%A9%E8%93%8B%E6%BD%9B%E4%BC%8F%E5%95%8F%E9%A1%8C%E7%9A%84%E9%82%8F%E8%BC%AF/</guid><description>&lt;h2 id="問題情境">問題情境&lt;/h2>
&lt;p>一個典型描述：&lt;/p>
&lt;blockquote>
&lt;p>「我在 feature branch 開發都沒問題，合併到 main 之後 build 就爆了。但合併前 main 也沒這個錯誤。」&lt;/p>&lt;/blockquote>
&lt;p>直覺反應會是「合併帶進來什麼壞東西」，但實際除錯後會發現：&lt;strong>根因在幾個月前就存在，合併只是觸發條件&lt;/strong>。&lt;/p>
&lt;hr>
&lt;h2 id="先檢查直覺真的是這次合併造成的嗎">先檢查直覺：真的是這次合併造成的嗎？&lt;/h2>
&lt;h3 id="步驟-1確認根因-commit">步驟 1：確認根因 commit&lt;/h3>
&lt;p>看具體錯誤訊息。例如 JVM target inconsistency，去找兩個關鍵時間點：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># JVM target 升級的 commit&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">git log --oneline --all -p -- android/app/build.gradle &lt;span class="p">|&lt;/span> grep -B1 &lt;span class="s2">&amp;#34;jvmTarget&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1"># Kotlin plugin 版本升級的 commit&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">git log --oneline --all -p -- android/settings.gradle &lt;span class="p">|&lt;/span> grep -B1 &lt;span class="s2">&amp;#34;kotlin&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="c1"># 問題 plugin 引入的 commit&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">git log --all --oneline -p -S &lt;span class="s2">&amp;#34;problematic_plugin&amp;#34;&lt;/span> -- pubspec.yaml&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>三個時間點疊起來就能看出地雷是什麼時候埋下的。&lt;/p>
&lt;h3 id="步驟-2確認地雷埋好後有幾次成功-build">步驟 2：確認地雷埋好後有幾次成功 build&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">git log --since&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&amp;lt;地雷埋下的日期&amp;gt;&amp;#34;&lt;/span> --oneline -- android/&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>如果清單裡有好幾個 commit，其中有些是 CI 或本地曾經成功 build 的，代表&lt;strong>地雷埋下後確實 build 過、卻沒炸&lt;/strong>。這就是 cache 掩蓋的證據。&lt;/p>
&lt;h3 id="步驟-3確認合併帶進的改動">步驟 3：確認合併帶進的改動&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">git show --stat &amp;lt;合併 commit&amp;gt;&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>看改到什麼檔案。關鍵檢查：&lt;/p>
&lt;ul>
&lt;li>&lt;code>pubspec.lock&lt;/code>、&lt;code>pubspec.yaml&lt;/code> → 會讓 Gradle 重新 resolve 依賴&lt;/li>
&lt;li>&lt;code>android/*.gradle&lt;/code> → 直接改 build script&lt;/li>
&lt;li>&lt;code>.gradle/&lt;/code> 或 &lt;code>build/&lt;/code> 目錄被清過 → cache 失效&lt;/li>
&lt;/ul>
&lt;p>這三類任何一項存在都可能打破 configuration cache。&lt;/p>
&lt;hr>
&lt;h2 id="gradle-的四層快取掩蓋機制">Gradle 的四層快取掩蓋機制&lt;/h2>
&lt;h3 id="四層-cache-各自掩蓋什麼">四層 cache 各自掩蓋什麼&lt;/h3>





&lt;pre tabindex="0">&lt;code class="language-mermaid" data-lang="mermaid">flowchart TD
 Build[一次 build] --&amp;gt; C1[Configuration cache]
 C1 --&amp;gt;|命中| Skip1[跳過 configuration 階段]
 C1 --&amp;gt;|miss| C2[Task up-to-date 檢查]
 C2 --&amp;gt;|up-to-date| Skip2[跳過 task execution]
 C2 --&amp;gt;|需執行| C3[Build cache]
 C3 --&amp;gt;|命中| Skip3[reuse 之前的 output]
 C3 --&amp;gt;|miss| C4[Incremental compilation]
 C4 --&amp;gt;|小改| Skip4[只編改動部分]
 C4 --&amp;gt;|大改| Full[完整編譯]&lt;/code>&lt;/pre>&lt;p>&lt;strong>每一層都能掩蓋不同的問題&lt;/strong>：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Cache&lt;/th>
 &lt;th>掩蓋的情境&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Configuration cache&lt;/td>
 &lt;td>跳過 build script 重跑，所以 &lt;code>tasks.withType(...)&lt;/code> 內的 validation 不會再跑&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Task up-to-date&lt;/td>
 &lt;td>plugin 的 &lt;code>.class&lt;/code> 已存在，整個 compile task skip，validation 也跳過&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Build cache&lt;/td>
 &lt;td>從其他機器或之前的 build 拉 output，完全不編譯&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Incremental&lt;/td>
 &lt;td>只編改動的 source 檔，新加的 validation 若沒影響到改動檔就不觸發&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="cache-失效的觸發條件">Cache 失效的觸發條件&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Cache&lt;/th>
 &lt;th>失效 trigger&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Configuration cache&lt;/td>
 &lt;td>build script 改動、依賴 resolution 結果變、Gradle 版本變&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Task up-to-date&lt;/td>
 &lt;td>input 檔改動、task 的 configuration 改動&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Build cache&lt;/td>
 &lt;td>cache key 改（input hash 變）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Incremental&lt;/td>
 &lt;td>compiler 認為需要重跑&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;code>pubspec.lock&lt;/code> 改動會打破 configuration cache 和 dependency resolution cache，這就是合併後最常見的引爆點。&lt;/p></description><content:encoded><![CDATA[<h2 id="問題情境">問題情境</h2>
<p>一個典型描述：</p>
<blockquote>
<p>「我在 feature branch 開發都沒問題，合併到 main 之後 build 就爆了。但合併前 main 也沒這個錯誤。」</p></blockquote>
<p>直覺反應會是「合併帶進來什麼壞東西」，但實際除錯後會發現：<strong>根因在幾個月前就存在，合併只是觸發條件</strong>。</p>
<hr>
<h2 id="先檢查直覺真的是這次合併造成的嗎">先檢查直覺：真的是這次合併造成的嗎？</h2>
<h3 id="步驟-1確認根因-commit">步驟 1：確認根因 commit</h3>
<p>看具體錯誤訊息。例如 JVM target inconsistency，去找兩個關鍵時間點：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># JVM target 升級的 commit</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git log --oneline --all -p -- android/app/build.gradle <span class="p">|</span> grep -B1 <span class="s2">&#34;jvmTarget&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># Kotlin plugin 版本升級的 commit</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">git log --oneline --all -p -- android/settings.gradle <span class="p">|</span> grep -B1 <span class="s2">&#34;kotlin&#34;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"># 問題 plugin 引入的 commit</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">git log --all --oneline -p -S <span class="s2">&#34;problematic_plugin&#34;</span> -- pubspec.yaml</span></span></code></pre></div><p>三個時間點疊起來就能看出地雷是什麼時候埋下的。</p>
<h3 id="步驟-2確認地雷埋好後有幾次成功-build">步驟 2：確認地雷埋好後有幾次成功 build</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git log --since<span class="o">=</span><span class="s2">&#34;&lt;地雷埋下的日期&gt;&#34;</span> --oneline -- android/</span></span></code></pre></div><p>如果清單裡有好幾個 commit，其中有些是 CI 或本地曾經成功 build 的，代表<strong>地雷埋下後確實 build 過、卻沒炸</strong>。這就是 cache 掩蓋的證據。</p>
<h3 id="步驟-3確認合併帶進的改動">步驟 3：確認合併帶進的改動</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git show --stat &lt;合併 commit&gt;</span></span></code></pre></div><p>看改到什麼檔案。關鍵檢查：</p>
<ul>
<li><code>pubspec.lock</code>、<code>pubspec.yaml</code> → 會讓 Gradle 重新 resolve 依賴</li>
<li><code>android/*.gradle</code> → 直接改 build script</li>
<li><code>.gradle/</code> 或 <code>build/</code> 目錄被清過 → cache 失效</li>
</ul>
<p>這三類任何一項存在都可能打破 configuration cache。</p>
<hr>
<h2 id="gradle-的四層快取掩蓋機制">Gradle 的四層快取掩蓋機制</h2>
<h3 id="四層-cache-各自掩蓋什麼">四層 cache 各自掩蓋什麼</h3>





<pre tabindex="0"><code class="language-mermaid" data-lang="mermaid">flowchart TD
    Build[一次 build] --&gt; C1[Configuration cache]
    C1 --&gt;|命中| Skip1[跳過 configuration 階段]
    C1 --&gt;|miss| C2[Task up-to-date 檢查]
    C2 --&gt;|up-to-date| Skip2[跳過 task execution]
    C2 --&gt;|需執行| C3[Build cache]
    C3 --&gt;|命中| Skip3[reuse 之前的 output]
    C3 --&gt;|miss| C4[Incremental compilation]
    C4 --&gt;|小改| Skip4[只編改動部分]
    C4 --&gt;|大改| Full[完整編譯]</code></pre><p><strong>每一層都能掩蓋不同的問題</strong>：</p>
<table>
  <thead>
      <tr>
          <th>Cache</th>
          <th>掩蓋的情境</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Configuration cache</td>
          <td>跳過 build script 重跑，所以 <code>tasks.withType(...)</code> 內的 validation 不會再跑</td>
      </tr>
      <tr>
          <td>Task up-to-date</td>
          <td>plugin 的 <code>.class</code> 已存在，整個 compile task skip，validation 也跳過</td>
      </tr>
      <tr>
          <td>Build cache</td>
          <td>從其他機器或之前的 build 拉 output，完全不編譯</td>
      </tr>
      <tr>
          <td>Incremental</td>
          <td>只編改動的 source 檔，新加的 validation 若沒影響到改動檔就不觸發</td>
      </tr>
  </tbody>
</table>
<h3 id="cache-失效的觸發條件">Cache 失效的觸發條件</h3>
<table>
  <thead>
      <tr>
          <th>Cache</th>
          <th>失效 trigger</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Configuration cache</td>
          <td>build script 改動、依賴 resolution 結果變、Gradle 版本變</td>
      </tr>
      <tr>
          <td>Task up-to-date</td>
          <td>input 檔改動、task 的 configuration 改動</td>
      </tr>
      <tr>
          <td>Build cache</td>
          <td>cache key 改（input hash 變）</td>
      </tr>
      <tr>
          <td>Incremental</td>
          <td>compiler 認為需要重跑</td>
      </tr>
  </tbody>
</table>
<p><code>pubspec.lock</code> 改動會打破 configuration cache 和 dependency resolution cache，這就是合併後最常見的引爆點。</p>
<hr>
<h2 id="為什麼-kotlin-22-的-validation-會被-cache-掩蓋">為什麼 Kotlin 2.2 的 validation 會被 cache 掩蓋</h2>
<p>這次的具體案例：</p>
<ol>
<li><strong>T1</strong>：專案初始化，引入 <code>flutter_broadcasts_4m</code>，plugin 的 <code>build.gradle</code> 硬寫 <code>jvmTarget = '1.8'</code></li>
<li><strong>T2</strong>：升級 Kotlin 1.8.22 → 2.2.10（strict validation 從此 enabled）</li>
<li><strong>T3</strong>：升級 <code>:app</code> 的 JVM target 1.8 → 17</li>
</ol>
<p>從 T3 開始，理論上每次 build 都應該觸發 validation 炸掉。但實際上：</p>
<ul>
<li>升級當下的 build：可能在本地用 <code>./gradlew --stop</code> 重啟過 daemon，有一次完整 configuration，validation 觸發 → 但因為「一次」而工程師沒記錄下來</li>
<li>更可能：升級時恰好在 CI 跑過一次綠燈（因為 CI cache），之後所有 local build 都吃 configuration cache 跳過 validation</li>
</ul>
<p>後續幾個月：</p>
<ul>
<li>每次 build 靠 configuration cache 或 task up-to-date 跳過 validation</li>
<li>地雷一直存在但看不見</li>
<li>合併 PR 改到 <code>pubspec.lock</code> → configuration cache 失效 → validation 終於被執行 → 爆炸</li>
</ul>
<hr>
<h2 id="診斷流程">診斷流程</h2>
<h3 id="步驟-1判斷根因vs觸發條件">步驟 1：判斷「根因」vs「觸發條件」</h3>
<p>錯誤訊息說的是<strong>當下的症狀</strong>，不一定是真正的根因。用 git log 回溯：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 找寫死有問題設定的 plugin 是何時引入的</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">git log --all -p -S <span class="s2">&#34;jvmTarget = &#39;1.8&#39;&#34;</span> -- pubspec.yaml
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 找讓 strict validation 生效的配置變更</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">git log --all -p -- android/settings.gradle</span></span></code></pre></div><p>如果這些 commit 都比當前合併早很多，就能確認「根因早存在，合併只是觸發」。</p>
<h3 id="步驟-2判斷-cache-類型">步驟 2：判斷 cache 類型</h3>
<p>執行無快取 build，看錯誤會不會重現：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">./gradlew clean
</span></span><span class="line"><span class="ln">2</span><span class="cl">./gradlew --stop                           <span class="c1"># 停掉 daemon</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">rm -rf .gradle build                       <span class="c1"># 清 project-level cache</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># ~/.gradle/caches/ 也可以清但會很慢</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">flutter clean
</span></span><span class="line"><span class="ln">6</span><span class="cl">flutter build apk --no-build-cache</span></span></code></pre></div><p>如果這樣 build 還會爆 → 確認是真實問題，不是 cache 偶發
如果這樣 build 不會爆 → cache 掩蓋的真實問題已被解決，之前只是殘留 state 問題</p>
<h3 id="步驟-3驗證修復後不會復發">步驟 3：驗證修復後不會復發</h3>
<p>修復後，在<strong>乾淨環境</strong>下跑過一次完整 build：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">flutter clean
</span></span><span class="line"><span class="ln">2</span><span class="cl">rm -rf ~/.pub-cache/hosted/pub.dev/&lt;problem_plugin&gt;-*
</span></span><span class="line"><span class="ln">3</span><span class="cl">flutter pub get
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nb">cd</span> android <span class="o">&amp;&amp;</span> ./gradlew clean <span class="o">&amp;&amp;</span> ./gradlew build</span></span></code></pre></div><p>避免「修好但實際還是靠 cache 蓋著」的假綠燈。</p>
<hr>
<h2 id="防禦讓潛伏問題提早暴露">防禦：讓潛伏問題提早暴露</h2>
<h3 id="方法-1ci-定期跑無快取-build">方法 1：CI 定期跑無快取 build</h3>
<p>排程一週一次的 CI job，跑完整清除 cache 後的 build：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln">1</span><span class="cl"><span class="c"># 偽 CI 腳本</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w"></span>- <span class="l">flutter clean</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w"></span>- <span class="l">rm -rf ~/.gradle/caches/modules-2/metadata-*</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w"></span>- <span class="l">cd android &amp;&amp; ./gradlew --no-configuration-cache --no-build-cache clean assembleDebug</span></span></span></code></pre></div><p>這樣 catch 到的錯誤通常比開發者自己遇到早一週到一個月，能在觸發條件（合併、升級）發生之前就看到。</p>
<h3 id="方法-2升級依賴時強制全量驗證">方法 2：升級依賴時強制全量驗證</h3>
<p>每次升 Flutter、AGP、Kotlin plugin 版本時，遵守以下流程：</p>
<ol>
<li>建立升級分支</li>
<li>升級前先 <code>flutter clean</code> + <code>./gradlew clean</code></li>
<li>升級後再跑一次無 cache build</li>
<li>確認綠燈才合併</li>
</ol>
<p>這一步常被忽略，因為「升版本的 PR 通常 diff 很小，看起來不會壞什麼」。但 Gradle 的 strict validation 規則通常就藏在這些小升級裡。</p>
<hr>
<h2 id="除錯思維的關鍵切換">除錯思維的關鍵切換</h2>
<p>看到「branch 上沒事、merge 後爆」這類時序弔詭時：</p>
<p><strong>不要先想「這次合併改了什麼造成問題」</strong>
→ 容易把時間花在閱讀無關的 diff</p>
<p><strong>要先想「是不是有什麼東西一直被 cache 蓋著」</strong>
→ 把 cache 當成嫌疑人，去找觸發條件</p>
<p>通常結論都會是：<strong>根因在幾個月前埋下，cache 蓋了很久，這次合併剛好扣扳機</strong>。</p>
<p>把這個思維框架套用在其他類似症狀上也成立：</p>
<ul>
<li>CI 一直綠燈，某次合併後才紅 → CI 的 cache 在那次被打破</li>
<li>某個開發者電腦上沒事，別人電腦上爆 → 兩台機器的 cache state 不同步</li>
<li>升級後立刻 build 綠，過幾天才出問題 → 那幾天有某個動作打破了 cache</li>
</ul>
]]></content:encoded></item></channel></rss>