本系列文章主要是汇总了一下大佬们的技术文章,属于Android基础部分
,作为一名合格的安卓开发工程师,咱们肯定要熟练掌握java和android,本期就来说说这些~
[非商业用途,如有侵权,请告知我,我会删除]
DD一下: Android进阶开发各类文档,也可关注公众号
1.Android高级开发工程师必备基础技能
2.Android性能优化核心知识笔记
3.Android+音视频进阶开发面试题冲刺合集
4.Android 音视频开发入门到实战学习手册
5.Android Framework精编内核解析
6.Flutter实战进阶技术手册
7.近百个Android录播视频+音视频视频dome
.......
JVM
是跨语言的平台,很多语言都可以编译成为遵守规范的字节码,这些字节码都可以在Java
虚拟机上运行。Java虚拟机不关心这个字节码是不是来自于Java程序,只需要各个语言提供自己的编译器,字节码遵循字节码规范,比如字节码的开头是CAFEBABY
。
将各种语言编译成为字节码文件的编译器,称之为前端编译器。而Java
虚拟机中,也有编译器,比如即时编译器,此处称为后端编译器。
Java
虚拟机要做到跨语言,目前来看应该是当下最强大的虚拟机。但是并非一开始设计要跨语言。
由于有了跨语言平台,多语言混合编程就更加方便了,通过特定领域的语言去解决特定领域的问题。
比如并行处理使用Clojure
语言编写,展示层使用JRuby/Rails
,中间层用Java
编写,每一应用层都可以使用不同的语言编写,接口对于开发者是透明的。不同语言可以相互调用,就像是调用自己语言原生的API一样。它们都运行在同一个虚拟机上。
字节码狭义上是java
语言编译而成,但是由于JVM
是支持多种语言编译的字节码的,而字节码都是一个标准规范,因为我们应该称其为JVM
字节码。
不同的编译器,可以编译出相同的字节码文件,字节码文件也可以在不同操作系统上的不同JVM
中运行。
因此,Java
虚拟机实际上和Java
语言并非强制关联的关系,虚拟机只和二级制文件(Class
文件)强关联。
Class文件是一组以8字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑的地排列在文件之中,中间没有添加任何分隔符,这使得整个class文件中存储的内容几乎全部都是程序的必要的数据。当遇到需要占用8字节以上空间的数据项时,则会按照高位在前的方式分割成若干个8字节进行存储。
Class文件格式只有俩种数据类型:“无符号数”和“表”。
如下图,为class类结构:
2.1.1 class文件格式:
常量池中每一项常量都是一个表,截至到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文件存储格式中对方法的描述与对字段的描述采用了几乎完全一样的方式,方法表的结构如同字段表一样,依次包括访问标志、名称索引、描述符索引、属性表集合。如果父类方法在子类中没有重写,方法表集合中就不会出现来自父类的方法信息。有可能出现编译器自己的方法.
Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数据(称为操作码)以及跟随其后的零至多个代表此操作所需的参数(称为操作数)构成。由于Java虚拟机采用面向操作数栈而不是面向寄存器的架构,所以大多数指令都不包括操作数,只有一个操作码,指令参数都放在操作数栈中。Java虚拟机的操作码为一个字节(0-255),这意味着指令集的操作码总数不能超过256条。class文件格式放弃了编译后代码的操作数对齐,这就意味着虚拟机在处理那些超过一个字节的数据时,不得不在运行时从字节中重建出具体的数据结构。
加载与存储指令:用于将数据在栈桢中的局部变量和操作数栈之间来回传输。例如:iload(将一个局部变量加载到操作数栈)、istore(将一个数值从操作数栈存到局部变量表)、bipush(将常量加载到操作数栈)
运算指令:用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入操作数栈顶。例如:iadd、isub、imul、idiv、irem、ineg。
类型转换指令:可以将两种不同的数值类型相互转换。
对象创建与访问指令:虽然类实例和数组都是对象,但Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令。对象创建之后,就可以使用对象访问指令获取对象实例的字段或者数组元素
操作数栈指令:出栈(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、Dalvik是基于寄存器,JVM基于栈;
2、Dalvik运行dex文件,JVM运行java字节码;
3、自Android2.2以后,Dalvik支持JIT(即时编译技术)。
1、在Dalvik下,应用每次运行,字节码都需要通过即时编译器转化为机器码,这样会拖慢应用的运行效率;
2、在ART下,应用第一次安装时,字节码就会预先变异成机器码,使其真正成为本地应用。这个过程叫做预编译(AOT),这样,每次启动和执行的时候都会更快。
Dalvik与ART区别最大的不同就是:Dalvik是即时编译,每次运行前都先编译;而ART采用预编译。
ART优缺点
优点:
1、系统性能显著提升;
2、应用启动更快,运行更快,体验更流畅;
3、更长的电池续航能力;
4、支持更低的硬件。
缺点:
1、机器码占用存储空间更大;
2、应用安装时间变长。
Dex文件是Dalvik的可执行文件,Dalvik是针对嵌入式设备设计的java虚拟机,所以Dex文件和Class文件的结构上有很大区别。为了更好的利用嵌入式你设备的资源,Dalvik在java程序编译后,还需要用dx工具将编译产生的数个Class文件整合成一个Dex文件。这样其中的各个类就可以共享数据,减少冗余,使文件结构更加紧凑。
一个设备在执行Dex文件之前,需要优化该Dex文件并生成对应的Odex文件,然后该Odex文件被Dalvik执行。Odex文件本质是个Dex文件,只是针对目标平台做了相关优化,包括对内部字节码进行一系列处理,主要为字节码验证,替换优化及空方法消除。
安卓可以运行多个app,对应运行了多个dalvik实例,每一个应用都有一个独立的linux进程,独立的进程可以防止虚拟机崩溃造成所有程序都关闭。就像一条电灯泡上的电灯都是并联关系的,一个灯泡坏了其他灯泡不受影响,一个程序崩溃了其他程序也不受影响。
1.每个线程都有自己的栈,栈中存储的是栈帧。 2.在这个线程上正在执行的每个方法都各自对应一个栈帧。方法与栈帧是一对一的关系。 3.栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。
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程序的角度来看,同一时刻,同一条线程里面,在调用堆栈的所有方法都同时处于执行状态。但对于执行引擎来讲,在活动线程中,只有栈顶的方法才是在运行的,即只有栈顶的方法是生效的,其被称为“当前栈帧”,与这个栈帧所关联的方法被称为"当前方法",执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。
栈帧中存储着方法的局部变量表,操作数栈,动态连接和方法返回地址。下面对这几个部分进行一一介绍。
局部变量表示一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。局部变量表的容量以变量槽为最小单位,一个变量槽占用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;//错误,必须要赋值
}
}
操作数占主要用于方法中变量之间的运算,其主要原理是遇到运算相关的字节码指令(如iadd)时,将最接近栈顶的两个元素弹出进行运算。操作数栈的具体工作流程可参照下面以这段代码:
public void method1(){
int a = 0;
int b = 2;
int c = a+b;
}
此外在虚拟机栈中,两个栈帧会重叠一部分,即让下面栈帧的部分操作数与上面栈帧的局部变量表的一部分重叠在一起,这样不仅可以节省空间,亦可以在调用方法时,直接共用一部分数据,无需进行额外参数的复制传递。
每个栈帧都包含一个指向运行时常量池中该栈帧所属的方法的引用,持有这个引用是为了支持方法调用过程中的动态连接,即每一次运行期间都要动态地将常量池中方法的符号引用转换为直接引用。
方法在执行完毕后,有两种方式退出这个方法。一是执行引擎遇到任意一个方法返回的字节码指令(return)。二是方法执行过程中出现了异常,并且在方法的异常表中没有找到对应的异常处理器,在方法退出后,必须返回最初方法被调用的位置,程序才能继续执行。而主调方法的PC计数器的值就可以作为返回地址,,栈帧中会保存着这个计数器的值。
下面要隆重介绍的是一款可视化的字节码查看插件:jclasslib。
大家可以直接在 IDEA 插件管理中安装(安装步骤略)。
使用方法:
这个插件的强大之处在于:
该插件对我们学习虚拟机指令有极大的帮助。
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对象的后面(所以也在堆中).
Android Runtime(ART)虚拟机和Dalvik虚拟机都使用分页(Paging)和 内存映射(Memory-mapped file)来 管理内存。这意味着应用修改的任何内存,无论修改的方式是分配新对象还是轻触内存映射的页面,都会一直驻留在RAM中,并且无法换出。要从应用中释放内存,只能释放应用保留的对象引用,使内存可供垃圾回收器回收。这种情况有一个例外:对于任何未被修改的内存映射文件(例如:代码) ,如果系统想要在其他位置使用其内存,可将其从RAM中换出。
虚拟机: JVM的作用是把平台无关的.class里面的字节码翻译成平台相关的机器码,来实现跨平台。Dalvik和Art(安卓5.0之后使用的虚拟机)就是安卓中使用的虚拟机。
虚拟机是什么,Jvm,Dalvik(DVM)与Art三者之间的区别
区别一: 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时间。以空间换时间的典型
dex 将文件划分为了 三个区域,这三个区域存储了整个工程中所有的java 文件的信息,所以 dex 在类越来越多的时候优势就提现出来了。他只要一个dex文件,很多区域都是可以进行复用的,减少了dex 文件的大小。
本质上他们是一样的,dex 是从 class 文件演变而来的,但是 calss 中存在了许多沉余信息,dex 去掉了沉余信息,并进行了整合
总结: Java虚拟机都是基于栈的结构,而Dalvik虚拟机则是基于寄存器。基于栈的指令很紧凑, Java虚拟机使用的指令只占一个字节,因而称为字节码。 基于寄存器的指令由于需要指定源地址和目标地址,因此需要占用更多的指令空间。 Dalvik虚拟机的某些指令需要占用两个字节。 基于栈和基于寄存器的指令集各有优劣,一般而言,执行同样的功能, 基于栈的需要更多的指令(主要是load和store指令),而基于寄存器需要更多的指令空间。 栈需要更多指令意味着要多占用CPU时间,寄存器需要更多指令空间意味着数据缓冲(d-cache)更易失效。
Android Runtime(ART)虚拟机或者Dalvik虚拟机的受管内存环境会跟踪每次内存分配。一旦确定程序不再使用某块内存,它就会将该内存重新释放在堆中,无需程序员进行任何干预,这种回收受管内存环境中的未使用内存的机制称为垃圾回收。垃圾回收有两个目标:在程序中查找将来无法访问的数据对象,并且回收这些对象使用的资源。
Android的堆是分代的,这意味着它会根据分配对象的预期寿命和大小跟踪不同的分配存储分区,例如:最近分配的对象属于新生代,当某个对象保持活动状态达足够长的时间,可将其提升为较老代,然后是永久代。
堆的每一代对相应对象可占用的内存量都有其自身的专用上限。每当一代开始填满时,系统便会执行垃圾回收事件以释放内存。垃圾回收的持续时间取决于它回收的是哪一代对象以及每一代有多少个活动对象。
尽管垃圾回收速度非常快,但是仍然会影响应用的性能。通常情况下,我们无法从代码中控制何时发生垃圾回收事件,系统有一套专门确定何时执行垃圾回收的标准,当满足条件时,系统会停止执行进程并开始垃圾回收。如果在动画或者音乐播放等密集型处理循环过程中发生垃圾回收,则可能会增加处理时间,进而可能会导致应用中的代码执行超出建议的16ms阈值,无法实现高效、流畅的帧渲染。
此外,我们的代码流执行的各种工作可能迫使垃圾回收事件发生得更频繁或者导致其持续时间超过正常范围,例如:我们在Alpha混合动画的每一帧期间,在for循环的最内层分配多个对象,则可能在堆中创建大量的对象,在这种情况下,垃圾回收器会执行多个垃圾回收事件,并可能降低应用的性能。
为了在RAM中容纳所需的一切,Android会尝试跨进程共享RAM页面,它可以通过以下方式实现:
Dalvik堆局限于每个应用进程的单个虚拟内存范围。这定义了逻辑堆大小,该大小可以根据需要增长,但不能超过系统为每个应用定义的上限。
堆的逻辑大小与堆使用的物理内存量不同。在检查应用堆时,Android会计算按比例分摊的内存大小(PSS)值,该值同时考虑与其他进程共享的脏页和干净页,但其数量与共享该RAM的应用数量成正比。此(PSS)总量是系统认为的物理内存占用量。
Dalvik堆不压缩堆的逻辑大小,这意味着Android不会对堆进行碎片整理来缩减空间。只有当堆末尾存在未使用的空间时,Android才能缩减逻辑堆大小,但是系统仍然可以减少堆使用的物理内存。垃圾回收之后,Dalvik遍历堆并查找未使用的页面,然后使用madvise将这些页面返回给内核,因此大数据块的配对分配和解除分配应该使所有(或者几乎所有)使用的物理内存被回收,但是从较小分配量中回收内存的效率要低很多,因为用于较小分配量的页面可能仍在与其他尚未释放的数据块共享。
为了维持多任务环境的正常运行,Android会为每个应用的堆大小设置硬性上限。不同设备的确切堆大小上限取决于设备的总体RAM大小。如果应用在达到堆容量上限后尝试分配更多内存,则可能会收到OutOfMemory异常。
在某些情况下,例如:为了确定在缓存中保存多少数据比较安全,我们可以通过调用getMemoryClass()方法 查询系统以确定当前设备上确切可用的堆空间大小,这个方法返回一个整数,表示应用堆的可用兆字节数。
当用户在应用之间切换时,Android会将非前台应用保留在缓存中。非前台应用就是指用户看不到或者未运行的前台服务(例如:音乐播放)的 应用。例如:当用户首次启动某个应用时,系统会为其创建一个进程,但是当用户离开此应用时,该进程不会退出,系统会将该进程保留在缓存中,如果用户稍后返回该应用,系统就会重复使用该进程,从而加快应用切换速度。
如果应用具有缓存的进程并且保留了目前不需要的资源,那么即使用户未使用应用,它也会影响系统的整体性能,当系统资源(例如:内存)不足时,它就会终止缓存中的进程,系统还会考虑终止占用最多内存的进程以释放RAM。
要注意的是,当应用处于缓存中时,所占用的内存越少,就越有可能免于被终止并得以快速恢复,但是系统也可能根据当下的需求不考虑缓存进程的资源使用情况而随时将其终止。
Android平台在运行时不会浪费可用的内存,它会一直尝试利用所有可用的内存。例如:系统会在应用关闭后将其保留在内存中,以便用户快速切回到这些应用,因此,通常情况下,Android设备在运行时几乎没有可用的内存,所以要在重要系统进程和许多用户应用之间正确分配内存,内存管理至关重要。
下面会讲解Android是如何为系统和用户应用分配内存的基础知识和操作系统如何应对低内存情况。
Android设备包含三种不同类型的内存:RAM、zRAM和存储器,如下图所示:
要注意的是,CPU和GPU访问同一个RAM。
随机存取存储器(RAM)分为 多个页面。通常,每个页面为4KB的内存。
系统会将页面视为可用或者已使用。可用的页面是未使用的RAM,已使用的页面是系统目前正在使用的RAM,可以分为以下类别:
缓存页:
有存储器中的文件(例如:代码或者内存映射文件)支持的内存。缓存内存有两种类型:
私有页:由一个进程拥有且未共享。
共享页:由多个进程使用。
匿名页:没有存储器中的文件支持的内存(例如:由设置了MAP_ANONYMOUS标记的mmap()进行分配)。
要注意的是,干净页包含存在于存储器中文件(或者文件一部分)的 精确副本。如果干净页不再包含文件的精确副本(例如:因应用操作所致),则会变成脏页。干净页可以删除,因为始终可以使用存储器中的数据重新生成它们;脏页不可以删除,否则数据将会丢失。
内存不足管理
Android有两种处理内存不足情况的主要机制:内核交换守护进程和低内存终止守护进程。
内核交换守护进程(kswapd)
内核交换守护进程(kswapd)是 Linux内核的一部分,用于将已使用内存转换为可用内存。当设备上的可用内存不足时,该守护进程将变为活动状态。Linux内核设有可用内存上下限阈值。当可用内存降至下限阈值以下时,kswapd开始回收内存;当可用内存达到上限阈值时,kswapd停止回收内存。
kswapd可以删除干净页来回收它们,因为这些页面受到存储器的支持且未经修改。如果某个进程尝试处理已删除的干净页,则系统会将该页面从存储器复制到RAM,这个操作成为请求分页。
下图展示的是由存储器支持的干净页已删除:
kswapd可以将缓存的私有脏页和匿名脏页移动到zRAM进行压缩,这样可以释放RAM中的可用内存(可用页面) 。如果某个进程尝试处理zRAM中的脏页,该页面将被解压缩并移回到RAM。如果与压缩页面关联的进程被终止,则该页面将从zRAM中删除。如果可用内存量低于特定阈值,系统会开始终止进程。
很多时候,内核交换守护进程(kswapd)不能为系统释放足够多的内存。在这种情况下,系统会使用onTrimMemory()方法 通知应用内存不足,应该减少其分配量。如果这还不够,Linux内核会开始终止进程以释放内存,它会使用低内存终止守护进程(LMK) 来执行此操作。
LMK使用一个名为oom_adj_score的内存不足分值来确定正在运行的进程的优先级,以此决定要终止的进程。最高得分的进程最先被终止。后台应用最先被终止,系统进程最后被终止。
下图列出了从高到低的LMK评分类别,评分最高的类别,即第一行中的项目将最先被终止:
要注意的是,设备制造商可以更改LMK的行为。
内核会跟踪系统中的所有内存页面。
下图展示的是不同进程使用的页面:
在确定应用使用的内存量时,系统必须考虑共享的页面。访问相同服务或者库的应用将共享内存页面,例如:Google Play服务和某个游戏应用可能会共享位置信息服务,这样便很难确定属于整个服务和每个应用的内存量分别是多少。下图展示的是由两个应用共享的页面(中间) :
如果需要确定应用的内存占用量,可以使用以下任一指标:
如果操作系统想要知道所有进程使用了多少内存,那么按比例分摊的内存大小(PSS)非常有用,因为 页面只统计一次,不过计算需要花很长时间,因为系统需要确定共享的页面以及共享页面的进程数量。常驻内存大小(RSS)不区分 共享和非共享页面,因此计算起来更快,更适合跟踪内存分配量的变化。
随机存取存储器(RAM)在任何软件开发环境中都是一项 宝贵资源,尤其是在移动操作系统中,由于物理内存通常都有限,因此RAM就更加宝贵了。虽然Android Runtime(ART)虚拟机和Dalvik虚拟机都执行例行的垃圾回收任务,但这并不意味着我们可以忽略应用分配和释放内存的位置和时间。我们仍然需要避免引入内存泄漏问题 (通常因为在静态成员变量中保留对象引用而引起) ,并且在适当时间(例如:生命周期回调) 释放所有Reference对象。
我们需要先找到应用中内存使用问题,然后才能修复问题。可以使用Android Studio中的内存性能剖析器(Memory Profiler)来帮助我们 查找和诊断内存问题:
如上面所述,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事件。
为了允许多个进程同时运行,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)
}
我们可以在代码中选择效率更高的方案,以尽可能降低应用的内存使用量。
如果我们的应用需要某项服务(Service)在 后台执行工作,请不要让其保持运行状态,除非它真的需要运行作业,在服务完成任务后应该使其停止运行,否则可能会导致内存泄漏。
在我们启动某项服务后,系统更倾向于让此服务的进程始终保持运行状态,这种行为会导致服务进程代价十分高昂,因为一旦服务使用了某部分RAM,那么这部分RAM就不再供其他进程使用,这样会减少系统可以在LRU缓存中保留的缓存进程数量,从而降低应用切换效率。当内存紧张,并且系统无法维护足够的进程以托管当前运行的服务时,就可能导致内存抖动。
通常应该避免使用持久性服务,因为它们会对可用内存提出持续性的要求,我们可以使用JobScheduler调度后台进程。
如果我们必须使用某项服务,则限制此服务的生命周期的最佳方式是使用IntentService,它会在处理完启动它的intent后立即自行结束。
编程语言所提供的部分类并未针对移动设备做出优化,例如:常规HashMap实现的内存效率可能十分低下,因为每个映射都需要分别对应一个单独的条目对象。
Android框架包含几个经过优化的数据容器,例如:SparseArray、SparseBooleanArray和LongSparseArray,以SparseArray为例,它的效率更高,因为它可以避免系统需要对键(有时还对值)进行自动装箱(这会为每个条目分别创建1~2个对象) 。
根据业务需要,尽可能使用精简的数据结构,例如:数组。
开发者往往会将抽象简单地当做一种良好的编程做法,因为抽象可以提高代码灵活性和维护性,不过抽象的代价很高,通常它们需要更多的代码才能执行,需要更多的时间和更多的RAM才能将代码映射到内存中,因此,如果抽象没有带来显著的好处时,我们就应该避免使用抽象。
协议缓冲区(Protocol Buffers)是 Google设计的一种无关语言和平台并且可扩展的机制,用于对结构化数据进行序列化,它与XML类似,但是更小、更快也更简单。在移动端中使用精简版的Protobuf,因为常规Protobuf会生成极其冗长的代码,这会导致应用出现各种问题:例如:RAM使用量增多、APK大小显著增加和执行速度变慢。
如前面所述,垃圾回收事件通常不会影响应用的性能,不过如果在短时间内发生许多垃圾回收事件,就可能会快速耗尽帧时间,系统花在垃圾回收上的时间越多,能够花在呈现界面或者流式传输音频等其他任务上的时间就越少。
通常,内存抖动可能会导致出现大量的垃圾回收事件,实际上,内存抖动可以说明在给定时间内出现的已分配临时对象的数量,例如:我们在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编译器协同工作来处理以下编译时任务:
使用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插件,可以按着以下步骤操作:
如下图所示:
安装插件后,从菜单栏依次选择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
./size-analyzer check-project
在讨论如何缩减应用的大小之前,有必要了解下APK的结构。APK文件由一个Zip压缩文件组成,其中包含构成应用的所有文件,这些文件包括Java类文件、资源文件和包含已编译资源的文件。
APK包含以下文件夹:
APK还包含以下文件,在这些文件中,只有AndroidManifest.xml是必需的:
APK的大小会影响应用加载速度、使用的内存量和消耗的电量。缩减APK大小的一种简单方法是缩减其包含的资源数量和大小,具体来说,我们可以移除应用不再使用的资源,并且可以用可伸缩的Drawable对象取代图片文件。
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的大小。
要注意的是,代码缩减可以清理库的一些不必要代码,但可能无法移除大型内部依赖项。
在开发Android应用时,我们通常需要使用外部库来提高应用的可用性和多功能性,例如:我们可以使用Glide来实现图片加载功能。
如果库是为服务器或者桌面设备设计的,则它可能包含应用不需要的许多对象和方法,如果库许可允许我们修改库,我们可以编辑库的文件来移除不需要的部分,我们还可以使用适合移动设备的库。
Android支持多种设备,涵盖了各种屏幕密度。在Android 4.4(API级别19)及更高版本中,框架支持各种密度:ldpi、mdpi、tvdpi、hdpi、xhdpi、xxhdpi和xxxhdpi。尽管Android支持所有这些密度,但是我们无需将光栅化资源导出为每个密度。
如果我们不添加用于特定屏幕密度的资源,Android会自动缩放为其他屏幕密度设计的资源,建议每个应用至少包含一个xxhdpi图片变体。
某些图片不需要静态图片资源,框架可以在运行时动态绘制图片。我们可以使用Drawable对象(XML中的shape元素)来 动态绘制图片,它只会占用APK中的少量空间,此外,XML的Drawable对象可以生成符合Material Design准则的单色图片。
我们可以为图片的变体添加单独的资源,例如:同一图片经过色调调整、阴影设置或者旋转的版本。建议重复使用同一组资源,并在运行时根据需要对其进行自定义。
在Android5.0(API级别21)及更高版本上,使用android:tint和android:tintMode属性可以更改资源的颜色,对于较低版本的平台,则使用ColorFilter类。
我们可以省略仅是另一个资源的旋转等效项的资源,下面例子展示了通过绕图片中心位置旋转180度,将拇指向上变成拇指向下,示例代码如下所示:
我们可以通过按一定程序渲染图片来缩减APK大小,这样可以释放不少空间,因为不需要在APK中存储图片文件。
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创建动画矢量可绘制资源。
我们可以使用多种方法来缩减应用中的原生(Native)和 Java代码库的大小。
确保了解自动生成任何代码所占用的空间,例如:许多协议缓冲区工具会生成过多的类和方法,这可能会使应用的大小增加一倍或者两倍。
单个枚举会使应用的classes.dex文件增加大约1.0到1.4KB的大小,这些增加的大小会快速累积,产生复杂的系统或者共享库,如果可能,请考虑使用@IntDef注解和代码缩减移除枚举并将它们转换为整数,此类型转换可保留枚举的各种安全优势。
如果我们的应用使用原生代码和Android NDK,我们还可以通过优化代码来缩减发布版应用的大小,移除调试符号和避免解压缩原生库是两项很实用的技术。
移除调试符号
如果应用正在开发中且仍需要调试,则使用调试符号非常合适,我们可以使用Android NDK中提供的arm-eabi-strip工具从原生库中移除不必要的调试符号,之后,我们就可以编译发布版本。
避免解压缩原生库
在构建应用的发布版本时,我们可以通过在应用清单的application元素中设置android:extractNativeLibs=“false” ,将未压缩的.so文件打包在APK中。停用此标记可防止PackageManager在安装过程中将 .so文件从APK复制到文件系统,并具有减少应用更新的额外好处。使用Android Gradle插件3.6.0版本及更高版本构建应用时,插件会默认将此属性设为false。
APK可能包含用户下载但从不使用的内容,例如:其他语言或者针对特定屏幕密度的资源。要确保为用户提供最小的下载文件,我们应该使用Android App Bundle将应用上传到Google Play。通过上传App Bundle,Google Play能够针对每位用户的设备配置生成并提供经过优化的APK,因此用户只需下载运行我们的应用所需的代码和资源,我们无需再编译、签署和管理多个APK以支持不同的设备,而用户也可以获得更小、更优化的下载文件包。
如果我们不打算将应用发布到Google Play,则可以将应用细分为多个APK,并按屏幕尺寸或者GPU纹理支持等因素进行区分。
当用户下载我们的应用时,我们的设备会根据设备的功能和设置接收正确的APK,这样的话设备就不会接收设备所不具备的功能和资源,例如:如果用户具有hdpi设备,则不需要为更高密度显示器提供的xxxhdpi资源。
依赖注入框架可以简化我们编写的代码,并提供一个可供我们进行测试及其他配置更改的自适应环境。
如果我们打算在应用中使用依赖注入框架,请考虑使用Dagger2。Dagger2不使用反射来扫描应用的代码,它的静态编译时实现意味着它可以在Android应用中使用,而不会带来不必要的运行时代价或者内存消耗量。
其他使用反射的依赖注入框架倾向于通过扫描代码中的注释来初始化进程,这个过程可能需要更多的CPU周期和RAM,并可能在应用启动时导致出现明显的延迟。
外部库代码通常不是针对移动环境编写的,在移动客户端上运行可能效率低下。如果我们决定使用外部库,则可能需要针对移动设备优化该库,在决定使用该库之前,请提前规划,并在代码大小和RAM消耗量方面对库进行分析。
即使是一些针对移动设备进行优化的库,也可能因实现方式不同而导致问题,例如:一个库可能使用的是精简版Protobuf,而另一个库使用的是Micro Protobuf,导致我们的应用出现两种不同的Protobuf实现。日志记录、分析、图片加载框架以及许多我们意外之外的其他功能的不同实现都可能导致这种情况。
虽然ProGuard可以使用适当的标记移除API和资源,但是无法移除库的大型内部依赖项。我们所需要的这些库中的功能可能需要较低级别的依赖项。如果存在以下情况,这就特别容易导致出现问题:我们使用某个库中的Activity子类(往往会有大量的依赖项) 、库使用反射(这很常见,意味着我们需要花费大量的时间手动调整ProGuard以使其运行) 等。
此外,请避免针对数十个功能中的一两个功能使用共享库,这样会产生大量我们甚至根本用不到的代码和开销,在考虑是否使用这个库时,请查找与我们的需求十分契合的实现,否则,我们可以决定自己去创建实现。
加载阶段可以细分如下
加载类的二进制流的方法
验证是连接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
可以考虑使用 -Xverify:none
参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
为类变量分配内存并设置类变量初始值,这些变量所使用的内存都将在方法区中进行分配。
虚拟机将常量池内的符号引用替换为直接引用的过程。 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用进行
到初始化阶段,才真正开始执行类中定义的 Java 程序代码,此阶段是执行
方法的过程。
虚拟机规范规定了有且只有 5 种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始)
注意:
MyClass[] cs = new MyClass[10];
把实现类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作的代码模块称为“类加载器”。
将 class 文件二进制数据放入方法区内,然后在堆内(heap)创建一个 java.lang.Class 对象,Class 对象封装了类在方法区内的数据结构,并且向开发者提供了访问方法区内的数据结构的接口。
对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性。
即使两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类也不相等。 这里所指的“相等”,包括代表类的 Class 对象的 equals() 方法、 isAssignableFrom() 方法、isInstance() 方法的返回结果,也包括使用 instanceof 关键字做对象所属关系判定等情况
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
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.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 suppressedExceptions = new ArrayList();
// 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 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 suppressed) {
return dexFile != null ? dexFile.loadClassBinaryName(name, definingContext, suppressed) : null;
}
public Class loadClassBinaryName(String name, ClassLoader loader, List suppressed) {
return defineClass(name, loader, mCookie, this, suppressed);
}
private static Class defineClass(String name, ClassLoader loader, Object cookie,
DexFile dexFile, List 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)
百家争鸣的热修复框架
代码修复:
优点:
缺点
Dex分包
几种不同的实现:
优点
缺点
几种不同的实现:
Instant Run新特性的原理就是当进行代码改动之后,会进行增量构建,也就是仅仅构建这部分改变的代码,并将这部分代码以补丁的形式增量地部署到设备上,然后进行代码的热替换,从而观察到代码替换所带来的效果。其实从某种意义上讲,Instant Run和热修复在本质上是一样的。
Instant Run打包逻辑
//$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,而是使用运行时工具动态地重新定义类,它应该比立刻运行更可靠和更快。
优点
缺点
@CallerSensitive
public static void load(String filename) {
Runtime.getRuntime().load0(Reflection.getCallerClass(), filename);
}
@CallerSensitive
public static void loadLibrary(String libname) {
Runtime.getRuntime().loadLibrary0(Reflection.getCallerClass(), libname);
}
两种方案: