Android热修复(二):以DexClassLoader类加载原理编写demo实现类替换修复

上一篇文章简易总结了热修复实现的几大原理,并详细介绍了Android中的类加载机制及源码探索,Android的类加载机制涉及到ClassLoader、DexClassLoader 、PathClassLoader 、BaseDexClassLoader 、DexPathList、DexFile多个类之间方法互相调用,但是真正的核心实现类其实是DexPathList,它具体实现了findClass方法,即所谓的“类加载”概念。

其实此方法中的实现逻辑给开发者暗示了热修复的实现思路,此篇文章将一探究竟,剖析出如何根据DexClassLoader类加载机制来实现类替换修复,通过分析思路具体编码实践整个过程。

此篇文章将学习:

  • DexClssLoader类加载核心
  • 通过修改dexElement数组实现类替换修复
  • 整体编码过程思路及源码展示( dx命令生成dex文件)

(阅读此篇文章之前,要求理解前一篇文章的分析以作基础。此系列文章是环环相扣,不可缺一,链接如下:)
Android热修复(一):底层替换、类加载原理总结 及 DexClassLoader类加载机制源码探索


一. 如何通过DexClssLoader类加载实现热修复

1. 找到突破口——DexPathList

在经过上一篇对DexClassLoader相关源码分析后,可以发现类加载的主要逻辑处理都在DexPathList类中进行,类中有一个重要的成员变量——Element类型数组,和两个重要的方法——构造函数和findClass

  • 主要在构造函数中处理对Element类型数组赋值(遍历指定路径中的所有文件,将dex文件相关信息赋值到数组中);
  • findClass方法中遍历Element数组,找到对应类名的dex文件(Element类型的dexFile方法可转化为DexFile类型),调用本身native方法获取字节码文件返回。

Android热修复(二):以DexClassLoader类加载原理编写demo实现类替换修复_第1张图片

以上我又再次强调归纳一遍DexPathList类的逻辑处理,这几乎代表着Android类加载机制的重点核心了,也与后续“热修复处理”有关。

在多次总结中可见DexPathList类的成员变量Element类型数组很关键,而且又是通过Element数组中的成员加载得到class字节码文件,因此可以说所有加载的类都在Element数组中!

Android热修复(二):以DexClassLoader类加载原理编写demo实现类替换修复_第2张图片

见上图,此时客户端中的Test类(Test.class)有bug,而服务器已有修复好的Test.class,如若能够将已修复的Test.class代替客户端上的,是否就达到成功修复bug的功能?

这里写图片描述

如上图,再仔细查看DexPathList类中对Element数组的注释:dex或者资源(类路径)的集合,更应该命名为pathElement,但是Facebook app使用反射来修改 ‘dexElement’。以上谷歌注释相当于默认开发者可以这么玩:修改dexElement


2. 思路

在获取谷歌助攻之后,实现热修复功能萌生的第一个想法是:反射获取到dexElement,直接修改其中有bug的类替换成已修复的类。

不过此方法进一步具体实现有些不太理想,因为dexElement是一个数组,需要一个一个遍历找到对应的再做替换,过程实现是比较麻烦的。再仔细观察DexPathList类的findClass方法,发现内部循环的一个重点逻辑:在遍历dexElement数组时,若加载到的指定类class不为空时,直接return结束遍历,将class字节码返回!

Android热修复(二):以DexClassLoader类加载原理编写demo实现类替换修复_第3张图片

综上所述,第二个想法由此萌生:根据findClass方法里的内部逻辑,遍历查找到指定类加载,class不为空就直接返回,那我们可以直接将已修复的类放到有bug类的前面,一旦加载到已修复好的类,那后面的bug类就不会有任何影响。

第二个想法的思路很简单,相当于做了一个拦截此时你可能还在纠结第一个想法,在dexElement数组中删去有bug的去替换成已修复的,这操作过程并没有表面那么容易,与第二个想法比起来,为何不采用更简单的呢?


3.具体实现方法

实现热修复功能思路:直接将已修复的dex放到dexElement数组中有bug类的dex前面!

