热修复简单实现

前言

最近项目上的事情比较少有不少空闲时间就来研究一下热修复技术,热修复功能可以用来为刚发布的应用修复比较严重的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这时才能够确保修复生效。

猜你喜欢

转载自blog.csdn.net/xingzhong128/article/details/80480716