Android热修复技术(一) 原理和实现

写在前面:
参考文章 热修复——深入浅出原理与实现

一、简述和意义

在热修复之前,一个上线的app如果出现了bug,即使非常小,要是想及时更新就必须将app重新打包发布到应用市场,让用户重新下载安装,使得用户体验非常差,而且很多用户不愿意去经常更新app,所以严重的bug还会造成用户流失,甚至带来严重的后果。

热修复技术就是能在用户不用下载安装新的app,甚至无感知的情况下修复一些紧急或者必须的bug的技术。该技术是这几年比较火的技术,也是项目非常需要的技术,更是作为开发者必须学习的技能之一。

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

  • 阿里系 Hotfix Sophix 从底层二进制入手
  • 腾讯系 Tinker 从java加载机制入手

备注:本篇就是基于java加载机制,来研究热修复的原理和实现

二、Dex分包方案分析

2.1 分包方案由来(Dalvik限制)

当一个app功能越来越复杂,可能就会出现编译失败,因为一个jvm中存储方法个数id用的是short类型,导致dex中方法数不能超过65535

基于此,就需要对app编译的时候进行分包,即将编译好的class文件拆分打包成多个dex,绕过dex方法数量的限制以及安装时的检查,在运行时在动态加载其他的dex文件,即其他dex文件在Application初始化的时候被注入到系统的ClassLoader中。

2.2 Dex分包加载的原理(ClassLoader源码分析)

在Android中,要加载dex中的class文件就需要用到PathClassLoder和DexClassLoder这两个专用的类加载器,它们都继承于BaseDexClassLoader

以下是Android5.0中的部分源码
PathClassLoader.java
DexClassLoader.java
BaseDexClassLoader.java
DexPathList.java

  • 使用场景

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

// 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);
    }
}
  • 参数解释:
    • dexPath:要加载的patch包文件(一般是dex文件,也可以是jar/zip/apk文件)的文件目录
    • optimizedDierctory:dex文件的输出目录(因为在加载jar/apk/zip等压缩格式的文件是会解压出其中的dex文件,该目录就是用于存放这些被解压出来的dex文件的)
    • libreayPath:要加载patch文件时需要用到的库路径
    • parent:父类加载器

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

  • PathClassLoader和DexClassLoader都继承于BaseDexClassLoader
  • PathClassLoader与DexClassLoader在构造方法中都是调用的父类的构造方法,但是DexClassLoader多传了一个optimizedDirectory

所以我们重点还是看BaseDexClassLoader都做了什么

public class BaseDexClassLoader extends ClassLoader {

   private final DexPathList pathList;

   public BaseDexClassLoader(String dexPath, File optimizedDirectory, String libraryPath, ClassLoader parent){
        super(parent);
        this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
    }
    ...

    //查找要加载的class
    @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;
 }
}

类加载器会提供一个方法供外界找到它所加载的class,这个方法就是上面的findClass(),可以看到findClass()内部是通过构造方法中创建的对象DexPathList的findClass()来获取对应的Class的,接下来分析DexPathList的源码

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

上面构造方法中,调用了makeDexElements方法得到了Element[]数组,这个数组就是通过将一个个patch包封装成一个个Element对象后得到的集合,对于内部调用的splitDexPath(dexPath)是将传进来的dexPath按照(” : “)分割成的一个List集合,所以可以得知dexPath是一个以(” : “)分割的多个dex文件目录拼成的字符串,这个方法比较简单,代码就不贴出来了,下面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文件,loadDexFile()是加载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的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的findClass非常简单,就是对Element[]数组进行遍历,一但找到与name相同的类时,就直接返回找到的class,找不到则返回null

三、基于类加载器的热修复的实现原理

经过上面对PathClassLoader、DexClassLoader、BaseDexClassLoader、DexPathList的分析,我们知道,Android的类加载器在加载一个类的时候会从DexPathList中的Element[]数组进行遍历,找到对应的DexFile,使用DexFile的loadClassBinaryName去加载对应的类。

这里写图片描述

所以,我们只需要让已经修复好的class打包成一个dex文件,使用DexClassLoader去加载dex文件,得到对应的Element数组,放在应用原Element数组的最前面,这样就能保证获取到的Class是最新修复好的class了,(其实有bug的Class也是存在的,只是没有机会被加载到而已)

实现方式是:创建对象DexClassLoader去加载补丁,加载后反射获取得到的pathList和dexElements数组,将补丁dexElements数组与App原来的dexElements数组进行合并,然后将这个新的Element数组通过反射的方式赋值给当前类加载器的pathList的属性dexElements,这样在加载类的时候就保证先加载到修复好bug的Class了

这里写图片描述

四、热修复的简单实现

经过上面那么多的理论分析,是时候实践一下了

1. 得到dex补丁

  • 修复好有问题的java文件
  • 将java文件编译成class文件(Android studio Build->Rebuild Project,完成后从build目录找到对应的class文件)
    这里写图片描述

    将修复好的class文件复制出来,注意在复制该class文件时,需要将它所在的完整包目录一起复制,对应于上图,复制出来的目录结构应该是:
    这里写图片描述

  • 将class文件打包成dex文件
    要想将class文件打包成dex文件,需要用到dx命令,这个命令类似于java命令,我们知道,java命令有javac、jar等等,之所以可以使用这类命令,是因为我们有jdk,而dx命令是由Android SDK提供的,它在build_tools目录下的各个Android版本目录中
    这里写图片描述
    要想使用dx指定,有两种方式

    • 配置环境变量(添加到classpath),然后终端在任意位置都能使用
    • 不配置环境变量,只能在dx所在目录使用

    dx –dex –output=dex/classes2.dex dex
    dx –dex –output=(dex输出目录) 空格 (要打包的完整class所在目录)

