Android 热修复原理与 HotFix 框架实现详解
在 Android 开发中,热修复技术允许在不重新发布应用的情况下修复线上 Bug。本文详细分析 QQ 空间热修复方案(HotFix)的核心原理及实现细节。
Android Dex 分包原理介绍
QQ 空间热修复方案基于 Android Dex 分包基础之上。简单来说,Android Dex 分包的原理是将多个 dex 文件塞入到 App 的 ClassLoader 之中。但是 Android Dex 拆包方案中的类是没有重复的,如果 classes.dex 和 classes1.dex 中有重复的类,当两者都具有同一个类的时候,ClassLoader 会选择加载哪个类呢?这要从 ClassLoader 的源码入手。
加载类是通过 ClassLoader 的 loadClass 方法实现的,我们看一下 loadClass 的源码:
public Class<?> loadClass(String className) throws ClassNotFoundException {
return loadClass(className, false);
}
protected Class<?> loadClass(String className, boolean resolve) throws ClassNotFoundException {
Class<?> clazz = findLoadedClass(className);
if (clazz == null) {
ClassNotFoundException suppressed = null;
try {
clazz = parent.loadClass(className, false);
} catch (ClassNotFoundException e) {
suppressed = e;
}
if (clazz == null) {
try {
clazz = findClass(className);
} catch (ClassNotFoundException e) {
e.addSuppressed(suppressed);
throw e;
}
}
}
return clazz;
}
ClassLoader 是基于双亲代理模型的。具体来说,ClassLoader 用 loadClass 方法调用了 findClass 方法。点进去发现 findClass 是抽象方法,而这个方法的实现是在它的子类 BaseDexClassLoader 中。BaseDexClassLoader 重载了这个方法。
进入 BaseDexClassLoader 类的 findClass 方法中:
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
Class clazz = pathList.findClass(name);
if (clazz == null) {
throw new ClassNotFoundException(name);
}
return clazz;
}
接着查看 DexPathList 的实现:
public Class findClass(String name) {
for (Element element : dexElements) {
DexFile dex = element.dexFile;
if (dex != null) {
Class clazz = dex.loadClassBinaryName(name, definingContext);
if (clazz != null) {
return clazz;
}
}
}
return null;
}
最后查看 DexFile 的实现:
public Class loadClassBinaryName(String name, ClassLoader loader) {
return defineClass(name, loader, mCookie);
}
private native static Class defineClass(String name, ClassLoader loader, int cookie);
一个 ClassLoader 可以包含多个 dex 文件,每个 dex 文件是一个 Element,多个 dex 文件排列成一个有序的数组 dexElements。当找类的时候,会按顺序遍历 dex 文件,然后从当前遍历的 dex 文件中找类,如果找到则返回,如果找不到从下一个 dex 文件继续查找。理论上,如果在不同的 dex 中有相同的类存在,那么会优先选择排在前面的 dex 文件的类。
所以,QQ 空间正是基于 ClassLoader 的这个原理,把有问题的类打包到一个 dex(patch.dex)中去,然后把这个 dex 插入到 Elements 的最前面。
关于如何进行 dex 分包后面再单独进行分析。
CLASS_ISPREVERIFIED 的问题
采用 dex 分包方案会遇到的问题,也就是 CLASS_ISPREVERIFIED 的问题。简单来概括就是:在虚拟机启动的时候,当 verify 选项被打开的时候,如果 static 方法、private 方法、构造函数等,其中的直接引用(第一层关系)到的类都在同一个 dex 文件中,那么该类就会被打上 CLASS_ISPREVERIFIED 标志。
那么,我们要做的就是,阻止该类打上 CLASS_ISPREVERIFIED 的标志。注意下,是阻止引用者的类。也就是说,假设你的 app 里面有个类叫做 AClass,在其内部引用了 BClass。发布过程中发现 BClass 有编写错误,那么想要发布一个新的 BClass 类,你就要阻止 AClass 这个类打上 CLASS_ISPREVERIFIED 的标志。也就是说,你在生成 apk 之前,就需要阻止相关类打上 CLASS_ISPREVERIFIED 的标志了。
如何阻止?简单来说,让 AClass 在构造方法中,去引用别的 dex 文件,比如 C.dex 中的某个类即可。所以总结下来,防止这个错误,只需要:
- 动态改变 BaseDexClassLoader 对象间接引用的 dexElements;
- 在 app 打包的时候,阻止相关类去打上 CLASS_ISPREVERIFIED 标志。
热修复框架 HotFix 解析
采用 QQ 空间的热修复方案而实现的开源热修复框架就是 HotFix。说到了使用 dex 分包方案会遇到 CLASS_ISPREVERIFIED 问题,而解决方案就是在 dx 工具执行之前,将所有的 class 文件进行修改,在其构造中添加 System.out.println(dodola.hackdex.AntilazyLoad.class),然后继续打包的流程。注意:AntilazyLoad.class 这个类是独立在 hack.dex 中。
Dex 分包方案实现需要关注以下问题
- 如何解决 CLASS_ISPREVERIFIED 问题
- 如何将修复的.dex 文件插入到 dexElements 的最前面
解决 CLASS_ISPREVERIFIED 问题
那么如何达到这个目的呢?在 HotFix 中采用的 javassist 来达到这个目的,以下是 HotFix 中的 PatchClass.groovy 代码:
public class PatchClass {
/**
* 植入代码
* @param buildDir 是项目的 build class 目录,就是我们需要注入的 class 所在地
* @param lib 这个是 hackdex 的目录,就是 AntilazyLoad 类的 class 文件所在地
*/
public static void process(String buildDir, String lib) {
println(lib)
ClassPool classes = ClassPool.getDefault()
classes.appendClassPath(buildDir)
classes.appendClassPath(lib)
//下面的操作比较容易理解,在将需要关联的类的构造方法中插入引用代码
CtClass c = classes.getCtClass("dodola.hotfix.BugClass")
if (c.isFrozen()) {
c.defrost()
}
println("====添加构造方法====")
def constructor = c.getConstructors()[0];
constructor.insertBefore("System.out.println(dodola.hackdex.AntilazyLoad.class);")
c.writeFile(buildDir)
CtClass c1 = classes.getCtClass("dodola.hotfix.LoadBugClass")
if (c1.isFrozen()) {
c1.defrost()
}
println("====添加构造方法====")
def constructor1 = c1.getConstructors()[0];
constructor1.insertBefore("System.out.println(dodola.hackdex.AntilazyLoad.class);")
c1.writeFile(buildDir)
}
static void growl(String title, String message) {
def proc = ["osascript", "-e", "display notification \"${message}\" with title \"${title}\""].execute()
if (proc.waitFor() != 0) {
println "[WARNING] ${proc.err.text.trim()}"
}
}
}
其实内部做的逻辑就是:通过 ClassPool 对象,然后添加 classpath。然后从 classpath 中找到 LoadBugClass,拿到其构造方法,在其中插入一行代码。
到这里插入代码的操作已经完成,但是还存在另外一个问题,那就是如何在 dx 之前去进行上述脚本的操作?答案就在 HotFix 的 app/build.gradle 中。
apply plugin: 'com.android.application'
task('processWithJavassist') << {
String classPath = file('build/intermediates/classes/debug')//项目编译 class 所在目录
dodola.patch.PatchClass.process(classPath, project(':hackdex').buildDir
.absolutePath + '/intermediates/classes/debug')//第二个参数是 hackdex 的 class 所在目录
}
buildTypes {
debug {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
applicationVariants.all { variant ->
variant.dex.dependsOn << processWithJavassist //在执行 dx 命令之前将代码打入到 class 中
}
可以看到在 build.gradle 中,在执行 dx 之前,会先执行 processWithJavassist 这个任务。这样会执行 PatchClass.groovy 的脚本,在构造方法中进行注入。
将修复的.dex 文件插入 dexElements
寻找 class 是遍历 dexElements;然后我们的 AntilazyLoad.class 实际上并不包含在 apk 的 classes.dex 中,并且根据上面描述的需要,我们需要将 AntilazyLoad.class 这个类打成独立的 hack_dex.jar,注意不是普通的 jar,必须经过 dx 工具进行转化。
具体做法:
jar cvf hack.jar dodola/hackdex/*
dx --dex --output hack_dex.jar hack.jar
还记得之前我们将所有的类的构造方法中都引用了 AntilazyLoad.class,所以我们需要把 hack_dex.jar 插入到 dexElements,而在 hotfix 中,就是在 Application 中完成这个操作的。
public class HotfixApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
File dexPath = new File(getDir("dex", Context.MODE_PRIVATE), "hackdex_dex.jar");
Utils.prepareDex(this.getApplicationContext(), dexPath, "hackdex_dex.jar");
HotFix.patch(this, dexPath.getAbsolutePath(), "dodola.hackdex.AntilazyLoad");
try {
this.getClassLoader().loadClass("dodola.hackdex.AntilazyLoad");
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}
在 app 的私有目录创建一个文件,然后调用 Utils.prepareDex 将 assets 中的 hackdex_dex.jar 写入该文件。Utils.prepareDex 中其实就是文件的读写操作,前提是你把 hackdex_dex.jar 放入到 assets 中。
public class Utils {
private static final int BUF_SIZE = 2048;
public static boolean prepareDex(Context context, File dexInternalStoragePath, String dex_file) {
BufferedInputStream bis = null;
OutputStream dexWriter = null;
try {
bis = new BufferedInputStream(context.getAssets().open(dex_file));
dexWriter = new BufferedOutputStream(new FileOutputStream(dexInternalStoragePath));
byte[] buf = new byte[BUF_SIZE];
int len;
while ((len = bis.read(buf, 0, BUF_SIZE)) > 0) {
dexWriter.write(buf, 0, len);
}
dexWriter.close();
bis.close();
return true;
} catch (IOException e) {
if (dexWriter != null) {
try {
dexWriter.close();
} catch (IOException ioe) {
ioe.printStackTrace();
}
}
if (bis != ) {
{
bis.close();
} (IOException ioe) {
ioe.printStackTrace();
}
}
;
}
}
}
接下来 HotFix.patch 就是去反射去修改 dexElements 了。
public static void patch(Context context, String patchDexFile, String patchClassName) {
if (patchDexFile != null && new File(patchDexFile).exists()) {
try {
if (hasLexClassLoader()) {
injectInAliyunOs(context, patchDexFile, patchClassName);
} else if (hasDexClassLoader()) {
injectAboveEqualApiLevel14(context, patchDexFile, patchClassName);
} else {
injectBelowApiLevel14(context, patchDexFile, patchClassName);
}
} catch (Throwable th) {
}
}
}
可以看到 patch 方法中有几个分支,说白了是根据不同的系统中 ClassLoader 的类型来做相应的处理。
阿里云 OS 适配
private static void injectInAliyunOs(Context context, String patchDexFile, String patchClassName)
throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException,
InstantiationException, NoSuchFieldException {
PathClassLoader obj = (PathClassLoader) context.getClassLoader();
String replaceAll = new File(patchDexFile).getName().replaceAll("\\.[a-zA-Z0-9]+", ".lex");
Class cls = Class.forName("dalvik.system.LexClassLoader");
Object newInstance =
cls.getConstructor(new Class[] {String.class, String.class, String.class, ClassLoader.class}).newInstance(
new Object[] {context.getDir("dex", 0).getAbsolutePath() + File.separator + replaceAll,
context.getDir("dex", 0).getAbsolutePath(), patchDexFile, obj});
cls.getMethod("loadClass", new Class[] {String.class}).invoke(newInstance, new Object[] {patchClassName});
setField(obj, PathClassLoader.class, "mPaths",
appendArray(getField(obj, PathClassLoader.class, "mPaths"), getField(newInstance, cls, "mRawDexPath")));
setField(obj, PathClassLoader.class, "mFiles",
combineArray(getField(obj, PathClassLoader.class, "mFiles"), getField(newInstance, cls, )));
setField(obj, PathClassLoader.class, ,
combineArray(getField(obj, PathClassLoader.class, ), getField(newInstance, cls, )));
setField(obj, PathClassLoader.class, ,
combineArray(getField(obj, PathClassLoader.class, ), getField(newInstance, cls, )));
}
上述方法中的 LexClassLoader 应该是阿里自己的 ClassLoader,可以看到上面将修复的文件的结尾都换成了.lex 的结尾,这些文件就是专门需要通过 LexClassLoader 进行加载的。
API 14 以下适配
我们分 API 14 以上和以下进行分析。
API 14 以下
private static void injectBelowApiLevel14(Context context, String str, String str2)
throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
PathClassLoader obj = (PathClassLoader) context.getClassLoader();
DexClassLoader dexClassLoader =
new DexClassLoader(str, context.getDir("dex", 0).getAbsolutePath(), str, context.getClassLoader());
dexClassLoader.loadClass(str2);
setField(obj, PathClassLoader.class, "mPaths",
appendArray(getField(obj, PathClassLoader.class, "mPaths"), getField(dexClassLoader, DexClassLoader.class,
"mRawDexPath")
));
setField(obj, PathClassLoader.class, "mFiles",
combineArray(getField(obj, PathClassLoader.class, "mFiles"), getField(dexClassLoader, DexClassLoader.class,
"mFiles")
));
setField(obj, PathClassLoader.class, "mZips",
combineArray(getField(obj, PathClassLoader.class, "mZips"), getField(dexClassLoader, DexClassLoader.class,
"mZips")));
setField(obj, PathClassLoader.class, "mDexs",
combineArray(getField(obj, PathClassLoader.class, "mDexs"), getField(dexClassLoader, DexClassLoader.class,
"mDexs")));
obj.loadClass(str2);
}
通过 setField 方法将 mPaths 属性,修改为通过 appendArray 方法创造的新元素。
private static Object getField(Object obj, Class cls, String str)
throws NoSuchFieldException, IllegalAccessException {
Field declaredField = cls.getDeclaredField(str);
declaredField.setAccessible(true);
return declaredField.get(obj);
}
private static Object appendArray(Object obj, Object obj2) {
Class componentType = obj.getClass().getComponentType();
int length = Array.getLength(obj);
Object newInstance = Array.newInstance(componentType, length + 1);
Array.set(newInstance, 0, obj2);
for (int i = 1; i < length + 1; i++) {
Array.set(newInstance, i, Array.get(obj, i - 1));
}
return newInstance;
}
而 appendArray 中就是创建一个新的 Array,把 obj2 插入到 obj 的前面,注意这里的 obj2 长度只有 1。
所以,在 injectBelowApiLevel14 的以下方法中,就是把 mRawDexPath 的元素插入到 mPaths 中所有元素之前,而重新组合而成的新 mPaths 替换掉旧的 mPaths。
setField(obj, PathClassLoader.class, "mPaths",
appendArray(getField(obj, PathClassLoader.class, "mPaths"), getField(dexClassLoader, DexClassLoader.class,
"mRawDexPath")
));
接下来的替换,是通过 combineArray 生成的新元素替换掉旧元素,这里分别是 mFiles,mZips,mDexs。
setField(obj, PathClassLoader.class, "mFiles",
combineArray(getField(obj, PathClassLoader.class, "mFiles"), getField(dexClassLoader, DexClassLoader.class,
"mFiles")
));
setField(obj, PathClassLoader.class, "mZips",
combineArray(getField(obj, PathClassLoader.class, "mZips"), getField(dexClassLoader, DexClassLoader.class,
"mZips")));
setField(obj, PathClassLoader.class, "mDexs",
combineArray(getField(obj, PathClassLoader.class, "mDexs"), getField(dexClassLoader, DexClassLoader.class,
"mDexs")));
于是我们需要看一下 combineArray 方法里面做了什么。
private static Object combineArray(Object obj, Object obj2) {
Class componentType = obj2.getClass().getComponentType();
int length = Array.getLength(obj2);
int length2 = Array.getLength(obj) + length;
Object newInstance = Array.newInstance(componentType, length2);
for (int i = 0; i < length2; i++) {
if (i < length) {
Array.set(newInstance, i, Array.get(obj2, i));
} else {
Array.set(newInstance, i, Array.get(obj, i - length));
}
}
return newInstance;
}
逻辑也很简单,也就是两个数组的合并而已。
API 14 以上适配
API 14 以上
private static void injectAboveEqualApiLevel14(Context context, String str, String str2)
throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
PathClassLoader pathClassLoader = (PathClassLoader) context.getClassLoader();
Object a = combineArray(getDexElements(getPathList(pathClassLoader)),
getDexElements(getPathList(
new DexClassLoader(str, context.getDir("dex", 0).getAbsolutePath(), str, context.getClassLoader()))));
Object a2 = getPathList(pathClassLoader);
setField(a2, a2.getClass(), "dexElements", a);
pathClassLoader.loadClass(str2);
}
根据 context 拿到 PathClassLoader,然后通过 getPathList(pathClassLoader),拿到 PathClassLoader 中的 pathList 对象,在调用 getDexElements 通过 pathList 取到 dexElements 对象。
private static Object getDexElements(Object obj) throws NoSuchFieldException, IllegalAccessException {
return getField(obj, obj.getClass(), "dexElements");
}
private static Object getPathList(Object obj) throws ClassNotFoundException, NoSuchFieldException,
IllegalAccessException {
return getField(obj, Class.forName("dalvik.system.BaseDexClassLoader"), "pathList");
}
同样是通过 combineArray 方法,对数组进行合并,合并完成后,将新的数组通过反射的方式设置给 pathList。
通过上面的一系列流程,那么 hack_dex.jar 已经插入到 dexElements 最前面了,补丁插入的过程也和 hack_dex.jar 的插入流程是一致的。
总结与展望
至此,dex 分包方案实现热修复的 HotFix 的分析就已经完毕了。HotFix 方案的核心在于利用 ClassLoader 的双亲委派机制漏洞,通过反射修改 DexPathList 中的 dexElements 数组,将补丁 dex 置于加载列表首位,从而实现类覆盖。
然而,这种方案也存在一定的局限性:
- 兼容性:不同厂商 ROM 可能修改了 ClassLoader 的内部字段名或结构,导致反射失效。
- 性能开销:频繁的反射操作和数组复制会在一定程度上影响应用启动速度和运行性能。
- Native 库支持:对于涉及 Native 层的 Bug,单纯修改 Java 层无法生效,需要配合其他方案。
- 安全性:热修复机制本身可能被恶意利用,需做好签名校验和完整性检查。
在实际生产环境中,建议结合 Tinker、Sophix 等更成熟的商业或开源方案,它们通常针对上述问题做了更好的兼容性和稳定性优化。开发者在选择热修复方案时,应充分评估业务需求、目标机型覆盖率以及维护成本。