美文网首页126Android coder进阶Android
热修复——深入浅出原理与实现

热修复——深入浅出原理与实现

作者: GitLqr | 来源:发表于2017-11-14 19:24 被阅读4445次

    一、简述

    热修复无疑是这2年较火的新技术,是作为安卓工程师必学的技能之一。在热修复出现之前,一个已经上线的app中如果出现了bug,即使是一个非常小的bug,不及时更新的话有可能存在风险,若要及时更新就得将app重新打包发布到应用市场后,让用户再一次下载,这样就大大降低了用户体验,当热修复出现之后,这样的问题就不再是问题了。

    目前较火的热修复方案大致分为两派,分别是:

    1. 阿里系:DeXposed、andfix:从底层二进制入手(c语言)。
    2. 腾讯系:tinker:从java加载机制入手。

    本篇的主题并非讲述上面两种方案的使用,而是基于java加载机制,来研究热修复的原理与实现。(类似tinker,当然tinker没这么简单)

    二、Android中如何动态修复bug

    关于bug的概念自己百度百科吧,我认为的bug一般有2种(可能不太准确):

    • 代码功能不符合项目预期,即代码逻辑有问题。
    • 程序代码不够健壮导致App运行时崩溃。

    这两种情况一般是一个或多个class出现了问题,在一个理想的状态下,我们只需将修复好的这些个class更新到用户手机上的app中就可以修复这些bug了。但说着简单,要怎么才能动态更新这些class呢?其实,不管是哪种热修复方案,肯定是如下几个步骤:

    1. 下发补丁(内含修复好的class)到用户手机,即让app从服务器上下载(网络传输)
    2. app通过"某种方式",使补丁中的class被app调用(本地更新)

    这里的"某种方式",对本篇而言,就是使用Android的类加载器,通过类加载器加载这些修复好的class,覆盖对应有问题的class,理论上就能修复bug了。所以,下面就先来了解和分析Android中的类加载器吧。

    三、Android中的类加载器

    Android跟java有很大的渊源,基于jvm的java应用是通过ClassLoader来加载应用中的class的,但我们知道Android对jvm优化过,使用的是dalvik,且class文件会被打包进一个dex文件中,底层虚拟机有所不同,那么它们的类加载器当然也是会有所区别,在Android中,要加载dex文件中的class文件就需要用到 PathClassLoaderDexClassLoader 这两个Android专用的类加载器。

    1、源码查看

    一般的源码在Android Studio中可以查到,但 PathClassLoaderDexClassLoader 的源码是属于系统级源码,所以无法在Android Studio中直接查看。不过,有两种方式可以在外部进行查看:第一种是通过下载Android镜像源码的方式进行查看,但一般镜像源码体积较大,不好下载,而且就只是为了看3、4个文件的源码动不动就下载3、4个g的源码,确实不太明智,所以我们一般采用第二种方式:到androidxref.com这个网站上直接查看,下面会列出之后要分析的几个类的源码地址,供看客们方便浏览。

    以下是Android 5.0中的部分源码:

    2、PathClassLoader与DexClassLoader的区别

    1)使用场景

    • PathClassLoader:只能加载已经安装到Android系统中的apk文件(/data/app目录),是Android默认使用的类加载器。
    • DexClassLoader:可以加载任意目录下的dex/jar/apk/zip文件,比PathClassLoader更灵活,是实现热修复的重点。

    2)代码差异

    因为PathClassLoader与DexClassLoader的源码都很简单,我就直接将它们的全部源码复制过来了:

    // PathClassLoader
    public 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);
        }
    }
    
    // DexClassLoader
    public class DexClassLoader extends BaseDexClassLoader {
        public DexClassLoader(String dexPath, String optimizedDirectory,
                String librarySearchPath, ClassLoader parent) {
            super(dexPath, new File(optimizedDirectory), librarySearchPath, parent);
        }
    }
    

    通过比对,可以得出2个结论:

    • PathClassLoader与DexClassLoader都继承于BaseDexClassLoader。
    • PathClassLoader与DexClassLoader在构造函数中都调用了父类的构造函数,但DexClassLoader多传了一个optimizedDirectory。

    3、BaseDexClassLoader

    通过观察PathClassLoader与DexClassLoader的源码我们就可以确定,真正有意义的处理逻辑肯定在BaseDexClassLoader中,所以下面着重分析BaseDexClassLoader源码。

    1)构造函数

    先来看看BaseDexClassLoader的构造函数都做了什么:

    public class BaseDexClassLoader extends ClassLoader {
        ...
        public BaseDexClassLoader(String dexPath, File optimizedDirectory, String libraryPath, ClassLoader parent){
            super(parent);
            this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
        }
        ...
    }
    
    • dexPath:要加载的程序文件(一般是dex文件,也可以是jar/apk/zip文件)所在目录。
    • optimizedDirectory:dex文件的输出目录(因为在加载jar/apk/zip等压缩格式的程序文件时会解压出其中的dex文件,该目录就是专门用于存放这些被解压出来的dex文件的)。
    • libraryPath:加载程序文件时需要用到的库路径。
    • parent:父加载器

    *tip:上面说到的"程序文件"这个概念是我自己定义的,因为从一个完整App的角度来说,程序文件指定的就是apk包中的classes.dex文件;但从热修复的角度来看,程序文件指的是补丁。

    因为PathClassLoader只会加载已安装包中的dex文件,而DexClassLoader不仅仅可以加载dex文件,还可以加载jar、apk、zip文件中的dex,我们知道jar、apk、zip其实就是一些压缩格式,要拿到压缩包里面的dex文件就需要解压,所以,DexClassLoader在调用父类构造函数时会指定一个解压的目录。

    不过,从Android 8.0开始,BaseDexClassLoader的构造函数逻辑发生了变化,optimizedDirectory过时,不再生效,详情可查看Android 8.0的BaseDexClassLoader.java源码

    2)获取class

    类加载器肯定会提供有一个方法来供外界找到它所加载到的class,该方法就是findClass(),不过在PathClassLoader和DexClassLoader源码中都没有重写父类的findClass()方法,但它们的父类BaseDexClassLoader就有重写findClass(),所以来看看BaseDexClassLoader的findClass()方法都做了哪些操作,代码如下:

    private final DexPathList pathList;
    
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
        // 实质是通过pathList的对象findClass()方法来获取class
        Class c = pathList.findClass(name, suppressedExceptions);
        if (c == null) {
            ClassNotFoundException cnfe = new ClassNotFoundException("Didn't find class \"" + name + "\" on path: " + pathList);
            for (Throwable t : suppressedExceptions) {
                cnfe.addSuppressed(t);
            }
            throw cnfe;
        }
        return c;
    }
    

    可以看到,BaseDexClassLoader的findClass()方法实际上是通过DexPathList对象(pathList)的findClass()方法来获取class的,而这个DexPathList对象恰好在之前的BaseDexClassLoader构造函数中就已经被创建好了。所以,下面就来看看DexPathList类中都做了什么。

    4、DexPathList

    在分析一个代码量较多的源码之前,我们要明确要从这段源码中要知道些什么?这样才不会在“码海”中迷失方向,我自己就定了2个小目标,分别是:

    • DexPathList的构造函数做了什么事?
    • DexPathList的findClass()方法是怎么获取class的?

    为什么是这2个目标?因为在BaseDexClassLoader的源码中主要就用到了DexPathList的构造函数和findClass()方法。

    1)构造函数

    private final Element[] dexElements;
    
    public DexPathList(ClassLoader definingContext, String dexPath,
            String libraryPath, File optimizedDirectory) {
        ...
        this.definingContext = definingContext;
        this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,suppressedExceptions);
        ...
    }
    

    这个构造函数中,保存了当前的类加载器definingContext,并调用了makeDexElements()得到Element集合。

    通过对splitDexPath(dexPath)的源码追溯,发现该方法的作用其实就是将dexPath目录下的所有程序文件转变成一个File集合。而且还发现,dexPath是一个用冒号(":")作为分隔符把多个程序文件目录拼接起来的字符串(如:/data/dexdir1:/data/dexdir2:...)。

    那接下来无疑是分析makeDexElements()方法了,因为这部分代码比较长,我就贴出关键代码,并以注释的方式进行分析:

    private static Element[] makeDexElements(ArrayList<File> files, File optimizedDirectory, ArrayList<IOException> suppressedExceptions) {
        // 1.创建Element集合
        ArrayList<Element> elements = new ArrayList<Element>();
        // 2.遍历所有dex文件(也可能是jar、apk或zip文件)
        for (File file : files) {
            ZipFile zip = null;
            DexFile dex = null;
            String name = file.getName();
            ...
            // 如果是dex文件
            if (name.endsWith(DEX_SUFFIX)) {
                dex = loadDexFile(file, optimizedDirectory);
    
            // 如果是apk、jar、zip文件(这部分在不同的Android版本中,处理方式有细微差别)
            } else {
                zip = file;
                dex = loadDexFile(file, optimizedDirectory);
            }
            ...
            // 3.将dex文件或压缩文件包装成Element对象,并添加到Element集合中
            if ((zip != null) || (dex != null)) {
                elements.add(new Element(file, false, zip, dex));
            }
        }
        // 4.将Element集合转成Element数组返回
        return elements.toArray(new Element[elements.size()]);
    }
    

    在这个方法中,看到了一些眉目,总体来说,DexPathList的构造函数是将一个个的程序文件(可能是dex、apk、jar、zip)封装成一个个Element对象,最后添加到Element集合中。

    其实,Android的类加载器(不管是PathClassLoader,还是DexClassLoader),它们最后只认dex文件,而loadDexFile()是加载dex文件的核心方法,可以从jar、apk、zip中提取出dex,但这里先不分析了,因为第1个目标已经完成,等到后面再来分析吧。

    2)findClass()

    再来看DexPathList的findClass()方法:

    public Class findClass(String name, List<Throwable> suppressed) {
        for (Element element : dexElements) {
            // 遍历出一个dex文件
            DexFile dex = element.dexFile;
    
            if (dex != null) {
                // 在dex文件中查找类名与name相同的类
                Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
                if (clazz != null) {
                    return clazz;
                }
            }
        }
        if (dexElementsSuppressedExceptions != null) {
            suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
        }
        return null;
    }
    

    结合DexPathList的构造函数,其实DexPathList的findClass()方法很简单,就只是对Element数组进行遍历,一旦找到类名与name相同的类时,就直接返回这个class,找不到则返回null。

    为什么是调用DexFile的loadClassBinaryName()方法来加载class?这是因为一个Element对象对应一个dex文件,而一个dex文件则包含多个class。也就是说Element数组中存放的是一个个的dex文件,而不是class文件!!!这可以从Element这个类的源码和dex文件的内部结构看出。

    四、热修复的实现原理

      终于进入主题了,经过对PathClassLoader、DexClassLoader、BaseDexClassLoader、DexPathList的分析,我们知道,安卓的类加载器在加载一个类时会先从自身DexPathList对象中的Element数组中获取(Element[] dexElements)到对应的类,之后再加载。采用的是数组遍历的方式,不过注意,遍历出来的是一个个的dex文件。

      在for循环中,首先遍历出来的是dex文件,然后再是从dex文件中获取class,所以,我们只要让修复好的class打包成一个dex文件,放于Element数组的第一个元素,这样就能保证获取到的class是最新修复好的class了(当然,有bug的class也是存在的,不过是放在了Element数组的最后一个元素中,所以没有机会被拿到而已)。

    五、热修复的简单实现

    通过前面的一堆理论之后,是时候实践一把了。

    1、得到dex格式补丁

    1)修复好有问题的java文件

    这一步根据bug的实际情况修改代码即可。

    2)将java文件编译成class文件

    在修复bug之后,可以使用Android Studio的Rebuild Project功能将代码进行编译,然后从build目录下找到对应的class文件。

    将修复好的class文件复制到其他地方,例如桌面上的dex文件夹中。需要注意的是,在复制这个class文件时,需要把它所在的完整包目录一起复制。假设上图中修复好的class文件是SimpleHotFixBugTest.class,则到时复制出来的目录结构是:

    3)将class文件打包成dex文件

    a. dx指令程序

    要将class文件打包成dex文件,就需要用到dx指令,这个dx指令类似于java指令。我们知道,java的指令有javac、jar等等,之所以可以使用这类指令,是因为我们有安装过jdk,jdk为我们提供了java指令,相同的,dx指令也需要有程序来提供,它就在Android SDK的build-tools目录下各个Android版本目录之中。

    b. dx指令的使用

    dx指令的使用跟java指令的使用条件一样,有2种选择:

    • 配置环境变量(添加到classpath),然后命令行窗口(终端)可以在任意位置使用。
    • 不配环境变量,直接在build-tools/安卓版本 目录下使用命令行窗口(终端)使用。

    第一种方式参考java环境变量配置即可,这里我选用第二种方式。下面我们需要用到的命令是:

    dx --dex --output=dex文件完整路径 (空格) 要打包的完整class文件所在目录,如:

    dx --dex --output=C:\Users\Administrator\Desktop\dex\classes2.dex C:\Users\Administrator\Desktop\dex

    具体操作看下图:

    在文件夹目录的空白处,按住shift+鼠标右击,可出现“在此处打开命令行窗口”。

    2、加载dex格式补丁

    根据原理,可以做一个简单的工具类:

    /**
     * @创建者 CSDN_LQR
     * @描述 热修复工具(只认后缀是dex、apk、jar、zip的补丁)
     */
    public class FixDexUtils {
    
        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";
        public static final String DEX_DIR = "odex";
        private static final String OPTIMIZE_DEX_DIR = "optimize_dex";
        private static HashSet<File> loadedDex = new HashSet<>();
    
        static {
            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) {
            if (context == null) {
                return;
            }
            // 遍历所有的修复dex
            File fileDir = patchFilesDir != null ? patchFilesDir : new File(context.getFilesDir(), DEX_DIR);// data/data/包名/files/odex(这个可以任意位置)
            File[] listFiles = fileDir.listFiles();
            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);// 存入集合
                }
            }
            // dex合并之前的dex
            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
                PathClassLoader pathLoader = (PathClassLoader) appContext.getClassLoader();
                for (File dex : loadedDex) {
                    // 2.加载指定的修复的dex文件
                    DexClassLoader dexLoader = new DexClassLoader(
                            dex.getAbsolutePath(),// 修复好的dex(补丁)所在目录
                            fopt.getAbsolutePath(),// 存放dex的解压目录(用于jar、zip、apk格式的补丁)
                            null,// 加载dex时需要的库
                            pathLoader// 父类加载器
                    );
                    // 3.合并
                    Object dexPathList = getPathList(dexLoader);
                    Object pathPathList = getPathList(pathLoader);
                    Object leftDexElements = getDexElements(dexPathList);
                    Object rightDexElements = getDexElements(pathPathList);
                    // 合并完成
                    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<?> componentType = arrayLhs.getClass().getComponentType();
            int i = Array.getLength(arrayLhs);// 得到左数组长度(补丁数组)
            int j = Array.getLength(arrayRhs);// 得到原dex数组长度
            int k = i + j;// 得到总数组长度(补丁数组+原dex数组)
            Object result = Array.newInstance(componentType, k);// 创建一个类型为componentType,长度为k的新数组
            System.arraycopy(arrayLhs, 0, result, 0, i);
            System.arraycopy(arrayRhs, 0, result, i, j);
            return result;
        }
    }
    

    代码虽然较长,但注释写得很清楚,请仔细看,这里要说两点:

    1)Class ref in pre-verified class resolved to unexpected implementation

    经反馈,这个是大家遇到的最多的一个问题,这里我把注意事项和我的解决方法写清楚:

    a.FixDexUtils

    // 合并完成
    Object dexElements = combineArray(leftDexElements, rightDexElements);
    // 重写给PathList里面的Element[] dexElements;赋值
    Object pathList = getPathList(pathLoader);// 一定要重新获取,不要用pathPathList,会报错
    setField(pathList, pathList.getClass(), "dexElements", dexElements);
    

    在合并守Element数组后,一定要再重新获取一遍App中的原有的pathList,不要复用前面的pathPathList,绝对会报错(Class ref in pre-verified class resolved to unexpected implementation)。

    b.Instant Run

    Android Studio的Instant Run功能也是用到了热修复的原理,在重新安装app时并不会完整安装,只会动态修改有更新的class部分,它会影响到测试结果,在跟着本文做试验的同学请确保Instant Run已经关闭。

    c.模拟器

    我在测试的过程中,使用的是Genymotion,发现Android 4.4的模拟器一直无法打上补丁,但是Android 5.0的模拟器却可以,真机测试也没问题,所以建议不要使用Android 5.0以下的模拟器来测试,强烈建议用真机测试!!

    2)dexPath与optimizedDirectory的目录问题

    DexClassLoader dexLoader = new DexClassLoader(
            dex.getAbsolutePath(),// 修复好的dex(补丁)所在目录
            fopt.getAbsolutePath(),// 存放dex的解压目录(用于jar、zip、apk格式的补丁)
            null,// 加载dex时需要的库
            pathLoader// 父类加载器
    

    上面的代码是创建一个DexClassLoader对象,其中第1个和第2个参数有个细节需要注意:

    • 参数1是dexPath,指的是补丁所有目录,可以是多个目录(用冒号拼接),而且可以是任意目录,比如说SD卡。
    • 参数2是optimizedDirectory,就是存放从压缩包时解压出来的dex文件的目录,但不能是任意目录,它必须是程序所属的目录才行,比如:data/data/包名/xxx。

    如果你把optimizedDirectory指定成SD卡目录,则会报如下错误:

    java.lang.IllegalArgumentException: Optimized data directory /storage/emulated/0/opt_dex is not owned by the current user. Shared storage cannot protect your application from code injection attacks.

    意思是说SD卡目录不属于当前用户。此外,这里再校正之前的一个小问题,optimizedDirectory不仅仅存放从压缩包出来的dex文件,如果补丁文件就是一个dex文件,那么它也会将这个补丁文件复制到optimizedDirectory目录下。

    3、加载jar、apk、zip格式补丁

    前面已经说了很多次DexClassLoader可以加载jar、apk、zip格式补丁文件了,那这类格式的补丁文件有什么要求吗?

    答案是:这类压缩包中必须放着一个dex文件,而且对名字有要求,必须是classes.dex。Why?这就需要分析DexPathList类中的loadDexFile()方法了。

    private static DexFile loadDexFile(File file, File optimizedDirectory)
            throws IOException {
        // 如果optimizedDirectory为null,其实就是PathClassLoader加载dex文件的处理方式
        if (optimizedDirectory == null) {
            return new DexFile(file);
        } 
        // 如果optimizedDirectory不是null,这就是DexClassLoader加载dex文件的处理方式了,重点看这个
        else {
            String optimizedPath = optimizedPathFor(file, optimizedDirectory);
            return DexFile.loadDex(file.getPath(), optimizedPath, 0);
        }
    }
    

    参数一file,可能是dex文件,也可能是jar、apk、zip文件。

    从上面的源码中,不难看出else分支才是DexClassLoader加载dex文件的处理方式,它调用的是optimizedPathFor()方法拿到之后dex文件在optimizedDirectory目录下的全路径:

    private static String optimizedPathFor(File path, File optimizedDirectory) {
        String fileName = path.getName();
        if (!fileName.endsWith(DEX_SUFFIX)) {
            int lastDot = fileName.lastIndexOf(".");
            // 如果补丁没有后缀,就给它加一个".dex"后缀
            if (lastDot < 0) {
                fileName += DEX_SUFFIX;
            } 
            // 不管补丁后缀是dex、jar、apk还是zip,最终放到optimizedDirectory目录下的一定是dex文件
            else {
                StringBuilder sb = new StringBuilder(lastDot + 4);
                sb.append(fileName, 0, lastDot);
                sb.append(DEX_SUFFIX);
                fileName = sb.toString();
            }
        }
    
        File result = new File(optimizedDirectory, fileName);
        return result.getPath();
    }
    

    前面已经说过了,Android的类加载器最终只认dex文件,即使补丁是jar、apk、zip等压缩文件,它也会把其中的dex文件解压出来,所以该方法得到的文件名一定是以dex结尾的。好了,这个optimizedPathFor()方法并不是重点,回头看loadDexFile()中的else分支还有一个DexFile.loadDex()方法,这个方法就相当重要了。

    static public DexFile loadDex(String sourcePathName, String outputPathName, int flags) throws IOException {
        return new DexFile(sourcePathName, outputPathName, flags);
    }
    

    这个方法中就调用了一下自己的构造函数,并传入各个参数,接着来看看DexFile的构造函数:

    /**
     * Open a DEX file, specifying the file in which the optimized DEX
     * data should be written.  If the optimized form exists and appears
     * to be current, it will be used; if not, the VM will attempt to
     * regenerate it.
     *
     * This is intended for use by applications that wish to download
     * and execute DEX files outside the usual application installation
     * mechanism.  This function should not be called directly by an
     * application; instead, use a class loader such as
     * dalvik.system.DexClassLoader.
     *
     * @param sourcePathName
     *  Jar or APK file with "classes.dex".  (May expand this to include
     *  "raw DEX" in the future.)
     * @param outputPathName
     *  File that will hold the optimized form of the DEX data.
     * @param flags
     *  Enable optional features.  (Currently none defined.)
     * @return
     *  A new or previously-opened DexFile.
     * @throws IOException
     *  If unable to open the source or output file.
     */
    private DexFile(String sourceName, String outputName, int flags) throws IOException {
        if (outputName != null) {
            try {
                String parent = new File(outputName).getParent();
                if (Libcore.os.getuid() != Libcore.os.stat(parent).st_uid) {
                    throw new IllegalArgumentException("Optimized data directory " + parent
                            + " is not owned by the current user. Shared storage cannot protect"
                            + " your application from code injection attacks.");
                }
            } catch (ErrnoException ignored) {
                // assume we'll fail with a more contextual error later
            }
        }
    
        mCookie = openDexFile(sourceName, outputName, flags);
        mFileName = sourceName;
        guard.open("close");
        //System.out.println("DEX FILE cookie is " + mCookie + " sourceName=" + sourceName + " outputName=" + outputName);
    }
    

    奇怪吗,这次我没有把构造函数的注释去掉,原因是在它的注释中就已经有我们想要的答案了:

    @param sourcePathName Jar or APK file with "classes.dex".  (May expand this to include "raw DEX" in the future.)
    

    这名注释的意思就是说,jar或apk格式的补丁文件中需要有一个classes.dex。至此,对于压缩格式的补丁文件的要求就弄明白了。那么接下来就只需要生成这几种格式的补丁试一试就好了。制作这类压缩文件也很简单,直接用压缩软件压缩成zip文件,然后改下后缀就可以。

    六、测试

    这部分其实本不想写的,因为比较简单,但想了想不写又觉得不完整,那接下来就来测试一波吧。

    1、代码

    1)Activity

    布局文件就俩按钮,很简单就不贴布局文件代码了,看这两个按钮的点击事件就行。

    public class SimpleHotFixActivity extends AppCompatActivity {
    
        @Override
        protected void onCreate(@Nullable Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView(R.layout.activity_simple_hot_fix);
        }
    
        // “修复”按钮的点击事件
        public void fix(View view) {
            FixDexUtils.loadFixedDex(this, Environment.getExternalStorageDirectory());
        }
    
        // “计算”按钮的点击事件
        public void clac(View view) {
            SimpleHotFixBugTest test = new SimpleHotFixBugTest();
            test.getBug(this);
        }
    }
    

    可以看到,“修复”按钮的点击事件是去加载SD卡目录下的补丁文件。

    2)SimpleHotFixBugTest

    public class SimpleHotFixBugTest {
        public void getBug(Context context) {
            int i = 10;
            int a = 0;
            Toast.makeText(context, "Hello,I am CSDN_LQR:" + i / a, Toast.LENGTH_SHORT).show();
        }
    }
    

    会发生什么事呢?除数是0异常,一个简单的运行时异常,修复它也很简单,把a的值改为非0即可。

    2、演示

    1、bug

    不多说,看操作。

    妥妥的ArithmeticException。

    Caused by: java.lang.ArithmeticException: divide by zero

    2、动态修复bug

    首先,我将补丁文件classes2.dex放到手机的SD目录下。

    然后先点击修复按钮,再点计算按钮。

    大功告成,压缩格式的补丁跟dex格式的补丁一样,直接丢掉SD卡目录下就行了,但一定要注意,压缩格式的补丁中的文件一定是classes.dex!!!

    最后贴下Demo地址

    https://github.com/GitLqr/HotFixDemo

    权限申请:本文的提供的Demo是读取SD卡下的补丁文件,但却没有为Android6.0以上适配动态权限申请,如果你有使用该demo进行测试,那要注意自己测试机的Android版本,若是6.0以上,请务必先为Demo分配SD卡读写操作权限,否则App崩溃都不知道是不是因为bug造成的 ,切记。

    相关文章

      网友评论

      • 不止技术流_:这种java加载机制的热修复在8.0之后手机以及厂商兼容性和稳定性怎么样? 我使用了同样机制的nuwa,热修复之后,偶现闪退。 我是有一个类和匿名内部类,匿名内部类加了一句对外部类成员变量的判空处理,结果告诉noSuchMethod的错误,no static method XXX.class.$700
      • LewisWeng:LZ,我有个小小的疑问,修复类加载顺序的步骤不应该放在Application里吗?
        当界面进入SimpleHotFixActivity的时候,SimpleHotFixBugTest这个类应该已经被加载了,比较奇怪为什么你的例子里可以?
        我自己测试是不行的,只能放在App里
        LewisWeng:@C_Cheng 你说的很对,十分感谢:smile:
        C_Cheng:修复类加载顺序的方法确实是越早执行越好,因为ClassLoader是通过loadClass方法去查找类的,而loadClass中又会先调用findLoadedClass查找已经加载过的类,如果能找到已经加载过的类就不会执行findClass了。至于LZ的Demo为啥可以,而你的不行可能是因为你的SimpleHotFixActivity中有import SimpleHotFixBugTest这个类,导致SimpleHotFixActivity加载时就把SimpleHotFixBugTest也加载进去了,LZ的Demo里面SimpleHotFixActivity是没有import SimpleHotFixBugTest的,所以是在执行clac方法时才会加载这个类
      • 香脆的大鸡排:深度好文,言简意该。谢谢你。学习了。
      • cp我要吃饭:打补丁成功了,但是发现一个问题就是:我结束进程后,再次进入APP发现还是打补丁之前的状态,必须再次点击修复才行?请问上述的方式没有覆盖掉原有的class文件么?
        cp我要吃饭:@CSDN_LQR 明白了
        GitLqr:@cp我要吃饭 这种补丁方案需要配合自定义的Application来使用,在onCreate中调用打补丁方法。
      • cp我要吃饭:复制class文件的时候,需要把com下面的内容全部粘贴过来么?
      • rockypzhang:4.4真机上面运行没有成功替换,combineArray和setField都正常执行了
        Caused by: java.lang.ArithmeticException: divide by zero
        at com.lqr.hotfixdemo.simplehotfix.SimpleHotFixBugTest.getBug(SimpleHotFixBugTest.java:10)
        at com.lqr.hotfixdemo.simplehotfix.SimpleHotFixActivity.clac(SimpleHotFixActivity.java:26)

        Instant Run 已经关闭了
        rockypzhang:使用gradle+javassist修改一下class文件,在SimpleHotFixBugTest类的构造方法里面加上一个System.out.println("modify class use transform javassist");,测试4.4机器也可以正常修复了
        rockypzhang:@CSDN_LQR 换了一台5.1.1的机器,可以修复。5.0之前的版本还需要研究一下。
        GitLqr:@阿三rtfc 如果成功合并element数组,那是不可能出现divide by zero的(也就是说你应该是失败了,补丁没打上),如果成功打上补丁,只会出现Class ref in pre-verified class resolved to unexpected implementation,测试Android5.0以上的真机都可以成功,但4.4的确实不行,应该是跟CLASS_ISPREVERIFIED有关,建议先用Android5.0以上的机器尝试。
      • rockypzhang:差分dex怎么管理比较方便,比如apk维护一年多热更新可能已经产生几十次的差分dex,这需要每次最新更新都不断在原有dex基础上增加类,导致差分dex越来越大。有没有别的好办法
      • 木子而东:为什么我甄姬测试的你的demo 修复后点calc 还是奔溃,Demo1Ex这个目录里的文件是补丁文件吗
        木子而东:我的是Android 7.0的真机,权限都给了
        木子而东:就是 没修复好。。我也自己生成了classes2.dex 文件,还是不行
        GitLqr:@小五_李陈 你看看报什么错,Demo1Ex这个目录里的文件是补丁文件,如果你没有改过包名的话可以直接使用。
      • 开发者头条_程序员必装的App:感谢分享!已推荐到《开发者头条》:https://toutiao.io/posts/y4laa5 欢迎点赞支持!
        欢迎订阅《CSDN_LQR》https://toutiao.io/subjects/206397
      • Alien的小窝:图是用什么画的?
        GitLqr:@编程之乐 FSCapture+思维导图
      • zzzz简:是不是加载补丁文件的名字必须是classes.dex呢? 您这边可以帮忙试试吗,如果不是这个名字会怎么样, 我之前也是按照这种方式去做过,但是失败了 ,加载补丁的流程也跟您这一样,刚刚去翻代码,以前的代码被我删掉了,只能回去找家里的代码看下,不过还是想知道是不是dex文件名字的原因
        zzzz简:@zzzz简 我这边就是修改toast内容 如果进去先修复在按按测试按钮,应用会闪退,如果进去先点测试按钮,再点修复按钮,然后在点测试按钮,应用程序闪退,抛的这个异常
        lass ref in pre-verified class resolved to unexpected implementation
        zzzz简:@CSDN_LQR 我这边把您这边demo下下来了,然后把相关类全部拷到module里去 日志显示已经插入dex文件了 ,但是结果还是不行 我之前写的也是这样 ,感觉不明觉厉
        GitLqr:@zzzz简 如果补丁文件是dex文件,那么名字随意,可以根据你的项目来对名字做限制(如:文章中的DexFixUitl代码中只判断后缀是不是dex而已,不管名字);如果补丁文件是apk、jar等压缩格式,那么压缩包里面一定是一个classes.dex文件(名字绝对是classes.dex)。
      • Android_YangKe:好文章,鸡汤不错!

      本文标题:热修复——深入浅出原理与实现

      本文链接:https://www.haomeiwen.com/subject/menfvxtx.html