前言
最近项目上的事情比较少有不少空闲时间就来研究一下热修复技术,热修复功能可以用来为刚发布的应用修复比较严重的bug,或者为用户推送一些小的功能给用户使用。实现的主要原理是从网络上下载带修复功能的补丁文件,然后通过反射技术将补丁内的代码加入到类加载器PathClassLoader里的dexElements最前面,这样当程序调用有问题的class时会优先使用补丁包内的class文件,这样就调用了没有BUG的类实现,这里通过使用简单的本地推送补丁到SDCard上简单实现热修复。
准备
在Demo的BugFixActivity里有一个按钮会在点击时解析一个异常,但是异常处理类里面并没有对异常是否为空做判断(当然在实际开发里这种BUG肯定会被测试发现,这里只是用来做演示),一旦用户点击了按钮就会导致程序调用抛出空指针异常。
@Override
public void onClick(View v) {
if (v == invokeBug) {
try {
ExceptionUtils.parseException(null);
} catch (Exception e) {
// 调用失败走这里
Toast.makeText(this, "调用BUG代码失败", Toast.LENGTH_SHORT).show();
return;
}
// 如果调用没有抛出异常走这里
Toast.makeText(this, "调用修复BUG代码成功", Toast.LENGTH_SHORT).show();
}
}
package com.example.misc2.utils;
public class ExceptionUtils {
public static String parseException(Exception exception) {
// 没有做空对象判断
int length1 = exception.getMessage().length();
int length2 = exception.toString().length();
StringBuilder res = new StringBuilder(length1 + length2);
res.append(exception.getMessage());
res.append("\n");
res.append(exception.toString());
return res.toString();
}
}
上面的有问题代码很简单,主要是在parseException这个方法里没有增加判空处理,不过由于APP已经发布很显然无法通过修改源代码来修复线上的BUG。这时我们需要自己手动生成一个ExceptionUtils类,这个类里的parseException方法会有判空处理。
package com.example.misc2.utils;
public class ExceptionUtils {
public static String parseException(Exception exception) {
if (exception == null) {
return "";
}
int length1 = exception.getMessage().length();
int length2 = exception.toString().length();
StringBuilder res = new StringBuilder(length1 + length2);
res.append(exception.getMessage());
res.append("\n");
res.append(exception.toString());
return res.toString();
}
}
之后使用javac命令行工具将上面的类编译成.class文件,我们知道Android只识别dex文件,无法直接使用class文件,这里需要调用AndroidSDK里的dx命令将class文件转换成dex,最后再把dex文件推送到SDCard根目录下。
// 现将修复过的java文件编译成class文件
javac com/example/misc2/utils/ExceptionUtils.java
// 将当前目录下的类文件转换成bugfix.dex文件
dx --dex --output=bugfix.dex .
// 将补丁文件推送到SDCard根目录下
adb push bugfix.dex /sdcard/
bugfix.dex: 1 file pushed. 0.0 MB/s (980 bytes in 0.075s)
实现修复
从前面的Android ClassLoader类加载可以知道已安装的APK内部的dex文件加载都是通过PathClassLoader来实现的,现在在Demo的BugFixActivity里添加如下代码并且查看logcat日志输出,可以看出PathClassLoader内部查看的记录确实包括App的base.apk路径。
ClassLoader classLoader = getClassLoader();
Log.d(TAG, classLoader.toString());
while (classLoader != null) {
classLoader = classLoader.getParent();
if (classLoader != null) {
Log.d(TAG, classLoader.toString());
}
}
dalvik.system.PathClassLoader[DexPathList[[zip file "/data/app/com.example.misc2-1/base.apk"],
nativeLibraryDirectories=[/data/app/com.example.misc2-1/lib/arm, /vendor/lib, /system/lib]]]
java.lang.BootClassLoader@3a98234
现在安装应用的所有Class加载都只是从应用安装APK中加载的,需要把前面准备的补丁dex文件加入到APK之前,这样程序在查找ExceptionUtils类的时候就会优先从补丁dex文件中查找,从而调用已经修复过的parseException方法。从BaseDexClassLoader源码阅读这篇我们知道PathClassLoader内部有一个DexPathList的pathList对象,pathList内部的dexElements数组就是包含所有查询Class对象的dex文件记录位置,需要把补丁dex的文件位置插入到最前面。
private boolean loadFixDex() {
String dexPath = Environment.getExternalStorageDirectory() + File.separator + "bugfix.dex";
String dexOutput = getCacheDir() + File.separator + "DEX";
File file = new File(dexOutput);
if (!file.exists()) file.mkdirs();
// 从bugfix.dex文件加载修复bug的dex文件
DexClassLoader dexClassLoader = new DexClassLoader(dexPath, dexOutput, null, getClassLoader());
PathClassLoader pathClassLoader = (PathClassLoader) getClassLoader();
try {
// 反射获取pathList成员变量Field
Field dexPathList = BaseDexClassLoader.class.getDeclaredField("pathList");
dexPathList.setAccessible(true);
// 现获取两个类加载器内部的pathList成员变量
Object pathList = dexPathList.get(pathClassLoader);
Object fixPathList = dexPathList.get(dexClassLoader);
// 反射获取DexPathList类的dexElements成员变量Field
Field dexElements = pathList.getClass().getDeclaredField("dexElements");
dexElements.setAccessible(true);
// 反射获取pathList对象内部的dexElements成员变量
Object originDexElements = dexElements.get(pathList);
Object fixDexElements = dexElements.get(fixPathList);
// 使用反射获取两个dexElements的长度
int originLength = Array.getLength(originDexElements);
int fixLength = Array.getLength(fixDexElements);
int totalLength = originLength + fixLength;
// 获取dexElements数组的元素类型
Class<?> componentClass = originDexElements.getClass().getComponentType();
// 将修复dexElements的元素放在前面,原始dexElements放到后面,这样就保证加载类的时候优先查找修复类
Object[] elements = (Object[]) Array.newInstance(componentClass, totalLength);
for (int i = 0; i < totalLength; i++) {
if (i < fixLength) {
elements[i] = Array.get(fixDexElements, i);
} else {
elements[i] = Array.get(originDexElements, i - fixLength);
}
}
// 将新生成的dexElements数组注入到PathClassLoader内部,
// 这样App查找类就会先从fixdex查找,在从App安装的dex里查找
dexElements.set(pathList, elements);
return true;
} catch (NoSuchFieldException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
return false;
}
上面的代码首先使用DexClassLoader读去外部的bugfix.dex文件,这样DexClassLoader内部就会存放解析完成的bugfix.dex的dexElements,之后再获取到当前PathClassLoader解析APK得到的dexElements对象,通过反射生成一个前面的bugfix解析dexElements后面是原始的dexElements的数组,将这个数组注入到PathClassLoader修改了它内部解析Class查找的数据,最后程序调用parseException的时候就会先从bugfix.dex文件读取修复过的代码。接着查看修复过之后classLoader的内容,可以看到bugfix.dex确实排在安装APK之前,再次点击调用parseException会发现修复已经完成。
dalvik.system.PathClassLoader[DexPathList[[dex file "/storage/emulated/0/bugfix.dex",
zip file "/data/app/com.example.misc2-1/base.apk"],
nativeLibraryDirectories=[/data/app/com.example.misc2-1/lib/arm, /vendor/lib, /system/lib]]]
java.lang.BootClassLoader@3a98234
如果在修复之前已经调用过有问题的parseException,直接在修复之后点击调用parseException还是不行,因为系统已经初始化过ExceptionUtils不会再加载就不会再从dex文件中读取类。需要在应用下一次冷启动的时候先点击修复,在调用parseException这时才能够确保修复生效。