iOS底层原理之 UIView绘制显示原理流程解析以及性能优化

2,551 阅读25分钟

背景说明

UIView是iOS开发最基本的UI控件之一, 所有的显示控件几乎都是继承于UIView, 通过不同类型的UIView, 我们可以将文本, 图片等显示到屏幕上, 所以了解UIView的绘制原理, 对于后续的自定义绘制以及性能优化有很大的帮助。

图像绘制显示流程简图

以显示Hello world为例, 整个绘制和显示流程大概如上图所示, 其中CPU层面, 主要负责 1 Layout: UI布局 文本计算 2 Display: 绘制 3 Prepare: 图片编解码 4 Commit: 提交位图, GPU层面, 主要是渲染管线, 包括顶点着色 图元装配 光栅化 片段着色 片段处理 FrameBuffer。

接下来, 我们就详细解析整个流程。

UIView和CALayer的关系

1 UIView继承UIResponder, 可以响应事件, 其内部持有一个CALayer成员layer, 同时签订了CALayerDelegate协议。

2 CALayer继承NSObject, 负责绘制UIView显示的内容, 而实际的绘图工作都是Layer向其backing store里绘制bitmap完成的。

3 操作View的绝大多数图形属性,其实都是直接操作的其拥有的Layer属性, 比如frame, bounds, backgroundColor等等。

4 实际上UIView的显示内容是由CALayer的contents决定的, 对应的是backing store, 实际上一个bitmap类型的位图。

5 UIView为其提供内容, 以及负责处理触摸等事件, 参与响应链, CALayer负责显示内容contents, 体现了单一职责设计原则。

CALayer是什么?

官方文档的定义是 管理基于图像的内容并允许您对该内容执行动画的对象。通常用于为 view 提供后备存储,但也可以在没有 View 的情况下使用以显示内容。

Layer的主要工作是管理您提供的可视内容,但Layer本身可以设置可视属性(例如背景颜色、边框和阴影)。

除了管理可视内容外,该Layer还维护有关内容几何的信息(例如位置、大小和变换),用于在屏幕上显示该内容。

为什么不直接用一个UIView或CALayer处理所有事件?

主要有两点考虑:

1 职责不同

UIVIew 的主要职责是负责接收并响应事件;而 CALayer 的主要职责是负责显示 UI, 体现了单一职责设计原则。

2 需要复用

在macOS和App系统上,NSView 和 UIView 虽然行为相似,在实现上却有着显著的区别,却又都依赖于 CALayer 。在这种情况下,只能封装一个 CALayer 出来。

CALayer的显示基础

CALayer中有一个很重要的属性, 叫contents, 里面就提供显示的内容, 定义如下

    /* An object providing the contents of the layer, typically a CGImageRef
     * or an IOSurfaceRef, but may be something else. (For example, NSImage
     * objects are supported on Mac OS X 10.6 and later.) Default value is nil.
     * Animatable. */
    
    /** Layer content properties and methods. **/
    open var contents: Any?

翻译成中文, 大概意思就是: contents是图层内容属性和方法, 是提供层内容的对象, 通常是 CGImageRef 或 IOSurfaceRef,但也可能是其他东西。 (例如,Mac OS X 10.6 及更高版本支持 NSImage 对象。)默认值为 nil。

实际上, contents 属性保存了由设备渲染流水线渲染好的位图 bitmap(通常也被称为 backing store), 而当设备屏幕进行刷新时,会从CALayer中读取生成好的 bitmap, 进而呈现到屏幕上。

也正因为每次要被渲染的内容是被静态的存储起来的,所以每次渲染时,Core Animation 会触发调用 drawRect: 方法,使用存储好的 bitmap 进行新一轮的展示。

注: 如果是View 的图层,应避免直接设置此属性的内容。视图和图层之间的相互作用通常会导致视图在后续更新期间替换此属性的内容。

CALayer的图层树 Layer-tree

UIView和CALayer都有自己的树状结构,它们都可以有自己的SubView和SubLayer

iOS中有三种Layer tree

1 layer tree(model tree) (模型树)

