APP卡顿总结

1,598 阅读7分钟

什么是卡顿

卡顿就是在应用使用过程中出现界面不响应或者界面渲染粘滞的情况。而应用界面的渲染以及事件响应是在主线程完成的,出现卡顿的原因可以归结为主线程阻塞。

在开发过程中,遇到的造成主线程阻塞的原因可能是:

  • 主线程在进行大量I/O操作:为了方便代码编写,直接在主线程去写入大量数据;
  • 主线程在进行大量计算:代码编写不合理,主线程进行复杂计算;
  • 大量UI绘制:界面过于复杂,UI绘制需要大量时间;
  • 主线程在等锁:主线程需要获得锁A,但是当前某个子线程持有这个锁A,导致主线程不得不等待子线程完成任务。

如何解决卡顿

捕获得到卡顿当时应用的主线程堆栈,有了堆栈,就可以知道主线程在什么函数哪一行代码卡住了,是在等什么锁,还是在进行I/O操作,或者是进行复杂计算。有了堆栈,就可以对问题进行针对性解决

原理分析

主线程有一个 Runloop。Runloop 是一个 Event Loop 模型,让线程可以处于接收消息、处理事件、进入等待而不马上退出。在进入事件的前后,Runloop 会向注册的 Observer 通知相应的事件

image.png

在 Runloop 的起始最开始和结束最末尾位置添加 Observer,从而获得主线程的开始和结束状态。卡顿监控起一个子线程定时检查主线程的状态,当主线程的状态运行超过一定阈值则认为主线程卡顿,从而标记为一个卡顿。

image.png

主程序 Runloop 超时的阈值是 2 秒,子线程的检查周期是 1 秒。每隔 1 秒,子线程检查主线程的运行状态;如果检查到主线程 Runloop 运行超过 2 秒则认为是卡顿,并获得当前的线程快照。

CPU 过高也可能导致应用出现卡顿,所以在子线程检查主线程状态的同时,如果检测到 CPU 占用过高,会捕获当前的线程快照保存到文件中。微信App认为,单核 CPU 的占用超过了 80%,此时的 CPU 占用就过高了

监控APP卡顿的三种方式

  • FPS
  • Runloop
  • 子线程ping主线程

FPS检测APP卡顿

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

image.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)的计算任务,否则就会卡顿

我们可以用CADisplayLink来检测FPS,CADisplayLink则是使用帧率来作为时间间隔的单位

- (void)setupDisplayLink {

   //创建CADisplayLink,并添加到当前run loop的NSRunLoopCommonModes
   _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(linkTicks:)];
   [_displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];
}

- (void)linkTicks:(CADisplayLink *)link {

   //执行次数
   _scheduleTimes ++;

   //当前时间戳
   if(_timestamp == 0){
       _timestamp = link.timestamp;
   }
   
   CFTimeInterval timePassed = link.timestamp - _timestamp;

   if(timePassed >= 1.f)
       //fps
       CGFloat fps = _scheduleTimes/timePassed; 
       printf("fps:%.1f, timePassed:%f\n", fps, timePassed);

       //reset
       _timestamp = link.timestamp;
       _scheduleTimes = 0;
   }
} 

我们也可以通过Xcode中的Instruments中的Core Animation来监测FPS

在上述FPS指示器中,如果将CADisplayLink放置于子线程的Runloop中,将会发生什么?

答案是无论主线程有多么繁忙,GPU占用有多么高,FPS始终是60,原因是基于CADisplayLink的FPS指示器只能检测到当前RunLoop的FPS

Runloop如何检测卡顿

由于runloop会调起同步屏幕刷新的callback,如果loop的间隔大于16.67ms,fps自然达不到60hz。而在一个loop当中存在多个阶段,可以监控每一个阶段停留了多长时间

- (void)startRunloopMonitorFreeze {
    
    CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(CFAllocatorGetDefault(), kCFRunLoopAllActivities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
       
        
       ///如果CFAbsoluteTimeGetCurrent() - 上一次的时间差 比阈值大,说明卡顿了,APP处于闲置状态常驻beforeWaiting状态,判断不准确
        
        
    });
    
    CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
    
}

这种检测方式当APP处于闲置状态Runloop常驻beforeWaiting状态,判断不准确

子线程检测主线程

退火算法

为了降低检测带来的性能损耗,我们为检测线程增加了退火算法:

  • 每次子线程检查到主线程卡顿,会先获得主线程的堆栈并保存到内存中(不会直接去获得线程快照保存到文件中);
  • 将获得的主线程堆栈与上次卡顿获得的主线程堆栈进行比对:
    • 如果堆栈不同,则获得当前的线程快照并写入文件中;
    • 如果相同则会跳过,并按照斐波那契数列将检查时间递增直到没有遇到卡顿或者主线程卡顿堆栈不一样。 这样,可以避免同一个卡顿写入多个文件的情况;避免检测线程遇到主线程卡死的情况下,不断写线程快照文件。

如何提前耗时堆栈

子线程检测到主线程 Runloop 时,会获得当前的线程快照当做卡顿文件。但是这个当前的主线程堆栈不一定是最耗时的堆栈,不一定是导致主线程超时的主要原因。

比如主线程分别先后执行funcA() funcB() funcC() funcD() 这几个方法, 子线程在检测到超出阈值时获得的线程快照,主线程的当前任务是 funcD()但其实funcB()才是耗时操作,导致主线程超时的主要原因。 卡顿监控通过主线程耗时堆栈提取来解决这个问题

卡顿监控定时获取主线程堆栈,并将堆栈保存到内存的一个循环队列中。如下图,每间隔时间 t 获得一个堆栈,然后将堆栈保存到一个最大个数为 3 的循环队列中。有一个游标不断的指向最近的堆栈。

微信的策略是每隔 50 毫秒获取一次主线程堆栈,保存最近 20 个主线程堆栈。这个会增加 3% 的 CPU 占用,内存占用可以忽略不计。

image.png

当主线程检测到卡顿时,通过对保存到循坏队列中的堆栈进行回溯,获取最近最耗时堆栈。

如下图,检测到卡顿时,内存的循环队列中记录了最近的20个主线程堆栈,需要从中找出最近最耗时的堆栈。卡顿监控用如下特征找出最近最耗时堆栈:

  • 以栈顶函数为特征,认为栈顶函数相同的即整个堆栈是相同的;
  • 取堆栈的间隔是相同的,堆栈的重复次数近似作为堆栈的调用耗时,重复越多,耗时越多;
  • 重复次数相同的堆栈可能很有多个,取最近的一个最耗时堆栈。

image.png