-
Notifications
You must be signed in to change notification settings - Fork 145
RunLoop F.A.Q
前不久试图实现一个在后台运行的 NSTimer,解决的过程中顺便把 Runloop 梳理了下。另外,推荐下面两个资料:
- Run Loops 官方资料:详细介绍了 RunLoop 的基础概念、运行机制和使用方法,入门首选,在阅读其他任何有关 RunLoop 的文章遇到难以理解的概念时,可以在这个官方资料里找到最原始、最准确的解释。建议把这个官方的资料读上三遍,再动手写个代码以加强理解。
- 深入理解RunLoop:这篇文章结合 RunLoop 的开源版本代码给出了更多的实现细节,并且讨论了很多 RunLoop 的实际应用。记得看下面的评论,知道了知识点不一定懂得怎么运用以及解决问题,评论里有很多实际的问题可以帮助你更好地理解 RunLoop。
本文主要是记录下自己的理解,并提供了些上面两篇资料没有顾及到的新内容。
问题跳转:
- RunLoop 与 Thread 的关系?
- RunLoop mode 是什么?
- 如何创建一个在后台线程里运行的 NSTimer?
- 如何创建一个常驻后台的线程?
- NSObject 的 performSelector 系列方法...
- 如何切换 RunLoop 的 mode?如何重启 RunLoop?
A: 首先了解下 NSThread 是干嘛的,不必害怕 NSThread,它的易用性实际上和通常的并发首选 GCD 或者 NSOperation 差不多,只不过后两者是对 NSThread 的封装,使用上更加方便,而且能够避免大部分使用 NSThread 时的陷阱,关于这点可以看并发编程:API 及挑战中的讨论。不过要了解 RunLoop 我们就必须得了解 NSThread,开头给出的官方资料其实是 Threading Programming Guide 中的一个章节。
不管是 GCD, NSOperation,抑或是 NSThread 的底层 pthread,我们用多线程最终是为了运行你的代码而已,等任务代码运行完毕,这个线程就无法再次使用了,来看下面的例子:
// NSThread 还提供了两个基于 selector 的方法,可以避免子类化。
class SEThread: Thread {
// 任务代码放在 main() 方法里
override func main() {
// 打印 log 来说明当前代码是否运行在主线程
NSLog("\(Thread.current) is main thread: \(Thread.isMainThread)")
}
}
func useAThread(){
let thread = SEThread.init()
// 启动线程,这个方法调用main()并维护相关状态,实际上调用start()
// 才会真正分配资源生成线程,文档里明确告诉我们是这么做的。
thread.start()
}
让一段代码在 NSThread 子类对象里执行,条件是很苛刻的:只有main()
运行在分配的线程里,而且必须通过start()
启动该线程,这点可通过配合上面的 log 来验证,因此必须在main()
里调用这段代码。如果你维护一个上面线程的引用,在其他地方再次start()
,应用就直接 crash 了,唯一的提示是你尝试再次启动这个线程,也就是说在线程的生命周期里只能启动一次。所以,NSThread 就是个一次性的资源。
那么有没有这么一种机制,保留 NSThread 的资源,只在需要的时候使用呢?RunLoop 就是为此设计的。RunLoop 就是一个无限循环,停留在main()
里,通过添加 Sources(比如 NSTimer),姑且称之为事件源,有事件发生时就唤醒线程(和通知机制类似)来干活(干活本质上还是运行一段代码,这段代码可以有多种来源),活干完了就让线程休眠,然后 RunLoop 也进入休眠状态,等待下个事件到来后唤醒线程干活,如此循环,具体的处理流程在官方资料里有详细的描述。我想了很多现实里的关系来比喻这两者,但没有很合适的。
总结下:NSThread 有两种工作方式,一种是直接运行任务代码,比如上面的例子,比如通过 GCD 的 Block 提供任务代码,是个一次性的资源,用完即废;另一种就是搭配 RunLoop,需要处理事件时被 RunLoop 唤醒执行代码,执行完毕后进入休眠,按需使用,只要 RunLoop 还存在,线程就可以一直提供服务。
那么这两种方式能不能结合起来呢,一举两得?写个代码测试下。
override func main() {
// NSTimer 的另外一种使用方法,timer 绑定的方法将会在当前线程里按照设定的时间执行
RunLoop.current.add(timer, forMode: .defaultRunLoopMode)
// 除了主线程的 RunLoop,其他线程的 RunLoop 必须手动启动
RunLoop.current.run()
// 任务代码,只是在当前线程里循环1000000次。
for i in 0..<1000000 {
if i % 100000 == 0{
NSLog("Iterate to \(i)")
}
}
}
RunLoop 运行后,代码就一直停留在运行这里无限循环,正如其名,其实还可以指定超时时间,到达时间后自动退出循环。如果 RunLoop 的事件源被移除或者结束后,RunLoop 无事可干,就会退出循环。在上面的代码里,如果 timer 是重复执行的,在 timer 结束前(可以使用invalidate()
来停止),RunLoop 不会退出循环,下面的任务代码永远不会被执行。所以,结合这两种方式是没有意义的:要么直接运行一段任务代码,结束后不再使用该线程;要么配合 RunLoop,有活干活,没活休眠。
RunLoop 无法通过 init 的方式生成,只能通过 RunLoop 类的类变量获取:
// 这两者在 Core Foundation 下都有对应的方法: CFRunLoopGetCurrent(), CFRunLoopGetMain()
class var current: RunLoop
class var main: RunLoop
而只有在线程内RunLoop.current
获取的才是那个线程的 RunLoop,线程的内部环境有以下几个入口:
-
NSThread 子类的
main()
里,而且只有通过调用start()
时main()
内部才是这个线程的环境,比如直接调用thread.main()
,这时候main()
内部的线程环境是当前线程,而不是 thread 本身的线程; -
NSThread 两个基于 selector 的 API,调用的 selector 内部;
-
GCD 和 NSOperationQueue 基于 Block 的 API,Block 里面。需要注意的是,GCD 里 sync 一类的方法出于优化的目的,会尽可能在当前线程里执行,这时候 Block 里的线程环境很可能不是你想要的,比如:
DispatchQueue.global().sync { // 这里的线程环境很大可能是调用 DispatchQueue.global().sync{} 这条语句所在的线程 // 这一段代码和直接写成RunLoop.current.run()无异 RunLoop.current.run() }
NSThread 对 RunLoop 是必需的,但 RunLoop 对 NSThread 并不是必需的,除了主线程,其他线程并不会主动生成 RunLoop,除非你通过RunLoop.current
主动索取,如果当前线程没有 RunLoop,这个方法会自动生成一个,在 NSThread 的生命周期内用的是同一个 RunLoop 对象。
A: 本来这似乎不是个问题,不过当初看到深入理解RunLoop这篇文章的时候,每次看到讲解 RunLoop mode 的时候我就晕了,但我这次直接看了官方资料的解释后立马就理解了,可能每个人对其他人的概念解释的接受度不一样,但几乎每次看不懂中文版本的概念解释时,回头看原始版本的解释就明白了。如果你看这篇文章有概念不清楚的地方,去看官方资料。
RunLoop 和事件源的关系与通知机制类似,订阅某些对象来获取它们的动态,上一个问答里笼统提到的事件源被 RunLoop 大致分为两类:
右边的 Sources 在官方资料里有详细的分类介绍,其中 Input sources 成分比较复杂,除了 performSelector,其他两个 Port source, Custom source 是一些比较底层的东西,暂时不懂也没有关系(我不懂);而 Timer source 就是 NSTimer。
RunLoop 可以接受多个 sources,不同场景下可能只需要接受某些事件源的信号,怎么办呢?RunLoop mode 就是为了解决这个问题的,将这些事件源任意组合,在运行 RunLoop 时指定接受哪一个组合的信号,这就是 mode 了,打个比方,RunLoop mode 就像早些年功能手机的模式:静音模式,会议模式,等等。在具体的实现中,做法是先建立模式名称,然后往模式里添加事件源。
// RunLoopMode 是一个结构体,defaultRunLoopMode 是其一个预定义的 mode 名
// 这行代码将 timer 添加到这个 mode 下了
RunLoop.current.add(timer, forMode: .defaultRunLoopMode)
// run() 这个方法实际上是调用 run(mode:before:),这里的 mode 参数就是 .defaultRunLoopMode
// 从这行代码开始进入无限循环,到了预定时间点就调用 timer 绑定的方法
RunLoop.current.run()
RunLoop mode 的设计对多个事件源进行了分组隔离,但也带来一个副作用:要想在某个模式下处理某个事件源,必须把这个事件源明确地添加到这个模式里,这有点繁琐。为了解决这个小问题,引入了commonModes
,被添加到这个模式的事件源,可以在所有其他的模式里使用,不必再手动添加一次了。你可能见到这样的技巧,让添加到主线程的 NSTimer 在视图滚动的时候也能触发:
RunLoop.main.add(timer, forMode: .commonModes)
视图滚动时主线程的 RunLoop 会切换到UITrackingRunLoopMode
,由于 timer 被添加到commonModes
,其他任何模式下都会处理这个 timer。
以上出现的三个 mode: defaultRunLoopMode, UITrackingRunLoopMode, commonModes
就是 iOS 平台的预定义模式。RunLoop 也支持自定义模式,虽然只在run(mode:before:)
的文档几个词里提到,还好深入理解RunLoop里明确指出了这点,在run(mode:before:)
直接使用新的自定义的 RunLoopMode 即可,就像 Dictionary 那样,使用自定义模式的代码如下:
let customMode = RunLoopMode.init("CustomMode")
RunLoop.current.add(timer, forMode: customMode)
// 这里指定了 RunLoop 的失效时间,而实际上 .distantFuture 也相当于无穷时间了
RunLoop.current.run(mode: customMode, before: .distantFuture)
自定义模式也能使用添加到commonModes
的所有事件源,但是需要处理下才能享受这个待遇:
// 又是 CF 里的方法,使用起来相当不方便;这行代码让 customMode 下也能处理所有添加到 commonModes 下的事件源。
// 这行代码放哪呢?只要在 RunLoop 运行之前就行了。
CFRunLoopAddCommonMode(RunLoop.current.getCFRunLoop(), CFRunLoopMode.init("CustomMode" as CFString))
注意: 虽然添加到 commonModes 的事件源可以在所有其他的 mode 处理,但是 RunLoop 本身并不能在 commonModes 下运行,RunLoop.current.run(mode: .commonModes, before: .distantFuture) 是无法启动 RunLoop 的。文档里没提到这点,还是蛮坑的。
RunLoop 如何切换 mode 呢?这部分看末尾的回答。
A: 这篇文章的起因就是我要实现这个需求,当初我是这样达到同样的目的的:直接将 timer 添加到主线程里,然后在绑定的方法里使用 GCD 切换到后台线程。为了比较两种方法里的性能差异,我用下面的方法实现了一个直接在后台线程里运行的 NSTimer。
DispatchQueue.global().async {
// sheduledTimer 这个方法将创建的 NSTimer 添加到当前线程的 RunLoop 的 defaultMode 下
Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(timerFire), userInfo: nil, repeats: true)
}
NSTimer 无法触发,即使在代码里手动触发,也只有一次执行。原因在哪里呢?现在我们知道因为这个线程的 RunLoop 没有运行,再添加一行代码即可:
RunLoop.current.run(mode: .defaultRunLoopMode, before: Date.distantFuture)
然而这样的代码还有一个问题:无法停止那个 timer,RunLoop 不会退出循环,进而这个线程永远不会消失。所以我们需要维持一个对 timer 的引用,并且合适的时间停止它,这样 RunLoop 会退出循环,进而线程也会被回收销毁,另外,由于 timer 会保持对 target 的强引用,为了避免引用循环,target 里应该维持一个弱引用。你可以给线程一个名字,在 timer 的 selector 里添加断点来观察这个线程的调用帧以及它的销毁。
不过当初的解决过程并不是这样的,我搜索到了这个页面: Run repeating NSTimer with GCD?,一个近6年前的问题,下面唯一的高票答案虽然本身提供了一些替代方向,不过他最大的错误是认为 timer 无法触发的原因是因为 GCD 派发的 thread 没有 RunLoop,完全是胡说八道,并且他对下面另外一个比较接近的答案(添加了RunLoop.current.run()
)给出了错误的指导意见,让这个答案止步于此。我起初看到这个页面时没有死心,尝试用 NSThread 去解决这个问题,无意中成功了,下面是使用 Thread 的实现:
func configurateTimerInBackgroundThread(){
let thread = Thread.init(target: self, selector: #selector(addTimer), object: nil)
//这里有个问题,这个方法返回后,thread会被回收销毁吗?
//不会,除非 RunLoop 退出循环,这里的达成条件是停止timer。
//查看内存引用图,发现Foundation框架本身会持有新的线程对象,当然
//在RunLoop退出或者没有RunLoop运行的时候,会及时断开引用,
//以便对象被回收销毁,不过,上面提到有时候在模拟器里不起作用。
thread.start()
}
@objc func addTimer() {
weakTimer = Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(timerFire), userInfo: nil, repeats: true)
// 这行关键代码不知道怎么加上去的,那个页面下接近的那个答案我看到投票是为-1就略过了
RunLoop.current.run()
}
既然 NSThread 可以,GCD 没道理不行。抱着这个疑惑,我最终决定还是好好学习下 RunLoop 的官方资料,于是问题解决了。所有的 NSThread 都有对应的 RunLoop(官方资料开头原文是:each thread, including the application’s main thread, has an associated run loop object)。即使是高票答案也不能全信,即使是一个多年经验的工程师,不保持学习是不行的。如果我的文章里有任何错误的地方,请指出来。
解决了这个问题后,我想起来直接搜索这个问题,然后找到了这个: How do I create a NSTimer on a background thread?,同样古老的问题,可以看到多个基于 GCD 的 答案(有个大神也现身了),有些东西我从来没用过,比如 GCD 版本的 timer。如果你是写 Swift 的,看这些答案会很痛苦,因为 GCD 的 API 在 Swift 里有很多版本,而且很多根本就没文档。
A: 这是一个很常见的的问题,几乎所有关于 RunLoop 的文章都会拿 AFNetworking 早期版本的代码做范例。来分析下这个需求,要求线程常驻后台以便响应事件,这个需求嘛,从 RunLoop 的设计来看,线程的设计目标之一就是随时响应事件。现在来讨论下怎样实现是最合适的?
要求线程不退出,那就是保持 RunLoop 不退出循环,让 RunLoop 不退出循环就要求当前运行的模式下持有有效的 sources: Input sources 或者 Timer sources,其中 NSTimer 会定期唤醒线程执行绑定的方法,其实让线程一直保持休眠是最好不过了,只要 source 不触发事件,线程和 RunLoop 就会一直休眠下去,直到被 source 唤醒,那么 Input sources 是比较合适的,Input sources 有三类,其中最合适的是 Port-Based Sources,它的代码最少,AFNetworking 的代码就是这样做的,用 Swift 写就是下面这样:
// 添加一个 mach port,但啥也不做,RunLoop 会一直等待 mach port 发送消息
RunLoop.current.add(NSMachPort.init(), forMode: .defaultRunLoopMode)
RunLoop.current.run(mode: .defaultRunLoopMode, before: .distantFuture)
而另一个 Custom Input Sources 则要多两行代码,而且得用 Core Foundation 框架,示例如下:
// 像上面的 mach port 一样,只是添加一个占位 input source
var sourceContext = CFRunLoopSourceContext.init()
let customSource = CFRunLoopSourceCreate(kCFAllocatorDefault, 0, &sourceContext)
CFRunLoopAddSource(RunLoop.current.getCFRunLoop(), customSource, CFRunLoopMode.defaultMode)
RunLoop.current.run(mode: .defaultRunLoopMode, before: .distantFuture)
深入理解RunLoop 在讲解这个案例时提到:
RunLoop 启动前内部必须要有至少一个 Timer/Observer/Source
Observer 是 RunLoop Observer,看名字就知道是干什么的了,它能跟踪 RunLoop 的运行状态,在 Run Loop Observers 里有详细的描述,也有使用范例。实际上,Observer 并不能维持 RunLoop 的运行,它只是用来跟踪 RunLoop 的状态,官方资料在 Configuring the Run Loop 是这样介绍 RunLoop 的运行条件的:
Before you run a run loop on a secondary thread, you must add at least one input source or timer to it. If a run loop does not have any sources to monitor, it exits immediately when you try to run it.
写个代码测试下就知道了,在 RunLoop 运行前只添加一个 Observer,运行 RunLoop 后根本不能观察到相关状态,因为 RunLoop 根本就没有启动,run(mode:before:)
会返回一个 Bool 值来表示 RunLoop 是否启动成功,用这个方法来启动 RunLoop 就知道结果了。
上面没有提到线程从哪儿获取,NSThread, GCD, NSOperationQueue? 后两者后基于某些规则维护着一个线程池,提交的 Block 不一定能及时分配到线程,使用 NSThread 就没问题了(当然这时候系统本身的资源很可能就不足了,这又是另外一个问题了);另外,日后若是想给这个提交 Block 的线程发送个任务,需要我们为这个线程维护一个引用(强行找理由)。如果是需要这个线程单独处理任务,还是使用 NSThread 比较好。
iOS 似乎不鼓励使用NSMachPort
,它传递的消息NSPortMessage
类只在 macOS 上是公开的;而NSMachPort
的替代者NSMessagePort
的文档里又建议避免使用,这个类基本上也废弃了。在 RunLoop 响应事件的流程里,Custom Input Sources 优先于 Port-Based Sources,而且在 iOS 里也有直接的应用,比如接下来要讲到的 NSObject 的 performSelector 系列方法。Custom Input Sources 本身我还没有深入了解,不懂。
A: 本来假装写成"如何实现的?",但发现这并不是一个好问题,就是用 RunLoop 相关的技术实现的嘛,像 afterDelay 的两个方法,文档里就直接指明使用了 timer。不过随手使用了几个方法后,发现这个系列的方法有很多需要注意的地方。在这系列方法里你可以看到很多深入理解RunLoop里提到的有关代码层次的细节(可通过在 selector 里设置断点来查看帧栈),借此验证下也可以帮助你更好地理解深入理解RunLoop这篇文章,这些细节当然在上面的内容也有,不过放到上面内容就太繁杂了。
官方资料将 Input sources 细分成三个类别:
- Port-Based Sources: 对应深入理解RunLoop里的 Source1,回调函数为
__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__
- Custom Input Sources: 对应 Source0,回调函数为
__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__
- Cocoa Perform Selector Sources:就是 NSObject 的 performSelector 系列。
如下,带 modes 参数的可以指定运行模式,没带的则在.defaultRunLoopMode
模式下运行:
performSelectorOnMainThread:withObject:waitUntilDone:
performSelectorOnMainThread:withObject:waitUntilDone:modes:
performSelector:onThread:withObject:waitUntilDone:
performSelector:onThread:withObject:waitUntilDone:modes:
performSelector:withObject:afterDelay:
performSelector:withObject:afterDelay:inModes:
这里面 afterDelay 的方法基于 Timer,可以算是 Timer sources 一类,而其他的方法基于 Source0,估计官方也不好分类,就把它搁 Input sources 里了。这个系列还有个performSelectorInBackground:withObject:
,这个方法并没有被 RunLoop 官方资料归纳到这部分,查看调用帧栈发现这个方法的确没有用到 RunLoop。
现在我们知道这些方法生效的首要前提当然是线程的 RunLoop 在运行,而且是以方法指定的模式运行,然而线程的 RunLoop 你不主动获取根本不会生成,主线程还好,它的 RunLoop 在应用启动过程中就激活了,而从线程外部是无法获取它的 RunLoop 的,那么有两种用法:一是使用 GCD 或者 NSOperation 中带有 Block 的 API,这样就直接获得了线程所在的环境,由于这些 performSelector 方法本身会给线程添加一个 source,剩下的只需要让 RunLoop 运行即可;二是预先让线程的 RunLoop 运行,使用 NSThread 基于 selector 的 API 或者子类化,像 AFNetworking 那样添加一个 mach port 让 RunLoop 不退出。
onThread 的方法有一个副作用需要特别注意:排他性。如果 RunLoop 在 onThread 方法指定的模式下运行,它会移除 RunLoop 在这个模式下的所有事件源,主线程除外。这到底是个 Bug 还是个 Feature(确保添加的 selector 能够得到执行,我瞎猜的)不得而知,按说这么重要的事情,文档里应该指出来,居然没有,那这很可能是个 Bug。另外,如果你在自定义模式下运行 RunLoop,并且将这个自定义模式通过CFRunLoopAddCommonMode(_:_:)
将其加入.commonModes
,performSelector:onThread:withObject:waitUntilDone:
也会生效,并且移除添加到commonModes
的事件源,不过这种情况下使用带modes
参数的API并指定.defaultRunLoopMode
的同等代码没有这样的效果。
于是使用 onThread 方法时,当 selector 执行完毕后,RunLoop 里已经没有事件源了,直接退出循环;一次性执行多个 onThread 方法则会在全部执行完毕后退出 RunLoop。
深入理解RunLoop 在分析 AFNetworking 的建立一个常驻后台的线程的做法时指出:
当需要这个后台线程执行任务时,AFNetworking 通过调用 [NSObject performSelector:onThread:..] 将这个任务扔到了后台线程的 RunLoop 中。
由于 onThread 方法的特性,这个办法其实只能奏效一次。那么有没有办法可以多次在同一个线程对象上执行这个方法呢?可以的,让线程的 RunLoop 重新启动就可以了,但是 RunLoop 只能从线程的内部获取,怎么解决呢?我们可以利用 RunLoop Observer 为线程实现 RunLoop 自启动的功能,参考下一个回答。
A: 其实后一个问题是前一个问题的子集。
RunLoop 的文档和开头给出的官方资料都没有直接提到如何切换 RunLoop 的 mode。一个正常的想法是:先退出当前模式,然后在run(mode:before:)
里重新选择模式开始新的循环。
如何退出 RunLoop 呢?(退出后不再处理这个模式下的事件)
-
在
run(mode:before:)
里可以指定失效时间,到了指定的时间 RunLoop 会自动退出循环。 -
RunLoop 运行后会检查当前运行模式下的 Input sources 和 Timer sources,如果 Input sources 的数量为0, Timer sources 里有效的 timer 数量也为0,RunLoop 就会退出循环,在上面这个简单的例子里,只要
timer.invalidate()
即可。不过文档指出,移除你已知的事件源并不能保证 RunLoop 退出循环,在 macOS 平台下,出于某些原因,系统会自动添加某些事件源,但没有明确指出其他平台会不会这样做。我在Demo里测试时发现在多个模拟器上这样做无法让 RunLoop 退出循环,而在真机上没有问题,但是呢,同样的手法在我其他项目的模拟器上又没有问题。 -
还可以强制退出 RunLoop,需要使用 Core Foundation 里的方法:
CFRunLoopStop(RunLoop.current.getCFRunLoop())
RunLoop 是 CFRunLoopRef 的封装,但是有不少相关的方法没有封装到 RunLoop 类里,必须使用 CF 框架里的方法,很不方便。
注意:NSThread 在其生命周期内使用的 RunLoop 都是同一个对象,退出后通过RunLoop.current
获取的 RunLoop 并不是重新生成的新对象。
从代码上看,运行 RunLoop 应该是 NSThread 线程入口比如main()
里的最后一行代码,不然后面的代码就没什么意义了。那切换模式的代码在哪里处理呢?
这里就要用到 RunLoop Observer 了,RunLoop 本身并没有提供接口查询它的状态,只能通过添加 observer 来跟踪它的状态。RunLoop Observer 的唯一版本是 Core Foundation 里的 CFRunLoopObserver,有两个方法可以创建 Observer:
CFRunLoopObserverCreateWithHandler
CFRunLoopObserverCreate
建议使用带闭包的方法,另外一个方法里捕获变量非常曲折。
// 放在 main() 里,RunLoop 运行之前
func addObserver(){
let observer = CFRunLoopObserverCreateWithHandler(
kCFAllocatorDefault, //不懂,默认参数就好
CFRunLoopActivity.allActivities.rawValue, //这个值表示观察所有的状态,由于类型不兼容,只能使用原始值
true, //这个参数和NSTimer的repeats 参数一样,
0 // 优先级,如果有多个observer,这个值越小优先级越高)
{ (observer, activity) in
switch activity {
case .entry://进入了RunLoop,开始循环检查
case .beforeTimers://检查timer
case .beforeSources://检查input source,有事件就处理
//如果input sources里没有事件发生并且timer的触发点还没到来,进入休眠
//或者input sources的事件处理完毕并且timer的触发点还没到来,进入休眠
case .beforeWaiting:
//被唤醒,准备处理触发唤醒的事件,比如timer的触发时间点到了,
//或者input sources发来了信号。如果是input sources,从头开始检查一遍
case .afterWaiting:
case .exit://已退出 RunLoop
/.添加 sources,重新(选择模式)启动 RunLoop./
RunLoop.current.run(mode: aMode, before: aDate)
default: break
}
}
//Observer需要指定要观察的模式,RunLoop在这个模式下运行时,发送以上通知给这个observer
//如果想跟踪所有模式,就指定commonModes
CFRunLoopAddObserver(RunLoop.current.getCFRunLoop(), observer, CFRunLoopMode.defaultMode)
}
我建议你添加一个 observer 观察 RunLoop 来验证上面的状态变化,特别是主线程的 RunLoop,你可以看到视图开始滚动以及停止时,RunLoop 会退出然后重新进入新的模式(这就是重启了)。通过实际观察,你会发现官方资料里有些地方写的不是那么完善,比如beforeTimers
这个通知,下面是最清楚的一条解释:
When the run loop is about to process a timer.
这个描述看上去是 timer 的触发时间点到了,RunLoop 要开始处理了,「深入理解RunLoop」在 RunLoop 的内部逻辑对 CFRunLoop 的源码进行了解读,也沿用了这个解释;我这里将其解释为检查timer,做个测试,添加一个 mach port 让 RunLoop 不退出,会发现不管有没有 timer,RunLoop 都会给 observer 发送beforeTimers
通知,回头再来看RunLoop 的内部逻辑里这个简化版本的代码:
/// 2. 通知 Observers: RunLoop 即将触发 Timer 回调。
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeTimers);
/// 8. 通知 Observers: RunLoop 的线程刚刚被唤醒了。
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopAfterWaiting);
/// 9.1 如果一个 Timer 到时间了,触发这个Timer的回调。
if (msg_is_timer) {
__CFRunLoopDoTimers(runloop, currentMode, mach_absolute_time())
}
所以我将.beforeTimers
解释为检查 timer 是说得过去的,.beforeSources
通知同理。
RunLoop 的处理流程可以这样简单理解:进入 RunLoop 后,开始循环,首先检查 timer(发送.beforeTimers
),然后再检查 input sources(发送beforeSources
),如果inpt sources有事件发生,就处理,接下来就进入休眠(发送beforeWaiting
)。之后如果有 timer 到了触发点,RunLoop 被唤醒(发送afterWaiting
),执行 timer 绑定的方法,然后重新开始循环,检查 timer,检查 input source,没事做就休眠等待信号。那么 input sources 怎么处理的呢?因为 input sources 的信号随时都可能来,来了之后,如果 RunLoop 在休眠,唤醒(发送afterWaiting
),又开始从头检查,检查 timer,检查 input sources,诶,有事情,处理,处理完了休眠。
实际上,不退出 RunLoop 直接使用run(mode:before:)
里选择新的模式运行也是可以的。官方资料在 Starting the Run Loop 里提到 RunLoop 是可以递归运行的,也就是说 RunLoop 里可以再嵌套一个 RunLoop:
像上面这种正常的嵌套会有以下影响:
-
.exit
通知会出现重复现象,如果依赖.exit
通知,注意过滤重复的通知; - 虽然将 observer 添加到
commonModes
能让 observer 观察所有模式,但是在这种 RunLoop 嵌套的情况下,还是必须再添加一次;
如果你不按上面的套路出牌,比如在两种模式中来回嵌套,或是同一种模式里嵌套自身,在某些条件下的确是会出问题的。