×

Android性能优化之渲染篇

96
xuluqxulu
2017.11.07 17:14* 字数 4916

总结Android渲染部分的工作原理,其中参考了如下网址:
http://www.androidpolice.com/2012/07/12/getting-to-know-android-4-1-part-3-project-butter-how-it-works-and-what-it-added/
http://blog.csdn.net/michaelcao1980/article/details/43233765
https://en.wikipedia.org/wiki/Screen_tearing
http://hukai.me/android-performance-render/

1)基本概念

在一个典型的显示系統中,一般包括CPU、GPU、display三個部分, CPU负责計计算数据,把计算好数据交給GPU,GPU会对图形数据进行渲染,渲染好后放到buffer里存起來,然后display(有的文章也叫屏幕或者显示器)负责把buffer里的数据呈現到屏幕上。很多時候,我們可以把CPU、GPU放在一起说,那么就是包括2部分,CPU/GPU 和display(本文主要按后面这种分类來解释)。
tearing: 一个屏幕內的数据來自2个不同的帧,画面会出现撕裂感。
jank: 一个帧在屏幕上连续出現2次。
lag:从用户体验來说,就是点击下去到呈现效果之间存在延迟。
Refresh Rate:代表了屏幕在一秒内刷新屏幕的次数,这取决于硬件的固定参数,例如60Hz。
Frame Rate:代表了GPU在一秒内绘制操作的帧数,例如30fps,60fps。

2)Screen tearing问题如何解决
screen tearing

显示过程,简单的说就是CPU/GPU准备好数据,存入buffer,display去buffer里取数据,然后显示出來。如果只有一个buffer,那么这个buffer既被GPU写,也同時被display读,如果读的速度跟写的速度一样,那可以正常显示。如果读的比写的快(显示器刷新频率略快于CPU/GPU准备缓存的速度),那也沒什么问题。但是如果读的比写的慢的話,很可能有buffer里的数据沒有被读取,就被重写了,这样相当于一部分数据丟失了,这是不允许的。比如display在读取帧1的过程中(为了显示帧1),CPU/GPU把帧2写到了buffer里 ,而display并不知道此时buffer里已经是帧2了,那么就会出现display读的上半部分是帧1,下半部分是2的, 出現画面“割裂”,这就叫tearing。

double-buffer

tearing发生的原因是display读buffer时,buffer被修改,那么多一个buffer是不是能解決问题,是的,事实上目前所有的显示系統都是双缓存的,单缓存存在于30年前。
双缓冲技术,基本原理就是采用两块buffer。一块back buffer用于CPU/GPU后台绘制,另一块framebuffer则用于显示,当back buffer准备就绪后,它们才进行交换。不可否认,doublebuffering可以在很大程度上降低screen tearing错误,但是它是万能的吗?

一个需要考虑的问题是我们什么时候进行两个缓冲区的交换呢?假如是back buffer准备完成一帧数据以后就进行,那么如果此时屏幕还没有完整显示上一帧内容的话,肯定是会出问题的。看来只能是等到屏幕处理完一帧数据后,才可以执行这一操作了。

我们知道,一个典型的显示器有两个重要特性,行频和场频。行频(HorizontalScanningFrequency)又称为“水平扫描频率”,是屏幕每秒钟从左至右扫描的次数; 场频(Vertical Scanning Frequency)也称为“垂直扫描频率”,是每秒钟整个屏幕刷新的次数。由此也可以得出它们的关系:行频=场频*纵坐标分辨率。

当扫描完一个屏幕后,设备需要重新回到第一行以进入下一次的循环,此时有一段时间空隙,称为VerticalBlanking Interval(VBI)。大家应该能想到了,这个时间点就是我们进行缓冲区交换的最佳时间。因为此时屏幕没有在刷新,也就避免了交换过程中出现screentearing的状况。VSync(垂直同步)是VerticalSynchronization的简写,它利用VBI时期出现的vertical sync pulse来保证双缓冲在最佳时间点才进行交换。

总结

Screen Tearing出现的原因有两个:
1,单缓冲情况下,在display的时候draw(也就是所谓的On Display Draw)
2,双缓冲情况下,在display的时候swap buffer(Flip).
所以:
我们只要使用双缓冲做Flip(避免了On display Draw),并且做VSync同步,即每次等到VSync阶段再做swap,就可以完美解决Screen Tearing问题。

3)VSYNC的前生今世

VSYNC(Vertical Synchronization)是一个相当古老的概念,对于游戏玩家,它有一个更加大名鼎鼎的中文名字—-垂直同步。“垂直同步(vsync)”指的是显卡的输出帧数和屏幕的垂直刷新率相同,这完全是一个CRT显示器上的概念。其实无论是VSYNC还是垂直同步这个名字,因为LCD根本就没有垂直扫描的这种东西,因此这个名字本身已经没有意义。但是基于历史的原因,这个名称在图形图像领域被沿袭下来。在当下,垂直同步的含义我们可以理解为,使得显卡生成帧的速度和屏幕刷新的速度的保持一致。举例来说,如果屏幕的刷新率为60Hz,那么生成帧的速度就应该被固定在1/60 s。

Android中的VSYNC — 黄油计划

从Android 4.1开始,谷歌致力于解决Android系统中最饱受诟病的一个问题,滑动不如iOS流畅。因谷歌在4.1版本引入了一个重大的改进—Project Butter,也即是黄油计划。Project Butter对Android Display系统进行了重构,引入了三个核心元素,即VSYNC、Triple Buffer和Choreographer。关于后面两个概念我们会在后面开专题讲解,这里我们重点讲解VSYNC的作用。玩过大型PC游戏的玩家都知道,VSYNC最重要的作用是防止出现画面撕裂(screentearing)。所谓画面撕裂,就是指一个画面上出现了两帧画面的内容,如下图。


为什么会出现这种情况呢?这种情况一般是因为显卡输出帧的速度高于显示器的刷新速度,导致显示器并不能及时处理输出的帧,而最终出现了多个帧的画面都留在了显示器上的问题。这也就是我们所说的画面撕裂。

提到垂直同步这里就多提一句,其实我认为对于PC上的大型游戏来说,只有配置足够高,高到显卡输出帧率可以稳定的高于显示器的刷新频率,才有开启垂直同步的必要。因为只有这个时候,画面撕裂才会真正成为一个问题。而对于很多情况下主机性能不足导致游戏输出帧率低于显示器的刷新频率的情况下,尤其是帧率稳定在40~60之间时,开启垂直同步可能会导致帧率倍数级的下降(具体原因我们在Graphic架构一文中提到过,当帧生成速度不及VSync速度时,帧率的下降不是平缓的,而且很可能是倍数级的。当然这在android系统上并非严重问题,因为android上很少有高速的复杂场景的频繁切换。事实上,在Android的普通应用场景下,VSync的使用不仅不会降低帧率,还可以有效解决卡顿问题)。

4)Jank问题,triple buffer

如果没有VSync同步, 绘图速度大于显示速度那么会有问题(screen tearing).
如果没有VSync同步,还有另一个问题,就是反过来绘图速度过慢的时候.
如图::


这个图中有三个元素,Display是显示屏幕,GPU和CPU负责渲染帧数据,每个帧以方框表示,并以数字进行编号,如0、1、2等等。VSync用于指导双缓冲区的交换。以时间的顺序来看下将会发生的异常:
Step1. Display显示第0帧数据,此时CPU和GPU渲染第1帧画面,而且赶在Display显示下一帧前完成。
Step2. 因为渲染及时,Display在第0帧显示完成后,也就是第1个VSync后,正常显示第1帧。
Step3. 由于某些原因,比如CPU资源被占用,系统没有及时地开始处理第2帧,直到第2个VSync快来前才开始处理
Step4. 第2个VSync来时,由于第2帧数据还没有准备就绪,显示的还是第1帧。这种情况被Android开发组命名为“Jank”。
Step5. 当第2帧数据准备完成后,它并不会马上被显示,而是要等待下一个VSync。所以总的来说,就是屏幕平白无故地多显示了一次第1帧。原因大家应该都看到了,就是CPU没有及时地开始着手处理第2帧的渲染工作,以致“延误军机”。

其实总结上面的这个情况之所以发生,首先的原因就在于第二帧没有及时的绘制。那么如何使得第二帧及时被绘制呢?这就是我们在Graphic系统中引入VSYNC的原因,考虑下面这张图:



