这篇文章我们来探究下屏幕撕裂、屏幕卡顿、离屏渲染。
一、屏幕撕裂
在探究屏幕撕裂问题之前,我们需要先了解下屏幕显示图像的原理。
电子枪按照上图显示的那样,从上往下逐行扫描,扫描完成后,就显示一帧的画面,随后电子枪回到初始位置。当电子枪换到新的一行时,会发出
水平同步信号(Horizonal Synchronization)
,简称Hsync
。而当一帧显示完成后,电子枪回到初始位置,准备画下一帧之前,显示器会发出一个垂直同步信号(Vertical Synchronization)
,简称VSync
。显示器通常以固定频率进行刷新,这个刷新率就是VSync
信号产生的频率。尽管现在的设备大都是液晶显示屏了,但原理仍然没有变。从上图可以看出,如果帧缓存区只有一个,这时帧缓存区的读取和刷新都都会有比较大的效率问题。因此引入了
双缓存机制
。在这种情况下,GPU
会预先渲染好一帧放入一个缓冲区内,让显示控制器
读取,当下一帧渲染好后,GPU
会直接把视频控制器的指针指向第二个缓冲器。如此一来效率会有很大的提升。双缓存机制
虽然能解决效率问题,但是又带来了一个新的问题。在
显示控制器
从帧缓存区
进行读取图像进行显示时,如果当前这一帧的内容还未读取完成,GPU又将新的一帧
内容提交到帧缓冲区并把两个帧缓冲区进行更新后,显示控制器就会把新的一帧数据的下半段显示到屏幕上,就会造成屏幕撕裂
的现象。
屏幕撕裂产生的原因
屏幕撕裂就是在于显卡输出帧的速度比显示器快,显示器的处理速度跟不上显卡,在显示器处理显卡丢过来的第1帧的时候,第2帧就又到了,导致同一个画面同时出现1、2两帧,屏幕撕裂就产生了。
如何解决屏幕撕裂
在双缓存基础上,引入垂直同步信号。
垂直同步信号
开启垂直同步后,显卡绘制3D图形前会等待垂直同步信号
,当该信号到达时,显卡才开始绘制3D图形,如果显卡性能较为强劲,在下个垂直同步信号到来之前已经完成了对该帧的渲染,显卡就会暂停处理,等下个垂直同步信号到来后才开始渲染下一帧。通俗的来讲,垂直同步就是让显卡每秒输出的帧数等于显示器的刷新率
。垂直同步是用来防止画面撕裂的
,反之,关闭垂直同步就会出现撕裂、跳帧的情况。
垂直同步信号虽然能解决屏幕撕裂现象,也增加了画面流畅度,但是需要消费更多的计算资源,也会带来部分延迟(屏幕卡顿)。
二、屏幕卡顿
在
VSync
信号到来后,系统图形服务会通过CADisplayLink
等机制通知App,App主线程开始在 CPU
中计算显示内容,比如视图的创建
、布局计算
、图片解码
、文本绘制
等。随后CPU
会将计算好的内容提交到GPU
去,由GPU
进行变换
、合成
、渲染
。随后GPU
会把渲染结果提交
到帧缓冲区去,等待下一次VSync
信号到来时显示到屏幕上。由于垂直同步
的机制,如果在一个VSync
时间内,CPU
或者GPU
没有完成内容提交,则那一帧就会被丢弃
,等待下一次机会再显示,而这时显示屏会保留之前的内容不变。这就是屏幕卡顿
的原因。从上面的图中可以看到,
CPU
和GPU
不论哪个阻碍了显示流程,都会造成掉帧现象。所以开发时,也需要分别对CPU
和GPU
压力进行评估和优化。
垂直同步信号与双缓冲的意义
强制同步屏幕刷新,以掉帧为代价解决屏幕撕裂问题。
屏幕卡顿的本质
CPU
和GPU
渲染流水线耗时过长,导致掉帧。
三、离屏渲染
APP通常的渲染流程:
App通过
CPU
和GPU
的合作,不停地将内容渲染完成放入Framebuffer
帧缓冲器中,而显示屏幕不断地从Framebuffer
中获取内容,显示实时的内容。而离屏渲染的流程:
与普通情况下GPU直接将渲染好的内容放入Framebuffer中不同,需要先
额外创建
离屏渲染缓冲区 Offscreen Buffer
,将提前渲染好的内容放入其中,等到合适的时机再将Offscreen Buffer
中的内容进一步叠加
、渲染
,完成后将结果切换到Framebuffer
中。
离屏渲染带来的问题
离屏渲染
时由于App需要提前
对部分内容进行额外的渲染并保存到Offscreen Buffer
,以及需要在必要时刻对Offscreen Buffer
和Framebuffer
进行内容切换,所以会需要更长的处理时间
(实际上这两步关于 buffer 的切换代价都非常大)。
并且Offscreen Buffer
本身就需要额外的空间
,大量的离屏渲染会造成内存
过大的压力。与此同时,Offscreen Buffer
的总大小也有限,不能超过屏幕总像素的2.5
倍。
可见离屏渲染的开销非常大
,一旦需要离屏渲染的内容过多,很容易造成掉帧
的问题。所以大部分情况下,我们都应该尽量避免离屏渲染。
为什么需要离屏渲染
既然离屏渲染会带来那么多的问题,那为什么又需要离屏渲染呢?
- 一些特殊效果需要使用额外的
Offscreen Buffer
来保存渲染的中间状态,所以不得不使用离屏渲染。比如系统自动触发的阴影、圆角等。 - 出于效率的目的,可以将内容提前渲染保存在
Offscreen Buffer
中,达到复用
的目的。
光栅化(shouldRasterize )
开启光栅化后
,会触发离屏渲染
,Render Server
会强制将CALayer
的渲染位图结果bitmap
保存下来,这样下次再需要渲染时就可以直接复用
,从而提高效率
。
而保存的bitmap包含layer
的subLaye
r、圆角
、阴影
、组透明度 group opacity
等,所以如果layer
的构成包含上述几种元素,结构复杂且需要反复利用,那么就可以考虑打开光栅化。
圆角
、阴影
、组透明度
等会由系统自动触发离屏渲染,那么打开光栅化可以节约第二次及以后的渲染时间。而多层 subLayer
的情况由于不会自动触发离屏渲染,所以相比之下会多花费第一次离屏渲染的时间,但是可以节约后续的重复渲染的开销。
不过使用光栅化的时候需要注意以下几点:
- 如果
layer
不能被复用,则没有必要打开光栅化 - 如果
layer
不是静态,需要被频繁修改,比如处于动画之中,那么开启离屏渲染反而影响效率
离屏渲染缓存内容有时间限制,缓存内容100ms
内如果没有被使用,那么就会被丢弃,无法进行复用 - 离屏渲染缓存空间有限,超过
2.5
倍屏幕像素大小的话也会失效,无法复用
圆角的离屏渲染
layer
由三层组成,我们设置圆角,会这样做:
view.layer.cornerRadius = 2
而苹果文档指出,cornerRadius
只会默认设置backgroundColor
和border
的圆角,而不会设置content
的圆角,除非同时设置了layer.masksToBounds
为true
(对应UIView
的clipsToBounds
属性)
Setting the radius to a value greater than 0.0 causes the layer to begin drawing rounded corners on its background. By default, the corner radius does not apply to the image in the layer’s contents property; it applies only to the background color and border of the layer. However, setting the masksToBounds property to true causes the content to be clipped to the rounded corners.
因此我们就知道,如果只是设置了cornerRadius
而没有设置masksToBounds
,由于不需要叠加裁剪,此时是并不会触发离屏渲染
的。而当设置了裁剪属性的时候,由于masksToBounds
会对 layer
以及所有subLayer
的content
都进行裁剪,所以会触发离屏渲染
。
离屏渲染的逻辑
图层的叠加绘制大概遵循画家算法
,在这种算法下会按层绘制,首先绘制距离较远的场景,然后用绘制距离较近的场景覆盖
较远的部分。
在普通的
layer
绘制中,上层的sublayer
会覆盖下层的sublayer
,下层sublayer
绘制完之后就可以抛弃
了,从而节约空间提高效率。所有sublayer
依次绘制完毕之后,整个绘制过程完成,就可以进行后续的呈现了。而当我们设置了
cornerRadius
以及masksToBounds
进行圆角 + 裁剪
时,如前文所述masksToBounds
裁剪属性会应用到所有
的sublayer
上。这也就意味着所有
的sublayer
必须要重新被应用一次圆角+裁剪
,这也就意味着所有
的sublayer
在第一次
被绘制完之后,并不能立刻被丢弃
,而必须要被保存在 Offscreen buffer
中等待
下一轮圆角+裁剪
,这也就诱发了离屏渲染
。实际上不只是
圆角+裁剪
,如果设置了透明度+组透明
(layer.allowsGroupOpacity+layer.opacity),阴影属性
(shadowOffset)等,都会产生类似的效果,因为组透明度
、阴影
都是和裁剪类似的,会作用与layer
以及其所有sublayer
上,这就导致必然会引起离屏渲染
。
避免圆角离屏渲染
除了尽量减少圆角裁剪的使用,还有什么别的办法可以避免圆角+裁剪引起的离屏渲染吗?
由于刚才我们提到,圆角引起离屏渲染的本质是裁剪的叠加,导致masksToBounds
对layer
以及所有sublayer
进行二次处理。那么我们只要避免使用masksToBounds
进行二次处理,而是对所有的sublayer
进行预处理,就可以只进行画家算法
,用一次叠加就完成绘制。
那么可行的实现方法大概有下面几种:
- 直接使用
带圆角的图片
,或者替换背景色为带圆角的纯色背景图,从而避免使用圆角裁剪。不过这种方法需要依赖具体情况,并不通用。 - 再增加一个和背景色相同的
遮罩mask
覆盖在最上层,盖住四个角,营造出圆角的形状。但这种方式难以解决背景色为图片或渐变色的情况。 - 用
贝塞尔曲线
绘制闭合带圆角的矩形,在上下文中设置只有内部可见,再将不带圆角的layer
渲染成图片,添加到贝塞尔矩形中。这种方法效率更高,但是layer
的布局一旦改变,贝塞尔曲线都需要手动地重新绘制,所以需要对frame
、color
等进行手动地监听并重绘。 - 重写
drawRect
,用CoreGraphics
相关方法,在需要应用圆角时进行手动绘制。不过CoreGraphics
效率也很有限,如果需要多次调用也会有效率问题。
触发离屏渲染原因的总结
- 使用了
mask
的layer
(layer.mask) - 需要进行裁剪的
layer
(layer.masksToBounds / view.clipsToBounds) - 设置了
组透明度
为YES
,并且透明度不为1
的layer
(layer.allowsGroupOpacity/layer.opacity) - 添加了投影的
layer
(layer.shadow) - 采用了光栅化的
layer
(layer.shouldRasterize) - 绘制了文字的
layer
(UILabel, CATextLayer, Core Text 等)
总结一下:设置圆角不一定会导致离屏渲染,离屏渲染不一定是由于设置圆角产生的
参考:iOS 渲染原理解析