热修复

本文最后更新于:1 年前

Reference


与插件化区别

补丁包中的类和资源在宿主中已经存在,只是有 bug 需要被修复。


各框架实现原理

Andfix(兼容复杂已废弃)
Java 中的类,方法,变量,对应到虚拟机里的实现是 Class,ArtMethod,ArtField。Andfix 是把旧方法的 ArtMethod 内容替换成新方法的 ArtMethod 内容,再次调用旧方法,就会跳转到新方法的入口。

Qzone(dex 插桩)
Qzone 基于的是 dex 分包方案。把有 bug 的方法修复以后,放到一个单独的 dex 补丁文件,让程序运行期间加载 dex 补丁,执行修复后的方法。

Robust(方法重定向)
对每个函数都在编译打包阶段自动的插入了一段代码。类似于代理,将方法执行的代码重定向到其他方法中。

Tinker
Tinker 通过计算对比指定的 base apk 中的 dex 与修改后的 apk 中的 dex 的区别,补丁包中的内容即为两者差分的描述。运行时将 base apk 中的 dex 与补丁包进行合成,重启后加载全新的合成后的 dex 文件。


Qzone 需要解决的问题

Dalvik 虚拟机中 CLASS_ISPREVERIFIED 导致的校验失败

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
E/AndroidRuntime(20525): FATAL EXCEPTION: main
E/AndroidRuntime(20525): java.lang.IllegalAccessError: Class ref in pre-verified class resolved to unexpected implementation
E/AndroidRuntime(20525): at zeus.test.hotfix.TestHotFixActivity.onCreate(Unknown Source)
E/AndroidRuntime(20525): at android.app.Activity.performCreate(Activity.java:5188)
E/AndroidRuntime(20525): at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1094)
E/AndroidRuntime(20525): at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2074)
E/AndroidRuntime(20525): at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:2135)
E/AndroidRuntime(20525): at android.app.ActivityThread.access$700(ActivityThread.java:140)
E/AndroidRuntime(20525): at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1237)
E/AndroidRuntime(20525): at android.os.Handler.dispatchMessage(Handler.java:99)
E/AndroidRuntime(20525): at android.os.Looper.loop(Looper.java:137)
E/AndroidRuntime(20525): at android.app.ActivityThread.main(ActivityThread.java:4921)
E/AndroidRuntime(20525): at java.lang.reflect.Method.invokeNative(Native Method)
E/AndroidRuntime(20525): at java.lang.reflect.Method.invoke(Method.java:511)
E/AndroidRuntime(20525): at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:1038)
E/AndroidRuntime(20525): at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:805)
E/AndroidRuntime(20525): at dalvik.system.NativeStart.main(Native Method)

如果一个类和它直接引用的类都在同一个 dex 中的话,那么这个类就会被打上 CLASS_ISPREVERIFIED 标记,而补丁包中的类存在于另一个 dex,不能满足 CLASS_ISPREVERIFIED 导致校验失败,所以需要避免类被打上 CLASS_ISPREVERIFIED 标记。

解决办法:

1、创建一个 dex;

比如随便创建一个 AntilazyLoad.java。

1
2
public class AntilazyLoad {
}

make module 将 java 文件编译成 class 文件。

执行命令编译成 dex 文件。

1
dx --dex --output=hack.dex com\enjoy\patch\hack\AntilazyLoad.class

2、让每个类都引用这个 dex。

将 hack.dex 放入 assets 目录。

借助 gradle,在 compileDebugJavaWithJavac 任务后,且在 transformClassesWithDexBuilderForDebug 任务前,修改每个类。

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
//gradle 执行会解析 build.gradle 文件,afterEvaluate 表示在解析完成之后再执行我们的代码
afterEvaluate ({
android.getApplicationVariants.all {
variant->
//获得 debug/release
String variantName = variant.getName()
//首字母大写
String capitalizedName = variantName.capitalize()
//通过任务名字找到打包 dex 的任务
Task dexTask = project.getTasks().findByName("transformClassesWithDexBuilderFor" + capitalizedName)
//定义 doFirst()
dexTask.doFirst {
//获取 .class 文件集合
Set<File> files = dexTask.getInputs().getFiles().getFiles()
//遍历
for (File file : files) {
String filePath = file.getAbsolutePath()
//依赖的库会以 .jar 包形式传过来,对依赖库也执行插桩
if (filePath.endsWith(".jar")) {
processJar(file)
//主要是我们的业务字节码 .class 文件
} else if (filePath.endsWith(".class")) {
processClass(variant.getDirName(), file)
}
}
}
}
})

