Four: The use of RunLoop in the system

Many APIs used in our development are implemented by RunLoop, such as the familiar perform selector method, such as the familiar Timer and so on.

Cocoa Perform Selector

The following is a cluster of perform selector methods available in NSObject in 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 has methods that can execute methods on other threads. From these methods themselves, we can roughly guess the relationship between them and RunLoop: for example, delay is related to time, and onThread is related to inter-thread communication. It is worth mentioning that if you want to execute the method called by perform, then the target thread must have a RunLoop has been activated, otherwise the method corresponding to the aSelector parameter will not be executed . And RunLoop will process all the performers of the queue in one loop, instead of one selector at a time.

Delayed execution

There are two delayed methods in the above method cluster:

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

func perform(_ aSelector: Selector, with anArgument: Any?, afterDelay delay: TimeInterval)
复制代码

This method will set a timer in the runLoop of the current thread, and call the selector through the timer's callback. This timer is added to the default mode (CFDefaultRunLoopMode). Of course, you can also manually specify the mode in which the timer is added. When the timer is triggered, the thread will take out the corresponding method from the message queue of the runloop and execute it, but the premise is that the running mode of the runloop is exactly the mode added by the timer, otherwise the timer will wait until the runloop runs. the specified mode.

For example, write a 5-second delay method in ViewController and add this timer to defaultMode:

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。

@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方法。

CFRunLoopAddTimer -> _CFRepositionTimerInMode(rlm, rlt, false)

-> __CFArmNextTimerInMode(rlm, rlt->_runLoop)
复制代码

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

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**中了:

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是直接相关联的。

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的:

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来实现的,这里在源码中有很明确的显示:

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

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

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 界面。这个函数内部得方法栈如下:

_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、developpaper.com/ios-event-h…

4、深入理解RunLoop

5、BackBoardServices.framework

Guess you like

Origin juejin.im/post/7119404366766800927