iOS 离屏渲染

发布于:2025-07-29 ⋅ 阅读:(16) ⋅ 点赞:(0)

iOS 离屏渲染

前言

笔者今天简单学习一下有关于iOS渲染的相关知识,认识离屏渲染之前我们得先了解iOS的渲染流程

屏幕卡顿

在我们平时的日常使用中,我们经常会遇到屏幕卡顿的问题,屏幕卡顿指的是图形撕裂,掉帧等问题.

产生原因

这里主要分成一下三点:

  • CPU和GPU在渲染的流水线中耗时太长,导致缓存区获取位图显示的时候,下一帧的数据还没有准备好,获取的仍然是上一帧的数据,从而产生了一个掉帧的现象
  • 苹果针对屏幕撕裂的问题,采用的是垂直同步+双缓存机制,这个可以防止屏幕撕裂,但是同时导致了新的问题也就是掉帧.
  • 在垂直同步+双缓存区的方案上,再次进行优化,将双缓存区,改为三缓存区,这样其实也并不能从根本上解决掉帧的问题,只是比双缓存区掉帧的概率小了很多,仍有掉帧的可能性,对于用户而言,可能是无感知的。
屏幕撕裂

img

在理解屏幕撕裂之前,我们先来了解屏幕是怎么成像,

屏幕成像过程:

img

  • 将需要显示的图象,经过GPU渲染
  • 将渲染后的结果,存储到帧缓存区,帧缓存区中存储的格式是位图
  • 有视屏控制器从帧缓存区中读取位图,交给显示器,从左上角逐行进行一个显示

这样的话就会出现一个屏幕撕裂的问题

在屏幕显示图形图像的过程中,是不断从帧缓存区获取一帧一帧数据进行显示的,

然后在渲染的过程中,帧缓存区中仍是旧的数据,屏幕拿到旧的数据去进行显示,

在旧的数据没有读取完时 ,新的一帧数据处理好了,放入了缓存区,这时就会导致屏幕另一部分的显示是获取的新数据,从而导致屏幕上呈现图片不匹配,人物、景象等错位显示的情况。

style月月

img

所以苹果采用了一个垂直同步+双缓存区,这个方案是强制要求同步,所以可能会出现掉帧的问题

img

  • 垂直同步:表示给帧缓存加锁,当电子光束扫瞄的过程中,只有扫面完成之后才会的读取下一帧的数据,而不是只读取一部分
  • 双缓存区:次阿勇两个帧缓存区用途GPU处理结果的存储,当屏幕显示其中一个缓存区的时候,另一个缓存区继续等待下一个缓冲结果,两个缓冲区交替进行
掉帧

掉帧的原因就是屏幕在刷新的时候会重复显示同一帧的内容,这里就会出现一个掉帧的问题.

img

iOS渲染架构

同样我们先从一副图来引入这个iOS的渲染架构:

img

  • App通过调用CoreGraphics、CoreAnimation、CoreImage等框架的接口触发图形渲染操作

  • CoreGraphics、CoreAnimation、CoreImage等框架将渲染交由OpenGL ES,由OpenGL ES来驱动GPU做渲染,最后显示到屏幕上

  • 由于OpenGL ES 是跨平台的,所以在他的实现中,是不能有任何窗口相关的代码,而是让各自的平台为OpenGL ES提供载体。在ios中,如果需要使用OpenGL ES,就是通过CoreAnimation提供窗口,让App可以去调用.

iOS渲染的框架主要分成一下几种

img

iOS CoreAnimation渲染流水线

img

这里主要分成了四个层级:

  • Application 应用处理阶段得到图元(由CPU负责)

这个阶段具体指的是就是图像在应用中被处理的阶段,此时还处于CPU负责的时期.在这个阶段应用肯会对图像进行一系列的操作:然后将图像传递给下一个阶段.

这部分内容被叫做图元,图元就是描述几何形状的基本元素,通常是三角形、线段、顶点等。

  • Geometry 几何处理阶段:处理图元(GPU)

进入这个阶段之后,以及之后的阶段,就都主要由 GPU 负责了。此时 GPU 可以拿到上一个阶段传递下来的图元信息,GPU 会对这部分图元通过顶点着色器、形状装配、几何着色器进行处理,之后输出新的图元。这一系列阶段包括:

  • 顶点着色器(Vertex Shader):这个阶段中会将图元中的顶点信息进行视角转换、添加光照信息、增加纹理等操作。

  • 形状装配(Shape Assembly):图元中的三角形、线段、点分别对应三个 Vertex、两个 Vertex、一个 Vertex。这个阶段会将 Vertex 连接成相对应的形状。

  • 几何着色器(Geometry Shader):额外添加额外的Vertex,将原始图元转换成新图元,以构建一个不一样的模型。简单来说就是基于通过三角形、线段和点构建更复杂的几何图形。

  • Rasterization 光栅化阶段:图元转换为像素(GPU)

光栅化的主要目的是将几何渲染之后的图元信息,转换为一系列的像素,以便后续显示在屏幕上

象下面这个图的划分中心点的操作

