质量监控-卡顿检测

原文链接

不管是应用秒变幻灯片,还是启动过久被杀,基本都是开发者必经的体验。就像没人希望堵车一样,卡顿永远是不受用户欢迎的,所以如何发现卡顿是开发者需要直面的难题。虽然导致卡顿的原因有很多,但卡顿的表现总是大同小异。如果把卡顿当做病症看待,两者分别对应所谓的本与标。要检测卡顿,无论是标或本都可以下手,但都需要深入的学习

instruments与性能

在开发阶段,使用内置的性能工具instruments来检测性能问题是最佳的选择。与应用运行性能关联最紧密的两个硬件CPUGPU,前者用于执行程序指令,针对代码的处理逻辑;后者用于大量计算,针对图像信息的渲染。正常情况下,CPU会周期性的提交要渲染的图像信息给GPU处理,保证视图的更新。一旦其中之一响应不过来,就会表现为卡顿。因此多数情况下用到的工具是检测GPU负载的Core Animation,以及检测CPU处理效率的Time Profiler

由于CPU提交图像信息是在主线程执行的,会影响到CPU性能的诱因包括以下:

  1. 发生在主线程的I/O任务
  2. 过多的线程抢占CPU资源
  3. 温度过高导致的CPU降频

而影响GPU的因素较为客观,难以针对做代码上的优化,包括:

  1. 显存频率
  2. 渲染算法
  3. 大计算量

本文旨在介绍如何去检测卡顿,而非如何解决卡顿,因此如果对上面列出的诱因有兴趣的读者可以自行阅读相关文章书籍

卡顿检测

检测的方案根据线程是否相关分为两大类:

  • 执行耗时任务会导致CPU短时间无法响应其他任务,检测任务耗时来判断是否可能导致卡顿
  • 由于卡顿直接表现为操作无响应,界面动画迟缓,检测主线程是否能响应任务来判断是否卡顿

与主线程相关的检测方案包括:

  1. fps
  2. ping
  3. runloop

与主线程不相关的检测包括:

  1. stack backtrace
  2. msgSend observe

衡量指标

不同方案的检测原理和实现机制都不同,为了更好的选择所需的方案,需要建立一套衡量指标来对方案进行对比,个人总结的衡量指标包括四项:

  • 卡顿反馈

    卡顿发生时,检测方案是否能及时、直观的反馈出本次卡顿

  • 采集精度

    卡顿发生时,检测方案能否采集到充足的信息来做定位追溯

  • 性能损耗

    维持检测所需的CPU占用、内存使用是否会引入额外的问题

  • 实现成本

    检测方案是否易于实现,代码的维护成本与稳定性等

fps

通常情况下,屏幕会保持60hz/s的刷新速度,每次刷新时会发出一个屏幕刷新信号,CADisplayLink允许我们注册一个与刷新信号同步的回调处理。可以通过屏幕刷新机制来展示fps值:

- (void)startFpsMonitoring {
    WeakProxy *proxy = [WeakProxy proxyWithClient: self];
    self.fpsDisplay = [CADisplayLink displayLinkWithTarget: proxy selector: @selector(displayFps:)];
    [self.fpsDisplay addToRunLoop: [NSRunLoop mainRunLoop] forMode: NSRunLoopCommonModes];
}

- (void)displayFps: (CADisplayLink *)fpsDisplay {
    _count++;
    CFAbsoluteTime threshold = CFAbsoluteTimeGetCurrent() - _lastUpadateTime;
    if (threshold >= 1.0) {
        [FPSDisplayer updateFps: (_count / threshold)];
        _lastUpadateTime = CFAbsoluteTimeGetCurrent();
    }
}
指标
卡顿反馈 卡顿发生时,fps会有明显下滑。但转场动画等特殊场景也存在下滑情况。高
采集精度 回调总是需要cpu空闲才能处理,无法及时采集调用栈信息。低
性能损耗 监听屏幕刷新会频繁唤醒runloop,闲置状态下有一定的损耗。中低
实现成本 单纯的采用CADisplayLink实现。低
结论 更适用于开发阶段,线上可作为辅助手段

ping

ping是一种常用的网络测试工具,用来测试数据包是否能到达ip地址。在卡顿发生的时候,主线程会出现短时间内无响应这一表现,基于ping的思路从子线程尝试通信主线程来获取主线程的卡顿延时:

@interface PingThread : NSThread
......
@end

@implementation PingThread

- (void)main {
    [self pingMainThread];
}

- (void)pingMainThread {
    while (!self.cancelled) {
        @autoreleasepool {
            dispatch_async(dispatch_get_main_queue(), ^{
                [_lock unlock];
            });
            
            CFAbsoluteTime pingTime = CFAbsoluteTimeGetCurrent();
            NSArray *callSymbols = [StackBacktrace backtraceMainThread];
            [_lock lock];
            if (CFAbsoluteTimeGetCurrent() - pingTime >= _threshold) {
                ......
            }
            [NSThread sleepForTimeInterval: _interval];
        }
    }
}

