页面卡顿优化总结

李军 2017-11-06 16:08:42

本文主要记录跳转可滑动项目详情页卡顿情况思考与解决过程,包括如下方面:

  1. 存在什么问题,优化之后预期达到什么样的目标
  2. 什么原因导致的这个问题
  3. 试错方案
  4. 最终解决方案
  5. 最终结果
  6. 总结
  7. 拓展

一、存在什么问题,优化之后预期达到什么样的目标

在项目列表页跳转项目详情页存在很大的卡顿问题,抓取堆栈信息,大概卡顿时间如图:

图片描述

有大概990ms的卡顿时间。 我们希望在这次优化之后,达到跳转不可滑动详情页的效果,跳转预期时间如图:

图片描述

二、什么原因导致的上述问题

首先我们要明白跳转时间是由三部分:跳转动画、搭建底部ViewController、搭建业务UI。前两者是由系统控制,对其优化难度大、限度很低(主要依赖于机器的性能)。

搭建业务UI产生的卡顿主要由下面三种原因:

  1. a页面跳转b页面,b页面需要加载大量数据,导致的页面跳转卡顿
  2. b页面需要加载大量的UI元素,导致的页面跳转卡顿
  3. a页面或者b页面的GPU使用率过高,导致页面跳转过程中过场动画不流畅、缓慢等

所以我们确定优化方案也要针对上述三种可能性

三、试错方案

1.将页面底层的ScrollView替换为UIPageViewController。

考虑到卡顿问题是出现在将不可滑动项目详情页变为可左右滑动的之后,是否是因为自己手动添加一个ScrollView手动管理滑动页面导致的卡顿,就想尝试换用UIPageViewController让系统自己进行页面管理。 经过我的尝试我发现其存在以下缺点:

  1. 优化很有限
  2. 需要改动大量原代码,会产生很多不确定性
  3. 该控件本身存在一点小缺陷(在文章的最下面将拓展讲一下其存在什么问题)

跳转时间优化如图:

图片描述

2.将页面初始化中对数据源的处理转移到上一级页面中

项目详情页中数据源是由上一级页面传递进来并按照需求进行处理,该操作是放在主线程中进行,会卡顿主线程。所以我猜测此处操作是否卡顿原因之一。 经过实际修改对比,发现这并不是主要原因,在原工程中采用了Swift中.filter.map高级函数效率极高。

3.分离跳转任务

在这之前先解释一下RunLoop机制,Application的主线程为了保持存活状态,启动了运行循环(RunLoop),RunLoop是一个事件处理循环,使用RunLoop的目的是让你的线程在有工作的时候忙于工作,而没工作的时候处于休眠状态。下图为RunLoop调度的顺序:

图片描述

页面的跳转动画、生成UI、渲染都可以理解为一个个事件,并且按照常理来说他们都是放在一个Runloop里进行的,假如任务过多自然会有很严重的卡顿现象,所以我们是否可以将任务分离。经发现跳转任务中生成我们所需要的UI最易被我们所操作,所以可以从这一点入手。

经实际操作发现,页面跳转时的过场动画,跟viewDidLoad是在同一次RunLoop中,所以viewDidLoad的执行时间就显得很关键。除了viewDidLoad以外,在UIViewController的生命周期里还有另外几个方法,通过log打印可以发现他的调度顺序:

viewDidLoad—>viewWillAppear—>viewWillLayoutSubviews—>viewDidLayoutSubviews—>viewWillLayoutSubviews—>viewDidLayoutSubviews

从打印信息中得知,viewWillAppearviewWillLayoutSubviewsviewDidLayoutSubviews是紧跟viewDidLoad之后执行的,所以这几个方法的执行时间同样很重要,但我们发现viewDidAppear方法并没有被调度,即viewDidAppear跟前面几个方法并在不同一次RunLoop中,既然如此,我们可以使用viewDidAppear来解决页面跳转延迟的情况。那viewDidAppear什么时候被调用呢,从主线程的执行堆栈可得知,viewDidAppear是在过场动画结束后被调用的。

