RunLoop与事件响应

2020-03-22

在上一篇《调试iOS用户交互事件响应流程》中,调试了 iOS 事件响应的完整过程,但是只涉及了事件在 UIKit 的视图层级之间的传递的应用层的实现细节,具体到事件在哪里生成,如何分发到 UIKit 层的底层流程则未有提及。本文尝试从 RunLoop 入手,探索事件响应的底层流程。

一、XNU内核和Mach

在进入正题之前,先聊聊 XNU 内核。首先 Mac OS X 和 iOS 都是基于 Darwin 系统开发而来。Darwin 系统是 Mac OS X 的核心操作系统部分,而 Darwin 系统的内核就是 XNU(X is Not Unix)。

1.1 XNU内核架构

XNU 内核是混合架构内核,它以 Mach 微内核为核心,在上层添加了 BSD 和 I/O Kit 等必要的系统组件。从苹果官方文档 [ NextPrevious Kernel Architecture Overview ] 搬运以下三张 Mac OS X 的内核架构图(iOS 内核架构也差不多如此)。

Mach 是 XNU 内核中的内核。Mach 提供的是 CPU 管理、内存管理、任务调度等最底层的功能,为操作系统层的组件提供了基于 mach message 的通信基础架构。Mach 更具体的功能是进程(机间)通讯(IPC)、远程过程调用(RPC)、对称多处理调度(SMP)、虚拟内存管理、分页支持、模块化架构、时钟管理等最基本的操作系统功能。

BSD(Berkly Software Distribution)实现了所有现代操作系统所包含的核心功能,包括文件系统、网络通讯系统、内存管理系统、用户管理系统等等。BSD 属于内核环境的一部分,但是由于它对外提供了丰富的应用层 API,因此它表现出来有点游离于内核之外而处于应用层。NKEs(Network Kernel Extensions)详见《OSX与iOS内核编程》可用于监听网络流量、修改网络流量、接收来自驱动层的异步通知等等。

I/O Kit 则是对外设 Driver 进行了面向对象封装,除了负责处理来自 I/O 设备的信号外,还提供了丰富的 KPI 用于 I/O 设备驱动开发。通常 iOS 开发很少涉及到驱动开发,主要原因是 iOS 操作系统的核心代码不是开源的,而 iOS 在硬件权限的管理上也相当谨慎,因此不会在 iOS 上开发如此底层的接口。对于涉及硬件交互的内容,iOS 通常是提供相关应用开发框架给开发者调用;Mac OS X 则开放了 I/O Kit 专门用于外设驱动层的开发。

总之,XNU 内核组成对外表现为 Mach + BSD + I/OKit,BSD 层建立于 I/O Kit 层之上,Mach 内核作为核心贯穿于两层之中。Mach 在任务调度和底层消息通信中占据核心地位。 NSRunLoop 的 Source1 就是通过 mach message 来唤醒 RunLoop 的。

1.2 mach_msg

在程序调试过程中,经常需要暂停程序运行下断点,程序暂停就是通过发送 mach_msg 消息实现的。从下图的调用栈可以发现, mach_msg 中调用了 mach_msg_trap 。当 App 接收到 mach_msg_trap 时,其中的 syscall 指令触发系统调用,应用从用户态转入内核态。

进入内核态意味着应用获取了访问系统资源的最高特权,包括 CPU 调度、寄存器读写、内存访问、虚拟内存管理、外围设备访问、跨进程访问等等。而这些任务在用户态下是无法完成的。此时收到 mach_msg 的当前线程暂停手中的任务,保存当前线程的上下文,等待系统调用完成。收到 msg_msg 消息唤醒后,线程才重新投入运行。

也许也正是因为 Mach 如此强大,而且建立在极少量的 API 的基础上而又具备很强的灵活性,所以 XNU 内核的设计者才在 Mach 之外套了一层 BSD 加以控制,以提供更加具体且统一的操作系统内核开发的规范。

