肆:RunLoop在系统中的使用

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

Cocoa Perform Selector

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

/*  在指定线程执行方法: 主线程或者其他线程  */
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。

延时执行

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

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

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

猜你喜欢

转载自juejin.im/post/7119404366766800927