iOS性能优化-UI卡顿检测

前言

在实现需求的同时,能写出既优雅性能又高效的代码是每个开发者都在追求的目标,但是在实际开发中,随着每个版本需求的迭代,功能变得越来越复杂,加上开发者的意识不够或者一时疏忽,日渐复杂的工程很容易产生或多或少的问题。
比如,app随机丢失动画、用户反馈app卡死等等的问题,这些问题都严重影响使用,也会降低产品口碑,我们除了在开发过程中,通过instrument来检测这些问题,还可以借助一些第三方监控工具来解决这些问题,KMCGeigerCounter就是一个很好的卡顿检测器。

在分析KMCGeigerCounter这个第三方app卡顿检测工具之前,我们先来分析几种UI卡顿检测方案。

卡顿检测的分析

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

通过Instrument设置16ms的采样率可以检测出大部分这种费时的任务,但有以下缺点:

1、Instrument profile一次重新编译,时间较长。
2、只能针对特定的操作场景进行检测,要预先知道卡顿产生的场景。
3、每次猜测,更改,再猜测再以此循环,需要重新profile。

我们的目标方案是,检测能够自动发生,并不需要开发人员做任何预先配置或profile。运行时发现卡顿能即时通知开发人员导致卡顿的函数调用栈。

检测方案一:基于Runloop

主线程绝大部分计算或者绘制任务都是以Runloop为单位发生。单次Runloop如果时长超过16ms,就会导致UI体验的卡顿。那如何检测单次Runloop的耗时呢?
Runloop的生命周期及运行机制虽然不透明,但苹果提供了一些API去检测部分行为。我们可以通过如下代码监听Runloop每次进入的事件:

- (void)setupRunloopObserver{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        CFRunLoopRef runloop = CFRunLoopGetCurrent(); 
        CFRunLoopObserverRef enterObserver;
        enterObserver = CFRunLoopObserverCreate(CFAllocatorGetDefault(),
                                               kCFRunLoopEntry | kCFRunLoopExit,
                                               true,
                                               -0x7FFFFFFF,
                                               BBRunloopObserverCallBack, NULL);
        CFRunLoopAddObserver(runloop, enterObserver, kCFRunLoopCommonModes);
        CFRelease(enterObserver);
    });
}
static void BBRunloopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) {
    switch (activity) {
        case kCFRunLoopEntry: {
            NSLog(@"enter runloop...");
        }
            break;
        case kCFRunLoopExit: {
            NSLog(@"leave runloop...");
        }
            break;
        default: break;
    }
}

看起来kCFRunLoopExit的时间,减去kCFRunLoopEntry的时间,即为一次Runloop所耗费的时间,这样就能找出大于16ms的runloop。
但是demo实践结果是:kCFRunLoopExit的时间减去kCFRunLoopEntry,得到的时间差,貌似不准。
缺陷:但无法定位到具体的函数,只能起到预报的作用。

方案一是可以通过监测runloop计算每次主线程的任务执行时间是否超过16ms来判断是否有卡顿,但是缺点在于无法定位卡顿的位置,所以有了方案二。

检测方案二:基于线程

最理想的方案是让UI线程“主动汇报”当前耗时的任务,听起来简单做起来不轻松。

我们可以假设这样一套机制:每隔16ms让UI线程来报道一次,如果16ms之后UI线程没来报道,那就一定是在执行某个耗时的任务。这种抽象的描述翻译成代码,可以用如下表述:
我们启动一个worker线程,worker线程每隔一小段时间(delta)ping一下主线程(发送一个NSNotification),如果主线程此时有空,必然能接收到这个通知,并pong以下(发送另一个NSNotification),如果worker线程超过delta时间没有收到pong的回复,那么可以推测UI线程必然在处理其他任务了,此时我们执行第二步操作,暂停UI线程,并打印出当前UI线程的函数调用栈。

难点在这第二步,如何暂停UI线程,同时获取到callstack。

iOS的多线程编程一般使用NSOperation或者GCD,这两者都无法暂停每个正在执行的线程。
所谓的cancel调用也只能在目标线程空闲的时候,主动检测cancelled状态,然后主动sleep,这显然非我所欲。

如果我们从worker线程给UI线程发送signal,UI线程会被即刻暂停,并进入接收signal的回调,再将callstack打印就接近目标了。
iOS确实允许在主线程注册一个signal处理函数,类似这样:

signal(CALLSTACK_SIG, thread_singal_handler);

iOS性能优化-UI卡顿检测_第1张图片

代码实现:

//在主线程注册signal handler
signal(CALLSTACK_SIG, thread_singal_handler);

//通过NSNotification完成ping pong流程
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(detectPingFromWorkerThread) name:Notification_PMainThreadWatcher_Worker_Ping object:nil];
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(detectPongFromMainThread) name:Notification_PMainThreadWatcher_Main_Pong object:nil];

//如果ping超时,pthread_kill主线程。
pthread_kill(mainThreadID, CALLSTACK_SIG);

//主线程被暂停,进入signal回调,通过[NSThread callStackSymbols]获取主线程当前callstack。
static void thread_singal_handler(int sig) {
    NSLog(@"main thread catch signal: %d", sig);
    if (sig != CALLSTACK_SIG) {
        return;
    }
    NSArray* callStack = [NSThread callStackSymbols];
    id del = [PMainThreadWatcher sharedInstance].watchDelegate;
    if (del != nil && [del respondsToSelector:@selector(onMainThreadSlowStackDetected:)])  {
        [del onMainThreadSlowStackDetected:callStack];
    }
    else {
        NSLog(@"detect slow call stack on main thread! \n");
        for (NSString* call in callStack) {
            NSLog(@"%@\n", call);
        }
    }
    return;
}

说明:
值得一提的是上述代码不能调试,因为调试时gdb会干扰signal的处理,导致signal handler无法进,但UI线程在遇到卡顿的时候还是能正常被中断。
现阶段的实现,worker线程每隔1秒会ping一次UI线程,检测出运行超过16ms的调用栈。开发阶段可以将1s的间隔调至更短,可能会对app整体性能造成少许的负担,但能检测出更多的卡顿调用。

signal相关的知识点
iOS系统的signal可以被归为两类:

第一类内核signal,这类signal由操作系统内核发出,比如当我们访问VM上不属于自己的内存地址时,会触发EXC_BAD_ACCESS异常,内核检测到该异常之后会发出第二类signal:BSD signal,传递给应用程序。

第二类BSD signal,这类signal需要被应用程序自己处理。通常当我们的App进程运行时遇到异常,比如NSArray越界访问。产生异常的线程会向当前进程发出signal,如果这个signal没有别处理,我们的app就会crash了。

平常我们调试的时候很容易遇到第二类signal导致整个程序被中断的情况,gdb同时会将每个线程的调用栈呈现出来。

pthread_kill允许我们向目标线程(UI线程)发送signal,目标线程被暂停,同时进入signal回调,将当前线程的callstack获取并处理,处理完signal之后UI线程继续运行。将callstack打印即可精确定位产生问题的函数调用栈。

方案一监听RunLoop无疑会污染主线程,死循环在线程间通信会造成大量的不必要损耗,即便GCD的性能已经很好了,因此,第三种方案采用CADisplayLink的方式来处理。

检测方案三:CADisplayLink监控

CADisplayLink监控的思路是每个屏幕刷新周期,派发标记位设置任务到主线程中,如果多次超出16.7ms的刷新阙值,即可看作是发生了卡顿。

什么是CADisplayLink?
CADisplayLink是一个能让我们以和屏幕刷新率相同的频率将内容画到屏幕上的定时器。
我们在应用中创建一个新的 CADisplayLink 对象,把它添加到一个runloop中,并给它提供一个 target 和selector 在屏幕刷新的时候调用。
一旦 CADisplayLink 以特定的模式注册到runloop之后,每当屏幕需要刷新的时候,runloop就会调用CADisplayLink绑定的target上的selector,这时target可以读到 CADisplayLink 的每次调用的时间戳,用来准备下一帧显示需要的数据。
例如一个视频应用使用时间戳来计算下一帧要显示的视频数据。在UI做动画的过程中,需要通过时间戳来计算UI对象在动画的下一帧要更新的大小等等。
在添加进runloop的时候我们应该选用高一些的优先级,来保证动画的平滑。可以设想一下,我们在动画的过程中,runloop被添加进来了一个高优先级的任务,那么,下一次的调用就会被暂停转而先去执行高优先级的任务,然后在接着执行CADisplayLink的调用,从而造成动画过程的卡顿,使动画不流畅。
duration属性提供了每帧之间的时间,也就是屏幕每次刷新之间的的时间。我们可以使用这个时间来计算出下一帧要显示的UI的数值。但是 duration只是个大概的时间,如果CPU忙于其它计算,就没法保证以相同的频率执行屏幕的绘制操作,这样会跳过几次调用回调方法的机会。
frameInterval属性是可读可写的NSInteger型值,标识间隔多少帧调用一次selector 方法,默认值是1,即每帧都调用一次。如果每帧都调用一次的话,对于iOS设备来说那刷新频率就是60HZ也就是每秒60次,如果将 frameInterval 设为2 那么就会两帧调用一次,也就是变成了每秒刷新30次。
我们通过pause属性开控制CADisplayLink的运行。当我们想结束一个CADisplayLink的时候,应该调用-(void)invalidate
从runloop中删除并删除之前绑定的 target跟selector
另外CADisplayLink 不能被继承。

#define LXD_RESPONSE_THRESHOLD 10
dispatch_async(lxd_fluecy_monitor_queue(), ^{
    CADisplayLink * displayLink = [CADisplayLink displayLinkWithTarget: self selector: @selector(screenRenderCall)];
    [self.displayLink invalidate];
    self.displayLink = displayLink;

    [self.displayLink addToRunLoop: [NSRunLoop currentRunLoop] forMode: NSDefaultRunLoopMode];
    CFRunLoopRunInMode(kCFRunLoopDefaultMode, CGFLOAT_MAX, NO);
});

