Runloop
引出 RunLoop
在学习 C 语言的时候,一般的命令行程序运行之后会马上结束。如果想要让程序不直接退出,而是一直等待用户的输入,并根据用户的输入决定是否退出程序,那就得加一个循环实现了。如以下这个简单的 C 程序:
1 |
|
这样使用一个循环就实现了让一个命令行程序运行之后不立即结束,并根据用户的输入执行不同的操作。那么对于 iOS 的应用程序来说,它同样是 APP 启动之后不会立即结束,并一直等待用户的交互,是不是同样存在着一个循环来实现这个效果的?答案是肯定,让 iOS 应用程序启动之后不立即结束,并一直等待用户交互的东西就是今天要介绍的 RunLoop。只不过相较于命令行程序的简单循环,RunLoop 的实现更加复杂。它负责处理用户的触摸事件,手势,UIView 的绘制,管理定时器,等等
对于第一次接触或者听说 RunLoop 的开发者来说,可能会感觉很抽象。但其实它一点儿也不抽象,它是一个实实在在的 CF 实例对象。它的实现在 CoreFoundation 框架。本质就是一个名为 __CFRunLoop 的 C 结构体及相关的 API 函数。在 Objective-C 层面,虽然存在一个 NSRunLoop 的类(来自于 Foundation 框架),但这个类只是对 CoreFoundation 框架中的 CFRunLoopRef 的包装,NSRunLoop 有一个成员变量就是 CFRunLoopRef,而 CFRunLoopRef 就是指向 __CFRunLoop 的结构体指针。
我们都知道 APP 在启动时会在入口点 main 函数中调用 UIApplicationMain() 函数。而这个函数里面会调用 CoreFoundation 提供的 CFRunLoopRunInMode() 函数启动运行循环,第一个参数传的是 kCFRunLoopDefaultMode,第二个和第三个参数传入的都是 0。我们可以添加一个符号断点来验证这个说法。之后我们会深入 CoreFoundation 的源码,分析 CFRunLoopRunInMode() 的底层实现。最后是一些 RunLoop 的应用场景。
关于符号和符号断点
符号(Symbols)和符号断点(Symbolic Breakpoints)是调试过程中的重要工具。以下是它们的详细解释和用法:
符号(Symbols)
定义:
- 符号是编译器在构建过程中生成的标识符,用于在二进制文件中表示代码中的实体(如函数、方法、类、全局变量等)。
- 在调试时,符号将机器码的地址映射回可读的源代码名称,帮助开发者理解程序执行流程。
作用:
- 调试信息:符号允许调试器(如LLDB)将内存地址、汇编指令与源代码中的变量名、方法名关联。
- 崩溃分析:符号化的崩溃日志(Symbolicated Crash Logs)能将堆栈跟踪中的地址转换为可读的类名和方法名。
- 动态链接:在动态库或框架中,符号用于运行时解析函数调用。
生成条件:
- 通常,Debug 构建配置会默认包含完整的调试符号,而 Release 配置可能会通过优化(如剥离符号)减少二进制体积。
符号断点(Symbolic Breakpoints)
定义:
- 符号断点是一种根据符号名称(而非具体代码行)触发的断点。当程序执行到与符号匹配的位置时,调试器会暂停。
常见用途:
- 跟踪系统方法调用(如
viewDidLoad、dealloc)。 - 监控属性访问(如
setter方法-[MyClass setName:])。 - 调试第三方库或框架(无需源码即可设置断点)。正是这次演示的用途。
- 分析特定条件的行为(如内存释放、网络请求触发)。
添加符号断点
打开一个 iOS 工程,在 Xcode 的左侧导航栏中选择断点导航栏,然后点击左下角的 + 号添加一个符号断点:
符号名就是 CFRunLoopRunInMode
命中符号断点
断点设置好之后,调试启动 APP 就会命中断点,不过可能第一次命中断点的时候并不是主线程,而是一个子线程启动了它的 RunLoop,过掉这个断点就好了,下一个就是我们主线程的 RunLoop 启动的断点了。如下图:
在汇编层面,函数的参数依次存放在寄存器 x0,x1,x2 等等之中,根据参数的个数依次存放在后续的寄存器中。通过 register read LLDB 命令,我们可以查看到函数的参数,除了第一个参数传入了字符串 kCFRunLoopDefaultMode,其他参数都是 0。当然参数个数过多时并不会继续放在后续的寄存器中,但这属于汇编语言层面的知识不是我们需要关注的重点。
现在我们知道,也验证了 iOS APP 启动的流程中会执行到 CoreFoundation 的 CFRunLoopRunInMode() 函数里,那到底这个函数做了什么,是如何实现 iOS APP 的永不结束的?将在下一篇中详细分析。
源码解析
苹果开源了部分 CoreFoundation 框架的源码,我们可以在 https://github.com/orgs/apple-oss-distributions/repositories?type=all 中找到。下载好之后打开发现没有 Xcode 工程,我们自己新建一个 FrameWork 工程,将这些源文件拖进工程就可以在 Xcode 中查看源码了。CFRunLoop.h 和 CFRunLoop.m 文件就是我们需要阅读的源码文件。
RunLoop 的几个核心对象
通过打印 [NSRunLoop currentRunLoop] 对象,可以看到很多信息。就是需要调整一下打印信息的缩进才能清晰的看到这些对象间的关系。RunLoop 对象存在多个 Mode,每个 Mode 中存在着一堆输入源,观察者,定时器。RunLoop 在运行的过程中,就是在一直不断的处理,执行这三种事情。RunLoop 提供了输入源,观察者,定时器,以及相关的 API 函数,让开发者,系统底层库,可以调用相关的 API 创建输入源,观察者,定时器并添加到 RunLoop 中,然后 RunLoop 循环处理这三件事情,当没有事情可做的时候,会让 CPU 进入休眠,不占用一点 CPU 资源。
通过 CFRunLoop.h 头文件,我们可以知道,RunLoop 里面重要的几个对象。分别是 CFRunLoopRef,CFRunLoopSourceRef,CFRunLoopObserverRef,CFRunLoopTimerRef。然后从 CFRunLoop.m 文件中,可以看到每个对象的底层结构体,并理清它们之间的关系。以下是 CFRunLoopRef 的结构体组成,只保留了一些重要的成员。通过 CFMutableSetRef _modes 可以得知一个 RunLoop 实例存在多个模式。常见的模式都有:
- kCFRunLoopDefaultMode:默认模式,处理主线程常规任务。
- UITrackingRunLoopMode:滑动模式,处理 UI 滚动事件(如 UIScrollView 滑动时)。
- kCFRunLoopCommonModes:通用模式,包含多个模式的集合(如 Default + Tracking)。
1 | typedef struct __CFRunLoop * CFRunLoopRef; |
再查看 CFRunLoopModeRef 的结构体组成:
1 | typedef struct __CFRunLoopMode *CFRunLoopModeRef; |
Source(输入源)
其中,每个模式都有 _sources0,_sources1 两个集合,中文译为输入源。咋一听会觉得很抽象,其实这些源中 source1 是系统创建并添加到 RunLoop 的,而应用层开发者只可以创建 source0 并添加到 RunLoop,或者通过间接的方法创建 source1 添加到 RunLoop。
Source0(非基于端口的 Source)
- 角色:处理应用内部手动触发的事件。
- 触发方法:需要开发者主动标记为待处理(CFRunLoopSourceSignal),才会被 RunLoop 处理。
- 示例场景:
- performSelector:onThread: 方法的回调。
- 自定义事件的分发(如手动触发的异步任务)
Source1(基于端口的 Source)
- 角色:接收系统事件(如硬件事件、内核事件),并唤醒 RunLoop。
- 触发方式:由系统底层(如 Mach 端口)直接触发,优先级高。
- 实例场景:
- 用户触摸屏幕(硬件事件)。
- 摇晃设备。
- 系统推送(如 NSURLConnection 的回调)。
触摸事件的完整处理流程
触摸事件的处理是由 Source1 和 Source0 协作完成的,具体流程如下:
步骤1:Source1 接收事件(唤醒 RunLoop)
- 当用户触摸屏幕时,系统底层(IOKit)生成一个硬件事件(如 IOHIDEvent)。
- 该事件通过 Mach 端口(系统级的进程间通信机制)传递给 SpringBoard(iOS 的桌面进程)。
- SpringBoard 将事件转发给前台 App 的 主线程 RunLoop,并触发 Source1(__IOHIDEventSystemClient 端口事件)。
- Source1 的作用:唤醒主线程 RunLoop,使其进入活动状态。
步骤2:Source0 处理事件(分发到应用层)
- RunLoop 被唤醒后,会处理当前 Mode 下的所有事件。额,其实就是继续往下执行后续的代码。。。
- Source1 事件触发后,被调用 __IOHIDEventSystemClientQueueCallback() 函数,将事件包装成 UIEvent 对象。
- 此时,系统会将事件标记为待处理,并生成一个 Source0 事件(如 _UIApplicationHandleEventQueue)。
- Source0 的作用:将事件分发给应用内部的 UIWindow、UIView 等组件,触发 touchesBegan:withEvent: 等回调方法
流程总结
硬件事件 -> Source1(唤醒 RunLoop)-> 转换为 Source0 事件 -> Source0(分发到应用层)
- 开发者接触到的是 Source0:应用层代码(如 touchesBegan:)是由 Source0 触发的,因此很多人误以为触摸事件时纯 Source0 处理的。
- 底层依赖 Source1:实际的事件接收和唤醒 RunLoop 的过程由 Source1 完成,但这一部分对应用层开发来说是未知的。
Observer(观察者)
其次是 _observers 观察者数组,乍一听也觉得很抽象,其实这个观察者是 RunLoop 内部实现的一个通知机制,RunLoop 会在不同的阶段去通知观察者此时 RunLoop 的状态是什么。开发者可以自己创建观察者并加入到 RunLoop,就能够在 RunLoop 的不同阶段执行一些逻辑。iOS 底层就利用了 RunLoop 的观察者机制优化主线程的内存,在 RunLoop 的关键阶段(进入、休眠、退出)隐式管理自动释放池,减少手动干预。
- 核心作用
- 监听 RunLoop 的状态变化:例如 RunLoop 即将处理事件、即将休眠、即将退出等状态。
- 被动回调:Observer 本身不处理事件,仅用于监控 RunLoop 的声明周期。
- 监听的状态
1
2
3
4
5
6
7
8typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
kCFRunLoopEntry, // 即将进入 RunLoop
kCFRunLoopBeforeTimers, // 即将处理 Timer
kCFRunLoopBeforeSources, // 即将处理 Source
kCFRunLoopBeforeWaiting, // 即将休眠
kCFRunLoopAfterWaiting, // 从休眠中唤醒
kCFRunLoopExit // 即将退出 RunLoop
}; - 使用场景
- 性能监控:统计 RunLoop 的卡顿情况。
- 资源管理:在 RunLoop 休眠时释放资源(如 AutoreleasePool 的创建和释放)
- 代码示例
1
2
3
4
5
6
7
8
9
10// 创建 Observer 监听 RunLoop 状态
CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(
kCFAllocatorDefault,
kCFRunLoopAllActivities, // 监听所有状态
YES, 0,
^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
// 根据 activity 处理逻辑
}
);
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
Timer(定时器)
最后是 _timers 数组,这个大家应该就很熟悉了,NSTimer 就是基于 RunLoop 实现的,平时创建 NSTimer 定时器都会加入到 RunLoop 就是添加到这个地方。
- 核心作用
- 处理时间相关的任务:在指定时间或周期触发回调。
- 基于时间的触发:与 RunLoop 的当前模式和时间有关。
- 特点
- 依赖 RunLoop 的执行。
- 定时器的触发精度受 RunLoop 当前任务影响,主线程卡顿时,定时器执行可能不会准时。此时可以考虑使用 GCD 的定时器,它不依赖 RunLoop。
- 需要添加到 RunLoop 的某个 Mode 中才会生效。
- 常见类型:
- NSTimer:Foundation 框架的定时器。
- CADisplayLink:与屏幕刷新率同步的定时器(用于动画)。
- 依赖 RunLoop 的执行。
- 使用场景
- 轮询任务,如倒计时。
- 周期性 UI 更新,如动画。
- 代码示例:
1
2
3// 创建 Timer 并添加到 RunLoop
NSTimer *timer = [NSTimer timerWithTimeInterval:1.0 target:self selector:@selector(tick) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
CFRunLoopRunInMode() 关键流程分析
查看 CFRunLoopRunInMode() 的实现会发现其实内部只有一句函数调用代码。就是 CFRunLoopRunSpecific(),这个函数也不是真正 RunLoop 运行的核心,而是它内部的 __CFRunLoopRun() 函数。不过当前函数内也开始处理观察者了,所以也可以从这里开始分析。
我将主要的流程代码保留,细枝末节的代码都删掉了方便查看整体流程:
1 | SInt32 CFRunLoopRunSpecific(CFRunLoopRef rl, CFStringRef modeName, CFTimeInterval seconds, Boolean returnAfterSourceHandled) { /* DOES CALLOUT */ |
从源码中,我们看到调用非常频繁的一个函数是 __CFRunLoopDoBlocks() 处理 RunLoop 的 Blocks。它的主要意义是将 CFRunLoopPerformBlock() 创建的 Block 任务提交到指定 RunLoop 的特定模式下,在 RunLoop 的下一个循环中适时执行。这种机制允许开发者在 RunLoop 的不同阶段灵活调度任务,确保代码在正确的线程和时机执行,同时避免阻塞其他事件源。
与 RunLoop 相关的应用场景
解决滑动冲突
相信很多 iOS 开发者都遇到过这个情况,在 UIScrollView(或者其子类)滚动的时候,NSTimer 定时器不执行了。默认的情况下,你创建的 NSTimer 定时器是加入到 RunLoop 的 kCFRunLoopDefaultMode 模式。而在你滑动 UIScrollView 的时候,此时 RunLoop 切换到了 UITrackingRunLoopMode,所以定时器不执行。解决办法是将 NSTimer 定时器加入 kCFRunLoopCommonModes 模式。
kCFRunLoopCommonModes 并不是一个单独的模式,它是一个标记。对于每一个单独的模式,它都可以被添加到 RunLoop 的 _commonModes 中,这是一个集合。当 Timer 添加到 RunLoop 的 kCFRunLoopCommonModes 模式之后,意味着 Timer 在 RunLoop 的 _commonModes 下的每个模式下都可以运行。
子线程保活
监控应用卡顿
LXDAppFluecyMonitor
