如何启动android虚拟机(重学Android基础系列篇)
如何启动android虚拟机(重学Android基础系列篇)字节码狭义上是java语言编译而成,但是由于JVM是支持多种语言编译的字节码的,而字节码都是一个标准规范,因为我们应该称其为JVM字节码。比如并行处理使用Clojure语言编写,展示层使用JRuby/Rails 中间层用Java编写,每一应用层都可以使用不同的语言编写,接口对于开发者是透明的。不同语言可以相互调用,就像是调用自己语言原生的API一样。它们都运行在同一个虚拟机上。将各种语言编译成为字节码文件的编译器,称之为前端编译器。而Java虚拟机中,也有编译器,比如即时编译器,此处称为后端编译器。Java虚拟机要做到跨语言,目前来看应该是当下最强大的虚拟机。但是并非一开始设计要跨语言。由于有了跨语言平台,多语言混合编程就更加方便了,通过特定领域的语言去解决特定领域的问题。
前言本系列文章主要是汇总了一下大佬们的技术文章,属于Android基础部分,作为一名合格的安卓开发工程师,咱们肯定要熟练掌握java和android,本期就来说说这些~
[非商业用途 如有侵权 请告知我 我会删除]
DD一下: Android进阶开发各类文档,也可关注公众号<Android苦做舟>获取。
1.Android高级开发工程师必备基础技能
2.Android性能优化核心知识笔记
3.Android 音视频进阶开发面试题冲刺合集
4.Android 音视频开发入门到实战学习手册
5.Android Framework精编内核解析
6.Flutter实战进阶技术手册
7.近百个Android录播视频 音视频视频dome
.......
JVM是跨语言的平台,很多语言都可以编译成为遵守规范的字节码,这些字节码都可以在JAVA虚拟机上运行。Java虚拟机不关心这个字节码是不是来自于Java程序,只需要各个语言提供自己的编译器,字节码遵循字节码规范,比如字节码的开头是CAFEBABY。
将各种语言编译成为字节码文件的编译器,称之为前端编译器。而Java虚拟机中,也有编译器,比如即时编译器,此处称为后端编译器。
Java虚拟机要做到跨语言,目前来看应该是当下最强大的虚拟机。但是并非一开始设计要跨语言。
1.1.1 跨语言的平台有利于什么?由于有了跨语言平台,多语言混合编程就更加方便了,通过特定领域的语言去解决特定领域的问题。
比如并行处理使用Clojure语言编写,展示层使用JRuby/Rails 中间层用Java编写,每一应用层都可以使用不同的语言编写,接口对于开发者是透明的。不同语言可以相互调用,就像是调用自己语言原生的API一样。它们都运行在同一个虚拟机上。
1.1.2 何为字节码?字节码狭义上是java语言编译而成,但是由于JVM是支持多种语言编译的字节码的,而字节码都是一个标准规范,因为我们应该称其为JVM字节码。
不同的编译器,可以编译出相同的字节码文件,字节码文件也可以在不同操作系统上的不同JVM中运行。
因此,Java虚拟机实际上和Java语言并非强制关联的关系,虚拟机只和二级制文件(Class文件)强关联。
1.2 class字节码解读1.2.1 class类文件结构class文件是一组以8字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑的地排列在文件之中,中间没有添加任何分隔符,这使得整个class文件中存储的内容几乎全部都是程序的必要的数据。当遇到需要占用8字节以上空间的数据项时,则会按照高位在前的方式分割成若干个8字节进行存储。
Class文件格式只有俩种数据类型:“无符号数”和“表”。
- 无符号数:属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照utf-8编码构成的字符串值。
- 表是由多个无符号数或者其他表作为数据项构成的复合数据类型,为了便于区分,所有表的命名都习惯性的以“_info” 结尾。表用于描述有层次关系的复合结构的数据,整个class文件本质上也可以是一张表,按严格顺序排列构成。
如下图,为class类结构:
2.1.1 class文件格式:
- 魔数和class文件的版本:每个class文件的头4个字节被称为魔数,它的唯一作用是确定这个文件是否为一个能被虚拟机接受的class文件。紧接着魔数的四个字节存储的是class文件的版本号:第5和第6个字节是次版本号,第7和第8个字节是主版本号。Java的版本号是从第45开始的。
- 常量池,紧接着主、次版本号之后的是常量池的入口,常量池可以比喻成class文件里面的源仓库,它是class文件结构中与其他项目关联最多的数据,通常也是占用class文件空间最大的数据项目之一,另外,他还是class文件中第一个出现的表类型的数据项目。常量池的入口需要放置一项u2类型的数据,代表常量池容量计数值。这个容量的计数是从1开始的不是从0开始。常量池中主要存放两大类常量:字面量和符号引用。字面量比较接近于Java层面的常量概念,如文本字符串、被声明为final的常量值等。符号引用则包括下面几类常量:
- 被模块导出或者开放的包类和接口的全限定名字段的名称和描述符方法的名称和描述符方法句柄和方法类型动态调用点和动态常量
常量池中每一项常量都是一个表,截至到jdk13,常量表中分别有17种不同类型的常量。
- 访问标志(access_flag):在常量池结束之后,紧接着的2个字节代表访问标志,这个表示用于是被一些类或者接口层次的访问信息,包括:这个class是类还是接口;是否定义为public;是否定义为abstract类型,等等。access_flag一共有16种标志位可以使用,当前只定义了9个,没有使用的标志位一律为0。
- 类索引(this_class)、父类索引(super_class)与接口索引集合(interfaces);类索引和父类索引都是一个u2类型的数据集合,接口索引集合是一组u2类型的数据集合 class文件中由这三项数据来确定该类型的继承关系。类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。接口索引集合用来描述这个类实现了哪些接口,这些被实现的接口将按implements关键字后的接口顺序从左到右排列在接口索引集合中。
- 字段表(field_class)用于描述接口或者类中声明的变量。包括类级别变量和实例级别的变量,但不包括在方法内部申明的局部变量。字段可以包括的修饰符有字段的作用域(public、protect)、实例变量还是类变量(static)、可变性(final)、并发可见性(volatile,是否从主内存读写)、可否被序列化(transient)、字段数据类型(基本类型、对象、数组)。上面各个修饰符要么有,要么没有,很适合使用标志位来表示。而字段和字段类型,只能引用常量池中的常量来描述。跟随着access_flag的标志的是两项索引值:name_index和description_index。它们都是对常量池的引用,分别代表字段的简单名称以及字段和方法的描述符。全限定名:类似:org/test/testclass;简单名称就是指没有类型和参数修饰的方法或者字段名称:类似 inc() inc、字段m m;方法和字段的描述符比较复杂
基本类型以及代表无返回值的void类型都用一个大写的字符表示,而对象则使用字段L加对象的全限定名来表示。对于数组,每一个维度将使用一个前置的[字符来描述,例如:java.lang.String[](#) -> [[Ljava.lang.String; 用来描述方法时,按照先参数列表后返回值的顺序描述,例如:int indexof(char[] source int first) ->([CI)I。字段表集合不会列出从父类或者父接口继承而来的字段,但有可能出现Java代码不存的字段。
- 方法表描述;class文件存储格式中对方法的描述与对字段的描述采用了几乎完全一样的方式,方法表的结构如同字段表一样,依次包括访问标志、名称索引、描述符索引、属性表集合。如果父类方法在子类中没有重写,方法表集合中就不会出现来自父类的方法信息。有可能出现编译器自己的方法.
- 属性表:class文件、字段表、方法表都可以携带自己的属性表集合,以描述某些场景的专用信息。下面为部分属性表信息。
Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数据(称为操作码)以及跟随其后的零至多个代表此操作所需的参数(称为操作数)构成。由于Java虚拟机采用面向操作数栈而不是面向寄存器的架构,所以大多数指令都不包括操作数,只有一个操作码,指令参数都放在操作数栈中。Java虚拟机的操作码为一个字节(0-255),这意味着指令集的操作码总数不能超过256条。class文件格式放弃了编译后代码的操作数对齐,这就意味着虚拟机在处理那些超过一个字节的数据时,不得不在运行时从字节中重建出具体的数据结构。
如下为Java虚拟机指令集支持的数据类型。
- 加载与存储指令:用于将数据在栈桢中的局部变量和操作数栈之间来回传输。例如:iload(将一个局部变量加载到操作数栈)、istore(将一个数值从操作数栈存到局部变量表)、bipush(将常量加载到操作数栈)
- 运算指令:用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入操作数栈顶。例如:iadd、isub、imul、idiv、irem、ineg。
- 类型转换指令:可以将两种不同的数值类型相互转换。
- 对象创建与访问指令:虽然类实例和数组都是对象,但Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令。对象创建之后,就可以使用对象访问指令获取对象实例的字段或者数组元素
- 创建类指令:new;创建数组指令:newarray,anewarray,multianewarray
- 访问类字段和实例字段:getfield,putfield,getstatic,putstatic
- 把一个数组元素加载到操作数栈的指令:baload,calod,等等
- 将一个操作数栈的元素存储到数组元素的指令:bastore,castore等等
- 取数组长度:arraylength;检查类实例类型的指令:instanceof,checkcast;
- 操作数栈指令:出栈(pop)、互相(swap)
- 控制转移指令:ifeq、iflt等等
- 方法调用和返回指令;invokevirtual(调用对象实例方法,根据对象的实际类型进行分配)、invokeinterface(调用接口方法,在运行时找一个实现了这个接口方法的对象)、invokespecoal(特殊处理的实例方法,类似私用方法、父类方法、初始化方法)、invokestatic(类静态方法)、invokedynamic(运行时动态解析出调用点限定符所引用的方法)。其分配逻辑由用户所设定的引导方法设定。返回指令:ireturn
- 异常处理指令:Java虚拟机中处理异常采用异常表来完成。
- 同步指令:Java虚拟机支持方法级别和方法内部一段指令序列的同步,这俩种都是使用monitro来实现的,同步一段指令序列通常由java语言中的synchronized语句块来表示,Java虚拟机中的指令有monitorenter和monitorexit来支持synchronized的语义。
1、Google自己设计的用于Android平台的虚拟机;
2、支持已转化为dex格式的java应用程序运行;dex是专为Dalvik设计的一种压缩格式
3、允许在有限的内存中同时运行多个虚拟机实例,并未每一个Dalvik应用作为一和独立的linux进程运行;
4、5.0以后,Google直接删除Dalvik,取而代之的是ART。
1.3.2 Dalvik与JVM区别1、Dalvik是基于寄存器,JVM基于栈;
2、Dalvik运行dex文件,JVM运行java字节码;
3、自Android2.2以后,Dalvik支持JIT(即时编译技术)。
1.3.3 ART(Android Runtime)1、在Dalvik下,应用每次运行,字节码都需要通过即时编译器转化为机器码,这样会拖慢应用的运行效率;
2、在ART下,应用第一次安装时,字节码就会预先变异成机器码,使其真正成为本地应用。这个过程叫做预编译(AOT),这样,每次启动和执行的时候都会更快。
Dalvik与ART区别最大的不同就是:Dalvik是即时编译,每次运行前都先编译;而ART采用预编译。
ART优缺点
优点:
1、系统性能显著提升;
2、应用启动更快,运行更快,体验更流畅;
3、更长的电池续航能力;
4、支持更低的硬件。
缺点:
1、机器码占用存储空间更大;
2、应用安装时间变长。
1.3.4 DexDex文件是Dalvik的可执行文件,Dalvik是针对嵌入式设备设计的java虚拟机,所以Dex文件和Class文件的结构上有很大区别。为了更好的利用嵌入式你设备的资源,Dalvik在java程序编译后,还需要用dx工具将编译产生的数个Class文件整合成一个Dex文件。这样其中的各个类就可以共享数据,减少冗余,使文件结构更加紧凑。
一个设备在执行Dex文件之前,需要优化该Dex文件并生成对应的Odex文件,然后该Odex文件被Dalvik执行。Odex文件本质是个Dex文件,只是针对目标平台做了相关优化,包括对内部字节码进行一系列处理,主要为字节码验证,替换优化及空方法消除。
1.3.5 Dalvik和Art区别安卓可以运行多个app,对应运行了多个dalvik实例,每一个应用都有一个独立的linux进程,独立的进程可以防止虚拟机崩溃造成所有程序都关闭。就像一条电灯泡上的电灯都是并联关系的,一个灯泡坏了其他灯泡不受影响,一个程序崩溃了其他程序也不受影响。
- Art一次编译,终身受用,提高app加载速度,运行速度,省电;不过安装时间略长,占Rom体积略大
- Dalvik占用Rom体积小,安装略快,不过加载app时间长,运行慢,更加耗电。
1.每个线程都有自己的栈,栈中存储的是栈帧。 2.在这个线程上正在执行的每个方法都各自对应一个栈帧。方法与栈帧是一对一的关系。 3.栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。
1.4.2 栈的运行原理1.JVM直接对java栈的操作只有两个,就是对栈帧的压栈和出栈。 2.在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的。这个栈帧被称为当前栈帧(Current Frame),与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)。 3.执行引擎运行的字节码只对当前栈帧进行操作。 4.如果该方法调用的其他的方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧。
栈的运行原理图: 如下图所示,有四个方法,方法1调用方法2 2调用3 3调用4。 这时栈中会有4个栈帧。当前栈帧是方法4对应的栈帧,位于栈顶。 方法执行完成,将依次出栈。出栈顺序为4,3,2,1。
5.栈帧是线程私有的,其它的线程不能引用另外一个线程的栈帧。
6.当前方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
7.Java函数返回方式有两种,使用return或者抛出异常。不管哪种方式,都会导致栈帧被弹出。
1.每个栈帧中存储着局部变量表
2.操作数栈
3.动态链接(指向运行时常量池的方法引用)
4.方法返回地址(或方法正常退出或者异常推出的意义)
5.一些附加信息
在JAVA虚拟机中以方法作为最基本的执行单元,“栈帧”则是用于支持虚拟机方法调用和执行的数据结构。它也是虚拟机运行时数据区中的栈中的栈元素。
从JAVA程序的角度来看,同一时刻,同一条线程里面,在调用堆栈的所有方法都同时处于执行状态。但对于执行引擎来讲,在活动线程中,只有栈顶的方法才是在运行的,即只有栈顶的方法是生效的,其被称为“当前栈帧”,与这个栈帧所关联的方法被称为"当前方法",执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。
栈帧中存储着方法的局部变量表,操作数栈,动态连接和方法返回地址。下面对这几个部分进行一一介绍。
1.5.1 局部变量表局部变量表示一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。局部变量表的容量以变量槽为最小单位,一个变量槽占用32位长度的内存空间,即栈中8个类型数据中除double和long需要占用两个变量槽之外,其余均占用一个变量槽。
需要注意的是,局部变量表是建立在线程的堆栈中的,即线程私有的数据,即对于变量槽的读写是线程安全的。
另外局部变量表中变量槽0通常存着this对象引用,其他数据从变量槽1开始存储,通过字节码指令store存入局部变量表,需要调用时,可通过load指令取出。同时为了节省栈帧占用的内存空间,局部变量表的变量槽是可以重用的,其作用域不一定会覆盖整个方法体,如果当前字节码的PC计数器已经超出某个变量的作用域,那么这个变量槽就可以交给其他变量来重用。
可以参照下面这段代码:
public void method1(){
int a = 0;
int b = 2;
int c = a b;
}
public void method2(){
int d = 0;
int e = 2;
int f = d e;
}
public void method1();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2 locals=4 args_size=1
0: iconst_0
1: istore_1
2: iconst_2
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: istore_3
8: return
LineNumberTable:
line 9: 0
line 10: 2
line 11: 4
line 12: 8
public void method2();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2 locals=4 args_size=1
0: iconst_0
1: istore_1
2: iconst_2
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: istore_3
8: return
LineNumberTable:
line 14: 0
line 15: 2
line 16: 4
line 17: 8
可以看到在两个不同的方法中,method2的d e f变量复用了method1中的a b c对应的变量槽。
这样虽然可以节省开销,却也会带来一定的问题,参考下面的代码:
public static void main(String[] args) {
{
byte[] b = new byte[64*1024*1024];
}
System.gc();
}
[GC (System.gc()) 68813K->66384K(123904K) 0.0017888 secs]
[Full GC (System.gc()) 66384K->66225K(123904K) 0.0074844 secs]
可以看到,本来应该被回收的数组b却并没有被回收,这主要是由于局部变量表的变量槽中依然还保存着对b的引用(虽然已经出了作用域,但该变量槽并没有被复用,因此引用关系依然保持),使得其无法被垃圾回收。可通过在代码块下方插入int a =0来复用相应的变量槽,打破引用关系,或者将b置为null,这两种方法均可以实现对b的回收。
另外局部变量表中的对象必须要进行赋值,不可以像类变量那样由系统赋予默认值
public class A{
int a;//系统赋值a = 0
public void method(){
int b;//错误,必须要赋值
}
}
1.5.2 操作数栈
操作数占主要用于方法中变量之间的运算,其主要原理是遇到运算相关的字节码指令(如iadd)时,将最接近栈顶的两个元素弹出进行运算。操作数栈的具体工作流程可参照下面以这段代码:
public void method1(){
int a = 0;
int b = 2;
int c = a b;
}
此外在虚拟机栈中,两个栈帧会重叠一部分,即让下面栈帧的部分操作数与上面栈帧的局部变量表的一部分重叠在一起,这样不仅可以节省空间,亦可以在调用方法时,直接共用一部分数据,无需进行额外参数的复制传递。
1.5.3 动态连接每个栈帧都包含一个指向运行时常量池中该栈帧所属的方法的引用,持有这个引用是为了支持方法调用过程中的动态连接,即每一次运行期间都要动态地将常量池中方法的符号引用转换为直接引用。
1.5.4 方法返回地址方法在执行完毕后,有两种方式退出这个方法。一是执行引擎遇到任意一个方法返回的字节码指令(return)。二是方法执行过程中出现了异常,并且在方法的异常表中没有找到对应的异常处理器,在方法退出后,必须返回最初方法被调用的位置,程序才能继续执行。而主调方法的PC计数器的值就可以作为返回地址,,栈帧中会保存着这个计数器的值。
1.6 Jclasslib与HSDB工具应用分析1.6.1 jclasslib应用分析下面要隆重介绍的是一款可视化的字节码查看插件:jclasslib。
大家可以直接在 Idea 插件管理中安装(安装步骤略)。
使用方法:
- 在 IDEA 打开想研究的类。
- 编译该类或者直接编译整个项目( 如果想研究的类在 jar 包中,此步可略过)。
- 打开“view” 菜单,选择“Show Bytecode With jclasslib” 选项。
- 选择上述菜单项后 IDEA 中会弹出 jclasslib 工具窗口。
那么有自带的强大的反汇编工具 javap 还有必要用这个插件吗?
这个插件的强大之处在于:
- 不需要敲命令,简单直接,在右侧方便和源代码进行对比学习。
- 字节码命令支持超链接,点击其中的虚拟机指令即可跳转到 jvms 相关章节,超级方便。
该插件对我们学习虚拟机指令有极大的帮助。
1.6.2HSDB的使用HSDB全称是HotSpotDebugger HotSpot虚拟机的调试工具 在使用的时候 需要程序处在暂停的状态 可以直接使用Idea的debug工具. 使用HSDB可以看到堆栈里面相关的内容
启动HSDB
无论哪种方式启动 都需要先知道当前java程序的进程号 我们使用jps命令 如下图所示:
然后我们使用命令 jhsdb hsdb --pid=87854 来启动HSDB 如下图所示:
使用HSDB查看JVM虚拟机栈信息
我们知道 在创建一个线程时 都会有一个为之分配一个jvm栈 如上图我们可以看到在java Threads中有5个线程 我们选中main线程 然后点击上面的查看栈信息的图标 如下图所示:
1:在原java Threads面板上 点第二个按钮 可召唤出Stack Memory for main 这个面板.
Stack Memory for main 面板主体有三大部分 如上图所述
2:最左侧是栈的内存地址
3:中间一列是该地址上存的值(大多是别的对象的地址)
4:最右侧是HotSpot的说明
5:在右侧的说明中 我们可以此时栈中有两个栈帧(Frame)
大家看到 Young com/platform/tools/jvm/Main$TestObject 这个我们定义的对象 记住这个地址0x00000001161d11e0 代表这个对象是在栈中被引用
使用HSDB查看堆信息
我们的对象大都是在堆里面 我们可以借助HSDB看堆中有多少个实例对象 如下图所示
1:点击 Tools->Object Histogram 打开右边的Object Histogram面板
2:在2处输入我们的类全名 然后点3望远镜搜索 在下面会显示 我们的类 有三个实例
4:可以双击选中我们的类 也可以放大镜 可以打开Show Objects of Type 面板 看到三个实例的详情
其中第三个 就是我们在栈中看到的方法内的成员变量.
对于另外两个 需要通过反向指针查询 看哪个类引用了这个实例 来看是哪个变量
HSDB使用revptrs 看实例引用
对于上面还有两个地址 我们不能确定是什么对象 所以我们可以通过指针反查来看他们被什么所引用 如下图所示:
如上图 我们可以看到 一个被Class对象所引用 是类静态变量 一个被jvm/Main 也就是我们Main类引用 是类成员变量. 通过这个我们也可以总结 静态变量 其实也是存在堆里面.
Class static及Klass的关系
这个版本的hsdb 有些指令不支持 如mem whatis等 所以要深入学习的小伙伴可以用jdk1.8的hsdb试下上述两个命令
多个Java对象(Java Object 在堆中)对应同一个Klass(在MetaSpace中)对应同一个Class对象(在堆中) 类的静态变量地址都存在Class对象的后面(所以也在堆中).
2.深入Android内存管理Android Runtime(ART)虚拟机和Dalvik虚拟机都使用分页(Paging)和** 内存映射(Memory-mapped file)来 管理内存。这意味着应用修改的任何内存,无论修改的方式是分配新对象还是轻触内存映射的页面,都会一直驻留在RAM中,并且无法换出。要从应用中释放内存,只能释放应用保留的对象引用,使内存可供垃圾回收器回收。这种情况有一个例外:对于任何未被修改的内存映射文件(例如:代码)** ,如果系统想要在其他位置使用其内存,可将其从RAM中换出。
1.1 Android虚拟机与JVM底层区别虚拟机: JVM的作用是把平台无关的.class里面的字节码翻译成平台相关的机器码,来实现跨平台。Dalvik和Art(安卓5.0之后使用的虚拟机)就是安卓中使用的虚拟机。
虚拟机是什么,Jvm Dalvik(DVM)与Art三者之间的区别
1.1.1 JVM和Android虚拟机的区别区别一: dvm执行的是.dex格式文件 jvm执行的是.class文件 android程序编译完之后生产.class文件,然后,dex工具会把 .class文件处理成 .dex文件,然后把资源文件和.dex文件等打包成.apk文件。apk就是android package的意思。 jvm执行的是.class文件。 区别二: dvm是基于寄存器的虚拟机 而jvm执行是基于虚拟栈的虚拟机。寄存器存取速度比栈快的多,dvm可以根据硬件实现最大的优化,比较适合移动设备 区别三: .class文件存在很多的冗余信息,dex工具会去除冗余信息,并把所有的.class文件整合到.dex文件中。减少了I/O操作,提高了类的查找速度
总结: JVM以Class为执行单元,Android虚拟机以Dex执行单元,编译流程JVM直接通过Javac即可加载。Android 虚拟机需要先编译成dex,然后编译成apk。最后执行 Android Art虚拟机在安装的时候讲dex缓存本地机器码,安装比较慢,耗存储空间 Android Dalvik虚拟机在程序运行过程中进行翻译。节省空间,耗cpu时间。以空间换时间的典型
1.1.2 dex和class到底在结构上有什么区别?dex 将文件划分为了 三个区域,这三个区域存储了整个工程中所有的java 文件的信息,所以 dex 在类越来越多的时候优势就提现出来了。他只要一个dex文件,很多区域都是可以进行复用的,减少了dex 文件的大小。
本质上他们是一样的,dex 是从 class 文件演变而来的,但是 calss 中存在了许多沉余信息,dex 去掉了沉余信息,并进行了整合
1.1.3 栈和寄存器的概念,你之前有深入理解过吗?总结: Java虚拟机都是基于栈的结构,而Dalvik虚拟机则是基于寄存器。基于栈的指令很紧凑, Java虚拟机使用的指令只占一个字节,因而称为字节码。 基于寄存器的指令由于需要指定源地址和目标地址,因此需要占用更多的指令空间。 Dalvik虚拟机的某些指令需要占用两个字节。 基于栈和基于寄存器的指令集各有优劣,一般而言,执行同样的功能, 基于栈的需要更多的指令(主要是load和store指令),而基于寄存器需要更多的指令空间。 栈需要更多指令意味着要多占用CPU时间,寄存器需要更多指令空间意味着数据缓冲(d-cache)更易失效。
1.2 垃圾回收Android Runtime(ART)虚拟机或者Dalvik虚拟机的受管内存环境会跟踪每次内存分配。一旦确定程序不再使用某块内存,它就会将该内存重新释放在堆中,无需程序员进行任何干预,这种回收受管内存环境中的未使用内存的机制称为垃圾回收。垃圾回收有两个目标:在程序中查找将来无法访问的数据对象,并且回收这些对象使用的资源。
Android的堆是分代的,这意味着它会根据分配对象的预期寿命和大小跟踪不同的分配存储分区,例如:最近分配的对象属于新生代,当某个对象保持活动状态达足够长的时间,可将其提升为较老代,然后是永久代。
堆的每一代对相应对象可占用的内存量都有其自身的专用上限。每当一代开始填满时,系统便会执行垃圾回收事件以释放内存。垃圾回收的持续时间取决于它回收的是哪一代对象以及每一代有多少个活动对象。
尽管垃圾回收速度非常快,但是仍然会影响应用的性能。通常情况下,我们无法从代码中控制何时发生垃圾回收事件,系统有一套专门确定何时执行垃圾回收的标准,当满足条件时,系统会停止执行进程并开始垃圾回收。如果在动画或者音乐播放等密集型处理循环过程中发生垃圾回收,则可能会增加处理时间,进而可能会导致应用中的代码执行超出建议的16ms阈值,无法实现高效、流畅的帧渲染。
此外,我们的代码流执行的各种工作可能迫使垃圾回收事件发生得更频繁或者导致其持续时间超过正常范围,例如:我们在Alpha混合动画的每一帧期间,在for循环的最内层分配多个对象,则可能在堆中创建大量的对象,在这种情况下,垃圾回收器会执行多个垃圾回收事件,并可能降低应用的性能。
1.3 内存问题1.3.1 共享内存为了在RAM中容纳所需的一切,Android会尝试跨进程共享RAM页面,它可以通过以下方式实现:
- 每个应用进程都从一个名为Zygote的现有进程分叉(fork) 。系统启动并加载通用框架(Framework)代码和资源(例如:Activity主题背景)时,Zygote进程随之启动。为启动** 新的应用进程,系统会分叉(fork)Zygote进程,然后在新进程中加载并运行应用代码,这种方法可以让框架(Framework)代码和资源分配的大多数RAM页面在所有应用进程之间共享**。
- 大多数静态数据会内存映射到一个进程中,这种方法使得数据不仅可以在进程之间共享,还可以在需要时换出。静态数据示例包括:Dalvik代码(通过将其放入预先链接的.odex文件中进行直接内存映射) 、应用资源(通过将资源表格设计为可内存映射的结构以及通过对齐APK的zip条目)和** 传统项目元素(例如:.so文件中的原生代码)** 。
- 在很多地方,Android使用明确分配的共享内存区域(通过ashmem或者gralloc)在进程间共享同一动态RAM。例如:窗口surface使用在应用和屏幕合成器之间共享的内存,而光标缓冲区则使用在内容提供器和客户端之间共享的内存。
Dalvik堆局限于每个应用进程的单个虚拟内存范围。这定义了逻辑堆大小,该大小可以根据需要增长,但不能超过系统为每个应用定义的上限。
堆的逻辑大小与堆使用的物理内存量不同。在检查应用堆时,Android会计算按比例分摊的内存大小(PSS)值,该值同时考虑与其他进程共享的脏页和干净页,但其数量与共享该RAM的应用数量成正比。此(PSS)总量是系统认为的物理内存占用量。
Dalvik堆不压缩堆的逻辑大小,这意味着Android不会对堆进行碎片整理来缩减空间。只有当堆末尾存在未使用的空间时,Android才能缩减逻辑堆大小,但是系统仍然可以减少堆使用的物理内存。垃圾回收之后,Dalvik遍历堆并查找未使用的页面,然后使用madvise将这些页面返回给内核,因此大数据块的配对分配和解除分配应该使所有(或者几乎所有)使用的物理内存被回收,但是从较小分配量中回收内存的效率要低很多,因为用于较小分配量的页面可能仍在与其他尚未释放的数据块共享。
1.3.3 限制应用内存为了维持多任务环境的正常运行,Android会为每个应用的堆大小设置硬性上限。不同设备的确切堆大小上限取决于设备的总体RAM大小。如果应用在达到堆容量上限后尝试分配更多内存,则可能会收到OutOfMemory异常。
在某些情况下,例如:为了确定在缓存中保存多少数据比较安全,我们可以通过调用getMemoryClass()方法** 查询系统以确定当前设备上确切可用的堆空间大小,这个方法返回一个整数,表示应用堆的可用兆字节数**。
1.3.4 切换应用当用户在应用之间切换时,Android会将非前台应用保留在缓存中。非前台应用就是指用户看不到或者未运行的前台服务(例如:音乐播放)的** 应用。例如:当用户首次启动某个应用时,系统会为其创建一个进程,但是当用户离开此应用时,该进程不会退出,系统会将该进程保留在缓存中,如果用户稍后返回该应用,系统就会重复使用该进程,从而加快应用切换速度**。
如果应用具有缓存的进程并且保留了目前不需要的资源,那么即使用户未使用应用,它也会影响系统的整体性能,当系统资源(例如:内存)不足时,它就会终止缓存中的进程,系统还会考虑终止占用最多内存的进程以释放RAM。
要注意的是,当应用处于缓存中时,所占用的内存越少,就越有可能免于被终止并得以快速恢复,但是系统也可能根据当下的需求不考虑缓存进程的资源使用情况而随时将其终止。
1.3.5 进程间的内存分配Android平台在运行时不会浪费可用的内存,它会一直尝试利用所有可用的内存。例如:系统会在应用关闭后将其保留在内存中,以便用户快速切回到这些应用,因此,通常情况下,Android设备在运行时几乎没有可用的内存,所以要在重要系统进程和许多用户应用之间正确分配内存,内存管理至关重要。
下面会讲解Android是如何为系统和用户应用分配内存的基础知识和操作系统如何应对低内存情况。
1.3.6 内存类型Android设备包含三种不同类型的内存:RAM、zRAM和存储器,如下图所示:
要注意的是,CPU和GPU访问同一个RAM。
- RAM是最快的内存类型,但其大小通常有限。高端设备通常具有最大的RAM容量。
- zRAM是用于交换空间的RAM分区。所有数据在放入zRAM时会进行压缩,然后在从zRAM向外复制时进行解压缩。这部分RAM会随着页面进出zRAM而增大或者缩小。设备制造商可以设置zRAM大小上限。
- 存储器中包含所有持久性数据(例如:文件系统等)和** 为所有应用、库和平台添加的对象代码。存储器比另外两种内存的容量大得多。在Android上,存储器不像在其他Linux实现上那样用于交换空间,因为频繁写入会导致这种内存出现损坏,并缩短存储媒介的使用寿命**。
随机存取存储器(RAM)分为** 多个页面。通常,每个页面为4KB的内存**。
系统会将页面视为可用或者已使用。可用的页面是未使用的RAM,已使用的页面是系统目前正在使用的RAM,可以分为以下类别:
- 缓存页:
- 有存储器中的文件(例如:代码或者内存映射文件)支持的内存。缓存内存有两种类型:
- 私有页:由一个进程拥有且未共享。
- 干净页:存储器中未经修改的文件副本,可由内核交换守护进程(kswapd)删除以增加** 可用内存**。
- 脏页:存储器中经过修改的文件副本,可由内核交换守护进程(kswapd)移动到** zRAM或者在zRAM中进行压缩以增加可用内存**。
- 共享页:由多个进程使用。
- 干净页:存储器未经修改的文件副本,可由内核交换守护进程(kswapd)删除以增加** 可用内存**。
- 脏页:存储器中经过修改的文件副本,允许通过内核交换守护进程(kswapd)或者通过明确使用** msync()或 munmap()将更改写回 存储器中的文件,以增加内存空间**。
- 匿名页:没有存储器中的文件支持的内存(例如:由设置了MAP_ANONYMOUS标记的mmap()进行分配)。
- 脏页:可由内核交换守护进程(kswapd)移动到** zRAM或者在zRAM中进行压缩以增加可用内存**。
要注意的是,干净页包含存在于存储器中文件(或者文件一部分)的** 精确副本。如果干净页不再包含文件的精确副本(例如:因应用操作所致),则会变成脏页。干净页可以删除,因为始终可以使用存储器中的数据重新生成它们;脏页不可以删除,否则数据将会丢失**。
内存不足管理
Android有两种处理内存不足情况的主要机制:内核交换守护进程和低内存终止守护进程。
内核交换守护进程(kswapd)
内核交换守护进程(kswapd)是** Linux内核的一部分,用于将已使用内存转换为可用内存。当设备上的可用内存不足时,该守护进程将变为活动状态。Linux内核设有可用内存上下限阈值。当可用内存降至下限阈值以下时,kswapd开始回收内存;当可用内存达到上限阈值时,kswapd停止回收内存**。
kswapd可以删除干净页来回收它们,因为这些页面受到存储器的支持且未经修改。如果某个进程尝试处理已删除的干净页,则系统会将该页面从存储器复制到RAM,这个操作成为请求分页。
下图展示的是由存储器支持的干净页已删除:
kswapd可以将缓存的私有脏页和匿名脏页移动到zRAM进行压缩,这样可以释放RAM中的可用内存(可用页面) 。如果某个进程尝试处理zRAM中的脏页,该页面将被解压缩并移回到RAM。如果与压缩页面关联的进程被终止,则该页面将从zRAM中删除。如果可用内存量低于特定阈值,系统会开始终止进程。
下图展示的是脏页被移至zRAM并进行压缩:
1.3.8 低内存终止守护进程(LMK)很多时候,内核交换守护进程(kswapd)不能为系统释放足够多的内存。在这种情况下,系统会使用onTrimMemory()方法** 通知应用内存不足,应该减少其分配量。如果这还不够,Linux内核会开始终止进程以释放内存,它会使用低内存终止守护进程(LMK)** 来执行此操作。
LMK使用一个名为oom_adj_score的内存不足分值来确定正在运行的进程的优先级,以此决定要终止的进程。最高得分的进程最先被终止。后台应用最先被终止,系统进程最后被终止。
下图列出了从高到低的LMK评分类别,评分最高的类别,即第一行中的项目将最先被终止:
- 后台应用(Background apps) :之前运行过且当前不处于活动状态的应用。LMK将首先从具有最高oom_adj_score的应用开始终止后台进程。
- 上一个应用(Previous app) :最近用过的后台应用。上一个应用比后台应用具有更高的优先级(得分更低) ,因为相比某个后台应用,用户更有可能切换到上一个应用。
- 主屏幕应用(Home app) :这是启动器应用。终止该应用会使壁纸消失。
- 服务(Services) :服务由应用启动,例如:同步或者上传到云端。
- 可觉察的应用(Perceptible apps) :用户可通过某种方式察觉到的非前台应用,例如:运行一个显示小界面的搜索或者听音乐。
- 前台应用(Foreground app) :当前正在使用的应用。终止前台应用看起来就像是应用崩溃了,可能会向用户提示设备出了问题。
- 持久性(服务)(Persisient) :这些是设备的核心服务,例如:电话和WLAN。
- 系统(System) :系统进程。这些进程被终止后,手机可能看起来即将重新启动。
- 原生(Native) :系统使用的极低级别的进程,例如:内核交互终止守护线程(kswapd) 。
要注意的是,设备制造商可以更改LMK的行为。
1.3.9 计算内存占用量内核会跟踪系统中的所有内存页面。
下图展示的是不同进程使用的页面:
在确定应用使用的内存量时,系统必须考虑共享的页面。访问相同服务或者库的应用将共享内存页面,例如:Google Play服务和某个游戏应用可能会共享位置信息服务,这样便很难确定属于整个服务和每个应用的内存量分别是多少。下图展示的是由两个应用共享的页面(中间) :
如果需要确定应用的内存占用量,可以使用以下任一指标:
- 常驻内存大小(RSS) :应用使用的共享和非共享页面的数量。
- 按比例分摊的内存大小(PSS) :应用使用的非共享页面的数量加上共享页面的均匀分摊数量(例如:如果三个进程共享3MB,则每个进程的PSS为1MB) 。
- 独占内存大小(USS) :应用使用的非共享页面数量(不包括共享页面) 。
如果操作系统想要知道所有进程使用了多少内存,那么按比例分摊的内存大小(PSS)非常有用,因为** 页面只统计一次,不过计算需要花很长时间,因为系统需要确定共享的页面以及共享页面的进程数量。常驻内存大小(RSS)不区分 共享和非共享页面,因此计算起来更快,更适合跟踪内存分配量的变化**。
1.3.10 管理应用内存随机存取存储器(RAM)在任何软件开发环境中都是一项** 宝贵资源,尤其是在移动操作系统中,由于物理内存通常都有限,因此RAM就更加宝贵了。虽然Android Runtime(ART)虚拟机和Dalvik虚拟机都执行例行的垃圾回收任务,但这并不意味着我们可以忽略应用分配和释放内存的位置和时间。我们仍然需要避免引入内存泄漏问题 (通常因为在静态成员变量中保留对象引用而引起) ,并且在适当时间(例如:生命周期回调)** 释放所有Reference对象。
1.3.11 监控可用内存和内存使用量我们需要先找到应用中内存使用问题,然后才能修复问题。可以使用Android Studio中的内存性能剖析器(Memory Profiler)来帮助我们** 查找和诊断内存问题**:
- 了解我们的应用在一段时间内如何分配内存。Memory Profiler可以显示实时图表,包括:应用的内存使用量、分配的Java对象数量和垃圾回收事件发生的时间。
- 发起垃圾回收事件,并在应用运行时拍摄Java堆的快照。
- 记录应用的内存分配情况,然后检查有分配的对象、查看每个分配的堆栈轨迹,并在Android Studio编辑器中跳转到对应的代码。
如上面所述,Android可以通过多种方式从应用中回收内存或者在必要时完全终止应用,从而释放内存以执行关键任务。为了进一步帮助平衡系统内存并避免系统需要终止我们的应用进程,我们可以在Activity类中实现ComponentCallback2接口并且重写onTrimMemory()方法,就可以在处于** 前台或者后台时监听与内存相关的事件,然后释放对象以响应指示系统需要回收内存的应用生命周期事件或者系统事件**,示例代码如下所示:
/**
* Created by TanJiaJun on 2020/7/7.
*/
class MainActivity : AppCompatActivity() ComponentCallbacks2 {
/**
* 当UI隐藏或者系统资源不足时释放内存。
* @param level 引发的与内存相关的事件
*/
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
when (level) {
ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN -> {
/**
* 释放当前持有内存的所有UI对象。
*
* 用户界面已经移动到后台。
*/
}
ComponentCallbacks2.TRIM_MEMORY_RUNNING_MODERATE
ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW
ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> {
/**
* 释放应用程序不需要运行的内存。
*
* 应用程序运行时,设备内存不足。
* 引发的事件表示与内存相关的事件的严重程度。
* 如果事件是TRIM_MEMORY_RUNNING_CRITICAL,那么系统将开始杀死后台进程。
*/
}
ComponentCallbacks2.TRIM_MEMORY_BACKGROUND
ComponentCallbacks2.TRIM_MEMORY_MODERATE
ComponentCallbacks2.TRIM_MEMORY_COMPLETE -> {
/**
* 释放进程能释放的尽可能多的内存。
*
* 该应用程序在LRU列表中,同时系统内存不足。
* 引发的事件表明该应用程序在LRU列表中的位置。
* 如果事件是TRIM_MEMORY_COMPLETE,则该进程将是第一个被终止的。
*/
}
else -> {
/**
* 发布任何非关键的数据结构。
*
* 应用程序从系统接收到一个无法识别的内存级别值,我们可以将此消息视为普通的低内存消息。
*/
}
}
}
}
要注意的是,onTrimMemory()方法是** Android4.0才添加的,对于早期版本,我们可以使用onLowMemory()方法,这个 回调方法大致相当于TRIM_MEMORY_COMPLETE**事件。
1.3.13 查看应该使用多少内存为了允许多个进程同时运行,Android针对为每个应用分配的堆大小设置了硬性限制,这个限制会因设备总体可用的RAM多少而异。如果我们的应用已达到堆容量上限并尝试分配更多内存,系统会抛出OutOfMemory异常。
为了避免用尽内存,我们可以查询系统以确定当前设备上可用的堆空间,可以通过调用getMemoryInfo()方法向系统查询此数值,这个方法会返回** ActivityManager.MemoryInfo对象,这个对象会提供与设备当前的内存状态有关的信息,例如:可用内存、总内存和内存阈值(如果达到此内存级别,系统就会开始终止进程)** 。ActivityManager.MemoryInfo对象还会提供一个布尔值lowMemory,我们可以根据这个值确定设备是否内存不足。示例代码如下所示:
fun doSomethingMemoryIntensive() {
// 在执行需要大量内存的逻辑之前,检查设备是否处于低内存状态
if (!getAvailableMemory().lowMemory) {
// 执行需要大量内存的逻辑
}
}
// 获取设备当前内存状态的MemoryInfo对象
private fun getAvailableMemory(): ActivityManager.MemoryInfo =
ActivityManager.MemoryInfo().also {
(getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).getMemoryInfo(it)
}
1.3.14 使用内存效率更高的代码结构
我们可以在代码中选择效率更高的方案,以尽可能降低应用的内存使用量。
1.3.15 谨慎使用服务(Service)如果我们的应用需要某项服务(Service)在** 后台执行工作,请不要让其保持运行状态,除非它真的需要运行作业,在服务完成任务后应该使其停止运行,否则可能会导致内存泄漏**。
在我们启动某项服务后,系统更倾向于让此服务的进程始终保持运行状态,这种行为会导致服务进程代价十分高昂,因为一旦服务使用了某部分RAM,那么这部分RAM就不再供其他进程使用,这样会减少系统可以在LRU缓存中保留的缓存进程数量,从而降低应用切换效率。当内存紧张,并且系统无法维护足够的进程以托管当前运行的服务时,就可能导致内存抖动。
通常应该避免使用持久性服务,因为它们会对可用内存提出持续性的要求,我们可以使用JobScheduler调度后台进程。
如果我们必须使用某项服务,则限制此服务的生命周期的最佳方式是使用IntentService,它会在处理完启动它的intent后立即自行结束。
1.3.16 使用经过优化的数据容器编程语言所提供的部分类并未针对移动设备做出优化,例如:常规HashMap实现的内存效率可能十分低下,因为每个映射都需要分别对应一个单独的条目对象。
Android框架包含几个经过优化的数据容器,例如:SparseArray、SparseBooleanArray和LongSparseArray,以SparseArray为例,它的效率更高,因为它可以避免系统需要对键(有时还对值)进行自动装箱(这会为每个条目分别创建1~2个对象) 。
根据业务需要,尽可能使用精简的数据结构,例如:数组。
1.3.17 谨慎对待代码抽象开发者往往会将抽象简单地当做一种良好的编程做法,因为抽象可以提高代码灵活性和维护性,不过抽象的代价很高,通常它们需要更多的代码才能执行,需要更多的时间和更多的RAM才能将代码映射到内存中,因此,如果抽象没有带来显著的好处时,我们就应该避免使用抽象。
1.3.18 针对序列化数据使用精简版Protobuf协议缓冲区(Protocol Buffers)是** Google设计的一种无关语言和平台并且可扩展的机制,用于对结构化数据进行序列化,它与XML类似,但是更小、更快也更简单。在移动端中使用精简版的Protobuf,因为常规Protobuf会生成极其冗长的代码,这会导致应用出现各种问题:例如:RAM使用量增多、APK大小显著增加和执行速度变慢**。
1.4 避免内存抖动如前面所述,垃圾回收事件通常不会影响应用的性能,不过如果在短时间内发生许多垃圾回收事件,就可能会快速耗尽帧时间,系统花在垃圾回收上的时间越多,能够花在呈现界面或者流式传输音频等其他任务上的时间就越少。
通常,内存抖动可能会导致出现大量的垃圾回收事件,实际上,内存抖动可以说明在给定时间内出现的已分配临时对象的数量,例如:我们在for循环中分配多个临时对象或者在View的onDraw()方法中创建** Paint对象或者Bitmap对象,在这两种情况下,应用都会快速创建大量对象,这些操作可以快速消耗新生代(young generation)区域中的所有可用内存,从而迫使垃圾回收事件发生**。
我们可以借助Android Studio中内存性能剖析器(Memory Profiler)找到** 内存抖动较高的位置,确定代码中问题区域后,尝试减少对性能至关重要的区域中的分配数量,可以考虑将某些代码逻辑从内部循环中移出或者使用工厂方法模式**。
移除会占用大量内存的资源和库
代码中的某些资源和库可能会在我们不知情的情况下吞噬内存,APK的总体大小(包括第三方库或者嵌入式资源)可能会影响应用的** 内存消耗量,我们可以通过从代码中移除任何冗余、不必要或者臃肿的组件、资源或者库,降低应用的内存消耗量**。
缩减总体APK大小
我们可以通过缩减应用的总体大小来显著降低应用的内存使用量。位图(bitmap)大小、资源、动画帧数和第三方库都会影响APK的大小。Android Studio和Android SDK提供了帮助我们缩减资源和外部依赖项大小的多种工具,这些工具可以缩减代码,例如:R8编译。
当我们使用Android Gradle插件3.4.0版本及更高版本构建项目时,这个插件不再使用ProGuard来执行编译时代码优化,而是与R8编译器协同工作来处理以下编译时任务:
- 代码缩减(即摇树优化(Tree Shaking)) :从应用及其库依赖项中检测并安全地移除未使用的类、字段、方法和属性(这使其成为了一个对于规避64K引用限制非常有用的工具) 。例如:如果我们仅使用某个库依赖项的少数几个API,缩减功能可以识别应用未使用的库代码,并且从应用中移除这部分代码。
- 资源缩减:从封装应用中移除不使用的资源,包括应用库依赖项中不使用的资源,这个功能可以与代码缩减功能结合使用,这样一来,移除不使用的代码后,也可以安全地移除不再引用的所有资源。
- 混淆处理:缩短类和成员的名称,从而减少DEX文件的大小。
- 优化:检查并重写代码,以进一步减少应用的DEX文件的大小。例如:如果R8检测到从未使用过某段if/else语句的else分支的代码,则会移除else分支的代码。
使用Android App Bundle上传应用(仅限于Google Play)
要在发布到Google Play时立即缩减应用大小,最简单的方法就是将应用发布为Android App Bundle,这是一种全新的上传格式,包含应用的所有编译好的代码和资源,Google Play负责处理APK生成和签名工作。
Google Play的新应用服务模式Dynamic Delivery会使用我们提供的App Bundle针对每位用户的设备配置生成并提供经过优化的APK,因此他们只需下载运行我们的应用所需的代码和资源,我们不需要再编译、签署和管理多个APK以支持不同的设备,而用户也可以获得更小、更优化的下载文件包。
要注意的是,Google Play规定我们上传的签名APK的压缩下载大小限制为不超过100MB,而对使用App Bundle发布的应用压缩下载大小限制为150MB。
使用Android Size Analyzer
Android Size Analyzer工具可让我们轻松地发现和实施多种缩减应用大小的策略,它可以作为Android Studio插件或者独立JAR使用。
在Android Studio中使用Android Size Analyzer
我们可以使用Android Studio中的插件市场下载Android Size Analyzer插件,可以按着以下步骤操作:
- 依次选择Android Studio>Preferences,如果是Windows的话,依次选择File>Settings。
- 选择左侧面板中的Plugins部分。
- 点击Marketplace标签。
- 搜索Android Size Analyzer插件。
- 点击分析器插件的Install按钮。
如下图所示:
安装插件后,从菜单栏依次选择Analyze>Analyze App Size,对当前项目运行应用大小分析,分析了项目后,系统会显示一个工具窗口,其中包含有关如何缩减应用大小的建议,如下图所示:
通过命令行使用分析器
我们可以从GitHub以TAR或者ZIP文件形式下载最新版本的Android Size Analyer,解压缩文件后,使用以下某个命令对Android项目或者Android App Bundle运行size-analyzer脚本(在Linux或者MacOS上)或者** size-analyzer.bat脚本(在Windows上)** :
./size-analyzer check-bundle <path-to-aab>
./size-analyzer check-project <path-to-project-directory>
1.4.1 了解APK结构
在讨论如何缩减应用的大小之前,有必要了解下APK的结构。APK文件由一个Zip压缩文件组成,其中包含构成应用的所有文件,这些文件包括Java类文件、资源文件和包含已编译资源的文件。
APK包含以下文件夹:
- META-INF/ :包含CERT.SF和CERT.RSA签名文件,以及MANIFEST.MF清单文件。
- assets/ :包含应用的资源,可以使用AssetManager对象检索这些资源。
- res/ :包含未编译到resources.arsc中的资源。
- lib/ :包含特定于处理器软件层的已编译代码。这个目录包含每种平台类型的子目录,例如:armeabi、armeabi-v7a、arm64-v8a、x86、x86_64和mips。
APK还包含以下文件,在这些文件中,只有AndroidManifest.xml是必需的:
- resources.arsc:包含已编译的资源,这个文件包含res/values/文件夹的所有配置中的** XML内容。打包工具会提取此XML内容,将其编译成二进制文件形式,并压缩内容,这些内容包括语言字符串和样式,以及未直接包含在resources.arsc文件中的内容(例如:布局文件和图片)的路径**。
- classes.dex:包含以Android Runtime(ART)虚拟机和Dalvik虚拟机可理解的DEX文件格式编译的类。
- AndroidManifest.xml:包含Android清单文件,这个文件列出了应用的名称、版本、访问权限和引用的库文件,它使用了Android的二进制XML格式。
APK的大小会影响应用加载速度、使用的内存量和消耗的电量。缩减APK大小的一种简单方法是缩减其包含的资源数量和大小,具体来说,我们可以移除应用不再使用的资源,并且可以用可伸缩的Drawable对象取代图片文件。
1.4.3 移除未使用的资源lint工具是Android Studio中附带的静态代码分析器,可以检测到res/文件夹中** 未被代码引用的资源,当lint工具发现项目中有可能未使用的资源时,会显示一条消息**,消息如下所示:
res/layout/preferences.xml: Warning: The resource R.layout.preferences appears
to be unused [UnusedResources]
要注意的是,lint工具不会扫描assets/文件夹、通过反射引用的资源和已链接至应用的库文件,此外,它不会移除资源,只会提醒我们它们的存在。
如果我们在应用的build.gradle文件中启用了shrinkResource,那么Gradle可以帮我们自动移除未使用的资源,示例代码如下:
android {
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android.txt') 'proguard-rules.pro'
}
}
}
要使用shrinkResource,我们必须启用代码缩减功能,在编译过程中,R8首先会移除未使用的代码,然后Android Gradle插件会移除未使用的资源。
在Android Gradle插件0.7版本及更高版本中,我们可以声明应用支持的配置。Gradle会使用resConfig和resConfigs变体以及defaultConfig选项将这些信息传递给编译系统,随后,编译系统会阻止来自其他不受支持配置的资源出现在APK中,从而缩减APK的大小。
要注意的是,代码缩减可以清理库的一些不必要代码,但可能无法移除大型内部依赖项。
1.4.4 尽量减少库中的资源使用量在开发Android应用时,我们通常需要使用外部库来提高应用的可用性和多功能性,例如:我们可以使用Glide来实现图片加载功能。
如果库是为服务器或者桌面设备设计的,则它可能包含应用不需要的许多对象和方法,如果库许可允许我们修改库,我们可以编辑库的文件来移除不需要的部分,我们还可以使用适合移动设备的库。
1.4.5 仅支持特定密度Android支持多种设备,涵盖了各种屏幕密度。在Android 4.4(API级别19)及更高版本中,框架支持各种密度:ldpi、mdpi、tvdpi、hdpi、xhdpi、xxhdpi和xxxhdpi。尽管Android支持所有这些密度,但是我们无需将光栅化资源导出为每个密度。
如果我们不添加用于特定屏幕密度的资源,Android会自动缩放为其他屏幕密度设计的资源,建议每个应用至少包含一个xxhdpi图片变体。
1.4.6 使用可绘制对象某些图片不需要静态图片资源,框架可以在运行时动态绘制图片。我们可以使用Drawable对象(XML中的shape元素)来** 动态绘制图片,它只会占用APK中的少量空间,此外,XML的Drawable对象可以生成符合Material Design准则的单色图片**。
1.4.7 重复使用资源我们可以为图片的变体添加单独的资源,例如:同一图片经过色调调整、阴影设置或者旋转的版本。建议重复使用同一组资源,并在运行时根据需要对其进行自定义。
在Android5.0(API级别21)及更高版本上,使用android:tint和android:tintMode属性可以更改资源的颜色,对于较低版本的平台,则使用ColorFilter类。
我们可以省略仅是另一个资源的旋转等效项的资源,下面例子展示了通过绕图片中心位置旋转180度,将拇指向上变成拇指向下,示例代码如下所示:
<?xml version="1.0" encoding="utf-8"?>
<rotate xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/ic_thumb_up"
android:fromDegrees="180"
android:pivotX="50%"
android:pivotY="50%" />
1.4.8 从代码进行渲染
我们可以通过按一定程序渲染图片来缩减APK大小,这样可以释放不少空间,因为不需要在APK中存储图片文件。
1.4.9 压缩PNG文件aapt工具可以在编译过程中通过无损压缩来优化放置在res/drawable/中的图片资源,例如:aapt工具可以通过调色板将不需要超过256种颜色的真彩色PNG转换为8位PNG,这样做会生成质量相同但内存占用量更小的图片。
要注意的是,aapt工具具有以下限制:
- aapt工具不会缩减asset/文件夹中包含的PNG文件。
- 图片文件需要使用256种或更少的颜色才可供aapt工具进行优化。
- aapt工具可能会扩充已压缩的PNG文件,为防止出现这种情况,我们可以在Gradle中使用cruncherEnabled标记为PNG文件停用此过程,示例代码如下:
- aaptOptions { cruncherEnabled = false }
压缩PNG和JPEG文件
我们可以使用pngcrush、pngquant或者zopflipng等工具缩减PNG文件的大小,同时不损失画质。所有这些工具都可以缩减PNG文件的大小,同时保持肉眼感知的画质不变。
pngcrush工具是最有效的:该工具会迭代PNG过滤器和zlib(Deflate)参数,使用过滤器和参数的每个组合来压缩图片,然后它会选择可产生最小压缩输出的配置。
要压缩JPEG文件,我们可以使用packJPG和guetzli等工具。
使用WebP文件格式
如果以Android3.2(API级别13)及更高版本为目标(target) ,我们可以使用WebP文件格式的图片代替PNG文件或者JPEG文件。WebP格式提供有损压缩(例如:JPEG)和** 透明度(例如:PNG)** ,不过与PNG或者JPEG相比,这种格式可以提供更好的压缩效果。
我们可以使用Android Studio将现有的BMP、JPG、PNG或者静态GIF图片转换成WebP格式。
要注意的是,Google Play只接受PNG格式的启动器图标。
使用矢量图形
我们可以使用矢量图形创建与分辨率无关的图标和其他可伸缩媒体,它可以极大地减少APK占用的空间。矢量图片在Android中以VectorDrawable对象的形式表示,100字节的文件可以生成与屏幕大小相同的清晰图片。
要注意的是,系统渲染每个VectorDrawable对象需要花费大量时间,使用VectorDrawable对象渲染较大的图片需要更长的时间才能显示在屏幕上,因此建议在显示小图片时才使用VectorDrawable对象。
将矢量图形用于动画图片
请勿使用AnimationDrawable创建逐帧动画,因为这样做需要为动画的每个帧添加单独的位图(bitmap)文件,而这样做就会大大增加APK的大小,应该改为使用AnimatedVectorDrawableCompat创建动画矢量可绘制资源。
1.5 减少原生(Native)和Java代码我们可以使用多种方法来缩减应用中的原生(Native)和** Java代码库的大小**。
1.5.1 移除不必要的生成代码确保了解自动生成任何代码所占用的空间,例如:许多协议缓冲区工具会生成过多的类和方法,这可能会使应用的大小增加一倍或者两倍。
1.5.2 避免使用枚举单个枚举会使应用的classes.dex文件增加大约1.0到1.4KB的大小,这些增加的大小会快速累积,产生复杂的系统或者共享库,如果可能,请考虑使用@IntDef注解和代码缩减移除枚举并将它们转换为整数,此类型转换可保留枚举的各种安全优势。
1.5.3 缩减原生二进制文件的大小如果我们的应用使用原生代码和Android NDK,我们还可以通过优化代码来缩减发布版应用的大小,移除调试符号和避免解压缩原生库是两项很实用的技术。
移除调试符号
如果应用正在开发中且仍需要调试,则使用调试符号非常合适,我们可以使用Android NDK中提供的arm-eabi-strip工具从原生库中移除不必要的调试符号,之后,我们就可以编译发布版本。
避免解压缩原生库
在构建应用的发布版本时,我们可以通过在应用清单的application元素中设置android:extractNativeLibs="false" ,将未压缩的.so文件打包在APK中。停用此标记可防止PackageManager在安装过程中将 .so文件从APK复制到文件系统,并具有减少应用更新的额外好处。使用Android Gradle插件3.6.0版本及更高版本构建应用时,插件会默认将此属性设为false。
1.6 维护多个精简APKAPK可能包含用户下载但从不使用的内容,例如:其他语言或者针对特定屏幕密度的资源。要确保为用户提供最小的下载文件,我们应该使用Android App Bundle将应用上传到Google Play。通过上传App Bundle,Google Play能够针对每位用户的设备配置生成并提供经过优化的APK,因此用户只需下载运行我们的应用所需的代码和资源,我们无需再编译、签署和管理多个APK以支持不同的设备,而用户也可以获得更小、更优化的下载文件包。
如果我们不打算将应用发布到Google Play,则可以将应用细分为多个APK,并按屏幕尺寸或者GPU纹理支持等因素进行区分。
当用户下载我们的应用时,我们的设备会根据设备的功能和设置接收正确的APK,这样的话设备就不会接收设备所不具备的功能和资源,例如:如果用户具有hdpi设备,则不需要为更高密度显示器提供的xxxhdpi资源。
1.7 使用Dagger2实现依赖注入依赖注入框架可以简化我们编写的代码,并提供一个可供我们进行测试及其他配置更改的自适应环境。
如果我们打算在应用中使用依赖注入框架,请考虑使用Dagger2。Dagger2不使用反射来扫描应用的代码,它的静态编译时实现意味着它可以在Android应用中使用,而不会带来不必要的运行时代价或者内存消耗量。
其他使用反射的依赖注入框架倾向于通过扫描代码中的注释来初始化进程,这个过程可能需要更多的CPU周期和RAM,并可能在应用启动时导致出现明显的延迟。
1.8 谨慎使用外部库外部库代码通常不是针对移动环境编写的,在移动客户端上运行可能效率低下。如果我们决定使用外部库,则可能需要针对移动设备优化该库,在决定使用该库之前,请提前规划,并在代码大小和RAM消耗量方面对库进行分析。
即使是一些针对移动设备进行优化的库,也可能因实现方式不同而导致问题,例如:一个库可能使用的是精简版Protobuf,而另一个库使用的是Micro Protobuf,导致我们的应用出现两种不同的Protobuf实现。日志记录、分析、图片加载框架以及许多我们意外之外的其他功能的不同实现都可能导致这种情况。
虽然ProGuard可以使用适当的标记移除API和资源,但是无法移除库的大型内部依赖项。我们所需要的这些库中的功能可能需要较低级别的依赖项。如果存在以下情况,这就特别容易导致出现问题:我们使用某个库中的Activity子类(往往会有大量的依赖项) 、库使用反射(这很常见,意味着我们需要花费大量的时间手动调整ProGuard以使其运行) 等。
此外,请避免针对数十个功能中的一两个功能使用共享库,这样会产生大量我们甚至根本用不到的代码和开销,在考虑是否使用这个库时,请查找与我们的需求十分契合的实现,否则,我们可以决定自己去创建实现。
3.类加载机制3.1 类的生命周期 3.1.1 加载阶段加载阶段可以细分如下
- 加载类的二进制流
- 数据结构转换,将二进制流所代表的静态存储结构转化成方法区的运行时的数据结构
- 生成java.lang.Class对象,作为方法区这个类的各种数据的访问入口
加载类的二进制流的方法
- 从zip包中读取。我们常见的JAR、AAR依赖
- 运行时动态生成。我们常见的动态代理技术,在java.reflect.Proxy中就是用ProxyGenerateProxyClass来为特定的接口生成代理的二进制流
验证是连接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
- 文件格式验证:如是否以魔数 0xCAFEBABE 开头、主、次版本号是否在当前虚拟机处理范围之内、常量合理性验证等。 此阶段保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个 Java类型信息的要求。
- 元数据验证:是否存在父类,父类的继承链是否正确,抽象类是否实现了其父类或接口之中要求实现的所有方法,字段、方法是否与父类产生矛盾等。 第二阶段,保证不存在不符合 Java 语言规范的元数据信息。
- 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。例如保证跳转指令不会跳转到方法体以外的字节码指令上。
- 符号引用验证:在解析阶段中发生,保证可以将符号引用转化为直接引用。
可以考虑使用 -Xverify:none 参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
3.1.3 准备为类变量分配内存并设置类变量初始值,这些变量所使用的内存都将在方法区中进行分配。
3.1.4 解析虚拟机将常量池内的符号引用替换为直接引用的过程。 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用进行
3.1.5 初始化到初始化阶段,才真正开始执行类中定义的 Java 程序代码,此阶段是执行 <clinit>() 方法的过程。
3.1.6 类加载的时机虚拟机规范规定了有且只有 5 种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始)
- 遇到new、getstatic 和 putstatic 或 invokestatic 这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。对应场景是:使用 new 实例化对象、读取或设置一个类的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)、以及调用一个类的静态方法。
- 对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
- 当初始化类的父类还没有进行过初始化,则需要先触发其父类的初始化。(而一个接口在初始化时,并不要求其父接口全部都完成了初始化)
- 虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类), 虚拟机会先初始化这个主类。
- 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。
注意:
- 通过子类引用父类的静态字段,不会导致子类初始化。
- 通过数组定义来引用类,不会触发此类的初始化。MyClass[] cs = new MyClass[10];
- 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
把实现类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作的代码模块称为“类加载器”。
将 class 文件二进制数据放入方法区内,然后在堆内(heap)创建一个 java.lang.Class 对象,Class 对象封装了类在方法区内的数据结构,并且向开发者提供了访问方法区内的数据结构的接口。
3.3 类的唯一性对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性。
即使两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类也不相等。 这里所指的“相等”,包括代表类的 Class 对象的 equals() 方法、 isAssignableFrom() 方法、isInstance() 方法的返回结果,也包括使用 instanceof 关键字做对象所属关系判定等情况
3.4 双亲委托机制如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
protected Class<?> loadClass(String name boolean resolve)
throws ClassNotFoundException
{
// First check if the class has already been loaded
//先从缓存中加没加载这个类
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
//从parent中加载
c = parent.loadClass(name false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
//加载不到,就自己加载
if (c == null) {
// If still not found then invoke findClass in order
// to find the class.
c = findClass(name);
}
}
return c;
}
好处
- 避免重复加载,当父加载器已经加载了该类的时候,就没有必要子ClassLoader再加载一次。
- 安全性考虑,防止核心API库被随意篡改。
- ClassLoader是一个抽象类,定义了ClassLoader的主要功能
- BootClassLoader是ClassLoader的子类(注意不是内部类,有些材料上说是内部类,是不对的),用于加载一些系统Framework层级需要的类,是Android平台上所有的ClassLoader的最终parent
- SecureClassLoader扩展了ClassLoader类,加入了权限方面的功能,加强了安全性
- URLClassLoader继承SecureClassLoader,用来通过URI路径从jar文件和文件夹中加载类和资源,在Android中基本无法使用
- BaseDexClassLoader是实现了Android ClassLoader的大部分功能
- PathClassLoader加载应用程序的类,会加载/data/app目录下的dex文件以及包含dex的apk文件或者java文件(有些材料上说他也会加载系统类,我没有找到,这里存疑)
- DexClassLoader可以加载自定义dex文件以及包含dex的apk文件或jar文件,支持从SD卡进行加载。我们使用插件化技术的时候会用到
- InMemoryDexClassLoader用于加载内存中的dex文件
-> ClassLoader.java 类
protected Class<?> loadClass(String name boolean resolve) throws ClassNotFoundException {
synchronized(this.getClassLoadingLock(name)) {
//先查找class是否已经加载过,如果加载过直接返回
Class<?> c = this.findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (this.parent != null) {
//委托给parent加载器进行加载 ClassLoader parent;
c = this.parent.loadClass(name false);
} else {
//当执行到顶层的类加载器时,parent = null
c = this.findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException var10) {
}
if (c == null) {
long t1 = System.nanoTime();
c = this.findClass(name);
PerfCounter.getParentDelegationTime().addTime(t1 - t0);
PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
//如果parent加载器中没有找到,
PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
this.resolveClass(c);
}
return c;
}
}
由子类实现
protected Class<?> findClass(String name) throws ClassNotFoundException {
throw new ClassNotFoundException(name);
}
BaseDexClassLoader类中findClass方法
protected Class<?> findClass(String name) throws ClassNotFoundException {
List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
// pathList是DexPathList,是具体存放代码的地方。
Class c = pathList.findClass(name suppressedExceptions);
if (c == null) {
ClassNotFoundException cnfe = new ClassNotFoundException(
"Didn't find class "" name "" on path: " pathList);
for (Throwable t : suppressedExceptions) {
cnfe.addSuppressed(t);
}
throw cnfe;
}
return c;
}
public Class<?> findClass(String name List<Throwable> suppressed) {
for (Element element : dexElements) {
Class<?> clazz = element.findClass(name definingContext suppressed);
if (clazz != null) {
return clazz;
}
}
if (dexElementsSuppressedExceptions != null) {
suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
}
return null;
}
public Class<?> findClass(String name ClassLoader definingContext
List<Throwable> suppressed) {
return dexFile != null ? dexFile.loadClassBinaryName(name definingContext suppressed) : null;
}
public Class loadClassBinaryName(String name ClassLoader loader List<Throwable> suppressed) {
return defineClass(name loader mCookie this suppressed);
}
private static Class defineClass(String name ClassLoader loader Object cookie
DexFile dexFile List<Throwable> suppressed) {
Class result = null;
try {
result = defineClassNative(name loader cookie dexFile);
} catch (NoClassDefFoundError e) {
if (suppressed != null) {
suppressed.add(e);
}
} catch (ClassNotFoundException e) {
if (suppressed != null) {
suppressed.add(e);
}
}
return result;
}
// 调用 Native 层代码
private static native Class defineClassNative(String name ClassLoader loader Object cookie DexFile dexFile)
- 重新发布版本代价大,成本高,不及时,用户体验差,对此有几种解决方案:
- Hybird:原生 H5混合开发,缺点是人工成本搞,用户体验不如纯原生方案好;
- 插件化:移植成本高,对老代码的改造费时费力,而且无法动态修改;
- 热修复技术,将补丁上传到云端,app可以直接从云端下来补丁直接应用;
- 热修复技术对于国内开发者来说是一个比较实用的功能,可以解决如下问题:
- 发布新版本代价较大,用户下载安装成本高;
- 版本更新的效率问题,需要较长时间来完成版本覆盖;
- 版本更新的升级率问题,不升级版本的用户得不到修复,强更又比较暴力。
- 小而重要的功能,需要短时间内完成版本覆盖,比如节日活动。
- 热修复的优势:无需发版,用户无感知,修复成功率高,用时短;
百家争鸣的热修复框架
- 手淘的Dexposed: 开源,底层替换方案 基于Xposed,针对Dalvik运行时的Java Method Hook技术,但对于Dalvik底层过于依赖,无法继续兼容Android5.0之后的ART,因此作罢;
- 支付宝的Andfix:开源,底层替换方案,借助Dexposed思想,做到了Dalvik和ART环境的全版本兼容,但其底层固定结构的替换方案稳定性不好,使用范围也存在着诸多限制,而且对于资源和so修复未能实现
- 阿里百川的Hotfix:开源,底层替换方案,依赖于Andfix并对业务逻辑解耦,安全性和易用性较好,但还是存在Andfix的缺点;
- Qzone超级补丁: 未开源,类加载方案,会侵入打包流程
- 美团的Robust:开源,Instant Run方案,
- 大众点评的Nuwa: 开源,类加载方案,
- 饿了么的Amigo:开源,类加载方案
- 微信的Tinker:开源,类加载方案
- 手淘的Sophix:未开源
- 热修复框架的核心技术主要有三类,分别是代码修复、资源修复和动态链接库修复
代码修复:
- 代码修复主要有三个方案,分别是底层替换方案、类加载方案和Instant Run方案
- 类加载方案需要重启App后让ClassLoader重新加载新的类,因为类是无法被卸载的,要想重新加载新的类就需要重启App,因此采用类加载方案的热修复框架是不能即时生效的。
优点:
- 不需要太多的适配;
- 实现简单,没有诸多限制;
缺点
- 需要APP重启才能生效(冷启动修复);
- dex插桩:Dalvik平台存在插桩导致的性能损耗,Art平台由于地址偏移问题导致补丁包可能过大的问题;
- dex替换:Dex合并内存消耗在vm head上,可能OOM,导致合并失败
- 虚拟机在安装期间为类打上CLASS_ISPREVERIFIED标志是为了提高性能的,强制防止类被打上标志会影响性能;
Dex分包
- 类加载方案基于Dex分包方案,而Dex分包方案主要是为了解决65536限制和LinearAlloc限制:
- 65536限制:DVM指令集的方法调用指令invoke-kind索引为16bits,最多能引用 65535个方法;
- LinearAlloc限制:DVM中的LinearAlloc是一个固定的缓存区,当方法数过多超出了缓存区的大小,安装时提示INSTALL_FAILED_DEXOPT;
- Dex分包方案: 打包时将应用代码分成多个Dex,将应用启动时必须用到的类和这些类的直接引用类放到主Dex中,其他代码放到次Dex中。当应用启动时先加载主Dex,等到应用启动后再动态的加载次Dex。主要有两种方案,分别是Google官方方案、Dex自动拆包和动态加载方案。
几种不同的实现:
- 将补丁包放在Element数组的第一个元素得到优先加载(QQ空间的超级补丁和Nuwa)
- 将补丁包中每个dex 对应的Element取出来,之后组成新的Element数组,在运行时通过反射用新的Element数组替换掉现有的Element 数组(饿了么的Amigo);
- 将新旧apk做了diff,得到patch.dex,然后将patch.dex与手机中apk的classes.dex做合并,生成新的classes.dex,然后在运行时通过反射将classes.dex放在Element数组的第一个元素(微信Tinker)
- Sophix:dex的比较粒度在类的维度,并且 重新编排了包中dex的顺序,classes.dex classes2.dex.. 可以看作是 dex文件级别的类插桩方案 对旧包中的dex顺序进行打破重组
- 其思想来源于Xposed框架,完美诠释了AOP编程,直接在Native层修改原有类(不需要重启APP),由于是在原有类进行修改限制会比较多,不能够增减原有类的方法和字段,因为这破坏原有类的结构(引起索引变化) 虽然限制多,但时效性好,加载轻快,立即见效;
优点
- 实时生效,不需要重新启动,加载轻快
缺点
- 兼容性差,由于 Android 系统每个版本的实现都有差别,所以需要做很多的兼容。
- 开发需要掌握 jni 相关知识 而且native异常排查难度更高
- 由于无法新增方法和字段,无法做到功能发布级别
几种不同的实现:
- 采用替换ArtMethod结构体中的字段,这样会有兼容问题,因为手机厂商的修改 以及 android版本的迭代可能会导致底层ArtMethod结构的差异,导致方法替换失败;(AndFix)
- 同时使用类加载和底层替换方案,针对小修改,在底层替换方案限制范 围内,还会再判断所运行的机型是否支持底层替换方案,是就采用底层替换(替换整个ArtMethod结构体,这样不会存在兼容问题),否则使用类加载替换;(Sophix)
Instant Run新特性的原理就是当进行代码改动之后,会进行增量构建,也就是仅仅构建这部分改变的代码,并将这部分代码以补丁的形式增量地部署到设备上,然后进行代码的热替换,从而观察到代码替换所带来的效果。其实从某种意义上讲,Instant Run和热修复在本质上是一样的。
Instant Run打包逻辑
- 接入Instant Run之后,与传统方式相比,在进行打包的时候会存在以下四个不同点
- manifest注入:InstantRun会生成一个自己的application,然后将这个application注册到manifest配置文件里面,这样就可以在其中做一系列准备工作,然后再运行业务代码;
- nstant Run代码放入主dex:manifest注入之后,会将Instant Run的代码放入到Android虚拟机第一个加载的dex文件中,包括classes.dex和classes2.dex,这两个dex文件存放的都是Instant Run本身框架的代码,而没有任何业务层的代码。
- 工程代码插桩——IncretmentalChange;这个插装里面会涉及到具体的IncretmentalChange类。
- 工程代码放入instantrun.zip;这里的逻辑是当整个App运行起来之后才回去解压这个包里面的具体工程代码,运行整个业务逻辑。
- Instant Run在第一次构建apk时,使用ASM在每一个方法中注入了类似如下的代码 (ASM 是一个 Java 字节码操控框架。它能被用来动态生成类或者增强既有类的功能)
//$change实现了IncrementalChange这个抽象接口。
//当点击InstantRun时,如果方法没有变化则$change为null,就调用return,不做任何处理。
//如果方法有变化,就生成替换类 假设MainActivity的onCreate方法做了修改,就会生成替换类MainActivity$override,
//这个类实现了IncrementalChange接口,同时也会生成一个AppPatchesLoaderImpl类,这个类的getPatchedClasses方法
//会返回被修改的类的列表(里面包含了MainActivity),根据列表会将MainActivity的$change设置为MainActivity$override
//因此满足了localIncrementalChange != null,会执行MainActivity$override的access$dispatch方法,
//access$dispatch方法中会根据参数”onCreate.(Landroid/os/Bundle;)V”执行MainActivity$override的onCreate方法,
//从而实现了onCreate方法的修改。
IncrementalChange localIncrementalChange = $change;
if (localIncrementalChange != null) {//2
localIncrementalChange.access$dispatch(
"onCreate.(Landroid/os/Bundle;)V" new Object[] { this
paramBundle });
return;
}
被废弃的Instant Run
Android Studio 3.5 中一个显著变化是引入了 Apply Changes,它取代了旧的 Instant Run。Instant Run 是为了更容易地对应用程序进行小的更改并测试它们,但它会产生一些问题。为了解决这一问题,谷歌已经彻底删除了 Instant Run,并从根本上构建了 Apply Changes ,不再在构建过程中修改 APK,而是使用运行时工具动态地重新定义类,它应该比立刻运行更可靠和更快。
优点
- 实时生效,不需要重新启动
- 支持增加方法和类
- 支持方法级别的修复,包括静态方法
- 对每个产品代码的每个函数都在编译打包阶段自动的插入了一段代码,插入过程对业务开发是完全透明
缺点
- 代码是侵入式的,会在原有的类中加入相关代码
- 会增大apk的体积
- 目前市面上大部分资源热修复方案基本都参考了Instant Run的实现, 其主要分两步:
- 创建新的AssetManager,并通过反射调用addAssetPath加载完整的新资源包;
- 找到所有之前引用到原有AssetManager的地方,通过反射,把引用处 替换为新AssetManager;
- 这里的具体原理可以参考章探索Android开源框架 - 10. 插件化原理中的资源加载部分;
- Sophix: 构造了一个package id为0x66的资源包(原有资源包为 0x7f),此包只包含改变了的资源项,然后直接在原有的AssetManager中 addAssetPath这个包就可以了,不修改AssetManager的引用处,替换更快更安全
- 主要是更新so,也就是重新加载so,主要用到了System的load和loadLibrary方法
- System.load(""): 传入so在磁盘的完整路径,用于加载指定路径的so
@CallerSensitive
public static void load(String filename) {
Runtime.getRuntime().load0(Reflection.getCallerClass() filename);
}
- System.loadLibrary(""):传入so名称,用于加载app安装后自动从apk包中复制到/data/data/packagename/lib下的so
@CallerSensitive
public static void loadLibrary(String libname) {
Runtime.getRuntime().loadLibrary0(Reflection.getCallerClass() libname);
}
- 最终都会调用到LoadNativeLibrary(),其主要做了如下工作:
- 判断so文件是否已经加载,若已经加载判断与class_Loader是否一样,避免so重复加载;
- 如果so文件没有被加载,打开so并得到so句柄,如果so句柄获取失败,就返回false,常见新的SharedLibrary,如果传入path对应的library为空指针,就将创建的SharedLibrary赋值给library,并将library存储到libraries_中;
- 查找JNI_OnLoad的函数指针,根据不同情况设置was_successful的值,最终返回该was_successful;
两种方案:
- 将so补丁插入到NativeLibraryElement数组的前部,让so补丁的路径先被返回和加载;
- 调用System.load方法来接管so的加载入口;