Android 应用性能优化个人总结 -- 图形优化

4,028 阅读25分钟
原文链接: mp.weixin.qq.com

文 / 腾讯 李棚

应用UI卡顿常见原因主要在以下几个方面:

1.人为在UI线程中做轻微耗时操作,导致UI线程卡顿;

2.布局Layout过于复杂,无法在16ms内完成渲染;

3.同一时间动画执行的次数过多,导致CPU或GPU负载过重;

4.View过度绘制,导致某些像素在同一帧时间内被绘制多次,从而使CPU或GPU负载过重;

5.View频繁的触发measure、layout,导致measure、layout累计耗时过多及整个View频繁的重新渲染;

6.内存频繁触发GC过多(同一帧中频繁创建内存),导致暂时阻塞渲染操作;

7.冗余资源及逻辑等导致加载和执行缓慢;

8.臭名昭著的ANR;

一、图形优化

渲染机制

大多数用户感知到的卡顿等性能问题的最主要根源都是因为渲染性能。从设计师的角度,他们希望App能够有更多的动画,图片等时尚元素来实现流畅的用户体验。但是Android系统很有可能无法及时完成那些复杂的界面渲染操作。


渲染操作通常依赖于两个核心组件:CPU与GPU。

CPU负责包括Measure,Layout,Record,Execute的计算操作,

GPU 负责Rasterization(栅格化)、渲染等操作


Android系统每隔16ms发出VSYNC信号,触发GPU对UI进行渲染,如果每次渲染都成功,这样就能够达到流畅的画面所需要的60fps,为了能够实现60fps,这意味着程序的大多数操作都必须在16ms内完成。


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


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

Why 60fps帧/秒? 为什么是16ms毫秒?

12fps大概类似手动快速翻动书籍的帧率,这明显是可以感知到不够顺滑的。

24fps使得人眼感知的是连续线性的运动,电影胶圈通常使用的帧率,

低于30fps是无法顺畅表现绚丽的画面内容的,60fps来达到想要的效果,

超过60fps是没有必要的。

开发app的性能目标就是保持60fps,这意味着每一帧你只有16ms=1000/60的时间。

VSYNC机制

通过Vsync信号来同步UI绘制和动画,使得它们可以获得一个达到60fps的固定的帧率;

为了理解App是如何进行渲染的,我们必须了解手机硬件是如何工作,那么就必须理解什么是VSYNC。

在讲解VSYNC之前,我们需要了解两个相关的概念:

Refresh Rate:代表了屏幕在一秒内刷新屏幕的次数,这取决于硬件的固定参数,例如60Hz。

Frame Rate:代表了GPU在一秒内绘制操作的帧数,例如30fps,60fps。

GPU会获取图形数据进行渲染,然后显示器硬件负责把渲染后的内容呈现到屏幕上,他们两者不停的进行协作。

不幸的是,刷新频率和帧率并不是总能够保持相同的节奏。如果发生帧率与刷新频率不一致的情况,就会容易出现Tearing的现象(画面上下两部分显示内容发生断裂现象,来自不同的两帧数据发生重叠)。

情况一:GPU Frame rate > LED Refresh rate(不常见)


在这种情况下,某些帧显示的画面内容就会与上一帧的画面相同。糟糕的事情是,帧率从超过60fps突然掉到60fps以下,这样就会发生LAG,JANK,HITCHING等卡顿掉帧的不顺滑的情况。这也是用户感受不好的原因所在。

垂直同步及二缓冲以及三缓冲

平滑的动画一般需要每秒六十帧。而帧是由像素点构成的。当屏幕绘制一帧的时候,像素点是一行一行的进行填充的。

显示屏(LCD,AMOLED或者别的什么)从图形芯片(GPU)获取每帧的数据,然后一行一行的进行绘制。理想状况下,你期望显示屏在绘制完一帧之后,图形芯片整好能提供新帧的数据。

图像撕裂的状况就发生在屏幕绘制图像到一半的时候,GPU就载入了新一帧的数据,以致你最终得到的数据帧是半个帧的新数据和半个帧的老数据。

垂直同步,用来同步的。它告知GPU在载入新帧之前,要等待屏幕绘制完成前一帧。


除了垂直同步,还有android双缓冲机制。

缓冲就是帧构建和保存的容器。

android的双缓冲机制,它可以在显示一帧的同时进行另一帧的处理。在图中,就是缓冲A和B。当显示缓冲A时,系统在缓冲B中构建新的帧。完成后,则交换缓冲。显示缓冲B,而A则被清空,继续下一帧的绘制。


