概述:
本文主要涉及一些结合使用可以提升Android APP整体性能的”微优化”, 但是不太可能导致非常显著的性能影响. 选择正确的算法和数据结构应该总是你的第一选择, 但是超出了本文的讨论范围. 你应该将本文提到的这些技巧作为平时用来提高APP效率的编码习惯. 编写高效的代码有两种基本原则:
l 不要做多余的事.
l 如果可以避免不要申请内存.
当”微优化”一个Android APP时你将会面对的一个最棘手的问题是你的APP肯定会运行在各种不同的Android设备上. 不同版本的VM运行在不同的处理器上并拥有不同的运行速度. 甚至不是可以简单的说”设备X比设备Y快/慢多少倍”, 并产生相应的运行效果. 尤其是在模拟器上测试得出的结果基本不能为在真机上运行提供什么性能上的信息. 设备是否使用JIT(Just In Time compiler)也会产生巨大的差异. 在拥有JIT的设备上运行的非常好的代码并不能保证在没有JIT的设备上顺利运行.
要确保你的APP在各种Android设备中运行良好, 必须确保你的代码在各种level的版本中都可以运行良好, 并积极的优化其性能.
避免创建不必要的对象:
对象的创建从来都不是免费的. 一个使用线程分配池的通用垃圾回收器可以让临时对象的分配变得廉价一些, 但是分配内存总是比不分配要昂贵得多.
随着在APP中分配更多的对象, 你就得实施定期的强制垃圾收集, 会导致用户体验产生小卡顿现象. 并发垃圾处理器在Android 2.3中引入, 但是总是应该避免不必要的工作. 因此应该在不必要的时候避免创建对象实例. 这里有些有帮助的栗子:
l 如果你有个方法返回一个字符串, 并且你知道它的结果将会总是被添加到一个StringBuffer中, 那么你不应该创建一个短期的临时对象, 而是应该直接由方法来添加. (能避免的临时对象最好避免掉)
l 当从一系列的输入数据中提取字符串时, 尝试返回一个原始数据的子字符串, 而不是创建一个拷贝. 你将会创建一个新的String对象, 但是它将会跟数据共享char[].
一个较为激进的想法是将多维数组切片成并行单个的一维数组:
l 一个int类型的数组比一个Integer对象的数组好很多, 但是这也可以推出一个事实,就是两个并行的int数组也远比一个(int, int)对象数组要高效的多. 这同样适用于任何组合或基础类型.
l 如果你需要实现一个容器用来保存(Foo, Bar)对象的元祖, 尝试记住: 两个并列的Foo[]和Bar[]数组通常要远比一个单个的自定义的(Foo, Bar)对象好. 这种情况有个列外, 就是当你设计外部API接口的时候. 这种情况可以做一点妥协来得到一个好的API设计. 但是在内部的代码中, 应该努力的设计高效的代码.
一般来说, 如果可以的话应避免创建短期临时的对象. 更少的对象创建意味着更少的垃圾回收, 这会对用户体验有直接的提升.
更多的使用静态:
如果你不需要访问对象的域, 那么应该使方法变为静态. 调用它将会提升15%~20%的速度. 除此之外这还是一个好的编程习惯, 因为它从方法的名称上就可以告知大家调用这个方法不会引起对象状态的变化.
对常量使用Static Final:
看一下这些在类头部的声明:
static int intVal = 42;
static String strVal = "Hello, world!";
编译器生成一个类初始化方法, 叫做<clinit>, 它会在类第一次被使用的时候执行. 该方法保存值42到intVal, 并从类文件字符串常量表中到处一个引用给strVal. 当这些值稍后被引用的时候, 它们会通过字段查找来访问. 我们可以通过使用”final”关键字来改善这一步骤:
static final int intVal = 42;
static final String strVal = "Hello, world!";
这样的话, 该类就不再需要一个<clinit>方法, 因为常量保存在一个静态域并在dex文件中初始化. 关联intVal的代码将直接使用整型42这个值, 对strVal的访问将会使用一个相对廉价一些的”字符串常量”指令来代替字段查找.
注意: 这种优化的方式只适用于基础类型和String常量, 而不是任意的引用类型. 尽量使用static final常量是一种很好的做法.
避免使用内部的Getter/Setter:
在本地语言中(比如C++), 经常会用到getter方法(I = getCount())来代替直接访问域(I = mCount). 这是C++中的一个很好的习惯并且也经常在C#和Java这种面向对象的语言中使用, 因为编译器可以使用inline访问, 如果需要约束或者调试域访问的话, 你可以在任何时间添加代码.
但是, 这在Android中是一个坏方法. 虚拟方法的调用是很昂贵的, 比实例的域查找要多得多. 在公共接口中使用getter和setter是合理的, 也符合面向对象编程的原则, 但是在类的内部应该总是直接访问域.
在没有JIT的情况下, 直接访问域大概比调用getter快3倍. 在使用JIT的情况下(直接域访问跟访问本地的是一样效率的), 直接域访问大概比访问getter快7倍. 如果你使用了ProGuard, 你就可以两全其美, 因为ProGuard可以为你执行inline访问.
使用增强的for循环语法:
增强for循环(有时也叫”for-each”循环)可以用于实现了Iterable接口的集合和数组. 对于集合, 会分配一个iterator来调用hasNext()和next(). 对于ArrayList, 一个手写的计数循环大概会快3倍(不管有没有JIT), 但是对于其他的集合, 增强的for循环将会完全等同于使用iterator. 对于数组的迭代有几种选择:
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;
}
}
zero()是最慢的, 因为JIT还不能优化通过循环一次获取所有数组的迭代.
one()要快一些, 它将所有的外部数据存入本地变量中, 避免了查询. 只有数组长度提供了性能收益.
two()对于没有JIT的设备是最快的, 在有JIT的设备上跟one()性能相近. 它使用Java1.5引入的增强循环.
所以, 你应该默认情况下使用增强for循环, 但是对于影响关键性能的ArrayList迭代使用手写计数循环.
还可以参考Josh Bloch的 Effective Java, item 46.
考虑使用包代替私有内部类:
看下面这个类的定义:
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)来直接访问其外部类的一个私有方法和一个私有实例. 这么做是合法的, 并且代码也会打印出预期的值”27”.
问题是VM认为直接从Foo$Inner访问Foo的私有成员是非法的, 因为Foo和Foo$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()方法的时候, 内部类代码就会调用这些静态方法. 这是在确定的语言中造成”不可见的”性能损失的具体的栗子. 如果你正在使用的代码在对性能要求较高的地方有这样的代码, 你可以通过声明内部类的域和方法拥有包访问权限而不是私有访问权限. 不幸的是这意味着这些域可以被其它同一包下的类直接访问, 所以, 你不应该在公共API中这样使用.
避免使用浮点型:
根据经验, 在Android设备上, 浮点型大概比整型慢两倍. 在速度方面, float和double在当前的硬件上没什么差别. 在空间上, double却要大两倍. 对于桌面系统来说, 假设空间不是问题, 那么你应该尽量使用double而不是float. 同样, 就算对于整型, 有些处理器拥有硬件乘法器缺没有硬件除法器. 在这种情况下, 整型的除法和模块操作都在软件中执行—如果你正在设计一个哈希表或者做大量的数学运算的时候, 可能需要考虑一下这些.
了解并使用库:
除了常见的更喜欢库的原因之外, 系统还可以随意使用汇编代码替代一些方法, 这可能比最好的JIT产生的代码还要好. 典型的栗子是: String.indexOf()和相关的API, Dalvik会使用固有的inline方法代替它们. 同样, System.arraycopy()方法在拥有JIT的情况下大概比一个手写的循环要快9倍.
小心的使用本地方法:
使用Android NDK开发你的APP并不一定比使用Java直接编写更加高效. 一方面, 跟Java的本地化转变有关, 并且JIT不能跨越这个边界优化. 如果你分配了本地资源(比如本地堆中的内存, 文件描述符或者别的), 那么及时收集这些资源会变得明显更加困难. 你还需要为不同的体系架构编译你的代码(而不能依靠JIT). 你可能不得不为同一个体系结构编译多个版本: 为G1中的ARM处理器不能利用完整的ARM而编译本地代码, 还得因为不在G1中的ARM运行的代码再编译一份代码.
本地代码主要在你已经拥有一份本地代码库并想接入Android的时候最有用, 而不是为了”提升部分使用Java语言编写的APP模块的速度”. 如果想要使用本地代码可以参考JNITIPs.
性能神话:
在没有JIT的设备上, 通过一个确切类型的变量调用方法的确比一个接口稍微高效一些. (所以, 比如, 在HashMap map上调用一个方法比Map map要更加廉价一些, 即使这两种情况下的map都是HashMap) 这并不表示它要慢上两倍; 事实上性能差异仅仅在6%左右. 此外, 由于JIT的存在, 使得两者几乎没什么区别了.
在没有JIT的设备上, 缓存域访问要比直接重复访问一个域快大概20%. 在有JIT的设备上, 域访问的访问跟本地访问一样, 所以除非这可以使得你的代码可读性更好, 否则这并不值得优化. (这对final, static, 和static final域同样有效).
衡量优化的必要性:
在你开始优化之前, 确保你拥有了一个需要解决的问题. 确保你可以准确计算现有的性能, 否则你将无法衡量你所做的工作的收益. 本文锁描述的所有的建议都有一个可以参考的标准, 这些标准的源码可以在code.google.com“dalvik” project中找到.
参考: https://developer.android.com/training/articles/perf-tips.html