Android渲染机制以及优化(官方版)
Last updated
Last updated
Android系统每隔16ms发出VSYNC信号,触发对UI进行渲染,如果每次渲染都成功,这样就能够达到流畅的画面所需要的60fps,为了能够实现60fps,这意味着程序的大多数操作都必须在16ms内完成。意思就是,系统每隔16ms会发个信号告诉系统要开始更新了,然后它就开始执行重绘屏幕操作,它只有16ms的时间去绘制完成这个页面,又过了16ms,不管上次有没有绘制完成,系统都会再发一个VSYNC信号,又要开始新的更新界面操作了,如果上次没有在16ms内完成,那么用户只能看到上一帧的画面了,就会造成所说卡顿。
16ms就意味着你要在16ms内运行所有的绘制逻辑进行绘制,从而达到60帧每秒的地步,如果你没有在16ms内把所有的绘制逻辑给执行完,你花费了更多时间,可能是24ms,就会发生丢帧现象,其它它就失去了一次绘制的机会,在它24ms执行完后,它并不会继续刚才那个错失的绘制,而是它会等到系统下一次发消息才会开始新的绘制,所以会出现掉帧,你会在32ms内看到的是一个画面,这就很严重了,一旦你超过16ms没有完成,那么它就会一直等到下一次接收消息才会刷新,也就是说,你是在32ms内看到的是一幅画面,而不是24ms。
如果你的某个操作花费时间是24ms,系统在得到VSYNC信号的时候就无法进行正常渲染,这样就发生了丢帧现象。那么用户在32ms内看到的会是同一帧画面。
用户容易在UI执行动画或者滑动ListView的时候感知到卡顿不流畅,是因为这里的操作相对复杂,容易发生丢帧的现象,从而感觉卡顿。有很多原因可以导致丢帧,也许是因为你的layout太过复杂,无法在16ms内完成渲染,有可能是因为你的UI上有层叠太多的绘制单元,还有可能是因为动画执行的次数过多。这些都会导致CPU或者GPU负载过重。
花费很多时间绘制视图层级的某些部分,这会浪费CPU周期
绘制太多对象,重复叠加,这也浪费了宝贵的时间进行像素着色
一次又一次的运行大量的动画,导致GPU和CPU大量的骚动
我们可以通过一些工具来定位问题,比如可以使用HierarchyViewer来查找Activity中的布局是否过于复杂,也可以使用手机设置里面的开发者选项,打开Show GPU Overdraw等选项进行观察。你还可以使用TraceView来观察CPU的执行情况,更加快捷的找到性能瓶颈。
软解时代:在Android2.3时代,所有的绘图由CPU完成,即通过软件运算画图。
硬解时代:在Android2.3之后,Android系统增加了GPU,同时把很多绘图工作交给CPU进行渲染。
黄油时代:在Android4.1之后,Google发布了“Project Butter”---黄油计划。通过VSYNC垂直同步机制和多缓冲机制(three frame buffer)进一步提高绘制效率。
异步绘制时代:在Android5.0之后,系统增加了Render Thread。通过这个线程进行异步绘制,即使某一帧发生延迟了不影响下一帧的绘制。
Overdraw(过度绘制)描述的是在一帧内某个像素在屏幕上重新绘制了多少次。
在多层次的UI结构里面,如果不可见的UI也在做绘制的操作,这就会导致某些像素区域被绘制了多次。这就浪费大量的CPU以及GPU资源。
当设计上追求更华丽的视觉效果的时候,我们就容易陷入采用越来越多的层叠组件来实现这种视觉效果的怪圈。这很容易导致大量的性能问题,为了获得最佳的性能,我们必须尽量减少Overdraw的情况发生。
蓝色,淡绿,淡红,深红代表了4种不同程度的Overdraw情况,我们的目标就是尽量减少红色Overdraw,看到更多的蓝色区域。
Overdraw有时候是因为你的UI布局存在大量重叠的部分,还有的时候是因为非必须的重叠背景。例如某个Activity有一个背景,然后里面的Layout又有自己的背景,同时子View又分别有自己的背景。仅仅是通过移除非必须的背景图片,这就能够减少大量的红色Overdraw区域,增加蓝色区域的占比。这一措施能够显著提升程序性能。
移除各个控件各自的背景这是最通常的做法,当然还有AppTheme里默认的背景也要移除,这样至少能减少一次绘制。
为了理解App是如何进行渲染的,我们必须了解手机硬件是如何工作,那么就必须理解什么是VSYNC。
在讲解VSYNC之前,我们需要了解两个相关的概念:
Refresh Rate:代表了屏幕在一秒内刷新屏幕的次数,这取决于硬件的固定参数,例如60Hz。
Frame Rate:代表了GPU在一秒内绘制操作的帧数,例如30fps,60fps。
GPU会获取图形数据进行渲染,然后硬件负责把渲染后的内容呈现到屏幕上,他们两者不停的进行协作。
理解图像渲染里面的双重与三重缓存机制,这个概念比较复杂,请移步查看这里:http://source.android.com/devices/graphics/index.html,还有这里http://article.yeeyan.org/view/37503/304664。
PC游戏玩家大概对垂直同步这个词很熟悉了。包含这个词的钩选框可以帮助你的游戏避免画面撕裂的问题。
为了理解垂直同步到底是个什么东西,我们需要对显示系统做一个新手教学:视象(比如说手机屏幕显示的东西)是由一系列叫做“帧”的独立图片所组成的。平滑的动画一般需要每秒六十帧。而帧是由像素点构成的。当屏幕绘制一帧的时候,像素点是一行一行的进行填充的。明白?很好。
显示屏(LCD,AMOLED或者别的什么)从图形芯片获取每帧的数据,然后一行一行的进行绘制。理想状况下,你期望显示屏在绘制完一帧之后,图形芯片整好能提供新帧的数据。图像撕裂的状况就发生在图形芯片在图像绘制到一半的时候,就载入了新一帧的数据,以致你最终得到的数据帧是半个帧的新数据和半个帧的老数据。
而垂直同步,顾名思义,就是用来同步的。它告知GPU在载入新帧之前,要等待屏幕绘制完成前一帧。
Android一直使用垂直同步来避免屏幕撕裂。而Jelly Bean更进一步。垂直同步脉冲现在会贯穿于所有的图形运作过程。
大多数Android显示系统是以每秒钟60帧的频率工作的(专业点说,叫60Hz)。为获得更平滑的动画,就必须具有每秒钟处理60帧的能力——意味着每帧只能花费16毫秒的时间。如果这个过程超过16毫秒,动画显示就会有停滞感,我们期待的如丝般顺滑的体验也就消失无踪了。
16毫秒不是很长的一段时间,所以你会希望充分利用它。在ICS中,系统对下一帧的处理有点懒加载的意思,需要的时候才进行处理。而在Jelly Bean中,整个进程将在上一帧刚结束时,在垂直同步脉冲开始时,就立刻开始下一帧的处理过程。换句话说,系统会尽量充分的利用那16毫秒。
除了垂直同步外,Android还使用了其它方式来使动效更顺畅。
那么,到底什么是“缓冲”呢?简单的说,缓冲就是帧构建和保存的容器。我们在前面用序号指代帧,但实际上,这些帧其实是在两个缓冲中的。遵循惯例,Anroid采用双缓冲机制,意味着它可以在显示一帧的同时进行另一帧的处理。在图中,就是缓冲A和B。当显示缓冲A时,系统在缓冲B中构建新的帧。完成后,则交换缓冲。显示缓冲B,而A则被清空,继续下一帧的绘制。
性能问题如此的麻烦,幸好我们可以有工具来进行调试。打开手机里面的开发者选项,选择Profile GPU Rendering,选中On screen as bars的选项。
选择了这样以后,我们可以在手机画面上看到丰富的GPU绘制图形信息,分别关于StatusBar,NavBar,激活的程序Activity区域的GPU Rending信息。
随着界面的刷新,界面上会滚动显示垂直的柱状图来表示每帧画面所需要渲染的时间,柱状图越高表示花费的渲染时间越长。
中间有一根绿色的横线,代表16ms,我们需要确保每一帧花费的总时间都低于这条横线,这样才能够避免出现卡顿的问题。
每一条柱状线都包含三部分,蓝色代表测量绘制Display List的时间,红色代表OpenGL渲染Display List所需要的时间,黄色代表CPU等待GPU处理的时间。
我们通常都会提到60fps与16ms,可是知道为何会是以程序是否达到60fps来作为App性能的衡量标准吗?这是因为人眼与大脑之间的协作无法感知超过60fps的画面更新。
12fps大概类似手动快速翻动书籍的帧率,这明显是可以感知到不够顺滑的。24fps使得人眼感知的是连续线性的运动,这其实是归功于运动模糊的效果。24fps是电影胶圈通常使用的帧率,因为这个帧率已经足够支撑大部分电影画面需要表达的内容,同时能够最大的减少费用支出。但是低于30fps是无法顺畅表现绚丽的画面内容的,此时就需要用到60fps来达到想要的效果,当然超过60fps是没有必要的。
开发app的性能目标就是保持60fps,这意味着每一帧你只有16ms=1000/60的时间来处理所有的任务。
了解Android是如何利用GPU进行画面渲染有助于我们更好的理解性能问题。那么一个最实际的问题是:各种控件的画面是如何绘制到屏幕上的?那些复杂的XML布局文件又是如何能够被识别并绘制出来的?
Resterization栅格化是绘制那些Button,Shape,Path,String,Bitmap等组件最基础的操作。栅格化是把那些组件转换成屏幕中的像素点。这是一个很费时的操作,GPU的引入就是为了加快栅格化的操作。
GPU本身被设计成使用一组特定的图元,主要的多边形、纹理和图片。在GPU将任何东西绘制到屏幕之前,你的CPU负责将这些图元传输到GPU,完成这项任务是使用了Android上常见的API,就是被我们熟知的OpenGL ES,也就是说你的任何UI对象,比如按钮,路径,复选框需要被绘制到屏幕上,它们首先要在CPU上转换成多边形和纹理,然后再传递给GPU进行光栅化处理。CPU处理控件转换成多边形和CPU向GPU上传这些转换好的数据并不是一个很快的过程,这样你就会想要减少转化对象的数量和向GPU提交的数量,幸运的是,GPU支持让你从CPU提交到GPU里的对象保存在GPU里,当你以后需要再次绘制一个按钮的时侯,你只需要参考GPU内存中已经存在的网格并告诉OPENGL如何绘制它。
优化渲染功能的一般规则是这样:尽可能多且快的将更多数据上传到GPU,然后留在GPU中尽可能的不去修改,每次你更新GPU中的资源你都会失去宝贵的处理时间,这也是Android系统遵循的一项原则,让渲染高性能的执行。
举个例子,例如你的主题中提供了一些资源,例如位图和一些可绘制的东西,被组合在一起形成一个纹理并上传到GPU中,这就意味着当你想要绘制一些图片时,你不需要让CPU经过任何转换,所有的内容都驻留在GPU中,这些类型的视图会快速显示出来。
比如显示图片,CPU要先加载图片到内存中,转换之后交给GPU进行绘制,使用路径创建一个单独的集群,因为我们可能需要在CPU中实际创建一个多边形的链,甚至在GPU上创建一个类似遮罩的纹理。
绘制文本简直就是灾难,在CPU上我们不得不栅格化文字到每一个纹理,然后上传到GPU,然后再对照着GPU的数据将字符串中的字符一个一个的绘制。
动画则是一个更加复杂的操作流程。
这还不包括其它的GPU的性能问题,例如不需要重新绘制视图中的每一帧,Android通过只绘制更新过的部分区域以节约性能。
为了能够使得App流畅,我们需要在每一帧16ms以内处理完所有的CPU与GPU计算,绘制,渲染等等操作。
通常来说,Android需要把XML布局文件转换成GPU能够识别并绘制的对象。这个操作是在DisplayList的帮助下完成的。DisplayList持有所有将要交给GPU绘制到屏幕上的数据信息。
在某个View第一次需要被渲染时,DisplayList会因此而被创建,当这个View要显示到屏幕上时,我们会执行GPU的绘制指令来进行渲染。如果你在后续有执行类似移动这个View的位置等操作而需要再次渲染这个View时,我们就仅仅需要额外操作一次渲染指令就够了。然而如果你修改了View中的某些可见组件,那么之前的DisplayList就无法继续使用了,我们需要回头重新创建一个DisplayList并且重新执行渲染指令并更新到屏幕上。
需要注意的是:任何时候View中的绘制内容发生变化时,都会重新执行创建DisplayList,渲染DisplayList,更新到屏幕上等一系列操作。这个流程的表现性能取决于你的View的复杂程度,View的状态变化以及渲染管道的执行性能。举个例子,假设某个Button的大小需要增大到目前的两倍,在增大Button大小之前,需要通过父View重新计算并摆放其他子View的位置。修改View的大小会触发整个HierarcyView的重新计算大小的操作。如果是修改View的位置则会触发HierarchView重新计算其他View的位置。如果布局很复杂,这就会很容易导致严重的性能问题。我们需要尽量减少Overdraw。
布局的变化会引起整个布局树进行Measure,然后重新定位新的位置,任何View的改变都会使DisplayList重新创建。这几个过程都是会消耗时间,消耗时间的多少取决于布局是否扁平化和是否有很多无效视图。
我们可以通过前面介绍的Monitor GPU Rendering来查看渲染的表现性能如何,另外也可以通过开发者选项里面的Show GPU view updates来查看视图更新的操作,最后我们还可以通过HierarchyViewer这个工具来查看布局,使得布局尽量扁平化,移除非必需的UI组件,这些操作能够减少Measure,Layout的计算时间。
引起性能问题的一个很重要的方面是因为过多复杂的绘制操作。我们可以通过工具来检测并修复标准UI组件的Overdraw问题,但是针对高度自定义的UI组件则显得有些力不从心。
有一个窍门是我们可以通过执行几个APIs方法来显著提升绘制操作的性能。前面有提到过,非可见的UI组件进行绘制更新会导致Overdraw。例如Nav Drawer从前置可见的Activity滑出之后,如果还继续绘制那些在Nav Drawer里面不可见的UI组件,这就导致了Overdraw。为了解决这个问题,Android系统会通过避免绘制那些完全不可见的组件来尽量减少Overdraw。那些Nav Drawer里面不可见的View就不会被执行浪费资源。
除了clipRect方法之外,我们还可以使用canvas.quickreject()来判断是否没和某个矩形相交,从而跳过那些非矩形区域内的绘制操作。做了那些优化之后,我们可以通过上面介绍的Show GPU Overdraw来查看效果。
虽然Android有自动管理内存的机制,但是对内存的不恰当使用仍然容易引起严重的性能问题。在同一帧里面创建过多的对象是件需要特别引起注意的事情。
除了速度差异之外,执行GC操作的时候,所有线程的任何操作都会需要暂停,等待GC操作完成之后,其他操作才能够继续运行。---- 这句话是不对,现在GC分为并行和串行,局部和全部,只有很少的GC才会引发全局的暂停,但是即使是并行的它也是会造成一定程序的资源消耗。
通常来说,单个的GC并不会占用太多时间,但是大量不停的GC操作则会显著占用帧间隔时间(16ms)。如果在帧间隔时间里面做了过多的GC操作,那么自然其他类似计算,渲染等操作的可用时间就变得少了。
导致GC频繁执行有两个原因:
Memory Churn内存抖动,内存抖动是因为大量的对象被创建又在短时间内马上被释放。
瞬间产生大量的对象会严重占用Young Generation的内存区域,当达到阀值,剩余空间不够的时候,也会 触发GC。即使每次分配的对象占用了很少的内存,但是他们叠加在一起会增加Heap的压力,从而触发更多其 他类型的GC。这个操作有可能会影响到帧率,并使得用户感知到性能问题。
同时我们还可以通过Allocation Tracker来查看在短时间内,同一个栈中不断进出的相同对象。这是内存抖动的典型信号之一。
当你大致定位问题之后,接下去的问题修复也就显得相对直接简单了。例如,你需要避免在for循环里面分配对象占用内存,需要尝试把对象的创建移到循环体之外,自定义View中的onDraw方法也需要引起注意,每次屏幕发生绘制以及动画执行过程中,onDraw方法都会被调用到,避免在onDraw方法里面执行复杂的操作,避免创建对象。对于那些无法避免需要创建对象的情况,我们可以考虑对象池模型,通过对象池来解决频繁创建与销毁的问题,但是这里需要注意结束使用之后,需要手动释放对象池中的对象。
JVM的回收机制给开发人员带来很大的好处,不用时刻处理对象的分配与回收,可以更加专注于更加高级的代码实现。相比起Java,C与C++等语言具备更高的执行效率,他们需要开发人员自己关注对象的分配与回收,但是在一个庞大的系统当中,还是免不了经常发生部分对象忘记回收的情况,这就是内存泄漏。
每一个级别的内存区域都有固定的大小,此后不断有新的对象被分配到此区域,当这些对象总的大小快达到这一级别内存区域的阀值时,会触发GC的操作,以便腾出空间来存放其他新的对象。
前面提到过每次GC发生的时候,所有的线程都是暂停状态的(现在Andorid的ART虚拟机早就不是这种模式了)。GC所占用的时间和它是哪一个Generation也有关系,Young Generation的每次GC操作时间是最短的,Old Generation其次,Permanent Generation最长。执行时间的长短也和当前Generation中的对象数量有关,遍历查找20000个对象比起遍历50个对象自然是要慢很多的。
虽然Google的工程师在尽量缩短每次GC所花费的时间,但是特别注意GC引起的性能问题还是很有必要。如果不小心在最小的for循环单元里面执行了创建对象的操作,这将很容易引起GC并导致性能问题。通过Memory Monitor我们可以查看到内存的占用情况,每一次瞬间的内存降低都是因为此时发生了GC操作,如果在短时间内发生大量的内存上涨与降低的事件,这说明很有可能这里有性能问题。我们还可以通过Heap and Allocation Tracker工具来查看此时内存中分配的到底有哪些对象。
虽然Java有自动回收的机制,可是这不意味着Java中不存在内存泄漏的问题,而内存泄漏会很容易导致严重的性能问题。
内存泄漏指的是那些程序不再使用的对象无法被GC识别,这样就导致这个对象一直留在内存当中,占用了宝贵的内存空间。显然,这还使得每级Generation的内存区域可用空间变小,GC就会更容易被触发,从而引起性能问题。
寻找内存泄漏并修复这个漏洞是件很棘手的事情,你需要对执行的代码很熟悉,清楚的知道在特定环境下是如何运行的,然后仔细排查。例如, 你想知道程序中的某个activity退出的时候,它之前所占用的内存是否有完整的释放干净了?首先你需要在activity处于前台的时候使用Heap Tool获取一份当前状态的内存快照,然后你需要创建一个几乎不这么占用内存的空白activity用来给前一个Activity进行跳转,其次在跳转到这个空白的activity的时候主动调用System.gc()方法来确保触发一个GC操作。最后,如果前面这个activity的内存都有全部正确释放,那么在空白activity被启动之后的内存快照中应该不会有前面那个activity中的任何对象了。
这个方案和我创造的方案差不多,分模块分界面,测试完一个模块或者界面后,退出这个模块或者界面,然后GC一次,再用MAT分析有没有内存Leak。
如果你发现在空白activity的内存快照中有一些可疑的没有被释放的对象存在,那么接下去就应该使用Alocation Track Tool来仔细查找具体的可疑对象。我们可以从空白activity开始监听,启动到观察activity,然后再回到空白activity结束监听。这样操作以后,我们可以仔细观察那些对象,找出内存泄漏的真凶。
Memory Monitor:查看整个app所占用的内存,以及发生GC的时刻,短时间内发生大量的GC操作是一个危险的信号。
Allocation Tracker:使用此工具来追踪内存的分配,前面有提到过。
Heap Tool:查看当前内存快照,便于对比分析哪些对象有可能是泄漏了的,请参考前面的Case。
幸运的是,我们可以通过手机设置里面的开发者选项,打开Show GPU Overdraw的选项,可以观察UI上的Overdraw情况。
Android系统里面有一个Generational Heap Memory的模型,系统会根据内存中不同的内存数据类型分别执行不同的GC操作。例如,最近刚分配的对象会放在Young Generation区域,这个区域的对象通常都是会快速被创建并且很快被销毁回收的,同时这个区域的GC操作速度也是比Old Generation区域的GC操作速度更快的。
解决上面的问题有简洁直观方法,如果你在Memory Monitor里面查看到短时间发生了多次内存的涨跌,这意味着很有可能发生了内存抖动。
原始JVM中的GC机制在Android中得到了很大程度上的优化。Android里面是一个三级Generation的内存模型,最近分配的对象会存放在Young Generation区域,当这个对象在这个区域停留的时间达到一定程度,它会被移动到Old Generation,最后到Permanent Generation区域。