如上图所示,一旦VSync出现后,立刻就开始执行下一帧的绘制工作。这样就可以大大降低Jank出现的概率。另外,VSYNC引入后,要求绘制也只能在收到VSYNC消息之后才能进行,因此,也就杜绝了另外一种极端情况的出现—-CPU(GPU)一直不停的进行绘制,帧的生成速度高于屏幕的刷新速度,导致生成的帧不能被显示,只能丢弃,这样就出现了丢帧的情况—-引入VSYNC后,绘制的速度就和屏幕刷新的速度保持一致了。
大部分的Android显示设备刷新率是60Hz,这也就意味着每一帧最多只能有1/60=16ms左右的准备时间。
假如CPU/GPU的FPS(FramesPer Second)高于这个值,那么这个方案是完美的,显示效果将很好。可是我们没有办法保证所有设备的硬件配置都能达到要求。假如CPU/GPU的性能无法满足上图的条件,又是什么情况呢?

在分析这一问题之前,我们先来看下正常情况下,采用双缓冲区的系统的运行情况,如图:

这个图采用了双缓冲,以及前面介绍的VSync,可以看到整个过程还是相当不错的,虽然CPU/GPU处理所用的时间时短时长,但总的来说都在16ms以内,因而不影响显示效果。A和B分别代表两个缓冲区,它们不断地交换来正确显示画面。

现在我们可以继续分析FPS低于屏幕刷新率的情况,如图所示:

当CPU/GPU的处理时间超过16ms时,第一个VSync到来时,缓冲区B中的数据还没有准备好,于是只能继续显示之前A缓冲区中的内容。而B完成后,又因为缺乏VSync pulse信号,它只能等待下一个signal的来临。于是在这一过程中,有一大段时间是被浪费的。当下一个VSync出现时,CPU/GPU马上执行操作,此时它可操作的buffer是A,相应的显示屏对应的就是B。这时看起来就是正常的。只不过由于执行时间仍然超过16ms,导致下一次应该执行的缓冲区交换又被推迟了——如此循环反复,便出现了越来越多的“Jank”。
那么有没有规避的办法呢?

很显然,第一次的Jank看起来是没有办法的,除非升级硬件配置来加快FPS。我们关注的重点是被CPU/GPU浪费的时间段,怎么才能充分利用起来呢?分析上述的过程,造成CPU/GPU无事可做的假象是因为当前已经没有可用的buffer了。换句话说,如果增加一个buffer,情况会不会好转呢?如图:

Triple Buffering是MultipleBuffering的一种,指的是系统使用3个缓冲区用于显示工作。我们来逐步分析下这个新机制是否有效。首先和预料中的一致,第一次“Jank”无可厚非。不过让人欣慰的是,当第一次VSync发生后,CPU不用再等待了,它会使用第三个buffer C来进行下一帧数据的准备工作。虽然对缓冲区C的处理所需时间同样超过了16ms,但这并不影响显示屏——第2次VSync到来后,它选择buffer B进行显示;而第3次VSync时,它会接着采用C,而不是像double buffering中所看到的情况一样只能再显示一遍了。这样子就有效地降低了系统显示错误的机率。
注意:triple Bufferring好像完美解决了连续jank问题,但是第一次的jank无法避免,而且还有lag问题,例如缓存区C的内容要16ms以后才能显示。

5)用户为什么会感觉到卡顿
根本原因

大多数用户感知到的卡顿等性能问题的最主要根源都是因为渲染性能。从设计师的角度,他们希望App能够有更多的动画,图片等时尚元素来实现流畅的用户体验。但是Android系统很有可能无法及时完成那些复杂的界面渲染操作。Android系统每隔16ms发出VSYNC信号,触发对UI进行渲染,如果每次渲染都成功,这样就能够达到流畅的画面所需要的60fps,为了能够实现60fps,这意味着程序的大多数操作都必须在16ms内完成。



如果你的某个操作花费时间是24ms,系统在得到VSYNC信号的时候就无法进行正常渲染,这样就发生了丢帧现象。那么用户在32ms内看到的会是同一帧画面。



