IOS 14.5以下 【列表快速滚动】列表渲染空白问题 成因以及解决方案

0 阅读12分钟

问题状况

环境:ios11-14.4

业务场景:一个题目列表(题目由富文本渲染)

DOM结构:滚动父容器使用了**position: absolute + overflow-y: auto 属性**

问题:快速滚动之后,出现白屏

.scroll-container {
  position: absolute;  /* ⚠️ 脱离文档流 */
  overflow-y: auto;    /* ⚠️ 独立滚动 */
}

IOS(14.5以下)渲染机制分析

浏览器的完整渲染逻辑

HTML + CSS
→ DOM + CSSOM
→ Render Tree
→ Layout(Reflow)
→ Paint
→ Layer Tree(合成层树)
→ Raster(栅格化)
→ Composite(合成)
→ Display
1.DOM + CSSOM

DOM(Document Object Model)

HTML 解析阶段生成 DOM 树,其本质是:

  • 将 HTML 字符串解析为节点结构

  • 每个节点包含:

    • tag 类型
    • 属性
    • 层级关系

DOM 的特性:

  • 仅表达结构语义
  • 不包含任何视觉信息
  • 不知道如何渲染

DOM 的输出是结构化树,但不是渲染结构。


CSSOM(CSS Object Model)

CSS 解析生成 CSSOM

  • selector → style rules
  • 规则按优先级与层叠关系组织

CSSOM 的特性

  • 表达样式规则集合
  • 不绑定具体 DOM 节点
  • 不具备几何信息

DOM 与 CSSOM 相互独立

  • DOM:结构
  • CSSOM:样式规则

浏览器在此阶段无法确定任何视觉结果。


2.Render Tree

Render Tree 是第一次 “渲染语义合成结构”。

2.1 合成规则

浏览器遍历 DOM,并为可见节点计算:

  • 匹配 CSSOM
  • 生成 computed style

每个 Render Tree 节点包含:

  • DOM 节点引用
  • computed style
  • 可见性状态

2.2 节点过滤

不会进入 Render Tree 的节点包括:

  • display: none
  • head 中非可视元素

注意区分:

  • visibility: hidden 仍进入 Render Tree(占位)
  • display: none 不进入 Render Tree (如果发生变化,会引发 Reflow重排,进而引起Repaint重绘)
如果是希望元素消失变化平滑,可以使用opacity+visibility
​
因为 opacity 切换只触发“合成(Composite),甚至不会触发Repaint重绘

2.3 阶段本质

Render Tree 的作用是:

确定“哪些节点参与后续渲染计算”。

此阶段不包含几何信息。


3.Layout(布局/重排)

Layout 阶段也称 Reflow(重排)

3.1 输入

Render Tree(包含 style,但无几何信息)

3.2 输出

每个 Render Node 的:

  • position(x, y)
  • size(width, height)
  • box model(margin / padding / border)
  • line box(文本换行)

3.3本阶段本质

计算“每个元素在页面上的几何布局结果”

输出

  • 完整的几何信息树:Layout Tree / Layout Object

4.Paint(绘制/重绘)

4.1 输入

  • layout tree(几何信息)
  • computed style

做什么

生成绘制指令:

  • draw background
  • draw border
  • draw text
  • draw image

输出

Paint Records(绘制命令列表)

典型内容包括

  • fillRect(背景)
  • strokeRect(边框)
  • drawText(文本)
  • drawImage(图片)
  • clipPath(裁剪)

4.3 Paint 的特点

  • 与像素无关
  • 与 GPU 无关
  • 是平台无关的绘制命令集合

4.4 阶段本质

Paint 的输出是:

“如何绘制”的指令,而不是结果。


5. Layer Tree

Layer(图层)

5.1 什么是 Layer(图层)?

浏览器(特别是 WebKit/Blink 引擎)的渲染管线中,Layer(合成层,Compositing Layer) 是页面被拆分成的一个个独立的渲染画布。

  • 浏览器不会把整个网页画在一张大纸上,而是把页面拆分成多个 Layer。

  • 每个 Layer 会独立进行光栅化(将 DOM/CSS 转换为像素点)

    然后由浏览器的合成器(Compositor) 按照 Z 轴顺序

    将这些 Layer 叠加、混合,最终输出到屏幕上


5.2Layer生成条件

浏览器创建新 Layer 通常基于:

合成属性触发

  • transform
  • opacity(非 1)
  • filter
  • will-change(部分情况)

布局隔离需求

  • overflow: scroll(滚动容器)
  • position: fixed(某些实现)

内容类型

  • video
  • canvas
  • iframe

5.3 Layer 的意义

  • 浏览器划分 Layer 的终极目的是:性能优化(用内存空间换渲染时间),避免昂贵的重排(Reflow)和重绘(Repaint)。
  • 隔离 repaint 与 raster 范围,让频繁的局部变化不影响/阻塞整体绘制

5.4 划分依据

  • 高频局部变化隔离 (动画与交互)

    如果一个元素经常发生位置、大小、透明度变化(transform, opacity),把它隔离出来,变化时就只更新它自己,不影响其他元素。

  • 滚动与裁剪隔离(局部更新)

    如果一个区域需要局部滚动 overflow: scroll 或裁剪,将其内部提升为 Layer,滚动时只需移动该 Layer,避免全局重绘。

  • 层叠上下文与定位隔离(Z轴独立)

    脱离正常文档流的元素 fixed, sticky, z-index 较大的元素)它们的渲染顺序和生命周期与主文档流不同,独立成 Layer 可以简化合成器的计算逻辑。

  • 特殊内容源隔离(非 DOM 渲染)

    内容不是由 DOM 树生成的(video, canvas, iframe, webgl),必须独立成 Layer 才能将外部像素源接入浏览器的合成管线。


6.Raster(栅格化)

Raster 是从【绘制指令】到【像素】的转换。

Rasterization(栅格化/光栅化) 是浏览器渲染管线中的核心步骤,指将矢量的逻辑渲染指令(DOM树、CSS样式、SVG等)转换为屏幕上实际显示的像素点阵(位图/Bitmap) 的过程。

6.1 输入

  • layer paint records
  • layout + style 信息

6.2 输出

  • bitmap(位图)
  • 即像素数据

栅格化是一个极其消耗 CPU/GPU 资源的过程

为了优化性能,现代浏览器采用了以下机制:

  • 分块处理(Tiling) :浏览器绝不会一次性栅格化整个页面或整个 Layer。它会将 Layer 切割成一个个小块(Tiles,通常是 256x256 或 512x512 像素)。
  • 优先级调度:浏览器会优先栅格化当前视口内的 Tiles,视口外的 Tiles 优先级较低。
  • 多线程并行(Raster Threads)浏览器会开辟专门的【栅格化线程池】在后台并行处理这些 Tiles,不阻塞主线程(Main Thread)。

iOS / WebKit 特性风险点

在移动端 WebKit 中:

  • tile cache 有限
  • raster 结果可能被回收
  • scroll 回退时需重新 raster

如果恢复不及时,会出现:

  • DOM 存在
  • Layout 正确
  • 但像素未恢复

表现为白屏或空白区域

6.3 阶段本质

Raster 输出:【已经确定的像素内容】


7. Composite (合成)

Composite(合成) 是浏览器渲染管线的最后一步指将多个已经栅格化(Rasterized)好的独立图层(Layer/位图) ,按照 Z 轴顺序(层叠上下文)进行叠加、混合、裁剪,最终生成一帧完整的画面并输出到屏幕上的过程。


7.1 输入

  • multiple layer bitmaps
  • transform / opacity / z-index 信息

7.2 过程

GPU 执行:

  • 图层叠加
  • 变换矩阵计算
  • alpha blending
  • scroll offset 应用

7.3 关键性质

Composite 不修改像素内容,只做空间与层级合成。

合成阶段主要由浏览器的合成线程(Compositor Thread)GPU 接管,它不关心 DOM 结构,只处理【图像叠加】的数学运算:

  1. 层叠排序(Stacking) :根据 CSS 的 z-index 和层叠上下文规则,决定哪个图层在上面,哪个在下面。
  2. 视觉特效计算:处理图层之间的透明度(opacity)、混合模式(mix-blend-mode)、裁剪(clip-path / overflow: hidden)和滤镜(filter)。
  3. 屏幕绘制(Draw) :将最终计算好的像素矩阵提交给屏幕进行刷新(通常是 60Hz,即每秒 60 帧)。
8.Display显示

8.1 输出

GPU framebuffer:

  • 完整帧图像
  • 发送到显示管线

