无侵入卡顿监测的核心思路是:不修改现有业务代码,通过监听 RunLoop 或利用系统层面的机制,自动捕获主线程上长时间执行的任务。
原理上,所有 UIKit 事件(触摸、滚动、定时器等)都是在主线程 RunLoop 的某次循环中处理的。如果一次循环耗时过长,就会丢掉下一次屏幕刷新的时机,产生卡顿。
下面是一套工程上常用的融合方案,结合了 RunLoop Observer 和子线程定时 Ping 两种策略,可兼顾准确性和性能。
一、核心原理:RunLoop 监听法
主线程的 RunLoop 会在多个状态间切换:
-
kCFRunLoopBeforeSources:即将处理输入源(触摸、响应链)。 -
kCFRunLoopBeforeTimers:即将处理定时器。 -
kCFRunLoopAfterWaiting:从休眠中醒来,准备干活。 -
kCFRunLoopBeforeWaiting:事情干完,准备休眠。 -
kCFRunLoopExit/kCFRunLoopEntry:进出循环。
如果 kCFRunLoopAfterWaiting 到 kCFRunLoopBeforeWaiting 之间的时间段过长,就代表主线程在处理繁重任务,发生了卡顿。
实现步骤
-
注册一个 CFRunLoopObserver,同时监听
kCFRunLoopAfterWaiting(开始干活)和kCFRunLoopBeforeWaiting(干完活)两个状态。 -
在“开始干活”的回调里:
- 记录一个极其短暂的超时阈值(如 16.7ms,或者放宽到 50ms 以排除正常短耗时)。
- 启动一个子线程的监控任务(或者利用已有的 Dispatch Timer)。
-
在“干完活”的回调里:
- 认为任务已完成,标记为未超时。
-
监控子线程:
- 等待超时时间后,检查是否已经收到了“干完活”的标记。
- 如果没有,说明主线程在单个 RunLoop 周期内卡住超过了阈值 → 触发卡顿记录:立即获取主线程的调用栈(Backtrace)。
- 如果主线程恢复得太慢(比如连续 3~5 次超时,即卡顿 150ms~250ms),再上传堆栈,避免偶发误报。
二、进阶手段:子线程 Ping 法 (ANR/Timeout 检测)
RunLoop Observer 法有时在处理一些极端耗时逻辑时容易漏检(例如长时间在 dispatch_async 的 block 里不回主线程?不对,如果在主线程串行 queue 上异步执行 block,它依然是在 RunLoop 的 Source 里处理的,Observer 可捕获)。
但更简单的 Ping 法也可以作为补充:
- 开启一个监控子线程,每隔 0.1s 做一次“Ping”。
-
Ping 的逻辑:在主线程设置一个标志位
flag为NO,然后dispatch_async(main_queue)将其设为YES。 - 子线程休眠 0.1s 后醒来,检查
flag:- 如果为
YES,说明主线程在 0.1s 内响应了异步任务,流畅。 - 如果为
NO,说明主线程有任务阻塞,没来得及调度这个小 block。
- 如果为
- 连续 3 次
NO,收集主线程堆栈。
这种方法实现极其轻量,且对 RunLoop 内部时序依赖更少。
三、如何真正做到“无侵入”
有了上述原理,还要封装得不侵入业务,一般有两种落地方式:
1. 全局 C 函数 + __attribute__((constructor))
- 在任意
.m文件里写一个CFRunLoopObserverRef的注册逻辑。 - 用
__attribute__((constructor))标记,它会在 main 函数之前自动运行。 - 这样,整个 App 不用 import 任何头文件,也不需要在 AppDelegate 写一行代码。
- 为了无感,OC 代码也可以写在一个 POD/SPM 组件里,直接引入即可生效。
2. Method Swizzling 接管主线程入口
- 有时候还需要监控 RunLoop 休眠后的后台队列任务是否堆积,可以 Hook
UIApplication的sendEvent:来感知触摸事件延迟,但这属于有损“侵入”,一般不作为首选。
四、如何拿到高质量的卡顿堆栈?
卡顿发生时的堆栈记录,是最宝贵的信息。直接在子线程调用 [NSThread callStackSymbolsForReturnAddresses] 虽然能拿,但有风险(信号不安全且不精确)。更推荐:
-
基于信号量
pthread_kill+ 信号处理:- 监控线程发现卡顿,向主线程发送一个信号,如
SIGURG或自定义信号。 - 主线程收到信号后,在信号处理函数中,通过
backtrace_symbols或bsd_thread_get_state记录当前指令地址。
- Mach 内核级的方法是
thread_get_state,它可以暂停目标线程,获取完整寄存器状态和调用栈帧,比pthread_kill更可靠。 - 第三方框架(如 PLCrashReporter 的内部实现)就广泛使用了 Mach 级线程状态获取。
- 监控线程发现卡顿,向主线程发送一个信号,如
但为了轻量级监控,直接调用 [NSThread callStackSymbols] 在主线程未完全卡死时,也能反映出大部分问题。
五、面试精炼回答模板
“我会实现一个基于 CFRunLoopObserver 的、完全无侵入的卡顿监测组件。
首先,核心原理是:主线程所有事件都在 RunLoop 的
AfterWaiting(开始)到BeforeWaiting(休眠)之间处理。我注册一个 Observer 监听这两个状态,在 AfterWaiting 时启动子线程的倒计时,如果过了阈值(比如 50ms)还没收到 BeforeWaiting 回调,就判定为卡顿。为了防止误报,我会让子进程连续检测 3 次超时才记录堆栈。获取堆栈时,我会用 Mach API 的
thread_get_state来暂停主线程并拿到精确的指令指针和帧指针,从而解析出完整的方法调用链,安全性比直接发信号更好。落地时,我利用
__attribute__((constructor))让 Observer 在main之前自动注册,整个组件做成一个 SDK 直接拖入或 pod 引入,不需要业务代码做任何修改,真正做到零侵入。”