货拉拉用户 iOS 端卡顿优化实践

语言: CN / TW / HK

前言

卡顿优化一直是客户端性能治理的重要方向之一,在这之前,我们先来解释下什么是卡顿。

卡顿,直白来说就是用户在使用APP的过程中能感受到界面一卡一卡的不流畅。从原理来说,就是在用户能够感知的视觉场景中,当事件处理和UI展示的综合消耗时间超过用户视觉系统的最大期待时间时,就会出现卡顿现象。卡顿会影响用户的操作,损害用户体验,进一步影响用户对APP的评价和留存。因此,操作流畅度是决定APP体验好坏的关键因素之一。优化卡顿,将APP的用户体验做到极致,在一定程度上能够提升用户的忠诚度和APP的市场占有率。

行业标准

那么,APP的卡顿率在多少区间算是正常或者优秀呢? 我们可以参考 《2020移动应用性能管理白皮书 | 基调听云》推荐的行业标准:

| 性能指标 | 优秀值 | 及格值 | 极差值 | 行业参考值 | | ------ | --- | --- | ---- | ----- | | 卡顿率(%) | <=2 | 5 | >=8 | 4 |

整体状况

APP卡顿优化是一个长期过程,货拉拉用户端APP卡顿治理分多期进行,在前期的治理中,我们的卡顿率数据采用的是bugly的卡顿监控。治理前,APP的卡顿率是6.13%,通过2个月的治理实践,卡顿率降到了2.1%,已接近行业优秀标准。因此,我们总结了这段时间的一些探索和实践,希望能给大家在App卡顿优化方面提供一些借鉴和思路。

卡顿原理和检测

为什么出现卡顿?

屏幕显示图像是需要CPU和GPU结合工作。CPU 负责计算显示内容,包括视图创建、布局计算、图片解码、文本绘制等,CPU 完成计算后,会将计算内容提交给 GPU;GPU 进行变换、合成、渲染,将渲染结果提交到帧缓冲区,当下一次垂直同步信号(简称 V-Sync)到来时,将渲染结果显示到屏幕上。

UI视图显示到屏幕中的过程:

image.png

在屏幕显示图像前,CPU 和 GPU 需要完成自身的任务,系统会每(1000/60=16.67ms)将UI的变化重新绘制,渲染到屏幕上。如果在16ms内,主线程进行了耗时操作,CPU和GPU没有来得及生产出一帧缓冲,那么这一帧会被丢弃,显示器就会保持不变,继续显示上一帧内容,用户的视觉上就出现了卡顿;因此卡顿产生的原因就是,CPU和GPU没有及时处理好数据。所以,针对卡顿优化的思路是,尽可能减少 CPU 和 GPU 资源消耗。

UIEvent的事件是在Runloop循环机制驱动下完成的,主线程任意一个环节进行了耗时操作,主线程都无法执行Core Animation回调,进而造成界面无法刷新。用户交互是需要UIEvent的传递和响应,也必须在主线程中完成。所以说主线程的阻塞会导致UI和交互的双双阻塞,这也是导致卡顿的根本原因。

卡顿检测

知道了卡顿出现的根本原因,我们就很好理解如何进行卡顿检测了。业界常见的卡顿检测是对主线程的Runloop进行监控,因为卡顿直接导致操作无响应,界面动画迟缓,所以通过检测主线程能否响应任务,来判断是否卡顿。在讲如何用Runloop来检测卡顿之前,我们先来回顾下Runloop的运行机制。

  1. Runloop的运行机制

RunLoop 会接收两种类型的输入源:

  • 来自另一个线程或者来自不同应用的异步消息;
  • 来自预订时间或者重复间隔的同步事件;

RunLoop主要的工作是,当有事件要去处理时保持线程忙,当没有事件要处理时让线程进入休眠 。

整个 RunLoop 过程 :

image.png

  1. RunLoop监控卡顿的原理

