主线程卡顿的根源与 RunLoop 有哪些关系?为什么 UI 更新和事件处理会被阻塞?CFRunLoopObserver 如何用来调试卡顿?
主线程卡顿(Jank)的本质是 RunLoop 的循环频率降低,导致无法在 16.7ms(60Hz)或 8.3ms(120Hz)内完成一次完整的事件闭环。
由于 iOS 的 UI 渲染和事件分发高度依赖 RunLoop,一旦循环的某个环节“超时”,用户就会感知到掉帧。
1. 根源:RunLoop 的串行执行特性
RunLoop 本质上是一个 do-while 循环。在一个循环周期内,它会按顺序处理 Timer、Source0、Source1 等。
- 阻塞逻辑:RunLoop 是串行的。如果 Source0(如处理点击事件)中包含了一个耗时的 C 函数或同步网络请求,RunLoop 就会停在这一步,无法继续向下执行。
- 渲染中断:UI 更新(Layout/Draw)通常挂载在
kCFRunLoopBeforeWaiting之前的 Observer 回调中。如果前面的任务没跑完,RunLoop 永远到不了“准备休息并刷新界面”的那一步,画面就会卡死。 - 无法响应:如果 RunLoop 被阻塞在某个任务中,它就无法进入下一次循环去接收 Source1 里的新触摸事件。这就是为什么卡顿时,你点的按钮没反应。
2. 为什么 UI 更新会被阻塞?
iOS 的 UI 渲染遵循 “提交-绘制” 模型。
- 收集任务:当你修改
view.frame时,系统并不会立刻改变屏幕像素,而是将这个 view 标记为dirty,并放入一个全局的 待处理队列(Transaction) 中。 - 触发点:主线程 RunLoop 注册了一个 Observer 监听
kCFRunLoopBeforeWaiting(即将休眠)。 - 批量渲染:只有当所有的业务逻辑代码执行完,RunLoop 准备休息时,这个 Observer 才会触发。它会遍历所有
dirty的视图,执行layoutSubviews、drawRect,并把渲染数据提交给 Render Server(一个独立进程)。
卡顿发生的时刻:如果你在执行 touchesBegan 时写了一个死循环,RunLoop 始终无法到达 BeforeWaiting 状态,Observer 就永远不会触发,屏幕也就永远不会刷新。
3. CFRunLoopObserver 如何调试卡顿?
由于 RunLoop 的状态切换非常明确,我们可以通过监测 状态切换之间的时间差 来精准定位卡顿。
核心原理
监控以下两个时间段:
- 从
kCFRunLoopAfterWaiting(唤醒)到kCFRunLoopBeforeSources(处理任务前)。 - 从
kCFRunLoopBeforeSources(开始处理)到kCFRunLoopBeforeWaiting(准备休息前)。
如果这两个时间段中的任何一个超过了阈值(如 200ms),就说明主线程发生了足以引起感知的卡顿。
调试步骤 (典型实现方案)
-
开辟子线程:必须在子线程监控,因为主线程卡住时,主线程的代码无法执行。
-
创建 Observer:在主线程注册一个
CFRunLoopObserver,回调函数中更新一个全局状态变量(记录当前 RunLoop 状态)。 -
信号量监控:
- 子线程开启一个持续循环。
- 每次循环调用
dispatch_semaphore_wait(semaphore, timeout)。 - 主线程 Observer 在每次状态变化时发送信号量
dispatch_semaphore_signal。
-
堆栈抓取:如果
wait超时(返回非 0),说明主线程在两个状态切换之间卡住了。此时立即通过backtrace或BSBacktraceLogger抓取主线程的函数调用堆栈。
总结:卡顿监控的闭环
| 监控点 | 代表的意义 |
|---|---|
| AfterWaiting BeforeSources | 唤醒后处理内核消息或 Timer 耗时。 |
| BeforeSources BeforeWaiting | 处理 Source0(你的大部分业务代码)耗时。 |
| BeforeWaiting AfterWaiting | 正常的休眠时间(此时不统计)。 |