7-4.【OC】【RunLoop】主线程卡顿的根源与 RunLoop 有哪些关系?为什么 UI 更新和事件处理会被阻塞?

3 阅读3分钟

主线程卡顿的根源与 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 渲染遵循 “提交-绘制” 模型。

  1. 收集任务:当你修改 view.frame 时,系统并不会立刻改变屏幕像素,而是将这个 view 标记为 dirty,并放入一个全局的 待处理队列(Transaction) 中。
  2. 触发点:主线程 RunLoop 注册了一个 Observer 监听 kCFRunLoopBeforeWaiting(即将休眠)。
  3. 批量渲染:只有当所有的业务逻辑代码执行完,RunLoop 准备休息时,这个 Observer 才会触发。它会遍历所有 dirty 的视图,执行 layoutSubviewsdrawRect,并把渲染数据提交给 Render Server(一个独立进程)。

卡顿发生的时刻:如果你在执行 touchesBegan 时写了一个死循环,RunLoop 始终无法到达 BeforeWaiting 状态,Observer 就永远不会触发,屏幕也就永远不会刷新。


3. CFRunLoopObserver 如何调试卡顿?

由于 RunLoop 的状态切换非常明确,我们可以通过监测 状态切换之间的时间差 来精准定位卡顿。

核心原理

监控以下两个时间段:

  1. kCFRunLoopAfterWaiting(唤醒)到 kCFRunLoopBeforeSources(处理任务前)。
  2. kCFRunLoopBeforeSources(开始处理)到 kCFRunLoopBeforeWaiting(准备休息前)。

如果这两个时间段中的任何一个超过了阈值(如 200ms),就说明主线程发生了足以引起感知的卡顿。

调试步骤 (典型实现方案)

  1. 开辟子线程:必须在子线程监控,因为主线程卡住时,主线程的代码无法执行。

  2. 创建 Observer:在主线程注册一个 CFRunLoopObserver,回调函数中更新一个全局状态变量(记录当前 RunLoop 状态)。

  3. 信号量监控

    • 子线程开启一个持续循环。
    • 每次循环调用 dispatch_semaphore_wait(semaphore, timeout)
    • 主线程 Observer 在每次状态变化时发送信号量 dispatch_semaphore_signal
  4. 堆栈抓取:如果 wait 超时(返回非 0),说明主线程在两个状态切换之间卡住了。此时立即通过 backtraceBSBacktraceLogger 抓取主线程的函数调用堆栈


总结:卡顿监控的闭环

监控点代表的意义
AfterWaiting \rightarrow BeforeSources唤醒后处理内核消息或 Timer 耗时。
BeforeSources \rightarrow BeforeWaiting处理 Source0(你的大部分业务代码)耗时。
BeforeWaiting \rightarrow AfterWaiting正常的休眠时间(此时不统计)。