翻译自 Android 开发者训练课程,原文链接:Performance tips


这篇文档主要涵盖了一些微小的优化,组合它们能够提升应用的整体性能,但是这些变化不会带来戏剧性的效果。你应该优选选择正确的算法和数据结构,但是它超出了本文档要说明的范围。在一般的开发练习中,你应该使用本文档中的提示,这样才能把提高代码效率当成一种习惯。

编写高效代码的两个基本原则:

  • 不要做不该做的事
  • 尽量避免分配内存

当你微优化安卓应用时,面对最棘手的问题之一就是,你的应用会运行在各种不同类型的硬件上。不同版本的虚拟机跑在不同处理器上,运行速度也不同。通常你不能简单地说,设备 X 是比设备 Y 运行快/慢的因素,将结果从一个设备扩展到其他设备。特别是,关于在其他设备上的性能,模拟器上的测量结果不全面。有没有 JIT 的设备也有非常大的差异:具有 JIT 的设备的最佳代码并不总是没有设备的最佳代码。

为确保你的应用在各种设备上都能正常运行,确保你的代码在各个级别都高效,并积极优化你的性能。

避免创建不必要的对象

创建对象并不是没有开销的。分代垃圾收集器具有用于临时对象的每个线程分配池,这可以使分配更便宜,但是分配内存总是比不分配代价要大。

当你在应用中创建更多的对象时,你将被迫进行垃圾收集,对于用户体验来说,它就像「打嗝」一样的。在安卓 2.3 之后引入了并发垃圾收集器,但是也应该避免不必要的工作。

因此,你要避免创建不必要的对象。下面是一些例子:

  • 如果你的方法返回一个字符串,你知道它的结果总会拼接到 StringBuffer,这时你就该更改签名和实现,这样函数会直接追加,而不是创建存活期短的临时对象。
  • 当从输入数据提取字符串时,尝试返回原始数据到子字符串,而不是创建一个拷贝。你会创建一个新的 String 对象,但是它会和原始数据共享 char[]。(需要考虑的是,如果你只使用原始输入的一小部分,那么无论如何,如果你用这个方法,你都会在内存中保留它。))

一个激进的想法是,把多维数组切片变成并行的一维数组。

  • int 数组比 Integer 对象数组好多了。但是概括来说,两个并行的 int 数组同样比二维数组 (int,int)高效。对于其他的基本数据类型的组合也是如此。
  • 如果你需要实现一个容器,用来存储二元组 (Foo,Bar) 对象,记住两个并行的 Foo[]Bar[] 数组通常比一个常规的 (Foo,Bar) 对象数组要好得多。(当然例外情况是,你为其他代码设计 API 以进行访问。在这些情况下,为了实现良好的 API 设计,通常最好对速度进行小的折衷。但是在你自己的内部代码中,你应该尝试尽可能高效。)

一般来说,尽量避免创建短期的临时对象。更少地创建对象意味着更低频率的垃圾回收,这对用户体验有直接影响。

首选静态虚拟

如果你不需要访问对象的字段,请将方法设为静态,调用速度就会提高 15%-20%。这也是很好的做法,因为你可以从方法签名中看出,调用方法不能改变对象的状态。

考虑下面的在类首部的声明。

static int intVal = 42;
static String strVal = "Hello, world!";

编译器生成一个类的初始化方法,叫做 <clint>,当第一次使用类的时候,该方法会被执行。这个方法把值 42 存在 intVal 变量中,从类文件字符串常量表中提取一个引用指向 strVal。当稍后引用这些值时,通过字段可以访问它们。

我们可以使用 final 关键字改善这一步:

static final int intVal = 42;
static final String strVal = "Hello, world!";

这样,类就不需要 <clinit> 方法了,因为常量进入 dex 文件中的静态字段初始值设定项。引用 intVal 的代码会直接使用整数值 42,访问 strVal 会使用相对划算的「字符串常量」指令,而不是字段查找。

注意:此优化仅适用于基本类型和字符串常量,而不适用于任意引用类型。尽管如此,最好尽可能地声明常量 static final 值。

使用增强型 for 循环

增强型 for 循环(也就是 for-each 循环)可以遍历实现了 Iterable 接口的集合和数组。对于集合,迭代器被分配用于创建叫做 hasNext()next() 的接口。对于 ArrayList,一个手写的计数循环比 for-each 快约 3 倍,但是对于其他集合,增强型 for 循环完全等同于显式迭代器用法。

这里有几个遍历数组的方案:

static class Foo {
    int splat;
}

Foo[] array = ...

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

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

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

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

zero() 最慢,因为每次通过循环迭代获得数组长度是有成本的,JIT 还不会优化。

one() 快一些,它将所有内容都拉到局部变量中,从而避免了查找。只有数组的长度才能提供性能优势。

two() 在没有 JIT 的设备上是最快的,与具有 JIT 的设备的 one() 无法区分。它使用了 Java 语言 1.5 版本后引入的增强型 for 循环语法。

所以,你应该默认使用增强型 for 循环,但是考虑一个手写的计数循环,用于性能关键的 ArrayList 迭代。

考虑包而不是私有内部类的私有访问

来看下面的类的定义:

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);
    } 
} 

重要的是,我们定义了一个私有的内部类 Foo$Inner,它可以直接访问外部类的私有方法和私有成员变量。这是合法的,代码会打印 「Value is27」。

问题是,虚拟机认为从 Foo$Inner 直接访问 Foo 的私有成员是非法的,因为 FooFoo$Inner 是不同的类,即使 Java 语言允许内部类访问外部类的私有成员。为了弥合差距,编译器会生成一对合成方法:

/*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 字段或者调用 doStuff() 方法时,它会调用这些静态方法。这意味着上面的代码实际上归结为,你通过访问器方法访问成员字段的情况。之前我们讨论到访问器如何比直接访问字段更慢。所以这是一个特定语言习语的例子,导致「看不见」的表演。

避免使用浮点型

根据经验,浮点数 比Android 设备上的整数慢约 2 倍。

在速度方面,现代硬件上的 floatdouble 没有区别。在空间方面,double 大 2 倍。与桌面计算机一样,假设空间不是问题,您应该更喜欢 double

此外,即使对于整数,一些处理器也有硬件乘法但缺乏硬件除法。在这种情况下,整数除法和模数运算在软件中执行 - 如果您正在设计哈希表或进行大量数学运算,则需要考虑。

了解并使用库

除了喜欢库代码而不是自己编写代码,请记住系统可以自由地用手动编译汇编程序替换对库方法的调用,这可能比 JIT 可以生成的等效的 Java 最佳代码更好。这里典型的例子是 String.indexOf() 和相关的 API,Dalvik 用内联的内在代替。类似地,System.arraycopy() 方法比带有 JIT 的 Nexus One 上的手动编码循环快约 9 倍。

小心使用原生方法

使用 Android NDK 的原生代码开发应用,不一定比用 Java 语言开发的更高效。一方面,Java 和 原生之间传递有损耗,JIT 不会跨越这些边界优化。如果你分配了原生资源(原生堆上的内存,文件描述符,或其他内容),安排及时收集这些资源可能要困难得多。你还需要为要运行的每个体系结构编译代码(而不是依赖于具有 JIT 的体系结构)。你可能甚至需要为相同的架构编译多个版本:为 G1 中的 ARM 处理器编译的原生代码无法充分利用 Nexus One 中的 ARM,以及为 Nexus One 中的 ARM 编译的代码不会在 G1 中的 ARM 上运行。

性能神话

在没有 JIT 的设备上,通过具有精确类型而不是接口的变量调用方法确实更有效。(因此例如,调用 HashMap 映射上的方法比使用 Map 映射更便宜,即使在这两种情况下映射都是 HashMap。)情况并非如此慢 2 倍,实际差异更像是慢了 6%。此外,JIT 使两者有效地难以区分。

在没有 JIT 的设备上,缓存字段访问比重复访问字段快约 20%。使用 JIT,字段访问的成本与本地访问大致相同,因此除非您觉得它使代码更易于阅读,否则这不值得进行优化。(对于 final,static 和 static final 字段也是如此。)

总是测量

在开始优化之前,请确保你遇到需要解决的问题。确保你可以准确衡量现有的绩效,否则你将无法衡量尝试的替代方案的好处。

你可能还会发现 Traceview 对于分析很有用,但重要的是要知道当前会禁用 JIT,这可能会导致它错误地将时间错误归结为 JIT 可能能够赢回的代码。在 Traceview 数据建议进行更改以确保在没有 Traceview 的情况下运行时生成的代码实际运行得更快时,这一点尤其重要。