在我们开发中使用的很多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)
主线程执行
而在指定主线程中执行的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)**方法。
其他线程执行
这里就不做标注了,因为和上图是一样的,只不过这里注意thread需要去创建一个runloop保活,不然是没办法在一个没有运行RunLoop的线程上去perform selector的。此处,也是通过source0来调用具体的方法(这里的方法我没有改名:testPerformMainThread,希望不会引起歧义)
以上的这些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。
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来处理的。
事件响应
一个硬件事件被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的方法:
参考
3、developpaper.com/ios-event-h…
- 我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。