页面间跳转的性能优化(一)

作者: Delpan | 来源:发表于2016-03-21 10:54 被阅读16062次

    前言

          现在App的页面越来越复杂,页面初始化的工作越来越多,加载页面所需的时间也随之增长,如果页面加载的时间过长,这将会影响App的流畅度及用户体验,我们需要解决这一问题。观察过一些日常使用的App,页面间跳转的性能问题总结为以下三种情形:

          1).A页面跳转到B页面,由于B页面需要加载大量的数据,所以导致页面跳转延迟。

          2).A页面跳转到B页面,由于B页面需要加载大量UI元素,所以导致页面跳转延迟。

          3).A页面跳转到B页面,由于A或B页面的GPU使用率过高,所以导致面页跳转时出现过场动画不流畅,缓慢等。

          情形一比较容易解决,利用辅助线程加数据即可;由于图层树的更新(即UI页面的更新)需要在主线程上完成,所以情形二的性能优化让很多开发人员头痛;虽然网上有很多视图性能优化的技术文,但据了解,其实大部份团队都不会去做视图的性能优化,情形三也是最普遍存在。本文将会讲述这三种情形的性能优化,但并不会讲述页面间跳转的过渡动画,及页面间跳转的原理,这部份在网上已经有大量技术文讲述。关于情形三所涉及的像素混合,像素对齐,离屏渲染等知识点将不进行讲述,本文会讲述一种偷懒的方式来优化情形三。

          点击下载Demo,或https://github.com/IOSDelpan/SmoothTransitionDemo。

    目录

    基础知识

     -渲染服务进程

     -UIView与CALayer

     -图层树,呈现树,渲染树

     -UI更新过程

     -RunLoop更新UI的工作

    情形一

    情形二

    续言

    情形三

    总结

    下期预告


    基础知识

          想在屏幕上显示一个视图,我们只需要简单地实现以下代码,并运行Application到模拟器或真机即可。

    -渲染服务进程

          虽然看到的效果跟Application的代码是一一对应的,但视图绘制渲染的工作并不是由Application完成的,而是由一个名为渲染服务的进程(BackBoard)来完成的,这个进程的工作便是你在屏幕上看到的一切内容。既然做实际绘制渲染工作的是渲染服务进程,那么渲染服务进程要进行绘制渲染的依据是什么呢?而Application跟渲染服务进程又是怎么交互的呢?

    -UIView与CALayer

          为了方便往后的讲述,首先简单讲述一下UIView与CALayer的关系(不讲述两者的区别)。简单来说,UIView就是CALayer的管理器,CALayer的主要工作是为屏幕的绘制渲染提供所需的数据源,也就是说,你在屏幕上看到的内容,都是来源于CALayer。每一个UIView都有一个Backing Layer,UIView的UI属性跟CALayer的属性是一一对应的,设置UIView的UI属性实际上是设置CALayer对应的属性,即UIView的绘制渲染工作是由CALayer完成。UIView对象之间存在着一定的层级关系,那么所以UIView的Backing Layer也相应的存在着一定的层级关系,这个层级关系叫做图层树(模型树)。接下来的知识点直接用图层来讲述。

    -图层树,呈现树,渲染树

          使用Core Animation的Application(iOS默认使用),除了图层树,还有呈现树和渲染树,每个图层对象集合都扮演着不同的角色。图层树中的图层对象负责存储在屏幕上显示的目标值,呈现树中的图层对象负责存储在屏幕上显示的瞬时值,而渲染树的图层对象是渲染服务进程用来绘制渲染所使用的。Application使用到的是图层树与呈现树,上图中的代码,使用的则是图层树中的图层对象。既然渲染服务进程使用的是渲染树,那么图层树中的图层对象所存储的目标值又是如何显示在屏幕上呢?

    -UI更新过程

          在Application的主线程中设置图层树中的图层对象时,被设置的图层对象会被标记为待处理状态(在辅助线程设置图层对象,图层对象不会被标记),当Application的主线程即将处理非端口输入源或即将进入休眠时,Core Animation会打包图层树中待处理的图层对象,并通过IPC发送到渲染服务进程,IPC是通过端口交互的,消息在两个端口间传递,而渲染服务进程的端口是不公开的,当打包的图层发送到渲染服务进程时,这些图层会被反序列化成渲染树,渲染服务进程便可以开始绘制渲染的工作。

    -RunLoop更新UI的工作

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

          从RunLoop调度的顺序得知,当没有未处理事件时,线程就会进入休眠状态。在RunLoop中注册了一个观察者,这个观察者用于监听线程即将处理非端口输入源或即将进入休眠的状态,当线程即将处理非端口输入源或即将进入休眠时,观察者会执行监听回调_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv(),这个函数实现了Core Animation打包图层树中待处理的图层对象,并通过IPC发送到渲染服务进程的工作。

    情形一

          绝大多数的App页面都是用来展示各式各样的数据,如果跳转页面的同时,在主线程加载大量的数据,便会出现以下情况。

          如Gif图所示,屏幕卡顿了一会才出现页面跳转的过场动画,即出现了页面跳转延迟的情况。从基础知识UI更新过程RunLoop更新UI的工作中得知,Application的UI更新在于主线程RunLoop观察者的回调函数_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv(),只要该函数执行完,我们就可以在屏幕上看到UI更新的结果。既然知道这是由于在主线程加载大量数据所致,那么我们来解决这一情形,首先需要知道是那个函数占用了CPU,使用Instruments的Time Profiler测试一下。

          从测试的结果可以看到,是setUpData这个方法占用了主线程,而setUpData方法是在viewDidLoad里被调用的,那么viewDidLoad又是在何时被调用的呢?

          从主线程活动的状态以及执行堆栈可以看出,viewDidLoad是在_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv()里被调用的,大致过程如下图。

          知道了问题函数和主线程的执行堆栈,那么解决这一问题就变得很简单。只需要把加载数据的setUpData方法放到辅助线程中执行并返回结果到主线程显示即可。

          当我们使用多线程去加载数据时,由于主线程没有被阻塞,所以没有出现页面跳转延迟的情况,具体代码请看Demo

    情形二

          在页面跳转时,除了加载数据,还需要加载UI元素,而加载UI元素的工作一般会在viewDidLoad中完成,如果需要加载的UI元素过多,同样会出现页面跳转延迟的情况。

          如Gif图所示,出现了页面跳转延迟的情况,这是由于在viewDidLoad中生成大量的UI元素所致。在情形一中,我们用辅助线程加载数据解决了页面跳转延迟的情况,那么我们可以以同样的方式来加载UI元素。

          虽然我们可以把生成UI元素的工作放到辅助线程中完成,且看到的效果相同,但这种处理方式的效率非常低,这种方式生成大量UI元素所需要的时间比直接在主线程中生成要多数倍,增加加载页面所需要的时间,这显然不是我们想要的结果,我们想要的是既可以在主线程生成UI,又可以不出现页面跳转延迟的情况。

          我们知道当Application的主线程即将处理非端口输入源或即将进入休眠时,Core Animation会打包图层树中待处理的图层对象,除了打包图层对象,Core Animation还会打包基础动画对象,一并发送到渲染服务进程,渲染服务进程接收到图层对象和动画对象后,会根据动画对象来不断计算和绘制图层对象,形成屏幕上看到的动画效果,所以动画对象能否及时发送到渲染服务进程就显得非常重要,这关系到你App的用户体验。页面跳转时的过场动画的打包工作,跟viewDidLoad是在同一次RunLoop中,所以viewDidLoad的执行时间就显得很关键。除了viewDidLoad以外,在UIViewController的生命周期里还有另外几个方法,我们来看一下这几个方法的被调度的情况。

          从打印信息中得知,viewWillAppear,viewWillLayoutSubviews,viewDidLayoutSubviews是紧跟viewDidLoad之后执行的,所以这几个方法的执行时间同样很重要,但我们发现viewDidAppear方法并没有被调度,即viewDidAppear跟前面几个方法并在不同一次RunLoop中,既然如此,我们可以便使用viewDidAppear来解决页面跳转延迟的情况。

          Gif图显示的效果和根据基础知识猜想的结果一样,解决了页面跳转延迟的情况,那么viewDidAppear何时被调用?

          从主线程的执行堆栈可得知,viewDidAppear是在过场动画结束后被调用的,而过场动画的持续时间是0.35秒。

          我们来算一下整个过程所需要的时间,假设生成页面需要0.5秒,那么优化前后所需要的时间都是0.85秒(经测试,其实时间有减少,只是少到可以忽略,时间减少的部份应该是GPU计算量的问题),虽然问题解决了,但效果并不理想,因为完成整个过程所需要的时间并没有减少,所以我们需要进一步优化。尝试过很多种方式,但似乎没有什么方式可以很好地减少生成UI元素所需要的时间,那么我们只能把优化的方向放在过场动画的持续时间上了。

          从Gif图显示的效果可以看到,完成整个过程所需要的时间明显减少了,实现原理请看下图。

          如图所示,把生成UI元素的任务从本次RunLoop中抽出,提交到下一次的RunLoop当中,因为本次RunLoop没有被阻塞,所以能及时把图层对象和动画对象发送到渲染服务进程,渲染服务进程便开始进行过场动画的绘制与渲染,与此同时,Application的主线程RunLoop进入下一次Loop,开始执行生成UI元素的任务,即,可以理解为渲染服务进程绘制渲染过场动画,和Application生成UI元素的任务同时进行,这样我们便把动画的时间也利用上,从而大大减小了整个过程所需的时间。

          在Demo中,是使用GCD的方式来实现,也可以使用performSelector: withObject: afterDelay:方法来实现同样的效果,但不建议,因为这样会增加主线程RunLoop的执行时间。

          我们还可以把这个耗时的任务分解成若干个小的任务来实现。

          如Gif图所示,没有出现页面跳转延迟的情况。使用定器时把任务分解,可以得到同样的结果,若是加上一些动画,效果会更棒。在Demo中,用到的定时器是CADisplayLink,用NSTimer可以达得到样的效果,关于CADisplayLink,建议能不用就不用,因为它会使目标线程长期处于活跃状态。

          情形三将会在页面间跳转的性能优化(二)中讲述。如果文中有讲错的地方,还望指出。

          Tips:虽然黑科技很强大,但也很危险,在你没有足够了解它的情况下,不能轻易去使用,更不能滥用。本文的讲述旨在如何利用基础知识来解决日常开发中遇到的问题,并不是硬式化地讲解使用方式。

    相关文章

      网友评论

      • 低调的魅力:Xcode9对于把UIKit对象放在子线程里回报警告啊,:stuck_out_tongue_winking_eye::stuck_out_tongue_winking_eye::stuck_out_tongue_winking_eye:
      • litt1err:学习了, 很好奇大佬简书的打印信息为什么demo中没有
        Delpan:@litt1err 不好意思,我去那些信息去掉了
      • fdf10bc7d4c8:不错不错,收藏了。

        推荐下,分库分表中间件 Sharding-JDBC 源码解析 17 篇:http://t.cn/R0UfGFT


      • zziazm:大神,你好,为什么在辅助线程创建大量UI元素所需要的时间比直接在主线程中生成要多数倍呢?
        Delpan:@zziazm 创建线程需要时间,切换线程也需要时间,回调到主线程也需要时间啊,一般UI元素不会大到需要用辅助线程来创建的,只是卡那么一下而已,看情况用吧
      • shidongmiao:膜拜大神
      • 照亮黑夜的曙光:好详细 mark
      • 冰室主人:过场动画的持续时间 怎么看?谢谢
        Delpan:有默认值的,默认值是0.35,通过遍历成员变量查找
      • 牵线小丑:情形二的:生成UI元素的工作放到辅助线程中完成
        你上面放的代码是错的吧?
        并不需要 dispatch_async(dispatch_get_global_queue(0, 0)) 吧?直接把所有 label 相关的放到
        dispatch_async(dispatch_get_main_queue()) 才对吧?
        小凉介:你这个是直接放在主线程了,只是异步执行,并没有放在辅助线程
      • 爱写措别字儿:不错收藏了
      • 3ee9282e7265:你好,如果界面是通过storyboard写的呢,跳转卡顿的问题怎么解决好?谢谢。
        3ee9282e7265:@Delpan 好得。我试试。谢谢。
        Delpan:你可以重写loadview来实现
      • 梦即是幻:大赞,刚好遇到个动画问题,就看到这篇文章了,完美解决 :+1:
      • zhouwude:真心牛逼啊
      • 偌上: dispatch_async(dispatch_get_global_queue(0, 0), ^{
        UILabel *label = [[UILabel alloc] initWithFrame:CGRectMake(j * labelDistance, vertical, LabelSize, LabelSize)];
        label.backgroundColor = [UIColor grayColor];
        label.opaque = YES;
        label.clipsToBounds = YES;
        dispatch_async(dispatch_get_main_queue(), ^{
        label.attributedText = attString;
        [self.view addSubview:label];
        });
        });
        这个方法异步,确实速度变快了啊。没有楼主说的没有效果,更耗时。iOS9 6splus模拟器,耗时push20ms+显示91ms
        Delpan:@偌上 要考虑项目中应用和主线程端口事件的及时性。
        偌上:@Delpan 我看的就是for循环里面的时间。for循环的总时间也没有变多啊
        Delpan:@偌上 执行一次没什么,执行多次效率就很低。
      • 郑明明:不错哦
      • FDZero:好长,先收藏了。。。
      • YooSky:先收藏~
      • 刘恒通:这篇写的真不错,学习了。
      • nuclear:学习了!有两个问题不太明白想请教下:_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv()函数在调用栈里面没找到,是通过什么方式知道这个方法调用之后机会渲染UI呢?
        从主线程活动的状态以及执行堆栈可以看出,viewDidLoad是在_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv()里被调用的。这个是从哪里看出来的
        牵线小丑:@牵线小丑 刚找到了,原来是 CFRunLoopRef 源码里面的
        牵线小丑:我也是这个问题……不知道从哪来的
      • degulade:mark!
      • 不知什么人: :joy: 相当有收获
      • MatthewSp:有空研究下 做视频音频这一块总是遇到启动缓慢切换缓慢的问题
      • 5b137c188a01: "从主线程活动的状态以及执行堆栈可以看出,viewDidLoad是在_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv()里被调用的"
        怎么看出来时在这里面调用的,xcode 的函数调用栈里面没有这个
        akak:同问
      • 扑倒的柔情:学习了,感谢:pray:
      • 开创未来86:很好,学习了 :smile:
      • Zoros:谢谢作者的详解,看了一下你的demo,你的编程思路很不错!
        Delpan:@ZoroNie 谢谢
      • allsome:真心期待二中,像你这么低调务实的人真心不多
        Delpan:@allsome 谢谢
      • 3727db4452eb:这个地方回到主线程的目的是什么?看到这段话,虽然看到的效果跟Application的代码是一一对应的,但视图绘制渲染的工作并不是由Application完成的,而是由一个名为渲染服务的进程(BackBoard)来完成的,这个进程的工作便是你在屏幕上看到的一切内容。额,没有看懂,把生成UI元素的任务从本次RunLoop中抽出,提交到下一次的RunLoop当中,下一次的Runloop是BackBoard的Runloop么?还是说,BackBoard和application这两个进程本身就是在同一个进程,只是他们的mode、source、port不同而已,这个地方的回到线程的动作,强制让Runloop的mode切换到application的mode?
        Delpan:@Andrew_Beta 多线程的方式我测试过非常不好,速度很慢。
        https://developer.apple.com/videos/play/wwdc2014/419/ 这个是讲渲染那块的,可以下载视图中的那个文档看会更清楚一点
        3727db4452eb:@Delpan 抱歉,刚刚同事喊吃饭,没注意,我上面的说错了。本来是想说BackBoard和application这两个进程本身就是在同一个Runloop中来着,你回复的已经已经告诉我了。 仔细分析了下,算是理会到在Runloop下一次加载UI这情形的思想了,就是适当的将原本要在一次Runloop循环过程中,要渲染的UI任务分成几个小的任务,在多次Runloop循环中执行,从而达到缩短主线程Runloop一次循环的时间。相比多线程,这样做的好处是,避免了线程并发带来的大量资源消耗,线程间的切换也是挺耗时的。不知道理解的对不对。
        // 1、初始化渲染View上的UI,在application主线程Runloop的一次循环中,将这个丢给进程BackBoard去处理,处理之后的结果立即传给主进程的对应的port,然后主线程在一次Runloop结束之后就将UI显示在界面上。
        if (_type == SecondSituationNextLoopLoadType || _type == SecondSituationViewDidAppearLoadType){

        tipsLabel = [[UILabel alloc] initWithFrame:CGRectMake(0, 0, iPhoneWidth, (iPhoneHeight - 64))];
        tipsLabel.backgroundColor = self.view.backgroundColor;
        tipsLabel.opaque = YES;
        tipsLabel.layer.masksToBounds = YES;
        tipsLabel.textAlignment = NSTextAlignmentCenter;
        tipsLabel.textColor = [UIColor grayColor];
        tipsLabel.text = @"Waiting...";
        [self.view addSubview:tipsLabel];
        }
        // 2、加载数据,如果没有数据加载,Runloop就去休息了。
        [self willLoadLabels];
        }

        - (void)willLoadLabels
        {
        if (_type == SecondSituationDefaultLoadType)
        {
        // Observer没有接到 要处理的Runloop source,本次Runloop执行完之后就开始休眠
        [self loadAllLabels];
        }
        else if (_type == SecondSituationNextLoopLoadType){

        // 回到主线程中执行,这里其实是手动给Application的Runloop添加一个source,本次Runloop从步骤5直接跳过步骤9,同时渲染UI的操作丢给了BackBoard,本次Runloop跑完,不进入休眠,立即开始下一次跑圈,执行。
        dispatch_async(dispatch_get_main_queue(), ^{

        [self loadAllLabels];
        });
        }
        else if (_type == SecondSituationTimerLoadType)
        {
        timer = [CADisplayLink displayLinkWithTarget:self selector:@selector(timerAction:)];
        // 有timer,并且是添加到runloop中,因此,本次的Runloop也会直接跳到步骤9。
        [timer addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
        }
        }
        注释是我的理解,可能有偏差,希望能多多交流。
        WWDC2014还没看过,能告知渲染篇具体是哪几个么?谢谢了
        Delpan:@Andrew_Beta Application的主线程RunLoop在每次Loop的时候都会检测当前是否有需要更新的图层,有就发送到BackBoard。把一个大的UI任务分解成两个,一次Loop更新一个UI任务避免线程被阻塞,渲染任务发不出去。Application和BackBoard的关系,Application的打包工作,在WWDC2014的渲染篇有讲的。
      • 3727db4452eb:关于情形二中,在Runloop下一次的循环中去更新UI,代码没看明白。- (void)willLoadLabels
        {
        if (_type == SecondSituationDefaultLoadType)
        {
        [self loadAllLabels];
        }
        else if (_type == SecondSituationNextLoopLoadType)
        {
        //回到主线程中执行
        dispatch_async(dispatch_get_main_queue(), ^{

        [self loadAllLabels];
        });
        }
        else if (_type == SecondSituationTimerLoadType)
        {
        timer = [CADisplayLink displayLinkWithTarget:self selector:@selector(timerAction:)];
        [timer addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
        }
        }

        Delpan:@Andrew_Beta 额,我在第二篇再详细讲述情形二最后两个处理方式的原理吧
      • 4305824b6977:情形1. NS_INLINE NSString *getString(void);
        是内联吗? 为什么这样用. 点一下,我自己查资料, O(∩_∩)O谢谢
        e80cd3cc9b93:@Delpan 但是不是说现代编译器会自动做内连优化吗,而不是由你写这个决定的?
        Delpan:@剁椒鸡蛋 编译的时候会直接替代,快一点吧
      • 6fdb0c58ceca:关注一下, 最近我的 app 就出现了因为跳转性能不佳导致的 bug, 症状就是点击某处跳转到下一个页面, 代码执行了, 但是屏幕上没有渲染出来, 用 Xcode 查看能看到新页面, 但是手机看不到, 就造成了一个透明的层罩在当前 Controller 上, 就好像 app 卡死了, 点击任何地方都没有反应, 已经持续两个月了, 一直没有完全解决, 看完博主的文章有点眉目了, 还要继续优化
      • 惜壤:mark
      • 4ab609fed76a:学习,太全了
      • d19274664505:回去试试
      • c494b29f043a:正好有用
      • 雷鸣1010:冲着楼主这篇文章,看来是必须注册一个简书账号了
        Delpan:@雷鸣1010 😂谢谢
      • afce6947bd5d:嘎嘎嘎
      • afce6947bd5d:刚刚GVv
        一片森林:@冀文彬Bin 哈哈
      • afce6947bd5d:工作关系
      • HandSon:可不可以发表一篇关于渲染方面的,谢谢博主
        Delpan:@HandSon 看看情况吧
      • 我叫阿水:挺好的。。博主是主要负责优化的吗:smile:必须关注下!
        vernepung:@KiBen 哇,大神好!!!
        Delpan:@KiBen 都会一点😂
      • 健尐: :stuck_out_tongue_closed_eyes: 大神看了你的代码收获好多,就是有些细节你没有说出来.
        ①在SecondSituationViewController.m上的导航控制器上的右边加个item吧
        ②这个item点击后就触发方法打印NSLog(@"点击了!");
        这样写才知道情形二前三个都是线程阻塞
        Delpan:@健尐 额,好吧
      • 健尐: :smiley: 怎么视频转成gif告诉一下我好了,谢谢!
        Delpan:@健尐 我是用licecap的,很方便
      • fallrainy::smile:很好,期待下一篇~
      • Ryan文濤:满满的都是干货,赞。
      • qBryant:学习了。。。

      本文标题:页面间跳转的性能优化(一)

      本文链接:https://www.haomeiwen.com/subject/huytlttx.html