图片

  • Pixel 像素处理阶段:处理像素,得到位图(GPU)

过上述光栅化阶段,我们得到了图元所对应的像素,之后我们需要通过片段着色器(给每一个像素 Pixel 赋予正确的颜色)和测试与混合(处理片段的前后位置以及透明度)给这些像素填充正确的颜色和效果得到位图(bitmap)以便最终显示到屏幕上。

现在我们简单了解了每个流程CPU和GPU各自完成的工作我们现在亲爱来正式理解一下这个渲染流程:

图片

这里一下分成几个步骤:

  1. Hanle Events:这个过程会先处理点击事件,这个过程中有可能会因为需要更改页面的布局和界面层次
  2. Commit Transaction此时App会通过CPU处理显示内容的前置计算,比如布局计算,图片解码等任务,之后把计算好的图层打包给Render Server
  3. Decode打包好的图层会被传输到Render Server中,会先进行解码,解码之后需要等待下一个RunLoop才可以执行Drawe Calls
  4. Render:这一阶段主要由 GPU 进行渲染。
  5. Display:显示阶段,需要等 render 结束的下一个 RunLoop 触发显示。

在我们开发中,我们主要影响的部分是Commit Transaction这两个阶段.这个部分中主要进行的是layout,Display,Prepare,commit这个四个具体的步骤:

  • Layout : 这个阶段只要处理视图的构建

    • 调用重载的layoutSubview方法
    • 创建视图,并且通过addSubview方法添加子视图
    • 计算视图布局,即所有的Layout Constraint
  • Display:绘制视图

    • 从上面得到图元
    • 如果重写了drawRect:方法,那么就会调用重载的drawRect:方法

正常情况下 Display 阶段只会得到图元信息,但是如果重写了 drawRect: 方法,这个方法会直接调用 Core Graphics 绘制方法得到 bitmap 数据,同时系统会额外申请一块内存,用于暂存绘制好的 bitmap。

  • Prepare CoreAnimation

这个步骤进行一个图片的解码和转换

  • Commit 打包并且发送

注意 commit 操作是依赖图层树递归执行的,所以如果图层树过于复杂,commit 的开销就会很大。这也是我们希望减少视图层级,从而降低图层树复杂度的原因。

离屏渲染

在前面简单了解渲染的流程之后,我们现在重新来认识一下什么叫做离屏渲染

通常的渲染流程是:CPU和GPU负责内容渲染流程,接着将得到的位图放入帧缓存区(frame buffer)中,而显示屏幕不断地从 Framebuffer 中获取内容,显示实时的内容

图片

离屏渲染则是直接写入frame buffer,而是先暂时存在另外的内存区域,之后再写入frame buffer, 那么这个过程就被称为离屏渲染.

img

CPU"离屏渲染"

对于类似这种“新开一块CGContext来画图“的操作,有很多文章和视频也称之为“离屏渲染”(因为像素数据是暂时存入了CGContext,而不是直接到了frame buffer)。进一步来说,其实所有CPU进行的光栅化操作(如文字渲染、图片解码),都无法直接绘制到由GPU掌管的frame buffer,只能暂时先放在另一块内存之中,说起来都属于“离屏渲染”。

其实通过CPU渲染就是俗称的“软件渲染”,而真正的离屏渲染发生在GPU

GPU离屏渲染

主要的渲染操作集中在我们的Render Server部分这里主要遵循"画家算法",按照次序一层一层输出到frame buffer中,后一层复盖前一层,就可以得到最终的一个显示结果.值得一提的是,与一般桌面架构不同,在iOS中,设备主存和GPU的显存共享物理内存,这样可以省去一些数据传输开销)

img

但是这里不可以在某一层渲染完成之后,在回头过来擦除/改变其中的某个部分----因为在这一层之前的若按layer像素数据,已经在渲染中被永久副高鲈,这就一位中对于每一次layer,最好找到一种可以通过单词遍历就可以完成渲染的算法,要么就不得不另外开一块内存,借助这个临时中转区域完成一些更加的,多次的修改/剪裁操作

这里给出一个一个大佬的解释:

如果要绘制一个带有圆角并剪切圆角以外内容的容器,就会触发离屏渲染。我的猜想是(如果读者中有图形学专家希望能指正):

  • 将一个layer的内容裁剪成圆角,可能不存在一次遍历就能完成的方法
  • 容器的子layer因为父容器有圆角,那么也会需要被裁剪,而这时它们还在渲染队列中排队,尚未被组合到一块画布上,自然也无法统一裁剪

这幅图展示了cornerRadius以及maskToBounds进行圆角加裁剪的时候,进行的一个处理,也就是说所有的sublayer在第一次会绘制之后不可以立刻被丢弃,还需要包存在Offscreen buffer中等待下一轮圆角加加裁剪.也就诱发了离屏渲染

在这里插入图片描述

此时我们就不得不开辟一块独立于frame buffer的空白内存,先把容器以及其所有子layer依次画好,然后把四个角“剪”成圆形,再把结果画到frame buffer中。这就是GPU的离屏渲染。