2. 加载dex格式补丁

根据原理,以下为加载patch包的简单工具类

public class FileDexUtils {
    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 FileDexUtils INSTANCE;

    private FileDexUtils() {
    }

    public static FileDexUtils getInstance() {
        if (INSTANCE == null) {
            synchronized (FileDexUtils.class) {
                if (INSTANCE == null) {
                    INSTANCE = new FileDexUtils();
                }
            }
        }
        return INSTANCE;
    }

    /**
     * 根据指定目录将指定目录下的所有符合patch包规则的文件 的文件目录以:分割拼接成字符串
     *
     * @param context
     * @param patchFileDir
     */
    public void loadFixedDex(Context context, File patchFileDir) {
        if (context == null) {
            return;
        }
        StringBuilder loadedDex = new StringBuilder();
        File fileDir = patchFileDir != null ? patchFileDir : new File(context.getFilesDir(), DEX_DIR);
        File[] listFiles = fileDir.listFiles();
        if (listFiles == null) {
            return;
        }
        for (File file : listFiles) {
            if (file.getName().startsWith("classes") &&
                    (file.getName().endsWith(DEX_SUFFIX)
                            || file.getName().endsWith(APK_SUFFIX)
                            || file.getName().endsWith(ZIP_SUFFIX)
                            || file.getName().endsWith(JAR_SUFFIX))) {
                loadedDex.append(file.getAbsolutePath()).append(":");
            }
        }
        if (loadedDex.length() > 0) {
            loadedDex.replace(loadedDex.length() - 1, loadedDex.length(), "");
        }
        doDexInject(context, loadedDex.toString());
    }

    /**
     * 根据拼接好的patch包文件目录去进行加载
     * @param context
     * @param loadedDex
     */
    private void doDexInject(Context context, String loadedDex) {
        if (TextUtils.isEmpty(loadedDex)) {
            return;
        }
        String optimizeDir = context.getFilesDir().getAbsolutePath() + File.separator + OPTIMIZE_DEX_DIR;
        File fopt = new File(optimizeDir);
        if (!fopt.exists()) {
            fopt.mkdirs();
        }
        try {
            PathClassLoader pathClassLoader = (PathClassLoader) context.getClassLoader();
            //1 加载指定要修复的dex文件
            DexClassLoader dexClassLoader = new DexClassLoader(loadedDex, optimizeDir, null, pathClassLoader);
            //2 获取BaseDexClassLoader内部的DexPathList
            Object dexPathList = getPathList(dexClassLoader);
            Object pathPathList = getPathList(pathClassLoader);
            //3 获取DexPathList内部的DexElements
            Object leftDexElements = getDexElements(dexPathList);
            Object rightDexElements = getDexElements(pathPathList);
            //4 将两个DexElements合并
            Object dexElements = combineArray(leftDexElements, rightDexElements);
            //重新给DexPathList的Element[] 赋值
            Object pathList = getPathList(pathClassLoader);
            setField(pathList, pathList.getClass(), "dexElements", dexElements);
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
    }

    private Object getDexElements(Object pathList) throws NoSuchFieldException, IllegalAccessException {
        return getField(pathList, pathList.getClass(), "dexElements");
    }

    private Object getPathList(ClassLoader classLoader) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
        return getField(classLoader, Class.forName("dalvik.system.BaseDexClassLoader"), "pathList");
    }

    /**
     * 反射得到对象中的属性值
     */
    private Object getField(Object obj, Class<?> cl, String field) throws NoSuchFieldException, IllegalAccessException {
        Field localField = cl.getDeclaredField(field);
        localField.setAccessible(true);
        return localField.get(obj);
    }

    /**
     * 反射给对象的属性重新赋值
     */
    private void setField(Object obj, Class<?> cl, String field, Object value) throws NoSuchFieldException, IllegalAccessException {
        Field localField = cl.getDeclaredField(field);
        localField.setAccessible(true);
        localField.set(obj, value);
    }

    /**
     * 数组合并
     *
     * @param arrayLeft
     * @param arrayRight
     * @return
     */
    private Object combineArray(Object arrayLeft, Object arrayRight) {
        Class<?> componentType = arrayLeft.getClass().getComponentType();
        int i = Array.getLength(arrayLeft);
        int j = Array.getLength(arrayRight);
        int k = i + j;
        Object result = Array.newInstance(componentType, k);
        System.arraycopy(arrayLeft, 0, result, 0, i);
        System.arraycopy(arrayRight, 0, result, i, j);
        return result;
    }
}

在application中加载patch包

public class MyApplication extends Application {
    @Override
    public void onCreate() {
        super.onCreate();
        boolean sdCardExist = Environment.getExternalStorageState()
                .equals(android.os.Environment.MEDIA_MOUNTED);
        if (sdCardExist) {
            FileDexUtils.getInstance().loadFixedDex(this, Environment.getExternalStorageDirectory());
        }
    }
}

下面就可以写demo按照上面步骤尝试以下bug修复啦

上面只是简单的patch包的加载,是基于类加载机制的热修复的核心原理,但若真想运用到项目中还要考虑patch包的下发和下载,版本的区分,系统类加载器不同版本源码的区分等流程和逻辑的控制,也是比较复杂的一套流程。

结束语:谢谢大家的聆听!

猜你喜欢

转载自blog.csdn.net/qq_33666539/article/details/80936294