在使用 Profile >> System Trace 工具跟踪应用的 CPU 使用情况时会发现,静止的应用大部分时间是处在 sytem call 状态下(如下图红色条带区域),主线程则是 Blocked 阻塞状态(如下图灰色条带区域),这是因为在 iOS 没有接收到用户事件、没有需要正在运行的逻辑时,系统调用了 mach_msg 进入了内核态并阻塞了主线程,此时主线程 RunLoop 处于睡眠状态。这就是 iOS 保证 CPU 能够大部分时间下低功耗运行的原因。仅当系统接收到需要零星需要处理的事件时(如图蓝色条带区域),才从内核态转回用户态处理事件,当然处理事件过程中如果要调度系统资源还会切到内核态,例如 NSLog 函数调用时,就会阻塞线程,进入 I/O 过程输出日志,完成后才返回用户态。

二、RunLoop调试

本节大量参照了苹果官方文档对 RunLoop 的描述,并结合 lldb 调试 RunLoop 的数据结构以及工作流程,是本文的核心章节。

2.1 RunLoop简介

通常情况下,线程在执行完指定任务后就立刻销毁。但有些情况,开发者希望线程能够常驻,并在空闲时进入等待任务的状态。此时就需要用到 RunLoop 实现线程保活。

下图是 RunLoop 的一个不完全的状态转换图,可以比较直观地展示 RunLoop 大致工作流程,右边红色标记部分就是 RunLoop 处理逻辑的主体,明显是一个“唤醒->处理消息->睡眠”的循环迭代过程,输入源可以看作是 RunLoop 接收消息的地方。

再参考来自官方文档的定义。RunLoop 用来监控任务的 输入源 (input sources),并在输入源准备就绪后,对其进行调度控制。常见的输入源包括:用户输入设备、网络连接、时钟、延迟触发事件、异步回调等等。RunLoop 可以监控的输入源种类有三种:Sources、Timers、Observers,它们有 回调函数 (callback)。RunLoop 接收到某输入源的 触发事件 时,会执行该输入源的回调函数。监控输入源之前,需要将其添加到 RunLoop。不再需要监控时,则将其从 RunLoop 移除,回调函数就不会再触发。

注意:RunLoop 是调用 input sources 的回调函数是同步调用,并不是异步,也就说如果回调函数的处理过程如果特别耗时,会直接影响到其他 input sources 事件响应的时效性。

Sources、Timers、Observers 都需要与一个或多个 Mode(run loop mode)关联。Mode 界定了 RunLoop 在运行之时,所监控的输入源(事件)的范围。运行 RunLoop 前,需要指定 RunLoop 所要进入的 Mode;开始运行后,RunLoop 只处理 Mode 中包含的监控对象的触发事件。加之 RunLoop 可以重复运行,因此可以控制 RunLoop 在适当的时间点,进入适当的 Mode,以处理适当的事件。

总结 RunLoop、Sources、Mode 的关系如下图所示:

2.1.1 Input Sources

输入源是根据触发事件的类型分类的。其中,Sources 的触发事件是外部消息(信号);Timer 的触发事件是时钟信号;Observer 的触发事件是 RunLoop 状态变更。其实,输入源触发事件时,发送的消息都非常简单,可以理解为一个脉冲信号 1 ,它只是给输入源 打上待处理标记 ,这样 RunLoop 在被唤醒时就能查询当前 Mode 的输入源中哪些需要处理,需要处理的则触发其回调函数。

Source0和Source1

Sources 根据消息种类分为 Source0 和 Source1。

Source0 是应用自行管理的输入源。应用选择在适当的时机调用 CFRunLoopSourceSignal 来告诉 RunLoop 有个 Source0 需要处理。譬如,在线程 A 上完成准备工作后,给线程 B 的 RunLoop 中的 Source0 发送信号,触发(并不是马上)Source0 回调函数中的主任务逻辑开始执行。 CFSocket 就是通过 Source0 实现的。

Source1 是 RunLoop 和内核共同管理的输入源。Source1 需要关联一个 mach port,并通过 mach port 发送触发事件信号,从而告诉 RunLoop 有个 Source1 需要处理。当 mach port 收到 mach 消息时,内核会自动给 Source1 发送信号,mach 消息的内容也会一并发送给 Source1,作为触发 Source1 回调函数触发的上下文(参数)。 CFMachPortCFMessagePort 就是通过 Source1 实现的。

