肆:RunLoop在系统中的使用

语言: CN / TW / HK

在我们开发中使用的很多API都依赖的RunLoop来实现的,比如我们熟悉的perform selector方法,比如我们熟悉的Timer等等。

Cocoa Perform Selector

以下是Swift中NSObject中提供的perform selector方法簇:

```swift / 在指定线程执行方法: 主线程或者其他线程 / open func performSelector(onMainThread aSelector: Selector, with arg: Any?, waitUntilDone wait: Bool, modes array: [String]?)

open func performSelector(onMainThread aSelector: Selector, with arg: Any?, waitUntilDone wait: Bool)

@available(iOS 2.0, *) open func perform(_ aSelector: Selector, on thr: Thread, with arg: Any?, waitUntilDone wait: Bool, modes array: [String]?)

@available(iOS 2.0, *) open func perform(_ aSelector: Selector, on thr: Thread, with arg: Any?, waitUntilDone wait: Bool)

@available(iOS 2.0, *) open func performSelector(inBackground aSelector: Selector, with arg: Any?)

/ 延迟时间执行方法 / open func perform(_ aSelector: Selector, with anArgument: Any?, afterDelay delay: TimeInterval, inModes modes: [RunLoop.Mode])

open func perform(_ aSelector: Selector, with anArgument: Any?, afterDelay delay: TimeInterval) ```

NSObjct有一些方法可以在其它的线程上执行方法。从这些方法本身,其实我们可以大概猜测出它们和RunLoop的关系:如delay和时间有关,onThread和线程间通信有关,值得一提的是如果想要perform调用的方法执行,那么目标线程必须有一个已经激活的RunLoop,否则aSelector参数对应的方法是不会执行的。而RunLoop会在一次循环中一次性处理完所有入队列的perform的selector,而不是一次循环处理一个selector。

延时执行

在以上的方法簇中有两个延时的方法:

```swift func perform(_ aSelector: Selector, with anArgument: Any?, afterDelay delay: TimeInterval, inModes modes: [RunLoop.Mode])

func perform(_ aSelector: Selector, with anArgument: Any?, afterDelay delay: TimeInterval) ```

这个方法会在当前线程的runLoop中设置一个timer,通过timer的callback来调用这个selector。这个timer被加入到默认的mode中(CFDefaultRunLoopMode),当然也可以手动指定timer被加入的mode。当这个timer被触发时,这个线程就会从runloop的消息队列中取出对应的方法并执行,但是前提是这个runloop运行的mode正好是timer加入的mode,否则的话timer就会等待,直到runloop运行了指定的mode。

比如在ViewController中写一个5秒延时的方法,并将此timer加入到defaultMode中:

swift self.perform(#selector(hahah), with: nil, afterDelay: 5.0, inModes: [.default])

在控制台断点输出如下,可以很明确的看到,就是在Timer触发之后(CFEUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION),在回调中调用这个具体的方法:#selector(hahah)

image.png

主线程执行

而在指定主线程中执行的perform方法中performSelector(onMainThread aSelector: Selector),很多文章都说这个也是设置一个Timer,但是经过测试发现并不是如此,并不是设置一个Timer来唤醒runloop,而是系统注册来一个source0事件,并手动来唤醒runloop。

```swift @objc func hahah() { self.performSelector(onMainThread: #selector(testPerformMainThread), with: nil, waitUntilDone: false) }

@objc func testPerformMainThread() { NSLog("I want to see the world.") } ```

通过我们实际测试代码可以看出这里RunLoop被唤醒之后执行了source0的回调,然后调用了#selector(hahah)方法。

image.png

其他线程执行

这里就不做标注了,因为和上图是一样的,只不过这里注意thread需要去创建一个runloop保活,不然是没办法在一个没有运行RunLoop的线程上去perform selector的。此处,也是通过source0来调用具体的方法(这里的方法我没有改名:testPerformMainThread,希望不会引起歧义)

image.png

以上的这些Cocoa Perform Selector Sources根据苹果文档的描述,它们不像基于Port的Source(即source1):一个perform selector source会在执行完它的selector之后,从runloop中被移除。

Timer

Timer

在Swift中我们使用的是Timer类型,而在Objective-C中是NSTimer类型,它们的底层都是CFRunLoopTimerRef。网上的部分文章说Timer会提前注册好时间点,然后一个一个的去执行,其实这个是不对的,它只会注册下一次时间点,RunLoop被Timer唤醒之后,执行完回调之中的方法,又会继续注册下一个时间点。

我们可以从两个地方的源码看,其一是CFRunLoopAddTimer方法,在这个方法中有一个方法的调用顺序,它在添加完Timer之后会调用__CFArmNextTimerInMode方法。

```c CFRunLoopAddTimer -> _CFRepositionTimerInMode(rlm, rlt, false)

-> __CFArmNextTimerInMode(rlm, rlt->_runLoop) ```

另一个地方是__CFRunLoopRun方法,在Runloop在被Timer唤醒之后会调用到__CFRunLoopDoTimers方法, 它的方法链为:__CFRunLoopDoTimers -> __CFRunLoopDoTimer —> __CFArmNextTimerInMode 也就是说最后还是会调用到__CFArmNextTimerInMode方法。

```c Bool __CFRunLoopRun() { ··· if (livePort == rlm->_timerPort) { CFRUNLOOP_WAKEUP_FOR_TIMER();

    if (!__CFRunLoopDoTimers(rl, rlm, mach_absolute_time())) {
        // Re-arm the next timer
        __CFArmNextTimerInMode(rlm, rl);
    }
}
···

} ```

那么这个Timer运行的核心就在__CFArmNextTimerInMode中了:

```c static void __CFArmNextTimerInMode(CFRunLoopModeRef rlm, CFRunLoopRef rl) { uint64_t nextHardDeadline = UINT64_MAX; uint64_t nextSoftDeadline = UINT64_MAX;

if (rlm->_timers) {
    // 1.设置每一个timer的下一次到期时间
    for (...)   {

    }
   // 2.判断下一次时间
   // - 如果时间是合理的
    if (nextSoftDeadline < UINT64_MAX
        && (nextHardDeadline != rlm -> _timerHardDeadline
            || nextSoftDealline != rlm -> _timerSoftDeadline)) {

        // 3、到点了给_timerPort发送消息
        if (rlm->_timerPort) {
            mk_timer_arm(rlm->_timerPort, __CFUInt64ToAbsoluteTime(nextSoftDeadline));
        }

    // - 如果时间是无限:那么就取消timer
    } else if (nextSoftDeadline == UINT64_MAX) {
        if (rlm->_mkTimerArmed && rlm->_timerPort) {
            AbsoluteTime dummy;
            mk_timer_cancel(rlm->_timerPort, &dummy);
            rlm->_mkTimerArmed = false;
        }
    }
   rlm->_timerHardDeadline = nextHardDeadline;
   rlm->_timerSoftDeadline = nextSoftDeadline;
}

} ```

上面的代码注释已经比较详细了,就是在时间到了之后通过mk_timer_arm 方法来给timerPort发送消息,那么如果因为滑动屏幕的时候切换了RunLoop运行的Mode呢?

核心代码是mk_timer_arm(rlm->_timerPort, __CFUInt64ToAbsoluteTime(nextSoftDeadline)); 到点之后依然会给timerPort发送消息,这个消息会存在timerPort的消息队列中!在RunLoop切换回timer所在的Mode之后,当执行到__CFRunLoopServiceMachPort方法的时候,就会接收到这个timerPort的消息队列中的消息,从而处理Timer的回调事件。这也是为什么切换Mode之后,timer的回调会立马执行一次的原因。

CADisplayLink

CADispalyLink 提供了几个基本的API,从中我们可以很直白的看出它和RunLoop是直接相关联的。

```swift open func add(to runloop: RunLoop, forMode mode: RunLoop.Mode)

open func remove(from runloop: RunLoop, forMode mode: RunLoop.Mode) ```

CADispalyLink和Timer在某些方面是有相似之处的,在创建完之后也需要将其加入到RunLoop的Mode中。我们可以设置display link的帧速率(preferredFramesPerSecond),帧速率也决定了一秒之内系统调用了target的这个方法多少次。然而实际上display link的帧速率是会受到设备的最大刷新率制约的。

比如说设备的最大刷新率是每秒60帧,我们设置的preferredFramesPerSecond如果比这个值大,那么display link的帧速率也只能是60,不能超过设备的屏幕最大刷新率。

接下来我们要看一看display link是如何唤醒runloop的:

```swift func createDisplayLink() { let link = CADisplayLink.init(target: self, selector: #selector(step)) link.preferredFramesPerSecond = 1 link.add(to: RunLoop.main, forMode: .default) }

@objc func step(displaylink: CADisplayLink) { print(displaylink.targetTimestamp) } ```

通过打断点的方法栈中可知:RunLoop是被CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION也就是被source1所唤醒的,然后最后调用到target的step方法。很直白,也很简单。因为它是硬件设备的屏幕刷新调度后台管理程序通过IPC通信,向当前前台进程的mach port 发送消息唤醒了RunLoop。

image.png

DispatchSourceTimer

这个比较特殊,一开始的时候我也以为它是和RunLoop有关系的,后来根据我自己测试,RunLoop已经处于休眠状态了,然而它还是会定时触发callback,基于此我在opensource.apple.com中查看了libdispatch的源码,dispatch_source_timer是由GCD管理的定时器,并不是由RunLoop管理的,所以它其实适合RunLoop无关的。

GCD

GCD和RunLoop是处于同一层级的,从开源代码的文件夹就可窥一二,其中RunLoop源码在开源的CF代码中,GCD源码在开源的libdispatch源码中。但是它们有一个很特别的关联:GCD主队列的任务派发是通过Runloop来实现的,这里在源码中有很明确的显示:

c // 在RunLoop被唤醒的源码中 if (livePort == dispatchPort) { ... CFRUNLOOP_WAKEUP_FOR_DISPATCH(); ... __CFRUNLOOP_IS_SERVECING_THE_MAIN_DISPATCH_QUEUE__(msg) ... }

GCD派发到主队列中的任务会唤醒RunLoop,但是其它任务队列中的任务并不会和RunLoop进行交互。举例:我们知道DispatchSourceTimer和RunLoop是无关的,所以可以使用DispatchSourceTimer写一个延时任务来执行GCD的主队列派发:

```swift func createSourceTimer() { sourceTimer = DispatchSource.makeTimerSource() sourceTimer?.schedule(deadline: .now(), repeating: 10.0, leeway: .nanoseconds(1)) sourceTimer?.setEventHandler { DispatchQueue.main.async { NSLog("我想知道我是谁?") } }

sourceTimer?.activate()

} ```

从方法栈可知,时间到了之后,会给dispatchPort端口发送消息,而这个端口接受消息之后就会唤醒RunLoop,然后就会执行__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__函数,最后执行派发到主队列中的任务。但是要强调的是,仅限于主队列的任务派发,而dispatch到其它线程的任务是通过libDispatch来处理的。

image.png

事件响应

一个硬件事件被iOS系统接受之后,一定会被系统处理然后再分发给应该处理该事件的进程,即当前正在前台的进程。

SpringBoard

我们打开iPhone可以看到许多不同App的icon,并且左右滑动,可以切换不同的页面,其实这是通过SpringBoard来管理的,它提供了所有App的应用启动服务,Icon的管理,状态栏的控制等等,它本质上就是iOS上的桌面程序,同时它是有BundleID的: com.apple.springboard,这一点正好可以验证它就是一个桌面程序。

但是在iOS6之后,SpringBoard的部分方法被分离到在BackBoardd中。BackBoardd是一个后台驻留程序,承担了以前SpringBoard的部分工作。它的主要目的是处理来自硬件的信息,比如触摸事件,按钮事件,加速度计信息。它通过BackBoardServices.framework来和SpringBoard通信。BackBoard勾连系统的IOKit以及用户进程(即应用程序),它也管理着应用程序的启动、暂停和结束。

触摸事件

以触摸事件为例:在屏幕被触摸之后(硬件事件),系统通过IOKit.framework处理该事件,IOKit将这个触摸事件封装为IOHIDEvent对象,然后BackBoard调用当前CAWindowDisplayServer-contextIdAtPosition 方法来得到touch事件应该要发往何处的contextID,这个contextID决定了哪个进程来接受这个touch事件。

如果前台并没有应用程序的话,那么就会通过mach port(IPC通信)将事件分发给SpringBoard来处理,这就意味着用户是操作的是iPhone的桌面,比如用户点击一个应用图标,它将启动这个应用。如果前台有应用程序的话,BackBoard得到contextID之后,会将这个事件通过mach port(IPC通信)分发给前台的这个应用程序

前台应用在接受到mach port 传递来的事件之后,它会唤醒主线程的RunLoop,触发Source1回调,Source1回调会调用__IOHIDEventSystemClientQueueCallback方法,这个方法会将事件交给source0来处理,source0将会调用__eventFetcherSourceCallback方法,在这个方法内部会调用__processEventQueue方法,在这个方法内部会对IOHIDEvent进行处理,将其转化为UIEevent对象,然后调用__dispatchPreprocessedEventQueue分发给UIApplication去寻找相应的响应视图。

界面更新

在对界面进行操作的时候,比如改变了UI的Frame,或者改变了UIView/CALayer的层次时,或者手动调用了UIView/CALayer的setNeedsLayout/setNeedsDisplay方法后,这个UIView/CALayer就会被标记为待处理,并被提交到一个全局的容器中去。

Apple注册了一个注册了一个 Observer 监听 BeforeWaiting(即将进入休眠) 和 Exit (即将退出Loop) 事件,回调去执行一个函数:

_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv。这个函数里会遍历所有待处理的 UIView/CALayer 以执行实际的绘制和调整,并更新 UI 界面。这个函数内部得方法栈如下:

c _ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv() QuartzCore:CA::Transaction::observer_callback: CA::Transaction::commit(); CA::Context::commit_transaction(CA::Transaction*, double, double*); CA::Layer::layout_and_display_if_needed(CA::Transaction*); CA::Layer::layout_if_needed(); [CALayer layoutSublayers]; [UIView layoutSubviews]; CA::Layer::display_if_needed(); [CALayer display]; [UIView drawRect];

对于改变页面的Frame确实是上述所示,那么对于动画也是在BeforeWaiting的时候才去commit_transition吗?答案是肯定的。在滑动一个UITableView的过程中,通过控制台可以看到,也是在每一次被Observer监听到唤醒之后,才去调用刷新UI的方法:

image.png

参考

1、SpringBoard.app

2、backboardd

3、https://developpaper.com/ios-event-handling-look-at-me-thats-enough/

4、深入理解RunLoop

5、BackBoardServices.framework