iOS 渲染系统工作原理介绍
0. 开篇叨叨
这篇博客主要从原理的角度来向大家介绍下一个界面是如何在CPU和GPU的配合下显示到屏幕上的,后续博客还会对Core Graphic,OpenGL, Core Animation的使用一一介绍,优化部分也会单独抽到后续的博客中进行介绍。下面是该博客的内容目录:
- iOS 渲染框架组成概述
- 界面渲染的整体流程
- 离屏渲染
1. iOS 渲染框架组成概述
上图是iOS 渲染框架组成部分,其中UIKit位于最上层,组成界面的各个元素基本上都来自UIKit,我们可以给它设置布局,可以通过绘制改变它的显示内容,除此之外还负责事件的接收,其实界面的显示是由它的一个被称为图层的属性CALayer来完成的。这个放在后面详细介绍。UIKit的下一层是Core Animation,最开始接触iOS的时候,我一直以为Core Animation只是用于生成动画的,实际上动画的生成只是Core Animation的冰山一角,可以说在iOS上绝大多数的原生控件都是通过Core Animation绘制出来的,Core Animation在这里最重要的任务是尽可能快地合成图层送到下一级。位于Core Animation之下是Open GL 以及 Core Graphic,其中Open GL 使用GPU进行渲染,而Core Graphic则是使用Qurtaz 2D引擎使用CPU进行渲染,这里个人理解Core Graphic不单单只有CPU参与,最终渲染到屏幕上还是需要GPU参与,这部分在下个小结中将会进行详细介绍,各个GPU厂商的实现是不同的,为了隔离这个不同,在GPU的上层添加了GPU驱动层,经过GPU处理后的数据会放到帧缓冲区中,最终显示到显示器上。
2.界面渲染的整体流程
2.1 UIView && CALayer
对于UIView和CALayer大家都比较熟悉,UIView内部包含着一个CALayer属性,它继承自NSObject,负责界面上的内容显示,而UIView继承自UIResponder它作为CALayer的CALayerDelegate负责事件的响应,以及创建并管理它的图层,以确保当子视图在层级关系中添加或者被移除的时候,它们关联的图层也同样对应在层级关系树当中有相同的操作。每个View被创建的时候都会自动创建一个CALayer,同时还可以在后续的操作中添加多个layer。
我们先来看下UIView 和 CALayer的结构:
上面我们介绍了二者的分工,UIView 负责事件的响应,CALayer负责内容的显示,但是为什么需要有这样的分工?归根到底是因为Mac上和iPhone上的事件存在很大的区别?iPhone 上的事件绝大多数是屏幕触摸事件,而Mac上还有鼠标,键盘等事件,但是显示上却是高度一致的,因此就可以将这部分显示的给拎出来,作为CALayer单独存在。
CALayer有个id类型的contents属性,它指向内存中的一个成为backing storage的存储空间。往contents上赋值的时候就会将图片存储到这个backing storage中,这里虽然是id类型,但是如果传递其他类型进去会不显示,这里为什么使用id类型而不是明确的CGImageRef,也还是为了兼容,因为图像类型在Mac OS中是NSImage类型而在iOS上却是CGImageRef类型。
下面是整个UIView 和 CALayer的结构图:
接下来看下最重要的一点:我们怎么将要显示的内容绘制到CALayer上,下图是整个流程,总共分成两大分支:
- 第一个分支是通过给layer.contents赋值,将内容绘制到CALayer 默认的backing store上,在我们调用[UIView setNeedsDisplay]的时候,会间接触发[view.layer setNeedsDisplay],紧接着调用[view.layer display] 在这个方法中会判断delegate 是否实现了displaylayer如果有则将layer传递出去,在这里可以对contents进行赋值,也就是说可以选择覆写CALayer的display方法为content赋值,或者直接对CALayer的content赋值,或者作为代理为在displaylayer方法中对content赋值。
比如SDAnimatedImageView中有如下代码:
- (void)displayLayer:(CALayer *)layer {
UIImage *currentFrame = self.currentFrame;
if (currentFrame) {
layer.contentsScale = currentFrame.scale;
layer.contents = (__bridge id)currentFrame.CGImage;
}
}
YYText中的*YYTextAsyncLayer**覆写了display方法
- (void)display { |
- 第二个分支开始的时候会创建一个新的backing store,然后开始走drawInContext,这时候会先看delegate是否实现了drawRect如果有则用drawRect,否则调用drawLayer:inContext:并将管理新建backing store的context传递出来。
这里需要注意的是drawRect: 方法没有默认的实现,因为对UIView来说,寄宿图并不是必须的,但是如果UIView检测到drawRect: 方法被调用了,它就会为视图分配一个寄宿图。因此如果没有自定义绘制的任务就不要在子类中写一个空的-drawRect:方法。
因此只有在创建单独的图层外,我们很少会用到CALayerDelegate,因为在UIView创建了它的Root Layer时,它就会自动地把Root Layer的delegate设置为它自己,并内部提供了-displayLayer:的实现。如果我们需要重新对控件进行自定义也不必实现-displayLayer:和-drawLayer:inContext:方法。通常做法是实现UIView的-drawRect:方法,UIView就会帮你做完剩下的工作,包括在需要重绘的时候调用-display方法。
2.2 Core Graphics && 图片加载
从上面的流程可以看出,可以通过两种方式给一个控件显示需要展示的内容,一种是通过设置layer的contents,一种是通过覆写对应的代理方法,在代理方法中会传出新建backing store的CGContextRef,我们可以使用它向backing store中绘制内容。这里需要注意两点:
- 使用第一种方式的时候,由于从磁盘文件中加载出来的图片文件往往是经过压缩的,因此在将它设置到contents之前需要对图片进行解压,这一步是可以在后台线程完成的,这往往是一个性能优化点。
- 使用第二种方式的时候需要注意的是这种方式会新建一个backing store所以是十分耗费资源的。这也是尽量不要使用重写drawRect来绘制界面的原因。
最后需要明确一点经过这个步骤我们得到的是Bitmap数据,这个Bitmap将会被输送到后续流程。
2.3 在提交到Render Server前都做了什么工作
在一个界面从开始到提交到Render Server前一共可以分成三个步骤:
* Layout |
2.3.1 Layout
在一个控件被加到界面上的时候,首先会触发控件的布局,从而确定出整个层级树中每个控件的frame。这部分大家可以看下我之前写的iOS布局总结,这里就不做重复介绍了。
2.3.2 Prepare && Display
这部分主要是由CPU参与,在2.1 和 2.2已经描述得相对比较详细了,这部分会涉及到图片的解码,文本绘制,或者通过CALayer暴露出来的CGContextRef在backing store中进行绘制。图片解码一般发生在Prepare阶段。存储在backing store的 bitmap后续就会被打包送到Render Server中。
2.3.3 Commit
当RunLoop即将进入休眠期间或者即将退出的时候,会通过已经注册的通知回调执行_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv函数,在这个函数会递归将待处理的图层进行打包压缩,并通过IPC方式发送到Render Server,这里还需要提到一点:这时候的Core Animation会创建一个OpenGL ES纹理并将2.3.2 中backing store中的位图上传到对应的纹理中。
2.4 在送到GPU之前Core Animation 都做了什处理
Render Server在拿到压缩后的数据的时候,首先对这些数据进行解压,从而拿到图层树,然后根据图层树的层次结构,每个层的alpha值opeue值,RGBA值、以及图层的frame值等对被遮挡的图层进行过滤,最终得到渲染树,渲染树就是指将图层树对应每个图层的信息,比如顶点坐标、顶点颜色这些信息,抽离出来,形成的树状结构。渲染树就是下一步送往GPU进行渲染的数据。
2.5 在GPU中做了哪些处理
这步骤输入的是两类数据,一个是渲染指令,一个是2.4 生成的顶点,以及对应的纹理数据,输出的是像素数据
整个管线从整体来讲可以分成两大阶段:
- 将3D 坐标转换到2D 的屏幕坐标系
- 把2D 坐标系转换为有颜色的像素值。
往细得分可以分成六个阶段:
- 顶点着色器(Vertex Shader)
- 图元装配(Shape Assembly)
- 几何着色器(Geometry Shader)
- 光栅化(Rasterization)
- 片段着色器(Fragment Shader)
- 测试与混合(Tests and Blending)
下面是整个流程的示意图:
其中蓝色部分是代表着色器,着色器是运行在GPU上的非常独立的可编程小程序,可以通过这些小程序来控制整个管线的各个部分。
2.5.1 顶点着色器
在Render Server 拿到顶点数据并输入到渲染管线的时候,顶点着色器会对每个顶点数据进行一次运算,每个顶点都对应一组顶点数组,这些数组可以用于存储:顶点坐标,表面法线,RGBA颜色,辅助颜色,颜色索引,雾坐标,纹理坐标以及多边形边界标志等。
- Step 1 模型坐标系 –> 世界坐标系
模型坐标系是为了方便建立模型而设立的坐标,在模型坐标系中我们不用考虑模型显示在屏幕的哪个位置,它是模型的自身坐标系,描述的是模型的各个部分相对于模型原点的坐标值。
要理解世界坐标系就需要先理解世界这个概念,在一个世界中可以存在很多模型,打个比方,整个银河系是一个世界,这个世界上存在很多的行星,这里的行星可以看成是一个个模型,模型本身也有它的坐标系,在Step 1 阶段就是将模型安放到制定的世界中,并将模型上的坐标转换为在这个世界中的坐标值。
上图第一张为三个茶壶各自的模型坐标系,第二张表示三个茶壶被放置到同一个世界坐标系的时候各个茶壶的坐标情况。
- Step 2 世界坐标系 –> 相机坐标系
在将多个模型放到同一个世界上并拥有同一个世界坐标系后,就需要考虑另一个问题,从哪个视角捕获我们想要的场景,相机坐标系中的坐标,就是从相机的角度来解释世界坐标系中位置,OpenGL中相机始终位于原点,指向-Z轴,而以相反的方式来调整场景中物体,从而达到相同的观察效果。
- Step 3 相机坐标系 –> 裁剪坐标系
投影是顺着相机的视角,将物体投射到屏幕上,投影方式有很多种,OpenGL中主要使用两种方式,即透视投影和正交投影,经过投影后我们获得的是二维的图像。
- Step 4 规范化设备坐标系 –> 屏幕坐标系
这个步骤最终决定生成的二维图像到底显示在屏幕的什么位置和显示窗口的大小。
2.5.2 图元装配
该阶段将顶点着色器输出的所有顶点作为输入,并将所有的点装配成指定图元的形状。图元是渲染的单位,用于表示如何渲染顶点数据,OpenGL ES 支持三种图元—– 点、线、三角形。也就是说图元装配的过程就是将顶点连接起来,形成一个个所支持的图元元素。
2.5.3 几何着色器
几何着色器把图元形式的一系列顶点的集合作为输入,它可以通过产生新顶点构造出新的图元来生成其他形状,它是一个可选的阶段。
2.5.4 光栅化
光栅化会把图元映射为最终屏幕上相应的像素,生成供片段着色器使用的片段。在片段着色器运行之前会执行裁切。裁切会丢弃超出你的视图以外的所有像素,用来提升执行效率。
2.5.5 片段着色器
在经过光栅化后得到的是一个一个片段,在介绍片段着色器之前,先了解下什么是片段,OpenGL中的一个片段是OpenGL渲染一个像素所需的所有数据,它包含位置,颜色,纹理坐标等信息。这些值是由图元的顶点信息进行插值计算得到的。片段着色器的主要目的是计算一个像素的最终颜色,这也是所有OpenGL高级效果产生的地方。通常,片段着色器包含3D场景的数据(比如光照、阴影、光的颜色等等),这些数据可以被用来计算最终像素的颜色。
其中贴图是最重要的部分,我们的 Resources资源中,可以包含纹理等数据,片段着色器可以根据顶点着色器输出的顶点纹理坐标对纹理进行采样,以计算该片段的颜色值。从而调整成各种各样不同的效果图。除了纹理贴图外另一个很重要的功能就是光照特效:我们可以传给片段着色器一个光源位置和光源颜色,就可以根据一定的公式计算出一个新的颜色值,这样就可以实现光照特效。
2.5.6 测试和混合阶段
这个阶段检测片段的对应的深度值,用它们来判断这个像素是其它物体的前面还是后面,决定是否应该丢弃。这个阶段也会检查alpha值并对物体进行混合(Blend)。所以,即使在片段着色器中计算出来了一个像素输出的颜色,在渲染多个三角形的时候最后的像素颜色也可能完全不同。总得来说这个阶段主要决定同一个位置的物体到底哪一个可以显示在屏幕上以及颜色的混合。
到目前位置我们拿到各个点的最终像素值,最后我们要做的就是将这些像素值写到帧缓存器中,等待VSync信号到来。
2.6 显示的原理
在介绍显示原理前需要先了解下CRT显示器的原理。CRT 的电子枪会从屏幕的左上角从上到下一行行扫描,扫描完成后显示器就呈现一帧画面,随后电子枪回到初始位置继续下一次扫描。但是这里存在生产者消费者现象,对于屏幕而言它是消费者,而GPU是生产者,为了同步二者的节奏显示器通过用硬件时钟产生一系列的定时信号,当电子枪换到新的一行,准备进行扫描时,显示器会发出一个水平同步信号,简称 HSync,而当一帧画面绘制完成后,电子枪回复到原位,准备画下一帧前,显示器会发出一个垂直同步信号,简称 VSync,并且在显示器和GPU之间使用了双缓存机制,在显示器显示某帧数据的时候,GPU可以往另一个缓存中提交渲染好的数据,在VSync信号到来的时候,视频控制器切换到另一个缓存用于显示,也就是说在Vsync信号到来的时候另一个缓存必须填满渲染数据,也就是之前的步骤必须完成,在iOS设备中,每秒60帧,每帧16.7ms。那么如果在16.7ms还没渲染完呢?这时候视频控制器就不会将缓存切换到未完成的帧,而是继续显示当前的内容。这就给人们带来视觉上的卡顿。因此在UI线程尽量少处理耗时操作。
2.7 动画渲染
iOS 动画的渲染也是基于上述 Core Animation 流水线完成的,所以大致的流程也是类似的,但是它需要CAdisplayLink 定时器协助下完成整个动画。
1. 调用 animationWithDuration:animations: 方法 |
3.离屏渲染
3.1 什么是离屏渲染
上面介绍的渲染为当前屏幕渲染(On-Screen Rendering),也就是GPU的操作是在当前用于显示的屏幕缓冲区中进行的,但是还有一种渲染模式为离屏渲染,它发生在某些图层元素未预先合成之前不能直接在当前屏幕上绘制的情况下,这种情况下系统会新开一个缓冲区,在这里进行渲染操作。
3.2 触发离屏渲染的因素
离屏渲染一般发生在如下几种情况:
* shouldRasterize = YES(开启光栅化) |
3.3 为什么离屏渲染会比较耗时
之所以耗时是因为离屏渲染涉及到两个开销较大的操作:
- 创建新缓冲区
要想进行离屏渲染,首先要创建一个新的缓冲区。
- 上下文切换
离屏渲染的整个过程,需要多次切换上下文环境:先是从当前屏幕切换到离屏;等到离屏渲染结束以后,将离屏缓冲区的渲染结果显示到屏幕上有需要将上下文环境从离屏切换到当前屏幕。而上下文环境的切换是要付出很大代价的。
所以能够避免离屏渲染尽量避免。
下图为Marks离屏渲染过程图,可以看到它其实是分两大部分,一部分用于渲染图层纹理,一部分用于生成遮罩纹理。最后再将这两部分合成。