就是各个树的节点的model信息, 比如常见的frame, affineTransform, backgroundColor等等, 这些model数据都是我们在APP开发中可以配置设置的, 我们任何对于view/layer的修改都能反应在model-tree中。

2 presentation tree (演示树)

这是一个中间层, 我们App无法主动操作, 这个层内容是iOS系统在Render Server中生成的! CAAnimation 的中间态就都在这一层上更改属性来完成动画的分动作。

3 render tree (渲染树)

这是直接对应于提交到render server上进行显示的树。

三种Layer tree显示如下, 最后需要提交给render server的内容都是在model-tree中, 包括Animation的相关参数。

UIView绘制原理

通过上面的介绍, 我们知道了图像的显示都是通过Layer来管理的, 但是我们在显示内容时, 却不是通过直接操作Layer层来实现, 虽然可以通过直接设置Layer的contents属性来实现, 但是比较麻烦, 比如我们要在屏幕上显示Hello world这两个单词, 我们会选择用UILabel来显示, 通过设置对应的text属性就能很快实现, 而UILabel也是继承UIView, 内部也是调用Layer的一些相关方法来实现绘制, 绘制流程图如下

绘制过程图归纳如下

1 当调用[UIView setNeedsDisplay]时,实际上会直接调用底层layer的同名方法 [layer setNeedsDisplay]

2 然后会被Core Animation捕获到layer-tree的变化, 提交一个CATransaction , 然后触发Runloop的Observer回调,在回调中调用[CALayer display]进行当前视图的真正绘制流程

3 [CALayer display]内部会先判断这个layer的delegate是否会响应displayLayer:方法,如果不响应就会进入系统绘制流程中。如果能够响应,实际上是提供了异步绘制的入口,也就是给我们进行异步绘制留有余地, 我们可以在这里实现异步绘制, 具体如何实现, 后面会讲。

系统绘制流程

本质是创建一个 backing storage 的流程

1 当[CALayer display]方法调用时, 判断是否有delegate去实现绘制方法, 如果没有就触发系统绘制。

2 系统绘制时, 会先创建 backing storage(CGContextRef). 注意每个layer都会有一个context, 这个context指向一块缓存区被称为backing storeage。

3 如果layer有delegate, 则调用delegate的- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx方法(默认会将创建的CGContextRef传入),否则调用-[CALayer drawInContext:]方法,进而调用[UIView drawRect:]方法, 此时已经在CGContextRef环境中, 如果在drawRect中通过UIGraphicsGetCurrentContext() 获取到的就是CALayer创建的CGContextRef。

4 注意drawRect方法是在CPU执行的, 在它执行完之后, 通过context将数据(通常情况下这里的最终结果会是一个bitmap, 类型是 CGImageRef)写入backing store, 通过rend server交给GPU去渲染,将backing store中的bitmap数据显示在屏幕上。

注: 每一个UIView的Layer都有一个对应的Backing Store作为其存储Content的实际内容, 而这些内容其实就是一个CGImage数据, 确切的说,是bitmap数据,以供GPU读取展示。

drawRect流程梳理

系统绘制流程中, 会调用到drawRect方法, 而在开发阶段, 与我们打交道最多的也是drawRect方法, 因此这里额外再梳理下其调用流程。

1 当我们调用[UIView setNeedsLayout], 底层会调用[CALayer setNeedsLayout], 然后会给图层增加一个dirty标记, 但还显示原来的内容。它实际上没做任何工作,所以多次调用 -setNeedsDisplay并不会造成性能损。

2 然后会触发[CALayer display]方法。

3 CALayer创建一个CGContextRef, 创建一个 backing store, 然后将CGContextRef推入Graphics context stack(因此 CGContextRef是可以嵌套的), 当我们调用UIKit的UIRectFill()等API, 会自动将绘制结果放在stack栈顶的CGContextRef中, 我们也可以直接调用UIGraphicsGetCurrent拿到当前的Grahics context栈顶的CGContextRef。

4 然后就是drawRect方法执行了, 绘制的内容在CGContextRef的backing storage中。