单个 Source 可以同时注册到多个 RunLoop 或 Mode 中,当 Source 事件触发时,无论哪个 RunLoop 率先接收到消息,都会触发 Source 的回调函数。单个 Source 添加到多个 RunLoop 可以应用于 处理离散数据集(数据间不存在关联性)的“worker”线程池管理,譬如消息队列的“生产者-消费者”模型,当任务到达时,会自动随机触发一条线程接收数据并进行处理。

总结 Source0 和 Source1 的主要区别如下:

CFRunLoopSourceSignal
1
Note: A run loop source can be registered in multiple run loops and run loop modes at the same time. When the source is signaled, whichever run loop that happens to detect the signal first will fire the source.

Timer

Timer 是一种预设好事件触发时间点的 RunLoop 输入源。既可以设置 Timer 只触发一次,也可以设置以指定的时间间隔重复触发。重复触发的 Timer 可以手动触发 Timer 的下一次事件。 CFRunLoopTimerNSTimer 是 toll-free bridged 的。

Timer 并不是实时的,它的触发是建立在,RunLoop 正在运行 Timer 所在 Mode 的前提上。当 到达 Timer 的预设触发时间点时,若 RunLoop 此时正运行于其他 Mode,或者 RunLoop 正在处理某个复杂的回调,RunLoop 的当前迭代则会跳过该 Timer 触发事件,直到 RunLoop 下次迭代到来再检查 Timer 并触发事件。

Timer 输入源的本质,是根据时钟信号,在 RunLoop 中注册触发时间点,RunLoop 唤醒并进入迭代时,会检查 Timer 是否到达触发时间点,若到达则调用 Timer 的回调函数。Timer 的注册时间点始终是按照 Timer 初始化时所指定的触发时间策略排布的。譬如一个在 2020-02-02 12:00:00 开始,每 5s 循环触发的 Timer,其 2020-02-02 12:00:05 触发事件被推迟到 2020-02-02 12:00:06 触发了,那么 Timer 的下个触发时间点仍然是 2020-02-02 12:00:10 ,而不是在延迟的触发时间点基础上再加 5s。另外,若 Timer 延迟时间内跳过了多个触发时间点,则 RunLoop 在下个触发时间点检查 Timer 时,仅仅会触发一次 Timer 回调函数。

需要注意,Timer 只能被添加到一个 RunLoop 中,但是 Timer 可以被添加到一个 RunLoop 的多个 Modes 中。

Note: A timer can be registered to only one run loop at a time, although it can be in multiple modes within that run loop.

Observer

前面介绍的输入源中,Source0 的事件来自手动触发信号,Source1 的时间来自内核的 mach ports,Timer 的事件来自内核通过 mach port 发送的时钟信号,Observer 的事件则是来自 RunLoop 本身的状态变更。

RunLoop 的状态用 CFRunLoopActivity 类型表示,包括

kCFRunLoopEntry
kCFRunLoopBeforeTimers
kCFRunLoopBeforeSources
kCFRunLoopBeforeWaiting
kCFRunLoopAfterWaiting
kCFRunLoopExit
kCFRunLoopAllActivities

构建 RunLoop Observer 时需要指定它所观察的目标 RunLoop 状态,状态是位域可以通过 CFRunLoopActivity 的“按位与”运算指定 Observer 观察多种目标状态。当 Observer 所观察的 RunLoop 状态发生相应变更时,RunLoop 触发 Observer 的回调函数。

需要注意,Observer 只能被添加到一个 RunLoop 中,但是 Observer 可以被添加到一个 RunLoop 的多个 Modes 中。

Note: A run loop observer can be registered in only one run loop at a time, although it can be added to multiple run loop modes within that run loop.

2.1.2 Modes

前面提到 Modes 为 RunLoop 的运行过程需要处理的输入源划定范围。缺省情况下都会指定 RunLoop 进入默认 RunLoop Mode( kCFRunLoopDefaultMode )。默认 RunLoop Mode 是用于在应用(线程)空闲时处理输入源的事件。但 RunLoop Mode 的种类绝不仅限于此,开发者甚至可以新建自定义的 Mode。Mode 之间是通过 mode name 字符串来区分的。Core Foundation 公开的 mode 只有:

kCFRunLoopDefaultMode
kCFRunLoopCommonModes

Foundation 公开的 mode 倒是更多:

NSDefaultRunLoopMode
NSRunLoopCommonModes
NSEventTrackingRunLoopMode
NSModalPanelRunLoopMode
UITrackingRunLoopMode

Common Modes

Core Foundation 还定义了一个特殊的 Mode,common modes( kCFRunLoopCommonModes ),用于将 Sources、Timers、Observers 输入源同时关联到多个 Mode。每个 RunLoop 都会有自己设定的 common modes 集合,但是默认 mode 必定是其中一个。Common modes 用集合数据类型(哈希表)保存。开发者可以使用 CFRunLoopAddCommonMode 将某个 Mode 指定为 common mode。

举个例子。当把 NSTimer 添加到主线程 RunLoop 的 NSDefaultRunLoopModeTimer 只与默认 mode 关联 。用户一直滚动界面时, NSTimer 注册的 selector 是不会触发的。因为用户滚动界面时主线程 RunLoop 会进入 UITrackingRunLoopMode ,其中并没有 Timer 这个输入源,因此 Timer 的事件就不会触发。其中一种解决方式是,将 NSTimer 添加到主线程 RunLoop 的 NSRunLoopCommonModes

为调试将 Timer 添加到 default mode 和添加到 common modes 有什么区别,使用以下一段代码进行调试。并在 NSLog(@""); 打上断点,然后运行。

CFRunLoopTimerRef defaultTimer = CFRunLoopTimerCreateWithHandler(kCFAllocatorDefault, 0, 1, 0, 0, ^(CFRunLoopTimerRef timer) {
    static int tick = 0;
    NSLog(@"Timer in default mode tick: %d", tick++);
});
CFRunLoopAddTimer(CFRunLoopGetCurrent(), defaultTimer, kCFRunLoopDefaultMode);
    
CFRunLoopTimerRef commonTimer = CFRunLoopTimerCreateWithHandler(kCFAllocatorDefault, 0, 2, 0, 0, ^(CFRunLoopTimerRef timer) {
    static int tick = 0;
    NSLog(@"Timer in common modes tick:%d", tick++);
});
CFRunLoopAddTimer(CFRunLoopGetCurrent(), commonTimer, kCFRunLoopCommonModes);

//让 RunLoop 持有 Timer 即可
CFRelease(defaultTimer);
CFRelease(commonTimer);

NSLog(@"");
复制代码

程序陷入断点后,输入以下红框的 lldb 命令打印两个 timer 以及当前 RunLoop 对象。如下图所示,RunLoop 对象信息太多,使用 Command+F 快捷键在调试日志中搜索两个 Timer 对象的内存地址。

首先搜索添加到 default mode 的 defaultTimer ,发现 defaultTimer 只被添加到 kCFRunLoopDefaultMode 中,如下图所示

然后搜索添加到 common modes 的 commonTimer ,发现 commonTimer 被添加到了三个地方,分别是:

  • common modes item

  • UITrackingRunLoopMode

  • kCFRunLoopDefaultMode

此时再回头看刚开调试,没有提到的蓝色方框框中的内容,其含义是当前 RunLoop 的 common modes 包含两个 kCFRunLoopDefaultModeUITrackingRunLoopMode 。这意味着 当把 input source 添加到 RunLoop 的 kCFRunLoopCommonModes 时,input source 同时会被添加到 RunLoop 的 common modes 包含的所有 modes 中,同时也将其添加到 RunLoop 的 common items 中进行备案 。重点是,这样一来,把 Timer 添加到 kCFRunLoopCommonModes ,则标记为 common mode 的 UITrackingRunLoopMode 也会添加该 Timer。这就是为什么,即使滚动页面时 RunLoop 运行在 UITrackingRunLoopMode 下,也能触发该 Timer 的事件的原因。而添加到 kCFRunLoopDefaultMode 的 Timer 不触发则是因为,它只被添加到了 kCFRunLoopDefaultMode 中。

可以进一步尝试搜索 common items 中任意一个 input source,在调试窗口日志中都会命中多个结果。

Note: Once a mode is added to the set of common modes, it cannot be removed.

2.2 RunLoop与线程

