以下每一條都是「看到什麼症狀 → 機制是什麼 → 怎麼改」。排錯的順序建議照症狀查,不要照筆記順序讀。

一|底部一截被工具列吃掉

症狀。手機上底部按鈕看不到,往上滑一下(工具列收起)就正常了。

機制100vh 綁的是大視口——工具列收起時的高度。工具列展開時,100vh 比可視區高出約 44–56px,超出的部分就在工具列底下。

改法。遊戲主容器改 position: fixed; inset: 0;需要用單位的地方改 100svh(最保守的高度,永遠看得到)。不要用 100dvh 當遊戲容器高度——它會隨工具列動畫階梯式跳動,每跳一次就觸發一次 canvas 重建。詳見 視口設定

二|瀏海或 home indicator 蓋住 UI

症狀。iPhone 橫向時左側按鈕被瀏海切一半;直向時底部按鈕壓在 home indicator 上。

機制。兩種可能。沒寫 viewport-fit=cover → Safari 自動縮排,你根本畫不到邊緣(症狀是「四周有奇怪的黑邊」);寫了 cover 但沒用 env(safe-area-inset-*) → 內容延伸到不安全區被遮擋。

改法。兩者一起做:meta 加 viewport-fit=cover,所有貼邊 UI 用 padding: max(12px, env(safe-area-inset-*))。背景/畫布不做 inset(要滿到邊),只有前景 UI 做。詳見 滿版容器

三|canvas 糊糊的

症狀。線條邊緣有灰邊,文字像蓋了一層霧,在 iPhone 上特別明顯、在舊筆電上看不出來。

機制canvas.width(backing store)沒有乘 devicePixelRatio,瀏覽器把低解析度 bitmap 拉伸填滿 CSS 尺寸。DPR 3 的裝置就是 3 倍拉伸。

改法canvas.width = Math.round(cssW * dpr),並用 ctx.setTransform(scale * dpr, 0, 0, scale * dpr, …) 把座標系換回設計單位。若還是有極輕微的糊,那是 Math.round 造成的半像素誤差,改用 ResizeObserverdevice-pixel-content-box 取整數實體像素。詳見 DPR 與清晰度

四|resize 之後畫面縮在左上角一小塊

症狀。轉向或改變視窗大小後,遊戲只畫在畫布左上角,比例也不對。

機制。設定 canvas.widthcanvas.height即使設成相同的值)會依規範清空 bitmap 並把 drawing state 重設為初始值——變換矩陣歸為單位矩陣,fillStylefontlineWidth、clip 全部復原。你的 DPR 補償與遊戲縮放矩陣就這樣沒了。

改法。resize 後重新套用所有 context 狀態,或乾脆每幀開頭呼叫一次 ctx.setTransform(…)(絕對設定,不累加)。同時加 if (canvas.width !== next) 守衛,避免無謂的清空。

五|轉橫向後跑版,要轉兩次才對

症狀。第一次轉向版面錯誤,再轉回去、再轉過來就正常了。

機制orientationchangeresize 事件在部分行動瀏覽器早於版面穩定觸發,此時 window.innerWidth/innerHeight 仍是舊值。加 setTimeout(…, 300) 只是賭裝置夠快。

改法。完全不監聽 orientationchangeresize 來量尺寸,改用 ResizeObserver——它的回呼保證在版面重算之後執行,contentRect 必然是新值。要知道「方向變了」這件事,用 matchMedia('(orientation: portrait)') 的 change 事件。詳見 重算迴圈

六|整頁可以被拖動、出現橡皮筋回彈

症狀。手指在遊戲區域上下滑,整個頁面跟著移動,放開後彈回;Android 上還會觸發下拉刷新。

機制。文件本身有可捲動內容(哪怕只多出 1px),觸控手勢就被瀏覽器判定為捲動。常見的 1px 來源是 <canvas> 沒設 display: block——它預設是 inline,行盒 baseline 下方留了約 4–5px。

改法。三層一起做:html, body { overflow: hidden; overscroll-behavior: none }、根容器 position: fixed; inset: 0、畫布 touch-action: nonedisplay: block。內部真的需要捲動的區塊用 overscroll-behavior-y: contain。詳見 滿版容器

七|點擊位置整體偏移

症狀。點在按鈕上沒反應,點在按鈕左上方一點才有反應;偏移量隨畫面縮放而變。

機制。三種常見寫法都會錯:用 ev.offsetX/offsetY(相對於事件目標,而目標可能是疊在上面的 DOM)、用 canvas.width 當分母(那是實體像素,clientX 是 CSS 像素,差一個 DPR)、或快取了 getBoundingClientRect() 卻沒在 resize 時失效。

改法

const r = canvas.getBoundingClientRect()
const x = (ev.clientX - r.left) / r.width  * view.worldW
const y = (ev.clientY - r.top ) / r.height * view.worldH

getBoundingClientRect() 已包含所有 CSS transform,所以這段程式碼不需要知道 scale 是多少。詳見 輸入座標換算

八|長按跳出「拷貝/分享」選單、雙擊放大

症狀。長按角色出現系統選單;快速連點兩下畫面放大。

