背景介绍
用户在使用App时,通常会遭遇以下典型场景:
- 打开复杂页面时出现黑屏/白屏延迟
- 列表滑动时偶发性卡顿
- 图片加载时界面响应滞后
- 网络请求密集时出现操作卡死等现象
这些场景不仅出现在低端设备上,在中高端机型中同样存在。如果主线程无法响应用户的交互就会造成卡顿,卡顿时间比较长是比较影响App的功能和用户体验的。在移动应用开发中,卡顿问题也始终是影响用户体验的核心痛点。
通常情况下,导致主线程阻塞并引发卡顿的原因主要有以下几种:
- 繁重的UI渲染: 当界面包含复杂的视图层级、大量的图文混排内容时,计算布局和绘制到屏幕上的工作量会急剧增加,超出单次刷新周期的处理能力。
- 主线程同步网络请求: 在主线程中发起同步的网络调用,意味着整个应用必须等待网络数据返回后才能继续执行,期间无法响应任何用户操作。
- 大量的文件读写(I/O): 在主线程上直接进行大规模的数据读取或写入操作,例如读写数据库或本地文件,会因为磁盘速度的限制而消耗大量时间。
- 高负荷的计算任务: 将复杂的算法或大量数据的处理逻辑直接放在主线程执行,会导致CPU持续处于高占用状态,无暇顾及UI事件。
- 线程锁使用不当: 当主线程需要等待其他线程释放某个锁资源时,它会被挂起,如果等待时间过长,便会造成卡顿。在极端情况下,不同线程间的相互等待还会引发“死锁”,导致应用彻底无响应。
由于这些问题的偶发性和环境依赖性,传统的线下调试手段往往难以奏效。为了能够精确、高效地定位并解决这些线上卡顿,我们进行一些卡顿监控技术的探索。
主流卡顿监控方案
在iOS开发中,以下是几种常见的主流卡顿监控方案:
- Ping线程方案
- FPS监控方案
- RunLoop监控方案
Ping线程方案简介
Ping线程方案的核心思想是:
- 创建一个子线程,通过子线程来“探测”主线程的响应能力。
- 子线程每次 ping 主线程时设置标记位为YES,然后派发任务到主线程中,主线程把标记位设置为NO。
- 子线程sleep指定时间,超时后判断标记位是否设置为NO,如果没有说明主线程发生了卡顿。
如下图所示:


关键实现步骤:
- 创建子线程:启动一个独立的监控线程。
- 定时派发任务:子线程定期向主线程派发一个简单的任务,并设置一个等待标记。
- 等待主线程响应:主线程执行该任务时,会回调子线程,并清除等待标记。
- 超时判断:如果子线程在派发任务后的一小段时间内,发现等待标记仍未被清除,则判定主线程卡顿。
- 捕获与上报:执行堆栈捕获和上报流程。
Ping线程的方案逻辑相对比较简单,也比较容易理解。但精度较差,Ping之间可能存在漏查的情况。同时,Ping线程会不停唤醒主线程RunLoop,也会存在一定的性能损耗。
FPS监控方案简介
通常情况下屏幕会保持60Hz/s的刷新速度(新的iOS设备甚至会保持120Hz/s的刷新速度),每次刷新时会发出一个屏幕刷新信号,CADisplayLink允许开发者注册一个与刷新信号同步的回调处理。

我们可以通过计算它1秒内调用多少次来查看界面的流畅度。虽然CADisplayLink更轻量,但需要在CPU稍微清闲时才能够回调,严重卡顿的堆栈获取不一定及时,并且就算50fps以下通过肉眼来看也是连贯的。所以,简单的通过监控FPS很难确定是否出现了卡顿问题。
RunLoop方案简介
基于RunLoop的监控方案,是目前比较主流的可用于生产环境的监控方案。其原理是利用CFRunLoopObserver来观察主线程RunLoop的状态变化。这里通过引用戴铭关于RunLoop原理的图,来对RunLoop方案的原理进行简单介绍:


