前言
本系列文章主要是汇总了一下大佬们的技术文章,属于
Android基础部分
,作为一名合格的安卓开发工程师,咱们肯定要熟练掌握java和android,本期就来说说这些~
[非商业用途,如有侵权,请告知我,我会删除]
DD一下:
Android进阶开发各类文档,也可关注公众号<Android苦做舟>获取。
1.Android高级开发工程师必备基础技能
2.Android性能优化核心知识笔记
3.Android+音视频进阶开发面试题冲刺合集
4.Android 音视频开发入门到实战学习手册
5.Android Framework精编内核解析
6.Flutter实战进阶技术手册
7.近百个Android录播视频+音视频视频dome
.......
Android虚拟机指令
1.指令集解读
1.1 JVM 跨语言与字节码
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文件、字段表、方法表都可以携带自己的属性表集合,以描述某些场景的专用信息。下面为部分属性表信息。
1.2.2 字节码与数据类型
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.3 Hotspot Dalvik ART关系对比
1.3.1 Dalvik简介
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 Dex
Dex文件是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.4 栈的存储结构和运行原理
1.4.1 栈中存储的是什么?
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.5 栈帧的内部结构
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.2
HSDB的使用
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
使用在
应用
和
屏幕合成器
之间
共享的内存
,而
光标缓冲区
则使用在
内容提供器
和
客户端
之间
共享的内存
。
1.3.2 分配与回收应用内存
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
实现上那样用于
交换空间
,因为
频繁写入
会导致这种
内存
出现
损坏
,并
缩短存储媒介的使用寿命
。
1.3.7 内存页面
随机存取存储器(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编辑器
中跳转到对应的代码。
1.3.12 释放内存以响应事件
如上面所述,
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格式
。
1.4.2 缩减资源数量和大小
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 维护多个精简APK
APK
可能包含
用户下载但从不使用的内容
,例如:
其他语言
或者
针对特定屏幕密度的资源
。要确保为用户提供
最小的下载文件
,我们应该使用
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来为特定的接口生成代理的二进制流
3.1.2 验证
验证是连接阶段的第一步,这一阶段的目的是为了确保 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];
- 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
3.2 类加载器
把实现类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作的代码模块称为“类加载器”。
将 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库被随意篡改。
3.5 Android中ClassLoader
- 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文件
3.6 ClassLoader的加载流程源码分析
-> 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)
3.7 热修复技术
3.7.1 热修复技术介绍
- 重新发布版本代价大,成本高,不及时,用户体验差,对此有几种解决方案:
- 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:未开源
3.7.2 热修复技术原理
- 热修复框架的核心技术主要有三类,分别是代码修复、资源修复和动态链接库修复
代码修复:
- 代码修复主要有三个方案,分别是底层替换方案、类加载方案和Instant Run方案
1. 类加载方案
- 类加载方案需要重启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顺序进行打破重组
2. 底层替换方案
- 其思想来源于Xposed框架,完美诠释了AOP编程,直接在Native层修改原有类(不需要重启APP),由于是在原有类进行修改限制会比较多,不能够增减原有类的方法和字段,因为这破坏原有类的结构(引起索引变化), 虽然限制多,但时效性好,加载轻快,立即见效;
优点
- 实时生效,不需要重新启动,加载轻快
缺点
- 兼容性差,由于 Android 系统每个版本的实现都有差别,所以需要做很多的兼容。
- 开发需要掌握 jni 相关知识, 而且native异常排查难度更高
- 由于无法新增方法和字段,无法做到功能发布级别
几种不同的实现:
- 采用替换ArtMethod结构体中的字段,这样会有兼容问题,因为手机厂商的修改 以及 android版本的迭代可能会导致底层ArtMethod结构的差异,导致方法替换失败;(AndFix)
- 同时使用类加载和底层替换方案,针对小修改,在底层替换方案限制范 围内,还会再判断所运行的机型是否支持底层替换方案,是就采用底层替换(替换整个ArtMethod结构体,这样不会存在兼容问题),否则使用类加载替换;(Sophix)
3. Instant Run方案
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的体积
4. 资源修复
- 目前市面上大部分资源热修复方案基本都参考了Instant Run的实现, 其主要分两步:
- 创建新的AssetManager,并通过反射调用addAssetPath加载完整的新资源包;
- 找到所有之前引用到原有AssetManager的地方,通过反射,把引用处 替换为新AssetManager;
- 这里的具体原理可以参考章探索Android开源框架 – 10. 插件化原理中的资源加载部分;
- Sophix: 构造了一个package id为0x66的资源包(原有资源包为 0x7f),此包只包含改变了的资源项,然后直接在原有的AssetManager中 addAssetPath这个包就可以了,不修改AssetManager的引用处,替换更快更安全
5. so库修复
- 主要是更新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的加载入口;