5 这个back storage会保存在与layer-model-tree关联的属性中, 一起在 commit 时, 提交给 render server。

特殊场景 -- UIImageView

当我们使用UIImageView时, 这个View仍然有一个CALayer, 但是它会直接使用CGImageRef(UIImage), 我们传给UIImageView的UIImage中的图片可能是没有解码的, 在 CA Commit之前会有一个prepare过程, 因此, 这样会在CA-Transaction的第三步prepare中能看到如下调用栈:

  1. CA::Layer::prepare_commit
  2. Render::prepare_image
  3. Render::copy_image
  4. Render::create_image
  5. ... decodeImage

注: UIImage其实是CGImage的一个轻量级封装, 因此在UIImageView中的UIImage对象直接将自己的CGImage图片数据作为CALayer的Content即可, 不再需要重新创建CGContetRef。

异步绘制流程

参考代码如下

/**
 维护线程安全的绘制状态
 */
@property (atomic, assign) ADLayerStatus status;

- (void)setNeedsDisplay {
    
    // 收到新的绘制请求时,同步正在绘制的线程本次取消
    self.status = ADLayerStatusCancel;
    
    [super setNeedsDisplay];
}

- (void)display {
    
    // 标记正在绘制
    self.status = ADLayerStatusDrawing;
    
    if ([self.delegate respondsToSelector:@selector(asyncDrawLayer:inContext:canceled:)]) {
        [self asyncDraw];
    } else {
        [super display];
    }
}

- (void)asyncDraw {
    
    __block ADQueue *q = [[ADManager shareInstance] ad_getExecuteTaskQueue];
    __block id<ADLayerDelegate> delegate = (id<ADLayerDelegate>)self.delegate;
    
    dispatch_async(q.queue, ^{
        
        // 重绘取消
        if ([self canceled]) {
            [[ADManager shareInstance] ad_finishTask:q];
            return;
        }
        
        // 生成上下文context
        CGSize size = self.bounds.size;
        BOOL opaque = self.opaque;
        CGFloat scale = [UIScreen mainScreen].scale;
        CGColorRef backgroundColor = (opaque && self.backgroundColor) ? CGColorRetain(self.backgroundColor) : NULL;
        UIGraphicsBeginImageContextWithOptions(size, opaque, scale);
        CGContextRef context = UIGraphicsGetCurrentContext();
        if (opaque && context) {
            CGContextSaveGState(context); {
                if (!backgroundColor || CGColorGetAlpha(backgroundColor) < 1) {
                    CGContextSetFillColorWithColor(context, [UIColor whiteColor].CGColor);
                    CGContextAddRect(context, CGRectMake(0, 0, size.width * scale, size.height * scale));
                    CGContextFillPath(context);
                }
                if (backgroundColor) {
                    CGContextSetFillColorWithColor(context, backgroundColor);
                    CGContextAddRect(context, CGRectMake(0, 0, size.width * scale, size.height * scale));
                    CGContextFillPath(context);
                }
            } CGContextRestoreGState(context);
            CGColorRelease(backgroundColor);
         } else {            
            CGColorRelease(backgroundColor);
        }        
        
        // 使用context绘制
        [delegate asyncDrawLayer:self inContext:context canceled:[self canceled]];
        
        // 重绘取消
        if ([self canceled]) {
            [[ADManager shareInstance] ad_finishTask:q];
            UIGraphicsEndImageContext();
            return;
        }
        
        // 获取image
        UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
        UIGraphicsEndImageContext();
        
        // 结束任务
        [[ADManager shareInstance] ad_finishTask:q];
        
        // 重绘取消
        if ([self canceled]) {
            return;
        }
        
        // 主线程刷新
        dispatch_async(dispatch_get_main_queue(), ^{
            self.contents = (__bridge id)(image.CGImage);
        });
    });
}

CALayerDelegate方法解析

通过上面的介绍, 我们知道, 不管是系统绘制还是异步绘制, 都跟CALayerDelegate中的相关方法有关, 接下来就具体介绍下CALayerDelegate的相关方法

