UI -[渲染原理] -界面渲染那些事

当你被问到下面问题,你能够回答出来么?
1、app从点击屏幕到完成渲染,中间发生了什么?
2、当一个UIImageView添加到视图后,内部是如何渲染到手机上的?
3、一个tableView中有多个cell,如何避免卡顿。

今天,我们就来了解iOS中的渲染过程;

图像渲染流水线

图像渲染流程粗粒度地大概分为下面这些步骤:
在这里插入图片描述
上述图像渲染流水线中,除了第一部分 Application 阶段,后续主要都由 GPU 负责,为了方便后文讲解,先将 GPU 的渲染流程图展示出来:
在这里插入图片描述
上图就是一个三角形被渲染的过程中,GPU 所负责的渲染流水线。可以看到简单的三角形绘制就需要大量的计算,如果再有更多更复杂的顶点、颜色、纹理信息(包括 3D 纹理),那么计算量是难以想象的。这也是为什么 GPU 更适合于渲染流程。
接下来,具体讲解渲染流水线中各个部分的具体任务:

Application 应用处理阶段:得到图元

这个阶段具体指的就是图像在应用中被处理的阶段,此时还处于 CPU 负责的时期。在这个阶段应用可能会对图像进行一系列的操作或者改变,最终将新的图像信息传给下一阶段。这部分信息被叫做图元(primitives),通常是三角形、线段、顶点等。

Geometry 几何处理阶段:处理图元

进入这个阶段之后,以及之后的阶段,就都主要由 GPU 负责了。此时 GPU 可以拿到上一个阶段传递下来的图元信息,GPU 会对这部分图元进行处理,之后输出新的图元。这一系列阶段包括:

顶点着色器(Vertex Shader):这个阶段中会将图元中的顶点信息进行视角转换、添加光照信息、增加纹理等操作。
形状装配(Shape Assembly):图元中的三角形、线段、点分别对应三个 Vertex、两个 Vertex、一个 Vertex。这个阶段会将 Vertex 连接成相对应的形状。
几何着色器(Geometry Shader):额外添加额外的Vertex,将原始图元转换成新图元,以构建一个不一样的模型。简单来说就是基于通过三角形、线段和点构建更复杂的几何图形。

Rasterization 光栅化阶段:图元转换为像素

光栅化的主要目的是将几何渲染之后的图元信息,转换为一系列的像素,以便后续显示在屏幕上。这个阶段中会根据图元信息,计算出每个图元所覆盖的像素信息等,从而将像素划分成不同的部分。
在这里插入图片描述

一种简单的划分就是根据中心点,如果像素的中心点在图元内部,那么这个像素就属于这个图元。如上图所示,深蓝色的线就是图元信息所构建出的三角形;而通过是否覆盖中心点,可以遍历出所有属于该图元的所有像素,即浅蓝色部分。

屏幕成像

在图像渲染流程结束之后,接下来就需要将得到的像素信息显示在物理屏幕上了。GPU 最后一步渲染结束之后像素信息,被存在帧缓冲器(Framebuffer)中,之后视频控制器(Video Controller)会读取帧缓冲器中的信息,经过数模转换传递给显示器(Monitor),进行显示。完整的流程如下图所示:
在这里插入图片描述
经过 GPU 处理之后的像素集合,也就是位图,会被帧缓冲器缓存起来,供之后的显示使用。显示器的电子束会从屏幕的左上角开始逐行扫描,屏幕上的每个点的图像信息都从帧缓冲器中的位图进行读取,在屏幕上对应地显示。扫描的流程如下图所示:
在这里插入图片描述
电子束扫描的过程中,屏幕就能呈现出对应的结果,每次整个屏幕被扫描完一次后,就相当于呈现了一帧完整的图像。屏幕不断地刷新,不停呈现新的帧,就能呈现出连续的影像。而这个屏幕刷新的频率,就是帧率(Frame per Second,FPS)。由于人眼的视觉暂留效应,当屏幕刷新频率足够高时(FPS 通常是 50 到 60 左右),就能让画面看起来是连续而流畅的。对于 iOS 而言,app 应该尽量保证 60 FPS 才是最好的体验。

渲染流水线带来的问题及解决方法

