0%

卡顿监控

为什么会卡顿

图像显示原理

此处输入图片的描述

  • 关于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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
final class FPSMonitor: NSObject {
    private var timer: Timer?
    private var link: CADisplayLink?
    private var count: UInt = 0
    private var lastTime: TimeInterval = 0
    func enableMonitor() {
        if link == nil {
            link = CADisplayLink(target: self, selector: #selector(fpsInfoCalculate(_:)))
            link?.add(to: RunLoop.main, forMode: .common)
        } else {
            link?.isPaused = false
        }
    }

    func disableMonitor() {
        if let link = link {
            link.isPaused = true
            link.invalidate()
            self.link = nil
            lastTime = 0
            count = 0
        }
    }
    
    @objc
    func fpsInfoCalculate(_ link: CADisplayLink) {
        if lastTime == 0 {
            lastTime = link.timestamp
            return
        }
        count += 1
        let delta = link.timestamp - lastTime
        if delta >= 1 {
            // 间隔超过 1 秒
            lastTime = link.timestamp
            let fps = Double(count) / delta
            count = 0
            let intFps = Int(fps + 0.5)
            print("帧率:\(intFps)")
        }
    }
}

CADisplayLink 实现的 FPS 在生产场景中只有指导意义,不能代表真实的 FPS。因为基于 CADisplayLink 实现的 FPS 无法完全检测出当前 Core Animation 的性能情况,只能检测出当前 RunLoop 的帧率。

主线程卡顿监控

主线程卡顿监控方案的原理是 通过子线程监控主线程的 RunLoop,判断两个状态区域之间的耗时是否达到一定阈值。因为主线程绝大部分计算或绘制任务都是以 RunLoop 为单位发生。单次 RunLoop 如果时长超过 16ms,就会导致 UI 体验的卡顿。

美团的移动端性能监控方案 Hertz 采用的就是这种方式。

此处输入图片的描述

首先我们需要了解一下 RunLoop 的原理。

RunLoop 定义

RunLoop 是 iOS 事件响应与任务处理最核心的机制。当有持续的异步任务需求时,我们会创建一个独立的生命周期可控的线程。RunLoop 就是控制线程生命周期并接收事件进行处理的机制

RunLoop 机制

主线程(有 RunLoop 的线程)几乎所有函数都从以下六个函数之一的函数调起:

  1. CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION
    1. CFRunloop is calling out to an abserver callback function
    2. 用于向外部报告 RunLoop 当前状态的改变,框架中很多机制都由 RunLoopObserver 触发,如:CAAnimation
  2. CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK
    1. CFRunloop is calling out to a block
    2. 消息通知、非延迟的 perform、dispatch 调用、block 回调、KVO
  3. CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE
    1. CFRunloop is servicing the main dispatch queue
    2. 执行主队列上的任务
  4. CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION
    1. CFRunloop is calling out to a timer callback function
    2. 基于定时器的延迟的 perfrom,dispatch 调用
  5. CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION
    1. CFRunloop is calling out to a source 0 perform function
    2. 处理 App 内部事件、App自己负责管理(触发),如:UIEventCFSocket。普通函数调用,系统调用
  6. CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION
    1. CFRunloop is calling out to a source 1 perform function
    2. 由 RunLoop 和内核管理,Mach port 驱动,如:CFMachPortCFMessagePort

RunLoop 运行时

如下所示为 CFRunLoop 源码中的核心方法 CFRunLoopRun 简化后的主要逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
int32_t __CFRunLoopRun() {
// 1. 通知 Observers:即将进入 RunLoop
__CFRunLoopDoObservers(KCFRunLoopEntry);

do {
// 2. 通知Observers:即将要处理 timer
__CFRunLoopDoObservers(kCFRunLoopBeforeTimers);
// 3. 通知Observers:即将要处理 source
__CFRunLoopDoObservers(kCFRunLoopBeforeSources);

// 处理非延迟的主线程调用
__CFRunLoopDoBlocks();
// 处理 UIEvent 事件
__CFRunLoopDoSource0();

// GCD dispatch main queue
CheckIfExistMessagesInMainDispatchQueue();

// 4. 通知 Observers:即将进入休眠等待
__CFRunLoopDoObservers(kCFRunLoopBeforeWaiting);

// 等待内核mach_msg事件
mach_port_t wakeUpPort = SleepAndWaitForWakingUpPorts();

// mach_msg_trap
// 休眠中 Zzz...
// Received mach_msg, wake up

// 5. 通知 Observers:从休眠等待中醒来
__CFRunLoopDoObservers(kCFRunLoopAfterWaiting);

if (wakeUpPort == timerPort) {
// 处理因timer的唤醒
__CFRunLoopDoTimers();
} else if (wakeUpPort == mainDispatchQueuePort) {
// 处理异步方法唤醒,如:dispatch_async
__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__()
} else {
// UI 刷新,动画显示
__CFRunLoopDoSource1();
}

// 再次确保是否有同步的方法需要调用
__CFRunLoopDoBlocks()
} while(!stop && !timeout);

// 6. 通知 Observers:即将退出runloop
__CFRunLoopDoObservers(CFRunLoopExit);
}

RunLoop 在运行时一直在向外部报告当前状态的更新,其状态定义如下:

1
2
3
4
5
6
7
8
9
typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
kCFRunLoopEntry , // 进入 loop
kCFRunLoopBeforeTimers , // 触发 Timer 回调
kCFRunLoopBeforeSources , // 触发 Source0 回调
kCFRunLoopBeforeWaiting , // 等待 mach_port 消息
kCFRunLoopAfterWaiting , // 接收 mach_port 消息
kCFRunLoopExit , // 退出 loop
kCFRunLoopAllActivities // loop 所有状态改变
}

从 RunLoop 运行逻辑中,不难发现 NSRunLoop 调用方法主要在于两个状态区间:

  • kCFRunLoopBeforeSources 和 kCFRunLoopBeforeWaiting 之间
  • kCFRunLoopAfterWaiting 之后
    如果这两个时间内耗时太久而无法进入下一步,可以线程受阻。如果这个线程时主线程,表现出来就是出现了卡顿。

代码实现

我们可以通过 CFRunLoopObserverRef 实时获取 NSRunLoop 的状态。具体使用方法如下:

首先创建一个 CFRunLoopObserverContext 观察者 observer。然后将观察者 observer 添加到主线程 RunLoop 的 kCFRunLoopCommonModes 模式下进行观察。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
- (void)registerObserver {
CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL};
CFRunLoopObserverRef observer = CFRunLoopObserverCreate(kCFAllocatorDefault,
kCFRunLoopAllActivities,
YES,
0,
&runLoopObserverCallBack,
&context);
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
}
static void runLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) {
MyClass *object = (__bridge MyClass*)info;
object->activity = activity;
}

然后,创建一个持续的子线程专门用来监控主线程的 RunLoop 状态。为了让计算更精确,需要让子线程更及时的获知主线程 RunLoop 状态变化,dispatch_semaphore_t 是一个不错的选择。另外,卡顿需要覆盖多次连续短时间卡顿和单次长时间卡顿两种情景,所以判定条件也需要做适当优化。优化后的代码实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
- (void)registerObserver {
CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL};
CFRunLoopObserverRef observer = CFRunLoopObserverCreate(kCFAllocatorDefault,
kCFRunLoopAllActivities,
YES,
0,
&runLoopObserverCallBack,
&context);
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);

// 创建信号
semaphore = dispatch_semaphore_create(0);

// 在子线程监控时长
dispatch_async(dispatch_get_global_queue(0, 0), ^{
while (YES) {
// 假定连续5次超时50ms认为卡顿(当然也包含了单次超时250ms)
long st = dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 50*NSEC_PER_MSEC));
if (st != 0) {
if (activity == kCFRunLoopBeforeSources || activity==kCFRunLoopAfterWaiting) {
if (++timeoutCount < 5)
continue;

NSLog(@"好像有点儿卡哦");
}
}
timeoutCount = 0;
}
});
}
static void runLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) {
MyClass *object = (__bridge MyClass*)info;

// 记录状态值
object->activity = activity;

// 发送信号
dispatch_semaphore_t semaphore = moniotr->semaphore;
dispatch_semaphore_signal(semaphore);
}

检测到卡顿时应该立刻获取卡顿的方法堆栈信息,并推送至服务端共开发者分析,从而解决卡顿问题。
获取堆栈信息:

直接使用 PLCrashReporter 第三方开源库

1
2
3
4
5
6
7
8
9
10
11
PLCrashReporterConfig *config = [[PLCrashReporterConfig alloc] initWithSignalHandlerType:PLCrashReporterSignalHandlerTypeBSD     
symbolicationStrategy:PLCrashReporterSymbolicationStrategyAll];
PLCrashReporter *reporter = [[PLCrashReporter alloc] initWithConfiguration:config];
// 获取数据
NSData *lagData = [reporter generateLiveReport];
// 转换成 PLCrashReport 对象
PLCrashReport *lagReport = [[PLCrashReport alloc] initWithData:lagData error:NULL];
// 进行字符串格式化处理
NSString *lagReportString = [PLCrashReportTextFormatter stringValueForCrashReport:lagReport withTextFormat:PLCrashReportTextFormatiOS];
// 将字符串上传服务器
NSLog(@"lag happen, detail below: \n %@",lagReportString);

CADisplayLink

Runloop

iOS 性能监控 SDK —— Wedjat(华狄特)开发过程的调研和整理