機制。瀏覽器的預設觸控行為。user-scalable=no 擋不住——iOS Safari 從 10 起就忽略它。

改法。CSS 一次處理:

.game-root {
  touch-action: none;
  -webkit-user-select: none; user-select: none;
  -webkit-touch-callout: none;
  -webkit-tap-highlight-color: transparent;
}

加上 canvas.addEventListener('contextmenu', e => e.preventDefault())。注意 touch-action: none 有無障礙代價(連捏合縮放也擋掉),只加在畫布層,設定頁與說明頁保留 manipulation

九|鍵盤彈出把整個遊戲頂上去

症狀。點聊天輸入框,遊戲畫面整個往上跑,底部 HUD 消失。

機制。預設 interactive-widget=resizes-visual——鍵盤只縮小視覺視口,佈局視口不變,於是瀏覽器自動捲動讓輸入框可見,把你的固定 UI 推出畫面。

改法。首選是避免在遊戲畫面內用原生輸入框,改成獨立覆蓋層 + 暫停遊戲。必須即時輸入時,用 visualViewport 推算鍵盤高度寫進 CSS 變數:innerHeight − vv.height − vv.offsetTop。Android 可以加 interactive-widget=resizes-content 讓瀏覽器代勞,Safari 尚未支援。詳見 鍵盤與 visualViewport

十|requestFullscreen() 沒反應且沒有錯誤

症狀。點按鈕沒進全螢幕,console 也沒有明顯錯誤。

機制。四種可能:不在使用者手勢的同步呼叫堆疊中(await 之後 activation 已過期)、iframe 缺 allow="fullscreen"、Promise 被 reject 但沒有 .catch() 所以看不到、或就是 iPhone Safari——caniuse 對 iOS Safari 的 Fullscreen API 標記從 12 到最新版都是 partial。

改法。把呼叫放在 click handler 的同步路徑上、一定要 catch 並記錄錯誤、先檢查 document.fullscreenEnabled。iPhone 上準備降級方案:PWA standalone + CSS 層的方向處理。詳見 全螢幕與沉浸模式

十一|console 出現 ResizeObserver loop completed with undelivered notifications

症狀。這行警告反覆出現,而且某些 resize 沒有生效。

機制。你在 ResizeObserver 的回呼裡改變了被觀察元素的尺寸,形成迴圈。瀏覽器偵測到後會丟棄該次通知——所以不只是警告,是真的有 resize 靜默失效。

改法。觀察一個你不會改動的父容器(根容器),而不是 canvas 本身;並把重建工作包進 requestAnimationFrame

十二|低階 Android 掉幀,但桌機很順

症狀。手機上明顯卡頓,profile 顯示 Rasterize 佔比很高。

機制。填充率瓶頸。滿版 + 高 DPR 讓每幀要寫的像素數量遠超裝置能力,與程式邏輯無關。

改法。降 render scale。像素量與它的平方成正比,降到 0.75 就砍掉 44% 的成本而多數人察覺不到。用中位數幀時間 + hysteresis 自動調節,並且只降遊戲層、UI 層維持原生。若 profile 顯示 Scripting 佔大頭,那降解析度沒用,要改演算法。詳見 效能

十三|iOS 上跑一陣子畫面變白或閃爍

症狀。多層 canvas 或高 DPR 下,Safari 執行一段時間後畫布內容消失。

機制。Safari 對 canvas 記憶體有上限(單一 canvas 面積與整個行程的總量),超過時系統會回收 backing store。這個上限沒有公開文件,只有社群實測值,且隨版本與裝置變動。

改法。減少 canvas 層數、把 DPR 夾在 2–2.5、對用不到的離屏 canvas 明確設 canvas.width = canvas.height = 0 釋放。並監聽 webglcontextlost(WebGL)或在 visibilitychange 回前景時重建關鍵資產。這條屬於平台相依的坑,見 主張 vs 可佐證

十四|畫面在有些裝置上比例對、有些變形

症狀。同一個關卡在不同手機上構圖不同,圓形變橢圓。

機制scaleXscaleY 分開算了(stretch 策略),或 CSS 的 width: 100%; height: 100% 直接套在有固定 backing store 的 canvas 上——後者等效於 stretch。

改法。等比縮放只能有一個 scale。用 aspect-ratiomax-width/max-height 讓排版引擎維持比例,或用 object-fit: contain/cover 交給瀏覽器。詳見 畫布縮放策略

排錯的通用順序

  1. 先看幾何:DevTools 裝置模擬,量 window.innerHeightvisualViewport.heightdevicePixelRatiocanvas.width 四個數字,錯的那個就是線索起點。
  2. 再看事件:在 ResizeObserver 回呼裡 log 一次尺寸,確認它有觸發且值是新的。
  3. 最後看 contextctx.getTransform() 印出矩陣,確認 DPR 與 scale 都在裡面。

裝置模擬能重現絕大多數版面問題,但工具列展開收合、safe-area、鍵盤行為、DPR 上限這四類必須用實機驗證——模擬器對它們的模擬都不完整。


最後一篇把本專欄的每個主張按證據強度分級 → 主張 vs 可佐證