[关闭]
@qidiandasheng 2022-08-09T23:31:22.000000Z 字数 9383 阅读 1714

UIKit的绘制原理(😁)

图像处理


UIView是如何显示到Screen上

iOS的屏幕刷新频率是每秒60帧,也就是说每每16.7ms会绘制一次屏幕,这个时间段内要完成view的缓冲区创建和view内容的绘制(如果重写了drawRect),这些CPU的工作。然后将这个缓冲区交给GPU渲染,这个过程又包括多个view的拼接(compositing),纹理的渲染(Texture)等,最终显示在屏幕上。因此,如果在16.7ms内完不成这些操作,比如,CPU做了太多的工作,或者view层次过于多,图片过于大,导致GPU压力太大,就会导致“卡”的现象,也就是丢帧。

苹果官方给出的最佳帧率是:60fps,也就是1帧不丢,当然这是理想中的绝佳的体验。

这个60fps改怎么理解呢?一般来说如果帧率达到25+fps,人眼就基本感觉不到停顿了,因此,如果你能让你ios程序稳定的保持在30fps已经很不错了,注意,是“稳定”在30fps,而不是,10fps,40fps,20fps这样的跳动,如果帧频不稳就会有卡的感觉。60fps真的很难达到,尤其在iphone4,4s上。

iOS 渲染框架

UIKit

UIKit 是 iOS 开发者最常用的框架,可以通过设置 UIKit 组件的布局以及相关属性来绘制界面。

事实上, UIKit 自身并不具备在屏幕成像的能力,其主要负责对用户操作事件的响应(UIView 继承自 UIResponder),事件响应的传递大体是经过逐层的 视图树 遍历实现的。

Core Animation

Core Animation 源自于 Layer Kit,动画只是 Core Animation 特性的冰山一角。

Core Animation 是一个复合引擎,其职责是 尽可能快地组合屏幕上不同的可视内容,这些可视内容可被分解成独立的图层(即 CALayer),这些图层会被存储在一个叫做图层树的体系之中。从本质上而言,CALayer 是用户所能在屏幕上看见的一切的基础。

Core Graphics

Core Graphics 基于 Quartz 高级绘图引擎,主要用于运行时绘制图像。开发者可以使用此框架来处理基于路径的绘图,转换,颜色管理,离屏渲染,图案,渐变和阴影,图像数据管理,图像创建和图像遮罩以及 PDF 文档创建,显示和分析。

当开发者需要在 运行时创建图像 时,可以使用 Core Graphics 去绘制。与之相对的是 运行前创建图像,例如用 Photoshop 提前做好图片素材直接导入应用。相比之下,我们更需要 Core Graphics 去在运行时实时计算、绘制一系列图像帧来实现动画。

Core Image

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

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

OpenGL ES

OpenGL ES(OpenGL for Embedded Systems,简称 GLES),是 OpenGL 的子集。 OpenGL 是一套第三方标准,函数的内部实现由对应的 GPU 厂商开发实现。是最终用来处理渲染显示图像的地方。

Metal

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

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

UIView 与 CALayer 的关系

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

由于这种一一对应的关系,视图层级拥有视图树的树形结构,对应 CALayer 层级也拥有图层树的树形结构。

ios-viewtree-layertree.png-56.7kB

其中,视图的职责是创建并管理图层,以确保当子视图在层级关系中添加或被移除时,其关联的图层在图层树中也有相同的操作,即保证视图树和图层树在结构上的一致性。

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

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

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

CALayer

什么是CALayer

那么为什么 CALayer 可以呈现可视化内容呢?因为CALayer保存了OpenGL渲染需要的顶点数据纹理数据。其中顶点数据是必须要有的,而纹理数据不一定有。

顶点数据:通过我们给CALayer设置各种属性比如framebackgroundColor来获取。即OpenGL获取到位置和大小信息,当然还包括一个颜色来进行一个图形的渲染。

纹理数据CALayer有个属性contents,被称为寄宿图,指向的是一块缓存区,称为 backing store,可以用来存放位图(Bitmap)。这个就是OpenGL渲染需要的纹理数据了。

当然我们也可以不需要这个纹理数据,就比如我们造一个房子通过顶点数据我们可以造好房子的骨架,并且确认好一种颜色。但是如果我们需要给房子的墙上绘制各种图案贴上墙纸使房子更好看呢。我们就需要纹理了,OpenGL会将图像初始化为一个纹理缓存后,每个像素会变成一个纹素,纹理也有一个坐标,OpenGL会根据坐标来做转换给最后贴到我们的骨架上面。

以下图片表示是否有纹理数据时的OpenGL的渲染过程:

未命名文件 (2).png-159.1kB

寄宿图(contents)的生成

我们知道CALayer包含一个contents属性指向一块缓存区,称为backing store,可以存放位图(Bitmap)。iOS 中将该缓存区保存的图片称为寄宿图。

ios-layer-contents.png-27.4kB

设置寄宿图有两种方式:使用图片(contents image)和手动绘制(custom drawing)。