- 通知 observers: RunLoop 即将开始“进入loop”
- 随后,会开启一个do while来保活线程
- 通知 obersers:RunLoop 会触发Timer、Source0回调,紧接着执行加入的block
- 如果 Source1 是 ready 状态,会跳转到“处理消息”流程
- 通知 observers:RunLoop 即将进入休眠状态
- 等待 mach_port 消息,以再次唤醒
- 基于 port 的 Source 事件
- Timer 时间到
- RunLoop 超时
- 被调用者唤醒
- 通知 observers:RunLoop 被唤醒
- 处理消息
- 继续下一个loop
基于RunLoop的方案实现中,一般会包含下面几个关键步骤:
- 注册Observer:向主线程RunLoop注册Observer来监听其状态
- 创建监控线程:用于监控主线程切换状态
- 状态标记与超时判断:子线程根据RunLoop的状态变化来设置标记,并循环检测该标记是否在预设的阈值内被更新,否则判定为卡顿。
- 堆栈捕获与上报:判定为卡顿时,捕获主线程的调用堆栈,并上报服务器进行分析。
基于RunLoop的方案能够精准捕获到由主线程阻塞导致的各类卡顿,适用于线上卡顿问题的监控和诊断分析。
方案对比
基于主流方案对比分析,三种性能监控策略分别聚焦不同维度,如下表:
对比维度 | Ping线程方案 | FPS监控方案 | RunLoop方案 |
核心原理 | 子线程定期派发任务检测主线程响应能力 | 通过CADisplayLink统计单位时间回调次数计算FPS | 通过CFRunLoopObserver监听状态切换超时事件 |
监控粒度 | 中等:依赖探测频率,可能遗漏间隔卡顿 | 较低:侧重平均表现,偶发严重卡顿易被平均 | 高精度:可捕捉单次长耗时阻塞事件 |
根因定位 | 中等:超时后捕获堆栈,但存在时机延迟 | 弱:仅反映流畅度结果,无法定位代码堆栈 | 强:超时时直接捕获主线程堆栈定位具体代码 |
性能开销 | 较低:子线程开销+轻微主线程负担,总体影响小 | 极低:CADisplayLink系统级轻量机制 | 较低:Observer回调轻量,仅卡顿时有额外处理 |
复杂度 | 中等:需处理线程管理和超时逻辑 | 低:基于计数与时间戳的简单实现 | 高:需深入RunLoop机制与多线程同步 |
适用场景 | 快速搭建基础卡顿监控 | 量化界面流畅度(如滚动/动画优化) | 定位主线程阻塞(I/O、死锁、复杂计算等) |
- Ping线程方案通过子线程周期性探测主线程响应时间识别卡顿,但精度低于RunLoop方案。
- FPS监控作为全局性能指标,通过帧率波动反映应用流畅度并判断卡顿,却无法定位性能瓶颈。
- RunLoop方案则介入主线程事件循环机制,实现单次阻塞事件的毫秒级捕捉,可精准识别主线程阻塞源。
卡顿监控方案实现
卡顿监控方案的核心目标在于精准捕获并定位导致用户操作中断、体验显著下降的"阻塞型"卡顿。当卡顿发生时,不仅包括识别卡顿事件的发生,更需追溯具体代码行级的执行路径以定位问题根源。
相较于其他主流方案,RunLoop监控方案通过持续追踪主线程任务执行耗时,能够精确捕获卡顿事件并同步采集完整的上下文调用堆栈信息。尽管其技术实现复杂度较高,但考虑到可部署于线上环境,以及对卡顿根因的诊断价值,最终被选定为核心实现方案。
上文中已经对RunLoop的原理有过大致的介绍,下文中将主要介绍如何具体实现。
监控RunLoop状态
基于RunLoop实现卡顿监控方案,需要先监控RunLoop的状态切换。如下图,通过注册的Observer可以监听到主线程RunLoop状态的切换事件,并通过running和startTime来记录关联的状态和时间戳信息。监控线程通过读取running状态,以及startTime来判断是否产生了状态超时:


当主线程在执行某个任务的耗时较长时,RunLoop的状态切换就会延时。通过在子线程监控RunLoop关键状态之间的时间差,就可以判断主线程是否发生了阻塞。
方案实现中,
- 当Observer收到kCFRunloopBeforeTimers、kCFRunloopBeforeSource、kCFRunLoopAfterWaiting通知时,会把running状态置为YES,并通过startTime记录当前时间戳。
- 当Observer收到kCFRunloopBeforeWaiting和kCFRunLoopExit通知时,把running状态置为NO。
- 监控线程需要持续读取running状态和startTime时间戳,通过判断当前时间与startTime的差异来确定是否发生了卡顿,如下图:


堆栈提取
当RunLoop状态超时,即检测到卡顿时,需要提取主线程堆栈并保存到内存中。堆栈的提取方案基于业界知名的KSCrash实现。相比通过系统函数获取堆栈,通过KSCrash获取的堆栈可以配合dSYM进行符号还原,能够定位到具体的代码位置,而且性能消耗也不大。


耗时堆栈提取
当监控线程监测主线程RunLoop时,会获取主线程的线程快照作为卡顿堆栈。但是,这个主线程堆栈不一定是最耗时的堆栈,也不一定是导致主线程超时的主要原因。为了对这个问题进行优化,需要在检测到主线程发生卡顿时,通过对保存在循环队列中的堆栈进行回溯(间隔50ms获取一次),获取最近最耗时堆栈。


如上图所示,通过以下特征找出最近最耗时堆栈:
- 以栈顶函数为特征,栈顶函数相同的,整个堆栈就是相同的,如:
- 堆栈A的栈顶调用函数为FuncA
- 堆栈B的栈顶调用函数为FuncB
- 栈顶函数FuncA与FuncB不同,因此堆栈A和堆栈B为不同的堆栈
- 获取堆栈间隔相同,堆栈的重复次数近似作为堆栈的调用耗时,重复越多耗时越多,如:
- 堆栈A重复一次,近似耗时为50ms
- 堆栈B重复一次,近似耗时为50ms
- 堆栈C重复三次,近似耗时为150ms
- 堆栈C为最耗时堆栈
- 重复次数相同的堆栈中,取最近的一个作为最耗时堆栈
监控线程退火算法
卡顿检测机制在无异常场景下性能开销可忽略,但遭遇持续数秒级卡顿时,频繁采集主线程堆栈信息时将引发显著性能损耗。且连续重复的堆栈记录无分析价值,完全没有必要。为了降低卡顿监测带来的性能损耗,SDK采用了退火算法递增时间间隔,避免因同一个卡顿问题带来的性能问题。


- 每次子线程检测到主线程卡顿,会先获取主线程的堆栈,并保存到内存中
- 把获得的主线程堆栈与上次卡顿获得的线程堆栈进行比对
- 不同:获得当前线程快照并写入到文件
- 相同:跳过,并按照斐波那契数列把检测时间递增,直到没有卡顿或卡顿堆栈不同
以上算法可以避免同一个卡顿写入多个文件的情况,避免检测线程遇到主线程卡死的情况下,不断写线程快照文件。
性能开销
任何一个监控工具的首要原则是不能影响被监控对象的性能。因此,我们还需要测量基于 RunLoop 的卡顿监控方案对应用性能的实际影响。性能测量的核心是进行A/B对比测试。我们需要准备两个几乎完全相同的App版本:
- A版本(基准版本):卡顿监控功能完全禁用。
- B版本(监控版本):卡顿监控功能完全开启。
然后在完全相同的设备和环境下,对这两个版本执行相同的操作,并测量关键性能指标的差异。这个差异就是卡顿监控带来的性能开销。
测试设备:iPhone 12 Pro
测试系统:iOS 18.7
卡顿监控功能没有开启的情况下,App持续运行一段时间并主动触发卡顿,App整体的CPU占用如下图:

卡顿监控功能开启的情况下,App持续运行一段时间并主动触发卡顿,App整体的CPU占用如下图:

在卡顿监控开启情况下,监控线程的CPU占用如下。
有卡顿发生时:

无卡顿发生时:

综上分析,App引入卡顿监控能力后:
- 无卡顿发生时,对App性能几乎无影响
- 有卡顿发生时,App整体CPU占用增加约0.33%(不同设备的测试值会略有差异)
总结
本文主要介绍了当下主流的iOS卡顿监控方案,和基于RunLoop的卡顿监控实现细节,包括RunLoop状态的处理,堆栈以及耗时堆栈的提取,持续卡顿场景下的退火处理等。卡顿监控方案的实现过程中,通过融合行业成熟优秀的方案思路,实现了主线程阻塞卡顿的检测能力。卡顿监控能力还在持续进化,后续还有不少可以优化和提升的点,如支持高CPU占用卡顿、启动卡顿等的检测。目前这套方案已经应用在阿里云ARMS用户体验监控iOS SDK中,您可以参考接入文档体验使用。相关问题可以加入“RUM用户体验监控支持群”(钉钉群号:67370002064)进行咨询。