static void processClass(String dirName, File file) {
//获取文件的绝对路径
String filePath = file.getAbsolutePath()
//去掉目录名,保留包名+类名
String className = filePath.split(dirName)[1].subString(1)
//控制台打印
println className
if (className.startsWith("com\\hotfix\\MyApplication") || isAndroidClass(className)){
return
}
try{
//执行插桩,插桩之后的 .class 数据用 byte[] 保存
FileInputStream is = new FileInputStream(filePath)
byte[] byteCode = referHackWhenInit(is)
is.close()
//用插桩之后的字节码数据覆盖掉插桩之前的字节码数据
FileOutputStream os = new FileOutputStream(filePath)
os.write(byteCode)
os.close()
} catch (Exception e) {
e.printStackTrace()
}
}

//执行插桩的核心代码
static byte[] referHackWhenInit(InputStream inputStream) throws IOException {
//class 文件解析器
ClassReader cr = new ClassReader(inputStream)
//class 文件输出器
ClassWriter cw = new ClassWirter(cr, 0)
//class 文件访问者,相当于操作回调
ClassVisitor cv = new ClassVisitor(Opcodes.ASM5, cw) {
@Overide
public MethodVisitor visitMethod(int access, final String name, String desc,
String signature, String[] exceptions) {
MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions)
mv = new MethodVisitor(Opcodes.ASM5, mv) {
@Override
public void visitInsn(int opcode) {
//若当前访问的方法是构造方法,则在构造方法 return 之前插入字节码
if ("<init>".equals(name) && opcode == Opcodes.RETURN) {
super.visitInsn(Type.getType("LpackageName/AntilazyLoad;"))
}
super.visitInsn(opcode)
}
}
return mv
}
}
//解析器启动解析
cr.accept(cv, 0)
return cw.toByteArray()
}

自定义插件实现插桩并生成补丁包

1、在 project 下新建名为 buildSrc 的 Directory(不是新建 Module);

2、build 编译项目后,会在 buildSrc 下生成 .gradle 文件夹和 build 文件夹;

3、拷贝一个 Module 的 build.gradle 文件放入 buildSrc 文件夹,并引入 gradleApi;

1
2
3
4
5
6
7
8
9
10
apply plugin: 'java-library'

dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
// 引入 gradleApi
implementation gradleApi()
}

sourceCompatibility = "1.7"
targetCompatibility = "1.7"

4、在 buildSrc 下新建 src/main/java 目录,用于存放插件的 Java 源代码;接着在该 src/main/java 目录下新建一个 Java 包,比如 com.enjoy.plugin 包;接着创建一个 PatchPlugin 类,PatchPlugin 作为插件类须实现 Plugin接口,并实现 apply() 方法;

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
package com.enjoy.plugin;

import org.gradle.api.Plugin;
import org.gradle.api.Project;

public class PatchPlugin implements Plugin<Project> {
@Oveerride
public void apply(Project project) {
if (!project.getPlugins().hasPlugin(AppPlugin.class)) {
throw new GradleException("无法在非android application插件中使用热修复插件");
}
//创建一个patch{}配置
//就和引入了 apply plugin: 'com.android.application' 一样,可以配置android{}
project.getExtensions().create("patch", PatchExtension.class);

//gradle执行会解析build.gradle文件,afterEvaluate表示在解析完成之后再执行我们的代码
project.afterEvaluate(new Action<Project>() {
@Override
public void execute(final Project project) {
final PatchExtension patchExtension =
project.getExtensions().findByType(PatchExtension.class);
//获得用户的配置,在debug模式下是否开启热修复
final boolean debugOn = patchExtension.debugOn;
//得到android的配置
AppExtension android = project.getExtensions().getByType(AppExtension.class);
// android项目默认会有 debug和release,
// 那么getApplicationVariants就是包含了debug和release的集合,all表示对集合进行遍历
android.getApplicationVariants().all(new Action<ApplicationVariant>() {
@Override
public void execute(ApplicationVariant applicationVariant) {
//当前用户是debug模式,并且没有配置debug运行执行热修复
if (applicationVariant.getName().contains("debug") && !debugOn) {
return;
}
//配置热修复插件生成补丁的一系列任务
configTasks(project, applicationVariant,
patchExtension);
}
});
}
});
}
}

