RunLoop与事件响应

[TOC]html

RunLoop与事件响应

2020-03-22编程

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

1、XNU内核和Mach

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

1.1 XNU内核架构

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

OS X architecture

Darwin and OS X

OS X kernel architecture

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 的。app

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 过程输出日志,完成后才返回用户态。

2、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 的主要区别以下:

  • 事件发送方式不一样,Source0 是经过CFRunLoopSourceSignal发送事件信号,Source1 是经过 mach port 发送事件消息;
  • 事件的复杂度不一样,Source0 的事件是不附带上下文的(至关于简单的1信号),Source1 的事件是附带上下文(有消息内容)的;
  • Source1 比 Source0 多了个 mach port 成员;

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 的流程图。

3、RunLoop与事件响应

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

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

4、总结

  • RunLoop 是线程保活的方式,与线程是一一对应的关系;
  • RunLoop 中包含了若干 mode,mode 中包含了若干输入源,mode 的含义是当 RunLoop 在 mode 状态下执行是,只响应 mode 中的输入源。RunLoop 能够嵌套运行,即在输入源的回调函数调用CFRunLoopRunXXX,使 RunLoop 能够在各类 mode 之间自由切换;
  • Common modes 是一种特殊的 mode,将 mode 标记为 common 意味着会将 RunLoop 中的 common mode items 同步到该 mode 中;
  • 输入源都包含一个回调函数,用户处理接收事件,事件处理逻辑则在回调函数中;
  • 输入源包括 Sources、Timers、Observers,Sources 有两种,Source0 和 Source1;
  • 经过CFRunLoopSourceSignal向 Source0 发送事件信号,若想 RunLoop 当即处理事件则调用CFRunLoopWakeUp唤醒 RunLoop;
  • Source1 与特定的 mach port 关联,经过向 mach port 发送 mach port 消息触发 Source1 事件;
  • Timer 的本质是在 RunLoop 中注册时间点,在时间点到达时触发 Timer 的回调函数,CFRunLoopTimer本质是经过 GCD Timer 实现的;
  • Observer 能够观察 RunLoop 的状态变动,触发 Observer 的回调函数;
  • 用户交互事件首先在 IOHID 层生成 HIDEvent,而后向事件处理线程的 Source1 的 mach port 发送 HIDEvent 消息,Source1 的回调函数将事件转化为 UIEvent 并筛选须要处理的事件推入待处理事件队列,向主线程的事件处理 Source0 发送信号,并唤醒主线程,主线程检查到事件处理 Source0 有待处理信号后,触发 Source0 的回调函数,从待处理事件队列中提取 UIEvent,最后进入 hit-test 等 UIEvent 事件响应流程。
相关文章
相关标签/搜索