Skip to content

RunLoop F.A.Q

seedante edited this page Feb 18, 2018 · 1 revision

前不久试图实现一个在后台运行的 NSTimer,解决的过程中顺便把 Runloop 梳理了下。另外,推荐下面两个资料:

  1. Run Loops 官方资料:详细介绍了 RunLoop 的基础概念、运行机制和使用方法,入门首选,在阅读其他任何有关 RunLoop 的文章遇到难以理解的概念时,可以在这个官方资料里找到最原始、最准确的解释。建议把这个官方的资料读上三遍,再动手写个代码以加强理解。
  2. 深入理解RunLoop:这篇文章结合 RunLoop 的开源版本代码给出了更多的实现细节,并且讨论了很多 RunLoop 的实际应用。记得看下面的评论,知道了知识点不一定懂得怎么运用以及解决问题,评论里有很多实际的问题可以帮助你更好地理解 RunLoop。

本文主要是记录下自己的理解,并提供了些上面两篇资料没有顾及到的新内容。

问题跳转:

Q: RunLoop 与 Thread 的关系?

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,线程的内部环境有以下几个入口:

  1. NSThread 子类的main()里,而且只有通过调用start()main()内部才是这个线程的环境,比如直接调用thread.main(),这时候main()内部的线程环境是当前线程,而不是 thread 本身的线程;

  2. NSThread 两个基于 selector 的 API,调用的 selector 内部;

  3. 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 对象。

Q: RunLoop mode 是什么?

A: 本来这似乎不是个问题,不过当初看到深入理解RunLoop这篇文章的时候,每次看到讲解 RunLoop mode 的时候我就晕了,但我这次直接看了官方资料的解释后立马就理解了,可能每个人对其他人的概念解释的接受度不一样,但几乎每次看不懂中文版本的概念解释时,回头看原始版本的解释就明白了。如果你看这篇文章有概念不清楚的地方,去看官方资料。

RunLoop 和事件源的关系与通知机制类似,订阅某些对象来获取它们的动态,上一个问答里笼统提到的事件源被 RunLoop 大致分为两类:

RunLoop Sources

右边的 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 呢?这部分看末尾的回答。

Q: 如何创建一个在后台线程里运行的 NSTimer?

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 里有很多版本,而且很多根本就没文档。

Q: 如何创建一个常驻后台的线程?

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 本身我还没有深入了解,不懂。

Q: NSObject 的 performSelector 系列方法...

A: 本来假装写成"如何实现的?",但发现这并不是一个好问题,就是用 RunLoop 相关的技术实现的嘛,像 afterDelay 的两个方法,文档里就直接指明使用了 timer。不过随手使用了几个方法后,发现这个系列的方法有很多需要注意的地方。在这系列方法里你可以看到很多深入理解RunLoop里提到的有关代码层次的细节(可通过在 selector 里设置断点来查看帧栈),借此验证下也可以帮助你更好地理解深入理解RunLoop这篇文章,这些细节当然在上面的内容也有,不过放到上面内容就太繁杂了。

官方资料将 Input sources 细分成三个类别:

  1. Port-Based Sources: 对应深入理解RunLoop里的 Source1,回调函数为__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__
  2. Custom Input Sources: 对应 Source0,回调函数为__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__
  3. 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(_:_:)将其加入.commonModesperformSelector: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 自启动的功能,参考下一个回答。

Q: 如何切换 RunLoop 的 mode?如何重启 RunLoop?

A: 其实后一个问题是前一个问题的子集。

RunLoop 的文档和开头给出的官方资料都没有直接提到如何切换 RunLoop 的 mode。一个正常的想法是:先退出当前模式,然后在run(mode:before:)里重新选择模式开始新的循环。

如何退出 RunLoop 呢?(退出后不再处理这个模式下的事件)

  1. run(mode:before:)里可以指定失效时间,到了指定的时间 RunLoop 会自动退出循环。

  2. RunLoop 运行后会检查当前运行模式下的 Input sources 和 Timer sources,如果 Input sources 的数量为0, Timer sources 里有效的 timer 数量也为0,RunLoop 就会退出循环,在上面这个简单的例子里,只要timer.invalidate()即可。不过文档指出,移除你已知的事件源并不能保证 RunLoop 退出循环,在 macOS 平台下,出于某些原因,系统会自动添加某些事件源,但没有明确指出其他平台会不会这样做。我在Demo里测试时发现在多个模拟器上这样做无法让 RunLoop 退出循环,而在真机上没有问题,但是呢,同样的手法在我其他项目的模拟器上又没有问题。

  3. 还可以强制退出 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:

recursive runloop.png

像上面这种正常的嵌套会有以下影响:

  1. .exit通知会出现重复现象,如果依赖.exit通知,注意过滤重复的通知;
  2. 虽然将 observer 添加到commonModes能让 observer 观察所有模式,但是在这种 RunLoop 嵌套的情况下,还是必须再添加一次;

如果你不按上面的套路出牌,比如在两种模式中来回嵌套,或是同一种模式里嵌套自身,在某些条件下的确是会出问题的。