手撸热修复框架(二)——加载补丁包修复Bug

前言

前面我们简单了解了几个热门热修复框架的实现和原理,以及优缺点。从自身的能力和目前的知识结构出发,我们选择模仿QZone框架并参考tinker的部分实现来一步步实现自己的热修复框架。

一、热修复分析

既然我们选择模仿QZone,那么也就是选择通过ClassLoader的类加载原理来实现热修复。所以我们必须要弄清楚ClassLoader是如何加载一个类的。这一部分内容在前面Android类加载原理章节我们已经讲过了,还不清楚的建议移步。

通过分析QZone热修复的原理我们总结了热修复的几大流程

(1)、获取当前应用的PathClassLoader

  (2)、反射获取ClassLoader中的DexPathList属性对象pathList;

(3)反射修改pathList中的dexElements数组,将补丁包插入数组,这个过程又可细分为如下三步

  • 把补丁包patch.dex转换成dexElements(patch)
  • 获取pathList的dexElements属性值(old)
  • 将patch+old合并成一个新的dexElements数组,并反射赋值给pathList的dexElements,即替换掉原数组。

这样说可能还是比较空洞,下面我们直接通过代码来实现。注意:这里需要我们有一定的阅读ClassLoader源码的能力,且有一定的了解。否则只会看的云里雾里。

二、获取当前应用的ClassLoader

这个应该很简单吧,我们只要如下通过Application来获取ClassLoader就行了。

ClassLoader classLoader = application.getClassLoader();

但实际上并不是这样简单的通过Application来获取ClassLoader就完全可行了,在这一环节同样需要我们进行Android的版本适配。这里我们先不做过多说明,后面我们会单独讲解。

三、反射获取pathList实列

当我们获取到当前应用的ClassLoader对象后,就可以通过反射来获取它的内部属性和对象了。反射相关知识在前面反射章节我们也讲解过,这里也不重复讲解了。这也是为啥我们总是说知识都是呈体系的。每一个知识都是由一堆的其他知识点来支撑的。基础真的很重要!

Field pathListField = findField(loader, "pathList");//这里的loader就是前面我们取到的PathClassLoader,通过反射我们获取它的pathList属性
Object dexPathList = pathListField.get(loader);//获取pathList对象实列
/**
 * 从 instance 到其父类 找 name 属性
 *
 * @param instance
 * @param name
 * @return
 * @throws NoSuchFieldException
 */
public static Field findField(Object instance, String name) throws NoSuchFieldException {
    for (Class<?> clazz = instance.getClass(); clazz != null; clazz = clazz.getSuperclass()) {
        try {
            //查找当前类的 属性(不包括父类)
            Field field = clazz.getDeclaredField(name);

            if (!field.isAccessible()) {
                field.setAccessible(true);
            }
            return field;
        } catch (NoSuchFieldException e) {
            // ignore and search next
        }
    }
    throw new NoSuchFieldException("Field " + name + " not found in " + instance.getClass());
}

 四、反射修改pathList中的dexElements数组

(1)把补丁包patch.dex转换成dexElements(patch)

ClassLoader在加载类时会去从dexElements数组中查询,而每一个dex文件在dexElements数组中对应的都是一个Element对象中的DexFile文件。我们要做的就是将补丁包的dex文件插入到dexElements数组的最前面。dexdexElements的成员变量是Element。这就需要我们将补丁包中dex文件转换成Element;

  ArrayList<IOException> suppressedExceptions = new ArrayList<>();
  // 从 pathList找到 makePathElements 方法并执行
    // 得到补丁文件patchfiles创建的 Element[]
  Object[] patchs= makePathElements(dexPathList,
                    new ArrayList<>(patchfiles), optimizedDirectory,
                    suppressedExceptions);
 /**
     * 这里我们通过反射DexPathList中的makeDexElements方法来将补丁包文件转换成Element[]
     * @param dexPathList  pathList对象实列
     * @param files 补丁包文件
     * @param optimizedDirectory opt优化文件存储路径
     * @param suppressedExceptions 一个处理异常的空列表
     * @return
     * @throws IllegalAccessException
     * @throws InvocationTargetException
     * @throws NoSuchMethodException
     */
    private static Object[] makeDexElements(Object dexPathList, ArrayList<File> files, File optimizedDirectory,
            ArrayList<IOException> suppressedExceptions)
            throws IllegalAccessException, InvocationTargetException, NoSuchMethodException {
        Method makeDexElements = PatchReflectUtil.findMethod(dexPathList, "makeDexElements",
                ArrayList.class, File.class,
                ArrayList.class);


        return (Object[]) makeDexElements.invoke(dexPathList, files, optimizedDirectory,
                suppressedExceptions);
    }
}

 (2)获取pathList的dexElements属性值(old)

通过上一步新的补丁包的Element[]已经生成了,接下来我们要做的就是将新生成的Element[]插入到dexElements数组的最前面。但是插入最前面还要做位移操作,所以我们不如先取出原dexElements,再将两个合并成一个新的,再替换。

//拿到 classloader中的dexelements 数组
Field jlrField = findField(dexPathList, "dexElements");
//old Element[]
Object[] old = (Object[]) jlrField.get(dexPathList);

 (3)将patch+old合并成一个新的dexElements数组,并替换掉原dexElements数组

通过上两步步我们已经拿到了ClassLoader中原dexElements数组old和补丁包dexElements数组patch。就差最后一步了,我们只需要重新创建一个融合了补丁包和旧版本的dexElements数组并将新数组赋值给classLoader中 pathList的 dexelements属性进行替换就大功告成了。

//合并后的数组
Object[] newElements = (Object[]) Array.newInstance(old.getClass().getComponentType(),
        old.length + patchs.length);