再结合Android系统思考详细操作:Android类加载加载的是dex文件,也就是解压APK后的classes.dex,而一个dex文件是包含了整个工程的class字节码文件。在发现原工程中有某个类或涉及到的几个类有错误,将这些已修复好的类打包到修复好的补丁包classes2.dex中,可以通过推送或其他方式由服务器传送到客户端中,那么接下来的任务就是将这两个dex合并,其注意目的也就是将修复的class放置到原来出bug的class(其实这里只需要将其放置到数组中第一个位置,就可以一劳永逸!)

Android热修复(二):以DexClassLoader类加载原理编写demo实现类替换修复_第4张图片

如上图,这是github上Tinker技术的介绍图,其原理与上述实现思路是不是非常类似?实则两者实现原理相同,皆从DexClssLoader类加载原理入手,但是此篇文章并不详细介绍Tinker使用及原理(下一篇介绍),而是了解以上原理自行编写demo实现类替换修复。

DexPathList类源码




二. 编码过程

(以下过程采用Genymotion模拟器操作,AS的java目录如下)

Android热修复(二):以DexClassLoader类加载原理编写demo实现类替换修复_第5张图片

1. 测试场景搭建

(1)测试UI界面

如下图,测试界面十分简单,一个activity中2个button,点击后的处理逻辑如下:

  • TEST按钮:点击后会进行10/0这样一个错误运算,点击之后程序必然出错闪退,以此来模拟应用闪退的情况(实际编码中不会出现此疏漏,在此仅为模拟)。
  • FIX按钮: 将已修复好的dex文件(设置其名称为classes2.dex)移置到应用程序目录中,再开始进行热修复类替换。

注意:此过程中的服务器传送给客户端已修复的dex文件(实际可通过推送或其他方式),此步骤笔者并未详细实现,而自行简化直接将已修复好的dex文件拖入Genymotion模拟器中,重在突出热修复逻辑处理过程。

Android热修复(二):以DexClassLoader类加载原理编写demo实现类替换修复_第6张图片


(2)TEST按钮方法实现

如上所述,点击TEST按钮后将进行10/0这个错误运算,即调用DexFixTest类的testFix方法,程序肯定会出错闪退,后续将修复此bug类,并生成已修复的dex用于热修复。

DexFixTest类仅用于测试,代码如下:

public class DexFixTest {
    public  void testFix(Context context){
        int dividend = 10;
        //bug:除数不可为0
        int divisor = 0;
        Toast.makeText(context, "shit:"+dividend/divisor, Toast.LENGTH_SHORT).show();
    }
}

(3)FIX按钮方法实现

此按钮点击会调用fixBug()方法:

  • 方法作用: 就是将已修复好的dex文件(设置其名称为classes2.dex)放置到应用程序目录中,做好准备工作后调用 DexFixUtils 进行热修复操作。
  • 方法逻辑:是对dex文件进行读写操作,主要易混点是dex文件下载到的路径和规定应用程序下的目录路径。

注意:此处可能有人会疑惑为何需要移置dex文件位置?因为此热修复方案采用的原理是DexClssLoader类加载,DexClassLoader有一个限制就是加载指定的dex文件必须要在应用程序的目录下面!因此先要移动dex文件到指定位置,再实行热修复。

获取规定应用程序下的目录路径

Context.java
public File getDir(String name, int mode)

此API返回/data/data/youPackageName/app_name(”app_” 是返回时系统自己加上的)下的指定名称的文件夹File对象,如果该文件夹不存在则用指定名称创建一个新的文件夹。用此API在内部存储中的对应app下创建name文件夹,用于存放移置后的已修复dex文件,后续DexClassLoader会加载此处dex来进行热修复。

获取dex文件下载到的路径

Environment.getExternalStorageDirectory().getAbsolutePath()

此API返回sdcard路径。在测试过程中是直接将已修复的dex文件拖入模拟器/真机中,通过此API获取到下载好的dex文件,准备移置。

fixBug()方法完整代码如下:

    /*
    * 把下载完成的已修复文件classes2.dex(SD卡)移置到 应用目录filePath
    * */
    private void fixBug() {
        String dexName = "classes2.dex";
        File fileDir = getDir(HotfixConstants.DEX_DIR, Context.MODE_PRIVATE);
        //⭐️⭐️⭐️⭐️⭐️DexClassLoader指定的应用程序目录
        String filePath = fileDir.getPath()+File.separator+dexName;
        File dexFile = new File(filePath);
        if(dexFile.exists()){
            dexFile.delete();
        }

        //移置dex文件位置(从sd卡中移置到应用目录)
        InputStream is = null;
        FileOutputStream os = null;
        // ⭐️⭐️⭐️⭐️⭐️sd卡路径应为"/storage/emulated/0",此处直接将文件拖入Genymotion,因此路径还需加上"/Download"
        String sdPath = Environment.getExternalStorageDirectory().getAbsolutePath()+"/Download";

        try {
            is = new FileInputStream(sdPath+File.separator+dexName);
            os = new FileOutputStream(filePath);
            int len = 0;
            byte[] buffer = new byte[1024];
            while((len = is.read(buffer)) != -1){
                os.write(buffer, 0, len);
            }

            //测试是否成功写入
            File fileTest = new File(filePath);
            if(fileTest.exists()){
                Toast.makeText(this, "dex重写成功", Toast.LENGTH_SHORT).show();
            }

            //获取到已修复的dex文件,进行热修复操作
            DexFixUtils.loadFixedDex(this);

        }catch (Exception e){
            e.printStackTrace();
        }

    }

最后还是要强调一下sd卡路径和应用程序目录路径,笔者在编写代码时对此处有些混淆,因此在第一大点中增加了Android内外存的相关介绍。分别用Genymotion模拟器(Google Nexus 5 API 23)和真机(华为 API 23)测试的结果如下:

  • 应用程序目录路径: 两者都是一样,为/data/user/0/com.lemon.hotfix/app_odex,odex是自定义的文件夹名,此文件夹用于存放移置后的已修复dex文件。
  • sd卡获取路径:
    • Genymotion模拟器:/storage/emulated/0
    • 真机:/storage/sdcard0

下图举例模拟器测试时debug到的路径:

Android热修复(二):以DexClassLoader类加载原理编写demo实现类替换修复_第7张图片



2. 热修复逻辑实现DexFixUtils

DexFixUtils类的主要作用是:

/*
* @author lemonGuo
* 处理热修复主要逻辑
* */
public class DexFixUtils {
    private static HashSet loadedDex = new HashSet();

    static{
        loadedDex.clear();
    }
    ......
}

(1)loadFixedDex 加载dex方法

/*
    * 遍历所有的dex文件存储到成员变量loadedDex中,用于后续合并
    * */
    public static void loadFixedDex(Context context) {
        if(context == null){
            return ; 
        }

        File fileDir = context.getDir(HotfixConstants.DEX_DIR, Context.MODE_PRIVATE);
        File[] listFiles = fileDir.listFiles();
        for (File file:listFiles){
            if(file.getName().startsWith("classes") && file.getName().endsWith("dex")){
                loadedDex.add(file);
            }
        }
        //合并之前到dex
        doDexInject(context,fileDir);
    }

(2)doDexInject 合并替换dex方法

目前的条件是已经获取到dex集合,即原本的classes.dex和已修复好的classes.dex2,而接下来的任务就是合并这两个dex文件,再回顾以下两个加载器作用:

  • PathClassLoader:用来加载已安装的应用程序dex;
  • DexClassLoader:支持加载外部的APK、Jar或dex文件;(限制:必须要在应用程序目录下)

根据以上加载器的各自作用,再结合第一点中的思路讲解,实现dex文件合并稍稍有了头绪,整体逻辑可分为3个步骤:

  1. 首先获得加载应用程序dex文件的PathClassLoader,即通过Context上下文context.getClassLoader()获取的便是加载应用的PathClassLoader;
  2. 然后获得加载制定路径下dex文件的DexClassLoader,这里的DexClassLoader需要自行创建,其构造方法中的四个参数分别是:指定要加载dex文件的路径dexPath、指定dex文件需要被写入的目录,一般是应用程序内部路径optimizedDirectory(不可以为null)、包含native库的目录列表librarySearchPath(可能为null)、父类加载器parent;
  3. 最后通过这两个加载器去重写 DexPathList类中的Element类型数组dexElements,即直接将已修复的dex放到dexElement数组中有bug类的dex前面。

上面3个步骤,重点在于获取到PathClassLoader、DexClassLoader加载器后,第三步如何详细实现第三步,即重写dexElements数组?

