最近阅读了一下Android编译和运行相关的文章,大概了解了整体运行过程,在这里总结一下。

整篇文章相对而言不涉及到过多的技术细节,没有什么基础的人也能够通过这篇文章了解到Android的相关知识。

这篇文章内容较多,主要内容有:

  • JVMAndroid的关系
  • 字节码
  • Android构建系统
  • AOTJIT的含义,以及它们和R8的关系
  • Android运行系统

在此之前,先要说一下一些基础知识。

一、CPU & JVM

每台手机设备都具有CPUCPU性能强弱是手机流畅性影响因素之一,这里不展开讨论这个问题。目前手机端有名的CPU应当是高通的骁龙系列。当然也有其他架构的CPU,如ARMARM64x86x86_64MIPS。针对不同架构的CPU进行开发时,需要对不同的CPU架构生成不同的.so文件,这是一件很麻烦的事情。

不同的CPU架构
不同的CPU架构

这个时候JVM(Java虚拟机)就展示出了它的优势。JVM(Java Virtual Machine)会在硬件层面上增加一层抽象层。也就是说,你开发的app只需要支持Java API构成的”CPU”即可,再也不用被繁多的CPU架构困扰,也不用做额外的适配工作。

JVM原理
JVM原理

Java代码只需要使用javac编译器编译成字节码文件(.class文件),然后代码就可以跑在JVM上,和操作系统隔离。一次编译,到处运行。这样,开发者无需考虑系统类型、设备类型、内存以及CPU,只要把精力集中在业务逻辑就可以了。

1.1 JVM内部结构

JVM结构
JVM结构

下面来说说JVMJVM主要由三部分构成:

  • ClassLoader
    负责加载编译后的Java文件(.class),验证链接关系,检测字节码是否正确,为静态变量和代码分配内存以及初始化。
  • 运行时数据
    负责所有的程序数据:stack、方法、变量还有Heap
  • 执行引擎
    执行编译完成并加载的代码和GC

了解完以上知识之后,就可以开始继续了解解释器(Interpreter)和JIT编译器了。

1.2 Interpreter & JIT

这两兄弟是一起工作的。每次跑程序时,Interpreter选取应当执行的.class字节码,然后将其实时翻译成机器码执行。这么做有一个缺点,如果一个方法或者逻辑被多次调用,那么每次调用都要重新翻译一次,效率特别低,实际上翻译出来的机器码可以重复使用。

在这种情况下,JIT(Just In Time)就派上用场了。执行引擎在Interpreter的帮助下将字节码翻译成机器码,如果发现有重复执行的代码,JIT就开始工作,将这部分频繁调用的代码翻译成机器码,当程序再次调用这个部分的逻辑时,执行引擎会直接使用JIT翻译完成的机器码,这样就能够提升系统的性能表现,这部分代码也叫Hot Code

执行流程
执行流程

1.3 Dalvik

那么上面说的这些和Android有什么关系呢?

JVM设计运行的硬件环境和Android设备不一样,JVM最初是为电视机顶盒设计的,拥有“无限”电量,而一般来说手机设备有4000mah已经是很大的电池了,另外Android设备存储空间比较小(现在其实挺大的)。因此,Google修改了JVM的整体结构,包括Java代码编译过程和字节码结构,以适应手机设备。编译过程的差异将在下一节讲述,字节码结构则下面用代码实例说明一下。

1
2
3
4
public int method(int i1, int i2) {
int i3 = i1 + i2;
return i3 * 2;
}

上面这段代码生成的.class字节码如下:

java bytecode
java bytecode

使用AndroidDex编译器编译代码生成的.dex字节码如下:

Dex bytecode
Dex bytecode

两张图对比可以明显看出来,.dex字节码会比.class字节码更简洁。

另外,Java字节码是基于栈结构(所有的变量都存储在栈上)的,而Dex字节码基于寄存器结构(所有变量存储在寄存器中)。

基于栈结构的字节码,在执行操作时需要先将操作数加载到操作栈上,然后计算,最后再将得到的结果从栈上弹出到局部变量表中。

基于栈的计算过程
基于栈的计算过程

而基于寄存器的字节码可以直接读取寄存器中存储的数据,然后一步到位将结果存入另外的寄存器。Dex这种字节码相对Java字节码而言更高效,需要的空间也更少。

Android 5.0以前,Android系统中解析Dex字节码的虚拟机叫做Dalvik

Dalvik加载和解析执行Dex字节码的方式和JVM使用JIT以及Interpreter一样。

二、Android 构建过程

Gralde Build Process
Gralde Build Process

.java.kt文件由Java/Kotlin编译器编译成.class文件。这些.class文件则通过Dex编译器编译成.dex文件,最终和资源文件等打包成.apk文件。

