Oct
24
在iOS的学习过程中,我们总会聊到“离屏渲染”,在百度上能搜到的关于离屏渲染的描述都是
当然也不全是,通过理解一个自然的GUI框架包含内容/搭建方案,结合苹果提供给我们的已知工具,我们应该可以分析出UIKit离屏渲染的逻辑与基础界面合成行为到底怎么回事。同时也可以通过苹果公开的一些文档进行验证。
给UIKit初步定性
狭义的UIKit是一个典型的NativeGUI框架。典型的GUI框架类比于QT/MFC,往轻量级类比则是DUILib/Swing,同时如今的HTML也是完全可以横行对比的对象。GUI即Graphical User Interface,相对于一般我们所说的UI框架, GUI的关键与基石是Graphical部分,即UI界面的基础绘制能力,也是我们探索的地方。
要了解UIKit,虽然它没开源,我们知道UIKit来源于Cocoa其延展的Cocoa Touch对应AppKit的那一部分,因此理解UIKit可以从AppKit开始。Cocoa从NeXTSTEP演变为OPENSTEP(OpenStep API)的94年开始,基本上和QT等一众c/c++的老派GUI框架一样,应该有如下特点:
- 基础核心逻辑原本应该在纯CPU执行基础上搭建并纯CPU可执行,随着迭代可能部分逻辑可选迁移到图形方案上。(UIKit对iPhone移动端优化应该是一定有一部分迁移到了图形方案上,但是那部分也应该本是可以纯CPU执行的,原因第二部分讲)
- 从我们关心的绘制的角度,核心基于位图进行绘制及2D绘制的,图形部分类似于现代Canvas一样的会话API进行绘制输出位图,所有界面虽然以View或Widget为单位,但是实际是建立在位图之上的。
有了以上背景知识,我们再review下苹果官图中对AppKit与UIKit的描述:
- UIKit/AppKit目前的绘制基础是搭建在CoreAnimation或者叫QuartzCore上的(在AppKit中使用CoreAnimation渲染应该是可选能力,可以降级为上个时代的纯CPU渲染),即我们一般所说的UIView的绘制能力是由CALayer实现的。
- CoreAnimation则是搭建在CoreGraphics(即我们一般所说的Quartz/Quartz2D,区别于QuartzCore)与图形API(openGL or Metal)之上的,另外根据上面的分析,核心部分应该是在CoreGraphics之上,图形API为后续补充/优化部分。
- 延展:图形学部分在AppKit中有可能可有可无,但是在UIKit中看起来是紧密相关的,有可能是因为移动设备的CPU渲染成本过高,图形处理器渲染是必不可少的一环。因此下文分析UIKit也基于图形API是整体渲染流程必要一环来展开。
- 延展2:同时,理论上Quartz2D也有能力通过图形API加速。但是考虑到1. 目前CoreGraphics的API与原版的并无二致,也没有实现使用过图形API的痕迹。2. 同时已经由CoreImage承接了图像处理硬件加速的能力,同时实际观察CoreAnimation的渲染行为也结合了CoreGraphics与CoreImage等诸多功能。因此应该不需要做如此考虑。
因此,我们对UIKit有了初步定性:在传统GUI框架设计之下,以2D绘制能力为核心,依托移动端图形硬件优化过的GUIAPI。基础特征如上。
同时还注意到一点是既然只有AppKit中CoreAnimation是可选项,那就是移动端UIKit合成一定需要GPU,有什么移动端必要使用GPU的原因么?可以判断的有以下几点:
a. 移动端以最低功耗为目标,而苹果SOC上优化过的图形处理器在界面合成的某些处理上功耗显然优于CPU,因此渲染路径上会汇到GPU上。猜测这些行为包括各种shader实现的滤镜(CIFilter)行为比如阴影,模糊等。
b. AppKit是从无GPU时代发展而来,自然会有CPU渲染的历史包袱。同时在PC上开启GPU渲染(硬件加速)反倒可能会有更高的功耗,除了功耗之外,反正CPU与GPU都相对强劲,所以给开发者留出更多的可能性多多益善。
离屏渲染猜测
有了以上理解,我们再回头看离屏渲染。
离屏渲染即Offscreen-Render,苹果文档(document站与archive的文档)上翻阅了半天也没有这个词的官方标准定义,最详尽的描述来自对直接触发离屏幕渲染行为的shouldRasterize的描述:
看这个描述还是不太直接,不过我们都知道狭义上的离屏渲染在图像图形工程上提得最多。如OpenGL中一般所说的离屏渲染方案:即创建一套新的RenderBuffer并且将单次的渲染输出到该FrameBuffer,并同步的获取其纹理输出到其他执行渲染的shader中。这个过程写过图形学的同学一听就明白,这个用法太常见了,如:镜面绘制,先通过镜像摄像头渲染出镜像图像,然后将镜像图像作为贴图渲染到实际场景的镜面上;屏幕空间操作,将画面的渲染结果作为贴图丢入一个独立的shader,再该shader中进行二次处理后再输出到屏幕。
对比起来,其他领域也是,离屏渲染基本上就是指的将某些东西渲染为位图/图像,并再投入场景进行渲染的行为,UIKit也不例外。这个过程的关键词是行为“渲染”与目标“图像”。在OpenGL中,渲染过程即调用图形硬件执行draw的过程,而目标图像是硬件输出到FrameBuffer中的内容,而这个过程的主要新增的成本就是独立开出的那套RenderBuffer,是实打实的占用了一堆显存,这是完成如上行为必要的成本。
我们也说UIKit的离屏渲染也是高成本但是也是必要,那么UIKit离屏渲染到底渲染的什么,必要性是什么,额外的成本是?结合对UIKit的定性,首先先从逻辑上来推测UIKit中离屏渲染行为:
1. 说到输出为位图,我们已知UIKit中的绘制行为本身就是基于位图的,即大部分View都是通过Quartz2D绘制出来的,我们的自定义View也会在drawRect中通过CoreGraphics绘制图像或者CoreText绘制文本,并且实际上每一个CALayer在渲染后都会有一个实际对应的bitmap。那么显然,上文描述的“the layer is rendered as a bitmap”,如果只是一个layer本身,的绘制为bitmap,当然不会这么描述。考虑到这里会把整个view绘制为位图,那么这里实际特殊的地方就是:1. 把sublayer,即subview也一起叠加为了bitmap。2. 把阴影等一些特殊滤镜也绘制为了bitmap。考虑到以上两个行为在layer流程中的未知性,我们可以猜测在正常的渲染流程中,layer之间的合成和阴影等layer效果并不会体现在layer的位图绘制结果中,对应的,这两个流程可能原本可能是图形API渲染即硬件渲染的了,另外本来阴影在移动设备上CPU渲染性能估计也不可接受。
2. 这同样也可以解释成本问题。一般所说离屏渲染会开一个“独立的缓冲区”,一般进行后台渲染开启的成本无非是一段内存或者显存。如果是指的内存,iOS绘制图像占用的内存即在CoreGraphics中通过
创建出的CGContext上下文。不过我们知道每个view如果存在内容或者画面,一定会一个内部位图缓存。之前已知的渲染流程即大部分有绘制能力的view均是通过CoreGraphics(其中文本绘制部分单独为CoreText,可以把CoreText视为地位等同于CoreGraphics子组件的东西)在drawRect步骤绘制一张位图并在内存中持有(dump内存可以观测到内存中有大量layer持有的bitmap对象,并且所谓viewController回收即原viewDidUnload时机销毁vc的所有layer持有的bitmap)。注意到这个“正常”流程所得到的缓存bitmap是不会被特殊标记的,那么离屏渲染输出的bitmap结果内容上和流程上肯定是一个成本相对包袱的东西即多余的渲染流程与多余的内存占用即多余的bitmap。
3. 网传离屏渲染的触发情况根据上面的信息与实际测试来看,有大量的错误与遗漏,如这个列表:
- 为图层设置遮罩(layer.mask) => 确实一定会
- 将图层的layer.masksToBounds / view.clipsToBounds属性设置为true => 视裁减是否实际发生
- 将图层layer.allowsGroupOpacity属性设置为YES和layer.opacity小于1.0 => 确实一定会
- 为图层设置阴影(layer.shadow *)。=> 视阴影是否有path,没有的话和mask行为一致
- 为图层设置layer.shouldRasterize=true => 一定会
- 具有layer.cornerRadius,layer.edgeAntialiasingMask,layer.allowsEdgeAntialiasing的图层 => 视苹果优化与系统实现
- 文本(任何种类,包括UILabel,CATextLayer,Core Text等) => 本质是cpu绘制,不在讨论范围
- 使用CGContext在drawRect :方法中绘制大部分情况下会导致离屏渲染,甚至仅仅是一个空的实现 => 本质是cpu绘制,不在讨论范围
而实际上还有一个大的离屏渲染来源是界面(CALayer)transform,并且可以通过transform对比观测到一些离屏渲染执行的特点。
Case1,构造一个会产生离屏渲染的cornerRadius情况(目前iOS版本如果裁剪区域只有一个位图layer即普通CALayer(不包含会生产特殊Mesh的layer如CAShapeLayer),则会直接裁剪而不发生离屏渲染,因此很有可能使用的是shader执行裁剪)。我们知道圆角产生离屏渲染后,目前会形成四个角落的离屏渲染,但是如果做一下transform,会有如下现象:
左图是做的2dtransform(包括在transform3d属性上设置的CATransform3D的m34为0的情况即构成仿射变换),观测到的现象是依旧是四角离屏渲染,但是注意到四个角落的裁剪区域始终保持和屏幕水平的。右图是真正的3D变换,注意到整个区域都离屏渲染了。
知识背景上,对于仿射变换和真正的3D变换的区别,除了从数值上看仿射变换在CATransform3D的m34一定为0之外,要注意a. CoreGraphics的transform能力只能处理仿射变换。可以参考安卓或者js的canvas也只支持仿射变换,即CPU的绘制能力只支持仿射变换。b. 一般3D变换需要交给GPU完成。可以参考css3d中,真3d变换一定会启用硬件加速的图层。
结合以上知识,我们可以给UIKit中变换和圆角离屏渲染行为做一定猜测:
a. 一般的CALayer,2d变换是在CPU完成的(这里的完成不是指的图片数据旋转,而是mesh坐标旋转),完成后再进行的离屏渲染裁剪。3d变换则是在离屏渲染完成后进行的,因此可以注意到离屏渲染随变换发生旋转。
b. 圆角的离屏渲染裁剪区域始终保持和屏幕水平,并且看起来每一个离屏渲染区域尽量保持了正方形或者某种对齐。
c. 基于a的前半部分,可以猜测,2d变换后layer实际渲染mesh依旧保持横平竖直。同时这种情况下的4角优化则是对渲染mesh进行了拆分,划出了4个角变成了5个片面,其中中间的那个使用的是原始layer的位图,而四个角落的片面使用的离屏渲染返回的位图,最后组合出整个layer的渲染效果。
d. 结合c也可以猜测性的解释为什么3d变换没有使用圆角4角优化。可能基于引擎(系统render server)限制,mesh拆分后每个片面再进行3d变换的transform矩阵计算过于复杂或者不可完成,因此只能保持整个矩形mesh丢入GPU进行渲染。这里也可猜测render server的最小渲染输入单元是单个mesh,每个mesh对于一个可能存在位图与各种GPU处理属性,包括transform。
e. 猜测圆角4角优化裁剪总是保持和屏幕横平竖直的原因,是利于图形在渲染流程上切合Tile Based Rendering流程。
Case2,构造一个View是3d变换,其中有一个子view,拥有变换CATransform3DTranslate(rot2, 20, 20, -100)。如下图,其中左图的子view即如上的仿射变换,而右侧的子view在变换基础上添加了m34即构成了真正的3d变换。
渲染结果上,注意到右侧即3d变换的子view,整个发生了离屏渲染。另外这个现象在父view不是3d变换而是仿射变换时也会出现。观察如上现象和父view发生transform后子view的各种表现,能得到如下结论:
a. transform执行的时候会把所有的子view叠加在一个平面上。这里的叠加不是指的会被合成在一个界面上,而是如果发生了transform,shape会执行变换后抹平到一个平面上。然后再执行父view的transform。比如左图的子view执行translate(20, 20, -100)后映射到父view平面上,z轴的-100变换当然是没体现的。而进而父view执行变换后,子view不会因为有-100的深度距离而错开,而是保持和父view在一个平面上。
b. 3d变换也是相同的逻辑,3d变换的结果也会帖在父view的平面上。但是因为case1讨论的2d变换只是shape变换,而3d变换要丢进gpu执行的缘故,右侧的子view的变换丢进gpu渲染了一次,因此发生了离屏渲染。渲染出的结果当然是执行了translate(20, 20, -100)并且因为变形矩阵有m34的缘故导致-100的z轴变换拉近了图形,图形变大了。然后将这个变大的图形帖到父view平面上,继续执行。
上面两个case只是还能发现可能触发离屏渲染的一部分,这两个case可以清晰的指出离屏渲染执行的必要性——需要渲染流水线GPU去执行,并且需要将渲染结果(不管是位图信息还是坐标尺寸信息)放回CPU进行二次加工。
界面的渲染流水线
结合上面对“离屏渲染”的讨论,我们最终需要理清的即UIKit中执行“渲染”的整个流水线是如何的。
0. UIKit的整体渲染是以CoreAnimation为核心,调用系统的RenderServer的绘制实现,驱动openGl/Metal执行界面绘制过程。整体涉及到的系统如下图,另外本文实际主要推测的是系统RenderServer的内部逻辑。
1. 根据已知的基础知识,我们可以大体绘制出最简化的一个layer完成绘制的流程。如下:
整个流程按照0部分,划分为用户程序部分,renderserver部分与gpu部分。
a. 用户程序部分的任务其实很简单,每一个CALayer把渲染自己需要的数据准备好,发送给renderserver即可。但是绝大部分的layer是有图像的,即对于渲染流程我们需要在程序中绘制好那一张位图。因此对于需要位图的layer,会在本身没有缓存位图时调用drawrect系列方法从CPU绘制生成这张位图。这个位图生成后会被layer持有,直到下次刷新。如果这个位图被系统释放掉(比如内存紧张的时候),则会在渲染时触发再次重绘。总之,用户应用的流程核心就是搞定这张位图,剩下的就交给renderserver了。
b. renderserver收到的是各种类型的calayer数据,从静态渲染角度,核心当然是CA::Layer数据结构。要将这个数据交给GPU渲染,需要把layer转换为实际gpu中的mesh/uv/贴图,同时也将后续shader需要的数据进行转换。因此不管是位图+矩形mesh的配置还是其他奇怪的shape,都在这里转换为实际要丢给GPU的mesh(顶点)数据与每个mesh的参数如使用贴图等,这个参数的范围可以认为CALayer上绝大部分属性都是后续丢给GPU使用的。同时我们也在此处探明,如果layer的transform是2d的,这里生成的mesh会直接进行矩阵变换。同时还有做的是mesh扩大,对于需要生成border或者阴影的layer,实际需要进行光栅化的顶点区域比原本的位图区域更大,因此需要对顶点进行扩大,完整覆盖可能存在的光栅化区域。
renderserver对当前帧渲染的所有mesh进行如上处理,并进行排序最后构成一个庞大的场景顶点数据,这才是本次系统渲染要渲染的所有数据。当然,猜测这其中会有一些遮挡剔除等优化操作,那是后话。
c. 将mesh即顶点数据,贴图数据与其他操作数据交给图形API,第三部分即进行GPU渲染流程。所有的GPU渲染流程本身都是一致的(注意iOS设备会有一个tiling操作,搜索Tile Based Rendering即可,本次讨论跳过)。核心关心的是顶点着色器与片元着色器包含哪些业务操作。
顶点着色器在GUI渲染上目测作用不大,目前能观测到的即上文提到的进行3D变换,一定会经过顶点着色器执行。同时考虑到动态场景,一些renderserver直接驱动的动画行为也会通过顶点着色器操作。
片元着色器则发挥着大作用。首先对于拥有贴图的mesh即绝大部分layer,把贴图正确的按照uv绘制上去是顶点着色器的首要任务。同时layer的基础属性如backgroundcolor/opacity,裁剪类如cornerRadius,绘制生成类如border与shadow,还有自定义滤镜filters本质上是自定义shader流程(之前讨论过的系统实现界面反色功能),以上流程均是在片元着色器步骤实现并调用。
如上图,列举了一个最简单的UILabel的渲染关键过程。这个Label的layer上的特殊点包括拥有黑色的background与进行了3d变换。因此根据上面的讨论:a. 应用程序中,uilabel自身的drawrect实现通过CoreText绘制出了一张背景透明,包含文字的位图,即图2的文字部分。b. renderserver则结合view的大小,准备了如图2中的mesh与对于uv以承载位图图像。c. 顶点着色器依赖transform数据将顶点转换为图3的样子。最后片元着色器在顶点范围内完成:根据backgroundcolor绘制背景,因为有圆角设置所以边缘区域露出;根据输入位图与mesh的uv绘制图像,同样根据圆角设置边缘区域露出(圆角还有别的不依赖离屏渲染的实现方式,此处按照一种场景实现举例)。
2. 考虑到“离屏渲染”这个行为其实是对于CoreAnimation的实现即renderserver而言的,因此可以认为一次完整的渲染流程是单位mesh经过renderserver处理并通过GPU渲染,在buffer中得到渲染结果,这么一个过程。那么离屏渲染的流程是如何的呢,我们首先尝试描述一下shouldRasterize即强制光栅化的流水线:
整个流程分歧的分歧点发生在renderserver中,如果一块mesh可能已经被光栅化即已经被离屏渲染过,会有一个检查是否存在缓存光栅化位图的行为(不止是shouldRasterize,所有的离屏渲染场景都应该会缓存渲染结果),如果不存在光栅化位图,即进入绘制该图的逻辑即离屏渲染核心流程。
a. 该流程和正常的renderserver到GPU流程区别不大,只不过渲染目标不是整个屏幕的元素,而是当前mesh与需要一同渲染的子mesh的元素,进行正常的界面堆叠并推到GPU中。
b. GPU在这个环节可能不会进行3d变换(视后续光栅化位图如何利用),一些layer属性也不会进行渲染如shouldRasterize文档上描述的透明度。而相对成本较高的行为如滤镜,阴影会在这里完成渲染。当然最重要的,把相关的所有layer所有mesh合成为一张位图了。
c. 将渲染完成的位图返回原本的递归调用前的渲染流程中,在内部已经完成渲染的部分就可以从数据中抹除了,包括子layer数据,已完成的滤镜/操作等数据,剩下的交给GPU继续完成渲染。
d. 考虑到CoreAnimation本身的行为和调用GPU的行为是全参数化的,上图中的各个renderserver流程和gpu流程应该没有啥本质上的不同,只是因为递归调用时传递的参数有所裁剪从而控制到各个流程上分别开启/关闭的渲染功能,最后组合出完整的界面。
3. 复用上面对基础离屏渲染流程的描述,我们也可以根据分析描述出圆角在四角优化的情况下的整个离屏渲染的流程。除去上面描述过的流程,这个case的关键流程如下:
这个流程的核心思路是,对目标layer的部分区域进行离屏渲染,并进行贴图更换。这样做的好处是多出来的光栅化区域与位图区域更小,既缩小了离屏渲染执行成本,也缩小了离屏渲染结果持有的成本。
a. 当然,如果一个layer的不同部分的贴图不一致,mesh结构上就不能保持一个完整的mesh了,而是把四个角拆分为独立的mesh,如下图:
其中底层的渲染元素还是保持原始的mesh与layer,包括所有的子layer堆叠结构。但是四个角上覆盖了4个新的mesh如图红色,这四个mesh使用离屏渲染的结果作为贴图覆盖在原始渲染数据之上,从而解决四个角落的圆角。
b. 离屏渲染执行是依赖的原始layer和上文一样,将目标layer的全部数据与子layer一起丢进GPU渲染,唯一的区别是,这次执行光栅化的不是完整的layer范围,而是四个角落的四个矩形范围。渲染出的结果放在更小的一个或者四个贴图中,再交回上文流程用于正常渲染。
c. 上文讨论中注意到如果有2d变换的话,离屏渲染裁剪出的mesh依旧是始终保持和屏幕横平竖直的,这说明了至少在离屏渲染的GPU空间中,mesh已经完成了旋转,因此光栅化出的是已旋转位图。如果离屏渲染位图在实际渲染过程中不再发生旋转,实际会有更好的效果与更低的性能(图像采样上,同时也有利于tiling)。而3d变换的情况下无论如何图像采样都会有错位,因此不在任何地方做预处理保持正常流程确实是最明智的。
d. mesh拆分后虽然节约了新生成的贴图的大小,但是也会产生额外的mesh即顶点数据与更复杂的GPU运算,利弊如何衡量?移动端上运算密集操作首要衡量的一定是功耗,而移动端SOC渲染的功耗消耗主要是在给GPU的数据传输上而这个量级的顶点运算完全无所谓(相对于真的3d应用)。而显然从数据上生成更大贴图传输带来的功耗消耗远大于多这么几个顶点的消耗,所以显然圆角四角离屏渲染这个优化是非常有益的。对于其他离屏渲染范围限制的优化也是这个道理。
4. 对于上文提到的叠加3dtransform产生的离屏渲染,这里也有说法。具体展开看一早提到的transform流程:
a. 这里的关键行为是,对进行transform的layer,它的子layer需要进行抹平即变成和父layer的mesh在一个平面上,再和父layer一起进行变换操作(不管是2d还是3d)。但是如果子layer进行了3d变换,它在空间中就不是平的了(mesh会在顶点着色阶段发生变换),因此需要提前进入一次GPU流程完成mesh计算,顺便也把变换后光栅化的渲染结果也拿到。相关数据再返回CoreAnimation就可以变成平面的mesh与对应贴图(虽然这个mesh可能已经被拉伸得不是矩形了),即完成了抹平动作。
b. 同时在抹平子layer合成阶段,这里并没有再次离屏渲染把所有子view合在一起而是一起进行变换,也是基于上面讨论过的理由即离屏渲染的位图持有成本太高了,做矩阵变换不管是在CPU完成的2d变换还是交给GPU做3d变换,都是完全可以接受的小成本。有些别的GUI框架会这么干,可能是因为他们的渲染合成行为是在CPU完成的(比如webcore,GPU渲染layer是异类,总归会返回cpu完成最终合成)。
5. 更复杂的可以考虑下layer的mask功能流程,mask功能需要保证被mask的layer与用于mask的layer都渲染为最终平面,才能将mask给重叠出来,因此这里实际上会发生两次离屏渲染(模拟器上看到的是被mask的layer标黄了,而用于mask的layer由于没有显示看不出来)。
mask这个流程的推论也可以通过苹果wwdc2014的文档得到佐证,官方下图中对mask流程在gpu上进行了3次管线描述,其中两次的渲染目标是texture,即我们所说的离屏渲染。当然,现对来说官图描述得不严谨的地方是,这个流程并不是GPU内部执行,而是CoreAnimation通过openGL/Metal多次调度GPU得到的。
渲染流水线汇总
上文已经对UIKit界面的渲染流水线的各个主流场景进行了罗列分析,是时候对这些分析结果进行汇总构成完整的UIKit渲染流水线了。不过继续用流程图来表示这个过程可能会太复杂并且也不清晰了,因此本段用中文伪代码来表示。
我们共计有4段伪代码,分别表示的是a. 用户应用程序中,UIView与layer触发渲染的流水线。b. 系统RenderServer中,处理用户的渲染请求的流水线。c. 每一次GPU渲染流程中的顶点着色器流程。d. 每一次GPU流程中,片元着色器流程。这四段代码基本上呈现从前向后调度的关系,从而构成整个UIKit渲染流水线。
总结
经由上文,虽然文字描述有限,描述的系统行为也并不全面实际更多的是将典型行为列举出来了,我们还是能对UIKit与iOS系统的整个渲染管线与各个业务关心的功能如何运作的有更清晰的认知迭代。同时,本文对这么一个话题进行分析与总结也是希望借此机会对iOS开发中在性能/表现上做抉择时常常困扰开发者的几个关键概念有更清晰的理解,从而指导我们进行更高性能与表现更优越的iOS GUI编程。
总结起来,后续继续关注如下几个话题:
- 关于离屏渲染:对界面的一部分在独立缓冲区进行不一定完整的GPU渲染流程。注意这个流程与普通的CPU渲染上的区别与必要性。——单次渲染它是高成本行为并且从功耗(GPU数据通信)/延迟(RenderServer与GPU流程串行运行即多次glFlush带来的运行延迟)与内存(新的位图输出)上都会有体现;但是这个缓存机制内存消耗可以从长期的角度换取未来的渲染性能,因此除了必要性,它在低频渲染场景确实可能带来功耗上的收益。——和CPU渲染相比,它能覆盖更多的渲染能力如3d变换/阴影等,在抉择某个功能是通过drawRect直接绘制还是交给CoreAnimation完成时,可以以能力边界作为抉择条件。——上文列举的离屏渲染场景并不全面,但是根据上面的分析过程,如果苹果有新增的场景或者其他隐藏行为我们也能够快速判断行为在流水线中的位置与特征。
- 关于GPU渲染的范围与边界:CPU渲染即CoreGraphics/CoreText的边界是view/layer的content即内容,而除了layer的content,CPU擅长图形绘制/生成/组合与其他io密集型行为;几乎所有CoreAnimation上的渲染行为都是驱动GPU完成的,GPU擅长图形采样/叠加/特效(基本上常见特效如模糊阴影描述边等本质都是依赖采样)/并有相对于CPU更低的功耗。从CoreAnimation的视角,CoreAnimation并不关心layer的content位图到底是由CoreGraphics还是什么别的工具绘制而成的,而CoreGraphics也对layer内容如何输出屏幕好不关心。——对于如今众多异步渲染方案,这个逻辑是一致的,无论做了什么,异步渲染使用CoreGraphics绘制的结果仍然是一张位图的形式交给CoreAnimation进而交给GPU完成渲染,因此衡量异步渲染实现对功耗/内存等关键指标的影响与收益有理论支持。——同时对于自定义View的实现方案选型上,应该是用drawrect绘制还是尽量由CALayer与其他子类组合实现,也可以根据需求View内容特征进行判断。
- 关于GUI渲染流水线的设计:无论如何iOS的GUI渲染管线是一个非常典型的移动端SOC渲染管线,参考安卓硬件加速后的渲染流程,Flutter自己通过图形API实现的自有渲染管线,都有大量的相似之处。同时对于更传统的GUI渲染方案,如AppKit与QT,也包括各个浏览器WebCore部分,它们的迭代方向也在逐渐向GPU渲染为主靠拢。——我们以上分析过程与经验并不仅限于iOS中,对于业界大量的GUI框架都可以按照类似思路进行分析,也能得到大量相似的结论,并为我们在大前端方向低功耗高性能GUI开发铺平道路。——同时,如果我们也在对现有的GUI方案进行二次开发或者迭代优化如RN,以上GUI渲染管线也是我们的指路人。
——————
P.S. 本文笔者review了多次,其中讲到的内容阅读困难度确实较高,通过文字描述不太容易清晰展现出来,后续可能考虑重新整理为多媒体/视频动画版本。
更新:视频版已补充https://lrdcq.com/me/read.php/149.htm
什么是离屏渲染. 离屏渲染(offscreen-rendering)顾名思义为屏幕外的渲染,即渲染的结果不会直接呈现到当前屏幕上,而是等待合适的时机才会被显示。
但是每次我问到候选人为什么UIKit会在这种情况下发生离屏渲染,极限是什么,根因是什么,可以避免么这样的问题时,大概率得不到方向正确的答案。毕竟是猜测UIKit的渲染行为,不像是安卓苹果的全闭源策略当然给开发者带来了理解困难。当然也不全是,通过理解一个自然的GUI框架包含内容/搭建方案,结合苹果提供给我们的已知工具,我们应该可以分析出UIKit离屏渲染的逻辑与基础界面合成行为到底怎么回事。同时也可以通过苹果公开的一些文档进行验证。
给UIKit初步定性
狭义的UIKit是一个典型的NativeGUI框架。典型的GUI框架类比于QT/MFC,往轻量级类比则是DUILib/Swing,同时如今的HTML也是完全可以横行对比的对象。GUI即Graphical User Interface,相对于一般我们所说的UI框架, GUI的关键与基石是Graphical部分,即UI界面的基础绘制能力,也是我们探索的地方。
要了解UIKit,虽然它没开源,我们知道UIKit来源于Cocoa其延展的Cocoa Touch对应AppKit的那一部分,因此理解UIKit可以从AppKit开始。Cocoa从NeXTSTEP演变为OPENSTEP(OpenStep API)的94年开始,基本上和QT等一众c/c++的老派GUI框架一样,应该有如下特点:
- 基础核心逻辑原本应该在纯CPU执行基础上搭建并纯CPU可执行,随着迭代可能部分逻辑可选迁移到图形方案上。(UIKit对iPhone移动端优化应该是一定有一部分迁移到了图形方案上,但是那部分也应该本是可以纯CPU执行的,原因第二部分讲)
- 从我们关心的绘制的角度,核心基于位图进行绘制及2D绘制的,图形部分类似于现代Canvas一样的会话API进行绘制输出位图,所有界面虽然以View或Widget为单位,但是实际是建立在位图之上的。
有了以上背景知识,我们再review下苹果官图中对AppKit与UIKit的描述:
- UIKit/AppKit目前的绘制基础是搭建在CoreAnimation或者叫QuartzCore上的(在AppKit中使用CoreAnimation渲染应该是可选能力,可以降级为上个时代的纯CPU渲染),即我们一般所说的UIView的绘制能力是由CALayer实现的。
- CoreAnimation则是搭建在CoreGraphics(即我们一般所说的Quartz/Quartz2D,区别于QuartzCore)与图形API(openGL or Metal)之上的,另外根据上面的分析,核心部分应该是在CoreGraphics之上,图形API为后续补充/优化部分。
- 延展:图形学部分在AppKit中有可能可有可无,但是在UIKit中看起来是紧密相关的,有可能是因为移动设备的CPU渲染成本过高,图形处理器渲染是必不可少的一环。因此下文分析UIKit也基于图形API是整体渲染流程必要一环来展开。
- 延展2:同时,理论上Quartz2D也有能力通过图形API加速。但是考虑到1. 目前CoreGraphics的API与原版的并无二致,也没有实现使用过图形API的痕迹。2. 同时已经由CoreImage承接了图像处理硬件加速的能力,同时实际观察CoreAnimation的渲染行为也结合了CoreGraphics与CoreImage等诸多功能。因此应该不需要做如此考虑。
因此,我们对UIKit有了初步定性:在传统GUI框架设计之下,以2D绘制能力为核心,依托移动端图形硬件优化过的GUIAPI。基础特征如上。
同时还注意到一点是既然只有AppKit中CoreAnimation是可选项,那就是移动端UIKit合成一定需要GPU,有什么移动端必要使用GPU的原因么?可以判断的有以下几点:
a. 移动端以最低功耗为目标,而苹果SOC上优化过的图形处理器在界面合成的某些处理上功耗显然优于CPU,因此渲染路径上会汇到GPU上。猜测这些行为包括各种shader实现的滤镜(CIFilter)行为比如阴影,模糊等。
b. AppKit是从无GPU时代发展而来,自然会有CPU渲染的历史包袱。同时在PC上开启GPU渲染(硬件加速)反倒可能会有更高的功耗,除了功耗之外,反正CPU与GPU都相对强劲,所以给开发者留出更多的可能性多多益善。
离屏渲染猜测
有了以上理解,我们再回头看离屏渲染。
离屏渲染即Offscreen-Render,苹果文档(document站与archive的文档)上翻阅了半天也没有这个词的官方标准定义,最详尽的描述来自对直接触发离屏幕渲染行为的shouldRasterize的描述:
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. Shadow effects and any filters in the filters property are rasterized and included in the bitmap.
//
当这个属性为YES,这个CALayer的内容渲染为当前坐标的位图再放置到其位置上,渲染成位图的包括阴影效果等其他滤镜属性。
看这个描述还是不太直接,不过我们都知道狭义上的离屏渲染在图像图形工程上提得最多。如OpenGL中一般所说的离屏渲染方案:即创建一套新的RenderBuffer并且将单次的渲染输出到该FrameBuffer,并同步的获取其纹理输出到其他执行渲染的shader中。这个过程写过图形学的同学一听就明白,这个用法太常见了,如:镜面绘制,先通过镜像摄像头渲染出镜像图像,然后将镜像图像作为贴图渲染到实际场景的镜面上;屏幕空间操作,将画面的渲染结果作为贴图丢入一个独立的shader,再该shader中进行二次处理后再输出到屏幕。
对比起来,其他领域也是,离屏渲染基本上就是指的将某些东西渲染为位图/图像,并再投入场景进行渲染的行为,UIKit也不例外。这个过程的关键词是行为“渲染”与目标“图像”。在OpenGL中,渲染过程即调用图形硬件执行draw的过程,而目标图像是硬件输出到FrameBuffer中的内容,而这个过程的主要新增的成本就是独立开出的那套RenderBuffer,是实打实的占用了一堆显存,这是完成如上行为必要的成本。
我们也说UIKit的离屏渲染也是高成本但是也是必要,那么UIKit离屏渲染到底渲染的什么,必要性是什么,额外的成本是?结合对UIKit的定性,首先先从逻辑上来推测UIKit中离屏渲染行为:
1. 说到输出为位图,我们已知UIKit中的绘制行为本身就是基于位图的,即大部分View都是通过Quartz2D绘制出来的,我们的自定义View也会在drawRect中通过CoreGraphics绘制图像或者CoreText绘制文本,并且实际上每一个CALayer在渲染后都会有一个实际对应的bitmap。那么显然,上文描述的“the layer is rendered as a bitmap”,如果只是一个layer本身,的绘制为bitmap,当然不会这么描述。考虑到这里会把整个view绘制为位图,那么这里实际特殊的地方就是:1. 把sublayer,即subview也一起叠加为了bitmap。2. 把阴影等一些特殊滤镜也绘制为了bitmap。考虑到以上两个行为在layer流程中的未知性,我们可以猜测在正常的渲染流程中,layer之间的合成和阴影等layer效果并不会体现在layer的位图绘制结果中,对应的,这两个流程可能原本可能是图形API渲染即硬件渲染的了,另外本来阴影在移动设备上CPU渲染性能估计也不可接受。
2. 这同样也可以解释成本问题。一般所说离屏渲染会开一个“独立的缓冲区”,一般进行后台渲染开启的成本无非是一段内存或者显存。如果是指的内存,iOS绘制图像占用的内存即在CoreGraphics中通过
UIGraphicsBeginImageContext(CGSize size)
创建出的CGContext上下文。不过我们知道每个view如果存在内容或者画面,一定会一个内部位图缓存。之前已知的渲染流程即大部分有绘制能力的view均是通过CoreGraphics(其中文本绘制部分单独为CoreText,可以把CoreText视为地位等同于CoreGraphics子组件的东西)在drawRect步骤绘制一张位图并在内存中持有(dump内存可以观测到内存中有大量layer持有的bitmap对象,并且所谓viewController回收即原viewDidUnload时机销毁vc的所有layer持有的bitmap)。注意到这个“正常”流程所得到的缓存bitmap是不会被特殊标记的,那么离屏渲染输出的bitmap结果内容上和流程上肯定是一个成本相对包袱的东西即多余的渲染流程与多余的内存占用即多余的bitmap。
3. 网传离屏渲染的触发情况根据上面的信息与实际测试来看,有大量的错误与遗漏,如这个列表:
- 为图层设置遮罩(layer.mask) => 确实一定会
- 将图层的layer.masksToBounds / view.clipsToBounds属性设置为true => 视裁减是否实际发生
- 将图层layer.allowsGroupOpacity属性设置为YES和layer.opacity小于1.0 => 确实一定会
- 为图层设置阴影(layer.shadow *)。=> 视阴影是否有path,没有的话和mask行为一致
- 为图层设置layer.shouldRasterize=true => 一定会
- 具有layer.cornerRadius,layer.edgeAntialiasingMask,layer.allowsEdgeAntialiasing的图层 => 视苹果优化与系统实现
- 文本(任何种类,包括UILabel,CATextLayer,Core Text等) => 本质是cpu绘制,不在讨论范围
- 使用CGContext在drawRect :方法中绘制大部分情况下会导致离屏渲染,甚至仅仅是一个空的实现 => 本质是cpu绘制,不在讨论范围
而实际上还有一个大的离屏渲染来源是界面(CALayer)transform,并且可以通过transform对比观测到一些离屏渲染执行的特点。
Case1,构造一个会产生离屏渲染的cornerRadius情况(目前iOS版本如果裁剪区域只有一个位图layer即普通CALayer(不包含会生产特殊Mesh的layer如CAShapeLayer),则会直接裁剪而不发生离屏渲染,因此很有可能使用的是shader执行裁剪)。我们知道圆角产生离屏渲染后,目前会形成四个角落的离屏渲染,但是如果做一下transform,会有如下现象:
左图是做的2dtransform(包括在transform3d属性上设置的CATransform3D的m34为0的情况即构成仿射变换),观测到的现象是依旧是四角离屏渲染,但是注意到四个角落的裁剪区域始终保持和屏幕水平的。右图是真正的3D变换,注意到整个区域都离屏渲染了。
知识背景上,对于仿射变换和真正的3D变换的区别,除了从数值上看仿射变换在CATransform3D的m34一定为0之外,要注意a. CoreGraphics的transform能力只能处理仿射变换。可以参考安卓或者js的canvas也只支持仿射变换,即CPU的绘制能力只支持仿射变换。b. 一般3D变换需要交给GPU完成。可以参考css3d中,真3d变换一定会启用硬件加速的图层。
结合以上知识,我们可以给UIKit中变换和圆角离屏渲染行为做一定猜测:
a. 一般的CALayer,2d变换是在CPU完成的(这里的完成不是指的图片数据旋转,而是mesh坐标旋转),完成后再进行的离屏渲染裁剪。3d变换则是在离屏渲染完成后进行的,因此可以注意到离屏渲染随变换发生旋转。
b. 圆角的离屏渲染裁剪区域始终保持和屏幕水平,并且看起来每一个离屏渲染区域尽量保持了正方形或者某种对齐。
c. 基于a的前半部分,可以猜测,2d变换后layer实际渲染mesh依旧保持横平竖直。同时这种情况下的4角优化则是对渲染mesh进行了拆分,划出了4个角变成了5个片面,其中中间的那个使用的是原始layer的位图,而四个角落的片面使用的离屏渲染返回的位图,最后组合出整个layer的渲染效果。
d. 结合c也可以猜测性的解释为什么3d变换没有使用圆角4角优化。可能基于引擎(系统render server)限制,mesh拆分后每个片面再进行3d变换的transform矩阵计算过于复杂或者不可完成,因此只能保持整个矩形mesh丢入GPU进行渲染。这里也可猜测render server的最小渲染输入单元是单个mesh,每个mesh对于一个可能存在位图与各种GPU处理属性,包括transform。
e. 猜测圆角4角优化裁剪总是保持和屏幕横平竖直的原因,是利于图形在渲染流程上切合Tile Based Rendering流程。
Case2,构造一个View是3d变换,其中有一个子view,拥有变换CATransform3DTranslate(rot2, 20, 20, -100)。如下图,其中左图的子view即如上的仿射变换,而右侧的子view在变换基础上添加了m34即构成了真正的3d变换。
渲染结果上,注意到右侧即3d变换的子view,整个发生了离屏渲染。另外这个现象在父view不是3d变换而是仿射变换时也会出现。观察如上现象和父view发生transform后子view的各种表现,能得到如下结论:
a. transform执行的时候会把所有的子view叠加在一个平面上。这里的叠加不是指的会被合成在一个界面上,而是如果发生了transform,shape会执行变换后抹平到一个平面上。然后再执行父view的transform。比如左图的子view执行translate(20, 20, -100)后映射到父view平面上,z轴的-100变换当然是没体现的。而进而父view执行变换后,子view不会因为有-100的深度距离而错开,而是保持和父view在一个平面上。
b. 3d变换也是相同的逻辑,3d变换的结果也会帖在父view的平面上。但是因为case1讨论的2d变换只是shape变换,而3d变换要丢进gpu执行的缘故,右侧的子view的变换丢进gpu渲染了一次,因此发生了离屏渲染。渲染出的结果当然是执行了translate(20, 20, -100)并且因为变形矩阵有m34的缘故导致-100的z轴变换拉近了图形,图形变大了。然后将这个变大的图形帖到父view平面上,继续执行。
上面两个case只是还能发现可能触发离屏渲染的一部分,这两个case可以清晰的指出离屏渲染执行的必要性——需要渲染流水线GPU去执行,并且需要将渲染结果(不管是位图信息还是坐标尺寸信息)放回CPU进行二次加工。
界面的渲染流水线
结合上面对“离屏渲染”的讨论,我们最终需要理清的即UIKit中执行“渲染”的整个流水线是如何的。
0. UIKit的整体渲染是以CoreAnimation为核心,调用系统的RenderServer的绘制实现,驱动openGl/Metal执行界面绘制过程。整体涉及到的系统如下图,另外本文实际主要推测的是系统RenderServer的内部逻辑。
1. 根据已知的基础知识,我们可以大体绘制出最简化的一个layer完成绘制的流程。如下:
整个流程按照0部分,划分为用户程序部分,renderserver部分与gpu部分。
a. 用户程序部分的任务其实很简单,每一个CALayer把渲染自己需要的数据准备好,发送给renderserver即可。但是绝大部分的layer是有图像的,即对于渲染流程我们需要在程序中绘制好那一张位图。因此对于需要位图的layer,会在本身没有缓存位图时调用drawrect系列方法从CPU绘制生成这张位图。这个位图生成后会被layer持有,直到下次刷新。如果这个位图被系统释放掉(比如内存紧张的时候),则会在渲染时触发再次重绘。总之,用户应用的流程核心就是搞定这张位图,剩下的就交给renderserver了。
b. renderserver收到的是各种类型的calayer数据,从静态渲染角度,核心当然是CA::Layer数据结构。要将这个数据交给GPU渲染,需要把layer转换为实际gpu中的mesh/uv/贴图,同时也将后续shader需要的数据进行转换。因此不管是位图+矩形mesh的配置还是其他奇怪的shape,都在这里转换为实际要丢给GPU的mesh(顶点)数据与每个mesh的参数如使用贴图等,这个参数的范围可以认为CALayer上绝大部分属性都是后续丢给GPU使用的。同时我们也在此处探明,如果layer的transform是2d的,这里生成的mesh会直接进行矩阵变换。同时还有做的是mesh扩大,对于需要生成border或者阴影的layer,实际需要进行光栅化的顶点区域比原本的位图区域更大,因此需要对顶点进行扩大,完整覆盖可能存在的光栅化区域。
renderserver对当前帧渲染的所有mesh进行如上处理,并进行排序最后构成一个庞大的场景顶点数据,这才是本次系统渲染要渲染的所有数据。当然,猜测这其中会有一些遮挡剔除等优化操作,那是后话。
c. 将mesh即顶点数据,贴图数据与其他操作数据交给图形API,第三部分即进行GPU渲染流程。所有的GPU渲染流程本身都是一致的(注意iOS设备会有一个tiling操作,搜索Tile Based Rendering即可,本次讨论跳过)。核心关心的是顶点着色器与片元着色器包含哪些业务操作。
顶点着色器在GUI渲染上目测作用不大,目前能观测到的即上文提到的进行3D变换,一定会经过顶点着色器执行。同时考虑到动态场景,一些renderserver直接驱动的动画行为也会通过顶点着色器操作。
片元着色器则发挥着大作用。首先对于拥有贴图的mesh即绝大部分layer,把贴图正确的按照uv绘制上去是顶点着色器的首要任务。同时layer的基础属性如backgroundcolor/opacity,裁剪类如cornerRadius,绘制生成类如border与shadow,还有自定义滤镜filters本质上是自定义shader流程(之前讨论过的系统实现界面反色功能),以上流程均是在片元着色器步骤实现并调用。
如上图,列举了一个最简单的UILabel的渲染关键过程。这个Label的layer上的特殊点包括拥有黑色的background与进行了3d变换。因此根据上面的讨论:a. 应用程序中,uilabel自身的drawrect实现通过CoreText绘制出了一张背景透明,包含文字的位图,即图2的文字部分。b. renderserver则结合view的大小,准备了如图2中的mesh与对于uv以承载位图图像。c. 顶点着色器依赖transform数据将顶点转换为图3的样子。最后片元着色器在顶点范围内完成:根据backgroundcolor绘制背景,因为有圆角设置所以边缘区域露出;根据输入位图与mesh的uv绘制图像,同样根据圆角设置边缘区域露出(圆角还有别的不依赖离屏渲染的实现方式,此处按照一种场景实现举例)。
2. 考虑到“离屏渲染”这个行为其实是对于CoreAnimation的实现即renderserver而言的,因此可以认为一次完整的渲染流程是单位mesh经过renderserver处理并通过GPU渲染,在buffer中得到渲染结果,这么一个过程。那么离屏渲染的流程是如何的呢,我们首先尝试描述一下shouldRasterize即强制光栅化的流水线:
整个流程分歧的分歧点发生在renderserver中,如果一块mesh可能已经被光栅化即已经被离屏渲染过,会有一个检查是否存在缓存光栅化位图的行为(不止是shouldRasterize,所有的离屏渲染场景都应该会缓存渲染结果),如果不存在光栅化位图,即进入绘制该图的逻辑即离屏渲染核心流程。
a. 该流程和正常的renderserver到GPU流程区别不大,只不过渲染目标不是整个屏幕的元素,而是当前mesh与需要一同渲染的子mesh的元素,进行正常的界面堆叠并推到GPU中。
b. GPU在这个环节可能不会进行3d变换(视后续光栅化位图如何利用),一些layer属性也不会进行渲染如shouldRasterize文档上描述的透明度。而相对成本较高的行为如滤镜,阴影会在这里完成渲染。当然最重要的,把相关的所有layer所有mesh合成为一张位图了。
c. 将渲染完成的位图返回原本的递归调用前的渲染流程中,在内部已经完成渲染的部分就可以从数据中抹除了,包括子layer数据,已完成的滤镜/操作等数据,剩下的交给GPU继续完成渲染。
d. 考虑到CoreAnimation本身的行为和调用GPU的行为是全参数化的,上图中的各个renderserver流程和gpu流程应该没有啥本质上的不同,只是因为递归调用时传递的参数有所裁剪从而控制到各个流程上分别开启/关闭的渲染功能,最后组合出完整的界面。
3. 复用上面对基础离屏渲染流程的描述,我们也可以根据分析描述出圆角在四角优化的情况下的整个离屏渲染的流程。除去上面描述过的流程,这个case的关键流程如下:
这个流程的核心思路是,对目标layer的部分区域进行离屏渲染,并进行贴图更换。这样做的好处是多出来的光栅化区域与位图区域更小,既缩小了离屏渲染执行成本,也缩小了离屏渲染结果持有的成本。
a. 当然,如果一个layer的不同部分的贴图不一致,mesh结构上就不能保持一个完整的mesh了,而是把四个角拆分为独立的mesh,如下图:
其中底层的渲染元素还是保持原始的mesh与layer,包括所有的子layer堆叠结构。但是四个角上覆盖了4个新的mesh如图红色,这四个mesh使用离屏渲染的结果作为贴图覆盖在原始渲染数据之上,从而解决四个角落的圆角。
b. 离屏渲染执行是依赖的原始layer和上文一样,将目标layer的全部数据与子layer一起丢进GPU渲染,唯一的区别是,这次执行光栅化的不是完整的layer范围,而是四个角落的四个矩形范围。渲染出的结果放在更小的一个或者四个贴图中,再交回上文流程用于正常渲染。
c. 上文讨论中注意到如果有2d变换的话,离屏渲染裁剪出的mesh依旧是始终保持和屏幕横平竖直的,这说明了至少在离屏渲染的GPU空间中,mesh已经完成了旋转,因此光栅化出的是已旋转位图。如果离屏渲染位图在实际渲染过程中不再发生旋转,实际会有更好的效果与更低的性能(图像采样上,同时也有利于tiling)。而3d变换的情况下无论如何图像采样都会有错位,因此不在任何地方做预处理保持正常流程确实是最明智的。
d. mesh拆分后虽然节约了新生成的贴图的大小,但是也会产生额外的mesh即顶点数据与更复杂的GPU运算,利弊如何衡量?移动端上运算密集操作首要衡量的一定是功耗,而移动端SOC渲染的功耗消耗主要是在给GPU的数据传输上而这个量级的顶点运算完全无所谓(相对于真的3d应用)。而显然从数据上生成更大贴图传输带来的功耗消耗远大于多这么几个顶点的消耗,所以显然圆角四角离屏渲染这个优化是非常有益的。对于其他离屏渲染范围限制的优化也是这个道理。
4. 对于上文提到的叠加3dtransform产生的离屏渲染,这里也有说法。具体展开看一早提到的transform流程:
a. 这里的关键行为是,对进行transform的layer,它的子layer需要进行抹平即变成和父layer的mesh在一个平面上,再和父layer一起进行变换操作(不管是2d还是3d)。但是如果子layer进行了3d变换,它在空间中就不是平的了(mesh会在顶点着色阶段发生变换),因此需要提前进入一次GPU流程完成mesh计算,顺便也把变换后光栅化的渲染结果也拿到。相关数据再返回CoreAnimation就可以变成平面的mesh与对应贴图(虽然这个mesh可能已经被拉伸得不是矩形了),即完成了抹平动作。
b. 同时在抹平子layer合成阶段,这里并没有再次离屏渲染把所有子view合在一起而是一起进行变换,也是基于上面讨论过的理由即离屏渲染的位图持有成本太高了,做矩阵变换不管是在CPU完成的2d变换还是交给GPU做3d变换,都是完全可以接受的小成本。有些别的GUI框架会这么干,可能是因为他们的渲染合成行为是在CPU完成的(比如webcore,GPU渲染layer是异类,总归会返回cpu完成最终合成)。
5. 更复杂的可以考虑下layer的mask功能流程,mask功能需要保证被mask的layer与用于mask的layer都渲染为最终平面,才能将mask给重叠出来,因此这里实际上会发生两次离屏渲染(模拟器上看到的是被mask的layer标黄了,而用于mask的layer由于没有显示看不出来)。
mask这个流程的推论也可以通过苹果wwdc2014的文档得到佐证,官方下图中对mask流程在gpu上进行了3次管线描述,其中两次的渲染目标是texture,即我们所说的离屏渲染。当然,现对来说官图描述得不严谨的地方是,这个流程并不是GPU内部执行,而是CoreAnimation通过openGL/Metal多次调度GPU得到的。
渲染流水线汇总
上文已经对UIKit界面的渲染流水线的各个主流场景进行了罗列分析,是时候对这些分析结果进行汇总构成完整的UIKit渲染流水线了。不过继续用流程图来表示这个过程可能会太复杂并且也不清晰了,因此本段用中文伪代码来表示。
我们共计有4段伪代码,分别表示的是a. 用户应用程序中,UIView与layer触发渲染的流水线。b. 系统RenderServer中,处理用户的渲染请求的流水线。c. 每一次GPU渲染流程中的顶点着色器流程。d. 每一次GPU流程中,片元着色器流程。这四段代码基本上呈现从前向后调度的关系,从而构成整个UIKit渲染流水线。
/* 用户应用程序中,UIView与layer触发渲染的流水线 */
function 准备图层(layer): void {
if (layer.needDisplayFlag 或者 layer.bitmap == null) {
layer.bitmap = layer.进行CPU绘制drawRect();
}
//递归准备所有的子图层
for (sublayer in layer.sublayer) {
准备图层(sublayer);
}
}
//主程序为程序runloop触发
function main(): void {
准备图层(window.rootlayer);
将所有layer发送给renderserver(window.rootlayer);
}
/* 系统RenderServer中,处理用户的渲染请求的流水线 */
function 准备图层数据(layer): array<meshpack> {
var meshpack = new meshpack();
meshpack.mesh = 生成mesh(layer);
if (layer.bitmap) {
meshpack.texture = layer.bitmap;
}
meshpack.alpha = layer.opacity;//基础属性alpha赋值
var mesharray = [meshpack];
var shouldRasterize = layer.shouldRasterize 或者 layer.groupopacity;
//是否已经光栅化
if (shouldRasterize 并且 var bitmap = 查询cache光栅化缓存(layer)) {
meshpack.texture = bitmap;
meshpack.mesh = 修正mesh(bitmap);//实际渲染结果可能导致mesh变大
//如果存在光栅化位图,直接范围只有这个位图的mesh并结束流程
} else {
meshpack.backgroundcolor = layer.backgroundcolor;//基础属性backgroundcolor赋值
for (sublayer in layer.sublayer) {
mesharray.add(准备图层数据(sublayer));
}
//变换处理
if (layer.transform) {
if (layer.transform.是2d的()) {
meshpack.mesh = 进行CPU矩阵变换(meshpack.mesh, layer.transform);
} else {
if (layer.父级也有transform()) {
meshpack.texture = 获取cache位图(mesharray) or 执行离屏渲染(mesharray);
meshpack.mesh = 3D修正mesh(meshpack.texture);
//抹除子layer
mesharray = [meshpack];
}
meshpack.matrix = layer.transform;
}
}
//mask流程
if (layer.有mask()) {
var thisbitmap = 获取cache位图(mesharray) or 执行离屏渲染(mesharray);
var masklayerarray = 准备图层数据(layer.masklayer);
var maskbitmap = 获取cache位图(masklayerarray) or 执行离屏渲染(masklayerarray);
meshpack.texture = thisbitmap;
meshpack.mesh = 修正mesh(thisbitmap);//mask可能会改变mesh实际范围
meshpack.masktexture = maskbitmap;
//抹除子layer
mesharray = [meshpack];
}
//圆角流程
if (layer.有圆角()) {
meshpack.corner = layer.cornerRadius;
if (mesharray.length <= 1) {
//如果没有子layer,不做啥特殊处理
} else {
if (meshpack.matrix) {
meshpack.texture = 获取cache位图(mesharray) or 执行离屏渲染(mesharray);
//抹除子layer
mesharray = [meshpack];
} else {
//四角优化
var 四个角mesharray = 生成四个角的mesh();
var bitmap圆角 = 获取cache位图(mesharray) or 执行离屏渲染(mesharray);
for (meshpack in 四个角mesharray) {
四个角mesharray.texture = bitmap圆角;
}
mesharray.addarray(四个角mesharray);
}
}
}
//border流程
if (layer.有border()) {
meshpack.border = layer.border信息();
if (mesharray.length <= 1) {
//如果没有子layer,不做啥特殊处理
} else {
meshpack.texture = 获取cache位图(mesharray) or 执行离屏渲染(mesharray);
meshpack.mesh = 修正mesh(meshpack.texture);//border会导致mesh变大
mesharray = [meshpack];
}
}
//阴影流程
if (layer.有阴影()) {
var shadowmeshpack = 生成阴影mesh层(layer);
if (layer.阴影目标是一个path而不是原始layer()) {
shadowmeshpack.shadowtexture = 生成固定阴影区域(layer.shadowpath);
meshpack.mesh = 修正mesh(shadowmeshpack.shadowtexture);//阴影会导致mesh变大
} else {
meshpack.texture = 获取cache位图(mesharray) or 执行离屏渲染(mesharray);
meshpack.mesh = 修正mesh(meshpack.texture);//阴影会导致mesh变大
mesharray = [meshpack];
shadowmeshpack.shadowtexture = meshpack.texture;
}
mesharray.add(shadowmeshpack);
}
}
if (shouldRasterize 并且 meshpack.texture == null) {
//光栅化流程要处理cifilter
if (layer.filters) {
meshpack.添加滤镜流水线(layer.filters);
}
var bitmap = 执行离屏渲染(mesharray);
放入光栅化缓存(mesharray, bitmap);
meshpack.texture = bitmap;
meshpack.mesh = 修正mesh(bitmap);
meshpack.移除所有已渲染过属性();
mesharray = [meshpack];
}
return mesharray;
}
function 执行离屏渲染(mesharray: array<meshpack>): bitmap {
准备渲染数据(mesharray);
gl.draw到贴图();
return gl.buffer位图();
}
function 执行普通渲染(mesharray: array<meshpack>): void {
准备渲染数据(mesharray);
gl.draw到屏幕();
}
function 准备渲染数据(mesharray: array<meshpack>) {
对mesh进行深度排序与修正(mesharray);
优化与剪枝(mesharray);
gl.将渲染数据推入GPU(mesharray);
}
//主程序为系统runloop触发
function main(): void {
var mesharray = [];
for (app in 所有的应用) {
mesharray.addarray(准备图层数据(app.rootlayer));
}
执行普通渲染(mesharray);
}
/* 每一次GPU渲染流程中,顶点着色器流程 */
//本质上是对mesh的处理
function main(): void {
output.point = input.point;//顶点着色器核心输出为顶点vec3
//处理3d变换
if (input.meshpack.matrix) {
output.point = input.point * input.meshpack.matrix;
}
//其他流程
tiling();
//...
}
/* 每一次GPU流程中,片元着色器流程 */
//本质上是对光栅化像素处理,顺序应该是从下而上
function main(): void {
output.color = 透明;//片元着色器核心输入为光栅像素点颜色
//处理背景
if (input.meshpack.backgroundcolor 并且 在content区域(input.uv)) {
output.color = input.meshpack.backgroundcolor;
}
//阴影
if (input.meshpack.shadowtexture) {
output.color = output.color ?: 阴影采样(input.meshpack.shadowtexture, input.uv);
}
//核心元素绘制
if (input.meshpack.texture 并且 在content区域(input.uv)) {
output.color += 采样(input.meshpack.texture, input.uv);
}
//mask
if (input.meshpack.masktexture 并且 在content区域(input.uv)) {
if (采样(input.meshpack.masktexture, input.uv) == 透明) {
output.color = 透明;
}
}
//圆角
if (input.corner) {
if (!当前位置在圆角区域内(input.uv)) {
output.color = 透明;
}
}
//border
if (input.meshpack.border && 在边缘border区域内(input.meshpack.border, input.uv)) {
output.color += border颜色并边缘采样处理();
}
//透明度
if (input.meshpack.alpha) {
output.color *= input.meshpack.alpha;
}
//filters调用
if (input.meshpack.filter) {
output.color = 调用CoreImage流水线shader函数(input.meshpack.filter, output.color, input.uv);
}
//其他流程
//...
}
总结
经由上文,虽然文字描述有限,描述的系统行为也并不全面实际更多的是将典型行为列举出来了,我们还是能对UIKit与iOS系统的整个渲染管线与各个业务关心的功能如何运作的有更清晰的认知迭代。同时,本文对这么一个话题进行分析与总结也是希望借此机会对iOS开发中在性能/表现上做抉择时常常困扰开发者的几个关键概念有更清晰的理解,从而指导我们进行更高性能与表现更优越的iOS GUI编程。
总结起来,后续继续关注如下几个话题:
- 关于离屏渲染:对界面的一部分在独立缓冲区进行不一定完整的GPU渲染流程。注意这个流程与普通的CPU渲染上的区别与必要性。——单次渲染它是高成本行为并且从功耗(GPU数据通信)/延迟(RenderServer与GPU流程串行运行即多次glFlush带来的运行延迟)与内存(新的位图输出)上都会有体现;但是这个缓存机制内存消耗可以从长期的角度换取未来的渲染性能,因此除了必要性,它在低频渲染场景确实可能带来功耗上的收益。——和CPU渲染相比,它能覆盖更多的渲染能力如3d变换/阴影等,在抉择某个功能是通过drawRect直接绘制还是交给CoreAnimation完成时,可以以能力边界作为抉择条件。——上文列举的离屏渲染场景并不全面,但是根据上面的分析过程,如果苹果有新增的场景或者其他隐藏行为我们也能够快速判断行为在流水线中的位置与特征。
- 关于GPU渲染的范围与边界:CPU渲染即CoreGraphics/CoreText的边界是view/layer的content即内容,而除了layer的content,CPU擅长图形绘制/生成/组合与其他io密集型行为;几乎所有CoreAnimation上的渲染行为都是驱动GPU完成的,GPU擅长图形采样/叠加/特效(基本上常见特效如模糊阴影描述边等本质都是依赖采样)/并有相对于CPU更低的功耗。从CoreAnimation的视角,CoreAnimation并不关心layer的content位图到底是由CoreGraphics还是什么别的工具绘制而成的,而CoreGraphics也对layer内容如何输出屏幕好不关心。——对于如今众多异步渲染方案,这个逻辑是一致的,无论做了什么,异步渲染使用CoreGraphics绘制的结果仍然是一张位图的形式交给CoreAnimation进而交给GPU完成渲染,因此衡量异步渲染实现对功耗/内存等关键指标的影响与收益有理论支持。——同时对于自定义View的实现方案选型上,应该是用drawrect绘制还是尽量由CALayer与其他子类组合实现,也可以根据需求View内容特征进行判断。
- 关于GUI渲染流水线的设计:无论如何iOS的GUI渲染管线是一个非常典型的移动端SOC渲染管线,参考安卓硬件加速后的渲染流程,Flutter自己通过图形API实现的自有渲染管线,都有大量的相似之处。同时对于更传统的GUI渲染方案,如AppKit与QT,也包括各个浏览器WebCore部分,它们的迭代方向也在逐渐向GPU渲染为主靠拢。——我们以上分析过程与经验并不仅限于iOS中,对于业界大量的GUI框架都可以按照类似思路进行分析,也能得到大量相似的结论,并为我们在大前端方向低功耗高性能GUI开发铺平道路。——同时,如果我们也在对现有的GUI方案进行二次开发或者迭代优化如RN,以上GUI渲染管线也是我们的指路人。
——————
P.S. 本文笔者review了多次,其中讲到的内容阅读困难度确实较高,通过文字描述不太容易清晰展现出来,后续可能考虑重新整理为多媒体/视频动画版本。
更新:视频版已补充https://lrdcq.com/me/read.php/149.htm