/** Delegate methods. **/
public protocol CALayerDelegate : NSObjectProtocol {

    
    /* If defined, called by the default implementation of the -display
     * method, in which case it should implement the entire display
     * process (typically by setting the `contents' property). */
    
    @available(iOS 2.0, *)
    optional func display(_ layer: CALayer)

    
    /* If defined, called by the default implementation of -drawInContext: */
    
    @available(iOS 2.0, *)
    optional func draw(_ layer: CALayer, in ctx: CGContext)

    
    /* If defined, called by the default implementation of the -display method.
     * Allows the delegate to configure any layer state affecting contents prior
     * to -drawLayer:InContext: such as `contentsFormat' and `opaque'. It will not
     * be called if the delegate implements -displayLayer. */
    
    @available(iOS 10.0, *)
    optional func layerWillDraw(_ layer: CALayer)

    
    /* Called by the default -layoutSublayers implementation before the layout
     * manager is checked. Note that if the delegate method is invoked, the
     * layout manager will be ignored. */
    
    @available(iOS 2.0, *)
    optional func layoutSublayers(of layer: CALayer)

    
    /* If defined, called by the default implementation of the
     * -actionForKey: method. Should return an object implementing the
     * CAAction protocol. May return 'nil' if the delegate doesn't specify
     * a behavior for the current event. Returning the null object (i.e.
     * '[NSNull null]') explicitly forces no further search. (I.e. the
     * +defaultActionForKey: method will not be called.) */
    
    @available(iOS 2.0, *)
    optional func action(for layer: CALayer, forKey event: String) -> CAAction?
}

func display(_ layer: CALayer)

当图层标记其内容为需要更新 (setNeedsDisplay()) 时,调用此方法。例如,为图层设置 contents 属性

private lazy var delegate = LayerDelegate()
     
private lazy var sublayer: CALayer = {
    let layer = CALayer()
    layer.delegate = self.delegate
    return layer
}()
     
// 调用 `sublayer.setNeedsDisplay()` 时,会调用 `sublayer.display(_:)`。
class LayerDelegate: NSObject, CALayerDelegate {
    func display(_ layer: CALayer) {
        layer.contents = UIImage(named: "rabbit.png")?.cgImage
    }
}

func draw(_ layer: CALayer, in ctx: CGContext)

和 display(_:) 一样,但是可以使用图层的 CGContext 来实现显示的过程

// sublayer.setNeedsDisplay()
class LayerDelegate: NSObject, CALayerDelegate {
    func draw(_ layer: CALayer, in ctx: CGContext) {
        ctx.addEllipse(in: ctx.boundingBoxOfClipPath)
        ctx.strokePath()
    }
}

和 View中draw(_ rect: CGRect)的关系

1 首先调用Layer的draw(_:in:) 方法;

2 随后在super.draw(_:in:) 方法里面创建并配置好绘图环境;

3 通过Layer的super.draw(:in:) 调用 view 的 draw(:) 方法。

