iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0

模組六|測試、效能與上線(Day 26–30)

昨天講完測試的分工,也講了 CI 只跑四個命令。今天講一張比測試更早存在的東西:效能預算表。

這個專案在寫任何一行遊戲程式之前,PRD.md 就有一節叫「效能預算」,十二列,每列都有目標值與警戒線。它確實反過來決定了架構——Day 14 的節點上限、Day 17 的物件池、Day 15 為什麼不用柔性繩索,答案全都在那張表上。

結論先講:這張表決定了架構,卻沒有決定過任何一次優化——因為它一次都沒被拿去對照過實測。 十二列裡,到 2026-08-07 為止有有效量測值的是零列。這篇要講的就是這件事本身,以及一個更難堪的發現:我後來補的量測工具,第一版說了謊。


十二列的原文

PRD.md:2044 的「效能預算」一節,前置語就把它的性質寫清楚了:「以下是專案內部驗收預算,不是框架保證值。」

指標 目標 警戒線
桌機 FPS 穩定 60 低於 55
中階手機 FPS 大多數時間 50–60 持續低於 45
單幀 P95 小於 20 ms 超過 25 ms
Matter 更新 P95 小於 8 ms 超過 12 ms
蜜蜂同時數 16 24 硬上限
Matter Body 總數 建議低於 100 140 硬上限
Constraint 數 MVP 近乎 0 50 硬上限
防線節點 建議 24–48 64 硬上限
初始核心傳輸量 壓縮後低於 3 MB 超過 5 MB
首頁可互動時間 中階手機 Wi-Fi 下約 2.5 秒內 超過 4 秒
關卡切換 已快取素材時低於 300 ms 超過 800 ms
一小時 Soak Test 無持續記憶體成長 每次重試明顯增加

兩個地方值得單獨講。

第一個是用 P95 不用平均值。平均 16 ms 但每秒卡一次 60 ms,玩起來就是頓;平均值會把這件事藏起來。這條選擇沒有成本,就是寫表格時多打三個字元。

第二個是**「Constraint 數:MVP 近乎 0」那一列,就是 Day 15 的答案**。柔性繩索的做法是每兩個節點之間掛一個 Constraint,一條四十節點的線就是三十九個,直接把這一列打爆。Day 15 那個「為什麼不用繩索」的決定不是寫到那裡才想的——它在寫程式之前就被一張表關掉了,歸檔規格裡那句 MUST NOT be modeled as a flexible constraint chain in the MVP 只是把它抄成驗收條件。


這張表反過來決定了什麼

大部分人的順序是「覺得卡了才去查」。這個專案前半段做對了:先訂數字,再讓數字往回長成程式碼裡的常數。

預算列 決定了什麼 落在哪
蜜蜂同時數 16/硬上限 24 蜂巢數量與生成間隔(Day 17) src/config/game.js:63-64BEE_SOFT_LIMIT = 16BEE_HARD_LIMIT = 24
Body 總數建議 < 100 防線節點上限 64(Day 14) 抽稀與節點上限的參數
Constraint 近乎 0 不用柔性繩索(Day 15) 歸檔規格的一句 MUST NOT

這是預算表最實在的價值:它把「要多快」翻譯成「所以只能有幾個東西」,而後者可以寫進 config 檔。


十二列裡,有自動檢查在守的是一列

實查結果:只有「Matter Body 總數低於 100」那一列有東西在斷言它——tests/e2e/smoke.spec.js:104level-outcome.spec.js:60 各一句 expect(...matterBodyCount).toBeLessThan(100)

其餘十一列沒有任何工具能驗。

而且這一列的守護還要再打一次折。昨天講過,E2E 不在 CI 的 verify job 裡——deploy.yml 只跑 assets:validatelinttest:unitbuild。所以嚴格按「會讓 CI 失敗」這個定義,十二列有零列被守著;那一列 body 上限的斷言,只有在我自己想起來跑 npm run test:e2e 的時候才會執行。


工具是昨天才做好的

我要把這件事寫在最前面,因為它會決定你怎麼讀後面的一切:整個 MVP 開發期間,這個專案沒有任何 FPS 計數器、沒有幀時間統計、沒有 debug HUD。

HUD 是 2026-08-07 才補的,?debug=1 才啟用,新增兩個檔:src/core/FrameStats.js(量測)與 src/ui/DebugHud.js(顯示)。它顯示 FPS、單幀耗時的平均與最糟、body 數、蜜蜂數。

