离屏渲染和正常渲染
下面的流程图可以很好的描述离屏渲染和正常渲染的流程区别
离屏渲染比正常渲染多了一个离屏缓冲区环节。
正常渲染
App内的数据经过CPU计算以及GPU的渲染后,将结果存储到帧缓冲区。然后视频控制器从帧缓冲区取出来显示到屏幕上。
- 在GPU渲染到帧缓冲区的过程中,遵循的"画家算法",对于每一层layer,由远到近(由下往上)的按次序输出到frame buffer。后一层覆盖前一层,就能得到最终的显示结果。
离屏渲染
然而有些场景并没有那么简单。作为“画家”的GPU虽然可以一层一层往画布上进行输出,但是无法在某一层渲染完成之后,再回过头来擦除/改变其中的某个部分——因为在这一层之前的若干层layer像素数据,已经在渲染中被永久覆盖了。这就意味着,对于每一层layer,要么能找到一种通过单次遍历就能完成渲染的算法,要么就不得不另开一块内存,借助这个临时中转区域(也就是离屏缓冲区)来完成一些更复杂的、多次的修改/剪裁操作。
- 离屏缓冲区的空间并不是无限大的, 它是存在上限的,最大只能是屏幕的2.5倍。
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。
常见离屏渲染场景分析
cornerRadius+clipsToBounds。
如果要绘制一个带有圆角并剪切圆角以外内容的容器,就会有可能触发离屏渲染,也有可能不会触发离屏渲染。
下面的代码不会触发离屏渲染:
UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom];
btn.frame = CGRectMake(100, 180, 100, 100);
btn.layer.cornerRadius = 50;
btn.backgroundColor = [UIColor blueColor];
[self.view addSubview:btn];
btn.clipsToBounds = YES;
但是如果在上面代码的基础上给btn设置图片或者设置title就会导致触发离屏渲染。 经过Xcode的Debug View Hierarchy视图层次调试发现,给btn设置title后,发现视图层次中在btn上面添加了一个Label子视图用来承载title。同样给btn设置了图片后,给btn添加了一个ImageView来承载image图片。这样我们不难猜测是因为设置了title或者image后,btn已经不止一层layer,所以设置圆角后,无法通过一次遍历就能完成圆角的处理,因此需要使用到离屏缓冲区。 依次推断,在上面代码的基础上给btn添加一个子视图,同样也会引起离屏渲染。
shadow
其原因在于,虽然layer本身是一块矩形区域,但是阴影默认是作用在其中”非透明区域“的,而且需要显示在所有layer内容的下方,因此根据画家算法必须被渲染在先。但矛盾在于此时阴影的本体(layer和其子layer)都还没有被组合到一起,怎么可能在第一步就画出只有完成最后一步之后才能知道的形状呢?这样一来又只能另外申请一块内存,把本体内容都先画好,再根据渲染结果的形状,添加阴影,最后处理好的内容传递到frame buffer。不过如果我们能够预先告诉CoreAnimation(通过shadowPath属性)阴影的几何形状,那么阴影当然可以先被独立渲染出来,不需要依赖layer本体,也就不再需要离屏渲染了。
group opacity
如下图所示,一个UIView上添加UILabel。view和label的背景颜色都是白色,view的Alpha为0.5,label的alpha为1.0。左边是View的allowsGroupOpacity=true,右边的为false。发现左边开启了组透明度的触发了离屏渲染,右边未触发离屏渲染。
原理:alpha并不是分别应用在每一层之上,而是只有到整个layer树画完之后,再统一加上alpha,最后和底下其他layer的像素进行组合。显然也无法通过一次遍历就得到最终结果。
mask
我们知道mask是应用在layer和其所有子layer的组合之上的,而且可能带有透明度,那么其实和group opacity的原理类似,不得不在离屏渲染中完成。
光栅化
当我们开启光栅化时,会将layer渲染成位图保存在离屏渲染缓存中,这样在下次使用时,就可以直接复用,提高效率。 针对光栅化的使用,有以下几个建议:
- 如果layer不能被复用,则没有必要开启光栅化;
- 如果layer不是静态,需要被频繁修改(例如动画过程中),此时开启光栅化反而影响效率;
- 离屏渲染缓存内容有时间限制,如果100ms内没有被使用,那么就会丢弃,无法进行复用;
- 离屏渲染的缓存空间有限,是屏幕的2.5倍,超过2.5倍屏幕像素大小的话也会失效,无法实现复用。
UIBlurEffect
同样无法通过一次遍历完成
GPU离屏渲染的性能影响
GPU的操作是高度流水线化的。本来所有计算工作都在有条不紊地正在向frame buffer输出,此时突然收到指令,需要输出到另一块内存,那么流水线中正在进行的一切都不得不被丢弃,切换到只能服务于我们当前的“切圆角”操作。等到完成以后再次清空,再回到向frame buffer输出的正常流程。
在tableView或者collectionView中,滚动的每一帧变化都会触发每个cell的重新绘制,因此一旦存在离屏渲染,上面提到的上下文切换就会每秒发生60次,并且很可能每一帧有几十张的图片要求这么做,对于GPU的性能冲击可想而知(GPU非常擅长大规模并行计算,但是我想频繁的上下文切换显然不在其设计考量之中)。
那为什么我们明知有性能问题时,还是要使用离屏渲染呢?
- 可以处理一些特殊的效果,这种效果并不能一次就完成,需要使用离屏缓冲区来保存中间状态,不得不使用离屏渲染,例如经常使用的圆角、阴影、高斯模糊、光栅化等。
- 可以提升渲染的效率,如果一个效果是多次实现的,可以提前渲染,保存到离屏缓冲区,以达到复用的目的。这种情况是需要开发者手动触发的。
如果避免离屏渲染
- 直接更换资源,让UI提供带圆角的图片;
- 【UIBezierPath】使用贝塞尔曲线绘制闭合圆角的矩形,在上下文中设置只有内部可见,再将不带圆角的 layer 渲染成图片,添加到贝塞尔矩形中。这种方法效率更高,但是 layer 的布局一旦改变,贝塞尔曲线都需要手动地重新绘制,所以需要对 frame、color 等进行手动地监听并重绘;
- AsyncDisplayKit(Texture)作为主要渲染框架,对于文字和图片的异步渲染操作交由框架来处理;
- 对于图片的圆角,统一采用“precomposite”的策略,也就是不经由容器来做剪切,而是预先使用CoreGraphics为图片裁剪圆角;
- 对于所有的阴影,使用shadowPath来规避离屏渲染;
- 对于特殊形状的view,使用layer mask并打开shouldRasterize来对渲染结果进行缓。