/// 注:此方法默认不执行任何操作,调用super.draw(_:) 与否并无影响。
override func draw(_ rect: CGRect) {
    print(#function)
}

override func draw(_ layer: CALayer, in ctx: CGContext) {
    print(#function)
}

// Prints "draw(_:in:)"
/// 注:此方法默认不执行任何操作,调用super.draw(_:) 与否并无影响。
override func draw(_ rect: CGRect) {
    print(#function)
}

override func draw(_ layer: CALayer, in ctx: CGContext) {
    print(#function)
    super.draw(layer, in: ctx)
}

// Prints "draw(_:in:)"
// Prints "draw"

注: 只有当系统在检测到View 的 draw(:) 方法被实现时,才会自动调用Layer的display(:) 或 draw(_ :in:) 方法。否则就必须通过手动调用图层的 setNeedsDisplay() 方法来调用。

func layerWillDraw(_ layer: CALayer)

在 draw(_ layer: CALayer, in ctx: CGContext) 调用之前调用,可以使用此方法配置影响内容的任何图层状态(例如 contentsFormat 和 isOpaque )。

func layoutSublayers(of layer: CALayer)

和 UIView 的 layoutSubviews() 类似。当发现边界发生变化并且其sublayers可能需要重新排列时(例如通过 frame 改变大小),将调用此方法。

func action(for layer: CALayer, forKey event: String) -> CAAction?

CALayer之所以能够执行动画,是因为它被定义在Core Animation框架中,是Core Animation执行操作的核心。

也就是说,CALayer 除了负责显示内容外,还能执行动画(其实是Core Animation与硬件之间的操作在执行,CALayer负责存储操作需要的数据,相当于Model)。

因此,使用CALayer的大部分属性都附带动画效果。但是在UIView中,默认将这个效果给关掉了,可以通过它图层的委托方法重新开启 ( 在View animation block中也会自动开启 ),返回决定它动画特效的对象,如果返回的是 nil ,将使用默认隐含的动画特效。

示例 - 使用图层的委托方法返回一个从左到右移动对象的基本动画

final class CustomView: UIView {
    override func action(for layer: CALayer, forKey event: String) -> CAAction? {
        guard event == "moveRight" else {
            return super.action(for: layer, forKey: event)
        }
        let animation = CABasicAnimation()
        animation.valueFunction = CAValueFunction(name: .translateX)
        animation.fromValue = 1
        animation.toValue = 300
        animation.duration = 2
        return animation
    }
}

let view = CustomView(frame: CGRect(x: 44, y: 44, width: UIScreen.width - 88, height: 300))
view.backgroundColor = .orange
self.view.addSubview(view)

let action = view.layer.action(forKey: "moveRight")
action?.run(forKey: "transform", object: view.layer, arguments: nil)

以上就是UIView的绘制原理和流程, 接下来解析UIView的渲染和显示原理。

渲染原理

图形渲染主要是利用GPU并行运算能力,实现图形渲染并显示在屏幕的每一个像素上。

渲染过程最常用的就是光栅化,即将数据转化为可见像素的过程。GPU及相关驱动实现了图形处理的OpenGL和DirectX模型,其实OpenGL不是函数API而是一种标准,制定了相关函数API及其实现的功能,具体的函数库由第三方来实现,通常是由显卡制造商来提供。

GPU架构模型

GPU内部包含了若干处理核来实现并发执行,其内部使用了二级缓存(L1、L2 cache),其与CPU的架构模型包含如下两种形式:分离式及耦合式

1 分离式结构

CPU 和 GPU 拥有各自的存储系统,两者通过 PCI-e 总线进行连接。

这种结构的缺点在于 PCI-e 相对于两者具有低带宽和高延迟,数据的传输成了其中的性能瓶颈。目前使用非常广泛,如PC、智能手机等。

2 耦合式结构

CPU 和 GPU 共享内存和缓存。AMD 的 APU 采用的就是这种结构,目前主要使用在游戏主机中,如 PS4。

GPU渲染过程

主要包括:

1 顶点着色器

包含了3D坐标系的转换,每个顶点属性值设定

2 形状(图元)装配

形成基本的图形

3 几何着色器

构造新的顶点来形成其他形状,如上图的另一个三角形

4 光栅化

将形状映射到屏幕的相应的像素生成片段,片段包含了像素结构所有的数据

5 片段着色器

丢弃超过视图以外的像素并着色

6 测试与混合

判断像素位置如是否在其他像素的后面及透明度等决定是否丢弃及混合

纹理

要想图形更加真实逼真需要更多的顶点及颜色属性,这样就增加了性能开销,为提升成产和执行效率,经常会使用纹理来表现细节。

纹理是一个 2D 图片(甚至也有 1D 和 3D 的纹理),纹理一般可以直接作为图形渲染流水线的第五阶段(即片段着色器)的输入。

图形渲染技术栈

App 使用 Core Graphics、Core Animation、Core Image 等框架来绘制可视化内容,这些软件框架相互之间也有着依赖关系。

这些框架都需要通过 OpenGL 来调用 GPU 进行绘制,最终将内容显示到屏幕之上,结构如下图所示

框架介绍

1 UIKit

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

2 Core Animation

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

3 Core Graphics

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

4 Core Image

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

5 OpenGL(ES)

OpenGL for Embedded Systems,简称GLES,是OpenGL的子集。

6 Metal

类似于OpenGL ES,也是一套第三方标准,具体实现由苹果实现。大多数开发者都没有直接使用过Metal,但其实所有开发者都在间接地使用Metal。Core Animation、Core Image、SceneKit、SpriteKit等等渲染框架都是构建于Metal 之上的。当在真机上调试OpenGL程序时,控制台会打印出启用 Metal 的日志。根据这一点可以猜测,Apple已经实现了一套机制将OpenGL命令无缝桥接到Metal上,由Metal担任真正于硬件交互的工作。

Render Server

iOS中应用并不负责渲染而是由专门的渲染进程负责,即Render Server

主要处理流程如下:

1 由App处理事件(Handle Events),如:用户的点击操作,在此过程中app可能需要更新视图树,相应地,图层树也会被更新。

2 App通过CPU完成对显示内容的计算,如:视图的创建、布局计算、图片解码、文本绘制等。在完成对显示内容的计算之后,App对图层进行打包,并在下一次RunLoop时将其发送至Render Server,即完成了一次Commit Transaction 操作。

具体commit transcation可以细分为如下步骤:

A) Layout,主要进行视图构建,包括LayoutSubviews方法的重载,addSubview方法填充子视图等。

B) Display,主要进行视图绘制,这里仅仅是设置最要成像的图元数据。重载视图的drawRect:方法可以自定义UIView的显示,其原理是在drawRect:方法内部绘制寄宿图,该过程使用CPU和内存。

C) Prepare,属于附加步骤,一般处理图像的解码和转换等操作。

D) Commit,主要将图层打包,并将它们通过IPC发送至Render Server。该过程会递归执行,因为图层和视图都是以树形结构存在。

