性能优化总纲:
大概会花一个月左右的时间出7-8个专题来分享一下在工作和学习中积累下来的android性能优化经验。
希望大家会持续关注。
现在是专题一:java代码优化
但这也仅仅是为大家提供一些思路与较为全面的总结,算不上什么,希望有错误或问题在下面评论。
最后完结以后会将思维导图与优化框架整理出来,请期待。
题记:
如何确保Java应用在Android设备上获得高性能?首先要做的:知道Android是如何来执行代码的,然后再体会一下所谓的优化技巧,以及一些提高应用响应速度和高效使用数据库的技巧。
不过,你应该意识到,代码优化不是应用开发者的首要任务,提供良好的用户体验并且专注于代码的可维护性,才是我们的首要任务。事实上,代码优化应该最后才做,如果你的程序自我感觉达到一个可以接受的水平,甚至不需要代码优化。
一、我们先来看看Android是如何来执行代码的
Android→Java代码→Java字节码→Dalvik字节码→Dalvik虚拟机(4.4之前)
Android→Java代码→Java字节码→机器码(5.0之后)
i: Android 4.4 中谷歌为开发者提供了两种编译模式,一种是默认的Dalvik模式,而另外一种则是ART模式,5.0废弃Dalvik。
ii:本地代码直接由CPU执行,而不必由虚拟机解释执行;本地代码可以为特定架构予以优化。
iii:从用户的角度来看,如果可以在100ms或者更短的时间内计算完成,那就是瞬时计算。
JIT与Dalvik
JIT是"Just In Time Compiler"的缩写,就是"即时编译技术",与Dalvik虚拟机相关。
怎么理解这句话呢?这要从Android的一些特性说起。
JIT是在2.2版本提出的,目的是为了提高Android的运行速度,一直存活到4.4版本,因为在4.4之后的ROM中,就不存在Dalvik虚拟机了。
我们使用Java开发android,在编译打包APK文件时,会经过以下流程
- Java编译器将应用中所有Java文件编译为class文件
- dx工具将应用编译输出的类文件转换为Dalvik字节码,即dex文件
- 之后经过签名、对齐等操作变为APK文件。
Dalvik虚拟机可以看做是一个Java VM,他负责解释dex文件为机器码,如果我们不做处理的话,每次执行代码,都需要Dalvik将dex代码翻译为微处理器指令,然后交给系统处理,这样效率不高。
为了解决这个问题,Google在2.2版本添加了JIT编译器,当App运行时,每当遇到一个新类,JIT编译器就会对这个类进行编译,经过编译后的代码,会被优化成相当精简的原生型指令码(即native code),这样在下次执行到相同逻辑的时候,速度就会更快。
当然使用JIT也不一定加快执行速度,如果大部分代码的执行次数很少,那么编译花费的时间不一定少于执行dex的时间。Google当然也知道这一点,所以JIT不对所有dex代码进行编译,而是只编译执行次数较多的dex为本地机器码。
有一点需要注意,那就是dex字节码翻译成本地机器码是发生在应用程序的运行过程中的,并且应用程序每一次重新运行的时候,都要做重做这个翻译工作,所以这个工作并不是一劳永逸,每次重新打开App,都需要JIT编译。
另外,Dalvik虚拟机从Android一出生一直活到4.4版本,而JIT在Android刚发布的时候并不存在,在2.2之后才被添加到Dalvik中。
ART与AOT
AOT是"Ahead Of Time"的缩写,指的就是ART(Anroid RunTime)这种运行方式。
前面介绍过,JIT是运行时编译,这样可以对执行次数频繁的dex代码进行编译和优化,减少以后使用时的翻译时间,虽然可以加快Dalvik运行速度,但是还是有弊病,那就是将dex翻译为本地机器码也要占用时间,所以Google在4.4之后推出了ART,用来替换Dalvik。
在4.4版本上,两种运行时环境共存,可以相互切换,但是在5.0+,Dalvik虚拟机则被彻底的丢弃,全部采用ART。
ART的策略与Dalvik不同,在ART 环境中,应用在第一次安装的时候,字节码就会预先编译成机器码,使其成为真正的本地应用。之后打开App的时候,不需要额外的翻译工作,直接使用本地机器码运行,因此运行速度提高。
当然ART与Dalvik相比,还是有缺点的。
- ART需要应用程序在安装时,就把程序代码转换成机器语言,所以这会消耗掉更多的存储空间,但消耗掉空间的增幅通常不会超过应用代码包大小的20%
- 由于有了一个转码的过程,所以应用安装时间难免会延长
但是这些与更流畅的Android体验相比而言,不值一提。
通过前面背景知识的介绍,我终于可以更简单的介绍这四个名词之间的关系了:
- JIT代表运行时编译策略,也可以理解成一种运行时编译器,是为了加快Dalvik虚拟机解释dex速度提出的一种技术方案,来缓存频繁使用的本地机器码
.
- ART和Dalvik都算是一种Android运行时环境,或者叫做虚拟机,用来解释dex类型文件。但是ART是安装时解释,Dalvik是运行时解释
.
- AOT可以理解为一种编译策略,即运行前编译,ART虚拟机的主要特征就是AOT
二、几点优化技巧
优化思路一:
微小的优化:当n等于0或者1的时候直接返回n,而不是在另外一个if语句中来检查n是否等于0或1.
public class Fibonacci{
public static long computeRecursively(int n){
if(n>1) return computeRecursivelv(n-2) + computeRecursivelv(n-1);
return n;
}
}
优化思路二:以优化斐波那契数列为例,简单谈谈思想
1、首次优化是消除一个方法调用
public class Fibonacci{
public static long computeRecursively(int n){
if(n>1) {
long result = 1;
do {
result += computeRecursivelyWithLoop(n-2);
n--;
}while (n>1)
return result;
}
return n;
}
}
2、第二次优化会换成迭代实现:尤其是在没有多少内存的时候,递归算法往往要消耗大量栈空间,有可能导致栈溢出,让应用崩溃。
public class Fibonacci{
public static long computeRecursively(int n){
if(n>1) {
long a= 0,b = 1;
do {
long tmp = b;
b += a;
a = amp;
}while (--n>1)
return b;
}
return n;
}
}
3、 到三次稍加修改,每次迭代计算两项,迭代总数少了一半。由于long型只有64位,在斐波拉契数列的第92项,会出现溢出,导致结果错误,第93项会变成负的。
public class Fibonacci{
public static long computeRecursively(int n){
if(n>1) {
long a= 0,b = 1;
n--;
a = n & 1;
n /= 2;
while (n-->0){
a += b;
b += a;
}
return b;
}
return n;
}
4、第四次用BigInteger,保证了不会溢出,但是速度再一次降了下来:1、BigInteger是不可变的 2、BigInteger使用BigInt和本地代码实现 3、数字越大,相加运算所花的时间越大
public class Fibonacci{
public static BigInteger computeIterativelvFasterUsingBigInteger(int n){
if(n>1) {
BigInteger a,b = BigInteger.ONE;
n--;
a = BigInteger.valueOf(n & 1);
n /= 2;
while (n-->0){
a=a.add(0);
b=b.add(a);
}
return b;
}
return n==0?BigInteger.ZERO : BigInteger.ONE;
}
5、第五次改进算法来减少分配数量。基于斐波那契Q-矩阵,我们会有一个算法公式来加快速度。
6、第六次使用BigInteger和基本类型Long的快速递归实现
当n>92时才使用BigInteger来进行运算,这样我们做以上运算会快20倍。
7、第七次使用BigInteger和预先计算结果递归快速实现......
> 好了,到了这里应该发现优化往往使源代码更难于阅读、理解和维护,而且,会有越来越少的人来能理解你写的代码的含义,代码复杂到笔者已经不想写了。而且,好的算法是无穷无尽的,只不过可能更复杂罢了,那我们费尽力气计算出来的结果,不能白白浪费(代价太高了),所以我们考虑到了缓存。
result = cache.get(n);//输入参数n作为键
if(result = null){
//如果在缓存中没有result值,就计算出来存进去
result = computeResult(n);
cache.put(n,result);//n作为键,result作为值
}
return result;
8、考虑计算代价过高,最好把结果缓存起来,安卓定义的SparseArray类,比HashMap更高效(Integer和int区别)
public class Fibonacci{
public static BigInteger computeRecursivelyWith Cache(int n){
SparseArray<BigInteger> cache = new SparseArray<BigInteger>();
return computeRecursivelyWithCache(n,cache);
}
private static BigInteger computeRecursivelyWithCache(int n,SparseArray<BigInteger> cache){
if(n>92) {
BigInteger fN = cache.get(n);
if(fN == null){
int m = (n/2) +(n&1);
BigIntger fM = computeRecursiveWithCache(m,cache);
BigIntger fM_1 = computeRecursiveWithCache(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){
...
}
}
}
另外值得一提的是LRUCache算法,同样对应着一个MRUCache算法
这个类是Android3.1引入的,可以在创建的时候自定义缓存的长度,另外,可以通过复写sizeof()方法改变每个缓存条目计算大小的方式。
- LRU(Least Recently Used)缓存县丢弃最近最少使用的项目,不过在某些分情境中我们还可能用到MRUcache丢弃最近最多使用的项目。这两种算法现在在这里不深入讨论,等以后有机会分享数据结构在详谈。
最后我们得出一个结论,我们对一个场景进行优化,往往有很多方式,但是某一种实现一般不是最好的解决方式,最好的结果就是结合多种不同的技术,而不是只依赖于其中一个,例如更快的实现可以用预计算、缓存机制、甚至采用不同的数学公式。
三、API
一般我们在manifest中应该使用<uses-asd>元素制定以下三个重要的信息
- 最低API等级(mainSdkVersion)
- 期望API等级(targetSdkVersion)
- 最高API等级(maxSdkVersion)
要注意一点 :过期的不能使用
另外注意:可以用新API来获取最好的性能,也可以在旧平台上正常运行
例如:
android6.0权限问题
android3.0以下兼容属性动画等。
sparseArray的使用:
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB){
sparseArray.removeAt(1);//11以上
} else {
int key = sparseArray.keyAt(1);//默认实现慢一些
sparseArray.remove(key);
}
如不想用上面的方法来检测版本号,还可以用反射来确认是否有特定方法,但是有一点,在性能至关主要的地方应尽量避免使用反射。替代的办法是在静态初始化代码块里调用Class.forName()和Class.getMethod()确认指定方法时否存在,在性能要求高的地方只调用Method.invoke()就好了。
四、数据结构
通过上面斐波那契数列实现证明,好的算法和数据结构是实现快速应用的关键。java.util包中已经定义好了很多我们可以随手拿来用的工具了,比如各种集合。Android还定义了一些为了解决性能问题而生的类:
- LruCache
- SparseArray
- SparseBooleanArray
- SparseIntArray
- Pair
数据结构还是和上面一样等以后有机会再讨论。
五、响应能力
让用户真正感觉到快才行,比如延迟加载技术。
通常我们的做法是在组件的onCreate()方法中执行所有初始化。虽然这样做可行,但这意味着onCreate()需要较长的时间才能结束。
这一点对应用的ACtivity尤为重要,onStart()直到onCreate()方法之后才会被调用(同样,onResume()只有在onStart()完成之后才会被调用).
任何的延迟都会导致应用需要较长时间才嗯那个启动,用户最终可能会感到难以忍受。
-
让你的主线程只做下面这种事情:
按键接收
绘制View
产生生命周期方法
来简单说一下用户感受:当用户感觉到你的应用有卡顿的时候,好感度就会降低,到一定临界点后,就再见了.
那么,什么时候才是感觉到卡顿的,一般我们人眼看到的图像帧率为60fps的时候,会感到比较流畅,换算成时间就是0.016s/帧,如果你的应用某个点再0.016s之内没有渲染完成,就会造成所谓的卡顿,那么从优化的角度来说,除了改变GPU,我们能做的事情,就是减少布局的嵌套与ViewStub推迟对象创建。当然你可以用视图树来检测,那不是在代码优化的范围内了,所以知道就好。
Android使用android.view.ViewStub来推迟初始化,它可以在运行时展开资源。当View-Stub需要展现时,它被相应的资源展开替换,自己就成为得待垃圾回收的对象。
由于内存分配需要花时间,等到对象真正需要时才进行分配,也是一个很好的选择。当某个对象并不是立即就要使用时,推迟创建对象有着很明显的好处。下面代码是退出初始化的示例:为了避免总是检查对象是否为空,考虑使用工厂方法模式。
int n = 100;
if(cache == null){
//createCache分配缓存对象,可以从许多地方调用它
cache = createCache();
}
BigInteger fN = cache.get(n);
if(fN == null){
fN = Fibonacci.computeRecurivelyWithCache(n);
cache.put(n,fN);
}
六、SQLite:
大多数应用都不会是SQLite的重度使用者,因此,不用太担心与数据库打交道时的性能(对数据库有大量使用请参考我的另一篇文章《三个方面解决性能问题》)。不过。在优化应用中SQLite相关的代码时,需要了解几个概念:
1、SQLite语句
2、事务
3、查询
因为普通的sql语句是简单的字符串,需要解释或者编译才可以执行。当你执行SQL语句时,例如:
SQLiteDatabase db = SQLiteDatabase.create(null);//数据库在内存中
db.execSQL("CREATE TABLE cheese(name TEXT,origin TEXT)");
db.execSQL("INSERT INTO cheeese VALUES ('Roquefort','Roquefort-sur-Solulzon')");
db.close();//关闭数据库
事实证明,执行SQLite的语句可能需要一段较长时间。除了编译,语句本身还需要创建。现在我们只关心INSERT的性能,毕竟,表只会创建一次,但会添加,修改,删除多次。
例如:String sql = "INSERT INTO cheese VALUES(\"" +name +"\",\"" + origin +"\")"';
- 这样的650条数据到内存数据库中用时393ms,平均一条0.6ms。
那么第一个优化方案是用StringBuilder或String.format()来替代“+”
builder.appended(name).append("\",\"").addped(origin).append("\")");
String sql = String.format("INSERT INTO cheese VALUES(\"%s\",\"%s\")")",name .origin);
- 上面这两个方案大概能快几十毫秒。
第二种方案:我们发现所有的语句都非常相似,所以可以使用一个语句,让一部分在循环外只编译一次:
public void populateWithCompileStatement(){
SQLiteStatement stmt = db.compileStatement("INSERT INTO cheese VALUES(?,?)");
int i = 0;
for(String name : sCheeseNames){
String origin = sCheeseOrigins[i++];
stmt.clearBingings();
stmt.bindString(1,name);//替换第一个问号name
stmt.bingString(2,origin);//替换第二个问号为origin
stmt.executeInsert();
}
}
- 因为只进行了一次语句编译,而不是650次,并且绑定值是比编译更轻量级的操作,所以这种方法明显快多了、总共用时269ms。
事务:上述例子并没有显示创建任何事务,但会自动为每个插入操作创建一个事务,并在每次插入后立即提交。显示创建事务有以下两个基本特征:
原子提交
性能更好
抛开对性能的追求,第一个特性是很重要的。原子提交意味着数据库的所有修改都完成或都不做。事务不会只提交部分修改。如上面代码,加入事务之后
try{
db.beginTransaction();
SQLiteStatement stmt = db.compileStatement("INSERT INTO cheese VALUES(?,?)");
int i = 0;
for(String name : sCheeseNames){
String origin = sCheeseOrigins[i++];
stmt.clearBingings();
stmt.bindString(1,name);//替换第一个问号name
stmt.bingString(2,origin);//替换第二个问号为origin
stmt.executeInsert();
}
db.setTransactionSuccessful();//删除这一调用不会提交任何改动!
} catch(e..){
//异常处理
}finally{
db.endTransaction();//必须写在finally里
}
查询:我们可以用限制数据库的访问方式来加快查询速度,尤其是对存储中的数据库。数据库查询仅会返回一个cursor(游标)对象,然后用它来遍历结果。
查询的时候,尽量只读取需要的数据。例如假设我们的表有两列,name和origin:
db.query("cheese,null,null,null,null,null,null");(1)
db.query("cheese",new String[]{"name"},null,null,null,null,null);(2)
查询一定量的数据两种方法分别用时61ms,23ms
所以,可以肯定,只读取需要的数据才是上上之选。
七、总结:
几年前,java由于性能问题而广受诟病,现在情况已大有改观。
每次发布新版本Android时,Dalvik虚拟机(包括它的JIT编译器)的性能都会有所提升。
代码可以编译为本地代码,从而利用最新的CPU架构,而不必重新编译。
虽然实现很重要,但最重要的还是慎选数据结构和算法。
好的算法可以弥补差的实现,甚至不需要优化就可以使应用流畅运行;而坏的算法无论你在实现上花费多少精力,其结果还是会很糟糕。
最后,响应顺畅是成功的关键。