(1) 屏幕撕裂
在这种单一缓存的模式下,最理想的情况就是一个流畅的流水线:每次电子束从头开始新的一帧的扫描时,CPU+GPU 对于该帧的渲染流程已经结束,渲染好的位图已经放入帧缓冲器中。但这种完美的情况是非常脆弱的,很容易产生屏幕撕裂:
在这里插入图片描述
解决方法:垂直同步 Vsync + 双缓冲机制 Double Buffering
解决屏幕撕裂、提高显示效率的一个策略就是使用垂直同步信号 Vsync 与双缓冲机制 Double Buffering。根据苹果的官方文档描述,iOS 设备会始终使用 Vsync + Double Buffering 的策略。
垂直同步信号(vertical synchronisation,Vsync)相当于给帧缓冲器加锁:当电子束完成一帧的扫描,将要从头开始扫描时,就会发出一个垂直同步信号。只有当视频控制器接收到 Vsync 之后,才会将帧缓冲器中的位图更新为下一帧,这样就能保证每次显示的都是同一帧的画面,因而避免了屏幕撕裂。
但是这种情况下,视频控制器在接受到 Vsync 之后,就要将下一帧的位图传入,这意味着整个 CPU+GPU 的渲染流程都要在一瞬间完成,这是明显不现实的。所以双缓冲机制会增加一个新的备用缓冲器(back buffer)。渲染结果会预先保存在 back buffer 中,在接收到 Vsync 信号的时候,视频控制器会将 back buffer 中的内容置换到 frame buffer 中,此时就能保证置换操作几乎在一瞬间完成(实际上是交换了内存地址)。

(2) 掉帧
启用 Vsync 信号以及双缓冲机制之后,能够解决屏幕撕裂的问题,但是会引入新的问题:掉帧。如果在接收到 Vsync 之时 CPU 和 GPU 还没有渲染好新的位图,视频控制器就不会去替换 frame buffer 中的位图。这时屏幕就会重新扫描呈现出上一帧一模一样的画面。相当于两个周期显示了同样的画面,这就是所谓掉帧的情况。
在这里插入图片描述
如图所示,A、B 代表两个帧缓冲器,当 B 没有渲染完毕时就接收到了 Vsync 信号,所以屏幕只能再显示相同帧 A,这就发生了第一次的掉帧。

解决方法:三缓冲 Triple Buffering
事实上上述策略还有优化空间。我们注意到在发生掉帧的时候,CPU 和 GPU 有一段时间处于闲置状态:当 A 的内容正在被扫描显示在屏幕上,而 B 的内容已经被渲染好,此时 CPU 和 GPU 就处于闲置状态。那么如果我们增加一个帧缓冲器,就可以利用这段时间进行下一步的渲染,并将渲染结果暂存于新增的帧缓冲器中

在这里插入图片描述
如图所示,由于增加了新的帧缓冲器,可以一定程度上地利用掉帧的空档期,合理利用 CPU 和 GPU 性能,从而减少掉帧的次数。

(3)屏幕卡顿的本质

手机使用卡顿的直接原因,就是掉帧。前文也说过,屏幕刷新频率必须要足够高才能流畅。对于 iPhone 手机来说,屏幕最大的刷新频率是 60 FPS,一般只要保证 50 FPS 就已经是较好的体验了。但是如果掉帧过多,导致刷新频率过低,就会造成不流畅的使用体验。
这样看来,可以大概总结一下

  • 屏幕卡顿的根本原因:CPU 和 GPU 渲染流水线耗时过长,导致掉帧。
  • Vsync 与双缓冲的意义:强制同步屏幕刷新,以掉帧为代价解决屏幕撕裂问题。
  • 三缓冲的意义:合理使用 CPU、GPU 渲染性能,减少掉帧次数。

iOS中的渲染框架

CALayer 是显示的基础:存储 bitmap

简单理解,CALayer 就是屏幕显示的基础。那 CALayer 是如何完成的呢?让我们来从源码向下探索一下,在 CALayer.h 中,CALayer 有这样一个属性 contents:

/** Layer content properties and methods. **/

/* An object providing the contents of the layer, typically a
CGImageRef, * but may be something else. (For example, NSImage
objects are * supported on Mac OS X 10.6 and later.) Default value is
nil. * Animatable. */

@property(nullable, strong) id contents;
An object providing the
contents of the layer, typically a CGImageRef.

contents 提供了 layer 的内容,是一个指针类型,在 iOS 中的类型就是 CGImageRef(在 OS X 中还可以是 NSImage)。而我们进一步查到,Apple 对 CGImageRef 的定义是:

A bitmap image or image mask.

看到 bitmap,这下我们就可以和之前讲的的渲染流水线联系起来了:实际上,CALayer 中的 contents 属性保存了由设备渲染流水线渲染好的位图 bitmap(通常也被称为 backing store),而当设备屏幕进行刷新时,会从 CALayer 中读取生成好的 bitmap,进而呈现到屏幕上。
所以,如果我们在代码中对 CALayer 的 contents 属性进行了设置,比如这样:

// 注意 CGImage 和 CGImageRef 的关系:
// typedef struct CGImage CGImageRef;
layer.contents = (__bridge id)image.CGImage;

复制代码那么在运行时,操作系统会调用底层的接口,将 image 通过 CPU+GPU 的渲染流水线渲染得到对应的 bitmap,存储于 CALayer.contents 中,在设备屏幕进行刷新的时候就会读取 bitmap 在屏幕上呈现。
也正因为每次要被渲染的内容是被静态的存储起来的,所以每次渲染时,Core Animation 会触发调用 drawRect: 方法,使用存储好的 bitmap 进行新一轮的展示。

Core Animation是什么

它本质上可以理解为一个复合引擎,主要职责包含:渲染、构建和实现动画。
通常我们会使用 Core Animation 来高效、方便地实现动画,但是实际上它的前身叫做 Layer Kit,关于动画实现只是它功能中的一部分。对于 iOS app,不论是否直接使用了 Core Animation,它都在底层深度参与了 app 的构建。而对于 OS X app,也可以通过使用 Core Animation 方便地实现部分功能。
在这里插入图片描述
ore Animation 是 AppKit 和 UIKit 完美的底层支持,同时也被整合进入 Cocoa 和 Cocoa Touch 的工作流之中,它是 app 界面渲染和构建的最基础架构。

Core Animation 的职责就是尽可能快地组合屏幕上不同的可视内容,这个内容是被分解成独立的 layer(iOS 中具体而言就是 CALayer),并且被存储为树状层级结构。这个树也形成了 UIKit 以及在 iOS 应用程序当中你所能在屏幕上看见的一切的基础。

Core Animation渲染全流程

CAAnimation 渲染全过程
整个流水线一共有下面几个步骤:
(1) Handle Events

这个过程中会处理一些触摸事件,这些事件可能会改变页面的布局和界面层次,比如:

  1. 创建和调整视图层级, 例如 addSubView, removeSubView等
  2. 设置 UIView 的 frame, 调制autolayout约束等
  3. 修改 CALayer 的透明度
  4. 为视图添加一个动画
  5. 其他可能导致 CALayer - Tree 变化的操作;

(2) Commit Transaction:

当上面的这些操作引起layer tree发生变化时,会隐式地生成一个事务transaction。整个transaction中,又包括Layout、Display、Prepare、Commit 等四个具体的操作。

Layout:构建视图

这个阶段主要处理视图的构建和布局,具体步骤包括:

  1. 调用重载的 layoutSubviews 方法
  2. 创建视图,并通过 addSubview 方法添加子视图
  3. 计算视图布局,即所有的 Layout Constraint

由于这个阶段是在 CPU 中进行,通常是 CPU 限制或者 IO 限制,所以我们应该尽量高效轻量地操作,减少这部分的时间,比如减少非必要的视图创建、简化布局计算、减少视图层级等。

Display:绘制视图

这个阶段主要是交给 Core Graphics 进行视图的绘制,注意不是真正的显示,而是得到前文所说的图元 primitives 数据:

根据上一阶段 Layout 的结果创建得到图元信息。
如果重写了 drawRect: 方法,那么会调用重载的 drawRect: 方法,在 drawRect: 方法中手动绘制得到 bitmap 数据,从而自定义视图的绘制。

注意正常情况下 Display 阶段只会得到图元 primitives 信息,而位图 bitmap 是在 GPU 中根据图元信息绘制得到的。但是如果重写了 drawRect: 方法,这个方法会直接调用 Core Graphics 绘制方法得到 bitmap 数据,同时系统会额外申请一块内存,用于暂存绘制好的 bitmap。
由于重写了 drawRect: 方法,导致绘制过程从 GPU 转移到了 CPU,这就导致了一定的效率损失。与此同时,这个过程会额外使用 CPU 和内存,因此需要高效绘制,否则容易造成 CPU 卡顿或者内存爆炸。

