R·ex / Zeng


音遊狗、安全狗、攻城獅、業餘設計師、段子手、苦學日語的少年。

由 Use Zoom For DSF 導致的幽靈 Bug

在寫專案時,我們都會產生一些 Bug。俗話說“解決 Bug 的第一步是先復現它”,但如果遇到了一個幽靈 Bug(無法穩定復現的 Bug)時,要如何解決呢?

Bug 的現象

我們有一個對外專案,其中一個頁面裡面展示了各類統計圖表。當我們還在慶祝專案按時開發完成時,QA 小姐姐找了過來,說頁面上有一個表格的行間分隔線消失了:

消失的行間分割線

但是我們經過多次除錯後,發現攢了很久的一句話終於派上用場了:

在我這兒是好的啊……見鬼了。

沒錯,這是個幽靈 Bug,因為我發現:在同事的電腦上,只要重新整理次數足夠多,一定會有一兩次可以復現。這可是我們團隊第二個對外的平臺,不像運營平臺這種專案,對外的平臺出現了任何可能影響樣式和體驗的問題,必須修復。

一步步的嘗試

嘗試穩定復現

經多次排查,我們沒有發現任何 CSS 異常:單元格的 Computed Style 裡面有 border-top,且不存在 backgroundoverflow: hidden,表示它不是被某個元素蓋住了。

好吧,既然不像是程式碼問題,也無法穩定復現,就試著分析和搜尋一下可能產生的原因吧。

嘗試分析

這個表格在頁面比較靠下的位置,它的上方有一個 Metrics 模組。在偶然復現過的幾次現象中,我發現表格線在頁面載入的一瞬間是有的,但在上面的 Metrics 模組的資料載入完畢、把表格稍微擠下去了一點,分隔線就消失了。

頁面的全貌

既然是在被擠下去時才會出現的問題,那會不會是因為物理畫素導致的?

我聯想到之前有同事問過我一個問題:

在 Chrome 是 75% 縮放的情況下,Button 元件中的加號圖示只剩了一條豎線,橫線消失了。這個問題是比較容易解釋的:在 Chrome 看來,螢幕的物理畫素不可能存在小數,因此所有畫素在渲染時都會轉換為整數。這可能會導致在某些縮放和定位條件下,加號中橫線的上下兩條邊的縱座標在四捨五入後相等了。例如經過計算得出上邊的 Y 座標是 80.5px,下邊的 Y 座標是 81.25px,這樣下邊舍上邊入,都是 81px,橫線就消失了。

但不是所有情況橫線都會消失(不然一縮放頁面就全變了),例如上邊的 Y 座標是 81.25px,下邊的 Y 座標是 82px,此時上邊舍下邊入,一個 81px 一個 82px,就會在螢幕上展示 1px 寬度的橫線。

這裡需要注意一下“物理畫素”與“次畫素渲染”的區別。一個“次畫素渲染”的例子是:在非高分屏上,如果有一個寬度為奇數個畫素(例如 251px)的元素被設定了 translateX(50%),它就會變模糊。這種情況到了高分屏就不容易出現了。

順著這個思路,我看了一圈大家的裝置,發現我們的 Chrome 瀏覽器都放在了 Mac 的視網膜屏上,但可以復現的那個同事,則是把 Chrome 放到了 1080P 顯示器上。這基本可以確定這個 Bug 與物理畫素有關。

嘗試搜尋

經過一番谷歌,我發現 Twitter 上面 EvanYou 發了一條 推文 說 Chrome 近期似乎有個 Bug,現象是一個設定了 position: sticky 的吸頂元素在頁面滾動時會抖動,偶爾會與頁面頂部有 1px 的空隙:

EvanYou 發的推文

我在回覆中看到了一個 crbug 連結:Issue 1076036: 1px gap with sticky positioning and mouse-wheel scrolling,跟推文的意思相近,裡面說在頁面上有一個 position: fixedtop: 0 的元素,但是在滑鼠滾動時,偶爾會出現元素上方有 1px 空隙的情況。

下方的評論說,Chromium 有一個叫 Use Zoom For DSF 的 Flag,之前的版本是一直啟用的,但由於出了一些問題,在近期版本被禁止了 。如果要重新開啟,可以在啟動 Chromium 的時候加上 --enable-use-zoom-for-dsf 引數。我試了一下,發現確實沒有這個問題了。

