1、RunLoop 的概念
我们都知道,APP 运行过程中有一个很重要的线程,就是主线程。但是,一般线程执行完任务后就会退出,而 APP 需要持续运行,所以就需要一个机制使主线程持续运行并随时处理用户事件,在 iOS 里,程序的持续运行就是通过 RunLoop 实现的。
RunLoop 的作用:
保持程序持续运行;
程序一启动就会开启一个主线程,主线程开启之后会自动运行一个主线程对应的 RunLoop,RunLoop 保证主线程不会被销毁,也就保证了程序的持续运行;处理 App 中的各种事件
比如:触摸事件,定时器事件等;节省 CPU 资源,提高程序性能
程序运行起来时,当什么操作都没有做的时候,RunLoop 就告诉 CPU,现在没有事情做,我要去休息,这时 CPU 就会将其资源释放出来去做其他的事情,当有事情做的时候 RunLoop 就会立马起来去做事情;
在 iOS 中,NSRunLoop 是基于 CFRunLoopRef 的封装,提供了面向对象的 API,但是这些 API 不是线程安全的。CFRunLoopRef 是在 CoreFoundation 框架内的,它提供了纯 C 函数的 API,CFRunLoopRef 这些 API 都是线程安全的,Apple 在其文档上对 NSRunLoop 非线程安全的提示:
通常不将 RunLoop 类视为线程安全的,并且只能在当前线程的上下文中调用其方法。永远不要尝试调用在不同线程中运行的 RunLoop 对象的方法,因为这样做可能会导致意外的结果。
CFRunLoopRef 是开源的,源码下载地址:http://opensource.apple.com/tarballs/CF/
为了源码阅读更容易,便于理解 RunLoop 代码关键逻辑,本文所贴出的源码部分有删减,只留下了关键部分。
2、RunLoop 的数据结构
在 CoreFoundation 中,RunLoop 主要有 5 个类:
- CFRunLoopRef
- CFRunLoopModeRef
- CFRunLoopSourceRef
- CFRunLoopTimerRef
- CFRunLoopObserverRef
(1) CFRunLoopRef
NSRunLoop 是基于 CFRunLoopRef
封装的,提供了面向对象的 API,接下来看下 NSRunLoop(即 CFRunLoopRef
)的数据结构:
1 | typedef struct __CFRunLoop * CFRunLoopRef; |
根据以上源码可知,RunLoop 也是一个结构体,即 __CFRunLoop
,并且可以看到其中几个关键的成员变量:
_commonModes
RunLoop 的内容发生变化时,RunLoop 会自动将_commonModeItems
里的 Source/Observer/Timer 同步到_commonModes
中所有Mode
里。主线程的 RunLoop 中kCFRunLoopDefaultMode
和UITrackingRunLoopMode
都已经被标记为“Common”。
我们可以通过下面方法把一个Mode
标记为 “Common”:CFRunLoopAddCommonMode(CFRunLoopRef runloop, CFStringRef modeName);
_commonModeItems
添加 mode item 的时候,如果modeName
传入NSRunLoopCommonModes
,则该 mode item 会被保存到 RunLoop 的_commonModeItems
中,例如:[[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
_currentMode
runloop 当前所在mode
_modes
存放mode
的集合
也就是说,RunLoop 可以有多个 mode(CFRunLoopModeRef) 对象,但是同一时间只能运行某一种特定的 Mode。
CFRunLoop 对外暴露的管理 Mode 接口只有下面 2 个:
1 | CFRunLoopAddCommonMode(CFRunLoopRef runloop, CFStringRef modeName); |
Mode 暴露的管理 mode item 的接口有下面几个:
1 | CFRunLoopAddSource(CFRunLoopRef rl, CFRunLoopSourceRef source, CFStringRef modeName); |
只能通过 mode name 来操作内部的 mode,当你传入一个新的 mode name 但 RunLoop 内部没有对应 mode 时,RunLoop 会自动创建对应的 CFRunLoopModeRef。对于一个 RunLoop 来说,其内部的 mode 只能增加不能删除。
(2) CFRunLoopModeRef
CFRunLoopModeRef
其实是指向 __CFRunLoopMode
结构体的指针,其源码如下:
1 | typedef struct __CFRunLoopMode *CFRunLoopModeRef; |
从以上源码可知,每个 mode 对象中,可以存储多个 source、observer、timer(source/observer/timer 被统称为 mode item)。
系统默认注册了 5 个 Mode:
kCFRunLoopDefaultMode
App的默认 Mode,通常主线程是在这个 Mode 下运行的,是大多数操作使用的模式。一般情况下,使用此模式来启动运行循环并配置输入源。UIInitializationRunLoopMode
在刚启动 App 时第进入的第一个 Mode,启动完成后就不再使用。UITrackingRunLoopMode
界面跟踪 Mode,用于 ScrollView 追踪触摸滑动,保证界面滑动时不受其他 Mode 影响。GSEventReceiveRunLoopMode
接受系统事件的内部 Mode。kCFRunLoopCommonModes
这是一个占位用的 Mode,作为标记kCFRunLoopDefaultMode
和 UITrackingRunLoopMode
用,并不是一种真正的 Mode
(3) CFRunLoopSourceRef
CFRunLoopSourceRef
是事件源(输入源)。其分为 source0
和 source1
:
source0
非基于 port 的,接收点击事件,触摸事件等 APP 内部事件,也就是用户触发的事件。这种 source 是不能主动唤醒 RunLoop 的。
使用时,需要先调用 :CFRunLoopSourceSignal(source)
将这个 Source 标记为待处理,然后再调用:CFRunLoopWakeUp(runloop)
来主动唤醒 RunLoop,让其处理这个事件。source1
基于 Port 的,能主动唤醒 RunLoop,通过内核和其他线程通信,接收分发系统事件;触摸硬件,通过 Source1 接收和分发系统事件到 Source0 处理。
关于 Port 内容后文会进行总结。
CFRunLoopSourceRef
源码如下:
1 | typedef struct __CFRunLoopSource * CFRunLoopSourceRef; |
(4) CFRunLoopObserverRef
CFRunLoopObserverRef
是观察者,每个 Observer
都包含了一个回调(函数指针),当 RunLoop 的状态发生变化时,观察者就能通过回调接受到这个变化。可以观测的时间点有以下几个:
1 | typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) { |
例如,监听 RunLoop 的状态:
1 | // 创建observer |
(5) CFRunLoopTimerRef
CFRunLoopTimerRef
是基于时间的触发器,我们常用的 NSTimer
其实就是 CFRunLoopTimerRef
,他们之间是 toll-free bridged 的,可以相互转换。其包含一个时间长度和一个回调(函数指针)。
当其加入到 RunLoop 时,RunLoop 会注册对应的时间点,当时间点到时,RunLoop 会被唤醒以执行那个回调。
总结:
一个 RunLoop 中,只能对应一个线程,但是可以包含多个 Mode,每个 mode,可以包含多个 source、observer、timer,其关系如下:
- RunLoop 启动时只能选择其中一个 Mode,作为 currentMode。
- 如果需要切换 Mode,只能退出当前 Loop,再重新选择一个 Mode 进入。
- 不同 Mode 的 Source0/Source1/Timer/Observer 能分隔开来,互不影响。
- 如果 Mode 里没有任何 Source0/Source1/Timer/Observer,RunLoop 会立马退出。
3、RunLoop 的执行流程
当 APP 没有任何任务的时候,RunLoop 会进入休眠,RunLoop 就告诉 CPU,现在没有事情做,我要去休息,这时 CPU 就会将其资源释放出来去做其他的事情。当下次有任务的时候,例如用户点击了屏幕,RunLoop 就会结束休眠开始处理用户的点击事件。所以,为了看到 RunLoop 执行流程,可以在点击事件里加个断点,查看 RunLoop 相关的方法调用栈:
根据上图发现,分析 RunLoop 执行流程,可以从 CFRunLoopRunSpecific
、__CFRunLoopRun
函数入手,而对 CFRunLoopRunSpecific
函数的调用,可以在源码中找到,是在 CFRunLoopRun
函数中,源码如下:
1 | void CFRunLoopRun(void) { /* DOES CALLOUT */ |
由以上源码可知:
- 默认底层是通过
CFRunLoopRun
开启 RunLoop 的,并且超时时间设置的非常大:1.0e10,可以理解为不超时。 - 我们也可以通过
CFRunLoopRunInMode
函数设置自定义启动方式,可以自定义超时时间、mode。
然后进入 CFRunLoopRunSpecific
函数,这是 RunLoop 的核心逻辑:
1 | SInt32 CFRunLoopRunSpecific(CFRunLoopRef rl, CFStringRef modeName, CFTimeInterval seconds, Boolean returnAfterSourceHandled) { { |
由以上源码可知,RunLoop 内部是一个 do-while 循环;当调用 CFRunLoopRun()
时,线程就会一直停留在这个循环里;直到超时或被手动停止,该函数才会返回。
RunLoop 执行流程可用下面这张图概括:
通过上面的执行流程可以发现,RunLoop 处理了很多次 Block,即调用了很多次 __CFRunLoopDoBlocks
,那这里处理的 Block 到底是什么 Block 呢?
前面提到了 __CFRunLoop
结构体中的一些常见成员,其实还有两个和 Block 相关的成员:
1 | struct __CFRunLoop { |
_blocks_head
和 _blocks_tail
就是用于存放 CFRunLoopPerformBlock
函数添加的 Block 的,可见 RunLoop 是将添加的 Block 任务保存在双向链表中的。
我们可以通过 CFRunLoopPerformBlock
将一个 Block 任务加入到 RunLoop:
1 | void CFRunLoopPerformBlock(CFRunLoopRef rl, CFTypeRef mode, void(block)( void)); |
可以看出添加 Block 任务的时候,是绑定到某个 runloop mode 的。调用上面的 api 之后,runloop 在执行的时候,会通过如下 API 执行对应 mode 中所有的 block:
1 | __CFRunLoopDoBlocks(rl, rlm); |
需要注意的是,CFRunLoopPerformBlock
不会主动唤醒 RunLoop,添加完 Block 之后可以使用 CFRunLoopWakeUp
来主动唤醒 RunLoop。
4、RunLoop 与线程的关系
CFRunLoop
是基于 pthread
来管理线程的,苹果不允许直接创建 RunLoop,它只提供了两个自动获取的函数:CFRunLoopGetMain()
和 CFRunLoopGetCurrent()
。 这两个函数内部的逻辑大致如下:
1 | // 获得当前线程的 RunLoop 对象,内部调用 _CFRunLoopGet0 函数 |
通过源码分析可以看出,线程和 RunLoop 之间是一一对应的,其关系是保存在一个 Dictionary 字典里。
所以我们创建子线程 RunLoop 时,只需在子线程中获取当前线程的 RunLoop 对象即可 [NSRunLoop currentRunLoop]
。如果不获取,那子线程就不会创建与之相关联的 RunLoop,并且只能在一个线程的内部获取其 RunLoop。
当通过调用 [NSRunLoop currentRunLoop]
方法获取 RunLoop 时,会先看一下字典里有没有子线程对应的 RunLoop,如果有则直接返回 RunLoop,如果没有则会创建一个,并将与之对应的子线程存入字典中。当线程结束时,RunLoop 会被销毁。
Runloop 与线程的关系总结:
- 每条线程都有唯一的一个与之对应的 RunLoop 对象;
- RunLoop 保存在一个全局的 Dictionary 里,线程作为 key,RunLoop 作为 value
- 调用
[NSRunLoop currentRunLoop]
方法获取 RunLoop 时,会先看一下字典里有没有子线程对应的 RunLoop,如果有则直接返回 RunLoop,如果没有则会创建一个,并将对应关系保存到字典里。 - 主线程的 RunLoop 已经自动创建好了,子线程的 RunLoop 需要主动创建;
- RunLoop 在第一次获取时创建,在线程结束时销毁;
5、Runloop 的启动与退出
(1) 创建 Runloop
无法直接创建 RunLoop,但是 RunLoop 在第一次获取时自动创建,获取 RunLoop:
1 | Foundation |
(2) 启动 Runloop
Apple 把 Runloop 启动方式分成了三种:
- 无条件地(Unconditionally)
- 有时间限制(With a set time limit)
- 指定 Mode(In a particular mode)
这三种方式分别对应下面三个方法:
1 | - (void)run; |
- 第 1 种方式,本质就是在
NSDefaultRunLoopMode
模式下无限循环调用runMode:beforeDate:
方法,在此期间会处理来自输入源的数据; - 第 2 种方式,本质也是在
NSDefaultRunLoopMode
模式下无限循环调用runMode:beforeDate:
方法,区别在于它达到指定的超时时间后就不会再调用,在此期间会处理来自输入源的数据。 - 第 3 种方式,Runloop 只会运行一次,达到指定超时时间或者第一个 input source 被处理,则 Runloop 就会退出,这个方法会阻塞当前线程,直到返回结果(YES:输入源被处理或者达到指定的超时值,NO:没有启动成功)。
(3) 退出 Runloop
相较于 Runloop 的启动,它的退出就比较简单了,只有两种方法:
- 设置超时时间
- 手动结束
针对前面提到的第 2、3 中启动方式,可以直接设置超时时间控制退出。如果想要手动退出,可以使用下面函数,其参数就是 Runloop 对象:
1 | void CFRunLoopStop(CFRunLoopRef rl) |
但是 Apple 文档中在介绍利用 CFRunLoopStop()
手动退出时提到:
The difference is that you can use this technique on run loops you started unconditionally.
这里的解释非常容易产生误会,如果在阅读时没有注意到 exit 和 terminate 的微小差异就很容易掉进坑里,因为在 run 方法的文档中还有这句话:
If you want the run loop to terminate, you shouldn’t use this method
也就是说,前面三种 Runloop 启动方式,对应退出方式如下:
run
无法退出runUntilDate:
只能通过设置超时时间进行退出runMode:beforeDate:
可以通过设置超时时间或者使用CFRunLoopStop
方法来退出
CFRunLoopStop()
函数只会结束当前的 runMode:beforeDate:
调用,而不会结束后续的调用,这也就是为什么 Runloop 的文档中说 CFRunLoopStop()
可以 exit(退出) 一个 Runloop,而在 run 等方法的文档中又说这样会导致 Runloop 无法 terminate(终结)。
如果既让 Runloop 长时间运行,又要在必要时刻手动退出 Runloop,Apple 官方文档提供了推荐方式:
1 | BOOL shouldKeepRunning = YES; // global |
在对应线程中通过如下逻辑退出 Runloop:
1 | shouldKeepRunning = NO; |
6、RunLoop 的底层实现
(1) RunLoop 与 mach port
Apple 将 iOS 系统大致划分为下面 4 个层次:
Darwin 的架构如下:
Darwin 是 macOS 和 iOS 操作环境的操作系统部分,Darwin 是一种类 Unix 操作系统(即 Unix 衍生出的系统,在一定程度上继承了原始 Unix 特性),Darwin 的内核是 XNU,XNU 是 Apple 开发的用于 macOS、iOS、tvOS、watchOS 操作系统的内核,XNU 是 X is Not Unix 的缩写。它是一个宏内核 BSD 与微内核 Mach 混合内核,以期将两者的特性兼收并蓄,同时拥有两种内核的优点。
关于 iOS 系统架构相关更多内容,可以看下我的这篇博客:《深入解析 iOS 系统架构》
Mach:
Mach 是一个由卡内基梅隆大学开发的计算机操作系统微内核,Mach 核心之上可平行运行多个操作系统,XNU 内核以一个被深度定制的 Mach 内核作为基础。Mach 提供了诸如处理器调度、IPC (进程间通信)等少量且不可或缺的基础 API。在 Mach 中,所有东西都是“对象”,进程(在 Mach 中称为任务)、线程和虚拟内存都是对象。但是,在 Mach 架构中,对象间不能相互调用,对象间通信只能通过消息传递。“消息”是 Mach 中最基础的概念,消息在两个端口 (port) 之间传递,这就是 Mach 的 IPC (进程间通信) 的核心。
BSD:
XNU 中的 BSD 代码来自 FreeBSD 内核,FreeBSD 是一种开放源代码的类 Unix 的操作系统,基于 BSD Unix 的源代码派生发展而来。BSD 层确保了 Darwin 系统的 UNIX 特性,真正的内核是 Mach,但是对外部隐藏。BSD 提供了更高层次的抽象 API,例如:基于 Mach 的任务之上的 Unix 进程模型、文件系统、网络协议栈等相关 API。
I/O Kit:
I/O Kit 为设备驱动提供了一个面向对象(C++)的一个框架,框架提供每种设备驱动需要的常见特性,以使驱动程序可以用更少的时间和代码完成。
用户态与内核态:
内核控制着操作系统最核心的部分,为了防止应用程序崩溃而导致的内核崩溃,内核与应用程序之间需要进行严格的分离。基于软件的分离会产生巨大的开销,因此现代的操作系统都是依靠硬件来分离。分离的结果就是用户态与内核态。
用户态和内核态的切换有两种类型:
- 自愿转换:比如系统调用;
- 非自愿转换:当发生异常、中断或处理器陷阱的时候,代码的执行会被挂起,并且保留发生错误时候的完整状态。控制权被转交给预定义的内核态错误处理程序或中断服务程序。
在 XNU 中,系统调用有四种类别:
- BSD 系统调用
- Mach 陷阱
- 机器相关调用
- 诊断调用
Mach 消息的发送和接收都是通过同一个 API 函数 mach_msg()
进行的,这个函数在用户态和内核态都有实现。mach_msg()
函数调用了一个 Mach 陷阱(trap),在用户态调用 mach_msg_trap()
会引发陷阱机制,切换到内核态,在内核态中,内核实现的 mach_msg()
会完成实际的工作,如下图:
RunLoop 的核心就是基于 mach port 的,其进入休眠时调用的函数是 mach_msg()
,RunLoop 调用这个函数去接收消息,如果没有别人发送 port 消息过来,内核会将线程置于等待状态。
前面提到的 source1
就是基于 mach port 的,它用来接收系统事件。当对应系统事件发生后(例如用户点击了屏幕),最终会通过 mach port 将事件转发给需要的 App 进程。随后苹果注册的那个 source1
就会触发回调,RunLoop 被唤醒,APP 开始处理对应事件。
(2) RunLoop 输入源
Runloop 作为线程的入口用来响应传入事件,Runloop 从两种不同类型的源接收事件:
输入源(Input Source)
用于传输异步事件,通常是来自另一个线程或者其他程序的消息。输入源将异步事件传递给相应的处理程序,并调用 runUntilDate: 方法(在线程的关联 NSRunLoop 对象上调用)退出。定时器源(Timer Source)
提供同步事件,预定的时间或者固定的时间间隔重复执行,计时器源将事件传递给其处理程序,但不会导致 Runloop 退出。
输入源(Input Source)
创建输入源时,可以将其分配给 Runloop 的一种或多种 mode。一般情况下应该在默认模式下运行 Runloop,但也可以指定自定义 mode。如果输入源不在当前监视的 mode 下,则它生成的任何事件都将保留,直到 Runloop 以正确的 mode 运行,输入源主要有:基于的端口的输入源、自定义输入源、Perform Selector 源。
基于的端口的输入源(Port-based Source)
监听应用程序的 Mach 端口,由内核自动发出信号,对应源码中的 source1
。
Cocoa 和 Core Foundation 都提供了创建基于的端口输入源相关的对象和函数,如果使用 Cocoa 提供的相关方法,不需要直接创建输入源,可以使用 NSPort
相关的方法来创建一个 Port
对象,并将该对象添加到 Runloop 中,该 Port 对象会负责创建和配置输入源。使用 Core Foundation 函数实现稍微复杂些,我们需要手动的创建 Port 和它的 Runloop 源。使用 CFMachPortRef
, CFMessagePortRef
, 或者 CFSocketRef
函数来创建适当地对象。
例如:
1 | - (void)testsource1 { |
打印结果:
1 | RunLoopTest[10368:5612468] 收到消息了,线程为:<NSThread: 0x6000015acf80>{number = 6, name = (null)} |
自定义输入源(Custom input Source)
监听自定义事件源,必须从另一个线程手动发信号通知自定义源,对应源码中的 source0
。
可以使用 CoreFoundation
中 CFRunLoopSourceRef
相关的函数来创建自定义输入源,可以使用多个回调函数配置自定义输入源,CoreFoundation 在必要时候调用这些函数来配置 source
,处理传入的事件,并在从 Runloop 中移除 source
时将其移除。
除此之外,还需要定义事件的传递机制,这部分是运行在单独的线程上,负责向输入源提供数据并在适当的时候发出信号,事件的传递机制可自行定义。
例如:
1 | @implementation ViewController{ |
打印结果:
1 | RunLoopTest[10630:5649707] starting thread....... |
Perform Selector 源(Cocoa Perform Selector Source)
除了基于端口的源外,Cocoa 还定义了一个自定义输入源,允许在任何线程上 Perfrom Selector。与基于端口的源一样,Perfrom Selector 请求在目标线程上序列化,缓解了在一个线程上运行多个方法时可能出现的许多同步问题。与基于端口的源不同的是,Perform Selector 源在 Perfrom Selector 后会从 Runloop 中删除自己。
在另一个线程上 Perfrom Selector 时,目标线程必须具有活动的 Runloop。这意味对于我们创建的子线程,需要显式创建 Runloop。由于主线程的 Runloop 是自动创建的,所以可以在 applicationDidFinishLaunching:
方法后随时 Perfrom Selector。Runloop 每次循环时,Runloop 都会处理所有的 Perfrom Selector 调用,而不是在每次循环时都只处理一个。
在其他线程上 Perfrom Selector 的相关方法:
1 | // 在主线程的下一个 Runloop 周期中,执行指定的选择器。这些方法允许您选择阻塞当前线程,直到执行选择器结束。 |
计时器源(Timer Source)
定时器源在预定时间内同步地将事件传递给线程,定时器可以让线程在对应时刻通知自己执行一些事务,尽管定时器是基于时间的通知方式,但是并不是真的时间机制。就像输入源,定时器在 Runloop 中也是和特定 Mode 相关联的。如果定时器没有处在 Runloop 正在监视的 Mode 中的话,该定时器是不会触发的。必须要等到 Runloop 在定时器支持的 Mode 中运行时,该定时器才会正常运行。如果定时器被触发时机正好是在 Runloop 执行任务中,那么这个定时器源的相关事件只有在 Runloop 下一次运行循环时才能被执行。如果 Runloop 停止运行,那么该定时器源的事件将永远没办法执行。
反复执行的定时器会根据它的触发时间自动配置,并不是真实的触发时间。例如,一个定时器设置的是每 5 秒触发一次,在真实时间上可能是有点延迟的,如果真实时间的延迟大于定时器触发时间的话,那么这次触发时机将被错过。
创建定时器源,可以使用下面两个方法:
1 | scheduledTimerWithTimeInterval:target:selector:userInfo:repeats: |
上面这两个方法创建了定时器并添加到当前线程的默认 mode (NSDefaultRunLoopMode
)中,但是也可以通过 NSRunLoop
的下面的实例方法来将 NSTimer
对象添加到其他 mode 中:addTimer:forMode:
例如,下面两种实现方式效果是一样的:
1 | /// 分开处理,我们可以通过更多的自定义方式来处理timer,比如添加到不同的NSDefaultRunLoopMode。 |
7、RunLoop 目前的应用
(1) AutoreleasePool
- RunLoop 的进入的时候会调用
objc_autoreleasePoolPush()
创建新的自动释放池。 - RunLoop 的进入休眠的时候会调用
objc_autoreleasePoolPop()
和objc_autoreleasePoolPush()
销毁自动释放池、创建一个新的自动释放池。 - RunLoop 即将退出时会调用
objc_autoreleasePoolPop()
释放自动自动释放池内对象。
(2) 事件响应
苹果注册了一个 Source1
(基于 mach port 的) 用来接收系统事件,当一个硬件事件(触摸/锁屏/摇晃等)发生后,首先由 IOKit.framework 生成一个 IOHIDEvent
事件并由 SpringBoard 接收。SpringBoard 只接收按键(锁屏/静音等)、触摸、加速、接近传感器等几种 Event,随后用 mach port 转发给需要的 App 进程。随后苹果注册的那个 Source1
就会触发回调,并调用 _UIApplicationHandleEventQueue()
进行应用内部的分发。
_UIApplicationHandleEventQueue()
会把 IOHIDEvent
处理并包装成 UIEvent
进行处理或分发,其中包括识别 UIGesture
/处理屏幕旋转/发送给 UIWindow
等。通常事件比如 UIButton
点击、touchesBegin
/Move
/End
/Cancel
事件都是在这个回调中完成的。
(3) 手势识别
当上面的 _UIApplicationHandleEventQueue()
识别了一个手势时,其首先会调用 Cancel 将当前的 touchesBegin
/Move
/End
系列回调打断。随后系统将对应的 UIGestureRecognizer
标记为待处理。
苹果注册了一个 Observer 监测 BeforeWaiting
(Loop 即将进入休眠) 事件,这个 Observe r的回调函数是 _UIGestureRecognizerUpdateObserver()
,其内部会获取所有刚被标记为待处理的 GestureRecognizer
,并执行 GestureRecognizer
的回调。
当有 UIGestureRecognizer
的变化(创建/销毁/状态改变)时,这个回调都会进行相应处理。
(4) 界面更新
当在操作 UI 时,比如改变了 Frame、更新了 UIView
/CALayer
的层次时,或者手动调用了 UIView/CALayer
的 setNeedsLayout
/setNeedsDisplay
方法后,这个 UIView
/CALayer
就被标记为待处理,并被提交到一个全局的容器去。
苹果注册了一个 Observer 监听 BeforeWaiting
(即将进入休眠) 和 Exit (即将退出Loop) 事件,回调去执行一个很长的函数:_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv()
。这个函数里会遍历所有待处理的 UIView
/CAlayer
以执行实际的绘制和调整,并更新 UI 界面。
这个函数内部的调用栈大概是这样的:
1 | _ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv() |
(5) 定时器
NSTimer
其实就是 CFRunLoopTimerRef
,他们之间是 toll-free bridged 的。一个 NSTimer
注册到 RunLoop 后,RunLoop 会为其重复的时间点注册好事件。例如 10:00, 10:10, 10:20 这几个时间点。RunLoop 为了节省资源,并不会在非常准确的时间点回调这个Timer。Timer 有个属性叫做 Tolerance (宽容度),标示了当时间点到后,容许有多少最大误差。
如果某个时间点被错过了,例如执行了一个很长的任务,则那个时间点的回调也会跳过去,不会延后执行。就比如等公交,如果 10:10 时我忙着玩手机错过了那个点的公交,那我只能等 10:20 这一趟了。
(6) PerformSelector
当调用 NSObject
的 performSelecter:afterDelay:
后,实际上其内部会创建一个 Timer
并添加到当前线程的 RunLoop 中。所以如果当前线程没有 RunLoop,则这个方法会失效。
当调用 performSelector:onThread:
时,实际上其会创建一个 Timer
加到对应的线程去,同样的,如果对应线程没有 RunLoop 该方法也会失效。
performSelector:withObject:
只是发消息,不会有 Timer ,所以不会有上面的问题,在子线程调用,不需要开启 Runloop
(7) 关于 GCD
根据前面 RunLoop 的执行流程可以知道,GCD 也是可以唤醒 RunLoop 的,GCD 由子线程返回到 主线程,只有在这种情况下才会触发 RunLoop,会触发 RunLoop 的 Source 1
事件:
1 | dispatch_async(dispatch_get_main_queue(), ^{ |
当调用 dispatch_async(dispatch_get_main_queue(), block)
时,libDispatch
会向主线程的 RunLoop 发送消息,RunLoop 会被唤醒,并从消息中取得这个 block,并在回调 CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE()
里执行这个 block。但这个逻辑仅限于 dispatch
到主线程,dispatch
到其他线程仍然是由 libDispatch
处理的。
(8) 关于网络请求
iOS 中,关于网络请求的接口有如下几层:CFSocket
是最底层的接口,只负责 socket 通信。
CFNetwork
是基于CFSocket
等接口的上层封装,ASIHttpRequest
工作于这一层。NSURLConnection
是基于CFNetwork
的更高层的封装,提供面向对象的接口,AFNetworking 工作于这一层。
+ NSURLSession
是 iOS7 中新增的接口,表面上是和 NSURLConnection
并列的,但底层仍然用到了 NSURLConnection
的部分功能 (比如 com.apple.NSURLConnectionLoader
线程),AFNetworking 2 和 Alamofire 工作于这一层。
下面主要介绍下 NSURLConnection
的工作过程。
通常使用 NSURLConnection
时,你会传入一个 Delegate
,当调用了 [connection start]
后,这个 Delegate
就会不停收到事件回调。实际上,start
这个函数的内部会会获取 CurrentRunLoop
,然后在其中的 DefaultMode
添加了 4 个 Source0
(即需要手动触发的 Source)。其中 CFMultiplexerSource
是负责各种 Delegate
回调的,CFHTTPCookieStorage
是处理各种 Cookie
的。
当开始网络传输时,我们可以看到 NSURLConnection
创建了两个新线程:com.apple.CFSocket.private
和 com.apple.NSURLConnectionLoader
。其中 CFSocket
线程是处理底层 socket 连接的。NSURLConnectionLoader
这个线程内部会使用 RunLoop 来接收底层 socket 的事件,并通过之前添加的 Source0
通知到上层的 Delegate
。NSURLConnectionLoader
中的 RunLoop 接收来自底层 CFSocket
的 Source1
通知。当收到通知后,在合适的时机向 Delegate
线程 RunLoop 发送 CFMultiplexerSource
等 Source0
通知,同时唤醒 Delegate
线程的 RunLoop 来让其处理这些通知。接收到 CFMultiplexerSource
通知后,Delegate 线程的 RunLoop 执行对应 Delegate
回调。
8、Runloop 开发中的使用场景
唯一需要显式运行 Runloop 是在创建子线程时。主线程的 Runloop 已自动创建并运行。对于子线程,需要自行判断是否需要 Runloop,如果需要,则开发者自行创建。例如,下列操作需要启动 Runloop:
- 使用端口或自定义输入源与其他线程通信。
- 在子线程上使用计时器。
- 调用 performSelector… 相关方法。
- 线程保活,以执行周期性任务。
以下是在实际开发是,Runloop 的一些使用场景:
(1) 线程保活
当子线程中的任务执行完毕后,线程就被会被立刻销毁。如果 APP 中需要经常在子线程中执行任务,频繁的创建和销毁线程,会造成资源的浪费,这时候我们就可以使用 Runloop 来让该线程长时间存活而不被销毁,实现如下:
1 | KeepAliveThread.h |
(2) 保证 Timer 正常运行
创建 Timer 有下面两种方式,两种实现方式是等价的:
1 | // 方式 1 |
当滑动 UIScrollView
时,主线程的 RunLoop 会切换到 UITrackingRunLoopMode
这个 Mode,执行的也是 UITrackingRunLoopMode
下的任务(Mode 中的 item),而 Timer 是添加在 NSDefaultRunLoopMode
下的,所以 Timer 任务并不会执行,只有当 UITrackingRunLoopMode
的任务执行完毕,Runloop 切换到 NSDefaultRunLoopMode
后,才会继续执行 Timer。解决方法很简单,我们只需要在添加 Timer 时,将 mode 设置为 NSRunLoopCommonModes
即可:
1 | NSTimer *timer = [NSTimer timerWithTimeInterval:1.0 target:self selector:@selector(timerUpdate) userInfo:nil repeats:YES]; |
如果是在子线程中使用 Timer,由于子线程的 Runloop 并不会自动创建,所以必须在子线程中创建并启动 Runloop,否则 Timer 无法正常运行,创建并启动 Runloop 方法:
1 | [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]]; |
由于子线程中不会涉及到 UI 更新,所以无需再主动将 Timer 添加到 NSRunLoopCommonModes
下。
(3) 利用 Runloop 优化 UITableView 加载图片时滑动卡顿问题
UITableView
滚动时,主线程的 Runloop 会切换到 UITrackingRunLoopMode
这个 Mode,我们可以在 NSDefaultRunLoopMode
中设置图片,避免一边滑动一边设置 image 引起的卡顿问题:
1 | - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { |
(4) 利用 Runloop 监控卡顿
根据 Runloop 的执行流程可以发现,Runloop 对我们业务逻辑的处理时间在两个阶段:
kCFRunLoopBeforeSources
和kCFRunLoopBeforeWaiting
之间kCFRunLoopAfterWaiting
之后
所以,如果主线程 Runloop 处在 kCFRunLoopBeforeSources
时间过长,也就是迟迟无法将任务处理完成,顺利到达 kCFRunLoopBeforeWaiting
阶段,说明发生了卡顿。
同样的,如果 Runloop 处在 kCFRunLoopAfterWaiting
时间过长,也是发生了卡顿。
所以,如果我们要利用 Runloop 来监控卡顿的话,就要关注 kCFRunLoopBeforeSources
和 kCFRunLoopAfterWaiting
两个阶段,一般卡顿时间超过 250ms 会被明显感知,所以,可以以连续 5 次卡顿时长超过 50ms 可以认为发生卡顿,或者根据需要调整统计阀值。以下是通过 Runloop 监听卡顿的一个例子:
1 | @interface LagMonitor() { |
上面只是统计卡顿的基础版本,如果真的使用到项目中上面逻辑还有不少需要优化的地方,例如:
- 避免多次重复上报同一个卡顿堆栈
- 可以先将堆栈保存到内存中,以堆栈栈顶函数为特征,如果相同认为整个堆栈是同一个,不重复上报。
- 准确定位真正卡顿的堆栈
- 假如主线程有三个任务,只有第一个是引起卡顿的任务,当开始上报卡顿时获取到的堆栈可能是后两个不耗时的任务的堆栈。这种情况可以每 50ms 甚至更短时间获取一次堆栈,只保留最近一定数量(例如最近 20 个)堆栈信息,当发生卡顿时相同堆栈数量最多的堆栈就是真正引起卡顿的堆栈。
目前也有一些比较成熟的卡顿监控方案,例如:matrix。
- 本文章采用 知识共享署名 4.0 国际许可协议 进行许可,完整转载、部分转载、图片转载时均请注明原文链接。