5、在 app/build.gradle 中引用插件后,gradle 编译时就会执行插件的 apply() 方法;

1
apply plugin: com.enjoy.patch.plugin.PatchPlugin

6、暴露一些用户可配置的参数;

新建 PatchExtension 类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class PatchExtension {
boolean debugOn;
String output;
String applicationName;

public PatchExtension() {
debugOn = false;
}

public void setDebugOn(boolean debugOn) {
this.debugOn = debugOn;
}

public void setOutput(String output) {
this.output = output;
}

public void setApplicationName(String applicationName) {
this.applicationName = applicationName;
}
}

在 gradle 中使用。

1
2
3
4
patch {
debugOn true
applicationName 'com.enjoy.qzonefix.MyApplication'
}

PatchPlugin 设置 JavaBean 为 PatchExtension,并使用传递进来的参数。

1
2
3
project.getExtensions().create("patch", PatchExtension.class);

final PatchExtension patchExtension = project.getExtensions().findByType(PatchExtension.class);

7、保存 mapping 文件,给以后补丁包用;

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
//获得android的混淆任务
final Task proguardTask =
project.getTasks().findByName("transformClassesAndResourcesWithProguardFor" + capitalizeName);
/**
* 备份本次的mapping文件
*/
final File mappingBak = new File(outputDir, "mapping.txt");
//如果没开启混淆,则为null,不需要备份mapping
if (proguardTask != null) {
// dolast:在这个任务之后再干一些事情
// 在混淆后备份mapping
proguardTask.doLast(new Action<Task>() {
@Override
public void execute(Task task) {
//混淆任务输出的所有文件
TaskOutputs outputs = proguardTask.getOutputs();
Set<File> files = outputs.getFiles().getFiles();
for (File file : files) {
//把mapping文件备份
if (file.getName().endsWith("mapping.txt")) {
try {
FileUtils.copyFile(file, mappingBak);
project.getLogger().error("备份混淆mapping文件:" + mappingBak.getCanonicalPath());
} catch (IOException e) {
e.printStackTrace();
}
break;
}
}
}
});
}
//将上次混淆的mapping应用到本次,如果没有上次的混淆文件就没操作
if (mappingBak.exists() && proguardTask != null) {
TransformTask task = (TransformTask) proguardTask;
ProGuardTransform transform = (ProGuardTransform) task.getTransform();
transform.applyTestedMapping(mappingBak);
}

8、记录 MD5,并生成补丁包。

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
/**
* 在混淆后 记录类的hash值,并生成补丁包
*/
final File hexFile = new File(outputDir, "hex.txt");

// 需要打包补丁的类的jar包
final File patchClassFile = new File(outputDir, "patchClass.jar");
// 用dx打包后的jar包
final File patchFile = new File(outputDir, "patch.jar");

//打包dex任务
final Task dexTask =
project.getTasks().findByName("transformClassesWithDexBuilderFor" + capitalizeName);

//dofirst:在任务之前干一些事情
// 在把class打包dex之前,插桩并记录每个class的md5 hash值
dexTask.doFirst(new Action<Task>() {
@Override
public void execute(Task task) {
/**
* 插桩 记录md5并对比
*/
PatchGenerator patchGenerator = new PatchGenerator(project, patchFile,
patchClassFile, hexFile);
//用户配置的application,实际上可以解析manifest自动获取,但是java实现太麻烦了,干脆让用户自己配置
String applicationName = patchExtension.applicationName;
//windows下 目录输出是 xx\xx\ ,linux下是 /xx/xx ,把 . 替换成平台相关的斜杠
applicationName = applicationName.replaceAll("\\.",
Matcher.quoteReplacement(File.separator));
//记录类的md5
Map<String, String> newHexs = new HashMap<>();
//任务的输入,dex打包任务要输入什么? 自然是所有的class与jar包了!
Set<File> files = dexTask.getInputs().getFiles().getFiles();
for (File file : files) {
String filePath = file.getAbsolutePath();
if (filePath.endsWith(".jar")) {
processJar(applicationName, file, newHexs, patchGenerator);

} else if (filePath.endsWith(".class")) {
processClass(applicationName, variant.getDirName(), file, newHexs,
patchGenerator);
}

}
//类的md5集合 写入到文件
Utils.writeHex(newHexs, hexFile);
try {
//生成补丁
patchGenerator.generate();
} 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
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
/**
* /xxxxx/app/build/intermediates/classes/debug/com/enjoy/qzonefix/MainActivity.class
*
* @param file
* @param hexs
*/
static void processClass(String applicationName, String dirName, File file,
Map<String, String> hexs,
PatchGenerator patchGenerator) {

String filePath = file.getAbsolutePath();
//注意这里的filePath包含了目录+包名+类名,所以去掉目录
String className = filePath.split(dirName)[1].substring(1);
//application或者android support我们不管
if (className.startsWith(applicationName) || Utils.isAndroidClass(className)) {
return;
}
try {
FileInputStream is = new FileInputStream(filePath);
//执行插桩
byte[] byteCode = ClassUtils.referHackWhenInit(is);
String hex = Utils.hex(byteCode);
is.close();

FileOutputStream os = new FileOutputStream(filePath);
os.write(byteCode);
os.close();

hexs.put(className, hex);
//对比缓存的md5,不一致则放入补丁
patchGenerator.checkClass(className, hex, byteCode);
} catch (Exception e) {
e.printStackTrace();
}
}


static void processJar(String applicationName, File file, Map<String, String> hexs,
PatchGenerator patchGenerator) {
try {
// 无论是windows还是linux jar包都是 /
applicationName = applicationName.replaceAll(Matcher.quoteReplacement(File.separator),
"/");
File bakJar = new File(file.getParent(), file.getName() + ".bak");
JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(bakJar));

JarFile jarFile = new JarFile(file);
Enumeration<JarEntry> entries = jarFile.entries();
while (entries.hasMoreElements()) {
JarEntry jarEntry = entries.nextElement();

jarOutputStream.putNextEntry(new JarEntry(jarEntry.getName()));
InputStream is = jarFile.getInputStream(jarEntry);

String className = jarEntry.getName();
if (className.endsWith(".class") && !className.startsWith(applicationName)
&& !Utils.isAndroidClass(className) && !className.startsWith("com/enjoy" +
"/patch")) {
byte[] byteCode = ClassUtils.referHackWhenInit(is);
String hex = Utils.hex(byteCode);
is.close();
hexs.put(className, hex);
//对比缓存的md5,不一致则放入补丁
patchGenerator.checkClass(className, hex, byteCode);
jarOutputStream.write(byteCode);
} else {
//输出到临时文件
jarOutputStream.write(IOUtils.toByteArray(is));
}
jarOutputStream.closeEntry();
}
jarOutputStream.close();
jarFile.close();
file.delete();
bakJar.renameTo(file);
} catch (IOException e) {
e.printStackTrace();
}
}

ART 虚拟机在 Android 7.0 时因优化安装速度导致的无法插桩

应用在安装时不做编译,而是运行时解释字节码,同时在 JIT 编译了一些热点代码后将这些代码信息记录至 Profile 文件,等到设备空闲的时候使用 AOT(All-Of-the-Time compilation:全时段编译)编译生成称为 app_image 的 base.art(类对象映像)文件,这个 art 文件会在 app 启动时自动加载(相当于缓存)。根据类加载原理,类被加载后无法被替换,即无法修复。

解决办法:
1、因为 app_image 中的 class 会插入到 PathClassloader,所以不使用 PathClassloader,而是自定义 Classloader,并通过反射修改引用 PathClassloader 的地方为自定义的 Classloader;

2、参考 app_image 生成方式,生成修复后的 app_image。



热修复
https://weichao.io/f1b0a390bc50/
作者
魏超
发布于
2020年4月13日
更新于
2022年12月4日
许可协议