iOS卡顿监控探索与实践

5,959 阅读5分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第16天,点击查看活动详情

我们知道,与用户交互的事件都是在主线程里处理的,但如果主线程无法响应用户的交互就会造成卡顿,卡顿时间比较长是非常影响App的功能和用户体验的,所以这也是一个非常值得重视的问题。

一般能够造成卡顿的几种原因如下:

  • 复杂的UI、图文混排的绘制量过大
  • 在主线程上做网络同步请求
  • 在主线程做大量的IO操作
  • 运算量过大,CPU持续高占用
  • 死锁和主线程抢锁

卡顿监控的方法

要想治理卡顿问题,首先要先监控到卡顿的问题才行,一般监控卡顿有三种方案:

  • 利用Instruments监测卡顿;
  • 利用CADisplayLink来监控刷新的帧率;
  • 利用runLoop,创建一个观察者,监控runLoop状态。

Instruments

image.png Instruments只能在Debug联调的时候使用,但很多用户的场景和数据都与debug下的数据并不一致,并且没有办法做到线上实时监控用户的卡顿数据。

FPS

FPS是一秒显示的帧数,也就是一秒内画面变化的数量。一般认为50FPS以上会不卡顿。

可以利用CADisplayLink,CADisplayLink是一个特殊的定时器,我们常说的60HZ,就是说默认情况下CADisplayLink每秒调用60次。我们可以通过计算它1秒内调用多少次来查看界面的流畅度。虽然CADisplayLink更轻量,但需要在CPU稍微清闲时才能够回调,严重卡顿的堆栈获取不一定及时,并且就算50FPS以下通过肉眼来看也是连贯的,所以简单的通过监视FPS很难确定是否出现了卡顿问题。

RunLoop

RunLoop_1.png 监控卡顿就是要去找到主线程上做了哪些事情。我们知道主线程有一个RunLoop。RunLoop是一个Event Loop模型,让线程可以处于接收消息、处理事件、进入等待而不马上退出。在进入事件的前后,RunLoop会向注册的Observer通知相应的事件。

使用RunLoop监控的方案是。通过监控RunLoop的状态来判断是否会出现卡顿。

想要监听RunLoop,首先需要创建一个CFRunLoopObserverContext观察者,代码如下:

_observer = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, kCFRunLoopAllActivities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
    switch (activity) {
        case kCFRunLoopEntry: {
        } break;

        case kCFRunLoopBeforeTimers: {
        } break;

        case kCFRunLoopBeforeSources: {
        } break;

        case kCFRunLoopBeforeWaiting: {
            //处理完事件,即将休眠
        } break;

        case kCFRunLoopAfterWaiting: {
            //将被唤醒
        } break;

        case kCFRunLoopExit: {
        } break;

        default:
            break;
    }
});

将创建好的观察者_observer添加到主线程RunLoop的common模式下观察。

CFRunLoopAddObserver(CFRunLoopGetMain(), _observer, kCFRunLoopCommonModes);

然后,创建一个持续的子线程专门用来监控主线程的RunLoop状态。

//创建子线程监控
dispatch_async(dispatch_get_global_queue(0, 0), ^{
    //子线程开启一个持续的 loop 用来进行监控
    while (YES) {
        long semaphoreWait = dispatch_semaphore_wait(dispatchSemaphore, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC));
        if (semaphoreWait != 0) {
            if (!_observer) {
                timeoutCount = 0;
                dispatchSemaphore = 0;
                runLoopActivity = 0;
                return;
            }
            //BeforeSources 和 AfterWaiting 这两个状态能够检测到是否卡顿
            if (runLoopActivity == kCFRunLoopBeforeSources || runLoopActivity == kCFRunLoopAfterWaiting) {
                //将堆栈信息上报服务器的代码放到这里
            } //end activity
        }// end semaphore wait
        timeoutCount = 0;
    }// end while
});

5s是触发卡顿的时间阈值,这个时间如何设置才更合理,我们可以参考WatchDog机制来设置。WatchDog在不同状态下设置的不同时间,如下所示:

  • 启动(Launch):20s
  • 恢复(Resume):10s
  • 挂起(Suspend):10s
  • 退出(Quit):6s
  • 后台(Background):3min(每次申请3min,可连续申请,最多申请到10min) 大原则是这个阈值要小于WatchDog的限制时间。然后卡顿主要也是解决掉用户感知非常明显的卡顿问题,所以可以动态的去配置阈值的时间。如果是刚刚开始监控的话可以把阈值设置的大一些,然后慢慢的收紧,达到一个可监控和体验的平衡。

获取堆栈信息

当监控到卡顿后,还需要将卡顿的堆栈记录下来,并且上传给开发者分析和定位问题。

获取堆栈信息的一种方法是直接调用系统函数。这种方法的优点在于,性能消耗小。但是只能获取到简单的信息。

异常捕获的方法:

NSSetUncaughtExceptionHandler(&UncaughtExceptionHandler);

这个方法也用来获取crash异常,使用时要进行区分。

第二种方式是使用第三方库来获取堆栈信息,第三方库要满足以下条件:

  • 能够在任何时候获取到堆栈信息,如果只有crash时才能获取则不满足
  • 获取到的堆栈信息中要包含Binary Images,否则可能无法符号化

在这里推荐使用 PLCrashReporter 。如果项目中包含了其他获取堆栈的库,如果满足以上的条件那就可以直接用;如果不满足需要使用其他库时需要注意是否影响crash的获取和上报。上报成功后通过dsym进行符号化分析。

总结

我们通过卡顿监控与上报解决了多个卡顿问题,是之前的复现手段无法复现的(通过日志路径模拟、特殊账号复现等)。目前卡顿问题也被逐渐梳理出来并得到解决。

在使用过程中总结了以下一些经验:

  1. 在我们解决的过程中发现大部分的卡顿问题都是代码逻辑中把错误的逻辑放到主线程中了。比如使用异步线程做一些操作但是回调后又回到了主线程;一些文件IO在主线程中进行的;一些UI的bug等。
  2. 在监控的堆栈里也并不是所有堆栈都是有效的,因为获取卡顿堆栈的逻辑是通过一个时间阈值来获取堆栈,比如卡顿了5s然后真正卡顿的堆栈已经过去了,这时候再取堆栈可能就不能提供有效信息了。但是大部分都还是准确的。