3 Render Server执行OpenGL、Core Graphics相关操作,如根据layer的各种属性(如果是动画属性,则会计算动画layer的属性的中间值)并用OpenGL准备渲染。

4 GPU通过Frame Buffer、视频控制器等相关组件对图层进行渲染到屏幕。

为了满足屏幕60FPS刷新率,RunLoop每次操作的时间间隔不应超过16.67ms,且上述步骤需要并行执行。

渲染与RunLoop

1 iOS 的显示系统是由VSync信号驱动的,VSync信号由硬件时钟生成,每秒钟发出60次(这个值取决设备硬件,比如iPhone真机上通常是59.97)。

2 iOS图形服务接收到VSync信号后,会通过IPC通知到App内。

3 App的Runloop在启动后会注册对应的CFRunLoopSource通过mach_port接收传过来的时钟信号通知,随后Source的回调会驱动整个Ap 的动画与显示。

注:实际观察App启动后未注册相关的VSync相关的Source,因此上述应用应该是Render Server渲染进程注册Source监听VSync信号来驱动图层的渲染,进而提交至GPU。

4 Core Animation在RunLoop中注册了一个Observer,监听了BeforeWaiting和Exit事件。这个Observer的优先级是2000000,低于常见的其他Observer。

5 当一个触摸事件到来时,RunLoop被唤醒,App中的代码会执行一些操作,比如创建和调整视图层级、设置 UIView的frame、修改CALayer的透明度、为视图添加一个动画。这些操作最终都会被CALayer捕获,并通过CATransaction提交到一个中间状态去。

6 当上面所有操作结束后,RunLoop即将进入休眠(或者退出)时,关注该事件的Observer都会得到通知。这时Core Animation注册的那个Observer就会在回调中,把所有的中间状态合并提交到GPU去显示。

7 如果此处有动画,Core Animation会通过CADisplayLink等机制多次触发相关流程。

显示原理

以屏幕显示播放视频为例, 其屏幕图形显示结构如图所示

1 CPU将图形数据通过总线BUS提交至GPU

2 GPU经过渲染处理转化为一帧帧的数据并提交至帧缓冲区

3 视频控制器会通过垂直同步信号VSync逐帧读取帧缓冲区的数据并提交至屏幕控制器最终显示在屏幕上。

双缓冲机制

为解决一个帧缓冲区效率问题(读取和写入都是一个无法有效的并发处理),采用双缓冲机制,在这种情况下,GPU会预先渲染一帧放入一个缓冲区中,用于视频控制器的读取。当下一帧渲染完毕后,GPU 会直接把视频控制器的指针指向第二个缓冲器,如下图所示:

双缓冲机制虽然提升了效率但也引入了画面撕裂问题,即当视频控制器还未读取完成时,即屏幕内容刚显示一半时,GPU将新的一帧内容提交到帧缓冲区并把两个缓冲区进行交换后,视频控制器就会把新的一帧数据的下半段显示到屏幕上,造成画面撕裂现象

垂直同步 (V-Sync)

为了解决画面撕裂的问题,GPU通常有一个机制叫做垂直同步( V-Sync),当开启垂直同步后,GPU会等待显示器的VSync信号发出后,才进行新的一帧渲染和缓冲区更新。这样能解决画面撕裂现象,也增加了画面流畅度,但需要消费更多的计算资源,也会带来部分延迟。

页面卡顿掉帧

在VSync信号到来后,系统图形服务会通过CADisplayLink等机制通知App,App主线程开始在CPU中计算显示内容,比如视图的创建、布局计算、图片解码、文本绘制等。随后CPU会将计算好的内容提交到GPU去,由GPU进行变换、合成、渲染。随后GPU会把渲染结果提交到帧缓冲区去,等待下一次VSync信号到来时显示到屏幕上。由于垂直同步的机制,如果在一个VSync时间内,CPU或者GPU没有完成内容提交,则那一帧就会被丢弃,等待下一次机会再显示,而这时显示屏会保留之前的内容不变。这就是界面卡顿的原因。

性能优化

通过上面的讲解, 我们了解了UIView绘制到显示的整个流程, 但是在实际开发中, 可能因为一些不当的操作, 导致整个流程耗时增加, 甚至出现掉帧卡顿的问题, 这时候, 我们可以通过以下几个方面来做性能优化, 保证页面显示滚动的流畅性。

CPU层面

cpu层面主要考虑降低资源消耗, 可以从以下几个方面入手

1 对象创建

对象创建会分配内存、调整属性、甚至还有读取文件(如创建UIViewController读取xib文件)等操作,比较消耗CPU资源。因此,尽量使用轻量的对象替代重量的对象,如

  1. CALayer比UIView不需要响应触摸事件。

  2. 如果对象不涉及UI操作,则尽量放到后台线程执行。

  3. 性能敏感的视图对象,尽量使用代码创建而不是Storyboard来创建。

  4. 如果对象可以复用,可以使用缓存池来复用。

2 对象调整

如CALayer属性修改、视图层次调整、添加和移除视图等。

注: CALayer内部并没有属性方法,其内部是通过runtime动态接收方法resoleInstanceMethod方法为对象临时添加一个方法,并把对应属性值保存到内部的一个Dictionary字典里,同时还会通知delegate、创建动画等。UIView的关于显示相关的属性(比如frame/bounds/transform)等实际上是CALayer属性映射来的。

3 对象销毁

虽然对象销毁销毁资源不多,但累积起来也不容忽视。通常当容器类持有大量对象时,其销毁时的资源消耗就非常明显,因此,可见用于后台线程去释放的对象挪动后台线程去。

4 布局计算

视图布局计算是应用最为常见的消耗CPU资源的地方,其最终实现都会通过UIView.frame/bounds/center等属性的调整上,因此,避免CPU资源消耗尽量提前计算好布局,在需要时一次性调整好对应属性,而不要多次、频繁的计算和调整这些属性。

5 Autolayout

Auotlayout是苹果提倡的技术,可在大部分情况下能很好地提升开发效率,但是其对于复杂视图来说会带来严重的性能问题,因此对于性能要求高的视图尽量使用代码实现视图。

6 文本计算