@end
指标
卡顿反馈 主线程出现堵塞直到空闲期间都无法回包,但在ping之间的卡顿存在漏查情况。中高
采集精度 子线程在ping前能获取主线程准确的调用栈信息。中高
性能损耗 需要常驻线程和采集调用栈。中
实现成本 需要维护一个常驻线程,以及对象的内存控制。中低
结论 监控能力、性能损耗和ping频率都成正比,监控效果强

runloop

作为和主线程相关的最后一个方案,基于runloop的检测和fps的方案非常相似,都需要依赖于主线程的runloop。由于runloop会调起同步屏幕刷新的callback,如果loop的间隔大于16.67msfps自然达不到60hz。而在一个loop当中存在多个阶段,可以监控每一个阶段停留了多长时间:

- (void)startRunLoopMonitoring {
    CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(CFAllocatorGetDefault(), kCFRunLoopAllActivities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
        if (CFAbsoluteTimeGetCurrent() - _lastActivityTime >= _threshold) {
            ......
            _lastActivityTime = CFAbsoluteTimeGetCurrent();
        }
    });
    CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
}
指标
卡顿反馈 runloop的不同阶段把时间分片,如果某个时间片太长,基本认定发生了卡顿。此外应用闲置状态常驻beforeWaiting阶段,此阶段存在误报可能。中
采集精度 fps类似的,依附于主线程callback的方案缺少准确采集调用栈的时机,但优于fps检测方案。中低
性能损耗 此方案不会频繁唤醒runloop,相较于fps性能更佳。低
实现成本 需要注册runloop observer。中低
结论 综合性能优于fps,但反馈表现不足,只适合作为辅助工具使用

stack backtrace

代码质量不够好的方法可能会在一段时间内持续占用CPU的资源,换句话说在一段时间内,调用栈总是停留在执行某个地址指令的状态。由于函数调用会发生入栈行为,如果比对两次调用栈的符号信息,前者是后者的符号子集时,可以认为出现了卡顿恶鬼

@interface StackBacktrace : NSThread
......
@end

@implementation StackBacktrace

- (void)main {
    [self backtraceStack];
}

- (void)backtraceStack {
    while (!self.cancelled) {
        @autoreleasepool {
            NSSet *curSymbols = [NSSet setWithArray: [StackBacktrace backtraceMainThread]];
            if ([_saveSymbols isSubsetOfSet: curSymbols]) {
                ......
            }
            _saveSymbols = curSymbols;
            [NSThread sleepForTimeInterval: _interval];
        }
    }
}

@end
指标
卡顿反馈 由于符号地址的唯一性,调用栈比对的准确性高。但需要排除闲置状态下的调用栈信息。高
采集精度 直接通过调用栈符号信息比对可以准确的获取调用栈信息。高
性能损耗 需要频繁获取调用栈,需要考虑延后符号化的时机减少损耗。中高
实现成本 需要维护常驻线程和调用栈追溯算法。中高
结论 准确率很高的工具,适用面广

msgSend observe

OC方法的调用最终转换成msgSend的调用执行,通过在函数前后插入自定义的函数调用,维护一个函数栈结构可以获取每一个OC方法的调用耗时,以此进行性能分析与优化:

#define save() \
__asm volatile ( \
    "stp x8, x9, [sp, #-16]!\n" \
    "stp x6, x7, [sp, #-16]!\n" \
    "stp x4, x5, [sp, #-16]!\n" \
    "stp x2, x3, [sp, #-16]!\n" \
    "stp x0, x1, [sp, #-16]!\n");

#define resume() \
__asm volatile ( \
    "ldp x0, x1, [sp], #16\n" \
    "ldp x2, x3, [sp], #16\n" \
    "ldp x4, x5, [sp], #16\n" \
    "ldp x6, x7, [sp], #16\n" \
    "ldp x8, x9, [sp], #16\n" );
    
#define call(b, value) \
    __asm volatile ("stp x8, x9, [sp, #-16]!\n"); \
    __asm volatile ("mov x12, %0\n" :: "r"(value)); \
    __asm volatile ("ldp x8, x9, [sp], #16\n"); \
    __asm volatile (#b " x12\n");


__attribute__((__naked__)) static void hook_Objc_msgSend() {

    save()
    __asm volatile ("mov x2, lr\n");
    __asm volatile ("mov x3, x4\n");
    
    call(blr, &push_msgSend)
    resume()
    call(blr, orig_objc_msgSend)
    
    save()
    call(blr, &pop_msgSend)
    
    __asm volatile ("mov lr, x0\n");
    resume()
    __asm volatile ("ret\n");
}
指标
卡顿反馈
采集精度
性能损耗 拦截后调用频次非常高,启动阶段可达10w次以上调用。高
实现成本 需要维护方法栈和优化拦截算法。高
结论 准确率很高的工具,但不适用于Swift代码

总结

fps ping runloop stack backtrace msgSend observe
卡顿反馈 中高
采集精度 中高 中低
性能损耗 中低 中高
实现成本 中低 中低 中高
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349

推荐阅读更多精彩内容