当某帧的绘制时间超过16毫秒时,双缓冲的问题就暴露出来了。

我们知道,仅在收到垂直同步脉冲时,才会进行帧(缓冲)切换。之间的空白表示浪费的时间。这就是ICS的缺陷。


三缓冲机制

B. 三缓冲支持,改善GPU和CPU之间绘制节奏不一致的问题;

一般情况下,它只使用了双缓冲,但当需要的时候,会用三倍缓冲来进行增强。这样,即将输入延迟降低到最少,又能在发生意外的情况下保持画面流畅。

过度重绘Overdraw

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


当设计上追求更华丽的视觉效果的时候,我们就容易陷入采用越来越多的层叠组件来实现这种视觉效果的怪圈。这很容易导致大量的性能问题,为了获得最佳的性能,我们必须尽量减少Overdraw的情况发生。

幸运的是,我们可以通过手机设置里面的开发者选项,打开Show GPU Overdraw的选项,可以观察UI上的Overdraw情况。


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

Overdraw有时候是因为你的UI布局存在大量重叠的部分,还有的时候是因为非必须的重叠背景。例如某个Activity有一个背景,然后里面的Layout又有自己的背景,同时子View又分别有自己的背景。仅仅是通过移除非必须的背景图片,这就能够减少大量的红色Overdraw区域,增加蓝色区域的占比。这一措施能够显著提升程序性能。

1、抽象布局标签

(1) 标签

include标签常用于将布局中的公共部分提取出来供其他layout共用,以实现布局模块化,这在布局编写方便提供了大大的便利。

下面以在一个布局main.xml中用include引入另一个布局foot.xml为例。main.mxl代码如下:

Java


其中include引入的foot.xml为公用的页面底部,代码如下:

Java


标签唯一需要的属性是layout属性,指定需要包含的布局文件。可以定义android:id和android:layout_*属性来覆盖被引入布局根节点的对应属性值。注意重新定义android:id后,子布局的顶结点i就变化了。

(2) 标签

Java


(3) 标签

在使用了include后可能导致布局嵌套过多,多余不必要的layout节点,从而导致解析变慢,不必要的节点和嵌套可通过hierarchy viewer(下面布局调优工具中有具体介绍)或设置->开发者选项->显示布局边界查看。

merge标签可用于两种典型情况:

a.布局顶结点是FrameLayout且不需要设置background或padding等属性,可以用merge代替,因为Activity内容试图的parent view就是个FrameLayout,所以可以用merge消除只剩一个。

b.某布局作为子布局被其他布局include时,使用merge当作该布局的顶节点,这样在被引入时顶结点会自动被忽略,而将其子节点全部合并到主布局中。

以(1) 标签的示例为例,用hierarchy viewer查看main.xml布局如下图:


可以发现多了一层没必要的RelativeLayout,将foot.xml中RelativeLayout改为merge,如下:

Java


运行后再次用hierarchy viewer查看main.xml布局如下图:


这样就不会有多余的RelativeLayout节点了。

2、去除不必要的嵌套和View节点

(1) 首次不需要使用的节点设置为GONE或使用viewstub

(2) 使用RelativeLayout代替LinearLayout

大约在Android4.0之前,新建工程的默认main.xml中顶节点是LinearLayout,而在之后已经改为RelativeLayout,因为RelativeLayout性能更优,且可以简单实现LinearLayout嵌套才能实现的布局。

4.0及以上Android版本可通过设置->开发者选项->显示布局边界打开页面布局显示,看看是否有不必要的节点和嵌套。4.0以下版本可通过hierarchy viewer查看。

3、减少不必要的infalte

(1) 对于inflate的布局可以直接缓存,用全部变量代替局部变量,避免下次需再次inflate

如上面ViewStub示例中的

Java


(2) ListView提供了item缓存,adapter getView的标准写法,如下:

Java



4、其他点

(1) 用SurfaceView或TextureView代替普通View

SurfaceView或TextureView可以通过将绘图操作移动到另一个单独线程上提高性能。

普通View的绘制过程都是在主线程(UI线程)中完成,如果某些绘图操作影响性能就不好优化了,这时我们可以考虑使用SurfaceView和TextureView,他们的绘图操作发生在UI线程之外的另一个线程上。

因为SurfaceView在常规视图系统之外,所以无法像常规视图一样移动、缩放或旋转一个SurfaceView。TextureView是Android4.0引入的,除了与SurfaceView一样在单独线程绘制外,还可以像常规视图一样被改变。

(2) 使用RenderJavascript

