Android 手把手带你写热修复

热修复

通过Hook类的加载器,将我们的dex插入到dex元素数组的最前面达到热修复的目的,通常情况下类只会被加载一次

前言

随着公司的业务越来越复杂,代码迭代次数过多导致代码难以维护,很多潜在的逻辑关联容易被忽略,虽然在发版的时候有做灰度和ab,还是难以避免出现一些奇奇怪怪的bug或者机型适配问题。所以当务之急是接入热修复。本篇内容主要分析热修复原理,在文章最后会有一个demo对全文做一个概括。希望能对你有一些帮助

经测试发现android 10上可以完美运行

ClassLoader

ClassLoader是一个抽象类,用于类的加载操作。

try {
  ClassLoader classLoader = getClass().getClassLoader();
  Class<?> aClass = classLoader.loadClass("类的全路径");
} catch (ClassNotFoundException e) {
  e.printStackTrace();
}

看一下loadClass(String name)的具体实现

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
            // 首先在缓存中去查找该类是否已经被加载
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                try {
                    //再由父类的加载器去加载该类
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                      //如果父类的加载器为空就由顶级类加载器去加载该类
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                //如果父类的加载器和顶级类加载器都没有加载到再由自己来加载
                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    c = findClass(name);
                }
            }
            return c;
    }

这里出现了一个非常典型的双亲委托例子:

通俗的讲,就是某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载。

为什么要这么多,双亲委托有什么好处呢?

  1. 可以避免重复加载,当父亲已经加载了该类的时候,就没有必要子ClassLoader再加载一次。
  2. 考虑到安全因素,我们试想一下,如果不使用这种委托模式,那我们就可以随时使用自定义的String来动态替代java核心api中定义类型,这样会存在非常大的安全隐患,而双亲委托的方式,就可以避免这种情况,因为String已经在启动时被加载,所以用户自定义类是无法加载一个自定义的ClassLoader。

继续分析源码:

findBootstrapClassOrNull(name);是一个空实现,该方法直接返回null,所以实际上加载类的操作落到了findClass(String name)。

我们去查看findClass的代码,居然发现下面这种情况

protected Class<?> findClass(String name) throws ClassNotFoundException {
        throw new ClassNotFoundException(name);
}

前面我们提到ClassLoader只是一个抽象类,我们在调用getClass().getClassLoader()方法返回的ClassLoader实际上是一个PathClassLoader,而PathClassLoader继承与BaseDexClassLoader

继承关系如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Ct7cel5m-1593345583256)(/Users/a1/Library/Application Support/typora-user-images/image-20200628145613651.png)]

所以这时候我们应该去PathClassLoader中去找findClass(String name)方法,但但遗憾的是没有找到,所以转而去BaseDexClassLoader中去寻找,Bingo!

@Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // First, check whether the class is present in our shared libraries.
        if (sharedLibraryLoaders != null) {
            for (ClassLoader loader : sharedLibraryLoaders) {
                try {
                    return loader.loadClass(name);
                } catch (ClassNotFoundException ignored) {
                }
            }
        }
        // Check whether the class in question is present in the dexPath that
        // this classloader operates on.
        List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
        //划重点
        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 c = pathList.findClass(name, suppressedExceptions)这句代码,这是加载Class的最后一步,如果这里还找不到就要抛出ClassNotFoundException了。pathList是DexPathList的引用,那么DexPathList又是什么呢?看一下该类的注释:

/**
 * A pair of lists of entries, associated with a {@code ClassLoader}.
 * One of the lists is a dex/resource path — typically referred
 * to as a "class path" — list, and the other names directories
 * containing native code libraries. Class path entries may be any of:
 * a {@code .jar} or {@code .zip} file containing an optional
 * top-level {@code classes.dex} file as well as arbitrary resources,
 * or a plain {@code .dex} file (with no possibility of associated
 * resources).
 *
 * 

This class also contains methods to use these lists to look up * classes and resources.

* * @hide */
public final class DexPathList { ... /** * List of dex/resource (class path) elements. * Should be called pathElements, but the Facebook app uses reflection * to modify 'dexElements' (http://b/7726934). */ @UnsupportedAppUsage private Element[] dexElements; //构造方法 DexPathList(ClassLoader definingContext, String dexPath, String librarySearchPath, File optimizedDirectory, boolean isTrusted) { //。。。省略一大堆检查操作 this.definingContext = definingContext; ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>(); // 解析出dexElements this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory, suppressedExceptions, definingContext, isTrusted); //。。。。省略一些native操作 } }

DexPathList的构造方法里面包含了以下几个参数