Prepare:Core Animation 额外的工作

这一步主要是:图片解码和转换

Commit:打包并发送

这一步主要是:图层打包并发送到 Render Server。
注意 commit 操作是依赖图层树递归执行的,所以如果图层树过于复杂,commit 的开销就会很大。这也是我们希望减少视图层级,从而降低图层树复杂度的原因。

(3)Decode、Draw Calls、Render、display

打包好的图层被传输到 Render Server 之后,首先会进行解码。解码完成后,Core Animation 会调用下层渲染框架(比如 OpenGL 或者 Metal)的方法进行绘制,进而调用到 GPU。GPU渲染完成后,在下一个runloop等待显示
在这里插入图片描述
Render Server 通常是 OpenGL 或者是 Metal。以 OpenGL 为例,那么上图主要是 GPU 中执行的操作,具体主要包括:

GPU 收到 Command Buffer,包含图元 primitives 信息
Tiler 开始工作:先通过顶点着色器 Vertex Shader 对顶点进行处理,更新图元信息
平铺过程:平铺生成 tile bucket 的几何图形,这一步会将图元信息转化为像素,之后将结果写入 Parameter Buffer 中
Tiler 更新完所有的图元信息,或者 Parameter Buffer 已满,则会开始下一步
Renderer 工作:将像素信息进行处理得到 bitmap,之后存入 Render Buffer
Render Buffer 中存储有渲染好的 bitmap,供之后的 Display 操作使用

Runloop中触发渲染流程

什么是runloop?

so
runloop是一个事件驱动的大循环,它会把来自用户的交互事件、系统内部事件、计时器事件加入到事件队列中,并循环地从事件队列中取出事件进行处理,当所有的事件都处理完毕时,就会进入休眠状态,知道被新到来的事件唤醒。

通常所说的RunLoop指的是NSRunloop或者CFRunloopRef,CFRunloopRef是纯C的函数,而NSRunloop仅仅是CFRunloopRef的OC封装,并未提供额外的其他功能,因此下面主要分析CFRunloopRef,苹果已经开源了CoreFoundation源代码,因此很容易找到CFRunloop源代码👇
CFRunloop源代码

int32_t __CFRunLoopRun( /** 5个参数 */ )
{
    // 通知即将进入runloop
    __CFRunLoopDoObservers(KCFRunLoopEntry);
    
    do
    {
        // 通知将要处理timer和source
        __CFRunLoopDoObservers(kCFRunLoopBeforeTimers);
        __CFRunLoopDoObservers(kCFRunLoopBeforeSources);
        
        // 处理非延迟的主线程调用
        __CFRunLoopDoBlocks();
        // 处理Source0事件
        __CFRunLoopDoSource0();
        
        if (sourceHandledThisLoop) {
            __CFRunLoopDoBlocks();
         }
        // 如果有 Source1 (基于port) 处于 ready 状态,直接处理这个 Source1 然后跳转去处理消息。
        if (__Source0DidDispatchPortLastTime) {
            Boolean hasMsg = __CFRunLoopServiceMachPort();
            if (hasMsg) goto handle_msg;
        }
            
        // 通知 Observers:没有事件要处理, RunLoop 的线程即将进入休眠(sleep)。
        if (!sourceHandledThisLoop) {
            __CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeWaiting);
        }
            
        // GCD dispatch main queue
        CheckIfExistMessagesInMainDispatchQueue();
        
        // 即将进入休眠
        __CFRunLoopDoObservers(kCFRunLoopBeforeWaiting);
        
        // 等待内核mach_msg事件
        mach_port_t wakeUpPort = SleepAndWaitForWakingUpPorts();
        
        // 等待。。。
        
        // 从等待中醒来
        __CFRunLoopDoObservers(kCFRunLoopAfterWaiting);
        
        // 处理因timer的唤醒
        if (wakeUpPort == timerPort)
            __CFRunLoopDoTimers();
        
        // 处理异步方法唤醒,如dispatch_async
        else if (wakeUpPort == mainDispatchQueuePort)
            __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__()
            
        // 处理Source1
        else
            __CFRunLoopDoSource1();
        
        // 再次确保是否有同步的方法需要调用
        __CFRunLoopDoBlocks();
        
    } while (!stop && !timeout);
    
    // 通知即将退出runloop
    __CFRunLoopDoObservers(CFRunLoopExit);
}

