离屏渲染(OffScreen Rendering) 这个概念对于iOS开发者来说并不陌生,对App的性能优化和面试中不止一次的遇到,今天我们再来聊一聊这个问题。
本来是想写在上一篇 iOS下的图像渲染原理 中的,感觉篇幅有点长了,影响阅读体验,所以单写了一篇。
什么是离屏渲染
在讨论离屏渲染之前,我们先来看看正常的渲染逻辑。
这里省略了其他的渲染细节。GPU 以60 FPS的帧率将渲染结果存储到帧缓冲区(Frame Buffer), 屏幕把每一帧图像以60 Hz的频率刷新显示。
那么离屏渲染的大致流程是是什么样的呢?
如果有时因为面临一些限制,无法把渲染结果直接写入Frame Buffer,而是先暂存在另外的内存区域,之后再写入Frame Buffer,那么这个过程被称之为离屏渲染。
GPU 离屏渲染
通过上一篇iOS下的图像渲染原理的讲解,我们知道主要的渲染操作都是由 GPU CoreAnimation的Render Server模块,通过调用显卡驱动所提供的OpenGL/Metal接口来执行的。
对于每一层layer,Render Server会遵循画家算法,按次序输出到Frame Buffer,后一层覆盖前一层,就能得到最终的显示结果。
但是有一些情况下,并没有这么简单。GPU 的Render Server遵循画家算法,一层一层的进行输出,但是在某一层渲染完成后,无法在回过头来处理或者改变其中的某个部分,因为在这之前的所有层像素数据,已经在渲染结束后被永久覆盖/丢弃了。
如果我们想对某一层layer进行叠加/裁剪或者其他复杂的操作,就不得不新开一块内存区域,来处理这些些更加复杂的操作。
CPU 离屏渲染
我们看过一些文章有提到过CPU离屏渲染,那么什么是CPU离屏渲染呢?
如果我们在UIView中实现了-drawRect方法,就算它的函数体内部实际没有代码,系统也会为这个view申请一块内存区域,等待CoreGraphics可能的绘画操作。
这种情况下,新开辟了一块CGContext,渲染数据暂时存储在了CGContext中,而没有给到Frame Buffer。根据上面的定义来说,没有把渲染结果直接给到Frame Buffer的,那就属于离屏渲染了。
但是,所有 CPU 执行的光栅化操作,比如图片的解码等等,都无法直接绘制到 GPU 的Frame Buffer 中,多需要一块用来中转的内存区域。当然,我们知道 CPU 并不擅长渲染,所以我们应该尽量避免使用 CPU 渲染。根据苹果的说法,这并非真正意义上的离屏渲染,并且如果我们重写了-drawRect方法,使用Xcode检测,也并不会被标记为离屏渲染。
如何检测项目中哪些图层触发了离屏渲染?
在模拟器中通过设置 Color Off-Screen Rendered 来检查哪些图层触发了离屏渲染。
触发了离屏渲染的图层会被标记为黄色。
触发离屏渲染的场景
通过设置cornerRadius与masksToBounds达到圆角裁切效果
当需要裁切图层的内容content,很显然这就需要开辟一块内存来操作了。当只设置cornerRadius时,不需要裁切内容,只需要一个带圆角的边框,则不会触发离屏渲染。
shadow
阴影依赖layer本身的形状等信息,并且根据画家算法,阴影需要先画出来,这样来说就需要在单独的内存中先进行依赖的合成计算,再添加到Frame Buffer,造成离屏渲染。不过如果我们能够预先告诉CoreAnimation(通过shadowPath属性)阴影的几何形状,那么阴影当然可以先被独立渲染出来,不需要依赖layer本体,也就不再需要离屏渲染了。
group opacity
需要将一组图层画完之后,再整体加上alpha,最后和底下其他layer的像素进行组合。显然也无法通过一次遍历就得到最终结果。
mask
我们知道mask是应用在layer和其所有子layer的组合之上的,而且可能带有透明度,那么其实和group opacity的原理类似,不得不在离屏渲染中完成。
UIBlurEffect
渲染出毛玻璃效果,需要先画出原图层,然后capture原图层,进行水平模糊(Horizontal Blur)和垂直模糊(Vertical Blur),最后进行合成操作。显然这需要在离屏缓冲区中完成。
shouldRasterize
shouldRasterize一旦被设置为YES,Render Server就会强制把layer的渲染结果(包括其子layer,以及圆角、阴影、group opacity等等)保存在一块内存中,这样一来在下一帧仍然可以被复用,而不会再次触发离屏渲染。有几个需要注意的点
shouldRasterize的主旨在于降低性能损失,但总是至少会触发一次离屏渲染。如果你的layer本来并不复杂,也没有圆角阴影等等,则没有必要打开shouldRasterize- 如果layer的子结构非常复杂,渲染一次所需时间较长,可以打开
shouldRasterize,把layer绘制到一块缓存,然后在接下来复用这个结果,这样就不需要每次都重新绘制整个layer树了 - 离屏渲染缓存有空间上限,最多不超过屏幕总像素的
2.5倍大小,如果超出了会自动被丢弃,且无法被复用了 - 离屏渲染缓存内容有时间限制,一旦缓存超过
100ms没有被使用,会自动被丢弃,且无法被复用了 - 如果layer不是静态的,需要被频繁修改,比如处在动画之中,那么开启
shouldRasterize反而影响效率了
如果你无法仅仅使用Frame Buffer来画出最终结果,那就只能另开一块内存空间来储存中间结果。
圆角问题
通常情况下,我们会使用 cornerRadius 来设置圆角
view.layer.cornerRadius = 50;
我们看过很多文章都在说单独使用 cornerRadius 是不会触发离屏渲染的,先来实现一个非常简单圆角Button,只设置了backgroundColor,没有setImage:
UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom];
btn.frame = (CGRect){
.origin.x = 100,
.origin.y = 280,
.size.width = 100,
.size.height = 100,
};
btn.backgroundColor = [UIColor blueColor];
btn.layer.cornerRadius = 50;
[self.view addSubview:btn2];
此时的确不会触发离屏渲染,也达到了圆角的目的。
在实际项目中,一般会使用一张图片作为Button或者ImageView的背景,这样如果只设置cornerRadius ,是达不到圆角的效果的,还需要设置masksToBounds = YES
imageView.layer.cornerRadius = 50;
imageView.layer.masksToBounds = YES;
这里用UIImageView来举例,我们看下效果
UIImageView *imageView = [[UIImageView alloc] init];
imageView.frame = (CGRect){
.origin.x = 100,
.origin.y = 400,
.size.width = 100,
.size.height = 100,
};
imageView.layer.cornerRadius = 50;
imageView.layer.masksToBounds = YES;
imageView.image = [UIImage imageNamed:@"btn.png"];
[self.view addSubview:imageView];
可以看到,这里仍然没有发生离屏渲染。那么离屏渲染到底和什么有关系呢?
还是上面的UIImageView的案例,我们尝试设置一下它的backgroundColor
UIImageView *imageView = [[UIImageView alloc] init];
imageView.frame = (CGRect){
.origin.x = 100,
.origin.y = 400,
.size.width = 100,
.size.height = 100,
};
imageView.layer.cornerRadius = 50;
imageView.layer.masksToBounds = YES;
imageView.backgroundColor = [UIColor blueColor];
imageView.image = [UIImage imageNamed:@"btn.png"];
[self.view addSubview:imageView];
在同时设置了backgroundColor和setImage:之后,这里触发了离屏渲染。
总结
关于性能优化,就是平衡 CPU 与 GPU 的负载工作,因为要做的事情就那么多。当 GPU 忙不过来的时候,我们可以利用 CPU 的空闲来渲染然后提交给 GPU 显示,来提高整体的渲染效率。渲染不是CPU的强项,调用CoreGraphics会消耗其相当一部分计算时间,一般来说 CPU 渲染都在后台线程完成(这也是AsyncDisplayKit的主要思想),然后再回到主线程上,把渲染结果传回CoreAnimation。这样一来,多线程间数据同步会增加一定的复杂度。CPU渲染速度不够快,因此只适合渲染静态的元素,如文字、图片。作为渲染结果的bitmap数量较大,很容易导致OOM。如果你选择使用 CPU 来做渲染,那么就没有理由再触发 GPU 的离屏渲染了。