可以看到,Android构建其实和Java语言有很大关系。Java语言基本每年更新一个新版本,目前最新的是Java 12。而相对于Java的迅速迭代而言,Android虚拟机则慢了很多,很多语言新特性无法直接在虚拟机上使用。因此,Android构建过程中,除了要将class文件转化成dex文件之外,还有一个重要的任务是将新特性转化成老版本Java能够运行的特性,也就是所谓的“脱糖”。

脱糖
脱糖

2.1 Jack & Jill

为了解决这个问题,Google在16年推出了Jack & Jill这套编译机制。具体流程如下:

Jack & Jill
Jack & Jill

Google想使用Jack工具直接将Java代码编译成dex文件,至于应用所使用过的第三方库,则使用Jill工具编译成中间文件然后再编译成dex文件。

这样看起来简单明了,没有那么多中间过程。但实际效果没有预期那么好。Google需要重新实现Java生态所有特性支持,如注解,工作量很大;并且有些第三方库依赖于Java字节码神效;另外,经过实际应用,发现这个工具速度慢,耗内存,且不支持instant runGoogle在17年废弃了这个编译工具链。

2.2 D8

为了改善构建环境,GoogleAndroid Studio 3.2中使用D8(Dope 8)取代了原来的Dex编译器。这个替换操作主要的目的是将脱糖操作和class2dex操作合并,减少构建时间。

D8构建过程
D8构建过程

那么D8有多快呢?这得分工程规模来看。小APP的话,100次构建取平均值之后,优化的时间为2s

D8构建时间对比图
D8构建时间对比图

官方描述D8的优点有:

  • 编译更快、时间更短;
  • DEX 编译时占用内容更小;
  • .dex 文件大小更小;
  • D8 编译的 .dex 文件拥有相同或者是更好的运行时性能;

这还没完,Google还有一个大招。

2.3 R8

R8D8的衍生产品,他们使用相同的代码库,但是R8还解决了其他问题。跟D8一样,R8也能将Javafeature提供给开发者使用,但不仅限于此。

R8带来的一个最大改进是优化.dex中的代码,只保留了支持应用所需要的API,删除了没有用上的类和方法。

另外,R8能够替代ProguardProguard是一个在构建过程中使用到的混淆工具,在.class转换.dex过程中,R8会替代Proguard的工作,比如优化、混淆代码,移除无用的类。

用Java 8的lambda表达式做测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class MathLambda {
interface NumericTest {
boolean computeTest(int n);
}

void doSomething(NumericTest numericTest) {
numericTest.computeTest(10);
}
}

private void java8ShowCase() {
MathLambda math = new MathLambda();
math.doSomething((n) -> (n % 2) == 0);
}

使用R8能够减少生成的字节码行数。

Dex vs R8
Dex vs R8

R8Android Studio 3.4被引入并默认开启,官方宣称能够优化逻辑代码,混淆代码和清除无用的类和方法。

同一个APP,分别使用Dex+Proguard和R8构建100次,取平均值,结果如下:

Dex-Proguard vs R8
Dex-Proguard vs R8

如上图,在构建过程中,使用R8减少了13s构建时间,同时减少了112个方法,生成的apk也小了348kb。整体而言,结果很不错。

举一个R8优化字符串操作的例子,如下代码:

直接编译成字节码如下:

从字节码可以看到,每次都先计算字符串WILDCARD的长度,然后取子字符串。其实字符串常量WILDCARD长度为固定的,所以没有必要一次次重新计算,编译期计算出来就可以。优化之后的字节码如下:

可以看到0008行直接将一个固定值赋给了变量v0,然后在获取子字符串进行之后的操作。

三、Android运行过程

3.1 Dalvik虚拟机

从应用商店下载一个apk文件,安装后,点击图标icon,然后就可以看到APP运行在了手机上。那么中间发生了哪些事情?

APK安装文件一般包含了所有的资源文件(图片,图标和布局等)和代码文件,安装时系统会把这些资源存储到内存中。当用户点击应用图标时,手机会启动一个Dalvik进程,然后将该appdex文件加载到内存,同时Dalvik虚拟机将会把dex字节码通过InterpreterJIT翻译成机器码,最终这款app运行在了你手机上。

APP 运行过程
APP 运行过程

当应用要使用到某部分代码(字节码)时,Dalvik会通过Interpreter将字节码实时翻译成机器码运行,经过一段时间后,如果有部分代码被经常调用(比如刷抖音时调用的请求视频数据逻辑),那么这部分代码会被定义成hotcode,随后被JIT翻译成机器码存储在内存中。当这部分代码再次被调用时,就不需要实时翻译,直接使用翻译好的机器码即可,这样就提高了运行效率。

这个过程和餐厅接待客人的一些现象很相似。Interpreter就是客人来了之后现场点现场做,JIT则是根据之前的经验,将这个客人常点的菜提前先做好,这样就能提高上菜速度。

