原文链接:关于iOS离屏渲染的深入研究、iOS圆角的离屏渲染
作为一个客户端工程师,把控渲染性能是最关键、最独到的技术要点之一,如果仅仅了解表面知识,到了实际应用时往往会失之毫厘谬以千里,无法得到预期的效果。
一、先来了解iOS的渲染流程
如果对下图没有疑问,可以直接向下阅读。反之,可以先阅读上篇文章 渲染流程探究及性能分析
二、GPU的两种渲染方式
OpenGL中,GPU屏幕渲染有以下两种方式:
2.1 On-Screen Rendering
On-Screen Rendering意为当前屏幕渲染,指的是GPU的渲染操作是在当前用于显示的屏幕缓冲区中进行。
正常情况下,如果要在显示屏上显示内容,我们至少需要一块与屏幕像素数据量一样大的帧缓冲区(frame buffer),作为像素数据存储区域,而这也是GPU存储渲染结果的地方。流程如图:
2.2 Off-Screen Rendering
Off-Screen Rendering意为离屏渲染,指的是GPU在当前屏幕缓冲区以外新开辟一个(离屏)缓冲区进行渲染操作。
如果有时因为一些限制,无法把渲染结果直接写入frame buffer,而是先暂存在另外的内存区域,之后再写入frame buffer,那么这个过程被称之为离屏渲染。流程如图:
三、GPU离屏渲染
3.1 为什么及何时需要离屏渲染
在上面的渲染流水线示意图中我们可以看到,主要的渲染操作都是由CoreAnimation的Render Server模块,通过调用显卡驱动所提供的OpenGL/Metal接口来执行的。通常对于每一层layer,Render Server会遵循“画家算法”,按次序输出到frame buffer,后一层覆盖前一层,就能得到最终的显示结果(值得一提的是,与一般桌面架构不同,在iOS中,设备主存和GPU的显存共享物理内存,这样可以省去一些数据传输开销)。
画家算法通过按深度对图像中的物体进行排序,并按从(距离观察者)最远到最近的顺序,依次将每一层绘制输出到画布,来创建图像。
然而有些场景并没有那么简单。作为“画家”的GPU虽然可以一层一层往画布上进行输出,但是无法在某一层渲染完成之后,再回过头来擦除/改变其中的某个部分——因为在这一层之前的若干层layer像素数据,已经在渲染中被永久覆盖了。这就意味着,对于每一层layer,要么能找到一种通过单次遍历就能完成渲染的算法,要么就不得不另开一块内存,借助这个临时中转区域来完成一些更复杂的、多次的修改/剪裁操作。
以绘制一个带有圆角并剪切圆角以外内容的容器,会触发离屏渲染为例。我的猜想是(如果读者中有图形学专家希望能指正):
- 将一个layer的内容裁剪成圆角,可能不存在一次遍历就能完成的方法
- 容器的子layer因为父容器有圆角,那么也会需要被裁剪,而这时它们还在渲染队列中排队,尚未被组合到一块画布上,自然也无法统一裁剪
此时我们就不得不开辟一块独立于frame buffer的空白内存,先把容器以及其所有子layer依次画好,然后把四个角“剪”成圆形,再把结果画到frame buffer中。这就是GPU的离屏渲染。
个人总结:什么场景下需要离屏渲染
- 当一个属性的设置需要对一个以上的图层修改时。
- 如设置
cornerRadius以及masksToBounds进行圆角+裁剪时,masksToBounds裁剪属性会应用到所有的图层上。如果此时View、layer中不止一个图层,那就会触发离屏渲染了。 - 如设置
group opacity
- 如设置
- 当一个图层,按照画家算法本应该先绘制,但由于依赖后续图层的数据,只能延后绘制时
- 阴影默认是作用在其中”非透明区域“的,而且需要显示在所有layer内容的下方,因此根据画家算法必须被渲染在先。但矛盾在于此时阴影的本体(layer和其子layer)都还没有被组合到一起,怎么可能在第一步就画出只有完成最后一步之后才能知道的形状呢? 即阴影需要全部内容绘制完成,再根据外轮廓进行绘制。这就导致了,阴影这一层要一直占据一块内存区域,这就导致了离屏渲染。
3.2 GPU离屏渲染的性能影响
离屏渲染增大了系统的负担,会形象App性能。主要表现在以下几个方面:
- 离屏渲染需要额外的存储空间,渲染空间大小的上限是2.5倍的屏幕像素大小,超过无法使用离屏渲染。
- 容易掉帧:一旦因为离屏渲染导致最终存入帧缓存区的时候,已经超过了16.67ms,则会出现掉帧的情况,造成卡顿。
GPU的操作是高度流水线化的。本来所有计算工作都在有条不紊地正在向frame buffer输出,此时突然收到指令,需要输出到另一块内存,那么流水线中正在进行的一切都不得不被丢弃,切换到只能服务于我们当前的“切圆角”操作。等到完成以后再次清空,再回到向frame buffer输出的正常流程。
在tableView或者collectionView中,滚动的每一帧变化都会触发每个cell的重新绘制,因此一旦存在离屏渲染,上面提到的上下文切换就会每秒发生60次,并且很可能每一帧有几十张的图片要求这么做,对于GPU的性能冲击可想而知(GPU非常擅长大规模并行计算,但是我想频繁的上下文切换显然不在其设计考量之中)
每16ms就需要根据当前滚动位置渲染整个tableView,是个不小的性能挑战:
3.3 善用离屏渲染 shouldRasterize
尽管离屏渲染开销很大,但是当我们无法避免它的时候,可以想办法把性能影响降到最低。优化思路也很简单:既然已经花了不少精力把图片裁出了圆角,如果我能把结果缓存下来,那么下一帧渲染就可以复用这个成果,不需要再重新画一遍了。
CALayer为这个方案提供了对应的解法:shouldRasterize。一旦被设置为true,Render Server就会强制把layer的渲染结果(包括其子layer,以及圆角、阴影、group opacity等等)保存在一块内存中,这样一来在下一帧仍然可以被复用,而不会再次触发离屏渲染。有几个需要注意的点:
- 首先,layer不复用,没必要打开shouldRasterize。
- shouldRasterize的主旨在于降低性能损失,但总是至少会触发一次离屏渲染。如果你的layer本来并不复杂,也没有圆角阴影等等,打开这个开关反而会增加一次不必要的离屏渲染
- 离屏渲染缓存有空间上限,最多不超过屏幕总像素的2.5倍大小
- 一旦缓存超过100ms没有被使用,会自动被丢弃
- layer的内容(包括子layer)必须是静态的,因为一旦发生变化(如resize,动画),之前辛苦处理得到的缓存就失效了。如果这件事频繁发生,我们就又回到了“每一帧都需要离屏渲染”的情景,而这正是开发者需要极力避免的。针对这种情况,Xcode提供了“Color Hits Green and Misses Red”的选项,帮助我们查看缓存的使用是否符合预期
- 其实除了解决多次离屏渲染的开销,shouldRasterize在另一个场景中也可以使用:如果layer的子结构非常复杂,渲染一次所需时间较长,同样可以打开这个开关,把layer绘制到一块缓存,然后在接下来复用这个结果,这样就不需要每次都重新绘制整个layer树了
四、GPU离屏渲染常见场景分析及优化
4.1 关于cornerRadius
我们经常看到,圆角会触发离屏渲染。但其实这个说法是不准确的,因为圆角触发离屏渲染也是有条件的。
4.1.1 cornerRadius的官方释义
我们先来看看苹果官方文档对于cornerRadius的描述:
Setting the radius to a value greater than
0.0causes the layer to begin drawing rounded corners on its background. By default, the corner radius does not apply to the image in the layer’scontentsproperty; it applies only to the background color and border of the layer. However, setting themasksToBoundsproperty totruecauses the content to be clipped to the rounded corners.
设置cornerRadius大于0时,只会为layer的backgroundColor和border设置圆角。
只有同时设置了layer.masksToBounds为true(对应UIView的clipsToBounds属性)时,才会同时对layer的contents设置圆角。
- clipsToBounds:是类View的属性,如果设置为yes,则不显示超出父View的部分
- masksToBounds:是类CALayer的属性,如果设置为yes,则不显示超出父View layer的部分
如果这时,你认为layer.masksToBounds或者clipsToBounds设置为true就会触发离屏渲染,这是不完全正确的。
4.1.2 测试用例及现象(何时触发离屏渲染)
测试环境:Xcode 11.5 、iPhone 11 Pro Simulator 、iOS 13.5
测试手段:模拟器Simulator — Debug — Color Off-screen Rendered
测试用例及结论:
1 | UIView *view1 = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 200.0, 200.0)]; |
4.1.3 圆角触发离屏渲染的原因
当我们设置了cornerRadius以及masksToBounds进行圆角+裁剪时,masksToBounds裁剪属性会应用到所有的图层上。
本来我们从后往前绘制,绘制完一个图层就可以丢弃了。但现在需要依次在 Offscreen Buffer中保存,等待圆角+裁剪处理,即引发了 离屏渲染 。
背景色、边框、背景色+边框,再加上圆角+裁剪,根据文档说明,因为 contents = nil 没有需要裁剪处理的内容,所以
masksToBounds设置为YES或者NO都没有影响。一旦我们 为contents设置了内容 ,无论是图片、绘制内容、有图像信息的子视图等,再加上圆角+裁剪,就会触发离屏渲染。
不一定是直接为contents赋值!
原因就如同上面提到的,不得已只能另开一块内存来操作。而如果只是设置cornerRadius(如不需要剪切内容,只需要一个带圆角的边框),或者只是需要裁掉矩形区域以外的内容(虽然也是剪切,但是稍微想一下就可以发现,对于纯矩形而言,实现这个算法似乎并不需要另开内存),并不会触发离屏渲染。
4.1.4 iOS9及以后的优化
关于圆角,iOS 9及之后的系统版本,苹果进行了一些优化。
layer.contents/imageView.image- 我们只设置
contents或者UIImageView的image,并加上圆角+裁剪,是不会产生离屏渲染的。但如果加上了背景色、边框或其他有图像内容的图层,还是会产生离屏渲染。1
2
3
4
5
6
7UIView *view1 = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 200.0, 200.0)];
//设置图片
view1.layer.contents = (__bridge id)[UIImage imageNamed:@"qiyu"].CGImage;
// 设置圆角
view1.layer.cornerRadius = 100.0;
// 设置裁剪
view1.clipsToBounds = YES; - 其实这也是可以理解的,因为只有 单层 内容需要添加圆角和裁切,所以可以不需要用到离屏渲染技术。
- 但如果加上了背景色、边框或其他有图像内容的图层,就会产生为 多层 添加圆角和裁切,所以还是会触发离屏渲染(如2中的第3个例子)。
- 我们只设置
UIButton:使用类似于
UIButton的视图的时候需要注意:为UIButton设置一个图片,其实会添加一个UIImageView。为设置了图片的
UIButton添加圆角和裁剪,则会触发离屏渲染。1
2
3
4// 设置圆角
button.layer.cornerRadius = 100.0;
// 设置裁剪
button.clipsToBounds = YES;为设置了背景色的
UIButton添加圆角和裁剪,不会触发离屏渲染。如果改为
UIButton中的UIImageView添加圆角和裁剪,则 不会触发离屏渲染。1
2
3
4// 设置圆角
button.imageView.layer.cornerRadius = 100.0;
// 设置裁剪
button.imageView.clipsToBounds = YES;
4.1.5 实现圆角造成的离屏渲染优化
方案一:使用切图
方法1:要展示的图片,设计切成圆角状
方法2:在要添加圆角的视图上再叠加一个中间透明、对圆角部分遮挡的视图。图层混合的透明度处理方式与mask正好相反。此方法虽然是最优解,没有离屏渲染,没有额外的CPU计算,但是应用范围有限。
1
2
3
4
5
6//混合图层
UIView *parent = [view superview];
UIImageView *cover = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, imgSize.width, imgSize.height)];
cover.image = [UIImage imageNamed:@"cover"];
[parent addSubview:cover];
cover.center = view.center;
方案二:Core Graphics + UIBezierPath 贝塞尔曲线绘制圆角
1 | - (UIImage *)imageWithCornerRadius:(CGFloat)radius ofSize:(CGSize)size{ |
方案三:CAShapeLayer + UIBezierPath 绘制圆角来实现UITableViewCell圆角并绘制边框颜色(这种方式比直接设置圆角方式好,但也会触发离屏渲染),代码如下:
1 | - (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath{ |
关于方案三的解释:
- CAShapeLayer继承于CALayer,因而可以使用CALayer的所有属性值;
- CAShapeLayer需要和贝塞尔曲线配合使用才能够实现效果;
- CAShapeLayer(属于CoreAnimation)与贝塞尔曲线配合使用可以实现不在view的drawRect(继承于CoreGraphics走的是CPU,消耗的性能较大)方法中画出想要的图形;
- CAShapeLayer动画渲染是驱动GPU,而view的drawRect方法使用CPU渲染,相比其效率更高,消耗内存更少。
总的来说使用CAShapeLayer的内存消耗少,渲染速度快。
关于剪切圆角的性能优化,根据场景不同有几个方案可供选择,非常推荐阅读AsyncDisplayKit中的一篇文档。
ASDK中对于如何选择圆角渲染策略的流程图,非常实用:
YYKit是开发中经常用的三方库,YYImage对图片圆角的处理方法是值得推荐的,附上实现源码:
1 | - (UIImage *)imageByRoundCornerRadius:(CGFloat)radius |
4.2 shadow
一般情况下是用以下代码,但是不要用
1 | CALayer *imageViewLayer = cell.imageView.layer; |
请用更高效的
1 | imageViewLayer.shadowPath = CGPathCreateWithRect(imageRect, NULL); |
其原因在于,虽然layer本身是一块矩形区域,但是阴影默认是作用在其中”非透明区域“的,而且需要显示在所有layer内容的下方,因此根据画家算法必须被渲染在先。
Core Animation 必须要知道阴影的形状和位置。但矛盾在于时阴影的本体(layer和其子layer)都还没有被组合到一起,怎么可能在第一步就画出只有完成最后一步之后才能知道的形状呢?这样一来又只能另外申请一块内存,把本体内容都先画好,再查看其 alpha channel 来找出阴影的位置,渲染阴影到frame buffer,最后把内容画上去(实际情况有些复杂)。
不过如果我们能够预先告诉CoreAnimation(通过shadowPath属性)阴影的几何形状,那么阴影当然可以先被独立渲染出来,不需要依赖layer本体,也就不再需要离屏渲染了。
阴影会作用在所有子layer所组成的形状上,那就只能等全部子layer画完才能得到:
4.3 group opacity
设置了组透明度为 YES,并且透明度不为 1 的layer (layer.allowsGroupOpacity/ layer.opacity)。
其实从名字就可以猜到,alpha并不是分别应用在每一层之上,而是只有到整个layer树画完之后,再统一加上alpha,最后和底下其他layer的像素进行组合。显然也无法通过一次遍历就得到最终结果。将一对蓝色和红色layer叠在一起,然后在父layer上设置opacity=0.5,并复制一份在旁边作对比。左边关闭group opacity,右边保持默认(从iOS7开始,如果没有显式指定,group opacity会默认打开),然后打开offscreen rendering的调试,我们会发现右边的那一组确实是离屏渲染了。
同样的两个view,右边打开group opacity(默认行为)的被标记为Offscreen rendering:
4.4 mask
我们知道mask是应用在layer和其所有子layer的组合之上的,而且可能带有透明度,那么其实和group opacity的原理类似,不得不在离屏渲染中完成。
WWDC中苹果的解释,mask需要遍历至少三次:
4.5 UIBlurEffect
同样无法通过一次遍历完成,其原理在WWDC中提到:
4.6 shouldRasterize
shouldRasterize的主旨在于降低性能损失,但总是至少会触发一次离屏渲染。
shouldRasterize光栅化开启后,会将layer作为位图保存到OffscreenBuffer中。下次直接与其他内容进行混合。这样下次需要再次渲染的时候,就可以直接拿来使用了。
4.7 其他
其他还有一些,类似绘制了文字的 layer (UILabel, CATextLayer, Core Text 等)、allowsEdgeAntialiasing等等也可能会触发离屏渲染,原理也都是类似:如果你无法仅仅使用frame buffer来画出最终结果,那就只能另开一块内存空间来储存中间结果。这些原理并不神秘。
五、CPU”离屏渲染“?
5.1 CPU渲染
特殊的离屏渲染:
如果将不在GPU的当前屏幕缓冲区中进行的渲染都称为离屏渲染,那么是不是还有另一种特殊的“离屏渲染”方式: CPU渲染?
大家知道,如果我们在UIView中重写了drawRect方法,并且使用任何Core Graphics的技术进行了绘制操作,就涉及到了CPU渲染。整个渲染过程由CPU在App内 同步地完成,渲染得到的bitmap最后再交由GPU用于显示。(就算函数体内部实际没有代码,系统也会为这个view申请一块内存区域,等待CoreGraphics可能的绘画操作)。
备注:CoreGraphic通常是线程安全的,所以可以进行异步绘制,显示的时候再放回主线程
对于类似上面这种“新开一块CGContext来画图“的操作,有很多文章和视频也称之为“离屏渲染”(因为像素数据是暂时存入了CGContext,而不是直接到了frame buffer)。进一步来说,其实所有CPU进行的光栅化操作(如文字渲染、图片解码),都无法直接绘制到由GPU掌管的frame buffer,只能暂时先放在另一块内存之中,说起来都属于“离屏渲染”。
自然我们会认为,因为CPU不擅长做这件事,所以我们需要尽量避免它,就误以为这就是需要避免离屏渲染的原因。但是根据苹果工程师的说法,CPU渲染并非真正意义上的离屏渲染。另一个证据是,如果你的view实现了drawRect,此时打开Xcode调试的“Color offscreen rendered yellow”开关,你会发现这片区域不会被标记为黄色,说明Xcode并不认为这属于离屏渲染。
其实通过CPU渲染就是俗称的“软件渲染”,而真正的离屏渲染发生在GPU。
5.2 什么时候需要CPU渲染
渲染性能的调优,其实始终是在做一件事:平衡CPU和GPU的负载,让他们尽量做各自最擅长的工作。
平衡CPU和GPU的负载:
绝大多数情况下,得益于GPU针对图形处理的优化,我们都会倾向于让GPU来完成渲染任务,而给CPU留出足够时间处理各种各样复杂的App逻辑。为此Core Animation做了大量的工作,尽量把渲染工作转换成适合GPU处理的形式(也就是所谓的硬件加速,如layer composition,设置backgroundColor等等)。
但是对于一些情况,如文字(CoreText使用CoreGraphics渲染)和图片(ImageIO)渲染,由于GPU并不擅长做这些工作,不得不先由CPU来处理好以后,再把结果作为texture传给GPU。除此以外,有时候也会遇到GPU实在忙不过来的情况,而CPU相对空闲(GPU瓶颈),这时可以让CPU分担一部分工作,提高整体效率。
来自WWDC18 session 221,可以看到Core Text基于Core Graphics:
一个典型的例子是,我们经常会使用CoreGraphics给图片加上圆角(将图片中圆角以外的部分渲染成透明)。整个过程全部是由CPU完成的。这样一来既然我们已经得到了想要的效果,就不需要再另外给图片容器设置cornerRadius。另一个好处是,我们可以灵活地控制裁剪和缓存的时机,巧妙避开CPU和GPU最繁忙的时段,达到平滑性能波动的目的。
这里有几个需要注意的点:
- 渲染不是CPU的强项,调用CoreGraphics会消耗其相当一部分计算时间,并且我们也不愿意因此阻塞用户操作,因此一般来说CPU渲染都在后台线程完成(这也是AsyncDisplayKit的主要思想),然后再回到主线程上,把渲染结果传回CoreAnimation。这样一来,多线程间数据同步会增加一定的复杂度
- 同样因为CPU渲染速度不够快,因此只适合渲染静态的元素,如文字、图片(想象一下没有硬件加速的视频解码,性能惨不忍睹)
- 作为渲染结果的bitmap数据量较大(形式上一般为解码后的UIImage),消耗内存较多,所以应该在使用完及时释放,并在需要的时候重新生成,否则很容易导致OOM
- 如果你选择使用CPU来做渲染,那么就没有理由再触发GPU的离屏渲染了,否则会同时存在两块内容相同的内存,而且CPU和GPU都会比较辛苦
- 一定要使用Instruments的不同工具来测试性能,而不是仅凭猜测来做决定
六、优化实践
由于在iOS10之后,系统的设计风格慢慢从扁平化转变成圆角卡片,很多APP的设计风格也随之发生变化,加入了大量圆角与阴影效果,如果在处理上稍有不慎,就很容易触发离屏渲染。为此我们采取了以下一些措施:
- 大量应用AsyncDisplayKit(Texture)作为主要渲染框架,对于文字和图片的异步渲染操作交由框架来处理。关于这方面可以看之前的一些介绍
- 对于图片的圆角,统一采用“precomposite”的策略,也就是不经由容器来做剪切,而是预先使用CoreGraphics为图片裁剪圆角
- 对于视频的圆角,由于实时剪切非常消耗性能,我们会创建四个白色弧形的layer盖住四个角,从视觉上制造圆角的效果
- 对于view的圆形边框,如果没有backgroundColor,可以放心使用cornerRadius来做
- 对于所有的阴影,使用shadowPath来规避离屏渲染
- 对于特殊形状的view,使用layer mask并打开shouldRasterize来对渲染结果进行缓存
- 对于模糊效果,不采用系统提供的UIVisualEffect,而是另外实现模糊效果(CIGaussianBlur),并手动管理渲染结果
七、总结与资料推荐
离屏渲染牵涉了很多Core Animation、GPU和图形学等等方面的知识,在实践中也非常考验一个工程师排查问题的基本功、经验和判断能力——如果在不恰当的时候打开了shouldRasterize,只会弄巧成拙。
从一个更广阔的视角看,离屏渲染也仅仅是渲染性能优化中的一部分,而能否保证UI性能过关,将会直接影响到用户日常的操作体验。渲染技术作为客户端工程师的关键技术能力之一,值得持续研究。
推荐资料:
- Andy Matuschak关于离屏渲染的解释
- Objc.io: Moving Pixels onto the Screen
- Mastering Offscreen Render
- WWDC 2011 421 Core Animation Essentials
- WWDC 2011 121 Understanding UIKit Rendering
- WWDC 2014 419 Advanced Graphics and Animations for iOS Apps
- WWDC 2010 135 Advanced Performance Optimization on iPhone OS Part 1
- 《Core Animation: Advanced Techniques》