如果 RunLoop 的线程,进入睡眠前,方法执行时间过长而导致无法进入睡眠;或者线程唤醒后,接收消息时间过长而无法进入下一步,就可以认为是线程受阻。如果这个线程是主线程,表现出来的就是出现卡顿。 所以,利用 RunLoop 来监控卡顿,就需要关注这两个阶段。进入睡眠之前和唤醒后的两个loop状态值,也就是 kCFRunLoopBeforeSources 和 kCFRunLoopAfterWaiting(触发 Source0 回调和接收 match_port 消息两个状态)。线程的消息事件是依赖于 RunLoop ,通过开辟一个子线程来监控主线程的 RunLoop 的状态,就能够发现调用方法是否执行过长,从而判断出是否出现卡顿。

RunLoop的六个状态 :

``` / Run Loop Observer Activities /

typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {

kCFRunLoopEntry = (1UL << 0), //进入loop

kCFRunLoopBeforeTimers = (1UL << 1), //触发 Timer 回调

kCFRunLoopBeforeSources = (1UL << 2),//触发 Source0 回调

kCFRunLoopBeforeWaiting = (1UL << 5),//等待 mach_port 消息

kCFRunLoopAfterWaiting = (1UL << 6),//接受 mach_port 消息

kCFRunLoopExit = (1UL << 7), //退出 loop

kCFRunLoopAllActivities = 0x0FFFFFFFU //loop 所有状态改变

}; ```

  1. 监控的实现

    1. 创建一个RunLoop的观察者:

CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL}; _runLoopObserver = CFRunLoopObserverCreate(kCFAllocatorDefault, kCFRunLoopAllActivities, YES, 0, &runLoopObserverCallBack, &context); //将观察者添加到主线程runloop的common模式下 CFRunLoopAddObserver(CFRunLoopGetMain(), _runLoopObserver, kCFRunLoopCommonModes);

  1. 再将观察者 runLoopObserver 添加到主线程 RunLoop 的 common 模式下,然后再创建一个持续的子线程专门用来监控主线程的RunLoop状态。实时计算 kCFRunLoopBeforeSources 和 kCFRunLoopAfterWaiting 两个状态区域之间的耗时是否超过某个阈值,超过即可判断为卡顿,然后把对应的堆栈信息进行上报。

``` dispatch_async(dispatch_get_global_queue(0, 0), ^{ //子线程开启一个持续的loop用来进行监控 while (YES) { long semaphoreWait = dispatch_semaphore_wait(self.dispatchSemaphore, dispatch_time(DISPATCH_TIME_NOW, 3 * NSEC_PER_SEC)); if (semaphoreWait != 0) { if (!self.runLoopObserver) { self.timeoutCount = 0; self.dispatchSemaphore = 0; self.runLoopActivity = 0; return; }

    //BeforeSources和AfterWaiting这两个状态区间时间能够检测到是否卡顿
    if (self.runLoopActivity == kCFRunLoopBeforeSources || self.runLoopActivity == kCFRunLoopAfterWaiting) {
      //上报对应的卡顿堆栈信息
    }
  }
}

}); ```

除了上面介绍的自研卡顿监控方案,也可以使用第三方SDK,不过检测原理都是大同小异的。货拉拉用户端因工程中本身就集成了Bugly SDK,因此在治理前期,我们只是把Bugly的卡顿检测打开,以最小的投入成本,达到线上尽快有卡顿指标可以参考的目的。

卡顿治理实践

我们在开启Bugly的卡顿监控时,将卡顿阈值blockMonitorTimeout设置为3秒,这也是SDK默认阈值。即,监控主线程 Runloop 的执行,观察执行耗时是否超过3s。在监控到卡顿时会立即记录线程堆栈到本地,在App从后台切换到前台时,执行上报。治理前期有大量的卡顿异常上报,我们对上报的卡顿进行分期治理,根据异常发生次数划分为Top 20、Top50等。上报量Top 20的卡顿为高频卡顿,上报次数频繁、影响用户多,需优先治理。

用户端Top4的卡顿如下:

常见卡顿

