美文网首页性能优化
iOS卡顿检测:FPS及具体定位

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

作者: 张聪_2048 | 来源:发表于2019-09-29 09:39 被阅读0次

    前言

    项目刚起步的过程中,往往时间紧任务重,程序员在开发的时候,只想着要完成开发需求,没有多余的时间去关注性能问题。但随着项目越来越大,功能越来多,卡顿问题越来越严重,用户体验很不好。解决卡顿的问题,刻不容缓啊,于是整理了检测卡顿的一些方法,与大家做个分享,本文主要包含 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下载,请参转阅这篇文章:https://www.jianshu.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 ,可点击查看下载。

    相关文章

      网友评论

        本文标题:iOS卡顿检测:FPS及具体定位

        本文链接:https://www.haomeiwen.com/subject/ainructx.html