下图描述了Runloop运行流程。基本描述了上面Runloop的核心流程,当然可以查看官方 The RunLoop Sequence of Events描述描述。
在这里插入图片描述

输入源source

输入源是指事件的来源,输入源将事件异步传送到您的线程。事件的来源取决于输入源的类型,通常是两个类别之一。基于端口的输入源监视应用程序的 Mach 端口。自定义输入源监视自定义事件源。基于端口的源由内核自动发出信号,自定义源必须从另一个线程手动发出信号。
来看一下官方 Runloop 结构图(注意下图的 Input Source Port 和前面流程图中对应Source1。Source1和Timer都属于端口事件源,不同的是所有的Timer都共用一个端口“Mode Timer Port”,而每个Source1都有不同的对应端口):
在这里插入图片描述

source1和source0的区别:
source1: 基于mach_Port的,来自系统内核或者其他进程或线程的事件,可以主动唤醒休眠中的RunLoop(iOS里进程间通信开发过程中我们一般不主动使用)。mach_port大家就理解成进程间相互发送消息的一种机制就好, 比如屏幕点击, 网络数据的传输都会触发sourse1。
苹果创建用来接受系统发出事件,当手机发生一个触摸,摇晃或锁屏等系统,这时候系统会发送一个事件到app进程(进程通信),这也就是为什么叫基于port传递source1的原因;
source0 :非基于Port的 处理事件,什么叫非基于Port的呢?就是说你这个消息不是其他进程或者内核直接发送给你的。一般是APP内部的事件, 比如hitTest:withEvent的处理, performSelectors的事件.
简单举个例子:一个APP在前台静止着,此时,用户用手指点击了一下APP界面,那么过程就是下面这样的:
我们触摸屏幕,先摸到硬件(屏幕),屏幕表面的事件会被IOKit先包装成Event,通过mach_Port传给正在活跃的APP , Event先告诉source1(mach_port),source1唤醒RunLoop, 然后将事件Event分发给source0,然后由source0来处理。

常见的几种源有基于端口的源自定义的源performSelect源计时器源;

运行循环模式runloop mode

每次运行运行循环时,您都指定(显式或隐式)运行的特定“模式”。在运行循环的那段过程中,只有与该模式关联的源才会被监视并允许传递它们的事件。(类似地,只有与该模式关联的观察者才会被通知运行循环的进度。)与其他模式关联的源会保留任何新事件,直到随后以适当的模式通过循环。

从源码很容易看出,Runloop总是运行在某种特定的CFRunLoopModeRef下(每次运行__CFRunLoopRun()函数时必须指定Mode)。而通过CFRunloopRef对应结构体的定义可以很容易知道每种Runloop都可以包含若干个Mode,每个Mode又包含Source/Timer/Observer。每次调用Runloop的主函数__CFRunLoopRun()时必须指定一种Mode,这个Mode称为 _currentMode ,当切换Mode时必须退出当前Mode,然后重新进入Runloop以保证不同Mode的Source/Timer/Observer互不影响。

struct __CFRunLoop {  // 部分
    CFRuntimeBase _base;
    pthread_mutex_t _lock; /* locked for accessing mode list */
    __CFPort _wakeUpPort; // used for CFRunLoopWakeUp 
    Boolean _unused;
    pthread_t _pthread;
    CFMutableSetRef _commonModes;
    CFMutableSetRef _commonModeItems;
    CFRunLoopModeRef _currentMode;
    CFMutableSetRef _modes;
    CFAbsoluteTime _runTime;
    CFAbsoluteTime _sleepTime;
    CFTypeRef _counterpart;
};

// ----------------------------------------

struct __CFRunLoopMode {  // 部分
    CFRuntimeBase _base;
    /* must have the run loop locked before locking this */
    pthread_mutex_t _lock;
    CFStringRef _name;
    Boolean _stopped;
    CFMutableSetRef _sources0;
    CFMutableSetRef _sources1;
    CFMutableArrayRef _observers;
    CFMutableArrayRef _timers;
    CFMutableDictionaryRef _portToV1SourceMap;
    __CFPortSet _portSet;
    CFIndex _observerMask;
};