  1. definingContext:ClassLoader对象,主要用于加载指定路径下的dex文件
  2. dexPath:dex文件的路径,主要加载dex、apk等
  3. librarySearchPath:本地文件路径,主要加载程序引入的so库
  4. optimizedDirectory:加载dex文件优化后的文件存储路径,dex处理过的文件保存在此文件夹下
  5. isTrusted:是否被信任,true表示可以访问隐藏的API

makeDexElements方法将给定路径下的.dex(也可以是.apk, .jar)解析为Element数组并赋值给dexElements。

DexPathList的创建是在BaseDexClassLoader的构造方法里面。

我们再反过头来看Class c = pathList.findClass(name, suppressedExceptions);

public Class<?> findClass(String name, List<Throwable> suppressed) {
				//通过遍历dexElements来查找类
        for (Element element : dexElements) {
            Class<?> clazz = element.findClass(name, definingContext, suppressed);
            if (clazz != null) {
                return clazz;
            }
        }

        if (dexElementsSuppressedExceptions != null) {
            suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
        }
        return null;
    }

追根溯源,到最后才发现一切的根源都是dexElements。如果我们能对这个对象进行一些操作,那么热修复也就变得可能。

到这里可能有点蒙,我们理一下这个流程

  1. 通过ClassLoader的loadClass方法来加载类,类的加载会先去缓存中查找,如果找不到就会通过双亲委托的方式来查找类
  2. 查找类的流程会来到BaseDexClassLoader的findClass方法,在BaseDexClassLoader实例化的时候顺便会把DexPathList创建出来。DexPathList在实例化的时候会加载给定路径下的dex文件并保存在dexElements
  3. BaseDexClassLoader的findClass方法实际上又调用了DexPathList的findClass方法,该方法通过遍历dexElements的方式在Element中查找类
  4. 一旦类被成功加载就会缓存到虚拟机,下次会直接在缓存中拿到该类,不会再执行2,3步骤

反射操作dexElements

整体的思路如下:

baseDexClassLoader.pathList.dexElements = “我们创建的新Element数组”

  1. 创建一个补丁包(dex文件),放到项目缓存目录(切记不能放到外置存储卡目录,否则会报错)
  2. 通过PathClassLoader来加载这个补丁并获取到相应的dexElements,我们称这个dexElements为newDexElements
  3. 反射获取到pathList对象,再通过pathList获取dexElements,我们称这个dexElements为originDexElements
  4. 将newDexElements和originDexElements相结合(newDexElements在前)
  5. 执行赋值操作,完成热修复

如何将一个java类打包成dex文件?

  • 通过javac将java文件转为.class

  • 在sdk目录下的build_tools文件夹下选择相应的版本,找到d8这个文件。命令行中执行

    //将多个class打包进dex
    ./d8 1.class 2.class 3.class
    

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-q2a5oQZJ-1593345583257)(/Users/a1/Library/Application Support/typora-user-images/image-20200628162801996.png)]

执行完成后会在d8同目录下生成classes.dex

通过反射操作dexElements

前面已经分析了过程,话不多说,直接上代码了

public void hotFix(Context context) {
        try {
            //dex文件路径
            String dexPath = context.getCacheDir() + File.separator + "hotfix.dex";
            File file = new File(dexPath);
            if (!file.exists()) {
                return;
            }
            //获取类的加载器
            ClassLoader classLoader = getClass().getClassLoader();
            Class<BaseDexClassLoader> loaderClass = BaseDexClassLoader.class;
            //获取BaseDexClassLoader的pathList属性
            Field pathListFiled = loaderClass.getDeclaredField("pathList");
            pathListFiled.setAccessible(true);
            Object dexPathListObject = pathListFiled.get(classLoader);
            Class<?> dexPathListClass = dexPathListObject.getClass();
            //通过pathList获取到dexElements属性
            Field dexElementsField = dexPathListClass.getDeclaredField("dexElements");
            dexElementsField.setAccessible(true);
            Object oldElementObject = dexElementsField.get(dexPathListObject);

            //构建出一个全新的pathClassLoader,加载的是本地dex
            PathClassLoader newPathClassLoader = new PathClassLoader(dexPath, null);
            Object newPathListObject = pathListFiled.get(newPathClassLoader);
            Object newElementsObject = dexElementsField.get(newPathListObject);

            //将新老数组融合在一起组成一个新的Element数组并赋值给DexPathList的dexElements属性
            int oldLength = Array.getLength(oldElementObject);
            int newLength = Array.getLength(newElementsObject);
            Object concatElementsObject = Array.newInstance(oldElementObject.getClass().getComponentType(), oldLength + newLength);
            for (int i = 0; i < newLength; i++) {
                Array.set(concatElementsObject, i, Array.get(newElementsObject, i));
            }

            for (int i = 0; i < oldLength; i++) {
                Array.set(concatElementsObject, i + newLength, Array.get(oldElementObject, i));
            }
            
            //将新的element[]赋值给dexElementsField
            dexElementsField.set(dexPathListObject, concatElementsObject);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

加载补丁的操作可以放到attachBaseContext中进行,该方法的调用时机甚至早于onCreate

这里只是提到了对于类文件的替换,如果修改了资源文件又该如何呢?请参考

https://blog.csdn.net/u013894711/article/details/105166872

你可能感兴趣的:(Android 手把手带你写热修复)