当你被问到下面问题,你能够回答出来么? 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渲染全流程
整个流水线一共有下面几个步骤:
(1) Handle Events:
这个过程中会处理一些触摸事件,这些事件可能会改变页面的布局和界面层次,比如:
- 创建和调整视图层级, 例如 addSubView, removeSubView等
- 设置 UIView 的 frame, 调制autolayout约束等
- 修改 CALayer 的透明度
- 为视图添加一个动画
- 其他可能导致 CALayer - Tree 变化的操作;
(2) Commit Transaction:
当上面的这些操作引起layer tree发生变化时,会隐式地生成一个事务transaction。整个transaction中,又包括Layout、Display、Prepare、Commit 等四个具体的操作。
Layout:构建视图
这个阶段主要处理视图的构建和布局,具体步骤包括:
- 调用重载的 layoutSubviews 方法
- 创建视图,并通过 addSubview 方法添加子视图
- 计算视图布局,即所有的 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?
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读取展示