RunLoop 和线程(Thread)是一一对应的关系,默认情况下线程是没有 RunLoop 的(主线程除外),也就是说线程执行完任务后就可以直接销毁。且 Cocoa 也没有提供创建 RunLoop 的 API,仅能通过 CFRunLoopGetMain()CFRunLoopGetCurrent() 获取,当获取时检测到线程未创建 RunLoop 实例,则系统自动为其创建 RunLoop。

RunLoop 公开的接口有两套, NSRunLoopCFRunLoop 两者之间可以 toll-free bridging 转换。 CFRunLoop代码 是开源的。需要注意, NSRunLoop 不是线程安全的,Apple Documentation 中有以下一条 Warning 声明不能在 RunLoop 的线程之外的线程上,调用该 RunLoop 的方法。

Warning: The NSRunLoop class is generally not considered to be thread-safe and its methods should only be called within the context of the current thread. You should never try to call the methods of an NSRunLoop object running in a different thread, as doing so might cause unexpected results.

2.3 RunLoop的API

RunLoop 公布的 API 有两套 NSRunLoopCFRunLoop ,后者的 API 更加完备,因此本章只介绍 CFRunLoop 的 API。上面贴出摘自的 Apple Documentation 的 Warning 意思是 NSRunLoop 不是线程安全的,不能在 NSRunLoop 所在线程外调用 NSRunLoop 的方法(不能在 NSRunLoop 线程外调用其 performSelector:XXX 接口似乎会让 NSRunLoop 的这套接口变得有点鸡肋)。本章对 CFRunLoop 的公开 API 做了一个简单的分类,大部分从接口就可以知道其用途,因此只注释其中一部分 API。

2.3.1 RunLoop操作API

运行RunLoop

CFRunLoopRunResult CFRunLoopRunInMode(CFRunLoopMode mode, CFTimeInterval seconds, Boolean returnAfterSourceHandled)
复制代码

CFRunLoopRunInMode 用于以指定的 mode 运行 RunLoop。 CFRunLoopRunInMode 可以递归地调用,即开发者可以在 RunLoop 内的任何一个回调函数中调用 CFRunLoopRunInMode 从而在 RunLoop 所在线程的调用栈上形成层次嵌套的 RunLoop 激活形态。意思就是,在 RunLoop 的回调函数内,开发者可以按需自由调用 CFRunLoopRunInMode 切换 RunLoop Mode,而且基本不会产生副作用。

  • seconds 参数表示当次 RunLoop 运行的时间长度,如果 seconds 指定为 0 ,则 RunLoop 只会处理其中一个 input source 的事件(如果处理的恰好是 source0,则存在额外再多处理一个事件的可能(TODO)),此时无论开发者指定怎样的 returnAfterSourceHandled 都是无济于事的。

  • returnAfterSourceHandled 用于指定 RunLoop 执行完 source 后是否立即退出。如果是 NO ,则 source 执行完毕后,仍要等到 seconds 时间点到达时才退出。

  • 返回 RunLoop 退出的原因。

    • kCFRunLoopRunFinished :RunLoop 中已经没有 input source;
    • kCFRunLoopRunStoped :RunLoop 被 CFRunLoopStop 函数终止;
    • kCFRunLoopRunTimedOutseconds 计时到时,超时退出;
    • kCFRunLoopRunHandledSource :已完成一个 input source 的处理。该返回值只会在 returnAfterSourceHandled 参数为 true 时才会出现。

CFRunLoopRun 是在 default mode 下运行 RunLoop。

Note: You must not specify the kCFRunLoopCommonModes constant for the mode parameter. Run loops always run in a specific mode. You specify the common modes only when configuring a run-loop observer and only in situations where you want that observer to run in more than one mode.

唤醒RunLoop

CFRunLoopWakeUp 用于唤醒 RunLoop。当 input source 未事件触发时,RunLoop 处于睡眠状态,在它超时退出或被显式唤醒之前,RunLoop 都会一直维持在睡眠状态。当修改 RunLoop 时,譬如添加了 input source,必须唤醒 RunLoop 让它处理该修改操作。当向 Source0 发送信号,并希望 RunLoop 能立刻处理时,可以调用 CFRunLoopWakeUp 立即唤醒 RunLoop。

中止RunLoop

CFRunLoopStop 用于中止 RunLoop 当前运行,并将控制权交还给当初调用 CFRunLoopRunCFRunLoopRunInMode 激活 RunLoop 本次运行的函数。如果该函数是 RunLoop 的某个回调函数,也就是 CFRunLoopRunInMode 嵌套,则只会中止 最内层的那次 CFRunLoopRunInMode 调用 所激活的运行循环。

RunLoop的等待状态

若 RunLoop 的输入源中没有需要处理的事件,则 RunLoop 会进入睡眠状态,直到 RunLoop 被 CFRunLoopWakeUp 显式唤醒,或者被 mach_port 消息唤醒。 CFRunLoopIsWaiting 可以用于查询 RunLoop 是否处于睡眠状态,RunLoop 正在处理事件或者 RunLoop 还未开始运行,该函数都返回 false 。注意该函数只用于查询外部线程的 RunLoop 状态,因为如果查询当前 RunLoop 状态只会返回 false

2.4 RunLoop的流程

个人能想到的探索 RunLoop 的流程有两种方式,分别是 lldb 调试和源代码解读。前者比较直观,就先从它入手。

2.4.1 LLDB调试RunLoop流程

Source0调试

仍然沿用《调试iOS用户交互事件响应流程》的简单 Demo,但是屏蔽其中的所有定制的 hitTest:withEvent:nextRespondertouchesBegan:withEvent:touchesBegan:withEvent: 代码。因为调试不需要看这些打印内容。然后在 didClickBtnFront: 点击“点我前Button”的点击事件回调中种下一个断点。点击“点我前Button”程序打断。

使用 bt 命令查看调用栈如下图所示,提取出与 RunLoop 相关的调用为下图红框框中的内容。原来 iOS 的用户交互事件是在 GSEventRunModal 中调用 CFRunLoopRunSpecific 函数运行了某个 CFRunLoop 对象。当点击事件触发时唤醒了 RunLoop,RunLoop 通过 __CFRunLoopDoSource0 函数调用 __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__ 开始运行某个 Source0 的回调函数。后面就是事件响应流程了。

然而这“某个 CFRunLoop 对象”具体是哪个 RunLoop ,具体在哪个 mode 下运行的 RunLoop,且这“某个 Source0”具体是哪个 Source0。这些细节都暂时不得而知。

首先尝试扒一扒 CFRunLoop 的细节。首先从调用栈中找到调用 CFRunLoopRunSpecific 的栈帧,这里是 16 号栈帧, frame select 16 进入该栈帧。然后打印调用 CFRunLoopRunSpecific 前赋值的寄存器 $rbx ,结果是 kCFRunLoopDefaultMode ,原来是 以默认 mode 运行 RunLoop 的。

继续进到 15 号帧看看会不会有什么意外收获。打印到 r13 寄存器,哟吼,还真敢有。这里由看到了熟悉的 kCFRunLoopDefaultMode 的面孔,而且还找到一个“形迹可疑”回调函数名为 __handleEventQueue 的 Source0,注意调用栈第 10 帧恰好是 __handleEventQueueInternal 这就是我们要找的 Source0 了。

其实更大的惊喜在后头。打印 r15 寄存器。嗯?这不就是我们要找的 RunLoop 君么。而且它还包含了前面打印出来的 kCFRunLoopDefaultMode 君。再 po CFRunLoopGetMain() 打印一下主线程 RunLoop 可以确认该 RunLoop 其实就是 主线程 RunLoop

以上就是 Source0 的触发流程如下:

-> CFRunLoopRunXXX -> __CFRunLoopRun -> __CFRunLoopDoSources0 -> __CFRunLoopDoSource0 -> __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__

但是其中似乎少了添加 source 和添加 mode 的细节,这应该是应用初始化时需要完成的操作,而且基本就是调用 CFRunLoop 相应的 API 实现,因此不调试该部分内容。

那么到底是谁向 Source0 发送的触发信号呢?接下来就揪出这个“幕后黑手”。从前面对 Source0 的介绍已知,使用 CFRunLoopSourceSignal 发送触发信号。首先将前面下的断点用 breakpoint delete 全删掉,然后 breakpoint set -n CFRunLoopSourceSignalCFRunLoopSourceSignal 函数下个全局断点。准备就绪,点击“点我前Button”。接下来断点命中了很多次,每次命中都 bt 瞄一眼调用栈,发现前几次命中都是与事件触发相关。