如果页面包含大量文本,文本宽高计算会占用很大一部分资源,并且不可避免。可以通过富文本NSAttributedString的[NSAttributedString boundingRectWithSize:options:context]方法来计算文本宽高,用[NSAttributeString drawWithRect:options:context:]来绘制文本,并放在后台线程执行避免阻塞主线程;或者使用CoreText基于c的跨平台API来绘制文本。

7 文本渲染

屏幕上能看到的所有文本内容控件,包括UIWebView,在底层都是通过CoreText排版、绘制为Bitmap显示。常见的文本控件,如UILabel、UITextView等,其排版和绘制都是在主线程进行,当显示大量文本时,CPU的压力会非常大。解决方案只有一个,就是自定义文本控件,并用TextKit或最底层的CoreText对文本异步绘制。

8 图片解码

当使用UIImage或CGImageSource的那几个方法创建图片时,图片数据并不会立即解码。只有图片设置到UIImageView或者CALayer.contents中去,并且CALayer被提交到GPU前,CGImage中的数据才会得到解码,且需要在主线程执行。

解决方法:后台线程先把图片绘制到CGBitmapContext中,然后从Bitmap直接创建图片。目前常见的网络图片库都自带这个功能。

9 图像绘制

图像的绘制通常是指用CGxx开头的方法将图像绘制到画布中,然后从画布创建图片并显示这样的一个过程。这个最常见的就是[UIView drawRect:]方法,由于CoreCraphic方法通常都是线程安全的,所以图像的绘制可以很容易放到后台线程进行。

GPU层面

相对于CPU来说,GPU主要就是:接收提交的纹理和顶点描述(三角形),应用变换、混合并渲染,然后输出到屏幕上。通常你所能看到的内容,主要也就是纹理(图片)和形状(三角模拟的矢量图形)两类。因此可以从下面三方面入手。

1 纹理的渲染

所有的Bitmap,包括图片、文本、栅格化的内容,最终都要从内存提交到显存,绑定为GPU纹理。不论是提交到显存的过程,还是GPU调制和渲染纹理的过程,都要消耗不少GPU资源。

当在较短时间内显示大量图片时(如UITableView存在非常多的图片并且快速滑动时),CPU占用率很低,GPU占用非常高,因此会导致界面掉帧卡顿。有效避免此情况的方法就是尽量减少在短时间内大量图片的显示,尽可能将多张图片合并为一张进行显示。

2 视图的混合

存在多视图且多层次重叠显示时,GPU会首先将其混合在一起。如果视图结构很复杂,混合的过程也会消耗很多的GPU资源。为了减轻GPU的消耗,应尽量减少视图数量级层次,并在不透明的视图里标明opaque属性以避免无用的Alpha通道合成。

3 图形的生成

CALayer的border、圆角、阴影、遮罩(mask),CASharpLayer的矢量图形显示,通常会触发离屏渲染(offscreen rendering),而离屏渲染通常发生在GPU中。

当一个列表视图中存在大量圆角的CALayer且快速滑动时,会消耗大量的GPU资源,进而引发界面卡顿。为避免此种情况,可以尝试开启CALayer.shouldRasterize属性,这会把离屏渲染的操作转嫁到CPU上;最好是尽量避免使用圆角、阴影、遮罩等属性。

注: GPU屏幕渲染存在两种方式:

当前屏幕渲染(On-Screen Rendering)

正常的GPU渲染流程,GPU将渲染完成的帧放到帧缓冲区,然后显示到屏幕。

离屏渲染(Off-Screen Rendering)

会额外创建一个离屏渲染缓冲区(如保存后续复用的数据),后续仍会提交至帧缓冲区进而显示到屏幕。

离屏渲染需要创建新的缓冲区,渲染过程中会涉及从当前屏幕切换到离屏环境多次上下文环境切换,等到离屏渲染完成后还需要将渲染结果切换到当前屏幕环境,因此付出的代价较高。

以上就是UIView从绘制到渲染再到显示的全过程, 作为一个有追求的iOS开发工程师, 保持iOS流畅的性能是永远不变的追求。