原理图:

图片描述

所以我将页面UI的创建移到viewDidAppear中,经过我的实际测试发现由于一次性创建4个ViewController,虽然会优化跳转,但是跳转过来会等待很长时间进行页面初始化(俗称留白),造成的用户体验十分不好,瑕疵十分大。

以上三种方案都因存在种种问题被放弃。

四、最终解决方案:对预加载的ViewController进行处理

本方案是在试错方案三分离跳转任务方案的基础上进一步的优化。上一步方案经过我的实践发现,虽然会优化跳转,但是跳转过来会等待很长时间进行页面初始化(俗称留白)造成的用户体验也不是很好,所以在此基础上我进行思考,思考如何在不影响跳转的基础上快速展示页面。

经过阅读代码,发现页面初始化时会在buildPreViewController()方法中进行三个ViewController的预加载。那我们是否可以在进入页面第一次渲染界面的RunLoop里只渲染一个界面,在下一次系统方法的RunLoop里渲染剩下三个。 基于以上的思想,我进行了UI任务分离的优化,基本思路如下图:

图片描述

优化的具体实现:

老代码构建逻辑是:首先构建一个被选中的项目详情展示在页面中并将,并紧接着缓存三个接下来要展示的ViewController。在原始代码中这两大块的业务是放在一次RunLoop里进行的会严重阻塞主线程进而影响跳转时间。

我们优化方向是将这两大块的业务分开,但是又要兼顾里面存在其他的页面逻辑,原则是在不改动太多代码牵扯太多的逻辑情况下,又能实现两大模块的分离。

所以我们对preOffsetCount(预加载数)这个变量初始值设定为0,然后在第一次RunLoop结束(也就是初始界面渲染完成),系统走到viewDidAppear()(开始进行第二次的系统RunLoop)的时候,将preOffsetCount(预加载数)设定为三个,按照之前的逻辑会重新走一遍showSelectedViewControllerAndPreloadNext(预加载方法),这样只需要改变一个变量的值,不涉及到修改别的代码,就可以完成业务需求。

ViewController生命周期方法,大致流程如下:

图片描述

总结一句话:优先对展示页的UI构建和渲染,将未展示的页面的UI构建工作延后。

五、最终结果

我们最终用Time Profiler检测,优化后的跳转时间如下:

图片描述

达成初定目标。

六、总结

经过这次优化我感觉以后写代码,重点可以多考虑三方面:

  1. 熟悉系统的生命周期以及内存管理流程,写业务要利用好系统的生命周期
  2. 厚重的业务逻辑不可写在一起,尽量按照优先级分层次进行,不让主线程阻塞
  3. 对于自己写的模块尽量预留可扩展的地方

七、拓展

UIPageViewController的缺点:

切换childViewController引起的卡顿问题很严重。

一般情况下,page view controller切换页面的资源消耗至少相当于调用一次transitionFromViewController:toViewController:duration:options:animations:completion:。在配置较低的iPhone5s等手机上就会有明显卡顿,如果child view controller的生命周期方法中再做一些消耗资源的操作,App甚至会因为切换导致资源占用过多、内存警告,最终引起Crash。这种情况给低配手机性能优化带来了很大障碍。

其性能问题主要体现在,切换childController时候的CPU占用升高、以及切换时的内存频繁波动。

静止状态下,其CPU占用率位置在很低的水品甚至不到1%,当child controller切换时,其CPU占用率如图所示(iPhone6s /iOS10):

图片描述

这一点主要是因为UIPageViewController的设计更多的考虑了少占用内存,从下图中内存的波动曲线也可以看到。当频繁切换child controller时,UIPageViewController尽可能快的清理内存。快速切换时,其内存波动如下图所示:

图片描述