热修复基本原理
目前流行的热修复方案主要有以下三种:
- 代码修复
- 资源修复
- 动态链接库修复
本章主要讲述 第一种方案:代码修复。
代码修复
原理:对出现Bug的类进行修改或替换
类加载方案
核心思想:使用Android的类加载器,通过类加载器去加载已修复好Bug的Class并对有问题的Class进行覆盖。
加载完成后需要重启应用才可生效,因为当前在使用的类是无法卸载的即不可替换,只有重启后重新加载才可成功。
相关概念
65536限制
随着应用功能越来越复杂,代码量不断地增大,引入的库也会越来越多,可能导致出现异常
1
com.android.dex.DexIndexOverflowException:method ID not in [0,0xffff]:65536
应用中是限制了引用方法超过最大数65536个。限制是由于
DVM bytecode
的限制导致的,因为DVM指令集的方法调用指令invoke-kind
索引最大值为16bits,故为65536个方法。LinearAlloc限制
在安装应用时可能会提示
INSTALL_FAILED_DEXOPT
,产生的原因就是LinearAlloc
限制,LinearAlloc是一个固定的缓存区,超出即会报错。
为了解决上述的两个问题,产生了DEX分包方案。主要在打包时将应用代码分成多个Dex,将应用启动时必须的类以及直接引用类放入主Dex中,其他代码放到次Dex中。应用启动时就先去加载主Dex,然后动态加载次Dex,从而缓解上述限制。
原理分析
类加载方案需要通过ClassLoader
的实现类完成。在Android中主要有两种类加载器:
DexClassLoader
继承自BaseDexClassLoader,支持加载包含classes.dex的jar、apk,zip文件,可以是SD卡的路径。是实现热修复的关键。注意不要把优化后的文件放在外部存储,可能导致注入攻击。
/libcore/dalvik/src/main/java/dalvik/system/DexClassLoader.java 1
2
3
4
5
6public class DexClassLoader extends BaseDexClassLoader{
public DexClassLoader(String dexPath, String optimizedDirectory,
String librarySearchPath, ClassLoader parent) {
super(dexPath, null, librarySearchPath, parent);
}
}PathClassLoader
用来加载Android系统类和应用程序的类,在dalvik上只能加载已安装apk的dex(/data/app目录),在ART虚拟机上则没有这个限制。
/libcore/dalvik/src/main/java/dalvik/system/PathClassLoader.java 1
2
3
4
5
6
7
8
9public class PathClassLoader extends BaseDexClassLoader {
public PathClassLoader(String dexPath, ClassLoader parent) {
super(dexPath,null, null, parent);
}
public PathClassLoader(String dexPath, String librarySearchPath, ClassLoader parent) {
super(dexPath,null, librarySearchPath, parent);
}
}
上述类加载器都继承自BaseDexClassLoader
。
1 |
|
主要构造函数介绍:
dexPath
:指目标类所在的apk、dex或jar文件的路径,也可以是SD卡的路径
,类加载器从该路径加载目标类。如果包含多个路径,路径之间必须用特定的分隔符去分隔,特定的分隔符从System.getProperty("path.separtor")
获取(默认分割符为”:”)。最终将路径上的文件ODEX优化到optimizedDirectory,然后进行加载。optimizedDirectory
:解压出的dex文件路径,这个路径必须为内部路径,一般情况下的路径为/data/data/<Package_Name>/
librarySearchPath
:存放目标类中使用的native文件库,也以”:”分割parent
:父加载器,在Android中以context.getClassLoader
作为父加载器。
在Android8.0之后,
optimizedDirectory
参数失效。由子类去控制解压文件路径。
findClass()
用来加载dex中的Class文件。内部调用到DexPathList.findClass()
实现
DexPathList
内部存储的是一个个的Dex文件地址,方便后续进行寻找调用
1 |
|
保存当前的类加载器definingContext
,并调用makeDexElements()
初始化Element数组。
1 |
|
在makeDexElement
中,将传入的文件(Dex、apk、zip)封装成一个个的Element
对象,然后添加至Element集合中。
在Android的类加载器中,他们只会去解析
dex文件
。通过loadDexFile()
就可以将其他类型的文件转换成dex文件
以供加载。
findClass()
:寻找类名相同的类并返回
1 |
|
在findClass()
中,对Elements数组进行遍历,一旦找到与传入类名相同的类即返回。
Element内部封装了DexFile,DexFile用于加载Dex文件,因此一个Element对象会对应一个Dex文件。多个Element组成了有序数组
dexElements
。需要查找类时便去遍历dexElements
,再去调用findClass()
查找类。在Dex中存储的是一堆Class文件,需要在dex文件中通过loadClassBinaryName()
去找寻对应的Class文件。如果没有找到就接着去下一个Element中寻找。
实现原理
经过上述的源码分析,加载一个类时都会从dexElements
数组获取到对应的类之后再进行加载。遍历过程由数组头部开始。所以我们可以将已修复好的Class打包成一个Dex文件并放置到dexElements
数组的第一个位置(也解决了CLASS_ISPREVERIFIED问题,当打上该标记时该类就无法被替换),这样就可以保证已修复好的Class会被优先加载而排在数组后面的Bug类就不会被加载(由于双亲委托机制)。
{% fullimage /images/类加载方案.png,类加载方案,类加载方案%}双亲委托机制:如果一个类加载器收到了类加载的请求,不会自己去尝试加载这个类,而把这个请求委派给父类加载器去完成,每一层都是如此,依次向上递归,直到委托到最顶层的
Bootstrap ClassLoader
,若父加载器无法处理加载请求(它的搜索范围内没有找到所需的类时),则交由子加载器去加载。双亲委托机制的好处:
- 避免重复加载,若Class已被加载则从缓存中获取不会重新加载
- 更加安全,例如
java.lang.Object
基础类的加载都需要最终委派到BootstrapClassLoader
进行加载,即时去自定义类加载器进行加载也不会产生多个类。
修复实战
制作一个有Bug的类
1
2
3
4
5
6
7
8public BugActivity extends AppCompatActivity{
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.act_bug);
Log.e("Bug", String.valueOf(2/0));
}
}制作一个Bug修复类
1
2
3
4
5
6
7
8public BugActivity extends AppCompatActivity{
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.act_bug);
Log.e("Bug", "fix");
}
}将Bug修复类
BugActivity.class
打包成dex(DVM只能识别dex文件)rebuild project
之后在build->intermediates->javac
可以找到对应的class文件。取出该class文件后(
取出时需要带上完整的包名路径
),就需要通过SDK/build-tools/XX/dx
将class文件转成dex文件1
2
3
4//dex文件中放置着对应的class文件及其完整路径
Mac:sh dx --dex --output=../dex/classes2.dex ../dex
Win: dx --dex --output=../dex/classes2.dex ../dex
//执行完毕后就会生成对应的dex文件 -- classes2.dex此时就可以得到最终需要替换进去的dex文件。
加载dex文件
利用反射机制去修改
DexClassLoader
中的dexElements
,需要把修复过后的classes2.dex
插入到头部位置,保证可以优先加载。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
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152public class FixDexUtil {
//列出修复支持的文件格式
private static final String DEX_SUFFIX = ".dex";
private static final String APK_SUFFIX = ".apk";
private static final String JAR_SUFFIX = ".jar";
private static final String ZIP_SUFFIX = ".zip";
private static final String DEX_DIR = "odex";
private static final String OPTIMIZE_DEX_DIR = "optimize_dex";
private static HashSet<File> loadedDex = new HashSet<>();
static {
//清理已存在的dex
loadedDex.clear();
}
/**
* 加载补丁,使用默认目录:data/data/包名/files/odex
*
* @param context
*/
public static void loadFixedDex(Context context) {
loadFixedDex(context, null);
}
/**
* 加载补丁
*
* @param context 上下文
* @param patchFilesDir 补丁所在目录
*/
public static void loadFixedDex(Context context, File patchFilesDir) {
boolean canFix = false;
// 遍历所有的修复dex , 因为可能是多个dex修复包
File fileDir = patchFilesDir != null ?
patchFilesDir :
new File(context.getFilesDir(), DEX_DIR);// data/data/包名/files/odex(这个可以任意位置)
File[] listFiles = fileDir.listFiles();
if (listFiles != null && listFiles.length != 0)
for (File file : listFiles) {
if (file.getName().startsWith("classes") &&
(file.getName().endsWith(DEX_SUFFIX)
|| file.getName().endsWith(APK_SUFFIX)
|| file.getName().endsWith(JAR_SUFFIX)
|| file.getName().endsWith(ZIP_SUFFIX))) {
loadedDex.add(file);// 存入集合
//有修复包的存在,意味需要修复
canFix = true;
}
}
// dex合并之前的dex
if (canFix)
doDexInject(context, loadedDex);
}
private static void doDexInject(Context appContext, HashSet<File> loadedDex) {
String optimizeDir = appContext.getFilesDir().getAbsolutePath() +
File.separator + OPTIMIZE_DEX_DIR;
// data/data/包名/files/optimize_dex(这个必须是自己程序下的目录)
File fopt = new File(optimizeDir);
if (!fopt.exists()) {
fopt.mkdirs();
}
try {
// 1.加载应用程序dex的Loader
PathClassLoader pathLoader = (PathClassLoader) appContext.getClassLoader();
for (File dex : loadedDex) {
// 2.加载指定的修复的dex文件的Loader
DexClassLoader dexLoader = new DexClassLoader(
dex.getAbsolutePath(),// 修复好的dex(补丁)所在目录
fopt.getAbsolutePath(),// 存放dex的解压目录(用于jar、zip、apk格式的补丁)
null,// 加载dex时需要的库
pathLoader// 父类加载器
);
// 3.开始合并
// 合并的目标是Element[],重新赋值它的值即可
/**
* BaseDexClassLoader中有 变量: DexPathList pathList
* DexPathList中有 变量 Element[] dexElements
* 依次反射即可
*/
//3.1 准备好pathList的引用
Object dexPathList = getPathList(dexLoader);
Object pathPathList = getPathList(pathLoader);
//3.2 从pathList中反射出element集合
Object leftDexElements = getDexElements(dexPathList);
Object rightDexElements = getDexElements(pathPathList);
//3.3 合并两个dex数组
Object dexElements = combineArray(leftDexElements, rightDexElements);
// 重写给PathList里面的Element[] dexElements;赋值
Object pathList = getPathList(pathLoader);// 一定要重新获取,不要用pathPathList,会报错
setField(pathList, pathList.getClass(), "dexElements", dexElements);
}
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 反射给对象中的属性重新赋值
*/
private static void setField(Object obj, Class<?> cl, String field, Object value) throws NoSuchFieldException, IllegalAccessException {
Field declaredField = cl.getDeclaredField(field);
declaredField.setAccessible(true);
declaredField.set(obj, value);
}
/**
* 反射得到对象中的属性值
*/
private static Object getField(Object obj, Class<?> cl, String field) throws NoSuchFieldException, IllegalAccessException {
Field localField = cl.getDeclaredField(field);
localField.setAccessible(true);
return localField.get(obj);
}
/**
* 反射得到类加载器中的pathList对象
*/
private static Object getPathList(Object baseDexClassLoader) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
return getField(baseDexClassLoader, Class.forName("dalvik.system.BaseDexClassLoader"), "pathList");
}
/**
* 反射得到pathList中的dexElements
*/
private static Object getDexElements(Object pathList) throws NoSuchFieldException, IllegalAccessException {
return getField(pathList, pathList.getClass(), "dexElements");
}
/**
* 数组合并
*/
private static Object combineArray(Object arrayLhs, Object arrayRhs) {
Class<?> clazz = arrayLhs.getClass().getComponentType();
int i = Array.getLength(arrayLhs);// 得到左数组长度(补丁数组)
int j = Array.getLength(arrayRhs);// 得到原dex数组长度
int k = i + j;// 得到总数组长度(补丁数组+原dex数组)
Object result = Array.newInstance(clazz, k);// 创建一个类型为clazz,长度为k的新数组
System.arraycopy(arrayLhs, 0, result, 0, i);
System.arraycopy(arrayRhs, 0, result, i, j);
return result;
}
}该类中主要的功能:
- 获取对应目录中存在的
apk、dex、jar,zip
文件 - 将文件转换成
Element
格式并生成一个elements
数组 - 将生成的数组与原先存在的
dexElements
数组进行合并 - 合并完成后利用反射将数组放置回
ClassLoader
中
如果要加载的文件格式为
apk、jar,zip
需要进行一些特殊处理这些文件格式中需要有一个
classes.dex
文件,不然会出错- 获取对应目录中存在的
进行检测以及修复工作
1
2
3
4
5
6
7
8
9//在项目初始化时便去进行修复检测
class MyApplication extends Application{
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
//提前进行初始化 提前至 onCreate()之前
FixDexUtil.loadFixedDex(base, Environment.getExternalStorageDirectory());
}
}将修复好的
classes2.dex
文件放到对应的目录中,然后重新打开应用,重新观察结果即可。
底层替换方案
底层替换方案是在已经加载了的类中直接去替换原有方法,是在原来类的基础上进行修改。由于在原有类进行修改限制会比较多,且不能增减原有类的方法和字段,否则会破坏原有类的结构。底层的替换方案还与反射有所关联。
传统的底层替换方案,都是直接去修改虚拟机方法实现的具体字段。主要是去操作ArtMethod
结构体,但是会存在兼容性问题,可能由于厂商对其进行了修改。
优化点就是 直接替换整个ArtMethod
结构体,这样就不会存在兼容性的问题。
ArtMethod
:包含了Java方法的所有信息,包括执行入口、访问权限、所属类和代码执行地址等。
优点:底层替换方案直接替换了方法,而且是立即生效不需要进行重启操作。
Instant Run方案
Instant Run的部署方式有以下三种:
- Hot Swap:
效率最高
。代码的增量改变不需要重启App,甚至Activity都不需要重启。修改一个现有方法中的代码多采用这种部署方式。 - Warm Swap:App不需要重启,但是Activity需要重启。修改或删除一个现有的资源文件时多采用这种部署方式。
- Cold Swap:App需要重启,但是不需要重新安装。添加、删除或修改一个字段和方法或者修改一个类等多采用这种部署方式。
工作原理
利用ASM在每一个方法中注入类似如下的代码:
1 |
|
$change
指代了方法是否发生变化,如果发生变化就会调用到access$dispatch()
生成对应的替换类Class$override
替代执行原有方法,即完成了对原有方法的修改。
内容引用
《深入探索Android热修复技术原理》
《Android进阶解密》
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!