原来事件是通过 UIEventFetcher_receiveHIDEventInternal 方法触发的,从函数名可以知道,它是用来接收从 IOHID(I/O Hardware Interface Device) 层发送来的用户交互事件的。接下来在断点第一次命中时调试用户事件到底是从何而来。 frame select 1 进入第 1 帧,打印关键寄存器数据,可以推断出用户交互事件是底层通过 IOHIDEventSystemClientHIDServiceClient 发送而来。

那么,底层发送而来的事件是怎样的形式呢?我们再试探性地打印寄存器内容。试到 rbx 寄存器时,发现了一个很像事件的“东西”,看起来像是表示一次 touch 事件,进一步 po [$rdx class] 打印其类型是 HIDEvent 。看来这就是从 IOHID 层发送上来的用户触摸事件。

想必是 HIDEvent 通过 UIEventFetcher 接收,并转化为 UIEvent 发送到 UIKitCore 框架进行处理 。另外需要注意,从上面调试过程中的调用栈所属线程为 Thread 6 可以断定,上面收集 HIDEvent 的线程并不是主线程。也就是说收集来自 IOHID 层的 HIDEvent 和处理 UIEvent 事件是在不同的线程,而且后者才是在主线程。

UIEventFetcher 还不是最终 boss,再回头看本次的调用栈,从中发现了 __CFRunLoopDoSource1 ,Source1 是通过发送 mach port 消息触发的,原来这隐藏的幕后黑手竟然是内核!

最后的问题,是谁唤醒了主线程处理 Source0 还是说根本不需要?为验证这个问题,再下一个 CFRunLoopWakeUp 的全局断点,发现点击按钮后,确实有触发 CFRunLoopWakeUp 唤醒 RunLoop,那么这个 RunLoop 具体是哪个 RunLoop 呢?我们再试探性的打印寄存器内容,发现 rdi 寄存器里面保存的恰好是一个 RunLoop 对象,如下图所示。通过 po CFRunLoopGetMain() 打印主线程 RunLoop 对象后可以确认,这里 唤醒的正是主线程 RunLoop

至此,主线程通过 Source0 接收并触发 UIEvent 的流程就可以串联起来了。

Source1调试

紧接上一节的进度,继续探索 Source1 接收来自底层的 HIDEvent 的流程。陷入断点时,用 bt 命令查看调用栈,可见 Source1 的触发流程如下。其中 __CFMachPortPerform__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__ 调用用来触发 mach port 对应的 Source1 的回调事件的函数。RunLoop 的某次运行迭代,若没有检测到待处理的 mach port 消息,则不会触发 __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__

-> CFRunLoopRunXXX -> __CFRunLoopRun -> __CFRunLoopDoSource1 -> __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__ -> __CFMachPortPerform

关注到第 3 号和第 5 号栈帧。试探性地打印寄存器内容,可以查看到接收 IOHID 事件的 NSMachPort 对象,以及其对应的 Source1 内容。Source1 的回调是 __IOHIDEventSystemClientQueueCallback ,对应上面的调用栈中的第 2 号栈帧,由 __CFMachPortPerform 触发。

关于 IOHID 事件消息如何发送到 mach port,通过 sendPortsendBeforeDatereceivePort 断点是截获不到该过程的,估计其实现是直接调用了内核的 mach port 消息发送 API 实现的。不过该部分过程比较明显,这里就不继续调试了。只需要知道,如果是自定义的 Source1 输入源,需要给输入源指定 NSMachPort 对象,消息发送接收通过 sendPortsendBeforeDatereceivePort API 实现即可。

猜想:关于为何尝试了各种断点都没有捕捉到 mach port 消息的发送动作,很可能是因为该消息是从系统的另外一个进程发送过来的,其中最可能就是 SpringBoard,作为 iOS 的桌面 APP,SpringBoard 率先处理来自加速计事件处理横竖屏切换、接收锁屏键音量键等事件本是理所应当的。另外,从 iOS 6.0 开始,苹果引入了 BackBoard 分担了 SpringBoard 的部分功能,例如,处理来自光传感器的信号调整屏幕亮度、桌面 APP 图标的点击及长按事件。BackBoard 和 SpringBoard 一样,也是一个 Daemon 进程。