系统默认提供的 Run Loop Modes 有kCFRunLoopDefaultMode(NSDefaultRunLoopMode)和UITrackingRunLoopMode,需要切换到对应的Mode 时只需要传入对应的名称即可。前者是系统默认的 Runloop Mode,例如进入iOS程序默认不做任何操作就处于这种 Mode 中,此时滑动UIScrollView,主线程就切换 Runloop 到UITrackingRunLoopMode,不再接受其他事件操作(除非你将其他 Source/Timer 设置到UITrackingRunLoopMode下)。

但是对于开发者而言经常用到的 Mode 还有一个kCFRunLoopCommonModes(NSRunLoopCommonModes),其实这个并不是某种具体的 Mode,而是一种模式组合,在iOS系统中默认包含了 NSDefaultRunLoopMode 和UITrackingRunLoopMode;注意:并不是说 Runloop 会运行在kCFRunLoopCommonModes这种模式下,而是相当于分别注册了 NSDefaultRunLoopMode 和 UITrackingRunLoopMode。当然你也可以通过调用CFRunLoopAddCommonMode()方法将自定义 Mode 放到 kCFRunLoopCommonModes组合。

CFRunLoopRef和CFRunloopMode、CFRunLoopSourceRef/CFRunloopTimerRef/CFRunLoopObserverRef关系如下图:
在这里插入图片描述

一个RunLoop对象(CFRunLoop)中包含若干个运行模式(CFRunLoopMode)。而每一个运行模式下又包含若干个输入源(CFRunLoopSource)、定时源(CFRunLoopTimer)、观察者。

观察者Observer
struct __CFRunLoopObserver {
    CFRuntimeBase _base;
    pthread_mutex_t _lock;
    CFRunLoopRef _runLoop;
    CFIndex _rlCount;
    CFOptionFlags _activities; /* immutable */
    CFIndex _order; /* immutable */
    CFRunLoopObserverCallBack _callout; /* immutable */
};

相对来说CFRunloopObserverRef理解起来并不复杂,它相当于消息循环中的一个监听器,随时通知外部当前 RunLoop 的运行状态(它包含一个函数指针_callout_将当前状态及时告诉观察者)。具体的运行状态如下:

/* Run Loop Observer Activities */
typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
    kCFRunLoopEntry = (1UL << 0), // 进入RunLoop 
    kCFRunLoopBeforeTimers = (1UL << 1), // 即将开始Timer处理
    kCFRunLoopBeforeSources = (1UL << 2), // 即将开始Source处理
    kCFRunLoopBeforeWaiting = (1UL << 5), // 即将进入休眠
    kCFRunLoopAfterWaiting = (1UL << 6), // 从休眠状态唤醒
    kCFRunLoopExit = (1UL << 7), // 退出RunLoop
    kCFRunLoopAllActivities = 0x0FFFFFFFU
};
回调函数call out

RunLoop 几乎所有的操作都是通过Call out进行回调的(无论是 Observer 的状态通知还是 Timer、Source 的处理),而系统在回调时通常使用如下几个函数进行回调(换句话说你的代码其实最终都是通过下面几个函数来负责调用的,即使你自己监听Observer 也会先调用下面的函数然后间接通知你,所以在调用堆栈中经常看到这些函数):

static void __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__();
static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__();
static void __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__();
static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__();
static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__();
static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__();

例如在控制器的touchBegin中打入断点查看堆栈(由于UIEvent是Source0,所以可以看到一个Source0的Call out函数CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION调用):
在这里插入图片描述

runloop的休眠

其实对于 Event Loop 而言 RunLoop 最核心的事情就是保证线程在没有消息时休眠以避免占用系统资源,有消息时能够及时唤醒。RunLoop 的这个机制完全依靠系统内核来完成,具体来说是苹果操作系统核心组件Darwin中的Mach来完成的(Darwin是开源的)。可以从下图最底层Kernel中找到Mach:
在这里插入图片描述
而mach_msg()的本质是一个调用mach_msg_trap(),这相当于一个系统调用,会触发内核状态切换。当程序静止时,RunLoop 停留在

__CFRunLoopServiceMachPort(waitSet, &msg, sizeof(msg_buffer), &livePort, poll ? 0 : TIMEOUT_INFINITY, &voucherState, &voucherCopy)

而这个函数内部就是调用了mach_msg让程序处于休眠状态。

RunLoop 这种有事做事,没事休息的机制其实就是用户态和内核态的互相转化。用户态和内核态在 Linux 和 Unix 系统中,是基本概念,是操作系统的两种运行级别,他们的权限不一样,由于系统的资源是有限的,比如网络、内存等,所以为了优化性能,降低电量消耗,提高资源利用率,所以内核底层就这么设计了。

