Android性能最佳实践

最近看了谷歌官方关于Android性能最佳实践的部分,感觉应该要记下来才行。这里面有很多不看不知道的东西,我也为一些比较难懂的点增加了一些额外阅读的链接。刚总结完了JNI的小贴士,往后几天会陆续在这篇文章中把性能最佳实践这一部分补充完整。

后面还有安全最佳实践,权限最佳实践等部分,每一部分一篇文章,都会分开记录下来。

性能小贴士

这里介绍了一些小优化,可以提升app的整体性能。但是不一定会带来性能的飙升。选择正确的算法和数据结构是获得良好性能的首要任务。这里的小优化都是一些通用的编码实践,实现高效代码。

实现高效代码有两个基本规则:

  • 不要重复造轮子
  • 不要无谓分配内存

不要创建不必要的对象

创建对象总是要付出代价的。当我们创建越来越多的对象的时候,我们其实在强制地让垃圾回收器更加频繁地工作。这会造成类似于『打嗝』一样的波动,影响用户体验。

因此,要避免任何不必要的对象创建。以下是一些建议:

  • 比如说,如果一个方法要返回String对象。Java内部实现的时候,String最后都是会被加到StringBuffer中的,因此不需要在代码中创建一个临时的StringBuffer或者StringBuilder对象来append字符串,直接用String就好了。
  • 从字符串中截取子字符串的时候,不要创建新的String对象去存放原字符串的拷贝,可以选择直接return substring就好。

更加影响性能的一些点在于数组的使用上:

  • int类型的array比Integer类型的array性能更好;同时,想方设法避免(int, int)这样多维数组的使用,将多维数组降成一维从而获取性能提升;

总结一下,尽量避免临时对象的创建,这样能减少垃圾回收器的运行次数,提升用户体验。

尽可能使用static而不是virtual

如果一个方法不需要访问这个类的成员,那么用static修饰这个方法。这样一来,大致上会有15%-20%的访问速度提升。

这里stackoverflow有个针对于这个问题的很好的解释

使用static final修饰常量

使用statis final修饰常量,能提高访问速度。

这里涉及到java的编译原理,做不了深入解释了。

要注意的是,这只对原始类型数据以及String常量有效。

避免在类的内部使用Getter/Setter方法

在C++等语言中,(i = getCount())这样的代码是很好的编码习惯,编译器会提升执行效率。

但是在安卓中,使用这样的方式是很糟糕的想法。在安卓中调用Virtual Method差不多就像寻找成员变量一样消耗性能。在类中,直接访问成员变量而不要使用Getter方法。

没有JIT编译的情况下,直接访问成员的速度将3倍快于调用Getter方法。拥有JIT编译的情况下,直接访问成员变量变得和访问局部变量一样快捷,将达到7倍快于调用Getter方法。

这是一片关于Java中Virtual Method的文章

使用增强for循环

除了ArrayList的遍历之外,增强for循环的性能是最好的。无论有没有JIT编译支持,在ArrayList的遍历上使用手写for循环,都将3倍快于增强for。

但是在其他容器上,增强for的性能在没有JIT的情况下快于手写for循环,在有JIT的情况下等同于手写for循环。

鉴于更少的代码,在除了ArrayList的地方,都使用增强for。

看下面的情形:

static class Foo {
    int mSplat;
}

Foo[] mArray = ...

public void zero() {
    int sum = 0;
    for (int i = 0; i < mArray.length; ++i) {
        sum += mArray[i].mSplat;
    }
}

public void one() {
    int sum = 0;
    Foo[] localArray = mArray;
    int len = localArray.length;

    for (int i = 0; i < len; ++i) {
        sum += localArray[i].mSplat;
    }
}

public void two() {
    int sum = 0;
    for (Foo a : mArray) {
        sum += a.mSplat;
    }
}

zoo()方法是最慢的,使用了手写for循环,并且每次循环都要获取array长度;

one()方法快一些,不用每次都获取array长度了;

two()方法最快,使用了增强for(这里不是ArrayList)。

记住在非ArrayList的情况下用增强for。

使用默认包访问权限而不是私有private权限

考虑如下情形:

public class Foo {
    private class Inner {
        void stuff() {
            Foo.this.doStuff(Foo.this.mValue);
        }
    }

    private int mValue;

    public void run() {
        Inner in = new Inner();
        mValue = 27;
        in.stuff();
    }

    private void doStuff(int value) {
        System.out.println("Value is " + value);
    }
}

内部类Inner访问了外部类的成员变量mValue和成员方法run,语法上是没有问题的,运行结果也正确。

但是,因为mValuerun方法被private修饰,VM是禁止直接访问一个类的私有成员的,因为Foo Inner是两个不同的类。

为了能使内部类访问外部类的私有成员,编译器生成了如下两个方法:

/*package*/ static int Foo.access$100(Foo foo) {
    return foo.mValue;
}

/*package*/ static void Foo.access$200(Foo foo, int value) {
    foo.doStuff(value);
}

因此,无论是访问mValue还是run,都调用了其中一个方法。

在前面讲到了,在同一个类中要访问成员或者方法最好的方式是直接访问,而不是通过方法调用。因此,这里就是性能损耗的地方。

最好将private去掉,也就是使用默认权限。但是这样一来其他同一包中的类就能访问这些成员了。

最好的办法就是不要写这样的代码了。

避免使用浮点

最佳实践的一条是,浮点操作在安卓设备上,2倍慢于整型操作。

速度层面上说,float和double的速度差不多,其实就是一样的慢!表述范围上来说,double是2倍于float的,那么在没有内存烦恼的台式电脑上,double是首选。

另外要注意的是,有些处理器支持硬件乘操作,但是不支持硬件除操作。因此,在设计有大量除或者模运算的时候,要特别注意性能问题。

尽量使用标准库

Java有的方法,就不要自己去实现了。Java库的代码是针对于虚拟机进行过优化的。System.arrayCopy()f案发9倍快于手写的循环拷贝。

关于性能要知道的地方

没有JIT编译支持的设备上,直接确定一个引用的类型会使调用更加高效。比如,定义HashMap的时候,直接定义为:

HashMap hashMap = new HashMap();

而相较于:

Map hashMap = new HashMap();

对hashMap调用方法,会有大概6%的性能提升。

性能优化之前

在进行优化之前,先测试并确定我们确实有性能问题,并且尽量量化这个性能问题,这样,我们才能在性能优化之后测试优化的效果。

Traceview工具很好用,但是要记住,使用Traceview将会关闭JIT功能。这意味着使用Traceview的时候性能的下降可能在JIT功能恢复的时候就能弥补。

这里是Traceview的说明页面

这里是Systrace的说明页面


JNI小贴士

JavaVM和JNIEnv

JNI定义了两种最关键的数据结构:

  • JavaVM
  • JNIEnv

在源码中,C部分定义为两个结构体:

  • struct _JavaVM
  • struct _JNIENV

在C++部分中,可以看做定义了两个类

  • typedef _JNIENV JNIENV
  • typedef _JavaVM JavaVM

数据结构的定义是这样的:

struct _JNIEnv {

    const struct JNINativeInterface* functions;

    #if define(__cplusplus)
    jclass FindClass(const char* name) {
        return functions->FindClass(this, name);
    }

    ...

}

数据结构如果是C实现的代码:

JNIEnv即struct JAVANativeInterface*,在传递JNIEnv* env的时候,其实就传递了struct JAVANativeInterface** functions,从上面数据结构的定义看出,如果要在C代码中调用FindClass方法,必须先对env做一次解引用,才能得到指向functions具体函数的指针,即,(*env)->FindClass(this, name)。

而如果是C++实现的代码:

传递JNIENV* env,其实相当于传递了struct _JNIEnv* env。从上面的定义可以看出,C++部分对JNINativeFunctions做了封装。因此要调用函数,只需要用env直接调用即可,即,env->FindClass(this, name)

JNIEnv是threadlocal的,因此不能在线程间进行传递。

如果一段代码无从获取JNIEnv,那么应该共享JavaVM,然后使用GetEnv来获取JNIEnv。

在jni.h中,C和C++部分的定于是不同的,通过typedef来区分。因此,如果一个header file会被两种语言包含,就不要把涉及JNIEnv的部分放进去。

线程

所有线程都是Linux线程,由内核管理。

线程可以通过pthread_create另外创建,并通过AttachCurrentThread或者AttachCurrentThreadAdDaemon,来创建一个java.lang.Thread对象,添加到主线程组(ThreadGroup)中。

一个线程在attach之前无法进行任何JNI操作。

对一个已经attach的线程做attach操作产生一个no操作,可以看做操作被忽略。

调用AttachCurrentThread的线程在退出前必须调用DetachCurrentThread。也可以使用pthread_key_create创建析构函数,然后在那里调用DetachCurrentThread

jclass,jmethodID,jfieldID

如果想在native代码中操作java对象的成员,通过如下方式进行:

  • 通过FindClass方法获取对象引用
  • 通过GetFieldID获取成员ID
  • 通过相应的方式获取成员的值,例如GetIntField

找到成员ID和方法ID的过程就是通过字符串比对查找,如果考虑性能问题,可以通过下面的方式在类加载的时候缓存一份ID,已经查找过的就不再查找了。

    private static native void nativeInit();

    static {
        nativeInit();
    }

在native代码中实现这个方法,然后就会在每次类加载的时候自动执行ID的差找工作。

局部和全局引用

JNI中所有参数以及所有方法的返回都是局部引用。就算对象继续存在,只要方法已结束,局部引用就不可用了。

这适用于多有jobject的子类。

唯一保存局部引用的方式是通过NewGlobalRefNewWeakGlobalRef方法来创建全局引用。

这通常用于缓存一个FindClass返回的jclass对象:

jclass localClass = env->FindClass("MyClass");

jclass globalClass = reinterpret_cast<jclass>(env->NewGlobalRef(localClass));

值得注意的是,对于同一个对象的引用的值可以是不同的。例如,连续对同一个对象调用NewGlobalRef方法而返回值可以是不同的。因此,千万不要用==来判断两个引用是否相等,而应该使用IsSameObject方法。另外,不要用jobject当索引。

JNI的实现中只允许16个局部引用,如果要创建超过这个数量的引用,使用EnsureLocalCapacity/PushLocalFrame来获取更多数量的预留空间。创建的引用应该使用DeleteLocalRef来手动回收。

jfieldIDsjmethodIDs,以及GetStringUTFCharsGetByteArrayElements的返回值,都不能作为NewGlobalRef的参数,因为他们是非object类型。

最后,任何通过AttachCurrentThread添加的线程中的局部引用,都应该手动释放。

UTF-8和UTF-16字符串

Java语言使用的是UTF-16字符串。JNI提供了方法来兼容Modified UTF-8。这中编码的好处是我们可以继续使用C风格的\0结尾的字符串,但是坏处是我们不能给JNI的方法传递任意UTF-8字符串,因为有可能出错。

不要忘记releaseget方法获得的字符串。这些get方法获取的字符串只当在release调用之后才保证有效。

传递给NewStringUTF方法的数据必须是Modified UTF-8编码。常见的一个错误是,从一个文件或者网络读取字符串然后直接传递给NewStringUTF方法而不进行过滤。

这里有一篇关于如果和在JNI中使用正确编码的文章,有兴趣的同学可以看一看~

原始数组

JNI中,Object数组只能一次访问一个条目,但是原始数据类型的数组能像在C中定义的那样读和写。

使用Get<PrimitiveType>ArrayElements能获取到指向实际数据的指针或者开辟新的内存空间来拷贝数据。注意,只有当release方法调用了,get方法返回的数据才能被使用,也就是说,如果调用了get方法但是没有调用release方法,那一块堆内存就无法被其他指令使用了。因此必须release每个get到的数组,并且确保如果get失败了,我们没有去release一个NULL指针。

异常

大多数的JNI函数都不能在有异常发生而没有解决的时候调用。

在异常发生的时候可以调用的JNI函数如下:

  • DeleteGlobalRef
  • DeleteLocalRef
  • DeleteWeakGlobalRef
  • ExceptionCheck
  • ExceptionClear
  • ExceptionDescribe
  • ExceptionOccurred
  • MonitorExit
  • PopLocalFrame
  • PushLocalFrame
  • ReleaseArrayElements
  • ReleasePrimitiveArrayCritical
  • ReleaseStringChars
  • ReleaseStringCritical
  • ReleaseStringUTFChars

在调用诸如CallObjectMethod的方法时,必须检查是否有异常发生。

使用ExceptionCheckExceptionOccurred检查是否有异常,使用ExceptionClear清除异常。

JNI并没有提供操作Throwable类的方法。因此,如果要获取异常信息的字符串,只能找到Throwable 类,找到getMessage "()Ljava/lang/String;"方法的methodID,然后调用这个方法;如果返回了NON-NULL的值,则再使用GetStringUTFChars获取一些信息来打印。

扩展检查

JNI的错误检查机制很薄弱,因此Android提供了一种叫做CheckJNI的机制来让JavaVM和JNIEnv完成更多的错误检查工作。

检查的范围不限于:

  • 数组:试图创建负数大小的数组
  • 非可用指针:传递不可用的jarray/jobject/jstring给JNI函数,或者传递NULL给non-nullableJNI函数
  • 字符串编码错误

等等…

如果我们使用的是虚拟机,这个模式默认是打开的。

如果是在ROOT设备上,可以使用如下方式重启运行环境并打开CheckJNI:

adb shell stop
adb shell setprop dalvik.vm.checkjni true
adb shell start

除了上述两种情况,使用如下方式:

adb shell setprop debug.checkjni 1

打开CheckJNI模式之后,在logcat中可以看到如下输出:

D AndroidRuntime: CheckJNI is ON

已经在运行的app不会受到任何影响,但是从这时起运行的app都会在CheckJNI模式下。

修改CheckJNI模式到任何其他值或者重启运行时环境都会关闭CheckJNI模式。关系CheckJNI之后的输出如下:

D Late-enabling CheckJNI

64位系统需要考虑的

Android当前被设计运行在32位平台,当然,也可以在64位平台编译运行,但不是当前的设计目的。大部分时间我们不需要担心这个情况,但当遇到在对象中往整数类型成员中储存指针的时候,要做一些处理。为了支持那些使用64位指针的的平台,我们需要把指针存放在long类型中,而不是int类型。

不支持的特性/向上兼容

除了下面这个特性,JNI1.6所有的特性Android都能支持:

  • DefineClass 这个特性没有实现。由于Android不使用Java字节码或class文件,因此传递二进制class数据不会起作用。

向上兼容需要考虑的情况如下:

  • 动态获取方法ID 直到Android 2.0(Eclair),在搜索methodID的过程中,$符号不能被正确的转换为_00024。解决方案可以是显式地注册这个方法,或者从内部类中移出来。
  • 分离线程(Detach) 直到Android 2.0(Eclair),不能使用pthread_key_create的析构函数来防止thread must be detached before exit。(因为运行时环境也使用了pthread key destructor function,会造成看谁先调用的情况)
  • 、弱全局引用(Weak global references) 直到Android 2.2 (Froyo),没有weak global references可以使用。 直到Android 4.0 (Ice Cream Sandwich),weak global references只能被传递给NewLocalRefNewGlobalRef,以及 DeleteWeakGlobalRef方法。 Android 4.0 (Ice Cream Sandwich)之后, weak global references就能像其他引用一样被使用。
  • 局部引用(Local references) 直到Android 4.0 (Ice Cream Sandwich),local references事实上是直接指针(direct pointers),看Quora上的问答什么是direct pointer和indirect pointer。4.0增加了indirect pointer特性是为了更好地支持垃圾回收。这里有JNI Reference变动的详细说明
  • 通过GetObjectRefType确定引用类型 直到Android 4.0 (Ice Cream Sandwich),由于使用direct pointer,造成了无法使用GetObjectRefType获取引用类型。取而代之的,使用了一种先验式的机制,按照弱全局引用表(weak globals table),参数(arguments),局部引用表(locals table)以及全局引用表(globals table )这样的顺序查找这个引用的类型。因此,有可能我们使用GetObjectRefType去确定一个全局的jclass(global jclass)的类型,而这个全局的jclass就是那个参数jclass(把local jclass做成全局的),会返回JNILocalRefType,而不是JNIGlobalRefType。