iOS卡顿检测:FPS及具体定位

前言

项目刚起步的过程中,往往时间紧任务重,程序员在开发的时候,只想着要完成开发需求,没有多余的时间去关注性能问题。但随着项目越来越大,功能越来多,卡顿问题越来越严重,用户体验很不好。解决卡顿的问题,刻不容缓啊,于是整理了检测卡顿的一些方法,与大家做个分享,本文主要包含 fpsping 的方式检测。

一、卡顿原因

GPU、CPU帧率图.png

在显示器中是固定的频率,比如iOS中是每秒60帧(60FPS),即每帧16.7ms。从上图中可以看出,每两个VSync信号之间有时间间隔(16.7ms),在这个时间内,CPU主线程计算布局,解码图片,创建视图,绘制文本,计算完成后将内容交给GPU,GPU变换,合成,渲染,放入帧缓冲区。假如16.7ms内,CPU和GPU没有来得及生产出一帧缓冲,那么这一帧会被丢弃,显示器就会保持不变,继续显示上一帧内容,这就将导致导致画面卡顿。所以无论CPU,GPU,哪个消耗时间过长,都会导致在16.7ms内无法生成一帧缓存

简单来说,主线程为了达到接近60fps的绘制效率,不能在UI线程有单个超过(1/60s≈16ms)的计算任务,导致卡顿。

以下操作可能会引起卡顿:

  • 死锁:主线程拿到锁 A,需要获得锁 B,而同时某个子线程拿了锁 B,需要锁 A,这样相互等待就死锁了。
  • 抢锁:主线程需要访问 DB,而此时某个子线程往 DB 插入大量数据。通常抢锁的体验是偶尔卡一阵子,过会就恢复了。
  • 主线程大量 IO:主线程为了方便直接写入大量数据,会导致界面卡顿。
  • 主线程大量计算:算法不合理,导致主线程某个函数占用大量 CPU。
  • 大量的 UI 绘制:复杂的 UI、图文混排等,带来大量的 UI 绘制。

二、可视化FPS展示

FPS是Frames Per Second 的简称缩写,意思是每秒传输帧数,也就是我们常说的“刷新率”(单位为Hz)。FPS是测量用于保存、显示动态视频的信息数量。每秒钟帧数愈多,所显示的画面就会愈流畅,FPS值越低就越卡顿,所以这个值在一定程度上可以衡量应用在图像绘制渲染处理时的性能。一般我们的APP的FPS只要保持在 50-60 之间,用户体验都是比较流畅的。

我们可以通过CADisplayLink来监控我们的FPS。CADisplayLink是CoreAnimation提供的另一个类似于NSTimer的类,它总是在屏幕完成一次更新之前启动,它的接口设计的和NSTimer很类似,所以它实际上就是一个内置实现的替代,但是和timeInterval以秒为单位不同,CADisplayLink有一个整型的frameInterval属性,指定了间隔多少帧之后才执行。默认值是1,意味着每次屏幕更新之前都会执行一次。但是如果动画的代码执行起来超过了六十分之一秒,你可以指定frameInterval为2,就是说动画每隔一帧执行一次(一秒钟30帧)。

@implementation MDFPSLabel {
    CADisplayLink *_link;
    NSUInteger _count;
    NSTimeInterval _lastTime;
}

- (instancetype)initWithFrame:(CGRect)frame {
    self = [super initWithFrame:frame];
    self.textAlignment = NSTextAlignmentCenter;
    self.backgroundColor = [UIColor colorWithWhite:0.000 alpha:0.700];

    // 创建CADisplayLink,设置代理和回调
    _link = [CADisplayLink displayLinkWithTarget:[MDWeakProxy proxyWithTarget:self]
                                        selector:@selector(tick:)];
    // 并添加到当前runloop的NSRunLoopCommonModes
    [_link addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
    return self;
}

- (void)dealloc {
    [_link invalidate];
}

// 计算 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; // fps
    _count = 0;

    // 更新 fps 
    CGFloat progress = fps / 60.0;
    self.text = [NSString stringWithFormat:@"%d",(int)round(fps)];
    self.textColor = [UIColor colorWithHue:0.27 * (progress - 0.2)
                                saturation:1
                                brightness:0.9
                                     alpha:1];
}

@end

更多FPS介绍及demo下载,请参转阅这篇文章://www.greatytc.com/p/3d3f968c9cf4

三、定位具体位置

1、实现思路

使用FPS方式只能大概推测出是哪里的问题,但不能具体定位到具体的位置。最理想的方案是让UI线程“主动汇报”当前耗时的任务,听起来简单做起来不轻松。

我们可以假设这样一套机制:每隔16ms让UI线程来报道一次,如果16ms之后UI线程没来报道,那就一定是在执行某个耗时的任务。这种抽象的描述翻译成代码,可以用如下表述:

我们启动一个worker线程,worker线程每隔一小段时间(delta)ping以下主线程(发送一个NSNotification),如果主线程此时有空,必然能接收到这个通知,并pong以下(发送另一个NSNotification),如果worker线程超过delta时间没有收到pong的回复,那么可以推测UI线程必然在处理其他任务了,此时我们执行第二步操作,暂停UI线程,并打印出当前UI线程的函数调用栈。

ping、pong流程.png

2、具体实现

  1. 设置定时器:工作线程定时给主线程发送 ping 消息
/// 开始监听
- (void)startWatch {
    // 设置定时器:定时给主线程发送信息
    uint64_t interval = PMainThreadWatcher_Watch_Interval * NSEC_PER_SEC;
    dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
    self.pingTimer = createGCDTimer(interval,
                                    interval / 10000,
                                    queue,
                                    ^{
                                        [self pingMainThread];
                                    });
}

/// 给主线程发信息
- (void)pingMainThread {
    // 设置回应时长定时器
    uint64_t interval = PMainThreadWatcher_Warning_Level * NSEC_PER_SEC;
    dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
    self.pongTimer = createGCDTimer(interval,
                                    interval / 10000,
                                    queue,
                                    ^{
                                        [self onPongTimeout];
                                    });
    
    // 给主线程发送通知消息
    dispatch_async(dispatch_get_main_queue(), ^{
        NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
        [center postNotificationName:Notification_PMainThreadWatcher_Worker_Ping
                              object:nil];
    });
}

2)主线程收到 ping 消息,并返回 pong 消息

/// 收到从工作线程发送的Ping通知
- (void)detectPingFromWorkerThread {
    // 回应工作线程的通知:发送 pong 通知
    NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
    [center postNotificationName:Notification_PMainThreadWatcher_Main_Pong
                          object:nil];
}

3)判断回应时长,并做相应处理

/// 回应超时
- (void)onPongTimeout {
    [self cancelPongTimer];
    // 暂停主线程,打印堆栈信息
    printMainThreadCallStack();
}

/// 收到从主线程返回的Pong通知
- (void)detectPongFromMainThread {
    [self cancelPongTimer];
}

/// 取消回应时常定时器
- (void)cancelPongTimer {
    if (self.pongTimer) {
        dispatch_source_cancel(_pongTimer);
        _pongTimer = nil;
    }
}
  1. 如果超时则杀掉进程
int pthread_kill(pthread_t, int);

杀掉进程这里使用 pthread_kill(), 该函数的API介绍如下

The pthread_kill() function sends the signal sig to thread, a thread in the same process as the caller. The signal is asynchronously directed to thread. If sig is 0, then no signal is sent, but error checking is still performed.

别被名字吓到,pthread_kill 可不是kill,而是向线程发送signal,大部分signal的默认动作是终止进程的运行。向指定ID的线程发送sig信号,如果线程代码内不做处理,则按照信号默认的行为影响整个进程,也就是说,如果你给一个线程发送了SIGQUIT,但线程却没有实现signal处理函数,则整个进程退出。

static void printMainThreadCallStack() {
    NSLog(@"发送信号: %d 到主线程", CALLSTACK_SIG);
    // pthread_kill主线程
    pthread_kill(mainThreadID, CALLSTACK_SIG);
}

5)监听信号,打印堆栈信息

iOS允许在主线程注册一个signal处理函数,当调用pthread_kill函数时能收到该信号,这时候就可以在signal回调方法中打印堆栈信息了。

/// singal回调方法
static void thread_singal_handler(int sig) {
    NSLog(@"主线程捕获信号: %d", sig);
    if (sig != CALLSTACK_SIG) {
        return;
    }
    
    NSArray *callStack = [NSThread callStackSymbols];
    // 代理回调或打印堆栈信息
    id<PMainThreadWatcherDelegate> del = [PMainThreadWatcher sharedInstance].watchDelegate;
    if (del != nil && [del respondsToSelector:@selector(onMainThreadSlowStackDetected:)]) {
        [del onMainThreadSlowStackDetected:callStack];
    } else {
        NSLog(@"检测主线程上的耗时调用堆栈 \n");
        for (NSString *call in callStack) {
            NSLog(@"%@\n", call);
        }
    }
    
    return;
}

/// 注册signal函数
static void install_signal_handler() {
    // 主线程注册一个signal处理函数
    signal(CALLSTACK_SIG, thread_singal_handler);
}

注意
signal方法不能调试,因为Xcode Debug模式运行App时,App进程signal被LLDB Debugger调试器捕获,导致signal handler无法进,但UI线程在遇到卡顿的时候还是能正常被中断。

更多signal函数用法及解释,请转阅这篇文章:

本章节根据该文改编:http://mrpeak.cn/blog/ui-detect/ 。原文有对应的 Demo ,可点击查看下载。

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

推荐阅读更多精彩内容

  • Swift1> Swift和OC的区别1.1> Swift没有地址/指针的概念1.2> 泛型1.3> 类型严谨 对...
    cosWriter阅读 11,089评论 1 32
  • iOS设备虽然在硬件和软件层面一直在优化,但还是有不少坑会导致UI线程的卡顿。对于程序员来说,除了增加自身知识储备...
    skogt阅读 422评论 0 6
  • java 接口的意义-百度 规范、扩展、回调 抽象类的意义-乐视 为其子类提供一个公共的类型封装子类中得重复内容定...
    交流电1582阅读 2,209评论 0 11
  • OC与Swift如何实现混编 1、 Swift项目中使用OC 在Swift中引用OC需要借助桥接文件xx brid...
    MichealZJ阅读 163评论 0 0
  • 6月14日与15日,在网友淇妈的精心组织下,我们开启了出色时尚私塾的两日学习。 我是朱老师的粉丝。来学习的目地很明...
    Lyric_3220阅读 331评论 0 1