Android Muitldex热更新修复方案原理

目前最热门的热更新由两种:一种是腾讯Tinker为代表的,需重启app的热更新;一种是美团app为代表的Instant Run,无需重启app

背景描述

产品已经上线,此时由于引起bug的代码只有一行,机智的程序员用最快的方式修复了这个bug,也只是改了一行代码。那么,产品已经在线上,怎么办?

我们通过后台,向APP推送了一个fix.dex文件,等这个文件下载完成,APP提示用户,发现新的更新,需要重启APP。待用户重启,代码修复即会生效。无需发布新版本!

按照正常的逻辑,我们做bug修复一定是把fix.dex放到服务器上,APP去服务器下载补丁包,然后存放在APP私有目录,重启APP之后,fix.dex生效。当加载到这个类的时候,就会去读fix.dex中当时打包的已修复bug的类。

为了演示方便,fix.dex文件直接放在assets,然后使用项目中的AssetsFileUtil类通过io流将它读写到APP私有目录下。

演示方法:

  1. 删掉fix.dex,运行APP,Bug复现
  2. 还原fix.dex,运行APP,Bug已修复

hook实现

核心类:ClassLoaderHookHelper,实现fix.dex补丁作用。有3个方法,分别是在不同的系统版本上,来对源码程序逻辑进行hook,提高hook的兼容性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
import java.io.File;
import java.io.IOException;
import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;