- (void)screenRenderCall {
    __block BOOL flag = YES;
    dispatch_async(dispatch_get_main_queue(), ^{
        flag = NO;
        dispatch_semaphore_signal(self.semphore);
    });
    dispatch_wait(self.semphore, 16.7 * NSEC_PER_MSEC);
    if (flag) {
        if (++self.timeOut < LXD_RESPONSE_THRESHOLD) { return; }
        [LXDBacktraceLogger lxd_logMain];
    }
    self.timeOut = 0;
}

经过前面的分析,CADisplayLink监控是一个相对而言比较优的方案,KMCGeigerCounter就是一个借助CADisplayLink进行卡顿检测的第三方工具,接下来分析一下KMCGeigerCounter源码。

KMCGeigerCounter介绍

KMCGeigerCounter是一个iOS帧速计算器,像盖革计数器那样,当动画丢失一帧时它就记录一次。
掉帧通常是不可见的,但是很难区分55fps和60fps之间的不同,而KMCGeigerCounter可以让你观测到掉落5帧的情况,可以通过这个来检测app的卡顿程度。
KMCGeigerCounter弄了一个FPS监控条,通过CADisplayLink来获取屏幕刷新频率,在使用过程中就能即时知道什么页面流畅什么页面会卡顿。

因为官方的demo在didFinishLaunchingWithOptions方法中写了比较复杂的代码 而在Xcode7及以上的SDK不允许在设置rootViewController之前做过于复杂的操作 所以程序一直无法正常启动。

dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
    [KMCGeigerCounter sharedGeigerCounter].enabled = YES;
});

KMCGeigerCounter源码分析

程序里面,通过CADisplayLink来检测CPU的卡顿,CADisplayLink 是一个计时器对象,可以使用这个对象来保持应用中的绘制与显示刷新的同步。更通俗的讲,电子显示屏都是由一个个像素点构成,要让屏幕显示的内容变化,需要以一定的频率刷新这些像素点的颜色值,系统会在每次刷新时触发 CADisplayLink。

#define kNormalFrameDuration    (1/60)      //流畅的屏幕刷新时,每帧之间的间隔时间
self.displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(displayLinkWillDraw:)];
[self.displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];

- (void)displayLinkWillDraw:(CADisplayLink *)displayLink {
    //当前屏幕刷新回调的时间
    CFTimeInterval currentFrameTime = displayLink.timestamp;
    //这次屏幕刷新和上一次屏幕刷新的时间间隔
    CFTimeInterval frameDuration = currentFrameTime - [self lastFrameTime];
    //如果界面不卡顿,那么屏幕刷新频率应该是1秒钟60帧,那么帧间间隔时间应该是1/60秒,如果当前刷新和上一次屏幕刷新的时间间隔,超过这个时间间隔,那么就属于卡顿,则系统响一下,这里设定,屏幕刷新如果是1秒钟少于40帧(60/1.5)则响一下。
    if (1.5 < frameDuration / kNormalFrameDuration) {
        AudioServicesPlaySystemSound(self.tickSoundID);
    }
    //记录每次屏幕刷新时的时间(60次)
    [self recordFrameTime:currentFrameTime];
    //显示帧率和丢帧数
    [self updateMeterLabel];
}
- (void)recordFrameTime:(CFTimeInterval)frameTime {
    ++self.frameNumber;
    //通过一个数组(60个元素),来记录屏幕每次刷新时的时间(60次刷新的时间)
    _lastSecondOfFrameTimes[self.frameNumber % kHardwareFramesPerSecond] = frameTime;
}
//获取上一秒丢失的帧数
- (NSInteger)droppedFrameCountInLastSecond {
    NSInteger droppedFrameCount = 0;
    CFTimeInterval lastFrameTime = CACurrentMediaTime() - kNormalFrameDuration;
    for (NSInteger i = 0; i < kHardwareFramesPerSecond; ++i) {
        //_lastSecondOfFrameTimes数组记录了前60次屏幕刷新的时间,如果当前时间与这个数组中60次刷新时间超过了1秒钟,那么都会被丢弃而不显示,累加则知道1秒钟丢了多少帧
        if (1.0 <= lastFrameTime - _lastSecondOfFrameTimes[i]) {
            ++droppedFrameCount;
        }
    }
    return droppedFrameCount;
}
- (void)updateMeterLabel {
    //一秒钟屏幕刷新时的丢帧数
    NSInteger droppedFrameCount = self.droppedFrameCountInLastSecond;
    //一秒钟屏幕刷新时的显示帧数
    NSInteger drawnFrameCount = self.drawnFrameCountInLastSecond;
    //...显示代码
}

将CADisplayLink添加到主线程runloop中,一旦屏幕需要刷新时,就会回调CADisplayLink对应的selector方法,在 selector 中可以通过 CADisplayLink 对象的属性 duration、frameInterval 和 timestamp 获取帧率和时间信息。

总结:
上面CADisplayLink是检测CPU的卡顿,但是GPU的卡顿需要用到SKView,库里面用到一个 1×1 的 SKView 来进行监视。

你可能感兴趣的:(ios开发)