Timer调试

为调试 Timer,在 Demo 中增加一句使用 CFRunLoopTimer 的代码,实际上随便写一个 NSTimer 也可以,因为前面提到过两者是 toll-free bridged 的。

//调试Timer
CFRunLoopTimerRef defaultTimer = CFRunLoopTimerCreateWithHandler(kCFAllocatorDefault, 0, 1, 0, 0, ^(CFRunLoopTimerRef timer) {
    static int tick = 0;
    NSLog(@"Timer in default mode tick: %d", tick);
});
CFRunLoopAddTimer(CFRunLoopGetCurrent(), defaultTimer, kCFRunLoopDefaultMode);

//不要忘了手动 Release CF 资源,此时 Timer 会被 RunLoop 持有,因此添加完可以直接释放
CFRelease(defaultTimer);
复制代码

NSLog 除打上断点,运行不多久 Demo 程序就会陷入断点,此时 bt 查看调用栈,可以看到其调用 timer source 的触发过程也是相当简单的,其流程如下:

-> CFRunLoopRunXXX -> __CFRunLoopRun -> __CFRunLoopDoTimers -> __CFRunLoopDoTimer -> __CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__

前文提到过 Timer 的本质是在 RunLoop 中注册时间点。翻了 RunLoop 源代码,发现该时间的参照标准是来自内核的 uint64_t mach_absolute_time(void) 函数。时间点注册则是间接调用了 dispatch_time 。看来 无论是 NSTimer 还是 CFRunLoopTimer 定时器,本质都是通过 GCD Timer 实现的

CF_PRIVATE dispatch_time_t __CFTSRToDispatchTime(uint64_t tsr) {
    uint64_t tsrInNanoseconds = __CFTSRToNanoseconds(tsr);
    if (tsrInNanoseconds > INT64_MAX - 1) tsrInNanoseconds = INT64_MAX - 1;
    return dispatch_time(1, (int64_t)tsrInNanoseconds);
}
复制代码

Observer调试

用以下代码调试 CFRunLoopObserver 。在 NSLog 处打上断点,运行程序很快就会陷入断点。Observer 的触发流程如下:

-> CFRunLoopRunXXX -> __CFRunLoopRun -> __CFRunLoopDoObservers -> __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__

CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, kCFRunLoopAfterWaiting, true, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
    NSLog(@"");
});
CFRunLoopAddObserver(CFRunLoopGetCurrent(), observer, kCFRunLoopDefaultMode);
CFRelease(observer);
复制代码

Autorelease pool 是和 RunLoop 有十分密切的联系的。用户点击界面上的按钮时,主线程就会从阻塞状态转向运行状态(不考虑就绪中间态),主线程 RunLoop 也会触发 kCFRunLoopAfterWaiting 状态变更。同理,APP 静止时,主线程 RunLoop 就会进入 kCFRunLoopBeforeWaiting 。此时,RunLoop 会调用一次 objc_autoreleasePoolPop 清理 autorelease pool,紧接着调用 objc_autoreleasePoolPush 新建 autorelease pool,并发送 mach_msg 消息进入内核态,主线程进入阻塞状态。

2.4.2 解读RunLoop源代码

文章还是太长,再写下去就太太太太长了。这里直接安利 Ibireme 的[ 深入理解RunLoop ]吧。他的博文对 RunLoop 关键代码提取相当精炼。这里借用一张 Ibireme 文章里面总结的非常好的 RunLoop 处理 Input Sources 的流程图。

三、RunLoop与事件响应

本来是打算把本文写成《调试iOS用户交互事件响应流程》续集,标题原定《事件响应与RunLoop》写着写着(其实是边写边学)发现,RunLoop 渐渐“喧宾夺主”了,既然如此,于是将计就计,换了个顺序,让 RunLoop 做了“大哥”。

得益于第二部分调试 RunLoop 时,已经使用了事件响应作为例子调试了 RunLoop 的各种 input source 事件的响应逻辑,这里可以直接整理出 iOS 通过 RunLoop 处理用户事件的流程:

我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章