contents(寄宿图)除了给它赋值CGImage之外,我们也可以直接对它进行绘制,绘制的方法正是这次问题的关键,通过继承UIView并实现-drawRect:方法即可自定义绘制。-drawRect:方法没有默认的实现,因为对UIView来说,寄宿图并不是必须的,UIView不关心绘制的内容。如果UIView检测到-drawRect:方法被调用了,它就会为视图分配一个寄宿图,这个寄宿图的像素尺寸等于视图大小乘以contentsScale的值。

注: 获取图形上下文中drawRect:inContext:这两种方式系统都会为视图分配一个寄宿图。

测试:你可以用DSCoreGraphics的例子测试一下,使用drawRect:获取上下文的例子中把UIViewframe.size设的很大,比如2000x2000,你能看到内存急剧的上升,我看到的是159M左右。说明这里就产生了一个很大的寄宿图。

而我们使用inContext:获取上下文的例子中把layerframe.size设的很大,也是2000x2000,但是我们发现内存并没有急剧的上升。

文章内存恶鬼drawRect中说使用drawRect:会产生寄宿图,所以会产生大量内存。而CAShapeLayer不需要创建寄宿图,所以不会产生大量内存。
其实我觉得是错误的,既然CAShapeLayer是继承自CALayer,那为什么CALayer会产生寄宿图而CAShapeLayer不会呢。
你可要用我的DSCoreGraphicsCGOneViewController.h(drawRect:)的例子测试一下,只有给View设置了背景颜色的时候(view.backgroundColor = [UIColor blackColor];)才会有内存激增,如果不设置颜色,其实内存是跟使用CALayer差不多的。

所以我的结论就是drawRect:inContext:这两种方式都会产生寄宿图。只是drawRect:的时候产生的寄宿图其实是包含的view的颜色的,当view有颜色的时候这张寄宿图也会有相应的颜色所以就会特别大。
CALayer使用inContext:这种方式时产生的寄宿图是不包含CALayer的颜色的,所以产生的寄宿图就不会很大。

具体介绍可以参考寄宿图这篇文章。


Contents Image

Contents Image 是指通过 CALayer 的 contents属性来配置图片。然而contents 属性的类型为 id。在这种情况下,可以给 contents 属性赋予任何值,app 仍可以编译通过。但是在实践中,如果 content 的值不是 CGImage ,得到的图层将是空白的。

既然如此,为什么要将 contents 的属性类型定义为 id 而非 CGImage。这是因为在 Mac OS 系统中,该属性对 CGImageNSImage 类型的值都起作用,而在 iOS 系统中,该属性只对 CGImage 起作用。

本质上,contents 属性指向的一块缓存区域,称为 backing store,可以存放 bitmap 数据。

Custom Drawing

Custom Drawing 是指使用 Core Graphics 直接绘制寄宿图。实际开发中,一般通过继承 UIView 并实现 -drawRect: 方法来自定义绘制。

QQ20131123-1.png-27.4kB

虽然 -drawRect: 是一个 UIView 方法,但事实上都是底层的 CALayer 完成了重绘工作并保存了产生的图片。下图所示为 -drawRect: 绘制定义寄宿图的基本原理。

ios-layer-bitmap-custom-drawing.png-45.6kB

API调用流程

3816414-91bb25cb0ae94d8f.png-307.5kB
系统绘制流程:
3816414-ba793ef1c1f01a6d.png-300kB

  1. - (void)displayLayer:(CALayer *)layer;
  1. - (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx;

异步绘制原理(YYAsyncLayer)

YYAsyncLayer异步绘制就是重写了CALayerdisplay方法,在里面进行一个异步的绘制,然后回调主线程把绘制好的CGImage赋值给contents

  1. dispatch_async(YYAsyncLayerGetDisplayQueue(), ^{
  2. UIGraphicsBeginImageContextWithOptions(size, opaque, scale);
  3. CGContextRef context = UIGraphicsGetCurrentContext();
  4. task.display(context, size, isCancelled);
  5. UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
  6. UIGraphicsEndImageContext();
  7. dispatch_async(dispatch_get_main_queue(), ^{
  8. self.contents = (__bridge id)(image.CGImage);
  9. });
  10. }];

寄宿图(contents)的大小

首先我们要确定先生成存寄宿图,我们创建一个类继承自UIView

  1. @interface DSDrawView : UIView
  2. @end
  3. @implementation DSDrawView
  4. //这是是用drawRect绘图,里面不写任何绘制代码系统也会默认生存一张空的寄宿图
  5. -(void)drawRect:(CGRect)rect{
  6. }
  1. DSDrawView * dv = [[DSDrawView alloc] initWithFrame:CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height)];
  2. dv.center = self.view.center;
  3. dv.backgroundColor = [UIColor redColor];
  4. [self.view addSubview:dv];
  5. dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
  6. // <CABackingStore 0x7f928670be20 (buffer [750 1334] BGRX8888)>
  7. NSLog(@"%@",dv.layer.contents);
  8. });

