前言

对于iOS卡顿优化的重要性不必多言,不仅影响着用户体验,更关系到用户留存、DAU等重要产品数据。

卡顿的概念

卡顿,即应用使用过程中出现了一段时间的阻塞,屏幕内容没有任何的变化,也无法进行任何操作。根据这个阻塞时间的长短,可以将卡顿问题划分为丢帧、卡顿、卡死三个不同的等级。

  • 丢帧:画面更新不流畅,阻塞时间为几十毫秒。
  • 卡顿:应用短时间内无法进行任何操作,恢复后能继续使用,阻塞时间从几百毫秒至几秒。
  • 卡死:应用长时间无法进行任何操作,直至被系统杀死,阻塞时间超过几秒。

卡顿的原因

屏幕显示内容时,移动设备的具体处理流程是:

ios卡顿 ios卡顿严重_ios卡顿

  1. CPU计算好显示内容,提交至GPU;
  2. GPU渲染完成后,将渲染结果放入帧缓存区FrameBuffer;
  3. 视频控制器会按照垂直同步信号机制,GPU发出VSync信号后,逐行读取FrameBuffer的数据;
  4. 经过数模转换传递给显示器进行显示。

由于垂直同步的机制,如果在一个VSync时间内,CPU 或者GPU没有完成内容提交,则那一帧就会被丢弃,等待下一次机会再显示,而这时显示屏会保留之前的内容不变,这个现象就是掉帧,当这个现象持续下去,就会出现卡顿,甚至是卡死。

ios卡顿 ios卡顿严重_ios_02


那又是什么原因导致CPU 或者GPU在一个VSync时间内无法完成内容提交,从而产生了掉帧、卡顿和卡死呢?

UI操作需要在主线程完成,主线程基于Runloop机制完成各项任务的处理,UIEvent事件、Timer事件、dispatch主线程任务都是在Runloop循环机制的驱动下完成的。

一旦主线程中的任何一个环节进行了一个耗时的操作,或者因为锁的使用不当造成了与其它线程的死锁,主线程就会因为无法执行Core - Animation的回调而造成界面无法刷新。而用户的交互又依赖于UIEvent的传递和响应,该流程也必须在主线程中完成。所以说主线程的阻塞会导致UI和交互的双双阻塞,这也是导致掉帧、卡顿和卡死的根本原因。

卡顿的监控

监控卡顿,就是要监测主线程Runloop运行状态,判断什么时间出现阻塞,阻塞了多久。Runloop的运行逻辑如下图所示:

ios卡顿 ios卡顿严重_ios_03


这个运行逻辑对应了六个运行状态:

typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
    kCFRunLoopEntry           = (1UL << 0), // 即将进入Loop
    kCFRunLoopBeforeTimers    = (1UL << 1), // 即将处理 Timer
    kCFRunLoopBeforeSources   = (1UL << 2), // 即将处理 Source
    kCFRunLoopBeforeWaiting   = (1UL << 5), // 即将进入休眠
    kCFRunLoopAfterWaiting    = (1UL << 6), // 刚从休眠中唤醒
    kCFRunLoopExit            = (1UL << 7), // 即将退出Loop
    kCFRunLoopAllActivities // loop所有状态改变
};

所以,监听主线程Runloop的状态从kCFRunLoopBeforeSources到kCFRunLoopBeforeWaiting再到kCFRunLoopAfterWaiting就可以知道是否有卡顿的出现。具体实现方案:
创建一个CFRunLoopObserverContext观察者,实时获取主线程Runloop的状态变化,通过signal机制将状态传递给正在监听状态的子线程;之后在这个监听的子线程,获取kCFRunLoopBeforeSources到kCFRunLoopAfterWaiting的时长,当这个时长达到阀值时,获取主线程堆栈并上报卡顿出现。
对于卡死,一个或多个耗时的操作,只要其耗时超过了系统的允许阈值,都会触发卡死。不只是死锁、死循环这样的场景,当应用启动过程中,没有在限定时间内完成初始化工作也会被系统杀死。

卡顿优化

1 CPU 减少计算,减少耗时操作

  • 提前计算好布局,列表页高度在请求完成数据后,就计算好高度,显示时直接使用。
  • 尽量使用轻量级的对象,比如用不到事件处理的地方使用CALayer代替UIView
  • hook setNeedsLayout、setNeedDisplay、setNeedsDisplayInRect方法,保证方法在主线程运行
  • 查找因重复执行导致卡顿的方法,比如多个地方监听同一个通知,通知中执行多次的清除缓存的方法
  • 保证后台运行时,不调用接口
  • 把耗时的操作放到子线程
    1 文本处理(尺寸计算、绘制、CoreText和YYText)
    计算文本宽高 boundingRectWithSize: options: context:和文本绘制drawWithRect:options:context放在子线程操作。
    使用CoreText自定义文本空间,在创建对象过程中可以缓存宽高等信息,避免像UILabel/UITextView需要多次计算(调整和绘制都要计算一次),而且CoreText直接使用了CoreGraphics占用内存小,效率高。
    2 图片解码
    当使用UIImage或者CGImageSource创建图片时,图片数据并不会立即解码。图片设置到UIImageView或CALayer.content中,并且CALayer被提交到GPU前,CGImage中到数据才会得到解码,这一步是发生在主线程的,并且不可避免。SDWebImage处理方式:在后台线程先把图片绘制到CGBitmapmapContext中,然后直接从Bitmap创建图片。

2 GPU 减少渲染

  • 避免短时间内大量图片的显示,尽可能将多张图片合成一张显示
  • GPU能处理的最大纹理尺寸是4096*4096,一旦超过这个尺寸,就会占用CPU资源进行处理,所以纹理尽量不要超过这个尺寸
  • GPU会将多个视图混合在一起再去显示,混合的过程中会消耗CPU资源,尽量减少视图数量和层次
  • 减少透明的视图(alpha < 1),不透明的设置opacity为YES,GPU就不会进行alpha通道的合成
  • 尽量避免出现离屏渲染
    离屏渲染需要创建新的缓冲区,在渲染的整个过程中,需要多次切换上下文环境,先是从当前屏幕(On-Screen)切换到离屏(Off-Screen);等到离屏渲染结束以后,将离屏缓冲区的渲染结果显示到屏幕上,又需要将上下文环境从离屏切换到当前屏幕,造成了资源到极大消耗。一些会触发离屏渲染的操作:
    光栅化,layer.shouldRasterize = YES
    遮罩,layer.mask
    圆角,同时设置masksToBounds、cornerRadius大于0,考虑通过CoreGraphics绘制裁剪圆角,或者直接使用圆角图片
    阴影

参考文章

字节跳动 iOS Heimdallr 卡死卡顿监控方案与优化之路iOS卡顿优化

iOS界面卡顿原理及优化

iOS 之如何利用 RunLoop 原理去监控卡顿