兩個設計都是為了「量測工具不要污染被量測的東西」:

  • 環形緩衝、零每幀配置。樣本存在預先配置好的 Float64Array 裡——這東西每幀記錄一次,而每幀配置一個物件正是效能 HUD 最不該引進的東西。
  • DOM overlay 而不是 Pixi 圖層。不佔用 750×1334 的座標系、不進入渲染同步要走訪的那棵樹、換場景時不必每個場景自己掛一份。另加 pointer-events: none——HUD 絕不能吃掉一筆畫線。

量測工具說謊:worst gap 永遠正好是 100.0ms

我每張紀錄的記號都落在同一個高度,因為木環上面一直有一根釘子擋著

這是我在這個專案裡最喜歡的一個 bug。

HUD 第一版直接記錄 ticker.deltaMS。開瀏覽器跑起來,「最糟幀間隔」這個數字永遠正好是 100.0ms。不是 99.7,不是 101.3,是每次都剛好 100.0。

任何一個數字剛好卡在整數上不動,它就不是量出來的,是被夾出來的。翻 PixiJS 的原始碼(pixi.js/lib/ticker/Ticker.mjs):

  • :110this._maxElapsedMS = 100
  • :425-426if (elapsedMS > this._maxElapsedMS) { elapsedMS = this._maxElapsedMS }
  • :497get minFPS() { return 1000 / this._maxElapsedMS }

也就是 minFPS 預設 10,等價於「兩幀之間最多算 100 毫秒」。一次 400 毫秒的卡頓,deltaMS 會誠實地告訴你「100 毫秒」。

這個夾取本身是對的——迴圈需要它,否則切回分頁那一瞬間會累積出幾千毫秒要補算,直接死亡螺旋(Day 10 講過)。錯的是我拿一個為了保護模擬而存在的數字,去回答「裝置實際交出了什麼」。

修法是在 tick() 裡自己用 performance.now() 量前後兩幀的起始時間差,跟給模擬用的那個夾取後的值分開:

// src/core/GameLoop.js
const wallClockDeltaMs =
  this.lastTickStartedAt === null
    ? ticker.deltaMS
    : startedAt - this.lastTickStartedAt

this.lastTickStartedAt = startedAt

const frameMs = Math.min(ticker.deltaMS, this.maxFrameMs)
this.accumulatorMs += frameMs

// ...fixed-step 迴圈與 updateFrame...

// Record the raw delta, not the clamped `frameMs`: a 400 ms stall must show
// up as a 400 ms stall, otherwise the HUD would report the frame budget we
// pretend to have instead of the one the device delivered.
this.frameStats.record({
  deltaMs: wallClockDeltaMs,
  durationMs: this.now() - startedAt
})

改完之後,同一組操作量到的最糟幀間隔是 528.7 ms 與 409.9 ms

還有一層巧合值得標出來:專案自己的 src/config/game.js:5 也寫了 MAX_FRAME_MS = 100。同一個 100 毫秒,是兩道各自獨立、剛好同值的夾取。這種巧合最難查,因為你把自己那道拿掉,數字還是 100。

你以為在量裝置,其實在量框架的預設值。 而且這件事單元測試抓不到——測試餵什麼 delta 進去,它就記什麼;夾取發生在測試邊界之外。是開瀏覽器盯著那個不動的 100.0 才發現的。


一條架構規則決定了量測程式碼能放哪

補 HUD 的時候撞到一道自己設的閘門。

tests/unit/helpers/readCode.js:8 定義了一個正規表達式 WALL_CLOCK,比對 Date.nowperformance.now。三個單元測試用它直接把原始碼讀成字串,斷言 BeeSystemBeeCountdownSystemCollisionSystem 這幾個檔案不准出現這兩個 API——遊戲邏輯只能吃固定時間步長,不能吃真實時鐘。

所以幀時間量測不可能寫在模擬層。它只能落在 GameLoop.js(真的負責跟瀏覽器對接的那一層)與 HUD 自己的新檔。

我原本覺得這是麻煩,實際上它把量測推到了正確的位置:唯一有資格量「我們自己的 tick 用掉多少時間」的,就是包住那個 tick 的那一層。 一條為了決定性而設的規則,順手替我做了一個分層決定。


Soak Test 那一列,做到了一半

十二列的最後一列是「一小時 Soak Test,無持續記憶體成長」。一小時的實機 soak 我沒做,但可以自動化的那一半做了(tests/unit/leakCycles.test.js):重試 50 次,每次 destroy 之後 Matter world 的 bodies.length 都是 0、殘留 listener 都是 0;切場景 50 次,stage.children 恆為 1。

測試檔的註解把它的邊界寫死了,我直接照抄:

/**
 * Leak budget for the retry loop. The tasks list asks for "retry fifty times
 * and record the body count"; the honest automated version of that is to assert
 * the count comes back to the same place every time rather than trusting a
 * single eyeballed number.
 *
 * These run headless, so they prove the physics/system layer releases what it
 * allocates. They do not prove the browser reclaims Pixi textures — that still
 * needs a real device, and Day 23 must not claim otherwise.
 */

這是我認為「補測」該有的形狀:把待辦上那句「重試五十次記錄 body 數」翻譯成一個會失敗的斷言,而不是翻譯成一個我自己看過一眼的數字。


十二列,有效數字零列

這排杯子的刻度我畫得很整齊,一個都還沒裝過東西

現在講最難堪的部分。

HUD 做好之後,我確實跑出過數字:headless Chromium 量到 FPS 5–7、最糟幀間隔 400–530 ms。

這些數字不會出現在這篇文章裡,也不該出現在任何地方。 那是無頭瀏覽器被節流的結果,不是裝置效能。這個專案的量測記錄格式(dev-log.md:26)規定一筆有效紀錄必須同時有裝置型號、瀏覽器版本、蜜蜂數、節點數,缺一項作廢。上面那組全部缺。

所以到 2026-08-07 為止,十二列的實測欄位是空的。零列。

這不是「後來發現不用優化」,是根本沒查過——而遊戲跑得動,所以沒有人想起來要查。

這才是效能預算最常見的失敗模式:不是訂得不好,是訂完就沒再看。 它甚至比「沒訂預算」更有欺騙性,因為那張表看起來很專業,會讓你以為這件事有人在管。


交給 AI:這一段我沒有交,理由不是我想的那個

HUD 的程式碼是 AI 寫的,FrameStatsDebugHud、以及它們的兩個單元測試,都是。這部分完全可以委外:驗收條件明確(環形緩衝不准每幀配置、不准掛在模擬層、destroy() 要能拆乾淨),而且測試跑得過就是跑得過。

過程中它撞到過三次牆,三次都是機器擋下來的:把 root = document.body 寫成預設參數,在 environment: 'node' 的測試裡直接 document is not defined(預設參數在 constructor 就求值);節流的初始值設成一整個 interval,導致 mount() 畫完之後第一幀又重畫;還有前面那道 WALL_CLOCK 閘門。

不能交出去的是數字本身。 效能量測的價值在於「在我的目標裝置上實際跑出來的值」——中階 Android 的 GPU 跟桌機差一個數量級,iOS Safari 的記憶體上限更嚴格,背景切回來的第一幀特別長。這些東西不存在於任何模型的權重裡,它們存在於我口袋裡的那支手機裡。

然後我要承認第二件事:我不但沒把它交出去,我自己也還沒做。 上面這段話寫成「所以我親自量了」會好看很多,但那是假的。這一段誠實的版本是:這件事無法委外,而它到現在還沒發生。


帶走什麼

效能預算要在寫程式之前訂,因為它會反過來決定架構。但訂完不看,它就只是一份很有說服力的自我安慰。

三件今天就能做的事:

  1. 把預算表的每一列後面加一個「量測日期」欄。 空著的那幾列會自己盯著你看。這個專案要是第一天就有這一欄,我不會拖到第 33 個 commit 才發現十二格全空。
  2. 量測工具要跟模擬用的數值分開。 給迴圈用的 delta 需要被夾取,給你看的 delta 不能被夾取。同一個變數服務兩個目的,你會拿到一個永遠好看的數字。
  3. 任何一個「剛好是整數」的效能數字都先當它是假的。 100.0、16.7、60.0,先去翻框架有沒有在夾。

明天 Day 28 講部署。原本我打算寫「GitHub Pages 的 404 地獄」,查證之後發現前提就錯了:這個專案的主要部署是 Vercel(HTTP 200),GitHub Pages 站根本不存在。而且我在一份 build 裡找到了三種不同成因的 404——一種是設定寫錯,一種是 Vite 不碰的那一行,還有一種回 404 給你的根本不是 GitHub。CI 那四個命令三十幾次執行全部通過,站還是不在。


本篇數字的快照時間:2026-08-07 12:35(+0800),對應 commit 5aa3705。專案仍在開發中,量體數字會變動。文中所有效能數字皆標明來源與環境;缺量測條件的數字一律未寫入本文。
可玩網址https://save-the-dog-web.vercel.app/原始碼https://github.com/HarryFan/save-the-dog-web

參考資料

如果你卡在語法

深入原理


上一篇
Day 26|測什麼、不測什麼:我規劃了三層測試,中間那層一個檔案都沒有
下一篇
Day 28|同一份 build、兩個部署目標、三種 404
系列文
一條線救一隻狗:我用 PixiJS、Matter.js 和一條有閘門的 AI 產線做完一款網頁小遊戲28
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言