public class ClassLoaderHookHelper {

//23和19的差别,就是 makeXXXElements 方法名和参数要求不同
//后者是 makeDexElements(ArrayList<File> files, File optimizedDirectory, ArrayList<IOException> suppressedExceptions)
//前者是 makePathElements(List<File> files, File optimizedDirectory, List<IOException> suppressedExceptions)
public static void hookV23(ClassLoader classLoader, File outDexFilePath, File optimizedDirectory) throws IllegalAccessException, InvocationTargetException {
Field pathList = ReflectionUtil.getField(classLoader, "pathList"); //1、获得DexPathList pathList 属性
Object dexPathListObj = pathList.get(classLoader); //2、获得DexPathList pathList对象
Field dexElementsField = ReflectionUtil.getField(dexPathListObj, "dexElements"); //3、获得DexPathList的dexElements属性
Object[] oldElements = (Object[]) dexElementsField.get(dexPathListObj); //4、获得pathList对象中 dexElements 的属性值

List<File> files = new ArrayList<>(); //开始构建makeDexElements的实参
files.add(outDexFilePath);
List<IOException> ioExceptions = new ArrayList<>();
Method makePathElementsMethod = ReflectionUtil.getMethod( //获得 DexPathList 的 makePathElements 方法
dexPathListObj, "makePathElements", List.class, File.class, List.class);
assert makePathElementsMethod != null;
Object[] newElements = (Object[]) makePathElementsMethod.invoke( //这个方法是静态方法,所以不需要传实例,直接invoke。这里取得的返回值就是 我们外部的dex文件构建成的 Element数组
null, files, optimizedDirectory, ioExceptions); //构建出一个新的Element数组
//下面把新数组和旧数组合并,注意新数组放前面
Object[] dexElements = null;
if (newElements != null && newElements.length > 0) {
dexElements = (Object[]) Array.newInstance(oldElements.getClass().getComponentType(), oldElements.length + newElements.length); //先建一个新容器
//这5个参数解释一下, 如果是将A,B 你找AB的顺序组合成数组C,那么参数的含义,依次是 A对象,A数组开始复制的位置,C对象,C对象的开始存放的位置,数组A中要复制的元素个数
System.arraycopy(
newElements, 0, dexElements, 0, newElements.length); //新来的数组放前面
System.arraycopy(
oldElements, 0, dexElements, newElements.length, oldElements.length);
}
//最后把合并之后的数组设置到 dexElements里面
dexElementsField.set(dexPathListObj, dexElements);
}

public static void hookV19(ClassLoader classLoader, File outDexFilePath, File optimizedDirectory) throws IllegalAccessException, InvocationTargetException {
Field pathList = ReflectionUtil.getField(classLoader, "pathList"); //1、获得DexPathList pathList 属性
Object dexPathListObj = pathList.get(classLoader); //2、获得DexPathList pathList对象
Field dexElementsField = ReflectionUtil.getField(dexPathListObj, "dexElements"); //3、获得DexPathList的dexElements属性
Object[] oldElements = (Object[]) dexElementsField.get(dexPathListObj); //4、获得pathList对象中 dexElements 的属性值

List<File> files = new ArrayList<>(); //开始构建makeDexElements的实参
files.add(outDexFilePath);
List<IOException> ioExceptions = new ArrayList<>();
Method makePathElementsMethod = ReflectionUtil.getMethod( //获得 DexPathList 的 makeDexElements 方法
dexPathListObj, "makeDexElements", ArrayList.class, File.class, ArrayList.class); //别忘了后面的参数列表
Object[] newElements = (Object[]) makePathElementsMethod.invoke(
null, files, optimizedDirectory, ioExceptions); //这个方法是静态方法,所以不需要传实例,直接invoke;这里取得的返回值就是 我们外部的dex文件构建成的 Element数组

//下面把新数组和旧数组合并,注意新数组放前面
Object[] dexElements = null;
if (newElements != null && newElements.length > 0) {
dexElements = (Object[]) Array.newInstance(oldElements.getClass().getComponentType(), oldElements.length + newElements.length); //先建一个新容器
//这5个参数解释一下, 如果是将A,B 你找AB的顺序组合成数组C,那么参数的含义,依次是 A对象,A数组开始复制的位置,C对象,C对象的开始存放的位置,数组A中要复制的元素个数
System.arraycopy(
newElements, 0, dexElements, 0, newElements.length);//新来的数组放前面
System.arraycopy(
oldElements, 0, dexElements, newElements.length, oldElements.length);
}

//最后把合并之后的数组设置到 dexElements里面
dexElementsField.set(dexPathListObj, dexElements);
}

//14和19的区别,是这个方法 makeDexElements(ArrayList<File> files,File optimizedDirectory)···它又少了一个参数
public static void hookV14(ClassLoader classLoader, File outDexFilePath, File optimizedDirectory) throws IllegalAccessException, InvocationTargetException {
Field pathList = ReflectionUtil.getField(classLoader, "pathList"); //1、获得DexPathList pathList 属性
Object dexPathListObj = pathList.get(classLoader); //2、获得DexPathList pathList对象
Field dexElementsField = ReflectionUtil.getField(dexPathListObj, "dexElements"); //3、获得DexPathList的dexElements属性
Object[] oldElements = (Object[]) dexElementsField.get(dexPathListObj); //4、获得pathList对象中 dexElements 的属性值

List<File> files = new ArrayList<>(); //开始构建makeDexElements的实参
files.add(outDexFilePath);
List<IOException> ioExceptions = new ArrayList<>();
Method makePathElementsMethod = ReflectionUtil.getMethod( //获得 DexPathList 的 makeDexElements 方法
dexPathListObj, "makeDexElements", ArrayList.class, File.class); //别忘了后面的参数列表
Object[] newElements = (Object[]) makePathElementsMethod.invoke(
null, files, optimizedDirectory, ioExceptions); //这个方法是静态方法,所以不需要传实例,直接invoke;这里取得的返回值就是 我们外部的dex文件构建成的 Element数组

//下面把新数组和旧数组合并,注意新数组放前面
Object[] dexElements = null;
if (newElements != null && newElements.length > 0) {
dexElements = (Object[]) Array.newInstance(oldElements.getClass().getComponentType(), oldElements.length + newElements.length); //先建一个新容器
//这5个参数解释一下, 如果是将A,B 你找AB的顺序组合成数组C,那么参数的含义,依次是 A对象,A数组开始复制的位置,C对象,C对象的开始存放的位置,数组A中要复制的元素个数
System.arraycopy(
newElements, 0, dexElements, 0, newElements.length);//新来的数组放前面
System.arraycopy(
oldElements, 0, dexElements, newElements.length, oldElements.length);
}

//最后把合并之后的数组设置到 dexElements里面
dexElementsField.set(dexPathListObj, dexElements);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class MyApp extends Application {
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);

Log.d("yezhou", "" + getClassLoader()); //PathClassLoader
Log.d("yezhou", "" + getClassLoader().getParent()); //BootClassLoader

String fixPath = "fix.dex";
try {
String path = AssetsFileUtil.copyAssetToCache(this, fixPath);
File fixFile = new File(path);
if (Build.VERSION.SDK_INT >= 23) {
ClassLoaderHookHelper.hookV23(getClassLoader(), fixFile, getCacheDir());
} else if (Build.VERSION.SDK_INT >= 19) {
ClassLoaderHookHelper.hookV19(getClassLoader(), fixFile, getCacheDir());
} else if (Build.VERSION.SDK_INT >= 14) {
ClassLoaderHookHelper.hookV14(getClassLoader(), fixFile, getCacheDir());
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import java.lang.reflect.Field;
import java.lang.reflect.Method;

public class ReflectionUtil {

//得到指定类的指定成员变量 ,兼容该成员在父类中的情况
public static Field getField(Object instance, String fieldName) {
//下面这个,兼容该成员变量在父类中的情况
//解读:初始,clazz是当前类; 循环执行判定 clazz不为空; 变量变化:clazz赋值为它的父类
for (Class<?> clazz = instance.getClass(); clazz != null; clazz = clazz.getSuperclass()) {
try {
Field field = clazz.getDeclaredField(fieldName); //这个只是在本类中去找,如果我要的成员在父类中呢?
if (!field.isAccessible())
field.setAccessible(true);
return field;
} catch (NoSuchFieldException e) {
// e.printStackTrace();
}
}
return null;
}

//得到指定类的指定成员方法 ,兼容该成员在父类中的情况
public static Method getMethod(Object instance, String methodName, Class<?>... args) {
//下面这个,兼容该成员变量在父类中的情况
//解读:初始,clazz是当前类; 循环执行判定 clazz不为空;变量变化:clazz赋值为它的父类
for (Class<?> clazz = instance.getClass(); clazz != null; clazz = clazz.getSuperclass()) {
try {
Method method = clazz.getDeclaredMethod(methodName, args); //这个只是在本类中去找,如果我要的成员在父类中呢?
if (!method.isAccessible())
method.setAccessible(true);
return method;

} catch (NoSuchMethodException e) {
// e.printStackTrace();
}
}
return null;
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import android.content.Context;
import android.widget.Toast;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;

public class AssetsFileUtil {

/**
* 将assets中的文件拷贝到app的缓存目录,并且返回拷贝之后文件的绝对路径
*
* @param context
* @param fileName
* @return
*/
public static String copyAssetToCache(Context context, String fileName) {

File cacheDir = context.getFilesDir(); //app的缓存目录
if (!cacheDir.exists()) {
cacheDir.mkdirs(); //如果没有缓存目录,就创建
}
File outPath = new File(cacheDir, fileName); //创建输出的文件位置
if (outPath.exists()) {
outPath.delete(); //如果该文件已经存在,就删掉
}
InputStream is = null;
FileOutputStream fos = null;
try {
boolean res = outPath.createNewFile(); //创建文件,如果创建成功,就返回true
if (res) {
is = context.getAssets().open(fileName); //拿到main/assets目录的输入流,用于读取字节
fos = new FileOutputStream(outPath); //读取出来的字节最终写到outPath
byte[] buf = new byte[is.available()]; //缓存区
int byteCount;
while ((byteCount = is.read(buf)) != -1) { //循环读取
fos.write(buf, 0, byteCount);
}
Toast.makeText(context, "加载成功", Toast.LENGTH_SHORT).show();
return outPath.getAbsolutePath();
} else {
Toast.makeText(context, "创建失败", Toast.LENGTH_SHORT).show();
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (null != fos) {
fos.flush();
fos.close();
}
if (null != is)
is.close();
} catch (IOException e) {
e.printStackTrace();
}
}

return null;
}
}

热修复核心技术

其实热修复的核心技术,就一句话,Hook ClassLoader,但是要深入了解它,需要相当多的基础知识,下面列举出必须要知道的一些东西。

基础知识预备

Dex文件是什么

我们写安卓,目前还是用java比较多,就算是用kotlin,它最终也是要转换成java来运行。java文件,被编译成class之后,多个class文件,会被打包成classes.dex,被放到apk中,安卓设备拿到apk,去安装解析,当我们运行app时,app的程序逻辑全都是在classes.dex中。所以,dex文件是什么?一句话,dex文件是Android app的源代码的最终打包。

Dex文件如何生成

Android Studio 打包apk的时候会生成Dex,其实它使用的是SDK的dx命令,我们可以用dx命令自己去打包想要打包的class
命令格式为:dx --dex --output=output.dex xxxx.class
注:dx.bat在安卓SDK的目录下:比如我的 C:\AndroidSdk\build-tools\28.0.3\dx.bat

ClassLoader是什么

ClassLoader来自jdk,翻译为:类加载器,用于将class文件中的类,加载到内存中,生成class对象。只有存在class对象,我们才可以创建我们想要的对象。Android SDK继承了JDK的ClassLoader,创造出了新的ClassLoader子类。

用的比较多的是BaseDexClassLoaderDexClassLoaderPathClassLoader,其他这些,应该是谷歌大佬创造出来新的类加载器子类吧,还没研究过。

注:关于DexClassLoaderPathClassLoader,网上资料有个误区,应该不少人都认为,PathClassLoader用于加载app内部的dex文件,DexClassLoader用于加载外部的dex文件,但是其实只要看一眼这两个类的关系,就会发现,它们都是继承自BaseDexClassLoader,他们的构造函数内部都会去执行父类的构造函数。他们只有一个差别,那就是PathClssLoader不用传optimizedDirectory这个参数,但是DexClassLoader必须传。这个参数的作用是,传入一个dex优化之后的存放目录。而事实上,虽然PathClassLoader不要求传这个optimizedDirectory,但是它实际上是给了一个默认值。所以不要再认为PathClassLoader不能加载外部的dex了,它只是没有让传optimizedDirectory而已。

另外:

  • BootClassLoader 用于加载Android Framework层class文件(SDK中没有这个BootClassLoader,也是很奇怪)
  • PathClassLoader 是用于Android应用程序类的加载器,可以加载指定的dex,以及jar、zip、apk中的classes.dex
  • DexClassLoader 可以加载指定的dex,以及jar、zip、apk中的classes.dex

ClassLoader的双亲委托机制是什么

Android里面ClassLoader的作用,是将dex文件中的类,加载到内存中,生成Class对象,供我们使用
(举个例子:我写了一个A类,app运行起来之后,当我需要new一个A,ClassLoader首先会帮我查找A的Class对象是否存在,如果存在,就直接给我Class对象,让我拿去new A,如果不存在,就会出创建这个A的Class对象)。这个查找的过程,就遵循双亲委托机制。

一句话解释双亲委托机制:某个类加载器在加载某个类的时候,首先会将这件事委托给parent类加载器,依次递归,如果parent类加载器可以完成加载,就会直接返回Class对象。如果parent找不到或者没有父了,就会自己加载。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public abstract class ClassLoader {

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 { //如果没有parent,那就交给BootClassLoader去加载
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}

if (c == null) { //如果parent找到的是空,那就自己来findClass
// If still not found, then invoke findClass in order
// to find the class.
c = findClass(name);
}
}
return c;
}

hook思路

OK,现在可以来解读如何去hook ClassLoader了,解读之前,先弄清楚,为何要hook ClassLoader,为什么hook了它之后,我的fix.dex就能发挥作用?

先解决这个疑问,既然是hook,那么自然要读懂源码,因为hook就是在理解源码思维的前提下,更改源码逻辑。

按照上面图,去追踪源码,会发现,ClassLoader最终会从DexFile对象中去获得一个Class对象。并且在DexPathList类中findClass的时候,存在一个Element数组的遍历。

这就意味着,如果存在多个dex文件,多个dex文件中都存在同样一个class,那么它会从第一个开始找,如果找到了,就会立即返回。如果没找到,就往下一个dex去找。

也就是说,如果我们可以在这个数组中插入我们自己的修复bug的fix.dex,那我们就可以让我们已经修复bug的补丁类发挥作用,让类加载器优先读取我们的补丁类。

确定思路,我们要改变app启动之后,自带的ClassLoader对象(具体实现类是PathClassLoader)中DexPathListElement[]的实际值。

  1. 取得PathClassLoaderpathList的属性
  2. 取得PathClassLoaderpathList的属性真实值(得到一个DexPathList对象)
  3. 获得DexPathList中的dexElements属性
  4. 获得DexPathList对象中dexElements属性的真实值(它是一个Element数组)
  5. 用外部传入的Dex文件路径,构建一个我们自己的Element数组
  6. 将从外部传入的ClassLoader中得到的Element数组和 我们自己的Element数组合并起来,注意,我们自己的数组元素要放前面!
  7. 将刚才合并的新Element数组,设置到外部传入的ClassLoader里面去

TIPS

  • 当我们需要反射获得一个类的某个方法或者成员变量时,我们只想拿getDeclareXX,因为我们只想拿本类中的成员,但是仅仅getDeclareXX不能跨越继承关系拿到父类中的非私有成员,ReflectionUtil.java支持跨越继承关系拿到父类的非私有成员。

  • 这种热修复,是不是下载的包会很大,和原先的apk差不多大?答案是,NO,我们只需要将我们修复bug之后的补丁dex下载到设备,让app重启,去读取这个dex即可。补丁包很小,甚至只有1K.

  • 这种修复方式必须重启么?是的,必须重启,当然,存在不需要重启就可以修复bug的方法,那种方法叫做Instant Run方案,本文不涉及。而当前这种方案叫做:MultipleDex,即多dex方案。

  • 为什么要对SDK 23,19,14 写不同的hook代码?因为SDK版本的变迁,导致一些类的关系,变量名,方法名,方法参数(个数和类型)都会发生变化,所以,要针对各个变迁的版本进行兼容。

Powered by AppBlog.CN     浙ICP备14037229号

Copyright © 2012 - 2020 APP开发技术博客 All Rights Reserved.

访客数 : | 访问量 :