原文链接:关于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.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’scontents
property; it applies only to the background color and border of the layer. However, setting themasksToBounds
property totrue
causes 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》