前言

卡顿优化一直是客户端功能办理的重要方向之一,在这之前,咱们先来解说下什么是卡顿。

卡顿,直白来说便是用户在运用APP的进程中能感受到界面一卡一卡的不流通。从原理来说,便是在用户能够感知的视觉场景中,当事情处理和UI展现的综合耗费时刻超越用户视觉体系的最大期待时刻时,就会呈现卡顿现象。卡顿会影响用户的操作,危害用户体验,进一步影响用户对APP的点评和留存。因而,操作流通度是决议APP体验好坏的关键因素之一。优化卡顿,将APP的用户体验做到极致,在必定程度上能够提升用户的忠诚度和APP的市场占有率。

行业规范

那么,APP的卡顿率在多少区间算是正常或许优异呢? 咱们能够参阅 《2020移动使用功能办理白皮书 | 基调听云》推荐的行业规范:

功能指标 优异值 及格值 极差值 行业参阅值
卡顿率(%) <=2 5 >=8 4

全体状况

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

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

卡顿原理和检测

为什么呈现卡顿?

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

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

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

在屏幕显示图像前,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 进程 :

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

  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的卡顿如下:

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

常见卡顿

在办理进程中,咱们将常见的卡顿原因做了聚合分类,而且针对不同的卡顿原因,总结了对应不同的处理计划,以下为针对性的办理计划:

  1. IO读写

    1. 在主线程做许多的数据读写操作

优化计划:敞开子线程,异步去读取和保存本地的数据,逻辑处理完了再回到主线程改写UI

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

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

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

// 子线程异步存储
- (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。例如:

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

优化后的代码:

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内部引起的,可联络第三方进行对应的问题反应,促进问题的优化;

疑难卡顿

上报的卡顿线程仓库信息中,或许存在信息不准确的状况,也或许存在许多信息缺乏的状况,依据上报的内容,无法准确定位卡顿的具体方位。例如:

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

针对这种疑难的卡顿上报,需求借助用户的日志,进一步定位。在上报卡顿时,也上报用户的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. 版别迭代办理

每个版别上线初期,调查卡顿的上报状况。关于新增的卡顿,计算并分配使命,在下一个版别中进行优化办理。最大程度的削减了卡顿的存在,避免指标的恶化。

  1. 监控平台

在自研的监控平台上,用户端针对页面进行了卡顿次数的上报计算,新版别上线后,可依据版别号调查数据的改变,及时发现新的卡顿问题。

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

后续规划

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

结语

iOS的卡顿优化是一个杂乱且艰巨的使命,它涉及到代码的重构、逻辑的重写、底层组件的改动,在优化的同时,还必须要保障业务逻辑的正常和稳定。因而,合理地分期进行,优先处理卡顿上报量大的问题,再去处理上报量小的问题,抓大放小,继续办理,APP的用户体验必定会有耳濡目染的提升。

参阅

13 | 怎么使用 RunLoop 原理去监控卡顿?-极客时刻

2020移动使用功能办理白皮书 | 基调听云