8.2 显示机制

  • vsync 控制刷新
  • 每帧提交 framebuffer

浏览器独立合成层?

我们知道合成层Compoisiting 是把Raster光栅化后的bitmap的按照Z轴的index顺序

合成、重叠、剪切生成一张图片

为什么会有独立合成层?

  • 如果没有独立合成层,当某个元素发生频繁视觉变化时(例如 transform、opacity、动画),浏览器可能需要重新 Paint 和 Raster 整个受影响区域,造成大量无效计算。
  • 因此浏览器会把这些频繁变化的元素提升为独立合成层,让它拥有独立的 bitmap,在变化时只需要进行 Composite 操作,而不影响其他静态内容。

独立合成层的条件

1.transform
.box{
 transform:translate3d(0,0,0);
}

transform 不影响布局


2.opacity动画
.fade{
 opacity:0;
}

opacity不会改变几何布局,只是改变bitmap的透明度

不需要重绘Paint和Raster光栅化


3.filter滤镜元素
filter:blur(10px)

滤镜需要操作像素bitmap

需要先

Raster Bitmap

然后再GPU处理blur

4.视频/Canvas/WebGL

这些不是普通的DOM绘制

  • canvas内部像素变化

    但是DOM没变化

所以单独需要一个 Canvas Layer

5.固定定位
.box{
position:fixed
}

页面滚动的时候,box不懂

所以拆分成 document Layer 和 Fixed Layer

不然每次滚动都需要重新绘制header

6.overflow滚动容器
overflow:auto

例如

<div class="list">

 item1
 item2
 item3

</div>

这个区域需要自己滚动所以就自己创建

Scroll Layer


IOS的特殊情况

IOS webkit会在以下的情况创建独立的Composting Layer

强制创建的情况

属性原因
transformGPU移动
opacity < 1透明合成
filter像素处理
video外部像素源
canvas独立bitmap
iframe外部页面
position:fixed独立滚动
overflow滚动容器滚动优化

大概率触发独立合成层的组合

情况原因
border-radius + overflow:hidden裁剪
复杂box-shadow离屏绘制
3D transformGPU
will-change提前优化
动画元素预测优化

问题分析

环境:IOS11-14.4

业务场景:一个题目列表(题目由富文本渲染)

DOM结构:滚动容器使用了**position: absolute + overflow-y: auto 属性**

问题:快速滚动之后,出现白屏,item不渲染

.scroll-container {
  position: absolute;  /* ⚠️ 脱离文档流 */
  overflow-y: auto;    /* ⚠️ 独立滚动 */
}

WebKit 的判断:

  • position: absolute → 元素脱离正常文档流,可能需要absolute Layer

  • overflow: auto → 需要独立滚动,创建 Scrolling Layer

  • item同时属于是富文本 有大量复杂的DOM (img、svg、mathjax)

    而且最要命的是 item 有(border-radius + overflow:hidden) 【裁剪】

    【导致每个item都创建 Clipping Layer

100 个 item:

  • 最坏情况:100 个独立 Layer
  • 每个 Layer:一个 GPU 纹理(几 MB)
  • 总开销:几百 MB GPU 内存 → Layer Explosion(层爆炸)

最终导致Layer数量和结构都爆炸了

Root Layer
 |
 |
 +-- Scroll Layer
        |
        |
        +-- Content Layer
              |
              |
              +-- item1 Layer
              |       |
              |       +-- Clip Layer
              |       |
              |       +-- Texture
              |
              |
              +-- item2 Layer
              |       |
              |       +-- Clip Layer
              |
              |
              +-- item3 Layer
              |
              ...

每个 Layer都需要:

  • Paint Record
  • backing store
  • texture
  • tile cache

GPU 内存压力爆炸


Webkit本身也有 类似虚拟滚动的 渲染策略

滚动区域的高度假设是10000px+

Webkit不会一次性生成 10000px+的 bitmap

而是将内容拆分成 多个瓦片 (Tiles)

只渲染和缓存 【可见区域】 以及周边区域的部分

scroll-container


+-------------+
| Tile A      |  当前屏幕
+-------------+
| Tile B      |
+-------------+
| Tile C      |
+-------------+
| Tile D      |
+-------------+

每一个瓦片都需要

Paint
 |
Raster
 |
Bitmap

然后存入 Tile Cache

当快速滚动的时候

当前:

  • item1 item2 item3

Tile:

[A][B][C]

已经 raster。

但是当滚动的长度过长,超出了TileCache中存在(Raster)的Tile长度之后

当前屏幕

  • item80 item81 item82

但是这些item并没有Tile

对应的Tile还没有Raster光栅化

主要核心问题是

  • 滚动是Compositor Thread 线程
  • Compositor Thread
    
    改变scroll offset
    ↓
    马上合成下一帧
    ↓
    GPU找对应Tile
    

但是Raster线程还没有生成Tile

导致Composite拿到空的Tile

快速滚动的时候,Raster光栅线程处理不过来

浏览器直接提交了白色背景作为下一帧的画面

所以快速滚动之后,看到的Item是白屏

问题修复

问题的本质是

  • Layer层过多
  • 大量的Tile需要Raster光栅化
  • Compositor滚动速度 远远快于 Raster生成Tile的速度

所以优化方向就是:

  1. 减少需要 Raster 的内容
  2. 减少 Layer / Tile 数量
  3. 降低单个 Tile Raster 成本
  4. 避免触发旧 WebKit 激进滚动路径
  5. 必要时让浏览器重新提交 Layer

父容器Layer优化
.scroll-container {
  position: absolute;  /* ⚠️ 脱离文档流 */
  overflow-y: auto;    /* ⚠️ 独立滚动 */
}

通过布局优化,取消绝对定位 (优先使用Flex布局解决问题)

.scroll-container {
  overflow-y:auto;
}

减少Layer层

Absolute Layer
+
Scrolling Layer
+
独立 Tile 管理

尽量让 WebKit 走:

Document Scroll

而不是:

Independent Async Scrolling Layer

WebKit 两种滚动模式对比
主文档滚动(Root Scroller)

元素对应是<html>/<body>或者根视口滚动

window.scrolldocument.documentElement.scrollTop

Layer结构

使用主Page TiledBacking(根层级Tiled backing)

Webkit为整个页面提供统一管理的Tile Cache

通用全局覆盖Coverage 和 预缓存Keep 缓存策略

驱动:由WKWebview内部的UIScroll View驱动

核心优势

  • Tile 管理最高效:共用一套策略,快速滚动时瓦片复用率极高,白屏概率最低
  • 内存/GPU 最优:Layer 数量少,资源压力小
  • 异步滚动体验最佳:惯性滚动、橡皮筋效果等原生行为完全流畅

结论:WebKit 优先走此路径,虚拟化与 tile 创建机制在此路径上最成熟


独立异步滚动层

触发条件

  • overflow-y: auto + position: absolute (或其他形成独立滚动容器的组合)

Layer结构

  • 独立的Scrolling Layer ,映射成一个独立的**UI Scrollview**
  • 内部包含Scrolled Content Layer和独立的TiledBacking
  • 经常额外产生Clip Layer (裁剪Layer,配有overflow: hiddenborder-radius时)

关键特点

  • 独立异步滚动:滚动逻辑、tile 更新、绘制都相对独立于主文档
  • 需要跨进程同步:滚动位置、Layer Tree Commit 需在 Web Process 与 UI Process 间通过 Remote Layer Tree 协调
  • 额外开销:独立的 backing store、独立的 tile cache、独立的 paint records
  • 代价:复制了一套完整的加速滚动基础设施,非必要时会增加内存与同步成本

正式因为我们触发了独立的异步滚动层 (Independent Async Scrolling Layer)

所以出现Layer更新慢,跟不上Compositor层,才出现空白


子项目Item的Layer优化
.item {
 border-radius:20px;
 overflow:hidden;
}

这是高风险组合。

border-radius
+
overflow:hidden

容易触发成 Clip Layer 裁剪层

浏览器必须:

  1. 创建裁剪区域
  2. 离屏绘制
  3. 保存bitmap

如果100item,那么就会产生100个Clip Layer

解决方案

取消overflow:hidden

.item {
 border-radius:20px;
}

如果一定要裁剪

把裁剪交给父容器
.list-container{
  overflow:hidden
}

父容器的裁剪层Clip Layer只会生成一个

(这样能够大量减少Layer的数量)


虚拟滚动(长列表优化方案

原理

  • 只渲染可视区域(10–20 个 DOM)
  • DOM 数量少 → Raster 压力小 → 根本上解决

这个是长期方案