如何实现一个无侵入的卡顿监测方案

无侵入卡顿监测的核心思路是:不修改现有业务代码,通过监听 RunLoop 或利用系统层面的机制,自动捕获主线程上长时间执行的任务

原理上,所有 UIKit 事件(触摸、滚动、定时器等)都是在主线程 RunLoop 的某次循环中处理的。如果一次循环耗时过长,就会丢掉下一次屏幕刷新的时机,产生卡顿。

下面是一套工程上常用的融合方案,结合了 RunLoop Observer 和子线程定时 Ping 两种策略,可兼顾准确性和性能。


一、核心原理:RunLoop 监听法

主线程的 RunLoop 会在多个状态间切换:

  • kCFRunLoopBeforeSources:即将处理输入源(触摸、响应链)。
  • kCFRunLoopBeforeTimers:即将处理定时器。
  • kCFRunLoopAfterWaiting:从休眠中醒来,准备干活。
  • kCFRunLoopBeforeWaiting:事情干完,准备休眠。
  • kCFRunLoopExit / kCFRunLoopEntry:进出循环。

如果 kCFRunLoopAfterWaitingkCFRunLoopBeforeWaiting 之间的时间段过长,就代表主线程在处理繁重任务,发生了卡顿。

实现步骤

  1. 注册一个 CFRunLoopObserver,同时监听 kCFRunLoopAfterWaiting(开始干活)和 kCFRunLoopBeforeWaiting(干完活)两个状态。
  2. 在“开始干活”的回调里
    • 记录一个极其短暂的超时阈值(如 16.7ms,或者放宽到 50ms 以排除正常短耗时)。
    • 启动一个子线程的监控任务(或者利用已有的 Dispatch Timer)。
  3. 在“干完活”的回调里
    • 认为任务已完成,标记为未超时。
  4. 监控子线程
    • 等待超时时间后,检查是否已经收到了“干完活”的标记。
    • 如果没有,说明主线程在单个 RunLoop 周期内卡住超过了阈值 → 触发卡顿记录:立即获取主线程的调用栈(Backtrace)。
    • 如果主线程恢复得太慢(比如连续 3~5 次超时,即卡顿 150ms~250ms),再上传堆栈,避免偶发误报。

二、进阶手段:子线程 Ping 法 (ANR/Timeout 检测)

RunLoop Observer 法有时在处理一些极端耗时逻辑时容易漏检(例如长时间在 dispatch_async 的 block 里不回主线程?不对,如果在主线程串行 queue 上异步执行 block,它依然是在 RunLoop 的 Source 里处理的,Observer 可捕获)。

但更简单的 Ping 法也可以作为补充:

  1. 开启一个监控子线程,每隔 0.1s 做一次“Ping”。
  2. Ping 的逻辑:在主线程设置一个标志位 flagNO,然后 dispatch_async(main_queue) 将其设为 YES
  3. 子线程休眠 0.1s 后醒来,检查 flag
    • 如果为 YES,说明主线程在 0.1s 内响应了异步任务,流畅。
    • 如果为 NO,说明主线程有任务阻塞,没来得及调度这个小 block。
  4. 连续 3 次 NO,收集主线程堆栈。

这种方法实现极其轻量,且对 RunLoop 内部时序依赖更少。


三、如何真正做到“无侵入”

有了上述原理,还要封装得不侵入业务,一般有两种落地方式:

1. 全局 C 函数 + __attribute__((constructor))

  • 在任意 .m 文件里写一个 CFRunLoopObserverRef 的注册逻辑。
  • __attribute__((constructor)) 标记,它会在 main 函数之前自动运行。
  • 这样,整个 App 不用 import 任何头文件,也不需要在 AppDelegate 写一行代码。
  • 为了无感,OC 代码也可以写在一个 POD/SPM 组件里,直接引入即可生效。

2. Method Swizzling 接管主线程入口

  • 有时候还需要监控 RunLoop 休眠后的后台队列任务是否堆积,可以 Hook UIApplicationsendEvent: 来感知触摸事件延迟,但这属于有损“侵入”,一般不作为首选。

四、如何拿到高质量的卡顿堆栈?

卡顿发生时的堆栈记录,是最宝贵的信息。直接在子线程调用 [NSThread callStackSymbolsForReturnAddresses] 虽然能拿,但有风险(信号不安全且不精确)。更推荐:

  • 基于信号量 pthread_kill + 信号处理
    1. 监控线程发现卡顿,向主线程发送一个信号,如 SIGURG 或自定义信号。
    2. 主线程收到信号后,在信号处理函数中,通过 backtrace_symbolsbsd_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 引入,不需要业务代码做任何修改,真正做到零侵入。”

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容