RenderScript是Adnroid3.0引进的用来在Android上写高性能代码的一种语言,语法给予C语言的C99标准,他的结构是独立的,所以不需要为不同的CPU或者GPU定制代码代码。

(3) 使用OpenGL绘图

Android支持使用OpenGL API的高性能绘图,这是Android可用的最高级的绘图机制,在游戏类对性能要求较高的应用中得到广泛使用。

Android 4.3最大的改变,就是支持OpenGL ES 3.0。相比2.0,3.0有更多的缓冲区对象、增加了新的着色语言、增加多纹理支持等等,将为Android游戏带来更出色的视觉体验。

(4) 尽量为所有分辨率创建资源

减少不必要的硬件缩放,这会降低UI的绘制速度,可借助Android asset studio

5、布局调优工具

(1) hierarchy viewer

hierarchy viewer可以方便的查看Activity的布局,各个View的属性、measure、layout、draw的时间,如果耗时较多会用红色标记,否则显示绿色。

hierarchy viewer.bat位于/tools/目录下。

示例图如下:


(2) layoutopt(Lint)

layoutopt是一个可以提供layout及其层级优化提示的命令行,在sdk16以后已经被lint取代,在Windows->Show View->Other->Android->Lint Warnings查看lint优化提示,lint具体介绍可见Improving Your Code with lint。

Android是如何利用GPU进行画面渲染???

XML

View

每一个View都抽象为一个Render Node


每一个Render Node都关联有一个Display List Renderer,这个Display List不是Open GL里面的Display List。

Display List是一个绘制命令缓冲区。它也是一个树状结构

当View的成员函数onDraw被调用时,我们调用通过参数传递进来的Canvas的drawXXX成员函数绘制图形时,我们实际上只是将对应的绘制命令以及参数保存在一个Display List中

通过Display List Renderer执行这个Display List的命令

调用OpenGL Render,来通过Open GL命令绘制在一个Graphic Buffer中

最后这个Graphic Buffer被交给SurfaceFlinger服务进行合成和显示



Resterization栅格化是绘制那些Button,Shape,Path,String,Bitmap等组件最基础的操作。它把那些组件拆分到不同的像素上进行显示。这是一个很费时的操作,GPU的引入就是为了加快栅格化的操作。

CPU负责把UI组件计算成Polygons(多边形),Texture(纹理),然后交给GPU进行栅格化渲染。


Android系统是如何处理UI组件的更新操作的???

Android需要把XML布局文件转换成GPU能够识别并绘制的对象。

问:这个GPU可识别并绘制的对象是什么???

答:是Display List树

这个操作是在DisplayList的帮助下完成的。DisplayList持有所有将要交给GPU绘制到屏幕上的数据信息。

在某个View第一次需要被渲染时,DisplayList会因此而被创建,当这个View要显示到屏幕上时,我们会执行GPU的绘制指令来进行渲染。

如果View的Property属性发生了改变(例如移动位置),我们就仅仅需要Execute Display List指令就够了。


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


需要注意的是:

任何时候View中的绘制内容发生变化时,都会重新执行创建DisplayList,GPU渲染DisplayList数据,更新到屏幕上等一系列操作。

这个流程的表现性能取决于你的View的复杂程度,View的状态变化以及渲染管道的执行性能。

举个例子,假设某个Button的大小需要增大到目前的两倍,在增大Button大小之前,需要通过父View重新计算(Measure)并摆放(Layout)其他子View的位置。修改View的大小会触发整个HierarcyView的重新计算大小的操作。如果是修改View的位置则会触发HierarchView重新计算其他View的位置。如果布局很复杂,这就会很容易导致严重的性能问题。我们需要尽量减少Overdraw。


详细分析,请参考一下相关博客

Android应用程序UI硬件加速渲染技术简要介绍和学习计划

Android应用程序UI硬件加速渲染环境初始化过程分析

Android应用程序UI硬件加速渲染的预加载资源地图集服务(Asset Atlas Service)分析

Android应用程序UI硬件加速渲染的Display List构建过程分析

Android应用程序UI硬件加速渲染的Display List渲染过程分析

Android应用程序UI硬件加速渲染的动画执行过程分析

View的layout大小改变时的性能问题

布局中的任何一个View一旦发生一些属性变化,都可能引起很大的连锁反应。

例如:

某个button的大小突然增加一倍,有可能会导致兄弟视图的位置变化,也有可能导致父视图的大小发生改变。当大量的layout()操作被频繁调用执行的时候,就很可能引起丢帧的现象。(Layout()triggered触发)


例如,在RelativeLayout中,我们通常会定义一些类似alignTop,alignBelow等等属性,如图所示:


为了获得视图的准确位置,需要经过下面几个阶段。首先子视图会触发计算自身位置的操作,然后RelativeLayout使用前面计算出来的位置信息做边界的调整的操作,如下面两张图所示:



经历过上面2个步骤,relativeLayout会立即触发第二次layout()的操作来确定所有子视图的最终位置与大小信息。

除了RelativeLayout会发生两次layout操作之外,LinearLayout也有可能触发两次layout操作,通常情况下LinearLayout只会发生一次layout操作,可是一旦调用了measureWithLargetChild()方法就会导致触发两次layout的操作。另外,通常来说,GridLayout会自动预处理子视图的关系来避免两次layout,可是如果GridLayout里面的某些子视图使用了weight等复杂的属性,还是会导致重复的layout操作。

如果只是少量的重复layout本身并不会引起严重的性能问题,但是如果它们发生在布局的根节点,或者是ListView里面的某个ListItem,这样就会引起比较严重的性能问题。如下图所示:


我们可以使用Systrace来跟踪特定的某段操作,如果发现了疑似丢帧的现象,可能就是因为重复layout引起的。通常我们无法避免重复layout,在这种情况下,我们应该尽量保持View Hierarchy的层级比较浅,这样即使发生重复layout,也不会因为布局的层级比较深而增大了重复layout的倍数。另外还有一点需要特别注意,在任何时候都请避免调用requestLayout()的方法,因为一旦调用了requestLayout,会导致该layout的所有父节点都发生重新layout的操作。


频繁调用onDraw()

我们都知道应该避免在onDraw()方法里面执行导致内存分配的操作,下面讲解下为何需要这样做。

首先onDraw()方法是执行在UI线程的,在UI线程尽量避免做任何可能影响到性能的操作。虽然分配内存的操作并不需要花费太多系统资源,但是这并不意味着是免费无代价的。设备有一定的刷新频率,导致View的onDraw方法会被频繁的调用,如果onDraw方法效率低下,在频繁刷新累积的效应下,效率低的问题会被扩大,然后会对性能有严重的影响。


如果在onDraw里面执行内存分配的操作,会容易导致内存抖动,GC频繁被触发,虽然GC后来被改进为执行在另外一个后台线程(GC操作在2.3以前是同步的,之后是并发),可是频繁的GC的操作还是会影响到CPU,影响到电量的消耗。

那么简单解决频繁分配内存的方法就是把分配操作移动到onDraw()方法外面,通常情况下,我们会把onDraw()里面new Paint的操作移动到外面,如下面所示:

自定义view的性能优化

Android系统有提供超过70多种标准的View,例如TextView,ImageView,Button等等。在某些时候,这些标准的View无法满足我们的需要,那么就需要我们自己来实现一个View,这节会介绍如何优化自定义View的性能。

通常来说,针对自定义View,我们可能犯下面三个错误:

1、Useless calls to onDraw():我们知道调用View.invalidate()会触发View的重绘,有两个原则需要遵守,第1个是仅仅在View的内容发生改变的时候才去触发invalidate方法,第2个是尽量使用ClipRect(局部绘图)等方法来提高绘制的性能

2、Useless pixels:减少绘制时不必要的绘制元素,对于那些不可见的元素,我们需要尽量避免重绘。

3、Wasted CPU cycles:对于不在屏幕上的元素,可以使用Canvas.quickReject把他们给剔除,避免浪费CPU资源。另外尽量使用GPU来进行UI的渲染,这样能够极大的提高程序的整体表现性能。

优化建议:


在onDraw()方法中你应该减少冗余代码

大部分时候调用 onDraw()方法就是调用invalidate()的结果,所以减少不必要的调用invalidate()方法。有可能的,调用四种参数不同类型的invalidate(),而不是调用无参的版本。无参变量需要刷新整个view,而四种参数类型的变量只需刷新指定部分的view.这种高效的调用更加接近需求,也能减少落在矩形屏幕外的不必要刷新的页面。

使用clipRect and quickReject

Overdraw, Cliprect, QuickReject

引起性能问题的一个很重要的方面是因为过多复杂的绘制操作。我们可以通过工具来检测并修复标准UI组件的Overdraw问题,但是针对高度自定义的UI组件则显得有些力不从心。

