OC中的渲染 和 离屏渲染

1,391 阅读21分钟

OC中的渲染 和 离屏渲染

一、相关基本概念

1.帧缓存区(FrameBuffer)

帧缓冲存储器(FrameBuffer):简称帧缓存或显存,它是屏幕所显示画面的一个直接映像,又称为位映像(Bit Map)或 光栅。帧缓存的每一个存储单元对应屏幕上的一个像素,整个帧缓存对应一帧图像。

2.上下文切换

上下文 是指某一时间点CPU寄存器和程序计数器的内容。寄存器是有限存贮容量的高速存贮部件,它们可用来暂存指令、数据和位址。程序计数器是一个专用的寄存器,用于表明指令序列中CPU正在执行的位置,存的值为正在执行的指令位置或者下一个将要执行的指令位置(具体依赖于特定的系统)。 上下文切换 (有事也称做进程切换或者任务切换):是指CPU从一个 进程/线程/任务 切换到一个 进程/线程/任务。稍微详细点描述就是:上下文切换可以认为是内核(操作系统的核心)在CPU上对于进程(包括线程)进行以下活动: (1)挂起一个进程,将这个进程在CPU中的上下文状态存储到内存中的某处, (2)在内存中检索下一个进程的上下文内容并将其在CPU的寄存器中恢复, (3)跳转到程序计数器所指向的位置(即跳转到进程被中断是的代码行),以恢复该进程。

注意:CPU是通过为每个进程(包括线程)分配CPU时间片来实现多线程机智。CPU通过时间片分配算法来执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的上下文状态,以便下一次切回这个任务是,可以再加载这个任务的状态,以便继续执行上一个任务。所以任务从保存到在加载的过程就是一次上下文切换。 上下文切换通常是计算密集型的,意味着此操作会消耗大量的CPU时间,故线程也不是越多越好。如何减少系统中上下文切换的次数,是提升多线程的性能的一个重点课题。

3.画家算法

画家算法也叫做优先填充,它是计算机图形学中处理可见性问题的一种解决方法。”画家算法“表示一个头脑简单的画家首先绘制距离较远的场景,然后绘制距离较近的场景覆盖较远的部分。画家算法首先将场景中的多边形根据深度进行排序,然后按照顺序进行描绘。这种方法通常会将不可见的部分覆盖,这样就可以解决可见性问题。

在ios中 主要的渲染操作是由 CoreAnimationRender Server模块通过调用显卡驱动提供接口OpenGL/Matal来执行的的

IOS对于每一层的Layer,RenderServer会按照深度排序依次输出到FrameBuffer中,后一层会覆盖前一层,然后达到最终效果。此过程是不可逆的,遮住部分的数据永久丢失无法修改。

4.光栅化(Rasterization)

光栅化(Rasterization)是吧顶点数据转换为片元的过程,具有将图转化为一个个栅格组成的图像的作用,特点是每个元素对应帧缓冲区中的一个像素。

二、iOS Rendering Process 概念

iOS Rendering Process 译为 iOS 渲染流程,本文特指 iOS 设备从设置将要显示的图元数据到最终在设备屏幕成像的整个过程。

在开始剖析 iOS Rendering Process 之前,我们需要对 iOS 的渲染概念有一个基本的认知:

1. 基于平铺的渲染

IOS 设备的屏幕分为N*N像素的图块,每个图块都适合于SoC缓存,几何图形在图块内被大量的拆分,只有在所有几何体全部提交后才可以进行光栅化(Rasterization)。 图1

注意: 这里的光栅化值将屏幕上面被大量拆分出来的几何体渲染为像素点的过程。

图2

2.iOS Rendering 技术框架

事实上 iOS 渲染相关的层级划分大概如下: 图3

图形渲染技术栈

下图所示为 iOS App 的图形渲染技术栈,App 使用 Core Graphics、Core Animation、Core Image 等框架来绘制可视化内容,这些软件框架相互之间也有着依赖关系。这些框架都需要通过 OpenGL 来调用 GPU 进行绘制,最终将内容显示到屏幕之上。

各个框架主要作用如下:

·UIKit

我们日常开发中使用的用户交互组件都来自于 UIKit Framework,我们通过设置 UIKit 组件的 Layout 以及 BackgroundColor 等属性来完成日常的界面绘画工作。其实 UIKit Framework 自身并不具备在屏幕成像的能力,它主要负责对用户操作事件的响应,事件响应的传递大体是经过逐层的视图树遍历实现的。

·Core Animation

CoreAnimation 源自LayerKit,动画只是CoreAnimation特性的冰上一角。CoreAnimation是一个复合引擎,其职责是尽可能快的组合屏幕上不同的可视内容,这些可视内容被分解成独立的图层(即CALayer),这些图层会被存储在一个叫图层树的体系中。从本质上而言,CALayer是用户所能在屏幕上看见的唯一基础。

·Core Graphic

Core Graphic 基于 Quartz 高级绘图引擎,主要用于运行时绘制图像。开发者可以使用此框架处理基于路径的绘图、转换、颜色管理、离屏渲染、图案、渐变和阴影、图像数据管理、图像创建和图像遮罩以及 PDF 文档创建,显示和分析。 当开发者需要在 运行时创建图像 时,可以使用 Core Graphics 去绘制。与之相对的是 运行前创建图像,例如用 Photoshop 提前做好图片素材直接导入应用。相比之下,我们更需要 Core Graphics 去在运行时实时计算、绘制一系列图像帧来实现动画。

·Core Image

Core Image 与 Core Graphics 恰恰相反,Core Graphics 用于在 运行时创建图像,而 Core Image 是用来处理 运行前创建的图像 的。Core Image 框架拥有一系列现成的图像过滤器,能对已存在的图像进行高效的处理。

大部分情况下,Core Image 会在 GPU 中完成工作,但如果 GPU 忙,会使用 CPU 进行处理。

·OpenGL ES

OpenGL ES(OpenGL for Embedded Systems,简称 GLES),是 OpenGL 的子集。在前面的 图形渲染原理综述 一文中提到过 OpenGL 是一套第三方标准,函数的内部实现由对应的 GPU 厂商开发实现。

·Matal

Metal 类似于 OpenGL ES,也是一套第三方标准,具体实现由苹果实现。大多数开发者都没有直接使用过 Metal,但其实所有开发者都在间接地使用 Metal。Core Animation、Core Image、SceneKit、SpriteKit 等等渲染框架都是构建于 Metal 之上的。

当在真机上调试 OpenGL 程序时,控制台会打印出启用 Metal 的日志。根据这一点可以猜测,Apple 已经实现了一套机制将 OpenGL 命令无缝桥接到 Metal 上,由 Metal 担任真正于硬件交互的工作。

UIView 与 CALayer 的关系

在前面的 Core Animation 简介中提到 CALayer 事实上是用户所能在屏幕上看见的一切的基础。为什么 UIKit 中的视图能够呈现可视化内容?就是因为 UIKit 中的每一个 UI 视图控件其实内部都有一个关联的 CALayer,即 backing layer。

由于这种一一对应的关系,视图层级拥有 视图树 的树形结构,对应 CALayer 层级也拥有 图层树 的树形结构。 其中,视图的职责是 创建并管理 图层,以确保当子视图在层级关系中 添加或被移除 时,其关联的图层在图层树中也有相同的操作,即保证视图树和图层树在结构上的一致性。视图层还管理用户的交互响应

Q:那么为什么 iOS 要基于 UIView 和 CALayer 提供两个平行的层级关系呢?

其原因在于要做 职责分离,这样也能避免很多重复代码。在 iOS 和 Mac OS X 两个平台上,事件和用户交互有很多地方的不同,基于多点触控的用户界面和基于鼠标键盘的交互有着本质的区别,这就是为什么 iOS 有 UIKit 和 UIView,对应 Mac OS X 有 AppKit 和 NSView 的原因。它们在功能上很相似,但是在实现上有着显著的区别。

实际上,这里并不是两个层级关系,而是四个。每一个都扮演着不同的角色。除了 视图树图层树,还有 呈现树渲染树

1.CALyaer

那么为什么 CALayer 可以呈现可视化内容呢?因为 CALayer 基本等同于一个 纹理。纹理是 GPU 进行图像渲染的重要依据。

在 图形渲染原理 中提到纹理本质上就是一张图片,因此 CALayer 也包含一个 contents 属性指向一块缓存区,称为 backing store,可以存放位图(Bitmap)。iOS 中将该缓存区保存的图片称为寄宿图 图形渲染流水线支持从顶点开始进行绘制(在流水线中,顶点会被处理生成纹理),也支持直接使用纹理(图片)进行渲染。相应地,在实际开发中,绘制界面也有两种方式:一种是 手动绘制;另一种是 使用图片。 对此,iOS 中也有两种相应的实现方式:

  • 使用图片:contents image
  • 手动绘制:custom drawing
Contents Image

Contents Image 是指通过 CALayer 的 contents 属性来配置图片。然而,contents 属性的类型为 id。在这种情况下,可以给 contents 属性赋予任何值,app 仍可以编译通过。但是在实践中,如果 content 的值不是 CGImage ,得到的图层将是空白的。 既然如此,为什么要将 contents 的属性类型定义为 id 而非 CGImage。这是因为在 Mac OS 系统中,该属性对 CGImage 和 NSImage 类型的值都起作用,而在 iOS 系统中,该属性只对 CGImage 起作用。 本质上,contents 属性指向的一块缓存区域,称为 backing store,可以存放 bitmap 数据。

Custom Drawing

Custom Drawing 是指使用 Core Graphics 直接绘制寄宿图。实际开发中,一般通过继承 UIView 并实现 -drawRect: 方法来自定义绘制。 虽然 -drawRect: 是一个 UIView 方法,但事实上都是底层的 CALayer 完成了重绘工作并保存了产生的图片。下图所示为 -drawRect: 绘制定义寄宿图的基本原理。 UIView 有一个关联图层,即 CALayer。 CALayer 有一个可选的 delegate 属性,实现了 CALayerDelegate 协议。UIView 作为 CALayer 的代理实现了 CALayerDelegae 协议。 当需要重绘时,即调用 -drawRect:,CALayer 请求其代理给予一个寄宿图来显示。 CALayer 首先会尝试调用 -displayLayer: 方法,此时代理可以直接设置 contents 属性。

  • -(void)displayLayer:(CALayer *)layer; 如果代理没有实现 -displayLayer: 方法,CALayer 则会尝试调用 -drawLayer:inContext: 方法。在调用该方法前,CALayer 会创建一个空的寄宿图(尺寸由 bounds 和 contentScale 决定)和一个 Core Graphics 的绘制上下文,为绘制寄宿图做准备,作为 ctx 参数传入。

  • -(void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx; 最后,由 Core Graphics 绘制生成的寄宿图会存入 backing store。

Core Animation 流水线

通过前面的介绍,我们知道了 CALayer 的本质,那么它是如何调用 GPU 并显示可视化内容的呢?下面我们就需要介绍一下 Core Animation 流水线的工作原理。 事实上,app 本身并不负责渲染,渲染则是由一个独立的进程负责,即 Render Server 进程。 App 通过 IPC(InterProcess-Communication进程间通信) 将渲染任务及相关数据提交给 Render Server。Render Server 处理完数据后,再传递至 GPU。最后由 GPU 调用 iOS 的图像设备进行显示。

Core Animation 流水线的详细过程如下:

  • 首先,由 app 处理事件(Handle Events),如:用户的点击操作,在此过程中 app 可能需要更新 视图树,相应地,图层树 也会被更新。
  • 其次,app 通过 CPU 完成对显示内容的计算,如:视图的创建、布局计算、图片解码、文本绘制等。在完成对显示内容的计算之后,app 对图层进行打包,并在下一次 RunLoop 时将其发送至 Render Server,即完成了一次 Commit Transaction 操作。
  • Render Server 主要执行 Open GL、Core Graphics 相关程序,并调用 GPU
  • GPU 则在物理层上完成了对图像的渲染。
  • 最终,GPU 通过 Frame Buffer、视频控制器等相关部件,将图像显示在屏幕上。

对上述步骤进行串联,它们执行所消耗的时间远远超过 16.67 ms,因此为了满足对屏幕的 60 FPS 刷新率的支持,需要将这些步骤进行分解,通过流水线的方式进行并行执行,如下图所示。 Commit Transaction 在 Core Animation 流水线中,app 调用 Render Server 前的最后一步 Commit Transaction 其实可以细分为 4 个步骤:

  • Layout Layout 阶段主要进行视图构建,包括:LayoutSubviews 方法的重载,addSubview: 方法填充子视图等。
  • Display Display 阶段主要进行视图绘制,这里仅仅是设置最要成像的图元数据。重载视图的 drawRect: 方法可以自定义 UIView 的显示,其原理是在 drawRect: 方法内部绘制寄宿图,该过程使用 CPU 和内存。
  • Prepare Prepare 阶段属于附加步骤,一般处理图像的解码和转换等操作。
  • Commit Commit 阶段主要将图层进行打包,并将它们发送至 Render Server。该过程会递归执行,因为图层和视图都是以树形结构存在。

动画渲染原理

iOS 动画的渲染也是基于上述 Core Animation 流水线完成的。这里我们重点关注 app 与 Render Server 的执行流程 日常开发中,如果不是特别复杂的动画,一般使用 UIView Animation 实现,iOS 将其处理过程分为如下三部阶段:

  • Step 1:调用 animationWithDuration:animations: 方法
  • Step 2:在 Animation Block 中进行 Layout,Display,Prepare,Commit 等步骤。
  • Step 3:Render Server 根据 Animation 逐帧进行渲染。

离屏渲染(off-Screen Rendering)

离屏渲染的定义

在OpenGL中,GPU屏幕渲染有以下两种方式: 1.当前屏幕渲染(On-Screen Rendering):正常情况下,我们直接在帧缓冲区中(FrameBufffer)渲染好数据,然后读取数据显示在屏幕上。 2.离屏渲染(Off-Screen Rendering):如果有事因为一些限制,无法把渲染的结果写入到帧缓存区,而是先暂存在另外的内存区域,之后再写入帧缓冲去,这个过程称为离屏渲染。

在上面的CoreAnimation流水线示意图中,我们可以得知主要的渲染操作是由CoreAnimation的Render Server模块,通过调用显卡驱动提供的OpenGL或Metal接口执行,对于每一层layer,Render Server会遵循“画家算法”(由远及近),按次序输出到frame buffer,然后按照次序绘制到屏幕,当绘制完一层,就会将该层从帧缓存区中移除(以节省空间)如下图,从左至右依次输出,得到最后的显示结果。 但在某些场景下“画家算法”虽然可以逐层输出,但是无法在某一层渲染完成后,在回过头来擦除/修改某一部分,因为这一层之前的layer像素数据已经被永久覆盖了。这就意味着对于每一层的layer要么能够通过单次遍历就能完成渲染,要么就只能令开辟一块内存作为临时中转区来完成复杂的修改/裁剪等操作。

举例说明:对图3进行圆角和裁剪:imageView.clipsToBounds = YES,imageView.layer.cornerRadius=10时,这就不是简单的图层叠加了,图1,图2,图3渲染完成后,还要进行裁减,而且子视图layer因为父视图有圆角,也需要被裁剪,无法在某一层渲染完成之后,再回过头来擦除/改变其中的某个部分。所以不能按照正常的流程,因此苹果会先渲染好每一层,存入一个缓冲区中,即离屏缓冲区,然后经过层叠加和处理后,再存储到帧缓存去中,然后绘制到屏幕上,这种处理方式叫做离屏渲染

关于剪切触发离屏渲染分析

关于常见的设置圆角触发离屏渲染示例说明:

  • btn1设置了图片,设置了圆角,打开了clipsToBounds = YES,触发了离屏渲染.
  • btn2设置了背景颜色,设置了圆角,打开了clipsToBounds = YES,没有触发离屏渲染。
  • img1设置了图片,设置了圆角,打开了masksToBounds = YES,触发了离屏渲染。
  • img2设置了背景颜色,设置了圆角,打开了masksToBounds = YES,没有触发离屏渲染

对于每一层layer,要么能找到一种通过单次遍历就能完成渲染的算法,要么就不得不另开一块内存,借助这个临时中转区域来完成一些更复杂的、多次的修改/剪裁操作。对于btn1 因为其包含了子视图(image是它的子视图),在剪切btn1时需要去除子视图超出的部分所以会触发离线渲染。而img1 应为设置了背景色,画家算法也会先绘制backgroundColor 然后绘制 content中的内容,也是不能单次遍历完成渲染所以也会触发离屏。但是btn2 和 img2 都是可以单次遍历渲染完成所以不会触发离屏渲染

常见离屏渲染场景分析

  • cornerRadius+clipsToBounds,原因就如同上面提到的,不得已只能另开一块内存来操作。而如果只是设置cornerRadius(如不需要剪切内容,只需要一个带圆角的边框),或者只是需要裁掉矩形区域以外的内容(虽然也是剪切,但是稍微想一下就可以发现,对于纯矩形而言,实现这个算法似乎并不需要另开内存),并不会触发离屏渲染。关于剪切圆角的性能优化,根据场景不同有几个方案可供选择,非常推荐阅读AsyncDisplayKit中的一篇文档点我。

  • shadow ,原因在于,虽然layer本身是一块矩形区域,但是阴影默认是作用在其中"非透明区域"的,而且需要显示在所有layer内容的下方,因为此时阴影的本体(layer和其子layer)都还没有被组合到一起,所以不能在第一步就画出只有完成最后一步之后才能知道的形状 这样一来又只能另外申请一块内存,把本体内容都先画好,再根据渲染结果的形状,添加阴影到frame buffer,最后把内容画上去(这只是我的猜测,实际情况可能更复杂)。不过如果我们能够预先告诉CoreAnimation(通过shadowPath属性)阴影的几何形状,那么阴影当然可以先被独立渲染出来,不需要依赖layer本体,也就不再需要离屏渲染了。

  • group opacity,其实从名字就可以猜到,alpha并不是分别应用在每一层之上,而是只有到整个layer树画完之后,再统一加上alpha,最后和底下其他layer的像素进行组合。显然也无法通过一次遍历就得到最终结果。将一对蓝色和红色layer叠在一起,然后在父layer上设置opacity=0.5,并复制一份在旁边作对比。左边关闭group opacity,右边保持默认(从iOS7开始,如果没有显式指定,group opacity会默认打开),然后打开offscreen rendering的调试,我们会发现右边的那一组确实是离屏渲染了。

  • mask,我们知道mask是应用在layer和其所有子layer的组合之上的,而且可能带有透明度,那么其实和group opacity的原理类似,不得不在离屏渲染中完成。

  • 其他还有一些,类似allowsEdgeAntialiasing等等也可能会触发离屏渲染,原理也都是类似:如果你无法仅仅使用frame buffer来画出最终结果,那就只能另开一块内存空间来储存中间结果。这些原理并不神秘。

GPU离屏渲染的性能影响

离屏渲染对性能的影响,不单单只表现在额外离屏渲染的内存,相对于内存的影响其最主要的影响是上下文切换。 GPU的操作是高度流水线化的。本来所有计算工作都在有条不紊地正在向frame buffer输出,此时突然收到指令,需要输出到另一块内存,那么流水线中正在进行的一切都不得不被丢弃,切换到只能服务于我们当前的“切圆角”操作。等到完成以后再次清空,再回到向frame buffer输出的正常流程。

在tableView或者collectionView中,滚动的每一帧变化都会触发每个cell的重新绘制,因此一旦存在离屏渲染,上面提到的上下文切换就会每秒发生60次,并且很可能每一帧有几十张的图片要求这么做,对于GPU的性能冲击可想而知(GPU非常擅长大规模并行计算,但是我想频繁的上下文切换显然不在其设计考量之中)

善用离屏渲染,提升性能

CALayer为这个方案提供了对应的解法:shouldRasterize。一旦被设置为true,Render Server就会强制把layer的渲染结果(包括其子layer,以及圆角、阴影、group opacity等等)保存在一块内存中,这样一来在下一帧仍然可以被复用,而不会再次触发离屏渲染。有几个需要注意的点:

  • shouldRasterize的主旨在于降低性能损失,但总是至少会触发一次离屏渲染。如果你的layer本来并不复杂,也没有圆角阴影等等,打开这个开关反而会增加一次不必要的离屏渲染

  • 离屏渲染缓存有空间上限,最多不超过屏幕总像素的2.5倍大小

  • 一旦缓存超过100ms没有被使用,会自动被丢弃

  • layer的内容(包括子layer)必须是静态的,因为一旦发生变化(如resize,动画),之前辛苦处理得到的缓存就失效了。如果这件事频繁发生,我们就又回到了“每一帧都需要离屏渲染”的情景,而这正是开发者需要极力避免的。针对这种情况,Xcode提供了“Color Hits Green and Misses Red”的选项,帮助我们查看缓存的使用是否符合预期

  • 其实除了解决多次离屏渲染的开销,shouldRasterize在另一个场景中也可以使用:如果layer的子结构非常复杂,渲染一次所需时间较长,同样可以打开这个开关,把layer绘制到一块缓存,然后在接下来复用这个结果,这样就不需要每次都重新绘制整个layer树了

什么时候需要CPU渲染

渲染性能的调优,其实始终是在做一件事:平衡CPU和GPU的负载,让他们尽量做各自最擅长的工作。 绝大多数情况下,得益于GPU针对图形处理的优化,我们都会倾向于让GPU来完成渲染任务,而给CPU留出足够时间处理各种各样复杂的App逻辑。为此Core Animation做了大量的工作,尽量把渲染工作转换成适合GPU处理的形式(也就是所谓的硬件加速,如layer composition,设置backgroundColor等等)。

但是对于一些情况,如文字(CoreText使用CoreGraphics渲染)和图片(ImageIO)渲染,由于GPU并不擅长做这些工作,不得不先由CPU来处理好以后,再把结果作为texture传给GPU。除此以外,有时候也会遇到GPU实在忙不过来的情况,而CPU相对空闲(GPU瓶颈),这时可以让CPU分担一部分工作,提高整体效率。

参考

chuquan.me/2018/09/25/… juejin.cn/post/684490… www.jianshu.com/p/39b91ecaa… www.jianshu.com/p/c778cf2a1…