应用的流畅度是衡量应用用户体验和性能的一个重要指标,一个流畅的应用能够给用户带来良好的使用感受,提高用户的满意度和忠诚度。对于开发者来说应该重视并不断优化应用的流畅度,提升用户的使用体验。本篇文章主要介绍一下如何监控卡顿,以及一些常用的优化手段。
屏幕成像原理
屏幕成像最底层的原理要从 CRT 显示器(Cathode Ray Tube 阴极射线管)的原理来说,CRT 的电子枪发出电子束来激发屏幕内表面的荧光粉来显示图像的,由于荧光粉被点亮后会很快熄灭,所以电子枪必须循环地不断激发这些点。如上图所示,电子枪会从上到下一行一行的扫描,扫描完最后一行后显示器就呈现了一帧画面,随后电子枪会回到开始的位置继续下一帧的扫描。
同步信号
为了将显示器和系统的视频控制器进行同步,会通过硬件时钟产生一系列的定时信号,分别是:
-
水平同步信号(horizontal synchronization)
当电子枪换到新的一行时,在扫描之前会发出一个水平同步信号,简称 HSync。
-
垂直同步信号(vertical syncchronization)
当电子枪回到最初位置时,在扫描下一帧之前会发出一个垂直同步信号,简称 VSync。显示器通常是以固定的频率刷新的,这个刷新频率就是 VSync 信号产生的频率。
渲染流水线
如上图,计算机系统中 CPU、GPU、显示器按照上面这种方式协同工作的。
-
CPU
CPU 一般负责视图创建、销毁,布局计算、文本绘制、图片的解码等,最终将计算好的将要显示的内容提交给 GPU。
-
GPU
GPU将 CPU 计算好的内容进行合成、渲染,然后将渲染结果放再帧缓冲区(FrameBuffer)中。
-
视频控制器
随后视频控制器会随着 VSync 信号的到来,逐行读取帧缓冲区的数据,经过数模转换后给显示器显示。
双缓冲机制
最初,帧缓冲区只有一个,此时帧缓冲去的读取和刷新都会有较大的效率问题。为了提升效率,在显示系统引入两个缓冲区,即双缓冲机制。
此时,GPU 会提前渲染好一帧放入一个缓冲区内,让视频控制器来读该缓冲区的数据。当下一帧渲染好后,GPU 会等待显示器的 VSync 信号的发出后,才会改变视频控制器的指针指向新的缓冲区。
卡顿的原因
CPU 与 GPU 的协同
正常情况下,iOS 系统默认刷新频率是 60Hz,即每秒会渲染 60 帧,每帧之间间隔 16.67 ms,这也是两个 VSync 信号的间隔。
当VSync 信号到来后,系统图形服务会通知当前应用,应用的主线程开始在CPU 中执行相关计算操作,然后 GPU 进行合成、渲染等一系列操作,最终 GPU 将渲染结果提交到帧缓冲区中,等待 VSync 信号到来时显示到屏幕上。
-
正常显示
当 Vsync 信号到来时, CPU 与 GPU 在规定时间内将渲染结果提交到了帧缓冲区。
-
掉帧(卡顿)
当 Vsync 信号到来时,CPU 或者 GPU 没有完成内容提交,则这一帧就会被丢弃了,等待下一次 VSync 再显示,而此时显示器上仍然保留着之前的内容不变,这就界面卡顿的原因。
因此,在做性能优化时,只要使 CPU 的计算和 GPU 的渲染能规定时间内完成,就能避免掉帧,从而解决卡顿问题。
具体原因
-
CPU
CPU 提交计算结果是在主线程执行的,会影响 CPU 性能的因素大概有:
- 主线程大量 I/O操作:(未考虑性能,方便coding,直接在主线程写入大量数据)
- 主线程大量耗时操作:如图片编解码、复杂布局等
- 过多线程抢占 CPU 资源
- 高温导致 CPU 降频
-
GPU
- 视图层级过多、离屏渲染
- 大量计算以及渲染算法等等
卡顿监控
1.Xcode Instruments
在开发阶段,可直接使用内置工具 Instruments 来检测性能问题。一般使用使用 TimeProfiler 查看 CPU 相关的耗时操作,使用 Core Animation (FPS / Commits) 查看 GPU 的渲染以及提交情况。
2.主线程卡顿监控
了解了卡顿的原理,为了更好的解决卡顿问题,首先要定位到是哪些代码导致的卡顿,所以对于卡顿的监控是十分必要的。
FPS
一般情况,将 App 的 FPS 保持在 50~60 之间,用户就感觉不到卡顿。屏幕每次刷新时都会发出一个信号,通过向主线程添加 CADisplayLink 来注册一个与刷新频率相同的回调,从而统计出每秒屏幕刷新的次数。实现比较简单,可直接参考 YYFPSLabel 。
Objc
@implementation YYFPSLabel
- (instancetype)initWithFrame:(CGRect)frame {
// 省略...
// 创建CADisplayLink并添加到主线程的RunLoop中
_link = [CADisplayLink displayLinkWithTarget:[YYWeakProxy proxyWithTarget:self] selector:@selector(tick:)];
[_link addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
return self;
}
//刷新回调时去计算fps
- (void)tick:(CADisplayLink *)link {
if (_lastTime == 0) {
_lastTime = link.timestamp;
return;
}
_count++;
NSTimeInterval delta = link.timestamp - _lastTime;
if (delta < 1) return;
//每秒统计一次
_lastTime = link.timestamp;
float fps = _count / delta;
_count = 0;
self.text = [NSString stringWithFormat:@"%d FPS",(int)round(fps)];
}
@end
-
优点:
直观,卡顿发生时,FPS 会明显下降,反馈及时, 适用于开发阶段及时发现问题。
-
缺点:
需要 CPU 空闲时才能处理, 无法采集调用堆栈
RunLoop
首先通过下图,看一下 RunLoop 运行过程中的状态变化过程。
从上图我们发现,RunLoop 基本是在 BeforceSources 和 AfterWaiting 两个状态后执行任务,因此可以在子线程中定时检测主线程中的 RunLoop 是否处于 BeforceSources 或 AfterWaiting 状态,如果一定时间后仍处于这两个状态,则可认为主线程发生了卡顿。
源码:
Objc
//在主线程注册RunLoop观察者
- (void)registerMainRunLoopObserver {
CFRunLoopObserverContext context = {0, (__bridge void*)self, NULL, NULL};
self.runLoopObserver = CFRunLoopObserverCreate(kCFAllocatorDefault,
kCFRunLoopAllActivities,
YES,
0,
&runLoopObserverCallBack,
&context);
CFRunLoopAddObserver(CFRunLoopGetMain(), self.runLoopObserver, kCFRunLoopCommonModes);
}
//RunLoop 回调方法
static void runLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info)
{
self.runLoopActivity = activity;
//在主线程触发信号,使子线程不在阻塞
if (self.semaphore != nil) {
dispatch_semaphore_signal(self.semaphore);
}
}
//创建一个子线程去监听主线程RunLoop状态
- (void)createRunLoopStatusMonitor {
self.semaphore = dispatch_semaphore_create(0);
if (self.semaphore == nil) {
return;
}
//创建一个子线程,监测Runloop状态时长
dispatch_async(dispatch_get_global_queue(0, 0), ^{
while (YES){
//如果观察者已经移除,则停止进行状态监测
if (self.runLoopObserver == nil) {
self.runLoopActivity = 0;
self.semaphore = nil;
return;
}
//信号量等待。状态不等于0,说明状态等待超时
//方案一:设置单次超时时间为500毫秒
long status = dispatch_semaphore_wait(self.semaphore, dispatch_time(DISPATCH_TIME_NOW, 500 * NSEC_PER_MSEC));
if (status != 0) {
if (self.runLoopActivity == kCFRunLoopBeforeSources || self.runLoopActivity == kCFRunLoopAfterWaiting) {
//发生超过500毫秒的卡顿,此时去记录调用栈信息
}
}
/*
//方案二:连续5次卡顿50ms上报
long status = dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 50*NSEC_PER_MSEC));
if (status != 0)
{
if (!observer)
{
timeoutCount = 0;
semaphore = 0;
activity = 0;
return;
}
if (activity==kCFRunLoopBeforeSources || activity==kCFRunLoopAfterWaiting)
{
if (++timeoutCount < 5)
continue;
//保存调用栈信息
}
}
timeoutCount = 0;
*/
}
});
}
-
优点:
性能损耗较低,不会唤醒 RunLoop,优于 FPS;
可及时捕获卡顿堆栈,可用于线上监控。
-
缺点:
无法确定卡顿的时长,定义卡顿的阈值不好控制(根据业务人为设置)。
子线程 Ping 主线程
Ping 主线程核心思想是:在子线程内,与主线程通信使某个状态在主线程改变,判断改状态发生改变后的耗时,以此判断主线程是否发生了卡顿。
Objc
@implementation PingThread
- (void)main {
self.semaphore = dispatch_semaphore_create(0);
[self pingMainThread];
}
- (void)pingMainThread {
while (!self.cancelled) {
@autoreleasepool {
dispatch_async(dispatch_get_main_queue(), ^{
dispatch_semaphore_signal(self.semaphore);
});
CFAbsoluteTime pingTime = CFAbsoluteTimeGetCurrent();
dispatch_semaphore_wait(self.semaphore, DISPATCH_TIME_FOREVER);
if (CFAbsoluteTimeGetCurrent() - pingTime >= _threshold) {
......
}
[NSThread sleepForTimeInterval: _interval];
}
}
}
@end
-
优点:
可及时捕获卡顿堆栈;
可以记录卡顿的时长;
-
缺点:
一直 ping 主线程比较消耗硬件资源,费电;
3.全局监控
堆栈回溯
由于函数的调用会发生入栈行为,如果调用栈总是停留在某个地址指令的状态,说明该地址的函数比较耗时。可通过对比两次调用栈的符号信息,如果后一次仍然包含前一次的地址符号,可认为发生了卡顿。
Objc
@implementation StackBacktrace
- (void)main {
[self backtraceStack];
}
- (void)backtraceStack {
while (!self.cancelled) {
@autoreleasepool {
NSSet *curSymbols = //获取堆栈的符号集;
if ([_saveSymbols isSubsetOfSet: curSymbols]) {
//包含前一次的符号集,发生卡顿
}
_saveSymbols = curSymbols;
[NSThread sleepForTimeInterval: _interval];
}
}
}
@end
-
优点:
由于符号的唯一性,直接通过调用栈的符号信息对比,准确性较高。
-
缺点:
需要频繁获取调用栈,对性能损耗高;
实现难度相对较大,成本较高;
hook msgSend
主要是根据 OC 的消息机制,方法的调用最终转换成 msgSend 的调用,因此可通过 hook msgSend 方法,在函数的前后插入自定义的耗时监控函数,从而可统计每个函数的耗时。
-
优点:
采集精度高,覆盖面广,能准确记录每一个方法的耗时;
-
缺点:
整体性能损耗较高,统计了每个OC 函数;
只适用 OC,不适用Swift 代码;
总结
本篇简要介绍了屏幕成像的原理,和发生卡顿的根本原因,并总结了 iOS 业界常见的一些卡顿监控的原理,具体选择哪种方法可根据自己项目选择最适合的即可。
参考
iOS 保持界面流畅的技巧
iOS界面卡顿原理及优化
Matrix 卡顿监控
RunLoop实战:实时卡顿监控
深入理解 RunLoop