首先我们要了解一些基础知识:
计算机图形渲染原理
移动终端屏幕成像与卡顿
在硬件基础之上,iOS 中有 Core Graphics、Core Animation、Core Image、OpenGL 等多种软件框架来绘制内容,在 CPU 与 GPU 之间进行了更高层地封装。
①-应用交互前端UIKit/AppKit → ②-Core Animation → ③ OpenGL ES/ Metal → ④ GPU Driver →⑤ GPU → ⑥ Screen Display
简单理解,CALayer
就是屏幕显示的基础。在 CALayer.h 中,CALayer 有这样一个属性 contents
:
contents
提供了 layer
的内容,是一个指针类型,在 iOS 中的类型就是 CGImageRef
(在 OS X 中还可以是 NSImage)。CGImageRef
的定义是:A bitmap image or image mask
。
实际上,CALayer
中的 contents
属性保存了由设备渲染流水线渲染好的位图 bitmap(通常也被称为 backing store),而当设备屏幕进行刷新时,会从 CALayer
中读取生成好的 bitmap,进而呈现到屏幕上。
// typedef struct CF_BRIDGED_TYPE(id) CGImage *CGImageRef;
myView.layer.contents = (__bridge id)[UIImage imageNamed:@"1.jpg"].CGImage;
在运行时,操作系统会调用底层的接口,将 image 通过 CPU+GPU 的渲染流水线渲染得到对应的 bitmap,存储于 CALayer.contents
中,在设备屏幕进行刷新的时候就会读取 bitmap 在屏幕上呈现。
也正因为每次要被渲染的内容是被静态的存储起来的,所以每次渲染时,Core Animation 会触发调用 drawRect:
方法,使用存储好的 bitmap 进行新一轮的展示。
UIView
是 app 中的基本组成结构,定义了一些统一的规范。它会负责内容的渲染以及,处理交互事件。具体而言,它负责的事情可以归为下面三类:
CALayer
的主要职责是管理内部的可视内容。当我们创建一个 UIView
的时候,UIView
会自动创建一个 CALayer
,为自身提供存储 bitmap 的地方(也就是前文说的 backing store),并将自身固定设置为 CALayer
的代理。
CALayer
是 UIView
的属性之一,负责渲染和动画,提供可视内容的呈现。UIView
提供了对 CALayer
部分功能的封装,同时也另外负责了交互事件的处理
CALayer
,即 backing layerCALayer
事实上是用户所能在屏幕上看见的一切的基础UIView
的层级结构非常熟悉,由于每个 UIView
都一一对应了CALayer
负责页面的绘制,所以视图层级拥有视图树
的树形结构,对应 CALayer
层级也拥有图层树
的树形结构。UIView
只对 CALayer
的部分功能进行了封装,而另一部分如圆角、阴影、边框等特效都需要通过调用 layer 属性来设置。CALayer
不负责点击事件,所以不响应点击事件,而 UIView
会响应。CALayer
继承自 NSObject
,UIView
由于要负责交互事件,所以继承自 UIResponder
。因而可以子线程绘制layer
来显示。为什么要将 CALayer 独立出来,直接使用 UIView 统一管理不行吗?为什么不用一个统一的对象来处理所有事情呢?
UIKit
和 UIView
,OS X 则是AppKit
和 NSView
。视图树
和图层树
,还有呈现树
和渲染树
。为什么 CALayer
可以呈现可视化内容呢?因为 CALayer
基本等同于一个 纹理
。纹理是 GPU 进行图像渲染的重要依据。
在 计算机图形渲染原理 中提到纹理本质上就是一张图片,因此 CALayer
也包含一个 contents
属性指向一块缓存区,称为 backing store,可以存放位图(Bitmap)。iOS 中将该缓存区保存的图片称为 寄宿图
。
图形渲染流水线支持从顶点开始进行绘制(在流水线中,顶点会被处理生成纹理),也支持直接使用纹理(图片)进行渲染。相应地,在实际开发中,绘制界面也有两种方式:一种是 手动绘制;另一种是 使用图片。
对此,iOS 中也有两种相应的实现方式:
UIView
并实现 -drawRect:
方法来自定义绘制。-drawRect:
是一个 UIView 方法,但事实上都是底层的 CALayer 完成了重绘工作并保存了产生的图片。-drawRect:
绘制定义寄宿图的基本原理- (void)displayLayer:(CALayer *)layer;
-displayLayer:
方法,CALayer
则会尝试调用 -drawLayer:inContext:
方法。在调用该方法前,CALayer
会创建一个空的寄宿图(尺寸由 bounds
和 contentScale
决定)和一个 Core Graphics 的绘制上下文,为绘制寄宿图做准备,作为 ctx
参数传入- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx;
当我们了解了 Core Animation 以及 CALayer 的基本知识后,我们知道了 CALayer 的本质,那么它是如何调用 GPU 并显示可视化内容的呢?下面介绍一下 Core Animation 渲染流水线的工作原理。
如果对上述步骤进行串联
,它们执行所消耗的时间远远超过 16.67 ms,因此为了满足对屏幕的 60 FPS 刷新率的支持,需要将这些步骤进行分解,通过流水线的方式进行并行执行:
一般开发当中能影响到的就是 Handle Events 和 Commit Transaction 这两个阶段,这也是开发者接触最多的部分。Handle Events 就是处理触摸事件;
在 Core Animation 流水线中,app 调用 Render Server 前的最后一步 Commit Transaction 其实可以细分为 4 个步骤:
Layout 阶段主要进行视图构建和布局,具体步骤包括:
layoutSubviews
方法addSubview
方法添加子视图由于这个阶段是在 CPU 中进行,通常是 CPU 限制或者 IO 限制,所以我们应该尽量高效轻量地操作,减少这部分的时间。比如减少非必要的视图创建、简化布局计算、减少视图层级等。
drawRect:
方法,那么会调用重载的 drawRect:
方法,在 drawRect:
方法中手动绘制得到 bitmap 数据,从而自定义视图的绘制drawRect:
方法,这个方法会直接调用 Core Graphics 绘制方法得到 bitmap 数据,同时系统会额外申请一块内存,用于暂存绘制好的 bitmap;drawRect:
方法,导致绘制过程从 GPU 转移到了 CPU,这就导致了一定的效率损失;Prepare 阶段属于附加步骤,一般处理图像的解码和转换等操作。
Render Server 通常是 OpenGL 或者是 Metal。以 OpenGL 为例,那么上图主要是 GPU 中执行的操作,具体主要包括:
iOS 动画的渲染也是基于上述 Core Animation 流水线完成的。这里我们重点关注 app 与 Render Server 的执行流程。
日常开发中,如果不是特别复杂的动画,一般使用 UIView Animation 实现,iOS 将其处理过程分为如下三部阶段:
animationWithDuration:animations:
方法简化来看,通常的渲染流程是这样的:
App 通过 CPU 和 GPU 的合作,不停地将内容渲染完成放入 Framebuffer 帧缓冲器中,而显示屏幕不断地从 Framebuffer 中获取内容,显示实时的内容。
离屏渲染的流程是这样的:
与普通情况下 GPU 直接将渲染好的内容放入 Framebuffer 中不同,需要先额外创建离屏渲染缓冲区 Offscreen Buffer,将提前渲染好的内容放入其中,等到合适的时机再将 Offscreen Buffer 中的内容进一步叠加、渲染,完成后将结果切换到 Framebuffer 中。
那么为什么要使用离屏渲染呢?主要是因为下面这两种原因:
对于第一种情况,也就是不得不使用离屏渲染的情况,一般都是系统自动触发的,比如阴影、圆角等等。
最常见的情形之一就是:使用了 mask 蒙版:
由于最终的内容是由两层渲染结果叠加,所以必须要利用额外的内存空间对中间的渲染结果进行保存,因此系统会默认触发离屏渲染。
又比如下面这个例子,iOS 8 开始提供的模糊特效 UIBlurEffectView
:
整个模糊过程分为多步:
第二种情况,为了复用提高效率而使用离屏渲染一般是主动的行为,是通过 CALayer 的 shouldRasterize
光栅化操作实现的。
通常来讲,设置了 layer
的圆角效果之后,会自动触发离屏渲染。但是究竟什么情况下设置圆角才会触发离屏渲染呢?
如上图所示,layer 由三层组成,我们设置圆角通常会首先像下面这行代码一样进行设置:
view.layer.cornerRadius = 20
上述代码只会对background color
和 border of the layer
起作用, 但是对layer.content
并不起作用,就是说不会设置 content
的圆角,除非同时设置了 layer.masksToBounds
为 true
(对应 UIView 的 clipsToBounds
属性)。
如果只是设置了 cornerRadius
而没有设置 masksToBounds
,由于不需要叠加裁剪,此时是并不会触发离屏渲染的。而当设置了裁剪属性的时候,由于 masksToBounds
会对 layer
以及所有 subLayer
的 content
都进行裁剪,所以不得不触发离屏渲染
view.layer.masksToBounds = true // 触发离屏渲染的原因
所以,在没有必要使用圆角裁剪的时候,我们应该尽量不去触发离屏渲染而影响效率。
刚才说了圆角加上 masksToBounds
的时候,因为 masksToBounds
会对 layer
上的所有内容进行裁剪,从而诱发了离屏渲染,那么这个过程具体是怎么回事呢,下面我们来仔细讲一下。
图层的叠加绘制大概遵循“画家算法”,在这种算法下会按层绘制,首先绘制距离较远的场景,然后用距离较近的场景覆盖较远的部分。
在普通的 layer
绘制中,上层的 sublayer
会覆盖下层的 sublayer
,下层 sublayer
绘制完之后就可以抛弃了,从而节约空间提高效率。
所有 sublayer
依次绘制完毕之后,整个绘制过程完成,就可以进行后续的呈现了。假设我们需要绘制一个三层的 sublayer
,不设置裁剪和圆角,那么整个绘制过程就如下图所示:
而当我们设置了 cornerRadius 以及 masksToBounds 进行圆角 + 裁剪时,如前文所述,masksToBounds 裁剪属性会应用到所有的 sublayer 上。这也就意味着所有的 sublayer 必须要重新被应用一次圆角+裁剪,这也就意味着所有的 sublayer 在第一次被绘制完之后,并不能立刻被丢弃,而必须要被保存在 Offscreen buffer 中等待下一轮圆角+裁剪,这也就诱发了离屏渲染,具体过程如下:
实际上不只是圆角+裁剪,如果设置了透明度+组透明(layer.allowsGroupOpacity
+layer.opacity
),阴影属性(shadowOffset
等)都会产生类似的效果,因为组透明度、阴影都是和裁剪类似的,会作用与 layer
以及其所有 sublayer
上,这就导致必然会引起离屏渲染。
masksToBounds
对 layer
以及所有 sublayer
进行二次处理。那么我们只要避免使用 masksToBounds
进行二次处理,而是对所有的 sublayer
进行预处理,就可以只进行“画家算法”,用一次叠加就完成绘制layer
渲染成图片,添加到贝塞尔矩形中。这种方法效率更高,但是 layer
的布局一旦改变,贝塞尔曲线都需要手动地重新绘制,所以需要对 frame
、color
等进行手动地监听并重绘。drawRect:
,用 CoreGraphics 相关方法,在需要应用圆角时进行手动绘制。不过 CoreGraphics 效率也很有限,如果需要多次调用也会有效率问题。总结一下,下面几种情况会触发离屏渲染:
layer
(layer.mask
)layer
(layer.masksToBounds
/ view.clipsToBounds
)layer.allowsGroupOpacity
/layer.opacity
)layer.shadow
)layer.shouldRasterize
)UILabel
,CATextLayer
,Core Text
等)不过,需要注意的是,重写 drawRect:
方法并不会触发离屏渲染。前文中我们提到过,重写 drawRect:
会将 GPU 中的渲染操作转移到 CPU 中完成,并且需要额外开辟内存空间。但根据苹果工程师的说法,这和标准意义上的离屏渲染并不一样,在 Instrument 中开启 Color offscreen rendered yellow 调试时也会发现这并不会被判断为离屏渲染。