用户容易在UI执行动画或者滑动ListView的时候感知到卡顿不流畅,是因为这里的操作相对复杂,容易发生丢帧的现象,从而感觉卡顿。有很多原因可以导致丢帧,也许是因为你的layout太过复杂,无法在16ms内完成渲染,有可能是因为你的UI上有层叠太多的绘制单元,还有可能是因为动画执行的次数过多。这些都会导致CPU或者GPU负载过重。
渲染过程CPU和GPU的分工

渲染操作通常依赖于两个核心组件:CPU与GPU。CPU负责包括Measure,Layout,Record,Execute的计算操作,GPU负责Rasterization(栅格化)操作。CPU通常存在的问题的原因是存在非必需的视图组件,它不仅仅会带来重复的计算操作,而且还会占用额外的GPU资源。


了解Android是如何利用GPU进行画面渲染有助于我们更好的理解性能问题。一个很直接的问题是:activity的画面是如何绘制到屏幕上的?那些复杂的XML布局文件又是如何能够被识别并绘制出来的?


Resterization栅格化是绘制那些Button,Shape,Path,String,Bitmap等组件最基础的操作。它把那些组件拆分到不同的像素上进行显示。这是一个很费时的操作,GPU的引入就是为了加快栅格化的操作。
CPU负责把UI组件计算成Polygons,Texture纹理,然后交给GPU进行栅格化渲染。

然而每次从CPU转移到GPU是一件很麻烦的事情,所幸的是OpenGL ES可以把那些需要渲染的纹理Hold在GPU Memory里面,在下次需要渲染的时候直接进行操作。所以如果你更新了GPU所hold住的纹理内容,那么之前保存的状态就丢失了。
在Android里面那些由主题所提供的资源,例如Bitmaps,Drawables都是一起打包到统一的Texture纹理当中,然后再传递到GPU里面,这意味着每次你需要使用这些资源的时候,都是直接从纹理里面进行获取渲染的。当然随着UI组件的越来越丰富,有了更多演变的形态。例如显示图片的时候,需要先经过CPU的计算加载到内存中,然后传递给GPU进行渲染。文字的显示比较复杂,需要先经过CPU换算成纹理,然后交给GPU进行渲染,返回到CPU绘制单个字符的时候,再重新引用经过GPU渲染的内容。动画则存在一个更加复杂的操作流程。

为了能够使得App流畅,我们需要在每帧16ms以内处理完所有的CPU与GPU的计算,绘制,渲染等等操作。

后面章节引言:

前几章讲解的很多display相关的概念,也描述了Android一些机制来尽量保证显示流畅。
double buffering 和VSync解决了 screen tearing 问题。
Triple buffering解决了连续jank问题,但是第一次的jank还是无法避免,而且引入了lag问题,用户还是会感觉到卡顿。
假设硬件不升级的情况下,如果我们系统能在16ms之内完成所有渲染工作,那这些问题都从根本上解决(理想很丰满,现实很骨感)。我们APP编写者还是要节约资源,避免一些人为的资源浪费,后面的章节就从这点入手,讲解App如何避免一些不必要的消耗。

6)Overdraw问题

Overdraw(过度绘制)描述的是屏幕上的某个像素在同一帧的时间内被绘制了多次。在多层次重叠的UI结构里面,如果不可见的UI也在做绘制的操作,会导致某些像素区域被绘制了多次。这样就会浪费大量的CPU以及GPU资源。



当设计上追求更华丽的视觉效果的时候,我们就容易陷入采用复杂的多层次重叠视图来实现这种视觉效果的怪圈。这很容易导致大量的性能问题,为了获得最佳的性能,我们必须尽量减少Overdraw的情况发生。
幸运的是,我们可以通过手机设置里面的开发者选项,打开Show GPU Overdraw的选项,观察UI上的Overdraw情况。



蓝色,淡绿,淡红,深红代表了4种不同程度的Overdraw情况,我们的目标就是尽量减少红色Overdraw,看到更多的蓝色区域。
7)Visualize and Fix Overdraw - Quiz & Solution

这里举了一个例子,通过XML文件可以看到有好几处非必需的background。通过把XML中非必需的background移除之后,可以显著减少布局的过度绘制。其中一个比较有意思的地方是:针对ListView中的Avatar ImageView的设置,在getView的代码里面,判断是否获取到对应的Bitmap,在获取到Avatar的图像之后,把ImageView的Background设置为Transparent,只有当图像没有获取到的时候才设置对应的Background占位图片,这样可以避免因为给Avatar设置背景图而导致的过度渲染。