上面仅仅讲述了对于 圆角 + 裁剪, 如果设置了透明度+组透明(layer.allowsGroupOpacity + layer.opacity),阴影属性(shadowOffset)都会产生类似的效果,因为透明读,阴影都是和裁剪类似的,会作用于layer和所有对应的sublayer上,这就必然导致了离屏渲染:

离屏渲染的本质原因就是裁剪的叠加导致了对于所有的layer和所有的sublayer进行了一个二次处理

下面产生离屏渲染的原因:

  • 使用了mask 的layer (layer.mask)
  • 需要进行裁剪的layer (layer.masksToBounds/ view.clipsToBounds)
  • 设置了组透明读为YES,且layer.allowsGroupOpacity
  • 添加投影的layer
  • 采用了光栅化的layer(layer.shouldRasterize)
  • 绘制了文字的layer(UILabel, CATextLayer)

不过,需要注意的是,重写 drawRect: 方法并不会触发离屏渲染。前文中我们提到过,重写 drawRect: 会将 GPU 中的渲染操作转移到 CPU 中完成,并且需要额外开辟内存空间。这和标准意义上的离屏渲染并不一样

善用离屏渲染

尽管离屏渲染开销很大,但是当我们无法避免它的时候,可以想办法把性能影响降到最低。优化思路也很简单:既然已经花了不少精力把图片裁出了圆角,如果我能把结果缓存下来,那么下一帧渲染就可以复用这个成果,不需要再重新画一遍了。

CAlayer给这个方案提供了对应的解法shouldRasterize.一定被设置为true,Render就会把layer渲染结果保存在一块内存中,这样在下溢帧也可以继续使用,而不会再次触发离屏渲染,有几个需要注意的点:

  • shouldRasterize的主旨在于降低性能损失,但总是至少会触发一次离屏渲染。如果你的layer本来并不复杂,也没有圆角阴影等等,打开这个开关反而会增加一次不必要的离屏渲染
  • 离屏渲染缓存有空间上限,最多不超过屏幕总像素的2.5倍大小
  • 一旦缓存超过100ms没有被使用,会自动被丢弃
  • layer的内容(包括子layer)必须是静态的,因为一旦发生变化(如resize,动画),之前辛苦处理得到的缓存就失效了。如果这件事频繁发生,我们就又回到了“每一帧都需要离屏渲染”的情景,而这正是开发者需要极力避免的。
  • 其实除了解决多次离屏渲染的开销,shouldRasterize在另一个场景中也可以使用:如果layer的子结构非常复杂,渲染一次所需时间较长,同样可以打开这个开关,把layer绘制到一块缓存,然后在接下来复用这个结果,这样就不需要每次都重新绘制整个layer树了
如何避免圆角的离屏渲染

只要避免使用 masksToBounds 进行二次处理,而是对所有的 sublayer 进行预处理,就可以只进行“画家算法”,用一次叠加就完成绘制。

  1. 用带圆角的图片或者替换背景色为带圆角的纯色背景图。
  2. 用贝塞尔曲线绘制闭合带圆角的矩形,在上下文中设置只有内部可见,再将不带圆角的 layer 渲染成图片,添加到贝塞尔矩形中
  3. 重写 drawRect:,用 CoreGraphics 相关方法,在需要应用圆角时进行手动绘制 **(整个过程全部是由CPU完成的。这样一来既然我们已经得到了想要的效果,就不需要再另外给图片容器设置cornerRadius。)**这里注意下面几个点:
    • 渲染不是CPU的强项,调用CoreGraphics会消耗其相当一部分计算时间,并且我们也不愿意因此阻塞用户操作,因此一般来说CPU渲染都在后台线程完成(这也是AsyncDisplayKit的主要思想),然后再回到主线程上,把渲染结果传回CoreAnimation。
    • 因为CPU渲染速度不够块,因此只适合渲染晋太的元素,如文字图片
    • 作为渲染结果的bitmap数据量比较大,消耗内存比较多,应该在使用玩之后及时释放
    • 如果采用CPU来做渲染了,就不要在触发GPU的离屏渲染了,否则会同时存在两块内容相同的内存

UIView 和 CALayer的关系

UIView
  • UIView属于UIKit
  • 负责绘制图形和动画操作
  • 用于界面的布局和子视图的管理
  • 处理用户的点击事件
CALayer
  • CALayer属于CoreAnimation
  • 只负责显示,而且显示位图
  • CALaye既可以用于UIKit,也用于APPKit
两者的具体关系与区别
  • UIView属于UIKit,处理事件,管理子视图
  • CALayer基于CoreAnimation,这个类只负责显示,不处理触摸事件
  • 父类来说,CALaer继承NSObject,UIView继承与UIResponder
  • 从底层来说,UIView只属于UIKit的组建,而UIkit的组件到最后都没被分解成layer,存储到图层树中
  • 应用层面来说,.需要和用户交互的时候,使用UIView,否则采用两者都可以,采用CALayer性能更好

网站公告

今日签到

点亮在社区的每一天
去签到