为什么会卡顿
图像显示原理

- 关于CPU和GPU都是通过总线连接起来的,在CPU当中输出的往往是一个位图,再经由总线在合适的时机传递个GPU
- GPU拿到这个位图之后,会对这个位图的图层进行渲染,包括纹理的合成等
- 之后会把这个结果放到帧缓冲区中,然后视频控制器会按照VSync信号逐行读取帧缓冲区的数据,经过可能的数模转换传递给显示器,达到最终的显示效果
CPU和GPU做的事情

- 首先当我们创建一个UIView控件的时候,其中负责显示的CALayer
- CALayer中有一个contents属性,就是我们最终要绘制到屏幕上的一个位图,比如说我们创建了一个UILabel,那么在contents里面就放了一个关于Hello world的文字位图
- 然后系统会在一个合适的时机回调给我们一个drawRect:的方法,这个方法中我们可以去绘制一些自定义的内容
- 绘制好了之后,最终会由Core Animation这个框架提交给GPU部分的OpenGL渲染管线,进行最终的位图的渲染,包括纹理合成等,然后显示在屏幕上
CPU
具体分为四个阶段
- Layout:这里主要涉及到一些UI布局,文本计算等,例如一个label的size
- Display:绘制阶段,例如drawRect方法就在这一步骤中
- Prepare:图片的编解码等操作在此步骤中
- Commit:提交位图
GPU渲染管线
- 顶点着色
- 图元装配
- 光栅化
- 片段着色
- 片段处理
UI卡顿、掉帧的原因

在显示器中是固定的频率,比如iOS中是每秒60帧(60FPS),即每帧16.7ms
从上图中可以看出,每两个VSync信号之间有时间间隔(16.7ms),在这个时间内,CPU主线程计算布局,解码图片,创建视图,绘制文本,计算完成后将内容交给GPU,GPU变换,合成,渲染(详细可学习 OpenGL相关课程),放入帧缓冲区
假如16.7ms内,CPU和GPU没有来得及生产出一帧缓冲,那么这一帧会被丢弃,显示器就会保持不变,继续显示上一帧内容,这就将导致导致画面卡顿
所以无论CPU,GPU,哪个消耗时间过长,都会导致在16.7ms内无法生成一帧缓存
FPS 卡顿监控方案
FPS 卡顿监控方案的原理是 通过一段连续的 FPS 计算丢帧率来衡量当前页面绘制的质量。
FPS(Frames Per Second)是指画面每秒传输的帧数。每秒帧数越多,所显示的动画就越流畅,一般只要保持 FPS 在 50-60,App 就会有流畅的体验,反之会感觉到卡顿。
相关系统原理
CADisplayLink 是一个能让我们以和屏幕刷新率相同的频率将内容画到屏幕上的定时器。
一旦 CADisplayLink 以特定的模式注册到 runloop 之后,每当屏幕需要刷新时,runloop 就会调用 CADisplayLink 绑定的 target 上的 selector,此时 target 可以读取到 CADisplayLink 的每次调用的时间戳,用来准备下一帧显示需要的数据。如:一个视频应用使用时间戳来计算下一帧要显示的视频数据。
代码实现
现阶段,常用的 FPS 监控几乎都是基于 CADisplayLink 实现的。
1 | final class FPSMonitor: NSObject { |
CADisplayLink 实现的 FPS 在生产场景中只有指导意义,不能代表真实的 FPS。因为基于 CADisplayLink 实现的 FPS 无法完全检测出当前 Core Animation 的性能情况,只能检测出当前 RunLoop 的帧率。
主线程卡顿监控
主线程卡顿监控方案的原理是 通过子线程监控主线程的 RunLoop,判断两个状态区域之间的耗时是否达到一定阈值。因为主线程绝大部分计算或绘制任务都是以 RunLoop 为单位发生。单次 RunLoop 如果时长超过 16ms,就会导致 UI 体验的卡顿。
美团的移动端性能监控方案 Hertz 采用的就是这种方式。

首先我们需要了解一下 RunLoop 的原理。
RunLoop 定义
RunLoop 是 iOS 事件响应与任务处理最核心的机制。当有持续的异步任务需求时,我们会创建一个独立的生命周期可控的线程。RunLoop 就是控制线程生命周期并接收事件进行处理的机制。
RunLoop 机制
主线程(有 RunLoop 的线程)几乎所有函数都从以下六个函数之一的函数调起:
CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION- CFRunloop is calling out to an abserver callback function
- 用于向外部报告 RunLoop 当前状态的改变,框架中很多机制都由 RunLoopObserver 触发,如:CAAnimation
CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK- CFRunloop is calling out to a block
- 消息通知、非延迟的 perform、dispatch 调用、block 回调、KVO
CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE- CFRunloop is servicing the main dispatch queue
- 执行主队列上的任务
CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION- CFRunloop is calling out to a timer callback function
- 基于定时器的延迟的 perfrom,dispatch 调用
CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION- CFRunloop is calling out to a source 0 perform function
- 处理 App 内部事件、App自己负责管理(触发),如:
UIEvent、CFSocket。普通函数调用,系统调用
CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION- CFRunloop is calling out to a source 1 perform function
- 由 RunLoop 和内核管理,Mach port 驱动,如:
CFMachPort、CFMessagePort
RunLoop 运行时
如下所示为 CFRunLoop 源码中的核心方法 CFRunLoopRun 简化后的主要逻辑。
1 | int32_t __CFRunLoopRun() { |
RunLoop 在运行时一直在向外部报告当前状态的更新,其状态定义如下:
1 | typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) { |
从 RunLoop 运行逻辑中,不难发现 NSRunLoop 调用方法主要在于两个状态区间:
kCFRunLoopBeforeSources和kCFRunLoopBeforeWaiting之间kCFRunLoopAfterWaiting之后
如果这两个时间内耗时太久而无法进入下一步,可以线程受阻。如果这个线程时主线程,表现出来就是出现了卡顿。
代码实现
我们可以通过 CFRunLoopObserverRef 实时获取 NSRunLoop 的状态。具体使用方法如下:
首先创建一个 CFRunLoopObserverContext 观察者 observer。然后将观察者 observer 添加到主线程 RunLoop 的 kCFRunLoopCommonModes 模式下进行观察。
1 | - (void)registerObserver { |
然后,创建一个持续的子线程专门用来监控主线程的 RunLoop 状态。为了让计算更精确,需要让子线程更及时的获知主线程 RunLoop 状态变化,dispatch_semaphore_t 是一个不错的选择。另外,卡顿需要覆盖多次连续短时间卡顿和单次长时间卡顿两种情景,所以判定条件也需要做适当优化。优化后的代码实现如下所示:
1 | - (void)registerObserver { |
检测到卡顿时应该立刻获取卡顿的方法堆栈信息,并推送至服务端共开发者分析,从而解决卡顿问题。
获取堆栈信息:
直接使用 PLCrashReporter 第三方开源库
1 | PLCrashReporterConfig *config = [[PLCrashReporterConfig alloc] initWithSignalHandlerType:PLCrashReporterSignalHandlerTypeBSD |