JIT机制是Android 2.2引入的,之前全靠Interpreter实时翻译,可想而知之前卡顿有多严重。

3.2 ART

Dalvik对于手机设备来说是一个很好的解决方案,但是它。因此GoogleJVM进行了改进,推出了一种新的JVM,称为ART虚拟机。DalvikART最大的区别在于ART不会在运行时对字节码进行翻译执行,而是把这个过程提前到了安装过程中。ART使用AOT(Ahead of Time)编译器来将字节码翻译成.oat文件。

AOT 运行过程
AOT 运行过程

APP被安装时,ARTDex字节码预编译成.oat文件,每次启动APP时系统直接读取.oat文件运行即可,不再使用JIT以及Interpreter这种即时翻译的工具,大大提升了运行流畅度。

这样看起来好像AOT好像没什么毛病,Google当初也觉得自己找到了终极解决方式,但实际上AOT还是会有一些问题:

  • AOT在安装过程中进行预编译行为,这样安装和更新APP时间相比原来就会大大增加。另外,升级Android系统时,系统会把所有程序重新安装一次,想想那么多APP要重新安装编译成.oat文件,头都大了。
  • 空间大小。AOT将整个.dex文件都翻译为.oat文件,包括那种很少使用或者根本不会被使用到的代码(比如第一次打开APP的设置向导或者开屏界面)。根据Google相关的数据,常用的代码大概占所有代码的15~20%左右,这样就浪费了很大一部分空间,在一些小存储容量的低端机上这个问题尤其明显。

既然这也不好,那也不好,那就集中两种方式的优点就好了。Google工程师想出了一个新点子:Interpreter + JIT + AOT混合编译,具体方案如下:

  1. 安装的时候不进行预编译,也即不生成.oat文件。app第一次启动时,ART使用Interpreter来实时翻译.dex文件。
  2. 当出现Hot Code时,使用JIT进行翻译,并将翻译后的机器码存入缓存(内存)中,之后调用Hot Code时直接从缓存中取。
  3. 当设备空闲时,比如锁屏,Hot Code会被AOT编译器编译成oat文件存入本地存储空间。
  4. app再次启动时,如果存在.oat文件,那么直接使用.oat文件,否则从步骤1开始。
AOT 运行过程
AOT 运行过程

国内的厂商会有“基于用户操作习惯进行学习,APP打开速度不断提高
”的说法,有一部分是这个混合编译方案的功劳。

根据官方数据,平均来看,app运行8次之后,这个机制能够优化80%的空间。

这个混合机制为Android N(5.0)引入的,也正是这个时候开始用户对Android的运行效率看法有了改观。

3.3 PGO

经过上面这些动作,Android的运行速度其实已经有很大的改观。但还是有可以改进的地方。

在说AOT混合编译的时候系统会生成一个profile,这个profile记录了hotcode的信息,哪些类和哪些方法会被经常调用。而对于大多数人来说,同一个APPhotcode区别不大,其实可以共用,因此Google2018 Google I/O大会上提出了Cloud Profiles的方案。具体原理如下:

共享
共享

这个方案依赖Google Play来完成。当一个设备为空闲状态并且连接到WiFi时,Google Play Service会将编译后的文件共享,之后如果有一样的手机从Googole Play中下载这个APP时,终端会收到其他人的hotcode信息,这样用户在第一次使用时就能获得良好的体验。

但实际上,一个人的hotcode无法代表所有人的hotcode信息,那么需要多少个样本才能拿到一个比较稳定的hotcode profile呢?根据官方的数据,这个数字还挺小的。

平均而言,30profile已经能达到一个比较稳定的水平,而且效果也不差。

启动时间
启动时间

然而这些方案对于国内来说都没什么用…因为没法使用Google Play。但Android 9开始,Google提供了一个内置hotcode的方案,也就是说,可以在构建期间往APP中放置hotcode信息,这样系统在安装APP时直接将这部分代码编译成机器码,速度会有很大的提升。

不过这部分内容在国内资料很少,Google搜索了之后也才发现官方给了这一篇文章,Build With PGO,有兴趣的可以自己继续了解一下。

3.4 鸿蒙系统 & 方舟编译器

说到了编译器和Android的运行机制,不得不说一下最近大热的华为鸿蒙系统和方舟编译器。这里先放一张官网给的架构图:

在网上搜集了一些信息之后,不得不说华为的愿景很大。想把Java/Kotlin、C++直接编译成能够运行的机器码,据了解的信息,普通的APP经过华为的编译器之后,包体积会增大一些。另外,对于语言的动态特性,比如Java的多态,直接在编译期处理成静态特性。这些都是华为官方宣称的优点,不知道华为具体是怎么做的,拭目以待吧。

参考文档