前言
说起热修复,已经是目前Android开发必备技能。我所了解的一种实现方式就是类加载方案,即 dex 插桩,这种思路在插件化中也会用到。除此之外,还有底层替换方案,即修改替换 ArtMethod。采用类加载方案的主要是以腾讯系为主,包括微信的 Tinker、饿了么的 Amigo;采用底层替换方案主要是阿里系的 AndFix 等。今天我将围绕热修复实现原理以及常见的热修复方式来讲解热修复。
目录
热修复的应用场景
热修复就是在APP上线以后,如果突然发现有缺陷了,重新走发布流程可能时间比较长,重新安装APP用户体验也不会太好;热修复就是通过发布一个插件,使APP运行的时候加载插件里面的代码,从而解决缺陷,并且对于用户来说是无感的(用户也可能需要重启一下APP)。
认识Java类的加载机制(双亲委派模型)
Java负责加载class文件的就是类加载器(ClassLoader),APP启动的时候,会创建一个自己的ClassLoader实例,我们可以通过下面的代码拿到当前的ClassLoader。
ClassLoader classLoader = getClassLoader();
Log.i(TAG, "[onCreate] classLoader" + ":" + classLoader.toString());
然后我们在看一下构造函数。在ClassLoader 这个类中的 loadClass() 方法,它调用的是另一个2个参数的重载 loadClass() 方法。
public Class<?> loadClass(String name) throws ClassNotFoundException {
return loadClass(name, false);
}
我们点进去深入看一下loadClass这个方法:
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) {
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;
}
通过分析loadClass方法,你会发现,ClassLoader加载类的方法就是loadClass,是通过双亲委派模型(Parents Delegation Model)实现类的加载的。既在加载一个字节码文件时,会询问当前的classLoader是否已经加载过此字节码文件。如果加载过,则直接返回,不再重复加载。如果没有加载过,则会询问它的Parent是否已经加载过此字节码文件,同样的,如果已经加载过,就直接返回parent加载过的字节码文件,而如果整个继承线路上的classLoader都没有加载过,才由child类加载器(即,当前的子classLoader)执行类的加载工作。整个流程大致可以归纳成如下三步:
1. 加载流程
- 检查当前的 classLoader 是否已经加载这个 class ,有则直接返回,没有则进行第2步。
- 调用父 classLoader 的 loadClass() 方法,检查父 classLoader 是否有加载过这个 class ,有则直接返回,没有就继续检查上上个父 classLoader ,直到顶层 classLoader。
- 如果所有的父 classLoader 都没有加载过这个 class ,则最终由当前 classLoader 调用 findClass() 方法,去dex文件中找出并加载这个 class 。
2. 优点
采用这种类的加载机制的优点就是如果一个类被 classLoader 继承线路上的任意一个加载器加载过,后续在整个系统的生命周期中,这个类都不会再被加载,大大提高了类的加载效率。
3. 作用
- 类加载的共享功能
一些Framework层级的类一旦被顶层classLoader加载过,会缓存到内存中,以后在任何地方用到,都不会去重新加载,大大提高了效率。 - 类加载的隔离功能
不同继承线路上的 classLoader 加载的类,肯定不是同一个类,这样可以避免某些开发者自己去写一些代码冒充核心类库,来访问核心类库中可见的成员变量。如 java.lang.String 在应用程序启动前就已经被系统加载好了,如果在一个应用中能够简单的用自定义的String类把系统中的String类替换掉的话,会有严重的安全问题。
Android运行流程
Android运行流程简单来讲大致可以分成如下四步:
- Android程序编译的时候,会将.java文件编译成.class文件;
- 然后将.class文件打包为.dex文件;
- 然后Android程序运行的时候,Android的Dalvik/ART虚拟机就加载.dex文件;
- 加载其中的.class文件到内存中来使用。
Android中的classloader
我们知道,Android和Java有很深的渊源。基于jvm的Java应用是通过ClassLoader对象来加载应用中的class的。而Android在Java的基础上,对jvm又做一层优化和封装。既采用的是dalvik虚拟机,类文件将被打包成dex文件,底层的虚拟机是不同的,所以它们的类加载器当然也会不同。
而常见的Android类加载器有如下四种,下面我们一一讲解这四种。
-
BootClassLoader : 加载Android Framework层中的class字节码文件(类似java的Bootstrap
ClassLoader) -
PathClassLoader : 加载已经安装到系统中的Apk的 class 字节码文件(类似java的 App ClassLoader )
-
DexClassLoader : 加载制定目录的class字节码文件(类似java中的 Custom ClassLoader )
-
BaseDexClassLoader : PathClassLoader 和 DexClassLoader的父类
而我们开发的APP一定会用到BootClassLoader、PathClassLoader这2个类加载器,可通过如下代码进行验证:
override fun initView(savedInstanceState: Bundle?) {
setContentView(binding.root)
var classLoader = classLoader
if (classLoader != null) {
Log.e(TAG, "myclassLoader = $classLoader")
while (classLoader!!.parent != null) {
classLoader = classLoader.parent
Log.e(TAG, "myclassLoader = $classLoader")
}
}
}
查看一下运行的日志信息如下:
2021-09-08 16:14:49.334 10117-10117/com.bnd.andserver.sample.debug E/com.bnd.andserver.sample.MainActivity: myclassLoader = dalvik.system.PathClassLoader[DexPathList[[zip file "/data/app/com.bnd.andserver.sample.debug-1/base.apk"],nativeLibraryDirectories=[/data/app/com.bnd.andserver.sample.debug-1/lib/arm64, /data/app/com.bnd.andserver.sample.debug-1/base.apk!/lib/arm64-v8a, /system/lib64, /vendor/lib64]]]
2021-09-08 16:14:49.334 10117-10117/com.bnd.andserver.sample.debug E/com.bnd.andserver.sample.MainActivity: myclassLoader = java.lang.BootClassLoader@43d3385
通过日志和上面代码,我们可以知道,可以通过上下文拿到当前类的类加载器( PathClassLoader ),然后通过getParent()得到父类加载器( BootClassLoader ),这是由于Android中的类加载器和java类加载器一样使用的是双亲委派模型。
我们知道,Android Studio不是所有源码都可以查看的,但是,通过查看一些简单的构造函数还是可以粗滤获知他们的关系的。下面给出可以查看BaseDexClassLoader、PathClassLoader、DexClassLoader的部分代码(并非最终源码)。
DexClassLoader 代码:
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//
package dalvik.system;
import java.io.File;
public class DexClassLoader extends BaseDexClassLoader {
public DexClassLoader(String dexPath, String optimizedDirectory, String librarySearchPath, ClassLoader parent) {
super((String)null, (File)null, (String)null, (ClassLoader)null);
throw new RuntimeException("Stub!");
}
}
PathClassLoader 代码:
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//
package dalvik.system;
import java.io.File;
public class PathClassLoader extends BaseDexClassLoader {
public PathClassLoader(String dexPath, ClassLoader parent) {
super((String)null, (File)null, (String)null, (ClassLoader)null);
throw new RuntimeException("Stub!");
}
public PathClassLoader(String dexPath, String librarySearchPath, ClassLoader parent) {
super((String)null, (File)null, (String)null, (ClassLoader)null);
throw new RuntimeException("Stub!");
}
}
BaseDexClassLoader 代码:
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//
package dalvik.system;
import java.io.File;
import java.net.URL;
import java.util.Enumeration;
public class BaseDexClassLoader extends ClassLoader {
public BaseDexClassLoader(String dexPath, File optimizedDirectory, String librarySearchPath, ClassLoader parent) {
throw new RuntimeException("Stub!");
}
protected Class<?> findClass(String name) throws ClassNotFoundException {
throw new RuntimeException("Stub!");
}
protected URL findResource(String name) {
throw new RuntimeException("Stub!");
}
protected Enumeration<URL> findResources(String name) {
throw new RuntimeException("Stub!");
}
public String findLibrary(String name) {
throw new RuntimeException("Stub!");
}
protected synchronized Package getPackage(String name) {
throw new RuntimeException("Stub!");
}
public String toString() {
throw new RuntimeException("Stub!");
}
}
通过初步分析你会发现,PathClassLoader、DexClassLoader都是继承自BaseDexClassLoader ,而BaseDexClassLoader 又是继承自ClassLoader,下面我们就一层一层分析。
认识PathClassLoader和DexClassLoader
PathClassLoader和DexClassLoader的源码都是属于系统级别的,我们无法在开发工具里面查看,有兴趣的同学可以研究一下Android的源码。这里主要介绍一下他们的使用场景。
先来介绍一下这两种Classloader在使用场景上的区别:
1. 使用场景的区别
1: PathClassLoader :只能加载已经安装到Android系统中的apk文件(/data/app目录),是Android默认使用的类加载器;
2: DexClassLoader :可以加载任意目录下的dex/jar/apk/zip文件,比PathClassLoader 更灵活,是实现热修复的重点。
2. 代码层面的差别
你会通过对比你会发现,PathClassLoader 与 DexClassLoader 都继承于BaseDexClassLoader 。PathClassLoader 与 DexClassLoader 在构造函数中都调用了父类的构造函数,但 DexClassLoader 多传了一个 optimizedDirectory 。
认识BaseDexClassLoader
通过观察 PathClassLoader 与 DexClassLoader 的源码我们就可以确定,真正有意义的处理逻辑肯定是在 BaseDexClassLoader 中,所以下面着重分析 BaseDexClassLoader 源码。下面是一个构造方法。
public BaseDexClassLoader(String dexPath, File optimizedDirectory, String libraryPath, ClassLoader parent) {
super(parent);
this.originalPath = dexPath;
this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
}
参数说明:
- dexPath:要加载的程序文件(一般是dex文件,也可以是jar/apk/zip文件)所在目录。
- optimizedDirectory:dex文件的输出目录(因为在加载jar/apk/zip等压缩格式的程序文件时会
解压出其中的dex文件,该目录就是专门用于存放这些被解压出来的dex文件的。 - libraryPath :加载程序文件时需要用到的库路径。
- parent :父加载器
注意:对于一个完整App的来说,程序文件指定的就是apk包中的 classes.dex 文件;但从热修复的角度来看,程序文件指的是补丁。因为PathClassLoader只会加载已安装包中的dex文件,而DexClassLoader不仅仅可以加载dex文件,还可以加载jar、apk、zip文件中的dex。而jar、apk、zip其实就是一些压缩格式,要拿到压缩包里面的dex文件就需要解压,所以,DexClassLoader在调用父类构造函数时会指定一个解压的目录。
类加载器肯定会提供有一个方法来供外界找到它所加载到的class,该方法就是 findClass() ,不过在PathClassLoader 和 DexClassLoader 源码中都没有重写父类的 findClass() 方法,但它们的父类BaseDexClassLoader就有重写 findClass() ,所以来看看 BaseDexClassLoader 的 findClass() 方法都做了哪些操作,代码如下:
private final DexPathList pathList;
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
// 实质是通过pathList的对象findClass()方法来获取class
Class c = pathList.findClass(name, suppressedExceptions);
if (c == null) {
ClassNotFoundException cnfe = new ClassNotFoundException("Didn't find class \"" + name
for (Throwable t : suppressedExceptions) {
cnfe.addSuppressed(t);
}
throw cnfe;
}
return c;
}
可以看到, BaseDexClassLoader 的 findClass() 方法实际上是通过 DexPathList 的 findClass() 方法来获取class的,而这个 DexPathList 对象恰好在之前的 BaseDexClassLoader 构造函数中就已经被创建好了,里面解析了dex文件的路径,并将解析的dex文件都存在this.dexElements里面。所以,下面就来看看 DexPathList 类中都做了什么。
认识Element集合之DexPathList
好了,直接先看DexPathList构造函数:
public DexPathList(ClassLoader definingContext, String dexPath, String libraryPath, File optimizedDirectory) {
…
//将解析的dex文件都存在this.dexElements里面
this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory);
this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,suppressedExceptions)
}
这个构造函数中,保存了当前的类加载器 definingContext ,并调用了 makeDexElements() 得到 Element 集合。
通过对splitDexPath(dexPath)源码的追踪,发现该方法的作用其实就是将dexPath目录下的所有程序文件转变成一个File集合。同时,dexPath是一个用冒号(“:”)作为分隔符把多个程序文件目录拼接起来的字符串,比如(如:/data/dexdir1:/data/dexdir2:…)。
接下来在分析 makeDexElements() 方法:
//解析dex文件
private static Element[] makeDexElements(ArrayList<File> files, File optimizedDirectory, ArrayList<I
// 1.创建Element集合
ArrayList<Element> elements = new ArrayList<Element>();
// 2.遍历所有dex文件(也可能是jar、apk或zip文件)
for (File file : files) {
ZipFile zip = null;
DexFile dex = null;
String name = file.getName();
...
// 如果是dex文件
if (name.endsWith(DEX_SUFFIX)) {
dex = loadDexFile(file, optimizedDirectory);
// 如果是apk、jar、zip文件(这部分在不同的Android版本中,处理方式有细微差别)
} else {
zip = file;
dex = loadDexFile(file, optimizedDirectory);
}
...
// 3.将dex文件或压缩文件包装成Element对象,并添加到Element集合中
if ((zip != null) || (dex != null)) {
elements.add(new Element(file, false, zip, dex));
}
}
// 4.将Element集合转成Element数组返回
return elements.toArray(new Element[elements.size()]);
}
通过分析DexPathList 的makeDexElements方法,你会发现,DexPathList 的构造函数是将一个个的程序文件(可能是dex、apk、jar、zip)封装成一个个 Element 对象,最后添加到Element集合中。
其实,Android的类加载器(不管是PathClassLoader,还是DexClassLoader,它们最后在加载文件时,都是只认dex文件,而loadDexFile()是加载dex文件的核心方法,他可以可以从jar、apk、zip中提取出dex。
然后我们再回头看一下ClassLoade()加载类的方法,就是loadClass(),最后调用findClass方法完成的;而DexPathList也是重写findClass()方法。如下:
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
// 使用pathList对象查找name类
Class c = pathList.findClass(name, suppressedExceptions);
return c;
}
最终是调用 pathList的findClass方法,看一下方法如下:
public Class findClass(String name, List<Throwable> suppressed) {
// 遍历从dexPath查询到的dex和资源Element
for (Element element : dexElements) {
DexFile dex = element.dexFile;
// 如果当前的Element是dex文件元素
if (dex != null) {
// 使用DexFile.loadClassBinaryName加载类
Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
if (clazz != null) {
return clazz;
}
}
}
if (dexElementsSuppressedExceptions != null) {
suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
}
return null;
}
其实 DexPathList 的 findClass() 方法很简单,就只是对 Element 数组进行遍历,一旦找到类名与name相同的类时,就直接返回这个 class ,找不到则返回null。
而采用DexFile的loadClassBinaryName()方法来加载class,是因为一个Element对象对应一个dex文件,而一个dex文件则包含多个class。也就是说Element数组中存放的是一个个的dex文件,而不是class文件。这可以从Element这个类的源码和dex文件的内部结构看出。
通过如上分析,我们发现整个类加载流程就是:
1: 类加载器BaseDexClassLoader先将dex文件解析放到pathList到dexElements里面
2: 加载类的时候从dexElements里面去遍历,看哪个dex里面有这个类就去加载,生成class对象
所以我们可以将自己的dex文件加载到dexElements里面,并且放在前面,加载的时候就可以加载我们插件中的类,不会加载后面的,从而替换掉原来的class。
热修复的原理
通过上面一系列分析,我们已经知道了热修复的实现原理:
热修复的原理就是将补丁 dex 文件放到 dexElements 数组靠前位置,这样在加载 class 时,优先找到补丁包中的 dex文件,加载到 class 之后就不再寻找,从而原来的 apk 文件中同名的类就不会再使用,从而达到修复的目的。
明白了Android类的加载机制和实现原理,接下来就是热修复的实现了。
热修复的实现
知道了原理,实现就比较简单了,就是添加新的dex对象到当前APP的ClassLoader对象(也就是BaseDexClassLoader)的pathList里面的dexElements。要添加就要先创建,我们先使用DexClassLoader先加载插件,然后在生成插件的dexElements,最后再添加就好了。
当然整个过程需要使用反射来实现。除此以外,常用的两种方法是使用apk作为插件和使用dex文件作为插件。下面的两个实现都是对程序中的一个方法进行了修改,然后分别打了 dex包和apk包,程序运行起来执行的方法就是插件里面的方法而不是程序本身的方法。
dex插件
对于dex文件作为插件,和之前说的流程完全一致,先将修改了的类进行打包成dex包,在将dex进行加载,插入到dexElements集合的前面即可。而打包流程是先将.java文件编译成.class文件,然后使用SDK工具打包成dex文件并发布到远程服务端,然后APP端请求下载,下载完毕加载即可。
apk插件
apk作为插件,就是我们重新打了一个新的apk包作为插件,打包很简单方便,缺点就是文件大。使用apk的话就没必要是将dex插入dexElements里面去,直接将之前的dexElements替换就可以了。下面看一下apk插件的具体实现。
apk插件的实现
// apk作为插件加载
private void apkPlugin() {
//插件包文件
File file = new File("/sdcard/FixDexTest.apk");
if (!file.exists()) {
Log.i("MApplication", "插件包不在");
return;
}
try {
//获取到 BaseDexClassLoader 的 pathList字段
// private final DexPathList pathList;
Field pathListField = BaseDexClassLoader.class.getDeclaredField("pathList");
//破坏封装,设置为可以调用
pathListField.setAccessible(true);
//拿到当前ClassLoader的pathList对象
Object pathListObj = pathListField.get(getClassLoader());
//获取当前ClassLoader的pathList对象的字节码文件(DexPathList )
Class<?> dexPathListClass = pathListObj.getClass();
//拿到DexPathList 的 dexElements字段
// private final Element[] dexElements;
Field dexElementsField = dexPathListClass.getDeclaredField("dexElements");
//破坏封装,设置为可以调用
dexElementsField.setAccessible(true);
//使用插件创建 ClassLoader
DexClassLoader pathClassLoader = new DexClassLoader(file.getPath(), getCacheDir().getAbsolutePath(), null, getClassLoader());
//拿到插件的DexClassLoader 的 pathList对象
Object newPathListObj = pathListField.get(pathClassLoader);
//拿到插件的pathList对象的 dexElements变量
Object newDexElementsObj = dexElementsField.get(newPathListObj);
//将插件的 dexElements对象设置给 当前ClassLoader的pathList对象
dexElementsField.set(pathListObj, newDexElementsObj);
} catch (Exception e) {
e.printStackTrace();
}
}
总结
经过对 PathClassLoader 、 DexClassLoader 、 BaseDexClassLoader 、 DexPathList 的分析,我们知道,安卓的类加载器在加载一个类时会先从自身DexPathList对象中的Element数组中获取( Element[]``dexElements )到对应的类,之后再加载。采用的是数组遍历的方式,不过注意,遍历出来的是一个个的dex文件。在for循环中,首先遍历出来的是dex文件,然后再是从dex文件中获取class,所以,我们只要让修复好的class打包成一个dex文件,放于 Element 数组的第一个元素,这样就能保证获取到的class是最新修复好的class了(当然,有bug的class也是存在的,不过是放在了 Element 数组的最后一个元素中,这样就避免拿到有bug的class。
原文链接:https://blog.csdn.net/ljx1400052550/article/details/115515676