IBigerBiger的成长之路

Android性能优化(三)-运算优化

前面两篇主要是说到对于渲染相关问题的优化工作,那么这篇呢主要正对于运算相关的优化工作,说到运算相关的优化其实就是提高我们代码运行的速度,这里的优化工作其实相对来说是比较多也比较杂的,而且随着每个人的代码习惯不同,其实这里面出现的性能问题不是很好发现。

那么对于运行的代码和方法我们如何判断它的性能问题?

这里首先介绍下TraceView

TraceView

TraceView是什么,TraceView 是 Android 平台特有的数据采集和分析工具,主要用做热点分析,找出最需要优化的点。TraceView 从代码层面分析性能问题,针对每个方法来分析,比如当我们发现我们的应用出现卡顿的时候,我们可以来分析出现卡顿时在方法的调用上有没有很耗时的操作,通过TraceView,可以得到两种数据。

  • 单次执行最耗时的方法
  • 执行次数最多的方法
TraceView启动方式

打开TraceView一般有两种方法

1.代码方式

首先选择跟踪范围,在想要根据的代码片段之间使用以下两句代码

1
2
3
Debug.startMethodTracing(“trace”);
Debug.stopMethodTracing();

生成的traceview文件会自动放在SDCARD上,没有SDCARD卡会出现异常,所以使用这种方式需要确保应用的AndroidMainfest.xml中的SD卡的读写权限是打开的,其中hello是traceview文件的名字,然后可以通过启动Android Device Monitor,找到sd卡根目录下的trace.trace,打开这个traceview文件即可。

2.自动方式

首先打开Android Device Monitor,如下