在治理过程中,我们将常见的卡顿原因做了聚合分类,并且针对不同的卡顿原因,总结了对应不同的解决方案,以下为针对性的治理方案:

  1. IO读写

    1. 在主线程做大量的数据读写操作

优化方案:开启子线程,异步去读取和保存本地的数据,逻辑处理完了再回到主线程刷新UI

例如:+[CityManager saveLocalCityList:] (CityManager.m:)

全国的城市列表数据量大,在获取到最新的数据后,会将数据存放在本地。在数据写入和读取的时候,开启子线程,异步读取和存入本地。优化后的代码:

``` // 子线程异步存储

  • (void)getCityList { ......

    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ [CityManager saveLocalCityList:list]; ...... });

} ```

  1. UI绘制相关

    1. 复杂的UI、图文混排的绘制量过大

优化方案:无事件处理的地方尽可能的使用CALayer,保持视图的轻量,避免重写drawRect方法;

  1. 频繁使用setNeedsLayout,layoutIfNeeded来刷新UI

有时候为了达到立即刷新UI的效果,会调用如下的代码:

``` - (void)updateConfig {

......

[self setNeedsLayout];

[self layoutIfNeeded];

} ```

这样调用后,会触发调用layoutSubViews,强制视图立即更新其布局,使用自动布局时,布局引擎会根据需要来更新视图的位置,以满足约束的更改。这样会增加消耗,容易造成卡顿。

优化方案:有时候想要立即刷新UI,可能只是为了获取最新的frame数据。可进行代码逻辑的调整,换一种方式实现,就能减少layoutIfNeeded的调用,减少卡顿的出现。

  1. 主线程相关

    1. 在主线程上做网络同步请求,或者在主线程中做数据解析和模型转换

优化方案:在子线程中发起网络请求,并且在子线程中进行数据的解析和模型的转换;处理完逻辑后,再回到主线程中刷新UI。

  1. 主线程做大量的逻辑处理,运算量大,CPU 持续高占用

优化方案:有复杂逻辑的地方,建议梳理逻辑,优化算法,并且把逻辑的处理放在子线程中进行处

理;处理完后,再回到主线程中刷新UI。

首页是用户使用率最高的页面,版本不停的迭代,货拉拉首页的需求也是频繁的变化;在车型选择模块,随着需求的变化,有很多的AB实验叠加在一起,导致车型选择模块有大量的逻辑判断,视图非常多且复杂;随着需求的迭代,越来越难以维护,属于卡顿异常高发区。经过综合分析,决定对这个模块进行逻辑梳理,重构车型模块的UI。上线后该模块的卡顿上报量明显下降,收益明显。

  1. boundingRectWithSize在主线程中执行

优化方案:文本高度的计算会占用很大一部分CPU资源,因此在涉及计算的地方,最好不要在主线程中进行;在子线程中计算好再回到主线程中刷新对应的UI。例如:

优化后的代码:

``` NSString *text = @"货拉拉..."; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ CGFloat width = [text boundingRectWithSize:CGSizeMake(375, 20) options:NSStringDrawingUsesLineFragmentOrigin attributes:@{NSFontAttributeName :[UIFont systemFontOfSize:15]} context:nil].size.width; dispatch_async(dispatch_get_main_queue(), ^{ self.logoLabel.width = width; }); });

```

  1. 启动时任务太多,没有做线程的优先级管理,影响首页的UI创建,导致卡顿

优化方案:针对启动的所有任务进行梳理,根据任务的重要性进行优先级的划分;非启动必须的任务,可延迟执行,等待首页UI初始化完成后再进行执行;优先级较低的任务,将其放入子线程中执行,避免造成主线程阻塞,引起卡顿。

  1. 其他

    1. 本地读取icon

优化方案:本地的小图片尽量使用Images.xcassets来管理,不建议使用bundle。Images.xcassets中的图片,使用imageName读取,加载到内存中,会有缓存,占据内存空间。对于需要重复加载的icon,因为有缓存,加载速度会提升很多。

但是对于内存大的图片资源,最好放在bundle中,并且使用imageWithContentsOfFile读取,这个方法不会有缓存,这样可以更好的控制内存。

  1. 第三方SDK相关的问题

