许多Android应用开发者都有着丰富的Java开发经验。自从1995年问世以来,Java已经成为一种非常流行的编程语言。虽然一些调查显示,在与其他语言(比如Objective-C或C#)的竞争中,Java已光芒不再,但它们还是不约而同地把Java排为第一流行的语言。当然,随着移动设备的销量超过个人电脑,以及Android平台的成功(2011年12月平均每天激活70万部),Java在今天的市场上扮演着比以往更重要的角色。
移动应用与PC应用在开发上有着明显的差异。如今的便携式设备已经很强大了,但在性能方面还是落后于个人电脑。例如,一些基准测试显示,四核Intel Core i7处理器的运行速度大约是三星Galaxy Tab 10.1中的双核Nvidia Tegra 2处理器的20倍。
注意 基准测试结果不能全信,因为它们往往只测量系统的一部分,不代表典型的使用场景。
本章将介绍一些确保Java应用在Android设备上获得高性能的办法(无论其是否运行于最新版本的Android)。我们先来看看Android是如何来执行代码的,然后再品评几个著名数列代码的优化技巧,包括如何利用最新的Android API。最后再介绍几个提高应用响应速度和高效使用数据库的技巧。
在深入学习之前,你应该意识到代码优化不是应用开发的首要任务。提供良好的用户体验并专注于代码的可维护性,这才是你首要任务。事实上,代码优化应该最后才做,甚至完全可能不用去做。不过,良好的优化可以使程序性能直接达到一个可接受的水平,因而也就无需再重新审视代码中的缺陷并耗费更多的精力去解决它们。
1.1 Android如何执行代码
Android开发者使用Java,不过Android平台不用Java虚拟机(VM)来执行代码 ,而是把应用编译成Dalvik字节码,使用Dalvik虚拟机来执行。Java代码仍然编译成Java字节码,但随后Java字节码会被dex编译器(dx,SDK工具)编译成Dalvik字节码。最终,应用只包含Dalvik字节码,而不是Java字节码。
例如,代码清单1-1是包含类定义的计算斐波那契数列第n项的实现。斐波那契数列的定义如下:
F0 = 0
F1 = 1
Fn = Fn-2 + Fn-1 (n>1)
代码清单1-1 简单的斐波那契数列递归实现
public class Fibonacci {
public static long computeRecursively (int n)
{
if (n > 1) return computeRecursively(n-2) + computeRecursively(n-1);
return n;
}
}
注意 微小优化:当n等于0或1时直接返回n,而不是另加一个if语句来检查n是否等于0或1。
Android应用也称为apk,因为应用被打包成带有apk扩展名(例如,APress.apk)的文件,这是一个简单的压缩文件。classes.dex文件就在这个压缩文件里,它包含了应用的字节码。Android的工具包中有名为dexdump的工具,可以把classes.dex中的二进制代码转化为使人易读的格式。
提示 apk文件只是个简单的ZIP压缩文件,可以使用常见的压缩工具(如WinZip或7-Zip)来查看apk文件的内容。
代码清单1-2显示了对应的Dalvik字节码。
代码清单1-2 Fibonacci.computeRecursively的Dalvik字节码的可读格式
002548: |[002548] com.apress.proandroid.Fibonacci.computeRecursively:(I)J
002558: 1212 |0000: const/4 v2, #int 1 // #1
00255a: 3724 1100 |0001: if-le v4, v2, 0012 // +0011
00255e: 1220 |0003: const/4 v0, #int 2 // #2
002560: 9100 0400 |0004: sub-int v0, v4, v0
002564: 7110 3d00 0000 |0006: invoke-static {v0},
Lcom/apress/proandroid/Fibonacci;.computeRecursively:(I)J
00256a: 0b00 |0009: move-result-wide v0
00256c: 9102 0402 |000a: sub-int v2, v4, v2
002570: 7110 3d00 0200 |000c: invoke-static {v2},
Lcom/apress/proandroid/Fibonacci;.computeRecursively:(I)J
002576: 0b02 |000f: move-result-wide v2
002578: bb20 |0010: add-long/2addr v0, v2
00257a: 1000 |0011: return-wide v0
00257c: 8140 |0012: int-to-long v0, v4
00257e: 28fe |0013: goto 0011 // -0002
在“|”左边的本地代码中,除了第一行(用于显示方法名),每行冒号右边是一个或多个16位的字节码单元 ,冒号左边的数字指定了它们在文件中的绝对位置。“|”右边的可读格式中,冒号左边是绝对位置转换为方法内的相对位置或标签,冒号右边是操作码助记符及后面不定个数的参数。例如,地址0x00255a的两字节码组合3724 1100翻译为if-le v4, v2, 0012 // +0011,意思是说“如果虚拟寄存器v4的值小于等于虚拟寄存器v2的值,就跳转到标签0x0012,相当于跳过17(十六进制的11)个字节码单元”。术语“虚拟寄存器”是指实际上非真实的硬件寄存器,也就是Dalvik虚拟机使用的寄存器。
通常情况下,你不必看应用的字节码。在平台是Android 2.2(代号Froyo)和更高版本的情况下尤其如此,因为在Android 2.2中引入了实时(JIT)编译器。Dalvik JIT编译器把Dalvik字节码编译成本地代码,这可以明显加快执行速度。JIT编译器(有时简称JIT)可以显著提高性能,因为:
本地代码直接由CPU执行,而不必由虚拟机解释执行;
本地代码可以为特定架构予以优化。
谷歌的基准测试显示,Android 2.2的代码执行速度比Android 2.1快2到5倍。虽说代码的具体功能会对结果产生很大影响,但可以肯定的是,使用Android 2.2及更高版本会显著提升速度。
对于无JIT的Android 2.1或更早的版本而言,优化策略的选用可能会因此受到很大影响。如果打算针对运行Android 1.5(代号Cupcake)、1.6(代号Donut),或2.1(代号éclair)的设备开发,你要先仔细地审查应用在这些环境下需要提供哪些功能。此外,这些运行Android早期版本的旧设备是没新设备强劲的。尽管运行Android 2.1和更早版本的设备所占的市场份额正在萎缩,但直到2011年12月,其数量仍占大约12%。可选的策略有3条:
不予优化,因为应用在这些旧设备上运行得相当缓慢;
限制应用中Android API等级为最低8级,让它只能安装在Android 2.2或更高版本上;
即使没有JIT编译器,也要针对旧设备优化,给用户以舒畅的体验。也就是说禁掉那些非常耗CPU资源的功能。
提示 在应用的manifest配置里可以用Android:vmSafeMode启用或禁用JIT编译器。默认是启用的(如果平台有JIT)。这个属性是Android 2.2引入的。
现在可以在真实平台上运行代码了,看看它是如何执行的。如果你熟悉递归和斐波那契数列,可能已经猜到,这段代码运行得不会很快。没错!在三星Galaxy Tab 10.1上,计算第30项斐波那契数列花了约370毫秒。禁用JIT编译器之后需要大约440毫秒。如果把这个功能加到计算器程序里,用户会感觉难以忍受,因为结果不能“马上”计算出来。从用户的角度来看,如果可以在100毫秒或更短的时间内计算完成,那就是瞬时计算。这样的响应时间保证了顺畅的用户体验,这才是我们要达到的目标。
1.2 优化斐波那契数列
我们要做的首次优化是消除一个方法调用,如代码清单1-3所示。由于这是递归实现,去掉方法中的一个调用就会大大减少调用的总数。例如,computeRecursively(30)产生了2 692 537次调用,而computeRecursivelyWithLoop(30)产生的调用“只有”1 346 269次。然而,这样优化过的性能还是无法接受,因为前面我们把响应时间的标准定为100毫秒或者更少,而computeRecursivelyWithLoop(30)却花了270毫秒。
代码清单1-3 优化递归实现斐波那契数列
public class Fibonacci {
public static long computeRecursivelyWithLoop (int n)
{
if (n > 1) {
long result = 1;
do {
result += computeRecursivelyWithLoop(n-2);
n--;
} while (n > 1);
return result;
}
return n;
}
}
注意 这不是一个真正的尾递归优化。
1.2.1 从递归到迭代
第二次优化会换成迭代实现。递归算法在开发者当中的名声不太好,尤其是在没多少内存可用的嵌入式系统开发者中,主要是因为递归算法往往要消耗大量栈空间。正如我们刚才看到的,它产生了过多的方法调用。即使性能尚可,递归算法也有可能导致栈溢出,让应用崩溃。因此应尽量用迭代实现。代码清单1-4 是斐波那契数列的迭代实现。
代码清单1-4 斐波那契数列的迭代实现
public class Fibonacci {
public static long computeIteratively (int n)
{
if (n > 1) {
long a = 0, b = 1;
do {
long tmp = b;
b += a;
a = tmp;
} while (--n > 1);
return b;
}
return n;
}
}
由于斐波那契数列的第n项其实就是前两项之和,所以一个简单的循环就可以搞定。与递归算法相比,这种迭代算法的复杂性也大大降低,因为它是线性的。其性能也更好,computeIteratively(30)花了不到1毫秒。由于其线性特性,你可以用这种算法来计算大于30的项。例如,computeIteratively(50000),只要2毫秒就能返回结果。根据这个推测,你应该能猜出computeIteratively(500000)大概会花20至30毫秒。
虽然这样已经达标了,但相同的算法稍加修改后还可以更快,如代码清单1-5所示。这个新版本每次迭代计算两项,迭代总数少了一半。因为原算法的迭代次数可能是奇数,所以a和b的初始值要做相应的修改:该数列开始时如果n是奇数,则a= 0,b = 1;如果n是偶数,则a = 1, b= 1(Fib(2)= 1)。
代码清单1-5 修改后的斐波那契数列的迭代实现
public class Fibonacci {
public static long computeIterativelyFaster (int n)
{
if (n > 1) {
long a, b = 1;
n--;
a = n & 1;
n /= 2;
while (n-- > 0) {
a += b;
b += a;
}
return b;
}
return n;
}
}
结果表明此次修改的迭代版本速度比旧版本快了一倍。
虽然这些迭代实现速度很快,但它们有个大问题:不会返回正确结果。问题在于返回值是long型,它只有64位。在有符号的64位值范围内,可容纳的最大的斐波那契数是7 540 113 804 746 346 429,或者说是斐波那契数列第92项。虽然这些方法在计算超过92项时没有让应用崩溃,但是因为出现溢出,结果却是错误的,斐波那契数列第93项会变成负的!递归实现实际上有同样的限制,但得耐心等待才能得到最终的结论。
注意 在Java所有基本类型(boolean除外)中,long是64位、int是32位、short是16位。所有整数类型都是有符号的。
1.2.2 BigInteger
Java提供了恰当的类来解决这个溢出问题:java.math.BigInteger。BigInteger对象可以容纳任意大小的有符号整数,类定义了所有基本的数学运算(除了一些不太常用的)。代码清单1-6是computeIterativelyFaster的BigInteger版本。
提示 java.math包除了BigInteger还定义了BigDecimal,而java.lang.Math提供了数学常数和运算函数。如果应用不需要双精度(double precision),使用Android的FloatMath性能会更好(虽然不同平台的效果不同)。
代码清单1-6 BigInteger版本的Fibonacci.computeIterativelyFaster
public class Fibonacci {
public static BigInteger computeIterativelyFasterUsingBigInteger (int n)
{
if (n > 1) {
BigInteger a, b = BigInteger.ONE;
n--;
a = BigInteger.valueOf(n & 1);
n /= 2;
while (n-- > 0) {
a = a.add(b);
b = b.add(a);
}
return b;
}
return (n == 0) ? BigInteger.ZERO : BigInteger.ONE;
}
}
这个实现保证正确,不再会溢出。但它又出现了新问题,速度再一次降了下来,变得相当慢:计算computeIterativelyFasterUsingBigInteger(50000)花了1.3秒。表现平平的原因有以下三点:
BigInteger是不可变的;
BigInteger使用BigInt和本地代码实现;
数字越大,相加运算花费的时间也越长。
由于BigInteger是不可变的,我们必须写a = a.add(b),而不是简单地用a.add(b),很多人误以为a.add(b)相当于a += b, 但实际上它等价于a + b。因此,我们必须写成a = a.add(b),把结果值赋给a。这里有个小细节是非常重要的:a.add(b)会创建一个新的BigInteger对象来持有额外的值。
由于目前BigInteger的内部实现,每分配一个BigInteger对象就会另外创建一个BigInt对象。在执行computeIterativelyFasterUsingBigInteger过程中,要分配两倍的对象:调用computeIterativelyFasterUsingBigInteger(50000)时约创建了100 000个对象(除了其中的1个对象外,其他所有对象立刻变成等待回收的垃圾)。此外,BigInt使用本地代码,而从Java使用JNI调用本地代码会产生一定的开销。
第三个原因是指非常大的数字不适合放在一个64位long型值中。例如,第50 000个斐波那契数为347 111位长。
注意 未来Android版本的BigInteger内部实现(BigInteger.java)可能会改变。事实上,任何类的内部实现都有可能改变。
基于性能方面的考虑,在代码的关键路径上要尽可能避免内存分配。无奈的是,有些情况下分配是不可避免的。例如,使用不可变对象(如BigInteger)。下一种优化方式则侧重于通过改进算法来减少分配数量。基于斐波那契Q-矩阵,我们有以下公式:
F2n-1 = Fn2 + Fn-12
F2n = (2Fn-1 + Fn) * Fn
这可以用BigInteger实现(保证正确的结果),如代码清单1-7所示。
代码清单1-7 斐波那契数列使用BigInteger的快速归实现
public class Fibonacci {
public static BigInteger computeRecursivelyFasterUsingBigInteger (int n)
{
if (n > 1) {
int m = (n / 2) + (n & 1); // 较为晦涩,是否该有个更好的注释?
BigInteger fM = computeRecursivelyFasterUsingBigInteger(m);
BigInteger fM_1 = computeRecursivelyFasterUsingBigInteger(m - 1);
if ((n & 1) == 1) {
// F(m)^2 + F(m-1)^2
return fM.pow(2).add(fM_1.pow(2)); // 创建了3个BigInteger对象
} else {
// (2*F(m-1) + F(m)) * F(m)
return fM_1.shiftLeft(1).add(fM).multiply(fM); // 创建了3个对象
}
}
return (n == 0) ? BigInteger.ZERO : BigInteger.ONE; // 没有创建BigInteger
}
public static long computeRecursivelyFasterUsingBigIntegerAllocations(int n) {
long allocations = 0;
if (n > 1) {
int m = (n / 2) + (n & 1);
allocations += computeRecursivelyFasterUsingBigIntegerAllocations(m);
allocations += computeRecursivelyFasterUsingBigIntegerAllocations(m - 1);
// 创建的BigInteger对象多于3个
allocations += 3;
}
return allocations; // 当调用computeRecursivelyFasterUsingBigInteger(n)时,创建BigInteger
对象的近似数目
}
}
调用computeRecursivelyFasterUsingBigInteger(50000)花费了1.6秒左右,这表明最新实现实际上是慢于已有的最快迭代实现。拖慢速度的罪魁祸首是要分配大约200 000个对象(几乎立即标记为等待回收的垃圾)。
注意 实际分配数量比computeRecursivelyFasterUsingBigIntegerAllocations 返回的估算值少。因为BigInteger的实现使用了预分配对象,BigInteger.ZERO、BigInteger.ONE或BigInteger.TEN,有些运算没必要分配一个新对象。这需要在Android的BigInteger实现一探究竟,看看它到底创建了多少个对象。
尽管这个实现慢了点,但它毕竟是朝正确的方向迈出了一步。值得注意的是,即使我们需要使用BigInteger确保正确性,也不必用BigInteger计算所有n的值。既然基本类型long可容纳小于等于92项的结果,我们可以稍微修改递归实现,混合BigInteger和基本类型,如代码清单1-8所示。
代码清单1-8 斐波那契数列使用BigInteger和基本类型long的快速递归实现
public class Fibonacci {
public static BigInteger computeRecursivelyFasterUsingBigIntegerAndPrimitive(int n)
{
if (n > 92) {
int m = (n / 2) + (n & 1);
BigInteger fM = computeRecursivelyFasterUsingBigIntegerAndPrimitive(m);
BigInteger fM_1 = computeRecursivelyFasterUsingBigIntegerAndPrimitive(m - 1);
if ((n & 1) == 1) {
return fM.pow(2).add(fM_1.pow(2));
} else {
return fM_1.shiftLeft(1).add(fM).multiply(fM); // shiftLeft(1)乘以2
}
}
return BigInteger.valueOf(computeIterativelyFaster(n));
}
private static long computeIterativelyFaster(int n)
{
// 见代码清单 1–5 实现
}
}
调用computeRecursivelyFasterUsingBigIntegerAndPrimitive(50000)花了约73毫秒,创建了约11 000个对象:略微修改下算法,速度就快了约20倍,创建对象数则仅是原来的1/20,很惊人吧!通过减少创建对象的数量,进一步改善性能是可行的,如代码清单1-9所示。Fibonacci类首次加载时,先快速生成预先计算的结果,这些结果以后就可以直接使用。
代码清单1-9 斐波那契数列使用BigInteger和预先计算结果的快速递归实现
public class Fibonacci {
static final int PRECOMPUTED_SIZE= 512;
static BigInteger PRECOMPUTED[] = new BigInteger[PRECOMPUTED_SIZE];
static {
PRECOMPUTED[0] = BigInteger.ZERO;
PRECOMPUTED[1] = BigInteger.ONE;
for (int i = 2; i < PRECOMPUTED_SIZE; i++) {
PRECOMPUTED[i] = PRECOMPUTED[i-1].add(PRECOMPUTED[i-2]);
}
}
public static BigInteger computeRecursivelyFasterUsingBigIntegerAndTable(int n)
{
if (n > PRECOMPUTED_SIZE - 1) {
int m = (n / 2) + (n & 1);
BigInteger fM = computeRecursivelyFasterUsingBigIntegerAndTable (m);
BigInteger fM_1 = computeRecursivelyFasterUsingBigIntegerAndTable (m - 1);
if ((n & 1) == 1) {
return fM.pow(2).add(fM_1.pow(2));
} else {
return fM_1.shiftLeft(1).add(fM).multiply(fM);
}
}
return PRECOMPUTED[n];
}
}
这个实现的性能取决于PRECOMPUTED_SIZE:更大就更快。然而,内存使用量可能会成为新问题。由于许多BigInteger对象创建后保留在内存中,只要加载了Fibonacci类,它们就会占用内存。我们可以合并代码清单1-8和代码清单1-9的实现,联合使用预计算和基本类型。例如,0至92项可以使用computeIterativelyFaster,93至127项使用预先计算结果,其他项使用递归计算。作为开发人员,你有责任选用最恰当的实现,它不一定是最快的。你要权衡各种因素:
应用是针对哪些设备和Android版本;
资源(人力和时间)。
你可能已经注意到,优化往往使源代码更难于阅读、理解和维护,有时几个星期或几个月后你都认不出自己的代码了。出于这个原因,关键是要仔细想好,你真正需要怎样的优化以及这些优化究竟会对开发产生何种影响(短期或长期的)。强烈建议你先实现一个能运行的解决方案,然后再考虑优化 (注意备份之前能运行的版本)。最终,你可能会意识到优化是不必要的,这就节省了很多时间。另外,有些代码不易被水平一般的人所理解,注意加上注释,同事会因此感激你。另外,当你在被旧代码搞蒙时,注释也能勾起你的回忆。我在代码清单1-7中的少量注释就是例证。
注意 所有实现忽略了一个事实——n可以是负数。我是特意这样做的。不过,你的代码(至少在所有的公共API中)应该在适当时抛出IllegalArgumentException异常。
1.3 缓存结果
如果计算代价过高,最好把过去的结果缓存起来,下次就可以很快取出来。使用缓存很简单,通常可以转化为代码清单1-10所示的伪代码。
代码清单1-10 使用缓存
result = cache.get(n); // 输入参数n作为键
if (result == null) {
// 如果在缓存中没有result值,就计算出来填进去
result = computeResult(n);
cache.put(n, result); // n作为键,result是值
}
return result;
快速递归算法计算斐波那契项包含许多重复计算,可以通过将函数计算结果缓存 (memoization)起来的方法来减少这些重复计算。例如,计算第50 000项时需要计算第25 000和第24 999项。计算25 000项时需要计算第12 500项和第12 499项,而计算第24 999项还需要12 500项与12 499项!代码清单1-11是个更好的实现,它使用了缓存。
如果你熟悉Java,你可能打算使用一个HashMap充当缓存,它可以胜任这项工作。不过,Android定义了SparseArray类,当键是整数时,它比HashMap效率更高。因为HashMap使用的是java.lang.Integer 对象,而SparseArray使用的是基本类型int。因此使用HashMap会创建很多Integer对象,而使用SparseArray则可以避免。
代码清单1-11 使用BigInteger的快速递归实现,用了基本类型long和缓存
public class Fibonacci {
public static BigInteger computeRecursivelyWithCache (int n) {
SparseArray
cache = new SparseArray (); return computeRecursivelyWithCache(n, cache);
}
private static BigInteger computeRecursivelyWithCache (int n, SparseArray
cache) { if (n > 92) {
BigInteger fN = cache.get(n);
if (fN == null) {
int m = (n / 2) + (n & 1);
BigInteger fM = computeRecursivelyWithCache(m, cache);
BigInteger fM_1 = computeRecursivelyWithCache(m – 1, cache);
if ((n & 1) == 1) {
fN = fM.pow(2).add(fM_1.pow(2));
} else {
fN = fM_1.shiftLeft(1).add(fM).multiply(fM);
}
cache.put(n, fN);
}
return fN;
}
return BigInteger.valueOf(iterativeFaster(n));
}
private static long iterativeFaster (int n) {
//见代码清单1-5的实现
}
}
测量结果表明,computeRecursivelyWithCache(50000)用了约20毫秒,或者说比computeRecursivelyFasterUsingBigIntegerAndPrimitive(50000)快了约50毫秒。显然,差异随着n的增长而加剧,当n等于200 000时两种方法分别用时50毫秒和330毫秒。
因为创建了非常少的BigInteger对象,BigInteger的不可变性在使用缓存时就不是什么大问题了。但请记住,当计算Fn时仍创建了三个BigInteger对象(其中两个存在时间很短),所以使用可变大整数仍会提高性能。
尽管使用HashMap代替SparseArray会慢一些,但这样的好处是可以让代码不依赖Android,也就是说,你可以在非Android的环境(无SparseArray)使用完全相同的代码。
注意 Android定义了多种类型的稀疏数组(sparse array):SparseArray(键为整数,值为对象)、SparseBooleanArray(键为整数,值为boolean)和SparseIntArray(键为整数,值为整数)。
1.3.1 android.util.LruCache
值得一提的另一个类是android.util.LruCache
,这个类是Android 3.1(代号 Honeycomb MR1)引入的,可以在创建时定义缓存的最大长度。另外,还可以通过覆写sizeof()方法改变每个缓存条目计算大小的方式。因为android.util.LruCache只能在Android 3.1及更高版本上使用,如果针对版本低于3.1的Android设备,则仍然必须使用不同的类来实现自己的应用缓存。由于目前的Android 3.1设备占有率不高,这种情况很有可能出现。替代方案是继承java.util.LinkedHashMap覆写removeEldestEntry。LRU(Least Recently Used)缓存先丢弃最近最少使用的项目。在某些应用中,可能需要完全相反的策略,即丢弃缓存中最近最多使用的项目。Android现在没有这种MruCache类,考虑到MRU缓存不常用,这并不奇怪。 当然,缓存是用来存储信息而不是计算结果的。通常,缓存被用来存储下载数据(如图片),但仍需严格控制使用量。例如,覆写LruCache的的sizeof方法不能简单地以限制缓存中的条目数为准则。尽管这里简要地讨论了LRU和MRU策略,你仍可以在缓存中使用不同的替代策略,最大限度地提高缓存命中率。例如,缓存可以先丢弃那些重建开销很小的项目,或者干脆随机丢弃项目。请以务实的态度设计缓存。简单的替换策略(如LRU)可以产生很好的效果,把手上资源留给更重要的问题。
我们用了几个不同的技术优化斐波那契数列的计算。虽然每种技术都有其优点,却没有一个实现是最佳选择。往往最好的结果是结合多种不同的技术,而不是只依赖于其中之一。例如,更快的实现可以使用预计算、缓存机制,甚至采用不同的公式。(提示:当n是4的倍数,会发生什么?)怎样在100毫秒内计算出FInteger.MAX_VALUE?
1.4 API等级
上述LruCache类是一个很好的例子,它让你知道需要了解目标平台的API等级。Android大约每6个月发布一个新版本,随之发布的新API也只适用于该版本。试图调用不存在的API将导致崩溃,不仅会让用户失望,开发者也会感到羞愧。例如,在Android 1.5设备上调用Log.wtf(TAG,"really?")会使应用崩溃,因为Log.wtf是Android 2.2(API等级8)引入的,这是个可怕的错误。表1-1 列出了不同Android版本的性能改进情况。
表1-1 Android版本
API等级 版 本 代 号 重大的性能改进
1 1.0 Base
2 1.1 Base 1.1
3 1.5 Cupcake 相机启动时间、图像采集时间、更快的GPS定位、支持NDK
4 1.6 Donut
5 2.0 éclair 图形
6 2.0.1 éclair 0.1
7 2.1 éclair MR1
8 2.2 Froyo V8 Javascript引擎(浏览器)、JIT编译器、内存管理
9 2.3.0、2.3.1、2.3.2 Gingerbread 并发垃圾收集器、事件分布、更好的OpenGL驱动程序
10 2.3.3 2.3.4 Gingerbread MR1
11 3.0 Honeycomb Renderscript、动画、2D图形硬件加速、多核支持
12 3.1 Honeycomb MR1 LruCache、废弃部分硬件加速的view、新Bitmap.setHasAlpha() API
13 3.2 Honeycomb MR2
14 4.0 Ice Cream Sandwich Media效果(变换滤镜),2D图形硬件加速(必需)
不过,支持某种目标设备的决策依据通常并不是要使用的API,而是在于打算进入什么样的市场。例如,如果你的目标主要是平板电脑,而不是手机,就可以只考虑Honeycomb。这样做,会限制应用只能有一小部分Android用户,因为Honeycomb截至2011年12月的占有率只有2.4%左右,而且并不是所有平板电脑都支持Honeycomb。(例如,Barnes & Noble的Nook采用的是Android 2.2,而Amazon的Kindle Fire使用的是Android 2.3。)因此,支持较旧的Android版本仍有意义。
Android团队知道这个问题,它们发布了Android兼容包,可以通过SDK更新。这个软件包有一个静态库,包含了一些Android 3.0引入的新API,也就是分化的API。然而,此兼容包只包含Honeycomb分化的API,不支持其他API。这样的兼容包是例外情况,而不是通则。通常情况下,在特定的API等级引入的API是不可以在较低API 等级上用的,开发人员要认真考虑API等级。
你可以使用Build.VERSION.SDK_INT获得Android平台的API等级。具有讽刺意味的是,这个字段是在Android 1.6(API等级4)引入的,所以试图获取版本号,也会导致程序在Android 1.5或更早版本下崩溃。另一种选择是使用Build.VERSION.SDK,它是API等级1引入的。但这一字段现在已经废弃,版本字符串也没有归档(虽然很容易理解它们是如何被创建的)。
提示 可以用反射来检查是否存在SDK_INT字段(也就是判断该平台是不是Android 1.6或更高版本)。参见Class.forName("Android.os.Build$VERSION").getField("SDK")。
应用中的manifest清单文件应使用
元素指定以下两个重要的信息: 应用所需的最低API等级(android:minSdkVersion);
应用期望的API等级(android:targetSdkVersion)。
也可以限制最高的API等级(android:maxSdkVersion),但不推荐使用此属性。指定maxSdkVersion可能导致Android更新后,应用自动被卸载。 所针对平台的API等级应是你的应用实际测试通过的等级。
默认情况下,最小的API等级设置为1,即应用兼容所有Android版本。指定API等级大于1可防止应用安装到旧设备上。例如,Android minSdkVersion ="4"可确保使用Build.VERSION.SDK_INT没有任何崩溃的风险。最低API等级并不是一定要指定为应用可以用的最高API等级,只要确保要调用的特定API确实存在即可,如代码清单1-12所示。
代码清单1-12 调用Honeycomb 中的SparseArray方法(API等级11)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
sparseArray.removeAt(1); // API等级11及以上
} else {
int key = sparseArray.keyAt(1); // 默认实现慢一些
sparseArray.remove(key);
}
这类代码很常用,它既可以使用最适当的API来获取最好性能,也可以在旧的平台上(可能使用了较慢的API)正常运行。
Android也使用这些属性做其他的事情,比如决定应用是否应在屏幕兼容模式运行。如果minSdkVersion设置为3或更低,targetSdkVersion并没有设置为4或更高,应用将在屏幕兼容模式运行。这样应用就不能在平板电脑上全屏显示,会使它很难使用。平板电脑只是最近才流行开来,很多应用还没有为它更新,所以发现程序在大屏幕上显示不正确并不鲜见。
注意 Android Market使用minSdkVersion和maxSdkVersion属性来筛选可供特定设备下载安装的应用,其他属性也同样可以用来筛选。此外,Android定义了两个版本的屏幕兼容模式,它们的行为有所不同。请参阅http://d.android.com/guide页面中“Supporting Multiple Screens”中的完整描述。
如果不想用类似代码清单1-12所示的代码来检查版本号,也可以直接使用反射(reflection)来确认平台上是否有特定方法。虽然这是一种更清晰、更安全的实现,不过反射会使代码变慢,因此,在性能至关重要的地方应尽量避免使用反射。替代的办法是在静态初始化块里调用Class.forName()和Class.getMethod()确认指定方法是否存在,在性能要求高的地方只调用Method.invoke()就好了。
1.4.1 版本分化
Android的许多版本(到目前为止最高API等级为14 )割裂了目标市场,这使得出现了越来越多的类似代码清单1-12的代码。不过在现实中,主流设备上运行的Android版本并不多。截至2011年12月,连接到Android Market的设备中,2.x版本超过95%。尽管运行Android 1.6及更早版本的设备也还有,不过不优化它们以节约资源也是合情合理的。
运行Android的设备数量越来越多, 列出目前接近200个移动电话型号 ,其中美国就有80个。虽然列出的设备都是手机或平板电脑,它们仍然在许多方面不同:屏幕分辨率、实体物理键盘、硬件图形加速器以及处理器。支持各种配置,甚至只支持其中一个子集,对应用开发来说也是挑战。因此,要充分了解目标市场的状况,以便把努力的重点放在重要的功能和优化上。
注意 并没有列出所有的Android设备,甚至忽略了一些国家,例如印度及其使用的运行Android 2.2的双卡Spice MI270。
谷歌电视设备(2010年首次在美国由罗技和索尼公司发布)的技术与手机或平板电脑没什么大的不同,主要是在于人机交互方式的差异。如果要支持这些电视设备,主要难点之一要了解应用在电视上的使用方式。例如,应用可以在电视上提供更多的社交体验,游戏可以支持同时多人模式,而这功能对手机来说却没有多大用处。
1.5 数据结构
不同的斐波那契数列实现证明,好的算法和数据结构是实现快速应用的关键。Android和Java定义了许多数据结构,你需要有丰富的知识,快速地为任务选择适用的数据结构。选择适当的数据结构是最先要解决的事项。
java.util包中最常见的数据结构,如图1-1所示。
图1-1 java.util包中的数据结构
Android在这些数据结构中增加了自身的一些实现,一般是为了解决性能问题。
LruCache
SparseArray
SparseBooleanArray
SparseIntArray
Pair
注意 Java还定义了Arrays类和Collections类。这两个类只包含静态方法,分别操作数组和集合。例如,使用Arrays.sort对数组排序,使用Arrays.binarySearch在有序数组中搜索值。
虽然上述斐波那契数列实现有一个用了内部缓存(基于稀疏数组),但是缓存只是暂时的,计算出最终结果后立即变成待回收的垃圾。你也可以使用LruCache保存最终结果,如代码清单1-13所示。
代码清单1-13 使用LruCache存储斐波那契数列项
int maxSize = 4 * 8 * 1024 * 1024; // 32 兆位
LruCache
cache = new LruCache (maxSize) { protected int sizeOf (Integer key, BigInteger value) {
return value.bitLength(); // 和对象占用的位数接近
}
};
…
int n = 100;
BigInteger fN = cache.get(n);
if (fN == null) {
fN = Fibonacci. computeRecursivelyWithCache(n);
cache.put(n, fN);
}
每当需要选择一个数据结构来解决问题时,最好将选择范围缩小到只有几个类,因为通常每个类为特定目的或为提供特定的服务而优化。例如,如果你不需要在数据结构内部处理同步,应该选择ArrayList而不是Vector。 当然,你完全可以创建自己的数据结构类,无论是从零开始(继承Object),还是继承现有的类。
注意 在代码清单1-11所示的computeRecursivelyWithCache中,你能解释LruCache为什么不是内部缓存的好选择吗?
如果你使用基于散列的数据结构(例如HashMap),而且键是自定义的对象,确保你正确地覆写了类定义中的equal和hashCode方法。hashCode的低劣实现可以轻易将散列的收益化为乌有。
提示 请参考http://d.android.com/reference/java/lang/Object.html,那里有实现hashCode()的一个很好的示例。
尽管对于许多嵌入式程序开发者来说是不自然的,但也要考虑在程序中将一种数据结构转换成另一种:在某些情况下,因为使用了更好的算法,这种转换得到的性能提升远大于转换开销。一个常见的例子是将集合转为数组,可能是有序的。这种转换要创建新的对象,显然需要内存。在内存受限的设备上,这样的内存分配可能并不总会成功,会抛出一个OutOfMemoryError异常。Java语言规范说了两件事:
Error类及其子类是普通程序中抛出的异常,它们通常是不期望能够恢复的。
精密的程序可能希望抓住这异常并试图从错误异常中恢复。
如果内存分配仅是优化的一部分,作为一个老练的应用开发者,你可以提供一个备用的机制(例如,使用原始数据结构的算法,尽管速度较慢),然后捕获OutOfMemoryError异常,这样做是有价值的,它可以让你程序跑在更多的设备上。这种可选的优化让代码难以维护,但给了你一个更大的腾挪余地。
注意 与直觉相反,并非所有异常都是Exception的子类。所有异常都是Throwable的子类(只有Exception和Error是它的直接子类)。
一般情况下,你应该很熟悉java.util和android.util包,几乎所有的组件依赖这两个工具箱。每当新的Android版本发布,你都应该特别注意这些包的修改(添加和修改的类)并参考“API的变化报告”,详见http://d.android.com/sdk。 我们将在第5章讨论java.util.concurrent中更多的数据结构。
1.6 响应能力
应用的性能不仅仅在乎于速度,也要能让用户真正感觉到快才行。例如,显得更快的方法有,应用可以延迟创建对象,直到需要时才创建,称为推迟初始化的技术。另外,在开发过程中,你很有可能要在关注性能的地方侦测执行缓慢的代码。
下面的类是大多数Android Java应用的基石:
Application
Activity
Service
ContentProvider
BroadcastReceiver
Fragment(Android 3.0 及以上)
View
在这些类中要特别注意的是所有的onSomething()方法,它们由主线程调用,比如onStart()和onFocusChanged()。主线程也称为UI线程,可以说应用就在其中运行。在主线程中运行所有代码是可以做到的,但不推荐这样做。主线程里包括如下要处理的事情:
按键事件接收(例如,View.onKeyDown() 和Activity.onKeyLongPress());
绘制View(View.onDraw());
产生生命周期事件(例如,Activity.onCreate())。
注意 许多方法都被设计为由主线程予以调用。当你覆写方法时,要确认它是如何将被调用的。Android的文档并不没有明确说明方法是否该从主线程调用。
在一般情况下,无论事件从系统本身还是从用户处发生,主线程都在不断接收正在发生的事件的通知。应用只有一个主线程,因此,所有的事件都按顺序处理。很显然,这样一来响应能力会受到负面影响:在队列中的第一个事件处理完之前,后面的事件都是挂起的,一次只能处理一个。如果前面事件的处理需要很长时间才能完成,那么后续其他事件需要等很长时间才轮到处理。
一个简单的例子是从主线程调用computeRecursivelyWithCache。虽然当n很小时还算快,但随着n的增长调用会变得越来越慢。当n值非常大时,你肯定会碰到Android上臭名昭著的应用没有响应(Application Not Responding,ANR)对话框。如果Android检测到应用没有响应,也就是说当Android检测到输入事件在5秒钟内没有被处理,或者BroadcastReceiver在10秒内没有执行完毕,这个对话框就会跳出来。当发生这种情况,用户的可选项只有“等待”或“强制关闭”应用(这可能会导致你的应用迈向被卸载的结局)。
为所有Activity优化启动序列是非常重要的,包括以下调用:
onCreate
onStart
onResume
当然,这个序列发生在创建Activity时,实际上它发生的可能比你想象的更频繁。当配置发生变化时,当前Activity被销毁,并创建一个新实例,会调用以下序列:
onPause
onStop
onDestroy
onCreate
onStart
onResume
这个序列完成得很快,用户也会很快再次使用你的应用。最常见的配置变化之一是,设备的方向发生旋转。
注意 应用可以在manifest文件里指定每个Activity元素的Android:configChanges属性,让它只接受自己想处理的配置变化。这会导致调用Activity的onConfigurationChanged(),而不是销毁。
Activity的onCreate() 方法一般会包含调用setContentView或任何其他负责展开资源的方法。因为展开资源是一个开销相对较大的操作,所以你可以通过降低布局(Layout,XML文件定义应用的外观)复杂性来使资源展开加快。几个降低布局复杂性的步骤如下。
使用RelativeLayout代替嵌套LinearLayouts,尽可能保持“扁平化”的布局。此外,减少创建的对象数量,也会让事件的处理速度加快。
使用ViewStub推迟对象创建(见1.6.1节)。
注意 因为可能有许多项目在列表中,所以要特别注意ListView布局。你可以使用SDK的layoutopt工具来分析布局。
优化的基本原则是保持应用的持续响应,让主线程尽可能快地完成任务。这句话也常有另一个说法,在主线程当中尽可能少做事情。在大多数情况下,你可以通过把操作转移到另一个线程或推迟操作来加快应用响应速度,这两种技术通常并不会使代码更难维护。把任务转移到另一个线程之前,一定要确保你已充分了解任务执行太慢的原因。如果响应慢的原因是坏的算法或实现,就去修改它们,将任务转移到另一个线程只是欲盖弥彰而已。
1.6.1 推迟初始化
拖延还是有其可取之处。通常的做法是在组件的onCreate()方法中执行所有初始化。虽然这样做可行,但这意味着onCreate()需要较长的时间才能结束。这一点对应用的Activity尤为重要,onStart()直到onCreate()完成后才会被调用(同样,onResume()只有在onStart()完成后才会被调用),任何延迟都会导致应用需要较长时间才能启动,用户最终可能会感到难以忍受。
例如,Android使用android.view.ViewStub来推迟初始化,它可以在运行时展开资源。当View- Stub需要展现时,它被相应的资源展开替换,自己就成为等待垃圾回收的对象。
由于内存分配需要花时间,等到对象真正需要时才进行分配,也是一个很好的选择。当某个对象并不是立即就要使用时,推迟创建对象有着很明显的好处。代码清单1-14是推迟初始化的示例,它是基于代码清单1-13写的。为了避免总是检查对象是否为空,考虑使用工厂方法模式。
代码清单1-14 推迟分配缓存
int n = 100;
if (cache == null) {
// createCache分配缓存对象,可以从许多地方调用它
cache = createCache();
}
BigInteger fN = cache.get(n);
if (fN == null) {
fN = Fibonacci. computeRecursivelyWithCache(n);
cache.put(n, fN);
}
请参阅第8章学习如何在XML Layout中使用android.view.ViewStub,以及如何推迟资源展开。
1.6.2 StrictMode
写程序时,你应该始终假定下列两种情况:
网络很慢(你正在试图连接的服务器甚至可能没有响应);
文件系统的访问速度很慢。
结论就是,不应该在主线程内进行网络操作或访问文件系统,因为缓慢的操作会拖累系统的响应能力。虽然在开发环境中,你可能永远不会遇到任何网络问题或任何文件系统的性能问题,但用户可能不像你那么幸运。
注意 SD卡并不都具有相同“速度”,如果应用在很大程度上依赖外部存储设备的性能,那么你应该确保在来自不同制造商的各种SD卡上测试过你的应用。
Android有实用工具来帮助应用检测这类缺陷。它提供的StrictMode是检测不良行为的良好工具。通常情况下,在应用启动时,即当onCreate()被调用时,启用StrictMode,如代码清单1-15所示。
代码清单1-15 在应用中启用StrictMode
public class MyApplication extends Application {
@Override
public void onCreate () {
super.onCreate();
StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder()
.detectCustomSlowCalls()// API等级11,使用StrictMode.noteSlowCode
.detectDiskReads()
.detectDiskWrites()
.detectNetwork()
.penaltyLog()
.penaltyFlashScreen()// API等级11
.build());
// 其实和性能无关,但如果使用StrictMode,最好也定义VM策略
StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder()
.detectLeakedSqlLiteObjects()
.detectLeakedClosableObjects()// API等级11
.setClassInstanceLimit(Class.forName("com.apress.proandroid.SomeClass", 100)// API等级11
.penaltyLog()
.build());
}
}
StrictMode是Android 2.3引入的,在Android 3.0中加入了更多的功能,所以应该确保选择了正确的Android版本,让代码跑在适当的Android平台上,如代码清单1-12所示。
Android 3.0中引入的需要特别留意的方法包括detectCustomSlowCall()和noteSlowCall(),它们都是用来检测应用中执行缓慢的代码或潜在缓慢的代码。代码清单1-16说明了如何将代码标记为潜在缓慢的代码。
代码清单1-16 标记潜在缓慢的代码
public class Fibonacci {
public static BigInteger computeRecursivelyWithCache(int n) {
StrictMode.noteSlowCall("computeRecursivelyWithCache");// 消息可以带任何信息
SparseArray
cache = new SparseArray (); return computeRecursivelyWithCache(n, cache);
}
...
}
从主线程调用computeRecursivelyWithCache执行时间过长,如果StrictMode Thread 策略配置为检测缓慢调用时,会出现如下日志:
StrictMode policy violation; ~duration=21121 ms:
android.os.StrictMode$StrictModeCustomViolation: policy=31 violation=8 msg=computeRecursivelyWithCache
Android提供了一些辅助方法,可以在主线程里进行临时磁盘读写,如代码清单1-17所示。
代码清单1-17 修改线程策略,临时允许磁盘读取
StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskReads();
// 从磁盘读取数据
StrictMode.setThreadPolicy(oldPolicy);
目前没有临时允许网络访问的方法,但实在没有理由在主线程中允许这种访问,即使是暂时的,也没有合适的方式知道访问是否很快。有人可能会说,也没有合理的方式知道磁盘访问将是否是快速的,但那是另一场争论。
注意 只在开发阶段启用StrictMode,发布应用时,记得要禁用它。如果你使用detectAll()方法去建立策略总是可行的,那将来更可行,未来的Android版本会检测出更多的不良行为。
1.7 SQLite
大多数应用都不会是SQLite的重度使用者,因此,不用太担心与数据库打交道时的性能。不过,在优化应用中SQLite相关的代码时,需要了解几个概念:
SQLite语句
事务
查询
注意 本节不是SQLite完整指南,而是提供几个关注点,确保有效地使用数据库。想要找完整的指南,请参阅www.sqlite.org和Android的在线文档。
本节所涵盖的优化不会使代码难以阅读和维护,所以要养成使用它们的习惯。
1.7.1 SQLite语句
一开始,SQL语句是简单的字符串,例如:
CREATE TABLE cheese (name TEXT, origin TEXT)
INSERT INTO cheese VALUES ('Roquefort', 'Roquefort-sur-Soulzon')
第一条语句将创建一个名为“cheese”的表,表有“name”和“origin”两列;第二条语句在表中插入新行。因为这些语句是简单的字符串,需要解释或编译才可以执行。当你执行SQLite的语句时,如代码清单1-18所示的SQLiteDatabase.execSQL,SQLite内部是编译执行的。
代码清单1-18 执行简单的SQLite语句
SQLiteDatabase db = SQLiteDatabase.create(null); // 数据库在内存中
db.execSQL("CREATE TABLE cheese (name TEXT, origin TEXT)");
db.execSQL("INSERT INTO cheese VALUES ('Roquefort', 'Roquefort-sur-Soulzon')");
db.close(); // 操作完成后记得关闭数据库
注意 许多SQLite的相关方法会抛出异常。
事实证明,执行SQLite的语句可能需要相当长的一段时间。除了编译,语句本身可能还需要创建。由于String是不可改变的,这可能会和之前在computeRecursivelyFasterUsingBigInteger中创建大量BigInteger对象时出现同样的性能问题。我们现在只关注INSERT语句的性能。毕竟,表只会创建一次,但会添加、修改或删除许多行。
如果我们想建立一个全面的奶酪数据库 (谁不想呢?),需要加入许多INSERT语句才能结束,如代码清单1-19所示。每个INSERT语句都会创建一个String并调用execSQL,每个奶酪添加到数据库中时,SQL语句会在内部解析。
代码清单1-19 建立全面的奶酪数据库
public class Cheeses {
private static final String[] sCheeseNames = {
"Abbaye de Belloc",
"Abbaye du Mont des Cats",
...
"Vieux Boulogne"
};
private static final String[] sCheeseOrigins = {
"Notre-Dame de Belloc",
"Mont des Cats",
...
"Boulogne-sur-Mer"
};
private final SQLiteDatabase db;
public Cheeses () {
db = SQLiteDatabase.create(null); // 内存数据库
db.execSQL("CREATE TABLE cheese (name TEXT, origin TEXT)");
}
public void populateWithStringPlus () {
int i = 0;
for (String name : sCheeseNames) {
String origin = sCheeseOrigins[i++];
String sql = "INSERT INTO cheese VALUES(\"" + name + "\",\"" + origin + "\")";
db.execSQL(sql);
}
}
}
在三星Galaxy Tab 10.1上,添加650条奶酪数据到内存数据库用时393毫秒,平均一条0.6毫秒。
一个显而易见的优化方法是,加快要执行的SQL语句字符串的创建速度。在这种情况下,使用+运算符来连接字符串不是最有效的方法,而使用StringBuilder对象,或调用String.format可以提高性能。代码清单1-20 给出了两种新方法。它们只是优化了传递给execSQL的字符串创建速度,这两种方法本身并不算是与SQL相关的优化。
代码清单1-20 采用更快的方法创建SQL语句字符串
public void populateWithStringFormat () {
int i = 0;
for (String name : sCheeseNames) {
String origin = sCheeseOrigins[i++];
String sql = String.format("INSERT INTO cheese VALUES(\"%s\",\"%s\")", name, origin);
db.execSQL(sql);
}
}
public void populateWithStringBuilder () {
StringBuilder builder = new StringBuilder();
builder.append("NSERT INTO cheese VALUES(\"");
int resetLength = builder.length();
int i = 0;
for (String name : sCheeseNames) {
String origin = sCheeseOrigins[i++];
builder.setLength(resetLength); // 复位位置
builder.append(name).append("\",\"").append(origin).append("\")");// 链式调用
db.execSQL(builder.toString());
}
}
增加相同数量的奶酪条目,String.format版本用时436毫秒,而StringBuilder的版本用时371毫秒。String.format版本比原始版本慢,StringBuilder版本仅快了一点点。
尽管这三种方法创建字符串的方式不同,但都做了相同的事情,都调用了execSQL,execSQL完成实际的语句编译(解析) 。因为所有的语句都非常相似(仅是奶酪的名字和原产地不同),所以可以使用compileStatement让语句在循环外只编译一次。实现如代码清单1-21所示。
代码清单1-21 SQLite语句的编译
public void populateWithCompileStatement () {
SQLiteStatement stmt = db.compileStatement("INSERT INTO cheese VALUES(?,?)");
int i = 0;
for (String name : sCheeseNames) {
String origin = sCheeseOrigins[i++];
stmt.clearBindings();
stmt.bindString(1, name); // 替换第一个问号为name
stmt.bindString(2, origin); // 替换第二个问号为origin
stmt.executeInsert();
}
}
因为只进行一次语句编译,而不是650次,并且绑定值是比编译更轻量的操作,所以这种方法明显快多了,建立数据库只用了268毫秒。这样还使代码更具可读性。
Android还提供了其他的API,使用ContentValues对象把值插入到数据库中,它基本上包含了列名和值之间的绑定信息。实现如代码清单1-22所示,实际上非常近似populateWithCompile- Statement,"INSERT INTO cheese VALUES"字符串甚至不作为INSERT语句的一部分出场,暗示了这部分是靠调用db.insert()实现的。
但是,这个实现用了352毫秒,性能低于populateWithCompileStatement。
代码清单1-22 使用ContentValues填充数据库
public void populateWithContentValues () {
ContentValues values = new ContentValues();
int i = 0;
for (String name : sCheeseNames) {
String origin = sCheeseOrigins[i++];
values.clear();
values.put("name", name);
values.put("origin", origin);
db.insert("cheese", null, values);
}
}
最快的实现,也是最灵活的,因为它允许在语句中有更多的选择。例如,你可以使用INSERT OR FAIL或INSERT OR IGNORE来替换简单的INSERT。
注意 Android 3.0的android.database和android.database.sqlite包发生了许多变化。例如, Activity类中的managedQuery、startManagingCursor和stopManagingCursor方法已废弃,由CursorLoader取而代之。
Android也定义了一些可以提高性能的类。例如,可以使用DatabaseUtils.InsertHelper在数据库中插入多行,这样只需编译一次INSERT语句。它目前和populateWithCompileStatement的实现方案类似,但我们所关注的选项(例如,FAIL或ROLLBACK)不提供与之同样的灵活性。
题外话,和性能无关,你也可以使用DatabaseUtils类的静态方法来简化实现。
1.7.2 事务
上述例子中并没有显式创建任何事务,但会自动为每个插入操作创建一个事务,并在每次插入后立即提交。显式创建事务有以下两个基本特性:
原子提交
性能更好
抛开对性能的追求,第一个特性是很重要的。原子提交意味着数据库的所有修改都完成或都不做。事务不会只提交部分修改。在这个例子中,我们可以考虑把插入650个奶酪条目当作一个事务。要么成功建立完整的奶酪列表,要么没有插入任何奶酪条目,不会只建立部分列表。实现如代码清单1-23所示。
代码清单1-23 在事务中插入所有的奶酪
public void populateWithCompileStatementOneTransaction () {
try {
db.beginTransaction();
SQLiteStatement stmt = db.compileStatement("INSERT INTO cheese VALUES(?,?)");
int i = 0;
for (String name : sCheeseNames) {
String origin = sCheeseOrigins[i++];
stmt.clearBindings();
stmt.bindString(1, name); // 替换第一个问号为name
stmt. bindString(2, origin);// 替换第二个问号为origin
stmt.executeInsert();
}
db.setTransactionSuccessful(); // 删除这一调用不会提交任何改动!
} catch (Exception e) {
// 在这里处理异常
} finally {
db.endTransaction(); //必须写在finally块
}
}
新实现了用了166毫秒,改进相当大(快了约100毫秒)。有人会争论说这两种实现对于大多数应用来说都是可以接受的,因为同时这么快速地插入这么多行很少见。事实上,大多数应用通常会在某一时刻访问很多行,这可能是在响应一些用户操作。最重要的一点是,该数据库是在内存中,而不是保存到持久存储(SD卡或内部闪存)上的。在数据库工作时,大量的时间花费在访问持久性存储上(读/写),这比访问易失性记忆体慢得多。通过测量内部持久存储上的数据库的操作,可以确定单个事务的性能。建立在持久存储上的数据库,如代码清单1-24所示。
代码清单1-24 在持久存储上的创建数据库
public Cheeses (String path) {
// 路径可能已经由getDatabasePath("fromage.db")创建了
// 你也可以调用mkdirs确保路径存在
// File file = new File(path);
// File parent = new File(file.getParent());
// parent.mkdirs();
db = SQLiteDatabase.openOrCreateDatabase(path, null);
db.execSQL("CREATE TABLE cheese (name TEXT, origin TEXT)");
}
当数据库在持久存储,而不是在内存中时,调用populateWithCompileStatement需要花34秒的时间,每行(52毫秒),而调用populateWithCompileStatementOneTransaction时间不到200毫秒。无需多言,一次性事务是解决这类问题的较好办法。这些数据显然取决于要用的存储类型,外部SD卡上存储的数据库会更慢,更应该采用一次性事务。
注意 在存储上创建数据库时,确保父目录已经存在。参见Context.getDatabasePath和File.mkdirs的文档获取更多信息。为方便起见,可以使用SQLiteOpenHelper来代替手动创建数据库。
1.7.3 查询
我们可以用限制数据库访问的方式来加快查询速度,尤其是对存储中的数据库。数据库查询仅会返回一个Cursor(游标)对象,然后用它来遍历结果。代码清单1-25 给出了两种方法来遍历所有的行。第一种方法是创建Cursor获取数据库中的两列数据,而第二种方法创建的Cursor只获取第一列。
代码清单1-25 遍历所有的行
public void iterateBothColumns () {
Cursor c = db.query("cheese", null, null, null, null, null, null);
if (c.moveToFirst()) {
do {
} while (c.moveToNext());
}
c.close(); // 结束时(或在出现异常时)切记要关闭Cursor
}
public void iterateFirstColumn () {
Cursor c = db.query("cheese", new String[]{"name", null, null, null, null, null); // 唯一区别
if (c.moveToFirst()) {
do {
} while (c.moveToNext());
}
c.close();
}
和预想的一样,因为没有从第二列读取数据,第二种方法快了不少,两方法分别用时61毫秒和23毫秒(使用多个事务时)。迭代所有行时,将所有行作为一个事务处理会更快,这种情况下iterateBothColumns和iterateFirstColumn各用时11毫秒和7毫秒。你可以看到,只读取需要的数据才是上上之选。调用查询时选择正确的参数,可以使性能有可观的提升。如果只需要一定数量的行,指定调用查询的限制参数,则可以进一步减少数据库的访问时间。
提示 考虑使用SQLite的FTS(全文检索)扩展,它支持更多高级搜索特性(使用索引)。参阅www.sqlite.org/fts3.html获取更多信息。
1.8 总结
几年前,Java由于性能问题而广受诟病,但现在情况已大有改观。每次发布新版本Android时,Dalvik虚拟机(包括它的JIT编译器)的性能都会有所提升。代码可以编译为本地代码,从而利用最新的CPU架构,而不必重新编译。虽然实现很重要,但最重要的还是慎选数据结构和算法。好算法可以弥补差的实现,甚至不需要优化就可以使应用流畅运行;而坏算法无论你在实现上花费多少精力,其结果还是会很糟糕。
最后,不要牺牲响应能力。这可能会加大应用开发的难度,但响应顺畅是应用成功的关键。