这里我延迟1s等contents生成后输出contents,以下是我的输出,能看到buffer[750 1334],也就是我们屏幕的宽高contentScale,这里我设备的contentScale=2。也就是一张750x1334像素的位图,占用内存大小为750x1334*4字节(一个像素占用4个字节)。

  1. <CABackingStore 0x7f928670be20 (buffer [750 1334] BGRX8888)>

当然你可以把DSDrawView-(void)drawRect:(CGRect)rect方法注释,我们能看到contents输出为null,也就是不会生成寄宿图,内存也就没有增加。

UILabel会生成寄宿图吗?

UILabel继承自UIView,按道理应该跟UIView是一样的,但其实有一点是不一样的,我们创建一个UILabel,然后只给UILabel设置一个背景色,发现内存增加了,是contents依旧输出为null。这一步跟上面UIView的区别就是内存增加了。

然后我们创建一个DSDrawLabel,加入- (void)drawRect:(CGRect)rect方法,发现内存增加的不变,contents依旧输出为null。而上面的UIView则生成了一个寄宿图,两者结果并不相同。

  1. @interface DSDrawLabel : UILabel
  2. @end
  3. @implementation DSDrawLabel
  4. - (void)drawRect:(CGRect)rect{
  5. }
  6. @end
  1. DSDrawLabel *label = [[DSDrawLabel alloc] initWithFrame:CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height)];
  2. label.center = self.view.center;
  3. label.backgroundColor = [UIColor redColor];
  4. [self.view addSubview:label];
  5. dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
  6. // (null)
  7. NSLog(@"%@",label.layer.contents);
  8. });
  1. UILabel应该在设置背景的时候就默认生成了一张bitmaps,这张位图的大小为宽X高X4字节,注意这里跟UIView不一样的是UIView要乘以一个contentScale
  2. 同时给UILabel设置文字的话,内存就会变的很大,这里就不知道怎么算了。
  3. label.layer.contents为空,这里并没有给contents赋值contents并没有指向以上生成的那个位图。

渲染流程

下载.png-160kB

ios-core-animation-pipeline-steps.png-364.7kB

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

CATransaction原理

CATransaction的作用就是捕获layer的变化,然后提交,就像下面这样:

  1. [CATransaction begin];
  2. _testLayer.backgroundColor = [UIColor blueColor].CGColor;
  3. [CATransaction commit];

当然系统默认会有一个隐式的事务创建,不用我们像上面这样手动创建,隐式的事务(Implicit transactions)会在图层树的修改的时候自动创建,并且在下一次runloop迭代的时候提交。而主线程有一个自动开启的runloop,所以即使不写CATransaction代码也会起作用。

那这个事务是怎么保存这个变化并提交的呢,下面是内部处理的一个伪代码:

  1. //生成一个新的事务并返回
  2. [CATransaction newTransaction];
  3. { //这一段是layer修改背景色内部的逻辑
  4. setBackgroundColor{
  5. //获取当前的CATransaction,并把修改提供给它
  6. CATransaction *currentTrans = [CATransaction getCurrentTransaction];
  7. [currentTrans addLayerChange:self forKey:@"backgroundColor"];
  8. }
  9. }
  10. //提交layer变化并移除当前的事务
  11. [CATransaction commitLayerChanges];
  12. [CATransaction removeCurrentTransaction];

当在操作 UI 时,比如改变了 Frame、更新了 UIView/CALayer 的层次时,或者手动调用了 UIView/CALayersetNeedsLayout/setNeedsDisplay方法后,这个 UIView/CALayer 就被标记为待处理,并被提交到一个全局的容器去,这个容器就保存了这些CATransaction事务。

苹果注册了一个 Observer 监听 BeforeWaiting(即将进入休眠) 和 Exit (即将退出Loop) 事件,回调去执行一个很长的函数:
_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv()。这个函数里会遍历所有待处理的 UIView/CAlayer 以执行实际的绘制和调整,并更新 UI 界面。

我们看到下面的代码中就是获取到这个全局的Transaction容器,然后执行Transaction commit,判断layout_if_needed()display_if_needed(),然后执行视图的创建、布局计算、图片解码、文本绘制等。

  1. _ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv()
  2. QuartzCore:CA::Transaction::observer_callback:
  3. CA::Transaction::commit();
  4. CA::Context::commit_transaction();
  5. CA::Layer::layout_and_display_if_needed();
  6. CA::Layer::layout_if_needed();
  7. [CALayer layoutSublayers];
  8. [UIView layoutSubviews];
  9. CA::Layer::display_if_needed();
  10. [CALayer display];
  11. [UIView drawRect];

动画渲染原理

iOS 动画的渲染也是基于上述 Core Animation 流水线完成的。这里我们重点关注 app 与 Render Server 的执行流程。

日常开发中,如果不是特别复杂的动画,一般使用 UIView Animation 实现,iOS 将其处理过程分为如下三部阶段:

ios-animation-three-stage-process.png-234kB

参考

iOS 图像渲染原理
iOS 异步绘制
理解UIView的绘制原理

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注