首先滤清思路既然要重写dexElements数组,首先要获得加载程序PathClassLoader所加载的dexElements数组,和加载已修复dex文件DexClassLoader所加载的dexElements数组,获得这两组数组之后,再进行重写。条件是已获得PathClassLoader、DexClassLoader两个加载器,接下来任务是如何获取其对应的dexElements数组?

查看以下源码,可见我们已经洞悉dexElements数组最终藏身之处,可是在AS中是无法直接查阅BaseDexClassLoader及相关源码,这将意味着首先需要通过反射获取到BaseDexClassLoader类,再反射获取类中的DexPathList类,即可获取到dexElements数组。

//源码
BaseDexClassLoader{
    DexPathList pathList;
}

DexPathList{
    Element[] dexElements;
}

注意此过程还没有结束,获取到两个数组后,首先要进行合并,即将已修复的dex放到dexElement数组中有bug类的dex前面,然后将合并后的数组去替换成程序加载器PathClassLoader所加载的数组,因为DexClassLoader只是用来加载程序之外的已修复dex文件,最终加载程序应用的还是PathClassLoader。如此一来即可大功告成!

以上doDexInject方法逻辑完整实现源码如下:

    /**
     * 通过PathClassLoader、DexClassLoader合并dex文件,实现类替换修复
     * @param context 上下文环境
     * @param filesDir dex所在的文件目录
     */
    private static void doDexInject(Context context, File filesDir) {
        //dex文件需要被写入的目录
        String optimizeDir = filesDir.getAbsolutePath()+File.separator+"opt_dex";
        File fileOpt = new File(optimizeDir);
        if(!fileOpt.exists()){
            fileOpt.mkdirs();
        }

        //1.获得加载应用程序dex的PathClassLoader
        PathClassLoader pathClassLoader = (PathClassLoader) context.getClassLoader();

        for (File dex : loadedDex) {
            //2.获得加载指定路径下dex的DexClassLoader
            DexClassLoader dexClassLoader = new DexClassLoader(
                    dex.getAbsolutePath(),
                    fileOpt.getAbsolutePath(),
                    null,
                    pathClassLoader);
            //3.合并dex
            try {
                Object dexObj = getPathList(dexClassLoader);
                Object pathObj = getPathList(pathClassLoader);
                Object fixDexElements = getDexElements(dexObj);
                Object pathDexElements = getDexElements(pathObj);
                //合并两个数组
                Object newDexElements = combineArray(fixDexElements,pathDexElements);
                //重新赋值给PathClassLoader 中的exElements数组
                Object pathList = getPathList(pathClassLoader);
                setField(pathList,pathList.getClass(),"dexElements",newDexElements);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

(3)反射相关方法

此部分有关反射getField的代码较为常规,另笔者多做了一层封装,通过getPathListgetDexElements调用getField 反射方法获取指定类,这样整体代码风格看起来更优雅。

注意:此处两个反射获取BaseDexClassLoader类和dexElements数组时提供的参数字符串不同,获取类需要以“包+类名”,例如dalvik.system.BaseDexClassLoader;获取成员变量只需“变量名”即可,例如dexElements

    private static Object getPathList(Object baseDexClassLoader) throws Exception {
        return getField(baseDexClassLoader,Class.forName("dalvik.system.BaseDexClassLoader"),"pathList");
    }

    private static Object getDexElements(Object obj) throws Exception {
        return getField(obj,obj.getClass(),"dexElements");
    }

    /**
     * 通过反射获得对应类
     * @param obj Object类对象
     * @param cl class对象
     * @param field 获得类的字符串名称
     * @return
     */
    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);
    }

    /**
     * 通过反射修改值
     * @param obj 待修改值
     * @param cl  class对象
     * @param field  待修改值的字符串名称
     * @param value  修改值
     */
    private static void setField(Object obj,Class cl, String field, Object value) throws Exception {
        Field localField = cl.getDeclaredField(field);
        localField.setAccessible(true);
        localField.set(obj,value);
    }

(4)数组合并方法

此方法的参数就是DexClassLoader、PathClassLoader所加载的这两个dexElements数组,主要目的就是将已修复的dex放到dexElement数组中有bug类的dex前面。

正如第一大点所说,没必要去找到那个bug类所在的数组位置,而去刚刚好将已修复的放置它前面,有一个最简单的方法:DexClassLoader所加载的dexElements数组是已修复过的,而PathClassLoader所加载的dexElements数组是存有bug的,因此直接创建一个新组,长度是两者之和,先赋值已修复过的数组中的值,然后再去赋值存在bug的数组中的值,如此一来根据findClass方法的return机制,遍历找到指定类后直接return,数组后面的值不再考虑,即可完美简化解决数组合并问题。

方法逻辑图如下:

Android热修复(二):以DexClassLoader类加载原理编写demo实现类替换修复_第8张图片

该方法具体实现代码如下:

    /**
     * 两个数组合并
     * @param arrayLhs
     * @param arrayRhs
     * @return
     */
    private static Object combineArray(Object arrayLhs, Object arrayRhs) {
        Class localClass = arrayLhs.getClass().getComponentType();
        int i = Array.getLength(arrayLhs);
        int j = i + Array.getLength(arrayRhs);
        Object result = Array.newInstance(localClass, j);
        for (int k = 0; k < j; ++k) {
            if (k < i) {
                Array.set(result, k, Array.get(arrayLhs, k));
            } else {
                Array.set(result, k, Array.get(arrayRhs, k - i));
            }
        }
        return result;
    }



三. 过程测试

1 . 程序闪退展示

首先程序运行后直接点击TEST按钮,客户端处理10/0运算,必然出错,出现程序闪退,效果GIF如下:


2 . 生成已修复的dex文件

笔者本想通过AS来获取这个已修复的dex文件,可是错误类只有DexFixTest这一个类,因此只需要对其编译成class文件,再到dex文件即可(此处暂时不考虑混淆),可是AS每次编译是将整个项目工程一起编译,那生成的dex文件是包含所有类的,如此一来还需什么替换,直接用此dex不就行了?

这样并非达到热修复本意,已修复dex文件只需要包含对错误类的修复,因此笔者采用手动通过命令来生成。过程如下:

  • 首先在AS中的DexFixTest类修改运算10/010/1,点击编译,在/Users/lemon/Desktop/gym/android_project/apps_gym/HotfixDemo/app/build/intermediates/classes/debug/com/lemon/hotfix/test/DexFixTest.class 可以找到该类的class文件。
  • 在借助AS获取到DexFixTest.class文件后,在桌面随意建一个文件夹放置该文件,注意包名需对应文件夹名!(图片如下),打开终端输入dx --dex --output=/Users/lemon/Desktop/test/classes2.dex /Users/lemon/Desktop/test/,即可生成对应的dex文件。
  • 直接将文件拖入到模拟器或真机即可

这里写图片描述

这里写图片描述

(此处对于dx命令不作过多讲解,详细可看笔者写的另一篇博文,链接如下:)
Android Dex VS Class:实例图解剖析两种格式文件结构、优劣


3 . 热修复功能展示

最终演示效果图如下,首先点击TEST按钮,程序运行10/0出错闪退。再次进入程序点击FIX按钮,进行类替换修复,将DexFixTest类中的运算替换为10/1,屏幕吐司显示dex文件已被重写,再点击TEST按钮计算,此时计算的是10/1,吐司显示1,表示类已经被替换,热修复成功。

Android热修复(二):以DexClassLoader类加载原理编写demo实现类替换修复_第9张图片

注意:在编写代码中一定要关闭AS 的Instant Run功能,它虽然可以加快构建和部署流程的速度,但其本身有太多限制。笔者掉入坑中好久,一直调试有问题,最后才发现是这厮在作怪!




以上是根据第一篇解析了DexClassLoader类加载相关源码后,探索到可实现热修复的思路,即修改dexElement,让findClass方法预先加载已修复的dex文件而忽略后续的bug文件,并凭借此思路最终实现类替换修复功能。

整体实现思路并不复杂,不过其中涉及到一些底层知识,例如Android内外存、反射、dex文件相关知识等等,笔者掉了不少坑,继续努力。这两篇主要学习DexClassLoader类加载相关原理并自行实现了一个小demo,对此理解已经不少,下一篇将学习记录腾讯Tinker的使用及原理,共勉~

(源码正在整理中,后续放出)


若有错误,虚心指教~

你可能感兴趣的:(Android,学习笔记)