在写项目时,我们都会产生一些 Bug。俗话说“解决 Bug 的第一步是先复现它”,但如果遇到了一个幽灵 Bug(无法稳定复现的 Bug)时,要如何解决呢?
Bug 的现象
我们有一个对外项目,其中一个页面里面展示了各类统计图表。当我们还在庆祝项目按时开发完成时,QA 小姐姐找了过来,说页面上有一个表格的行间分隔线消失了:
但是我们经过多次调试后,发现攒了很久的一句话终于派上用场了:
在我这儿是好的啊……见鬼了。
没错,这是个幽灵 Bug,因为我发现:在同事的电脑上,只要刷新次数足够多,一定会有一两次可以复现。这可是我们团队第二个对外的平台,不像运营平台这种项目,对外的平台出现了任何可能影响样式和体验的问题,必须修复。
一步步的尝试
尝试稳定复现
经多次排查,我们没有发现任何 CSS 异常:单元格的 Computed Style 里面有 border-top
,且不存在 background
和 overflow: 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
的空隙:
我在回复中看到了一个 crbug 链接:Issue 1076036: 1px gap with sticky positioning and mouse-wheel scrolling,跟推文的意思相近,里面说在页面上有一个 position: fixed
、top: 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渲染流水线 - ¥ЯႭ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 或透视变换(
perspective
、transform
)这类 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 的评论:
在 2022 年 2 月 9 日,--use-zoom-for-dsf
已经被完全启用了。这表明,现在已经不会出现这个问题了!
收获
虽然最终的解决方式只是删掉了一行 fixed: 'left'
,但在解决问题的过程中,我学到了一些新东西,也更新了自己之前过时的知识。要说对于这种问题该如何解决,我觉得有以下四点: