Android 优化 (一) Java 代码优化

4,464 阅读15分钟

性能优化总纲:

大概会花一个月左右的时间出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或者更短的时间内计算完成,那就是瞬时计算。

那么我们来了解一下android的几种编译模式 JIT与Dalvik ART与AOT

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

if(result = null){

    

    result = computeResult(n);

    cache.put(n,result);

}

return result;

8、考虑计算代价过高,最好把结果缓存起来,安卓定义的SparseArray类,比HashMap更高效(Integer和int区别)

public class Fibonacci{
 public static BigInteger computeRecursivelyWith Cache(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);
     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中应该使用元素制定以下三个重要的信息

  • 最低API等级(mainSdkVersion)

  • 期望API等级(targetSdkVersion)

  • 最高API等级(maxSdkVersion)

要注意一点 :过期的不能使用

另外注意:可以用新API来获取最好的性能,也可以在旧平台上正常运行

例如:

android6.0权限问题

android3.0以下兼容属性动画等。

sparseArray的使用:

if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB){

    sparseArray.removeAt(1)

} 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()完成之后才会被调用).

任何的延迟都会导致应用需要较长时间才嗯那个启动,用户最终可能会感到难以忍受。

  • 让你的主线程只做下面这种事情:

来简单说一下用户感受:当用户感觉到你的应用有卡顿的时候,好感度就会降低,到一定临界点后,就再见了.

那么,什么时候才是感觉到卡顿的,一般我们人眼看到的图像帧率为60fps的时候,会感到比较流畅,换算成时间就是0.16s/帧,如果你的应用某个点再0.16s之内没有渲染完成,就会造成所谓的卡顿,那么从优化的角度来说,除了改变GPU,我们能做的事情,就是减少布局的嵌套与ViewStub推迟对象创建。当然你可以用视图树来检测,那不是在代码优化的范围内了,所以知道就好。

Android使用android.view.ViewStub来推迟初始化,它可以在运行时展开资源。当View-Stub需要展现时,它被相应的资源展开替换,自己就成为得待垃圾回收的对象。

由于内存分配需要花时间,等到对象真正需要时才进行分配,也是一个很好的选择。当某个对象并不是立即就要使用时,推迟创建对象有着很明显的好处。下面代码是退出初始化的示例:为了避免总是检查对象是否为空,考虑使用工厂方法模式。

int n = 100;
if(cache == null){
 
 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架构,而不必重新编译。

虽然实现很重要,但最重要的还是慎选数据结构和算法。

好的算法可以弥补差的实现,甚至不需要优化就可以使应用流畅运行;而坏的算法无论你在实现上花费多少精力,其结果还是会很糟糕。

最后,响应顺畅是成功的关键。