runloop和线程的关系

Runloop 是基于pthread进行管理的,pthread是基于 C 的跨平台多线程操作底层API。它是 mach thread 的上层封装(可以参见Kernel Programming Guide),和NSThread一一对应(而NSThread是一套面向对象的API,所以在iOS开发中我们也几乎不用直接使用pthread)
在这里插入图片描述
OS开发过程中对于开发者而言更多的使用的是NSRunloop,它默认提供了三个常用的run方法:

- (void)run; 
- (BOOL)runMode:(NSRunLoopMode)mode beforeDate:(NSDate *)limitDate;
- (void)runUntilDate:(NSDate *)limitDate;
  • run:方法对应上面CFRunloopRef中的CFRunLoopRun并不会退出,除非调用CFRunLoopStop();通常如果想要永远不会退出 RunLoop 才会使用此方法,否则可以使用runUntilDate:。
  • runMode:beforeDate:则对应CFRunLoopRunInMode(mode,limiteDate,true)方法,只执行一次,执行完就退出;通常用于手动控制 RunLoop(例如在while循环中)。
  • runUntilDate:方法其实是CFRunLoopRunInMode(kCFRunLoopDefaultMode,limiteDate,false),执行完并不会退出,继续下一次RunLoop直到timeout。
runloop的应用

runloop的应该有很多很多,具体可以参考深入理解runloop,本文由于主要和界面渲染有关,因此主要讲一下runloop在UI更新流程中的应用:

更新UI

如果打印App启动之后的主线程 RunLoop 可以发现另外一个 callout 为_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv的 Observer,这个监听专门负责UI变化后的更新,比如修改了frame、调整了UI层级(UIView/CALayer)或者手动设置了setNeedsDisplay:/setNeedsLayout:,这些调整操作会触发transaction commit,向渲染服务器提交图层树。当这个 Observer 监听了主线程 RunLoop 的即将进入休眠和退出状态,则会遍历所有的UI更新并提交进行实际绘制更新。

UIView display相关方法调用与过程
注意一下整个过程是CA Transaction中的Display步骤!!!
下图是关于在CALayer在渲染之前的流程!!
在这里插入图片描述

通过绘制过程图我们能归纳一下:

1、当调用[UIView setNeedsDisplay]时,实际上会直接调用底层layer的同名方法
调用[layer setNeedsDisplay];
2、然后会被Core Animation捕获到layer-tree的变化, 提交一个CATransaction , 然后触发Runloop的Observer回调,在回调中调用[CALayer display]进行当前视图的真正绘制流程. 这一步可以参考上面3 Runloop中触发渲染的过程;
3、[CALayer display]内部会先判断这个layer的delegate是否会响应displayLayer:方法,如果不响应就会进入系统绘制流程中。如果能够响应,实际上是提供了异步绘制的入口,也就是给我们进行异步绘制留有余地;

CoreGraphic的 API是线程安全的, 只要 CGBitmapContextCreate 和 endContext在同一个线程

wwdc2012 session 211 building concurrent user interfaces on ios
内部有一个demo, 帮你理解UIkit的渲染, 并且使用异步渲染结合UIImageView去展示复杂渲染逻辑图的实例.


关于layout的更新与layoutSubViews的触发, 当我们调用[UIView setNeedsLayout]
时也会触发[CALayer setNeedsLayout]给layer上打上一个脏标记,runloop在下一次循环时;
会去调用[UIView layoutSubviews]/[CALayer layoutSublayers]. 然后触发CA
Commit中的Layout处理

关于CALayer中渲染被触发的时机(不论是系统渲染 or drawRect渲染), 可以参考博主在
UIView/CALayer渲染的触发时机 (juejin.cn) 中实践的逻辑

系统绘制的流程
本质是创建一个 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, 通过rendserver交给GPU去渲染,将backing store中的bitmap数据显示在屏幕上。

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

参考文献

  1. iOS渲染全解析
  2. iOS 界面渲染与优化(一) - CPU与GPU干了啥事儿
  3. iOS界面渲染与优化(二) - UIView与渲染
  4. 深入理解runloop
  5. iOS-Runloop常驻线程/性能优化
  6. iOS界面渲染流程分析
  7. iOS UIView绘制(三)从Layout到Display
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Albert_YuHan

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值