有一个窍门是我们可以通过执行几个APIs方法来显著提升绘制操作的性能。前面有提到过,非可见的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()来判断是否没和某个矩形相交,从而跳过那些非矩形区域内的绘制操作。做了那些优化之后,我们可以通过上面介绍的Show GPU Overdraw来查看效果。


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


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

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


下面是优化过后的效果:


减少透明区域对性能的影响

这小节会介绍如何减少透明区域对性能的影响。

通常来说,对于不透明的View,显示它只需要渲染一次即可,可是如果这个View设置了alpha值,会至少需要渲染两次。原因是包含alpha的view需要事先知道混合View的下一层元素是什么,然后再结合上层的View进行Blend混色处理。

在某些情况下,一个包含alpha的View有可能会触发改View在HierarchyView上的父View都被额外重绘一次。

下面我们看一个例子,下图演示的ListView中的图片与二级标题都有设置透明度。


大多数情况下,屏幕上的元素都是由后向前进行渲染的。在上面的图示中,会先渲染背景图(蓝,绿,红),然后渲染人物头像图。如果后渲染的元素有设置alpha值,那么这个元素就会和屏幕上已经渲染好的元素做blend处理。

很多时候,我们会给整个View设置alpha的来达到fading的动画效果,如果我们图示中的ListView做alpha逐渐减小的处理,我们可以看到ListView上的TextView等等组件会逐渐融合到背景色上。但是在这个过程中,我们无法观察到它其实已经触发了额外的绘制任务,我们的目标是让整个View逐渐透明,可是期间ListView在不停的做Blending的操作,这样会导致不少性能问题。

如何渲染才能够得到我们想要的效果呢?

我们可以先按照通常的方式把View上的元素按照从后到前的方式绘制出来,但是不直接显示到屏幕上,而是使用GPU预处理之后,再又GPU渲染到屏幕上,GPU可以对界面上的原始数据直接做旋转,设置透明度等等操作。使用GPU进行渲染,虽然第一次操作相比起直接绘制到屏幕上更加耗时,可是一旦原始纹理数据生成之后,接下去的操作就比较省时省力。



如何才能够让GPU来渲染某个View呢?我们可以通过setLayerType的方法来指定View应该如何进行渲染,从SDK 16开始,我们还可以使用ViewPropertyAnimator.alpha().withLayer()来指定。如下图所示:


另外一个例子是:

包含阴影区域的View,这种类型的View并不会出现我们前面提到的问题,因为他们并不存在层叠的关系。

Opaque不透明semi-transparent半透明


为了能够让渲染器知道这种情况,避免为这种View占用额外的GPU内存空间,我们可以做下面的设置。


通过上面的设置是否有重叠渲染以后,性能可以得到显著的提升,如下图所示:

动画性能的优化

Android Material Design风格


参考
如何理解 Google 的 Material Design 设计语言?应用采用了大量的动画来进行UI切换,

优化动画的性能不仅能够提升用户体验还可以减少电量的消耗,

下面会介绍一些简单易行的方法。

在Android里面一个相对操作比较繁重的事情是对Bitmap进行旋转,缩放,裁剪等等。

例如

在一个圆形的钟表图上,我们把时钟的指针抠出来当做单独的图片进行旋转会比旋转一张完整的圆形图的所形成的帧率要高56%。


另外尽量减少每次重绘的元素可以极大的提升性能,假如某个钟表界面上有很多需要显示的复杂组件,我们可以把这些组件做拆分处理,例如把背景图片单独拎出来设置为一个独立的View,通过setLayerType()方法使得这个View强制用Hardware来进行渲染。至于界面上哪些元素需要做拆分,他们各自的更新频率是多少,需要有针对性的单独讨论。

如何使用Systrace等工具来查看某些View的渲染性能

对于大多数应用中的动画,我们会使用PropertyAnimation或者ViewAnimation来操作实现,Android系统会自动对这些Animation做一定的优化处理,

ANR问题

UI线程被阻塞超过5秒,就会出现ANR,这太糟糕了。防止程序出现ANR是很重要的事情,那么如何找出程序里面潜在的坑,预防ANR呢?很多大部分情况下执行很快的方法,但是他们有可能存在巨大的隐患,这些隐患的爆发就很容易导致ANR。

Android提供了一个叫做Strict Mode的工具,我们可以通过手机设置里面的开发者选项,打开Strict Mode选项,如果程序存在潜在的隐患,屏幕就会闪现红色。我们也可以通过StrictModeAPI在代码层面做细化的跟踪,可以设置StrictMode监听那些潜在问题,出现问题时如何提醒开发者,可以对屏幕闪红色,也可以输出错误日志。下面是官方的代码示例: