我们在平时的iOS面试中,经常会遇到有关离屏渲染(Offscreen rendering
)的知识点。一般来说,绝大多数人都能答出圆角、mask、阴影会触发离屏渲染
,但是也仅止于此。如果再问得深入哪怕一点点,比如:
离屏渲染是在哪一步进行的?为什么?
设置cornerRadius
一定会触发离屏渲染吗?
90%的候选人都没法非常确定地说出答案。作为一个客户端工程师,把控渲染性能是最关键、最独到的技术要点之一,如果仅仅了解表面知识,到了实际应用时往往会失之毫厘谬以千里,无法得到预期的效果。
离屏渲染的定义
如果要在显示屏上显示内容,我们至少需要一块与屏幕像素数据量一样大的frame buffer
作为像素数据存储区域,而这也是GPU存储渲染结果的地方。如果有时因为面临一些限制,无法把渲染结果直接写入frame buffer,而是先暂存在另外的内存区域
,之后再写入frame buffer
,那么这个过程被称之为离屏渲染
。
CPU”离屏渲染“
这里所说的CPU的离屏渲染并不是真正的离屏渲染,大家知道,如果我们在UIView
中实现了drawRect
方法,就算它的函数体内部实际没有代码,系统也会为这个view
申请一块内存区域,等待CoreGraphics
可能的绘画操作。
对于类似这种新开一块CGContext来画图
的操作,有很多文章和视频也称之为"离屏渲染"
(因为像素数据是暂时存入了CGContext,而不是直接到了frame buffer)。进一步来说,其实所有CPU进行的光栅化操作(如文字渲染、图片解码),都无法直接绘制到由GPU掌管的frame buffer,只能暂时先放在另一块内存之中,说起来都属于“离屏渲染”。
自然我们会认为,因为CPU不擅长做这件事,所以我们需要尽量避免它,就误以为这就是需要避免离屏渲染的原因。但是CPU渲染并非真正意义上的离屏渲染。证据就是,如果你的view实现了drawRect,此时打开Xcode调试的“Color offscreen rendered yellow”
开关,你会发现这片区域不会被标记为黄色,说明Xcode并不认为这属于离屏渲染。
其实通过CPU渲染就是俗称的“软件渲染”
,而真正的离屏渲染发生在GPU。
GPU离屏渲染
在上面的渲染流水线示意图中我们可以看到,主要的渲染操作都是由CoreAnimation
的Render Server
模块,通过调用显卡驱动所提供的OpenGL/Metal
接口来执行的。通常对于每一层layer,Render Server会遵循画家算法
,按次序输出到frame buffer
,后一层覆盖前一层,就能得到最终的显示结果(值得一提的是,与一般桌面架构不同,在iOS中,设备主存和GPU的显存共享物理内存
,这样可以省去一些数据传输开销)。
然而有些场景并没有那么简单。作为“画家”的GPU虽然可以一层一层往画布上进行输出,但是无法在某一层渲染完成之后,再回过头来擦除/改变其中的某个部分——因为在这一层之前的若干层layer像素数据,已经在渲染中被永久覆盖了。这就意味着,对于每一层layer,要么能找到一种通过单次遍历就能完成渲染的算法,要么就不得不另开一块内存,借助这个临时中转区域来完成一些更复杂的、多次的修改/剪裁操作。
什么时候会触发离屏渲染
如果需要使用离屏缓存区(Offscreen Buffer)
进行位图的处理时,就会触发离屏渲染。常见的情况有下面几种:
-
cornerRadius+clipsToBounds/layer.masksToBounds
,这里我们来看一段代码
//1.按钮存在背景图片
UIButton *btn1 = [UIButton buttonWithType:UIButtonTypeCustom];
btn1.frame = CGRectMake(100, 30, 100, 100);
btn1.layer.cornerRadius = 50;
[self.view addSubview:btn1];
[btn1 setImage:[UIImage imageNamed:@"btn.png"] forState:UIControlStateNormal];
btn1.clipsToBounds = YES;
//2.按钮不存在背景图片
UIButton *btn2 = [UIButton buttonWithType:UIButtonTypeCustom];
btn2.frame = CGRectMake(100, 180, 100, 100);
btn2.layer.cornerRadius = 50;
btn2.backgroundColor = [UIColor blueColor];
[self.view addSubview:btn2];
btn2.clipsToBounds = YES;
//3.UIImageView 设置了图片+背景色;
UIImageView *img1 = [[UIImageView alloc]init];
img1.frame = CGRectMake(100, 320, 100, 100);
img1.backgroundColor = [UIColor blueColor];
[self.view addSubview:img1];
img1.layer.cornerRadius = 50;
img1.layer.masksToBounds = YES;
img1.image = [UIImage imageNamed:@"btn.png"];
//4.UIImageView 只设置了图片,无背景色;
UIImageView *img2 = [[UIImageView alloc]init];
img2.frame = CGRectMake(100, 480, 100, 100);
[self.view addSubview:img2];
img2.layer.cornerRadius = 50;
img2.layer.masksToBounds = YES;
img2.image = [UIImage imageNamed:@"btn.png"];
模拟器运行,通过Debug-Color offscreen rendered
,可以看到只有1和3
触发了离屏渲染,而2和4
并没有触发。
这是为什么呢?
想解释这个我们需要先了解一下layer
的结构
layer
是由backgroundColor、contents、borderWidth&borderColor
构成的,跟我们即将解释的圆角触发离屏渲染息息相关,我们在苹果的官方文档可以找到答案
官方文档告诉我们,设置
cornerRadius
只会对CALayer中的backgroundColor 和 border
设置圆角,不会设置contents
的圆角,如果contents需要设置圆角,需要同时将maskToBounds / clipsToBounds
设置为true。
发现规律:
- 当只设置
backgroundColor、border
,而contents中没有子视图时,无论maskToBounds / clipsToBounds
是true
还是false
,都不会触发离屏渲染- 当contents中有子视图时,此时设置
cornerRadius
+maskToBounds / clipsToBounds
,就会触发离屏渲染,但是这种情况在UIImageView中并不适用,当UIImageView中只设置图片+maskToBounds / clipsToBounds
是不会触发离屏渲染,苹果对UIImageView优化我想也只是将image直接画在了contents上面这样不设置背景色其实只需要渲染一个layer,所以不需要用到离屏缓冲区,
,所以不会产生离屏渲染,如果此时再加上背景色,就会触发离屏渲染。
shadow,其原因在于,虽然layer本身是一块矩形区域,但是阴影默认是作用在其中”非透明区域“的,而且需要显示在所有layer内容的下方,因此根据画家算法必须被渲染在先。但矛盾在于此时阴影的本体(layer和其子layer)都还没有被组合到一起,怎么可能在第一步就画出只有完成最后一步之后才能知道的形状呢?这样一来又只能另外申请一块内存,把本体内容都先画好,再根据渲染结果的形状,添加阴影到frame buffer,最后把内容画上去(这只是我的猜测,实际情况可能更复杂)。不过如果我们能够预先告诉
CoreAnimation
(通过shadowPath
属性)阴影的几何形状,那么阴影当然可以先被独立渲染出来,不需要依赖layer本体,也就不再需要离屏渲染了。group opacity
(组透明度),其实从名字就可以猜到,alpha并不是分别应用在每一层之上,而是只有到整个layer树画完之后,再统一加上alpha,最后和底下其他layer的像素进行组合。显然也无法通过一次遍历就得到最终结果。将一对蓝色和红色layer叠在一起,然后在父layer上设置opacity=0.5,并复制一份在旁边作对比。左边关闭group opacity,右边保持默认(从iOS7开始,如果没有显式指定,group opacity会默认打开),然后打开offscreen rendering
的调试,我们会发现右边的那一组确实是离屏渲染了。
- mask,我们知道mask是应用在layer和其所有子layer的组合之上的,而且可能带有透明度,那么其实和group opacity的原理类似,不得不在离屏渲染中完成。
- UIBlurEffect,同样无法通过一次遍历完成
GPU离屏渲染的性能影响
GPU的操作是高度流水线化的。本来所有计算工作都在有条不紊地正在向frame buffer
输出,此时突然收到指令,需要输出到另一块内存,那么流水线中正在进行的一切都不得不被丢弃,切换到只能服务于我们当前的操作(例如“切圆角”)。等到完成以后再次清空,再回到向frame buffer
输出的正常流程。
在tableView
或者collectionView
中,滚动的每一帧变化都会触发每个cell的重新绘制,因此一旦存在离屏渲染,上面提到的上下文切换就会每秒发生60次
,并且很可能每一帧有几十张的图片要求这么做,对于GPU的性能冲击可想而知(GPU非常擅长大规模并行计算,但是我想频繁的上下文切换显然不在其设计考量之中)
善用离屏渲染
尽管离屏渲染开销很大,但是当我们无法避免它的时候,可以想办法把性能影响降到最低。优化思路也很简单:既然已经花了不少精力把图片裁出了圆角,如果我能把结果缓存下来,那么下一帧渲染就可以复用这个成果,不需要再重新画一遍了。
CALayer为这个方案提供了对应的解法:shouldRasterize
(光栅化)。一旦被设置为true,Render Server
就会强制把layer的渲染结果(包括其子layer,以及圆角、阴影、group opacity等等)保存在一块内存中,这样一来在下一帧仍然可以被复用,而不会再次触发离屏渲染。有几个需要注意的点:
When the value of this property is YES, the layer is rendered as a bitmap in its local coordinate space and then composited to the destination with any other content.
当我们开启光栅化时,会将layer渲染成位图保存在缓存中,这样在下次使用时,就可以直接复用,提高效率。
针对光栅化的使用,有以下几个建议:
- 如果layer不能被复用,则没有必要开启光栅化
- 如果layer不是静态,需要被频繁修改(例如动画过程中),此时开启光栅化反而影响效率
- 离屏渲染缓存内容有时间限制,如果100ms内没有被使用,那么就会丢弃,无法进行复用
- 离屏渲染的缓存空间有限,是屏幕的2.5倍,超过2.5倍屏幕像素大小的话也会失效,无法实现复用