// 先拷贝新数组 patch+old
System.arraycopy(patchs, 0, newElements, 0, patchs.length);
System.arraycopy(old, 0, newElements, patchs.length, old.length);
//修改 classLoader中 pathList的 dexelements
jlrField.set(dexPathList, newElements);

走完这一步我们的补丁包就已经插入到dexElements的最前面了。具体实现可参考SHotFix

五、版本适配与问题探究

我们知道,Android版本到目前为止已经发布到Android11了,而每个版本之前还是有着很大差异和改动的。这就需要我们在做像热修复这种通过反射源码来完成功能的时候进行版本适配。但是很多朋友可能都会觉得看不懂源码或者嫌弃看每个版本的源码太麻烦,这里给个小的建议就是如果知识为了快速实现功能可以先看别人是怎么做的,比如热修复版本适配旧可以看Tinker是源码是怎么做的。(我这里就直接参考的Tinker源码),等功能完成了有兴趣可以再去跟着功能有目的的阅读源码。这就不写怎么去做版本适配了,其实也就是反射的方法有些微差异,原理是一样的。代码可直接参考demo。这里我要讲的几点不是代码怎么写,而是再适配的过程中会遇到哪些问题,以及为什么会遇到这样的问题和怎么解决这些问题。

这里要推荐两篇博文  :

第一篇:QQ空间开发团队在兼容低版本android手机的热修复时遇到的问题

第二篇:阿里大佬在兼容华为Android N手机热修复时遇到的问题

安卓App热补丁动态修复技术介绍

Android N混合编译与对热补丁影响解析

建议先去看看这两篇文章再来继续看下面的内容可能会更容易些,毕竟我的表达能力和上面这些大佬还是差了很多的。

问题一、类加载校验问题

在第一篇博客中大佬提到了类加载过程中会有个校验的过程,简单点理解就是如果A类中引用的所有类都与A类被打包到同一个dex包中,如A类中只引用了:B类。当打包dex时, A与B都被打包到classes.dex中(即同一个dex包中),则加载时A类会被标记上CLASS_ISPREVERIFIED标签。而被标记的类进行加载时都会对引用者与被引用者进行一个是否在同一个dex包中的校验。此时B类出bug了,进行修复后如果使用补丁包中的B类取代出现bug的B类,则会导致A与其引用的B不在同一个Dex包中,但A已经被打上CLASS_ISPREVERIFIED标记,此时进行校验出现冲突。导致校验失败!直接报类似这样的错误:

 

 

 

那么如何去解决呢?大佬的想法就是绕开校验,如何绕开呢?那就是不让B被打上标签。

上面我们也讲了,A类中引用的所有类都与A在同一个dex包中A类才会被打上标签,那么如果我们让A类引用一个必然与A类不在同一个dex包中的C类,那么A引用的类就存在至少一个与A类不在同一个dex包的类,这样A在加载时就不会被打上标签,也就完美的绕开了校验。如下图所示

 

注意这里的hack.dex是一个永远不会变的C类dex包。那么这个hack.dex怎么来呢?这就更简单了,随便写个module然后编译一下这个module生成class文件,再通过dx工具执行dx --dex --output=hack.dex com/single/patch/hack/AntilazyLoad.class这样的命令就生成hack.dex啦。其实关键点并不是hack.dex怎么生成,而是如何让我们的每个类都直接引用hack.dex里面的C类,并且是无感知的使用。这就需要使用我们常听的字节码插桩技术了,在编译期通过插桩将我们C类引用到应用的各个类中。但是这张我们不讲怎么实现插桩技术,我们会在下章单独拿出来讲解

 

问题二、Android N混合编译问题

第二篇博文中tinker大佬遇到了一个Android N的混合编译带来的坑,简单解释就是在Android N之前应用安装时间都比较长等问题,而Android N为了解决这样的问题就引入了混合编译运行的实现。具体啥是混合编译运行咱还是去看大佬的文章吧,这里我就不献丑了,只是简单的说一下我的理解:Android N之前应用都是在安装启动之前就进行AOT预编译成机器码,但是到了Android N变成了安装启动之前不进行预编译了,而是运行时解释字节码的方式,且增加了JIT(just run in time )在设备空闲的时候进行AOT编译生成base.art(类对象映像)。而这个art相当于类加载的缓存文件,会在启动时自动加载,即应用启动时类已经被加载了。这就导致我们基于类加载原理进行替换的方案无法生效了。那么怎么解决这个问题呢?最终大佬经过研究决定查用的方式是直接替换掉PathClassLoader,这样新的PathClassLoader就不存在base.art预加载的类对象了(即不使用类加载缓存)。当然这里并不是简单的重新创建一个PathClassLoader就行了,我们还需要利用反射替换掉所有利用到原ClassLoader的系统变量。如ContextImpl中的mClassLoader、LoadApk中的mClassLoader、以及Resources中的mClassLoader等等。具体实现请参考demo或者tinker源码。

问题三 存储权限问题

这个问题是我在做demo时发现的,我们都知道Android Q在存储这块做了很大改动,增加了分区存储的概念,也就是内部存储和外部存储的概念。Android Q严格限制了应用访问外部存储的能力,于是我做热修复demo时发现补丁包总是无法生效,最后发现是因为我没做Android Q的分区存储适配导致无法直接访问被放在外部存储的补丁包文件,也就无法将补丁包加载进ClassLoader中。解决方法就是进行Android Q的分区存储适配,需要确保我们的应用能够正常访问并去读补丁包文件。最简单的做法就是添加android:requestLegacyExternalStorage="true"

 

 

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值