优化方案:需要结合具体的SDK进行优化;若是SDK内部引起的,可联系第三方进行对应的问题反馈,促进问题的优化;

疑难卡顿

上报的卡顿线程堆栈信息中,可能存在信息不准确的情况,也可能存在很多信息不足的情况,根据上报的内容,无法精确定位卡顿的具体位置。例如:

针对这种疑难的卡顿上报,需要借助用户的日志,进一步定位。在上报卡顿时,也上报用户的userid,根据用户的id在内部平台上查询用户详细的实时日志和离线日志。

实时日志:

  • 记录了用户的操作路由,可定位卡顿的具体页面
  • 包含行为埋点、自动化埋点、异常埋点内容,可分析用户的行为,进一步定位卡顿的代码位置

离线日志:

  • 属于实时日志的补充,实时日志无法定位问题时,进一步分析离线日志
  • 记录了更多的打点内容和网络请求相关数据

总结

以上都是对线上已经产生的卡顿进行治理的方案,更重要的其实是,我们如何在编码阶段就规避卡顿的产生。因此,我们也总结了一些思路和规范。

如何避免卡顿

  1. 避免使用CPU自定义绘图,无事件处理的地方尽可能的使用CALayer,保持视图的轻量;
  2. 尽量复用视图,减少视图的添加和移除;例如移除视图需要动画,可使用隐藏属性来实现;
  3. 避免重写drawRect方法,该方法会开辟额外的内存空间进行CPU绘制,更要避免在其中做耗时操作;
  4. 在更新布局的时候,减少layoutIfNeeded的使用,尽量只使用setNeedsLayout
  5. 将耗时操作放在子线程中进行,减轻主线程的压力
  6. 避免主线程进行IO相关的操作
  7. 针对于必须在 CPU 上进行绘制的组件,尝试使用多线程的异步绘制能力,减轻主线程压力
  8. 图片的大小和UIImageView的size保持一致,避免CPU进行伸缩操作
  9. 控制线程的最大并发数量,CPU调度处理也需要耗时,线程过多会使CPU繁忙
  10. 避免出现离屏渲染

防劣化措施

在经过卡顿治理后,为了进一步治理优化,并且防止数据恶化,我们采取了以下措施:

  1. 代码质量

提高开发阶段的代码质量,在开发阶段就减少卡顿的产生

  • 建立了Code Review 制度
  • 将引起卡顿的常见原因加入代码规范,code review时需特别注意
  • 通过CR发现可优化点,提前发现可能引起卡顿的地方

  • 版本迭代治理

每个版本上线初期,观察卡顿的上报情况。对于新增的卡顿,统计并分配任务,在下一个版本中进行优化治理。最大程度的减少了卡顿的存在,防止指标的恶化。

  1. 监控平台

在自研的监控平台上,用户端针对页面进行了卡顿次数的上报统计,新版本上线后,可根据版本号观察数据的变化,及时发现新的卡顿问题。

后续规划

  • 卡顿治理的一期都是基于bugly工具上报的线程信息进行优化,后续用户端将接入自研的卡顿检测工具,会在此数据上进一步治理卡顿;
  • 目前优化的都是普通的卡顿,对于APP卡死还未进行专项治理。后续会接入自研的工具,重点进行卡死现象的采集和治理;
  • 在DEBUG模式下,开启卡顿弹框提醒;检测到卡顿情况后弹出弹框,在开发和测试阶段可尽早的发现和治理卡顿;

结语

iOS的卡顿优化是一个复杂且艰巨的任务,它涉及到代码的重构、逻辑的重写、底层组件的改动,在优化的同时,还必须要保障业务逻辑的正常和稳定。因此,合理地分期进行,优先解决卡顿上报量大的问题,再去解决上报量小的问题,抓大放小,持续治理,APP的用户体验一定会有潜移默化的提升。

参考

13 | 如何利用 RunLoop 原理去监控卡顿?-极客时间

2020移动应用性能管理白皮书 | 基调听云