但是總不能讓 QA 小姐姐和使用者也這麼操作吧……這肯定不是一個好的解決方案。

Use Zoom For DSF 是個啥

遇到了一個新名詞,查了一圈,發現中文資料幾乎沒有。不過在英文搜尋結果中發現了 Chromium 團隊的一個提案 Using Zooming to implement DSF 和對應的技術方案文件 Use Blink’s Zoom to implement device scale,於是開始強行啃這兩個文件,如果遇到不了解的內容就去搜索。

文中說,DSF 是 Device Scale Factor 的簡稱,我覺得中文可以翻譯為“裝置縮放因子”。聽起來跟大家比較熟悉的 devicePixelRatio(DPR)有點關係,會在渲染過程中被用到。

渲染流水線

Chromium 的渲染流程可以用一條流水線來描述,其中分為好幾個步驟,每個步驟接收上一步驟產生的半成品,並生成新的內容傳遞給下一步驟,這是面試的一大考點。

我們先來“重溫”一下這張經典的圖(出自 Life of a Pixel):

Chromium 的渲染流程

每個步驟詳細的操作可以參考這篇文章:一個畫素的一生 - 剖析Chromium渲染流水線 - ¥ЯႭ1I0,結合上圖,這裡摘取文中的一些關鍵部分:

  • 主執行緒(main)負責的部分:

    • 排版佈局階段 - Layout:完成生成 DOM 樹與樣式計算後,需要處理的是元素的可視幾何屬性。
    • 繪製處理階段 - Paint:使用一個列表儲存需要繪製的物件,物件中記錄了要畫出該元素需要執行的繪製操作,比如使用哪種顏色,在什麼位置畫一個矩形。
  • 合成器執行緒(impl)負責的部分:

    • 呼叫 GPU 進行光柵化 - Raster:之前記錄的繪製操作會在光柵化階段中被執行。在光柵化後所得到的點陣圖中,每一個柵格都儲存著經過顏色與透明度編碼後像素位元值。
  • 圖層合成的過程(與合成層相關):在主執行緒中構建合成層,並提交到合成器執行緒中繪製。

讓我們回到 Chromium 團隊的提案和技術方案文件。對於問題的成因,原文的描述是這樣的:

The content painting commands are recorded at 1.0x scale factor, and then rastered at the target scale factor on another thread (impl side painting).

...

This can cause undesirable artifacts at fractional scale factor due to rounding.

大概翻譯一下就是,這次的問題是因為縮放的時機不對。主執行緒記錄繪製操作時是按照 1x 的比例來記錄(並且會對座標進行四捨五入),然後在合成器執行緒光柵化的時候把座標縮放到目標比例。這可能會導致一些問題。文中也給出了一個例子:

出現問題的渲染場景

在記錄繪製操作時四捨五入,一旦中間結果出現了小數,誤差將會非常大。文中也給出了正常情況下應該渲染成的樣子:

正常情況的渲染場景

Use Zoom For DSF 的提案與實現

Use Zoom 的意思是“使用跟頁面縮放一樣的技術來實現對 DSF 的處理”。提案作者發現瀏覽器縮放(Ctrl +Ctrl -)時會在 Layout 階段就進行座標的縮放(同時四捨五入)。如果當正常渲染時,可以在 Layout 階段按 DSF 進行縮放,那舍入誤差將會大大減小,就不會出現這樣的問題了。提案的後面分析了這種做法的可行性,給出了一些渲染的截圖來證明縮放不會有這類問題,也認為頁面縮放與目前渲染時按 DSF 縮放的差異足夠小,可以使用類似於前者的技術來替代後者。

在技術方案中給出的實現方式也基本是按照提案中來的:

主執行緒在渲染時,如果 DSF 是 X,就按照 Zoom = X00%DSF = 1 來記錄繪製指令。

不過這又引入了一個新問題:既然主執行緒的繪製命令是按照 DSF 生成的,如果我把視窗從一個低分屏移動到高分屏,DSF 一變,豈不是會模糊了?為了解決這個問題,主執行緒會在傳送合成後的 Frame 給合成器執行緒時,附帶上一個引數,表示“我是在 DSF = X 的時候合成的”,如果 UI 執行緒發現這個引數與自己獲取到的 DSF 不同,就會再做一些處理。例如把在 DSF = 1 時生成的繪製指令裡面所有的座標都乘上 2,這就可以處理從低分屏到高分屏的情況。

在實現上,Chromium 團隊為合成器執行緒添加了一個額外的 float painted_device_scale_factor_ 引數(為了與之前的 device_scale_factor_ 引數區分開),這就是剛才附帶上的那個引數。理論上來說,之前的 device_scale_factor_ 引數已經沒有意義了,可以被幹掉,但它的影響面太大,團隊認為還需要慎重考慮,因此截至目前,Chromium 的合成器執行緒裡還保留了兩個 DSF 引數。

這個提案其實很早就被實現了,因此之前的渲染不會出現這種情況。但大概在 Chrome 93 附近的時候,似乎有人發現它有一些問題,於是這個功能就先被停用了,這才導致了我們遇到的問題。

臨時解決方案

雖然 Use Zoom For DSF 功能短時間內無法恢復,但它確實對我們產生了影響,因此需要想辦法解決。記得之前在解決元件庫問題時,遇到過 getBoundingClientRect 在一些啟用了 transform 的元素上會獲取到小數,因此考慮到這可能跟合成層相關。我開啟 Devtools 的“顯示層邊界”,發現表格中的元素都有黃色邊界,也就是都是合成層。

記得我之前有寫過一篇文章:由於瀏覽器的最佳化導致的 Bug 們,裡面提到了 Chromium 在哪些條件下會為一個元素建立合成層:

  • 3D 或透視變換(perspectivetransform)這類 CSS 屬性
  • 使用加速影片解碼的 <video> 元素
  • 擁有 3D (WebGL) 上下文或加速的 2D 上下文的 <canvas> 元素
  • 混合外掛(如 Flash)
  • 對自己的 opacity 做 CSS 動畫,或使用一個動畫變換的元素
  • 擁有加速 CSS 過濾器的元素
  • 元素有一個包含合成層的後代節點(換句話說,就是一個元素擁有一個子元素,該子元素在自己的層裡)
  • 元素有一個 z-index 較低且包含一個合成層的兄弟元素(換句話說就是該元素在合成層上面渲染)

重新審視了一下程式碼,但並沒發現疑點。考慮到這篇文章已經很老了,可能這些條件已經發生了變化,於是我去找了最新的條件——都寫在原始碼 compositing_reasons.h 裡面呢!看來現在的條件比我之前瞭解的多了好多!