总结一下,优化步骤如下:
移除Window默认的Background
移除XML布局文件中非必需的Background
按需显示占位背景图片

8)ClipRect & QuickReject

前面有提到过,对不可见的UI组件进行绘制更新会导致Overdraw。例如Nav Drawer从前置可见的Activity滑出之后,如果还继续绘制那些在Nav Drawer里面不可见的UI组件,这就导致了Overdraw。为了解决这个问题,Android系统会通过避免绘制那些完全不可见的组件来尽量减少Overdraw。那些Nav Drawer里面不可见的View就不会被执行浪费资源。


但是不幸的是,对于那些过于复杂的自定义的View(通常重写了onDraw方法),Android系统无法检测在onDraw里面具体会执行什么操作,系统无法监控并自动优化,也就无法避免Overdraw了。但是我们可以通过canvas.clipRect()来帮助系统识别那些可见的区域。这个方法可以指定一块矩形区域,只有在这个区域内才会被绘制,其他的区域会被忽视。这个API可以很好的帮助那些有多组重叠组件的自定义View来控制显示的区域。同时clipRect方法还可以帮助节约CPU与GPU资源,在clipRect区域之外的绘制指令都不会被执行,那些部分内容在矩形区域内的组件,仍然会得到绘制。

除了clipRect方法之外,我们还可以使用canvas.quickreject()来判断是否没和某个矩形相交,从而跳过那些非矩形区域内的绘制操作。

9)Apply clipRect and quickReject - Quiz & Solution

上面的示例图中显示了一个自定义的View,主要效果是呈现多张重叠的卡片。这个View的onDraw方法如下图所示:



打开开发者选项中的显示过度渲染,可以看到我们这个自定义的View部分区域存在着过度绘制。那么是什么原因导致过度绘制的呢?


10)Fixing Overdraw with Canvas API

下面的代码显示了如何通过clipRect来解决自定义View的过度绘制,提高自定义View的绘制性能:



下面是优化过后的效果:


11)Layouts, Invalidations and Perf

Android需要把XML布局文件转换成GPU能够识别并绘制的对象。这个操作是在DisplayList的帮助下完成的。DisplayList持有所有将要交给GPU绘制到屏幕上的数据信息。
在某个View第一次需要被渲染时,Display List会因此被创建,当这个View要显示到屏幕上时,我们会执行GPU的绘制指令来进行渲染。
如果View的Property属性发生了改变(例如移动位置),我们就仅仅需要Execute Display List就够了。


然而如果你修改了View中的某些可见组件的内容,那么之前的DisplayList就无法继续使用了,我们需要重新创建一个DisplayList并重新执行渲染指令更新到屏幕上。

请注意:任何时候View中的绘制内容发生变化时,都会需要重新创建DisplayList,渲染DisplayList,更新到屏幕上等一系列操作。这个流程的表现性能取决于你的View的复杂程度,View的状态变化以及渲染管道的执行性能。举个例子,假设某个Button的大小需要增大到目前的两倍,在增大Button大小之前,需要通过父View重新计算并摆放其他子View的位置。修改View的大小会触发整个HierarcyView的重新计算大小的操作。如果是修改View的位置则会触发HierarchView重新计算其他View的位置。如果布局很复杂,这就会很容易导致严重的性能问题。

12)Hierarchy Viewer: Walkthrough

Hierarchy Viewer可以很直接的呈现布局的层次关系,视图组件的各种属性。 我们可以通过红,黄,绿三种不同的颜色来区分布局的Measure,Layout,Executive的相对性能表现如何。

13)Nested Hierarchies and Performance

提升布局性能的关键点是尽量保持布局层级的扁平化,避免出现重复的嵌套布局。例如下面的例子,有2行显示相同内容的视图,分别用两种不同的写法来实现,他们有着不同的层级。



image

下图显示了使用2种不同的写法,在Hierarchy Viewer上呈现出来的性能测试差异:


14)Optimizing Your Layout

下图举例演示了如何优化ListItem的布局,通过RelativeLayout替代旧方案中的嵌套LinearLayout来优化布局。


android系统优化
Web note ad 1