首先选择需要分析的app进程,然后点击上面三个箭头并带红色的按钮即Start Method Profiling(开启方法分析,这时候按钮会变为三个箭头并带黑色即Stop Method Profiling(停止方法分析),开启方法分析后,对应用的目标页面进行测试操作,测试完毕后停止方法分析,界面会自动跳转到 DDMS 的 trace 分析界面。

两种方式的对比:第一种方式更精确到方法,起点和终点都是自己定,不方便的地方是自己需要添加方法并且要找相关的traceview文件,第二种方式的优缺点刚好相反。

TraceView界面分析

trace分析界面,如下图所示:



TraceView 界面比较复杂,其 UI 划分为上下两个面板,即 Timeline Panel(时间线面板)和 Profile Panel(分析面板)。

Timeline Panel 又可细分为左右两个 Pane:

  • 左边 Pane 显示的是测试数据中所采集的线程信息。由图可知,本次测试数据采集了 main 线程,传感器线程和其它系统辅助线程的信息。

  • 右边 Pane 所示为时间线,时间线上是每个线程测试时间段内所涉及的函数调用信息。这些信息包括函数名、函数执行时间等。

开发者可以在时间线 Pane 中移动时间线纵轴。纵轴上边将显示当前时间点中某线程正在执行的函数信息,并且可以按住CTRL键加鼠标滚轮进行放大

Profile Panel 是 TraceView 的核心界面,其内涵非常丰富。它主要展示了某个线程(先在 Timeline Panel 中选择线程)中各个函数调用的情况,包括 CPU 使用时间、调用次数等信息。而这些信息正是查找 hotspot 的关键依据。所以,对开发者而言,一定要了解 Profile Panel 中各列的含义。下表列出了 Profile Panel 中各个字段的列名及其描述。



上面也有说到,TraceView主要作用是获取单次执行最耗时的方法与执行次数最多的方法,这两个数据的获取可以根据上述的字段获取

  • Cpu Time / Call可以反应单次执行最耗时的方法

  • Calls + Recur Calls / Total 可以反应执行次数最多

接下来就用一个简单例子来说明TraceView的使用与分析

TraceView例子分析

上面也说到TraceView可以检测单次执行最耗时的方法与执行次数最多的方法,那么我们这个例子就分别来模拟这两种情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
new Thread(new Runnable() {
@Override
public void run() {
traceTest();
}
},"trace_thread").start();
int count = 0;
private void traceTest() {
for (int i = 0; i < 2000; i++) {
trace();
}
}
private void trace(){
count=++count;
System.out.println("----->print" + count);
}

这里其实是开启了一个线程,执行了traceTest方法,traceTest方法里面循环执行trace方法,所以trace方法模拟了执行次数最多的方法,而traceTest方法则模拟了单次执行最耗时的方法

接下来看TraceView分析的数据



点击Calls + Recur Calls这一栏,可以按照方法调用次数排序,如下图,可以看出trace方法执行了2000次。



点击Cpu Time / Call这一栏,可以按照方法调用时间排序,可以看到traceTest方法执行了6s多,非常的耗时。如下图,



这是模拟的两个极端的情况,实际情况下,分析的难度比较大,但是当体验卡顿的时候,我们可以借助TraceView来定位问题。所以TraceView虽说不常用,但是还是很有意义的!

运算优化(代码优化)

运算相关的优化其实就是提高我们代码运行的速度,那么对于提升代码运算的速度,其实就是对于代码的各种优化工作,包括数据选择,算法优化等等,接下来会关于部分的代码优化做讲解

(1).数据优化

数据类型选择

1.避免使用浮点数

浮点型数据如long double是64位类型,而整型数据如int则是32位类型,通常的经验是,在Android设备中,浮点数会比整型慢两倍,对于早期没有FPU(浮点运算单元)的机器与有FPU机器之间的这之间的差距会更加明显

2.避免使用枚举

Android官方的Training课程里面有下面这样一句话:

1
Enums often require more than twice as much memory as static constants. You should strictly avoid using enums on Android.

即对于Android来说使用枚举会占用内存更多,所以并不推荐使用枚举

Enums代码使用如下

1
2
3
4
5
6
7
8
9
10
11
12
13
public static enum Value{
VALUE1,VALUE2,VALUE3
}
int func(value value){
switch(value){
case VALUE1:
return -1;
case VALUE2:
return -2;
case VALUE3:
return -3;
}
}

在使用Enums代码后我们会发现dex大小比之前增加了1632 bytes

接下来看一下使用静态常量方式,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static final int VALUE1 = 1;
public static final int VALUE1 = 2;
public static final int VALUE1 = 3;
int func(value value){
switch(value){
case VALUE1:
return -1;
case VALUE2:
return -2;
case VALUE3:
return -3;
}
}

在使用静态常量代码后我们会发现dex大小比之前增加了124 bytes

我们可以发现使用Enums,dex大小增加是静态常量方式的13倍之多,同时使用enum,运行时还会产生额外的内存占用。

所以Android官方强烈建议不要在Android程序里面使用到enum。

3.String,StringBuffer,StringBuilder的选择

  • String 字符串常量
  • StringBuffer 字符串变量(线程安全)
  • StringBuilder 字符串变量(非线程安全)

String 类型和 StringBuffer 类型的主要性能区别其实在于 String 是不可变的对象, 因此在每次对 String 类型进行改变的时候其实都等同于生成了一个新的 String 对象,然后将指针指向新的 String 对象,所以经常改变内容的字符串最好不要用 String ,因为每次生成对象都会对系统性能产生消耗的。 而StringBuffer 类则结果就不一样了,每次结果都会对 StringBuffer 对象本身进行操作,而不是生成新的对象,再改变对象引用。

StringBuilder一个可变的字符序列。此类提供一个与StringBuffer兼容的API,但不保证同步。该类被设计用作StringBuffer的一个简易替换,用在字符串缓冲区被单个线程使用的时候(这种情况很普遍)。如果可能,建议优先采用该类,因为在大多数实现中,它比StringBuffer要快。

将StringBuilder的实例用于多个线程是不安全的。如果需要这样的同步,则建议使用StringBuffer。

4.巧用final关键字

final关键字一般在定义常量和方法用的比较多,而大多数人对final的理解往往是在不可变性上,而final对性能优化也有很大的作用。

比如:static int AGE = 10;当10在后面被引用时,这时会有一个字段查找的过程,对于int类型也就是查找方法区中的整型常量池,而对于final的常量,则省去了这个过程,比如:static final int AGE = 10;在使用到AGE的地方将直接用10代替。(不过对于上面这种优化技巧,仅对基本类型和String类型有效,对于其它的引用类型则无效,但是我们在声明常量的时候加上 static final 依然是个好习惯)

对与final关键字,还有一个强大的作用,就是对那些使用频繁、已经确定为终态的方法定义final,这样有什么好处呢?

说这个前先来说说Java中方法的执行过程吧,当调用某个方法时,首先这个方法会入栈,执行完毕后,这个方法出栈,资源释放,而这个过程内部其实是内存地址的转移过程,当执行入栈的方法时,其实就是把程序的执行地址转移到该方法存放的内存地址中,而做此操作前,还有必须进行原先程序执行的内存地址保存过程,当方法执行完出栈后则继续按保存的地址继续执行程序,而这个过程,就是方法的调用过程。

所以,方法的调用过程实际上是需要空间和时间的,而对于同一个方法的频繁调用的优化实际上就是使用内联的办法。

又说到内联函数,内联函数实际上是在编译期做的优化,编译器会将标为为内联的函数在其调用的地方直接用整个函数体进行替换掉,这就省去了函数调用所耗去的时间资源了,而换来的却是目标代码量的增加,所以内联这种优化策略实际上是采取了以空间换时间的策略,对于移动端来说,巧用内联函数实则非常有益。

而要是一个函数成为内联函数,就是将它定义为final,这样在程序编译时,编译器会自动将final函数进行内联优化,那么在调用该函数时则直接展开该函数体进行使用。

总结,并不是内联函数越多越好,一方面它对我们程序的运行效率上确实有提升,而另一方面,对于过多的使用内联函数,则会弄巧成拙,有可能会把某个方法的方法体越搞越大,而且对于某些方法体比较大的方法,内联展开的时间有可能超过方法调用的时间,所以这不仅不会提供性能,反而是降低了本该有的性能。

数据结构选择

1.ArrayMap与HashMap的选择

HashMap内部有一个HashMapEntry[]对象,每一个键值对都存储在这个对象里,当使用put方法添加键值对时,就会new一个HashMapEntry对象

ArrayMap的存储中没有Entry这个东西,他是由两个数组来维护的,其中一个数组中保存的是每一项的HashCode值,另外一个数组中就是键值对,每两个元素代表一个键值对,前面保存key,后面的保存value

HashMap与ArrayMap之间的内存占用效率对比图如下:



但是这个对比也是在一定的条件下的,由于ArrayMap是查找是采用二分法的,所以对于大数据的数据结构,ArrayMap的使用速度会比HashMap要更慢

所以在满足下面2个条件的时候才考虑使用ArrayMap:

  • 对象个数的数量级最好是千以内
  • 数据组织形式包含Map结构

2.避免自动装箱(Autoboxing)

我们知道基础数据类型的大小:boolean(8 bits), int(32 bits), float(32 bits),long(64 bits),为了能够让这些基础数据类型在大多数Java容器中运作,会需要做一个autoboxing的操作,转换成Boolean,Integer,Float等对象
以int为例子,Autoboxing会做如下操作

1
2
3
4
int i=10;
Integer a = Integer.valueOf(i);//自动装箱操作
Integer a=new Integer(i);//手动装箱的操作

Autoboxing是会带来内存消耗的,所以我们要避免自动装箱的问题

Autoboxing的行为还经常发生在类似HashMap这样的容器里面,对HashMap的增删改查操作都会发生了大量的autoboxing的行为。为了避免这些autoboxing带来的效率问题,Android特地提供了SparseBoolMap,SparseIntMap,SparseLongMap,LongSparseMap等容器来解决这些问题

SparseArray与上述的ArrayMap的结构一致,同时SparseArray不需要对key和value进行auto- boxing,所以对于内存和性能的损耗会降低很多,同时由于与ArrayMap结构结构一致,所以使用SparseArray的条件与ArrayMap一样

(2)方法优化

1.遍历方法的选择

遍历容器是编程里面一个经常遇到的场景。在Java语言中,使用Iterate是一个比较常见的方法。可是在Android开发团队中,大家却尽量避免使用Iterator来执行遍历操作。下面我们看下在Android上可能用到的三种不同的遍历方法:

List(Iterator)

1
2
3
4
for(Iterator it = list.iterator; it.hasNext()){
Object obj = it.next();
...
}

List(For-Index)

1
2
3
4
for(int index = 0; index < list.size(); index++){
Object obj = list.get(index);
...
}

List(Simplified)

1
2
3
for(Object obj : list){
...
}

使用上面三种方式在同一台手机上,使用相同的数据集做测试,他们的表现性能如下所示:



从上面可以看到for index的方式有更好的效率,但是因为不同平台编译器优化各有差异,我们最好还是针对实际的方法做一下简单的测量比较好,拿到数据之后,再选择效率最高的那个方式。

2.算法的处理

对于移动端开发来说,其实算法相对来说都是比较简单的,但是也会出现复杂的算法的存在,对于这些算法具体问题具体分析,尽量不用O(n*n)时间复杂度以上的算法,必要时候可用空间换时间。查询考虑hash和二分,尽量不用递归。

算法如果确实比较复杂的情况也可以考虑用C或者C++完成,然后用JNI调用。但是如果是算法比较单间,不必这么麻烦,毕竟JNI调用也会花一定的时间。需要权衡好这之间的关系

(3)图片优化

对于现在的开发来说使用图片加载框架会解决很多对于图片不正确处理带来的性能问题,虽然现在使用第三方图片加载框架已经很频繁了,但是在实际的开发难免还是会出现我们要处理图片相关的问题。

1.解码率的权衡

常见的png,jpeg,webp等格式的图片在设置到UI上之前需要经过解码的过程,而解压时可以选择不同的解码率,不同的解码率对内存的占用是有很大差别的。在不影响到画质的前提下尽量减少内存的占用,这能够显著提升应用程序的性能。

Android为图片提供了4种解码格式,他们分别占用的内存大小如下图所示:



在Android里面可以通过下面的代码来设置解码率:

1
2
3
BitmapFactory.Options mBitmapOptions = new BitmapFactory.Options();
mBitmapOptions.inPreferredConfig = Bitmap.Config.ARGB_8888;
BitmapFactory.decodeResource(getResources(),R.drawable.xxx,mBitmapOptions);

随着解码占用内存大小的降低,清晰度也会有损失。我们需要针对不同的应用场景做不同的处理,大图和小图可以采用不同的解码率。所以要权衡好图片的解码率。

2.缩放处理

对于图片缩放在平时开发中是很常见的问题,对bitmap做缩放的意义很明显,提示显示性能,避免分配不必要的内存。Android提供了现成的bitmap缩放的API,叫做createScaledBitmap(),使用这个方法可以获取到一张经过缩放的图片。

1
Bitmap.createScaledBitmap(sourseBitmap,newWidth,newHeight);

上面的方法能够快速的得到一张经过缩放的图片,可是这个方法能够执行的前提是,原图片需要事先加载到内存中,如果原图片过大,很可能导致OOM。

所以需要更好的方式来进行缩放处理

首先我们看下BitmapFactory.Options相关的属性

  • inJustDecodeBounds 如果将这个值置为true,那么在解码的时候将不会返回bitmap,只会返回这个bitmap的尺寸。这个属性的目的是,如果你只想知道一个bitmap的尺寸,但又不想将其加载到内存时。这是一个非常有用的属性。

  • inSampleSize 这个值是一个int,当它小于1的时候,将会被当做1处理,如果大于1,那么就会按照比例(1 / inSampleSize)缩小bitmap的宽和高、降低分辨率,大于1时这个值将会被处置为2的倍数。例如,width=100,height=100,inSampleSize=2,那么就会将bitmap处理为,width=50,height=50,宽高降为1 / 2,像素数降为1 / 4。

  • inDensity 表示这个bitmap的像素密度(对应的是DisplayMetrics中的densityDpi,不是density)。

  • inTargetDensity 表示要被画出来时的目标像素密度(对应的是DisplayMetrics中的densityDpi,不是density)。

  • inScaled 设置这个Bitmap是否可以被缩放,默认值是true,表示可以被缩放。

通过设置BitmapFactory.Options的inJustDecodeBounds可以在不加载bitmap内存下获取bitmap的尺寸

代码如下

1
2
3
4
5
6
BitmapFactory.Options mBitmapOptions = new BitmapFactory.Options();
mBitmapOptions.inJustDecodeBounds = true;
BitmapFactory.decodeFile(fileName, mBitmapOptions);
srcWidth = mBitmapOptions.outWidth;
srcHeight = mBitmapOptions.outHeight;

通过设置BitmapFactory.Option的inSampleSize能够等比的缩放显示图片,同时还避免了需要先把原图加载进内存的缺点。

代码如下

1
2
3
BitmapFactory.Options mBitmapOptions = new BitmapFactory.Options();
mBitmapOptions.inSampleSize = 2;
BitmapFactory.decodeFile(fileName, mBitmapOptions);

通过设置BitmapFactory.Option的inScaled,inDensity,inTargetDensity的属性来对解码图片做处理

代码如下

1
2
3
4
5
BitmapFactory.Options mBitmapOptions = new BitmapFactory.Options();
mBitmapOptions.inScaled = true;
mBitmapOptions.inDensity = mDensity;
mBitmapOptions.inTargetDensity = mTargetDensity;
BitmapFactory.decodeResource(getResources(),R.drawable.xxx,mBitmapOptions);

3.使用inbitmap

在android3.0开始,系统在BitmapFactory.Options里引入了inBitmap机制来配合缓存机制。如果在载入图片时传入了inBitmap那么载入的图片就是inBitmap里的值。这样可以统一有缓存和无缓存的载入方式。

实际上总结起来就是,如果你使用了这个属性,那么使用这个属性的decode过程中,会直接参考inBitmap所引用的那块内存,不需要在重新给这个bitmap申请一块新的内存,避免了一次内存的分配和回收,大家都知道很多时候ui卡顿是因为gc操作过多而造成的。使用这个属性能避免大内存块的申请和释放。带来的好处就是gc操作的数量减少。这样cpu会有更多的时间做ui线程,界面会流畅很多,同时还能节省大量内存!

使用此方法需要设置BitmapFactory.Options中inMutable=true,inSampleSize=1

代码如下

1
2
3
4
5
BitmapFactory.Options options = new BitmapFactory.Options();
options.inSampleSize = 1;
options.inMutable = true;
Bitmap inBitmap = BitmapFactory.decodeFile(fileName,options);
options.inBitmap = inBitmap;

使用inBitmap,在4.4之前,只能重用相同大小的bitmap的内存区域,而4.4之后你可以重用任何bitmap的内存区域,只要这块内存比将要分配内存的bitmap大就可以。

但是这个属性在使用的时候一定要当心:

如果你不同的imageview 使用的scaletype 不同,但是你这些不同的imageview的bitmap 在decode时候 如果都是引用的同一个inBitmap的话,
这些图片会相互影响,所以大家一定要注意,使用inBitmap这个属性的时候一定要注意这个方面的问题。

(4)序列化优化

1.序列化选择

当我们想把的内存中的对象保存到一个文件中或者数据库中时候,将对象数据在进程之间进行传递时候,通过序列化操作将对象数据在网络上进行传输都需要序列化对象。

序列化一般在Android中以使用Serializable与Parcelable两种方式

对于Serializable,类只需要实现Serializable接口,并提供一个序列化版本id(serialVersionUID)即可。而Parcelable则需要实现writeToParcel、describeContents函数以及静态的CREATOR变量,实际上就是将如何打包和解包的工作自己来定义,而序列化的这些操作完全由底层实现。

Serializable实现如下

1
2
3
4
5
6
7
8
9
10
11
12
public class MySerializable implements Serializable{
private static final long serialVersionUID = -7060210544600464481L;
private String mString;
public String getString(){
return mString;
}
public void setString(String mString){
this.mString = mString;
}
}

Parcelable实现如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class MyParcelable implements Parcelable {
private String mStr;
public int describeContents() {
return 0;
}
public void writeToParcel(Parcel out, int flags) {
out.writeString(mStr);
}
public static final Parcelable.Creator<MyParcelable> CREATOR
= new Parcelable.Creator<MyParcelable>() {
public MyParcelable createFromParcel(Parcel in) {
return new MyParcelable(in);
}
public MyParcelable[] newArray(int size) {
return new MyParcelable[size];
}
};
private MyParcelable(Parcel in) {
mStr = in.readString();
}
}

Serializable的作用是为了保存对象的属性到本地文件、数据库、网络流、rmi以方便数据传输,当然这种传输可以是程序内的也可以是两个程序间的。而Android的Parcelable的设计初衷是因为Serializable效率过慢,为了在程序内不同组件间以及不同Android程序间(AIDL)高效的传输数据而设计,这些数据仅在内存中存在,Parcelable是通过IBinder通信的消息的载体。

通过上面可以得知Parcelable的性能比Serializable好,在内存开销方面较小,所以在内存间数据传输时推荐使用Parcelable,如activity间传输数据,而Serializable可将数据持久化方便保存,所以在需要保存或网络传输数据时选择Serializable,因为android不同版本Parcelable可能不同,所以其实是需要根据不同适用场景来使用。

当然除了Serializable与Parcelable这两种以外还有其他的序列化方式,比如GSON就提供了序列化,还有FlatBuffers等等

2.序列化数据优化

数据呈现的顺序以及结构会对序列化之后的空间产生不小的影响。通常来说,一般的数据序列化的过程如下图所示:



上面的过程,存在两个弊端,第一个是重复的属性名称,另外一个是GZIP没有办法对上面的数据进行更加有效的压缩,假如相似数据间隔了32k的数据量,这样GZIP就无法进行更加有效的压缩。

但是我们稍微改变下数据的记录方式,就可以得到占用空间更小的数据,如下图所示:



通过优化,至少有三方面的性能提升

  • 减少了重复的属性名
  • 使得GZIP的压缩效率更高
  • 同样的数据类型可以批量优化
(5)多线程并发优化

在程序开发的实践当中,为了让程序表现得更加流畅,我们肯定会需要使用到多线程来提升程序的并发执行性能,虽然使用多线程可以提高程序的并发量,但是我们需要特别注意因为引入多线程而可能伴随而来的内存问题。

所以说,多线程是提升程序性能的有效手段之一,但是使用多线程却需要十分谨慎小心,如果不了解背后的执行机制以及使用的注意事项,很可能引起严重的问题。

系统为我们提供了几种多线程方式,为AsyncTask,HandlerThread,IntentService,ThreadPool

简单看下这些多线程方式的使用场景

  • AsyncTask

    为UI线程与工作线程之间进行快速的切换提供一种简单便捷的机制。适用于当下立即需要启动,但是异步执行的生命周期短暂的使用场景。

  • HandlerThread

    为某些回调方法或者等待某些任务的执行设置一个专属的线程,并提供线程任务的调度机制。

    大多数情况下,AsyncTask 都能够满足多线程并发的场景需要(在工作线程执行任务并返回结果到主线程),但是它并不是万能的。例如打开相机之后的预览帧数据是通过 onPreviewFrame()的方法进行回调的,onPreviewFrame()和 open()相机的方法是执行在同一个线程的。如果使用 AsyncTask,会因为 AsyncTask 默认的线性执行的特性(即使换成并发执行)会导致因为无法把任务及时传递给工作线程而导致任务在主线程中被延迟,直到工作线程空闲,才可以把任务切换到工作线程中进行执行。所以我们需要的是一个执行在工作线程,同时又能够处理队列中的复杂任务的功能,而 HandlerThread 的出现就是为了实现这个功能的,它组合了 Handler,MessageQueue,Looper 实现了一个长时间运行的线程,不断的从队列中获取任务进行执行的功能。

  • IntentSerice

    默认的 Service 是执行在主线程的,可是通常情况下,这很容易影响到程序的绘制性能(抢占了主线程的资源)。除了前面介绍过的 AsyncTask 与 HandlerThread,我们还可以选择使用 IntentService 来实现异步操作。IntentService 继承自普通 Service 同时又在内部创建了一个 HandlerThread,在 onHandlerIntent()的回调里面处理扔到 IntentService 的任务,在执行完任务后会自动停止。所以 IntentService 就不仅仅具备了异步线程的特性,还同时保留了 Service 不受主页面生命周期影响,优先级比较高,适合执行高优先级的后台任务,不容易被杀死的特点。

  • ThreadPool

    系统为我们提供了 ThreadPoolExecutor 来实现多线程并发执行任务,把任务分解成不同的单元,分发到各个不同的线程上,进行同时并发处理。

我们通过这几种多线程的使用场景对比,来根据实际情况选择不同的方式