在这篇文章里面,我会对 Chrome 是何时对图片进行解码进行说明,并结合 Chrome 的渲染流水线说明为什么图片解码可能会造成动画卡顿,而新的 Image.decode API 是如何让我们控制图片解码的时机,通过预先解码来避免动画卡顿。
为了让读者更好地了解本文的内容,请阅读我之前的文章 —— 浏览器渲染流水线解析与网页动画性能优化。
图片解码
图片解码与动画
默认的情况下,图片的解码会发生在这个图片所属的 image 元素被光栅化的过程中。而当一个元素处于可见区域,或者非常接近可见区域,浏览器的合成器就会安排元素所在图层的光栅化。合成器会检查该图层是否包含图片,如果有的话会创建对应的图片解码任务交由光栅化线程去执行,在这个过程中,合成器所在的合成线程是前台线程,光栅化线程是后台线程。
![光栅化线程的图片解码任务](http://static.zybuluo.com/rogeryi/5p43tw45dtzwlnl7fel958oe/2018-09-07%2013-50-24%E5%B1%8F%E5%B9%95%E6%88%AA%E5%9B%BE.png?x-oss-process=image/resize,w_1400/format,webp)
光栅化线程的图片解码任务
我们从浏览器渲染流水线解析与网页动画性能优化了解到网页动画可以分为合成器动画(也可以称为图层动画)和非合成器动画(也可以称为 DOM 动画),对于合成器动画来说,因为可见区域的绘制允许出现空白,所以合成器在动画每一帧的绘制过程中不需要等待该帧可见区域的光栅化任务(包含图片解码任务)的完成,动画的运行和光栅化是异步的。而对于非合成器动画来说,因为不允许可见区域的绘制出现空白,所以动画的每一帧都需要等待可见区域的光栅化任务的完成才允许提交绘制请求,这相当于动画的运行和光栅化是强制同步的。
我在支付宝的技术分享上也解释过为什么页端通过 rAF/timer 来连续改变元素的 transform,模拟惯性滚动的动画,始终在流畅度上很难达到由浏览器合成器驱动的惯性滚动动画的水平,容易出现卡顿掉帧的情况(旧版的手淘页面就是采用这种方式模拟惯性滚动)。其中一个重要的原因就是图片解码,图片解码是光栅化过程中非常耗时的一个步骤,通常需要花费几十毫秒或者上百毫秒,有的超大图片甚至可能达到几百毫秒,如果动画会被图片解码所阻塞,那么当图片即将出现在可见区域时,掉帧也是必然会发生的事情。
之前也有帮蚂蚁庄园分析过一个弹出面板卡顿的问题,这个弹出面板动画也是一个 rAF/timer 驱动的非合成器动画,面板上包含了一个超大图片的显示,动画过程中当图片即将出现在可见区域时,触发图片解码就造成了几百毫秒的动画卡顿。
图片解码缓存
合成器会通过一个图片解码缓存 ImageDecodeCache 来缓存解码后的图片像素数据和生成的纹理,如果一个图片已经解码并在缓存中时,它再次被绘制时就不需要重复解码。但是解码缓存的大小是有限制的,只能容纳有限的图片(在移动设备的上限通常是 128 兆或者更低),缓存采用 MRU(最近使用)的淘汰策略,如果一个图片已经被移出可见区域并且一段时间没有参与绘制,就有可能会被缓存淘汰,当它重新进入可见区域时,就会触发重解码。
Image.decode API
因为图片解码可能会造成非合成器动画的卡顿,那么最直观的优化想法就是,我能不能先解码图片,解码完成后再把图片加入到 DOM 树里面参与绘制。这种方式在 UI 编程里面也十分常见,应用自己管理一个解码图片的缓存池,如果一个图片需要显示时先请求解码,解码完成后才真正加入到 UI 界面中参与绘制。浏览器新增的 Image.decode API 就是让 Web 也具备相似的能力。
Image.decode 可以让 Web 端请求对这个图片进行提前解码,这个请求被 Blink 发送到合成器,合成器就会生成一个图片解码任务交由光栅化线程去运行。Image.decode 会返回一个 Promise,当光栅化线程完成解码任务后会通知合成器并将结果保存在解码缓存里面,合成器再通知 Blink resolve 这个 Promise,这时 Web 端就能接收到图片解码完成的通知。
ScriptPromise HTMLImageElement::decode(ScriptState* script_state,
ExceptionState& exception_state) {
return GetImageLoader().Decode(script_state, exception_state);
}
void ImageLoader::DispatchDecodeRequestsIfComplete() {
...
LocalFrame* frame = GetElement()->GetDocument().GetFrame();
for (auto& request : decode_requests_) {
...
Image* image = GetContent()->GetImage();
frame->GetChromeClient().RequestDecode(
frame, image->PaintImageForCurrentFrame(),
WTF::Bind(&ImageLoader::DecodeRequestFinished,
WrapCrossThreadWeakPersistent(this), request->request_id()));
request->NotifyDecodeDispatched();
}
}
Image.decode API 在 Chrome 中的部分实现,Blink 向合成器发起解码请求
这个演示视频显示了如何使用 Image.decode API 来避免一个 rAF 动画的突然卡顿,这里是Demo 的代码。
function prepareImage() {
var img = new Image();
img.src = "nebula.jpg";
img.decode().then(function() { document.body.appendChild(img); });
}
上面的示例代码中,prepareImage 中请求对 nebula.jpg 进行解码,在解码完成后再把它加入到 DOM 树,这样就规避了 rAF 驱动的时钟指针旋转动画因为图片解码造成的卡顿。
如前所述,合成器的图片解码缓存大小是有限的,而且不必要的内存占用也是不好的行为,所以对所有已经加载的图片都发起解码请求并不是一个良好的使用方式。一个可能的更好做法是:
- 当图片加载完成后,需要在可见区域显示或者即将需要显示,这时先发起解码请求,等待解码完成后再加入到 DOM 树中(可以跟图片延迟加载的机制相结合);
- 如果一个图片已经不在可见区域很长一段时间,可以考虑从 DOM 树移除,只保留一个大小相同的占位符,等下次再进入可见区域时再重复 1 的操作;
如果读者觉得这篇文章有所帮助,请继续关注专栏和为本文点赞,这样可以帮助我继续创作更多更有价值的文章。