#define FOR_EACH_COMPOSITING_REASON(V)                                        \
  /* Intrinsic reasons that can be known right away by the layer. */          \
  V(3DTransform)                                                              \
  V(Trivial3DTransform)                                                       \
  V(Video)                                                                    \
  V(Canvas)                                                                   \
  V(Plugin)                                                                   \
  V(IFrame)                                                                   \
  V(DocumentTransitionContentElement)                                         \
  /* This is used for pre-CompositAfterPaint + CompositeSVG only. */          \
  V(SVGRoot)                                                                  \
  V(BackfaceVisibilityHidden)                                                 \
  V(ActiveTransformAnimation)                                                 \
  V(ActiveOpacityAnimation)                                                   \
  V(ActiveFilterAnimation)                                                    \
  V(ActiveBackdropFilterAnimation)                                            \
  V(AffectedByOuterViewportBoundsDelta)                                       \
  V(FixedPosition)                                                            \
  V(StickyPosition)                                                           \
  V(OverflowScrolling)                                                        \
  V(OverflowScrollingParent)                                                  \
  V(OutOfFlowClipping)                                                        \
  V(VideoOverlay)                                                             \
  V(WillChangeTransform)                                                      \
  V(WillChangeOpacity)                                                        \
  V(WillChangeFilter)                                                         \
  V(WillChangeBackdropFilter)                                                 \
  /* Reasons that depend on ancestor properties */                            \
  V(BackfaceInvisibility3DAncestor)                                           \
  /* This flag is needed only when none of the explicit kWillChange* reasons  \
     are set. */                                                              \
  V(WillChangeOther)                                                          \
  V(BackdropFilter)                                                           \
  V(BackdropFilterMask)                                                       \
  V(RootScroller)                                                             \
  V(XrOverlay)                                                                \
  V(Viewport)                                                                 \
                                                                              \
  /* Overlap reasons that require knowing what's behind you in paint-order    \
     before knowing the answer. */                                            \
  V(AssumedOverlap)                                                           \
  V(Overlap)                                                                  \
  V(NegativeZIndexChildren)                                                   \
  V(SquashingDisallowed)                                                      \
                                                                              \
  /* Subtree reasons that require knowing what the status of your subtree is  \
     before knowing the answer. */                                            \
  V(OpacityWithCompositedDescendants)                                         \
  V(MaskWithCompositedDescendants)                                            \
  V(ReflectionWithCompositedDescendants)                                      \
  V(FilterWithCompositedDescendants)                                          \
  V(BlendingWithCompositedDescendants)                                        \
  V(PerspectiveWith3DDescendants)                                             \
  V(Preserve3DWith3DDescendants)                                              \
  V(IsolateCompositedDescendants)                                             \
  V(FullscreenVideoWithCompositedDescendants)                                 \
                                                                              \
  /* The root layer is a special case. It may be forced to be a layer, but it \
  also needs to be a layer if anything else in the subtree is composited. */  \
  V(Root)                                                                     \
                                                                              \
  /* CompositedLayerMapping internal hierarchy reasons. Some of them are also \
  used in CompositeAfterPaint. */                                             \
  V(LayerForHorizontalScrollbar)                                              \
  V(LayerForVerticalScrollbar)                                                \
  V(LayerForScrollCorner)                                                     \
  V(LayerForScrollingContents)                                                \
  V(LayerForSquashingContents)                                                \
  V(LayerForForeground)                                                       \
  V(LayerForMask)                                                             \
  /* Composited layer painted on top of all other layers as decoration. */    \
  V(LayerForDecoration)                                                       \
  /* Used in CompositeAfterPaint for link highlight, frame overlay, etc. */   \
  V(LayerForOther)                                                            \
  /* DocumentTransition shared element.                                       \
  See third_party/blink/renderer/core/document_transition/README.md. */       \
  V(DocumentTransitionSharedElement)

P.S. 之後可以給面試官說:我看過 Chromium 原始碼,產生合成層有這些條件……(笑)

最終,我發現表格最左邊一列的 <td> 元素被設定了 position: sticky,這是因為這一列在程式碼中是固定列 fixed: 'left'。此外固定列的 z-index 需要比其它列大,以確保它們可以始終浮在其它列的上方。Ant Design 4 也是這樣實現固定列的。

按照上面程式碼塊中的規則,首先 position: sticky 會匹配到 V(StickyPosition) 產生單獨的合成層,然後又因為它是最左邊一列,z-index 更大,因此後面的列都會匹配到 V(AssumedOverlap),從而產生單獨的合成層。可能再之後還會有合成層壓縮,但已經跟這個問題無關,就不多提了。

考慮到這一列並不寬,應該可以不用固定。我試著修改程式碼,把這一列取消固定,問題解決了。

後續

有一天我收到了 Hacker News 的推送,點開一看連結到了一個 crbug 的評論:

crbug 的評論

在 2022 年 2 月 9 日,--use-zoom-for-dsf 已經被完全啟用了。這表明,現在已經不會出現這個問題了!

收穫

雖然最終的解決方式只是刪掉了一行 fixed: 'left',但在解決問題的過程中,我學到了一些新東西,也更新了自己之前過時的知識。要說對於這種問題該如何解決,我覺得有以下四點:

  1. 關於 Chrome 的 Bug,多看一下 crbug 網站 是有好處的。Firefox 的 Bug 就去看 Bugzilla。瀏覽器也是個軟體,並且邊界情況絕對比業務專案要多,因此有 Bug 也是很正常的。
  2. 做設計時一定要留下技術需求文件(TRD)和技術設計文件(TD),以便其它人瞭解你的思路。
  3. 多懂一些底層原理,無論是遇到普通問題,還是這次的幽靈 Bug,都可以幫助分析。
  4. 一些問題的根因是相通的,解決問題的經驗要記得總結。
Disqus 載入中……如未能載入,請將 disqus.com 和 disquscdn.com 加入白名單。

這是我們共同度過的

第 3853 天