06 项目架构-热修复-主流框架分析

作者: 凤邪摩羯 | 来源:发表于2021-11-08 09:41 被阅读0次

背景

热修复就是通过下发补丁包,让已安装的客户端动态更新,用户不用升级App版本,就能够修复软件缺陷。很多一线互联网公司都有自己的热修复框架,比如微信的Tinker,阿里的Sophix,QZone的超级补丁(已废弃),美团的Robust,这四个算是比较有名的热修复框架,除了Sophix,超级补丁没有开源(但也有文章分享一些实现方案),其他的都已开源。

热修复需要解决的问题

带着问题去了解一个模块往往能更有效率,尤其是对初学热修复的开发人员来说,下面我们提出这几个问题。

  1. 补丁包是什么?
  2. 如何生成补丁包?
  3. 开启混淆后呢?
  4. 什么时候执行热修复?
  5. 怎么执行热修复(使用补丁包)?
  6. 主流的的热修复框架的实现原理?
  7. Android版本的兼容问题?
  8. 热修复过程遇到的其他问题?

热修复现状

阿里现在主推的Sophix(未开源)非常有名,就目前的热修复现状,Sophix,Tinker,Robust是很多小公司的首选的三个热修复方案

  • Sophix是收费版本(有免费阈值),支持即时生效和重启生效,支持Java代码修复和资源,so库的修复。接入简单。
  • Tinker有免费、收费版本,只能重启生效,支持Java代码修复和资源,so库的修复。接入复杂。
  • Robust是免费的,支持实时生效,但是不支持资源和so库的修复(正在开发),修复成功率最高,不过会增加包体积。接入使用有点复杂,需要自己生成等,详情见github

总的来说,接入成本最高的是Tinker和Robust(没有官方服务端),在只修复java代码的情况下,同时追求修复成功率(不考虑增加包体积),那肯定选择的是Robust。在需要资源修复和so库修复的情况下,免费的情况下考虑Tinker和Sophix的免费阈值,这个和App的月活和补丁包大小有关系,免费方面Sophix更有利,收费的话各有利弊,详情可以参考这篇文章,不过最新的免费、收费标准可能有变化,最终还是得去官网看看,但是可以参考它的分析思路。

下面我们来对比Tinker,阿里的Sophix,,美团的Robust,这三个框架的优缺点:

方案对比 Tinker Sophix Robust
类替换 支持 支持 不支持(插桩方案原理)
So替换 支持 支持 不支持
资源替换 支持 支持 不支持
全平台支持 支持 支持 支持
即时生效 不支持 支持 支持
性能损耗 较大 较小 较小
补丁包大小 较小 一般 一般
开发透明
接入复杂度 较高 较低 较高,目前没有官方服务端的支持

Sophix是AndFix的进阶版本,专门有一本书《深入探索Android热修复技术原理》介绍它,书中也同时对比了它和其他框架的优缺点,总是就是一句话,老子最牛B,其他的都不太行!

简要说明三大框架的实现原理

Sophix(Native Hook,类加载)

在native层面替换,把ArtMethod方法作为整体进行替换,实时生效,如下图

image

但同时也会带来诸多限制,比如当修复引起了原有的类中发生结构变化的修改(字段,方法增加减少),这个时候就不能实时生效了,具体的原因可以看看《深入探索Android热修复技术原理》的分析,该框架的热修复实时生效限制内容不少。

但是Sophox也支持重启生效,对于那些不能实时生效的限制,采用重启生效来修复。我们要做的并不是把某个类的所有信息都从你dex移除,因为这么做,可能会导致dex的各个部分都发送变化,从而需要调整大量的offset,这么就变得费时费力了。我们需要做的仅仅是使得解析这个dex的时候找不到这个类的定义就可以了。因此只需要移除定义的入口,对于类的具体内容不进行删除,这样可以最大限度的减少offset的修改。

  • Dalvik下采用自行研发的全量dex方案
  • 在Art下本质上虚拟机已经支持多dex的加载,我们要做的仅仅是把补丁dex作为dex(classes.dex)加载而已。

Art模式下虚拟机默认支持多dex压缩文件的加载。Art下面最终冷启动解决方案:我们只要把补丁dex命名为classes.dex。原APK中dex依次命名为class(2,3,4…).dex就可以了,然后一起打包进一个压缩文件。在通过DexFile.loadDex得到DexFile对象,最后用该DexFile整体替换旧的dexElements就可以了。

注意Sophix的这种重启修复方案,其实网上介绍的不多,可能大家觉得都写到书里不用介绍了。

Tinker(类加载)

Tinker通过计算对比指定的Base Apk中的dex与修改后的Apk的区别,补丁包中的内容即视为两者差分的描述。运行时将Base Apk中的补丁包进行合成,重启后加载全新的合成后的dex文件。

image

也就是说,基于类加载的类替换方案,都是重启之后才能生效的,QZone的超级补丁方案也是重启生效。下面是Tinker和QZone的对比,Tinker生成这个差分包,就是说Tinker的补丁包会更小,而QZone是把出现bug类修复后直接打包到dex文件中,所以补丁包更大。

image
Robust(插桩,类似Instant Run)

Robust是对每个函数都在编译打包阶段自动插入了一段代码。类似于代理,将执行的方法重定向到其他方法中(Instant Run)。

image

所以说如果每个类中都插入了该代码,那么整个apk的包体积肯定会有一定程度的增加,但是该方案的修复成功率是所有方案中最高的。至于字节码插桩技术,美团分别使用了ASM和Javaassist两个框架实现了字节码的操作。

最简单的热修复方案

我们的实现方案是基于类加载方案热修复原理,热修复后,我们的apk中可能多有多个dex,假设一个app中有两个dex(patch.dex和class.dex,class2.dex),如果说其中两个dex中都有一个Key.class类,那么当前面一个dex加载后,后面一个dex中的Key.class(有bug的类)就不会加载,程序会认为你的Test.class已经加载完毕(因为之前已经加载过了)。这也就是类加载方案热修复的原理。

image

对于使用类加载方案的热修复框架来说,其实很大程度上和插件化框架挺像,起码在类加载这方面的代码很像。如果你看过我之前的插件化文章,就可以自己实现一个简单的热修复了。

手把手带你实现最简单的插件化[一]

手把手带你实现最简单的插件化[二](Activity)

手把手带你实现最简单的插件化[三](资源加载)

我们把第一篇文章中合并dex的代码直接拿过来,然后在合并dex的时候把插件(也就是修复的Dex)放到新生成的Element数组的最前面,然后就可以了(就是这么简单),插件化是放在Element数组中的后面,不过我们还是要复现一下整个流程:

首先在代码中造一个bug,导致程序一启动就崩溃

public class HotFixTestActivity extends AppCompatActivity {
    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_hotfix);
        Utils.test();
    }
}

//Utils.java
public class Utils {
    private static final String TAG = "Utils";
    public static void test(){
//        Log.i(TAG, "test: ========替换类,修复bug");
        throw new IllegalStateException("出错了,抛出异常");
    }
}
复制代码

然后我们将bug修复后,代码如下所示,你没有看错,就是把异常这行删掉,然后开启打印,用于标记是新的修复类。

public class Utils {
    private static final String TAG = "Utils";

    public static void test(){
        Log.i(TAG, "test: ========替换类,修复bug");
    }
}
复制代码

然后我们进行打包,打包成patch.jar包,其实.jar/.dex/.apk并没什么区别,都可以。

dx --dex --output=patch.jar com/jackie/plugingodemo/hotfix/Utils.class
复制代码

然后在push到我们的虚拟机的sd卡中

adb push patch.jar /sdcard/
复制代码

注意需要添加sd卡的权限,同时我们的代码中并没有动态的申请权限,所以需要手动的开启app权限,然后开始添加我们的热修复代码。

//直接把插件化的那一套代码copy过来,插件化是宿主的dex在前,插件dex在后,
//热修复是修复的patch.jar在前,宿主的在后,因为是用的类替换,如果前面的已经加载过该类了,后面有相同的类(bug类)  就不会加载
    //.jar,.dex,.apk都行
    public static void mergePatchTwo(Context context){

        //合并dexElements

        try{
            //获取BaseDexClassLoader中的pathList(DexPathList)
            Class<?> clazz = Class.forName("dalvik.system.BaseDexClassLoader");
            Field pathListField = clazz.getDeclaredField("pathList");
            pathListField.setAccessible(true);

            //获取DexPathList中的dexElements数组
            Class<?> dexPathListClass = Class.forName("dalvik.system.DexPathList");
            Field dexElementsField = dexPathListClass.getDeclaredField("dexElements");
            dexElementsField.setAccessible(true);

            //宿主的类加载器
            ClassLoader pathClassLoader = context.getClassLoader();
            //DexPathList类的对象
            Object hostPathList = pathListField.get(pathClassLoader);
            //宿主的dexElements
            Object[] hostDexElements = (Object[]) dexElementsField.get(hostPathList);

            //String apkPath = "/sdcard/output.dex";
            String apkPath = "/sdcard/patch.jar";
            //插件的类加载器
            ClassLoader dexClassLoader = new DexClassLoader(apkPath
                    ,context.getCacheDir().getAbsolutePath()
                    ,null
                    ,pathClassLoader);

            //DexPathList类的对象
            Object pluginPathList = pathListField.get(dexClassLoader);
            //插件的dexElements
            Object[] pluginElements = (Object[]) dexElementsField.get(pluginPathList);

            //创建一个新数组
            Object[] newDexElements = (Object[]) Array.newInstance(hostDexElements.getClass().getComponentType()
                    ,hostDexElements.length + pluginElements.length);
//            System.arraycopy(hostDexElements,0,newDexElements,0,hostDexElements.length);
//            System.arraycopy(pluginElements,0,newDexElements,hostDexElements.length,pluginElements.length);

            System.arraycopy(pluginElements,0,newDexElements,0,pluginElements.length);
            System.arraycopy(hostDexElements,0,newDexElements,hostDexElements.length,pluginElements.length);

            //赋值
            dexElementsField.set(hostPathList,newDexElements);

        }catch (Exception e){
            e.printStackTrace();
        }
复制代码

如果你看过之前的插件化代码,对比一下,其实我们仅仅是修改了dex的位置,该代码也能实现热修复。不过我们这个时候在创建新的dexElements数组的时候用的是新创建的DexClassLoader,然后一些列操作去生成pluginElements(Element[])数组,而不是使用系统的PathClassLoader以及一些列操作反射调用makePathElements去生成pluginElements(Element[])数组。有可能造成一个问题:

because in dalvik, if inner class is not the same classloader with it wrapper class. it won't fail at dex2opt
复制代码

我们来看一下DexPathList这个类中的makePathElements方法,该方法最终会生成一个Element[]数组。

436    /*
437     * TODO (dimitry): Revert after apps stops relying on the existence of this
438     * method (see http://b/21957414 and http://b/26317852 for details)
439     */
440    @SuppressWarnings("unused")
441    private static Element[] makePathElements(List<File> files, File optimizedDirectory,
442            List<IOException> suppressedExceptions) {
443        return makeDexElements(files, optimizedDirectory, suppressedExceptions, null);
444    }
复制代码

所以接下来的方案我们反射调用makePathElements方法去生成并最终合并到数组中,代码如下

    //热修复方案实现,使用系统的PathClassLoader,使用makePathElements去生成数组
    public static void mergePatchOne(Context context){

        try{

            //获取BaseDexClassLoader中的pathList(DexPathList)
            Class<?> clazz = Class.forName("dalvik.system.BaseDexClassLoader");
            Field pathListField = clazz.getDeclaredField("pathList");
            pathListField.setAccessible(true);

            //获取DexPathList中的dexElements数组
            Class<?> dexPathListClass = Class.forName("dalvik.system.DexPathList");
            Field dexElementsField = dexPathListClass.getDeclaredField("dexElements");
            dexElementsField.setAccessible(true);

            //获取宿主的classLoader,然后获取pathList(DexPathList),然后获取hostDexElements数组
            ClassLoader pathClassLoader = context.getClassLoader();
            Object hostPathList = pathListField.get(pathClassLoader);
            //宿主的数组
            Object[] hostDexElements = (Object[]) dexElementsField.get(hostPathList);

            String apkPath = "/sdcard/patch.jar";

            //注意下面注释的这段代码是找不到该方法的,虽然hostPathList是PathClassLoader的变量,但该hostPathList(DexPathList)
            //属性是继承自父类BaseDexClassLoader的属性,所以需要遍历父类的属性
//            Class<?> dexPathList = Class.forName("dalvik.system.DexPathList");
//            Log.i(TAG, "mergePatchOne: "+hostPathList.getClass()+"      "+dexPathList.getName() );
//            Method method = hostPathList.getClass().getMethod("makePathElements",List.class,File.class,List.class);
//            method.setAccessible(true);

            Method method = findMethod(hostPathList,"makePathElements",List.class,File.class,List.class);

            //makePathElements方法三个参数的配置
            //1.
            List<File> fileList = new ArrayList<>();
            fileList.add(new File(apkPath));
            //2.
            File optimizedDirectory = context.getCacheDir();
            //3.
            List<IOException> ioExceptions = new ArrayList<>();

            Object[] pluginElements = (Object[]) method.invoke(null,fileList,optimizedDirectory,ioExceptions);

            //创建一个新数组
            Object[] newDexElements = (Object[]) Array.newInstance(hostDexElements.getClass().getComponentType()
                    ,hostDexElements.length + pluginElements.length);

            System.arraycopy(pluginElements,0,newDexElements,0,pluginElements.length);
            System.arraycopy(hostDexElements,0,newDexElements,hostDexElements.length,pluginElements.length);

            //赋值
            dexElementsField.set(hostPathList,newDexElements);

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

    }

    /**
     * 从 instance 到其父类 找  name 方法
     *
     * @param instance
     * @param name
     * @return
     * @throws NoSuchFieldException
     */
    public static Method findMethod(Object instance, String name, Class<?>... parameterTypes)
            throws NoSuchMethodException {
        for (Class<?> clazz = instance.getClass(); clazz != null; clazz = clazz.getSuperclass()) {
            try {
                Method method = clazz.getDeclaredMethod(name, parameterTypes);

                if (!method.isAccessible()) {
                    method.setAccessible(true);
                }

                return method;
            } catch (NoSuchMethodException e) {
                // ignore and search next
            }
        }
        throw new NoSuchMethodException("Method "
                + name
                + " with parameters "
                + Arrays.asList(parameterTypes)
                + " not found in " + instance.getClass());
    }
复制代码

最终我们在我们的Application中调用该代码,可以看到如下打印,修复成功。

2020-12-03 17:37:25.551 20375-20375/com.jackie.plugingodemo I/Utils: test: ========替换类,修复bug
复制代码

由于Android版本的复杂性,各大热修复框架都有自己实现方案和兼容方案,下面我们来分析一下典型的场景下的解决方案,分别是Android N及其以上混合编译的影响(Tinker的解决方案)和Dalvik虚拟机的适配(QZone解决方案)

Tinker兼容方案分析

接下来我们来看一下这个兼容问题,我们先来看一下Tinker的兼容方案,可以看到在不同的版本,分别调用了不同的方法进行适配。

public static void installDexes(Application application, ClassLoader loader, File dexOptDir, List<File> files,
                                    boolean isProtectedApp, boolean useDLC) throws Throwable {
        ShareTinkerLog.i(TAG, "installDexes dexOptDir: " + dexOptDir.getAbsolutePath() + ", dex size:" + files.size());

        if (!files.isEmpty()) {
            files = createSortedAdditionalPathEntries(files);
            ClassLoader classLoader = loader;
            if (Build.VERSION.SDK_INT >= 24 && !isProtectedApp) {
                classLoader = NewClassLoaderInjector.inject(application, loader, dexOptDir, useDLC, files);
            } else {
                //because in dalvik, if inner class is not the same classloader with it wrapper class.
                //it won't fail at dex2opt
                if (Build.VERSION.SDK_INT >= 23) {
                    V23.install(classLoader, files, dexOptDir);
                } else if (Build.VERSION.SDK_INT >= 19) {
                    V19.install(classLoader, files, dexOptDir);
                } else if (Build.VERSION.SDK_INT >= 14) {
                    V14.install(classLoader, files, dexOptDir);
                } else {
                    V4.install(classLoader, files, dexOptDir);
                }
            }
            //install done
            sPatchDexCount = files.size();
            ShareTinkerLog.i(TAG, "after loaded classloader: " + classLoader + ", dex size:" + sPatchDexCount);

            if (!checkDexInstall(classLoader)) {
                //reset patch dex
                SystemClassLoaderAdder.uninstallPatchDex(classLoader);
                throw new TinkerRuntimeException(ShareConstants.CHECK_DEX_INSTALL_FAIL);
            }
        }
复制代码
Android N及以上(Version >=24)

先来说一下Android N系统的一些变化,Android N 引入了一种包含编译、解释和 JIT(Just In Time)的混合运行时,以便在安装时间、内存占用、电池消耗和性能之间获得最好的折衷。

ART 是在 Android KitKat(译者注:Android 4.0)引入并在 Lollipop(译者注:Android 5.0)中设为默认解决方案的主要特性之一,是当时的一种新的运行时。ART 取代了 Dalvik,但是前者与后者仍然保持了字节码级的兼容,因为前者仍在运行 DEX 文件。ART 的主要特征之一就是安装时对应用的 AOT 编译。这种方式的主要优点就是优化产生的本地代码性能更好,执行起来需要更少的电量。劣势在于安装文件所需的空间和时间。在 Lollipop 和 Marshmallow(译者注:Android 6.0)中,大的应用需要数分钟才能安装完。

Android N 开发者预览版包含了一个混合模式的运行时。应用在安装时不做编译,而是解释字节码,所以可以快速启动。ART 中有一种新的、更快的解释器,通过一种新的 JIT 完成,但是这种 JIT 的信息不是持久化的。取而代之的是,代码在执行期间被分析,分析结果保存起来。然后,当设备空转和充电的时候,ART 会执行针对“热代码”进行的基于分析的编译,其他代码不做编译。为了得到更优的代码,ART 采用了几种技巧包括深度内联。

对同一个应用可以编译数次,或者找到变“热”的代码路径或者对已经编译的代码进行新的优化,这取决于分析器在随后的执行中的分析数据。这个步骤仍被简称为 AOT,可以理解为“全时段的编译”(All-Of-the-Time compilation)。

这种混合使用 AOT、解释、JIT 的策略的全部优点如下。

  • 即使是大应用,安装时间也能缩短到几秒
  • 系统升级能更快地安装,因为不再需要优化这一步
  • 应用的内存占用更小,有些情况下可以降低 50%
  • 改善了性能
  • 更低的电池消耗

再来看一下该变化可能导致什么问题,简单总结就是上面说的"热代码"被优化后,会有一个叫app image的文件来记录这些"热代码",并且在启动时一次性把它们加载,预先加载代替用时查找以提升引用的性能,如果我们的有bug的代码是"热代码",那么即使我们用上面的dex合并的方式修复后,该bug代码也不会被修复,因为它已经被缓存了,启动时是加载的缓存而不是我们的新代码

解决方案:运行时替换PathClassLoader

App image中的class是插入到PathClassloader中的ClassTable中。我们可以废弃掉PathClassloader,采用一个新建的Classloader来加载后续的所有类,达到cache无用化的效果。

需要注意的问题是我们的Application类是一定会通过PathClassloader加载的,所以我们需要将Application类与我们的逻辑解耦,这里方式有两种:

  1. 采用类似instant run的实现;在代理application中,反射替换真正的application。这种方式的优点在于接入容易,但是这种方式无法保证兼容性,特别在反射失败的情况,是无法回退的。
  2. 采用代理Application实现的方法;即Application的所有实现都会被代理到其他类,Application类不会再被使用到。这种方式没有兼容性的问题,但是会带来一定的接入成本。

Tinker采用的是第二种,这种方式不会影响没有补丁时的性能,而且反射的兼容问题也需要考虑。但是废弃了App image会带来一定的性能损耗。

以上关于Tinker的Android N解决方案分析来源自这篇文章。解决代码在这里,截取出其中一小段可以看到classloader的替换

private static void doInject(Application app, ClassLoader classLoader) throws Throwable {
        Thread.currentThread().setContextClassLoader(classLoader);

        final Context baseContext = (Context) findField(app.getClass(), "mBase").get(app);
        try {
            findField(baseContext.getClass(), "mClassLoader").set(baseContext, classLoader);
        } catch (Throwable ignored) {
            // There's no mClassLoader field in ContextImpl before Android O.
            // However we should try our best to replace this field in case some
            // customized system has one.
        }

        final Object basePackageInfo = findField(baseContext.getClass(), "mPackageInfo").get(baseContext);
        findField(basePackageInfo.getClass(), "mClassLoader").set(basePackageInfo, classLoader);

        if (Build.VERSION.SDK_INT < 27) {
            final Resources res = app.getResources();
            try {
                findField(res.getClass(), "mClassLoader").set(res, classLoader);

                final Object drawableInflater = findField(res.getClass(), "mDrawableInflater").get(res);
                if (drawableInflater != null) {
                    findField(drawableInflater.getClass(), "mClassLoader").set(drawableInflater, classLoader);
                }
            } catch (Throwable ignored) {
                // Ignored.
            }
        }
    }
复制代码
Android(24 > Version >= 23)

兼容方式如下,其实就是系统的一些Api的变化,反射中的一些内容的改变。

private static void install(ClassLoader loader, List<File> additionalClassPathEntries,
                                    File optimizedDirectory)
            throws IllegalArgumentException, IllegalAccessException,
            NoSuchFieldException, InvocationTargetException, NoSuchMethodException, IOException {
            /* The patched class loader is expected to be a descendant of
             * dalvik.system.BaseDexClassLoader. We modify its
             * dalvik.system.DexPathList pathList field to append additional DEX
             * file entries.
             */
            Field pathListField = ShareReflectUtil.findField(loader, "pathList");
            Object dexPathList = pathListField.get(loader);
            ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
            ShareReflectUtil.expandFieldArray(dexPathList, "dexElements", makePathElements(dexPathList,
                new ArrayList<File>(additionalClassPathEntries), optimizedDirectory,
                suppressedExceptions));
            if (suppressedExceptions.size() > 0) {
                for (IOException e : suppressedExceptions) {
                    ShareTinkerLog.w(TAG, "Exception in makePathElement", e);
                    throw e;
                }

            }
        }

        /**
         * A wrapper around
         * {@code private static final dalvik.system.DexPathList#makePathElements}.
         */
        private static Object[] makePathElements(
            Object dexPathList, ArrayList<File> files, File optimizedDirectory,
            ArrayList<IOException> suppressedExceptions)
            throws IllegalAccessException, InvocationTargetException, NoSuchMethodException {

            Method makePathElements;
            try {
                makePathElements = ShareReflectUtil.findMethod(dexPathList, "makePathElements", List.class, File.class,
                    List.class);
            } catch (NoSuchMethodException e) {
                ShareTinkerLog.e(TAG, "NoSuchMethodException: makePathElements(List,File,List) failure");
                try {
                    makePathElements = ShareReflectUtil.findMethod(dexPathList, "makePathElements", ArrayList.class, File.class, ArrayList.class);
                } catch (NoSuchMethodException e1) {
                    ShareTinkerLog.e(TAG, "NoSuchMethodException: makeDexElements(ArrayList,File,ArrayList) failure");
                    try {
                        ShareTinkerLog.e(TAG, "NoSuchMethodException: try use v19 instead");
                        return V19.makeDexElements(dexPathList, files, optimizedDirectory, suppressedExceptions);
                    } catch (NoSuchMethodException e2) {
                        ShareTinkerLog.e(TAG, "NoSuchMethodException: makeDexElements(List,File,List) failure");
                        throw e2;
                    }
                }
            }

            return (Object[]) makePathElements.invoke(dexPathList, files, optimizedDirectory, suppressedExceptions);
        }
复制代码
Android(23 > version >= 19)

兼容方式如下,其实就是系统的一些Api的变化,反射中的一些内容的改变。

private static void install(ClassLoader loader, List<File> additionalClassPathEntries,
                                    File optimizedDirectory)
            throws IllegalArgumentException, IllegalAccessException,
            NoSuchFieldException, InvocationTargetException, NoSuchMethodException, IOException {
            /* The patched class loader is expected to be a descendant of
             * dalvik.system.BaseDexClassLoader. We modify its
             * dalvik.system.DexPathList pathList field to append additional DEX
             * file entries.
             */
            Field pathListField = ShareReflectUtil.findField(loader, "pathList");
            Object dexPathList = pathListField.get(loader);
            ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
            ShareReflectUtil.expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList,
                new ArrayList<File>(additionalClassPathEntries), optimizedDirectory,
                suppressedExceptions));
            if (suppressedExceptions.size() > 0) {
                for (IOException e : suppressedExceptions) {
                    ShareTinkerLog.w(TAG, "Exception in makeDexElement", e);
                    throw e;
                }
            }
        }

        /**
         * A wrapper around
         * {@code private static final dalvik.system.DexPathList#makeDexElements}.
         */
        private static Object[] makeDexElements(
            Object dexPathList, ArrayList<File> files, File optimizedDirectory,
            ArrayList<IOException> suppressedExceptions)
            throws IllegalAccessException, InvocationTargetException, NoSuchMethodException {

            Method makeDexElements = null;
            try {
                makeDexElements = ShareReflectUtil.findMethod(dexPathList, "makeDexElements", ArrayList.class, File.class,
                    ArrayList.class);
            } catch (NoSuchMethodException e) {
                ShareTinkerLog.e(TAG, "NoSuchMethodException: makeDexElements(ArrayList,File,ArrayList) failure");
                try {
                    makeDexElements = ShareReflectUtil.findMethod(dexPathList, "makeDexElements", List.class, File.class, List.class);
                } catch (NoSuchMethodException e1) {
                    ShareTinkerLog.e(TAG, "NoSuchMethodException: makeDexElements(List,File,List) failure");
                    throw e1;
                }
            }

            return (Object[]) makeDexElements.invoke(dexPathList, files, optimizedDirectory, suppressedExceptions);
        }
复制代码
Android(19 > version >= 14)

在Android 4.4(19)及其以下,也就是Dalvik虚拟机,因为Tinker采用的是差分包和出现bug的dex包重新合成,采用的是全量合成Dex的解决方案,也就是只要做版本的兼容就可以了(后面我们会单独介绍QZone实现方案下Dalvik的解决方案)。兼容方式如下,其实就是系统的一些Api的变化,反射中的一些内容的改变。

/**
     * Installer for platform versions 14, 15, 16, 17 and 18.
     */
    private static final class V14 {

        private static void install(ClassLoader loader, List<File> additionalClassPathEntries,
                                    File optimizedDirectory)
            throws IllegalArgumentException, IllegalAccessException,
            NoSuchFieldException, InvocationTargetException, NoSuchMethodException {
            /* The patched class loader is expected to be a descendant of
             * dalvik.system.BaseDexClassLoader. We modify its
             * dalvik.system.DexPathList pathList field to append additional DEX
             * file entries.
             */
            Field pathListField = ShareReflectUtil.findField(loader, "pathList");
            Object dexPathList = pathListField.get(loader);
            ShareReflectUtil.expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList,
                new ArrayList<File>(additionalClassPathEntries), optimizedDirectory));
        }

        /**
         * A wrapper around
         * {@code private static final dalvik.system.DexPathList#makeDexElements}.
         */
        private static Object[] makeDexElements(
            Object dexPathList, ArrayList<File> files, File optimizedDirectory)
            throws IllegalAccessException, InvocationTargetException,
            NoSuchMethodException {
            Method makeDexElements =
                ShareReflectUtil.findMethod(dexPathList, "makeDexElements", ArrayList.class, File.class);

            return (Object[]) makeDexElements.invoke(dexPathList, files, optimizedDirectory);
        }
    }
复制代码
Android(version < 14)
private static void install(ClassLoader loader, List<File> additionalClassPathEntries, File optimizedDirectory)
            throws IllegalArgumentException, IllegalAccessException,
            NoSuchFieldException, IOException {
            /* The patched class loader is expected to be a descendant of
             * dalvik.system.DexClassLoader. We modify its
             * fields mPaths, mFiles, mZips and mDexs to append additional DEX
             * file entries.
             */
            int extraSize = additionalClassPathEntries.size();

            Field pathField = ShareReflectUtil.findField(loader, "path");

            StringBuilder path = new StringBuilder((String) pathField.get(loader));
            String[] extraPaths = new String[extraSize];
            File[] extraFiles = new File[extraSize];
            ZipFile[] extraZips = new ZipFile[extraSize];
            DexFile[] extraDexs = new DexFile[extraSize];
            for (ListIterator<File> iterator = additionalClassPathEntries.listIterator();
                 iterator.hasNext();) {
                File additionalEntry = iterator.next();
                String entryPath = additionalEntry.getAbsolutePath();
                path.append(':').append(entryPath);
                int index = iterator.previousIndex();
                extraPaths[index] = entryPath;
                extraFiles[index] = additionalEntry;
                extraZips[index] = new ZipFile(additionalEntry);
                //edit by zhangshaowen
                String outputPathName = SharePatchFileUtil.optimizedPathFor(additionalEntry, optimizedDirectory);
                //for below 4.0, we must input jar or zip
                extraDexs[index] = DexFile.loadDex(entryPath, outputPathName, 0);
            }

            pathField.set(loader, path.toString());
            ShareReflectUtil.expandFieldArray(loader, "mPaths", extraPaths);
            ShareReflectUtil.expandFieldArray(loader, "mFiles", extraFiles);
            ShareReflectUtil.expandFieldArray(loader, "mZips", extraZips);
            try {
                ShareReflectUtil.expandFieldArray(loader, "mDexs", extraDexs);
            } catch (Exception e) {
                // Ignored.
            }
        }
    }
复制代码

QZone的超级补丁热修复框架对Dalvik虚拟机的适配

在Android4.4版本,我们的的虚拟机其实还是Dalvik,在Android 5.0及后续Android版本中作为正式的运行时库取代了以往的Dalvik虚拟机,下面我们以4.4虚拟机为例,我们来复现出Dalvik虚拟机可能遇到的问题,同时给出解决代码解决方案。

因为4.4版本的Android Api和我们之前在Android 9.0的Api有差异,所以我们需要先做一下该版本的适配,当然流程还是没变,依旧是patch.jar合成到Elements数组中

//1、获取程序的PathClassLoader对象
        ClassLoader classLoader = application.getClassLoader();
        //Android N兼容,参考Tinker
//        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
//            try {
//                ClassLoaderInjector.inject(application, classLoader, patchs);
//            } catch (Throwable throwable) {
//            }
//            return;
//        }
        //2、反射获得PathClassLoader父类BaseDexClassLoader的pathList对象
        try {
            Field pathListField = ShareReflectUtil.findField(classLoader, "pathList");
            Object pathList = pathListField.get(classLoader);
            //3、反射获取pathList的dexElements对象 (oldElement)
            Field dexElementsField = ShareReflectUtil.findField(pathList, "dexElements");
            Object[] oldElements = (Object[]) dexElementsField.get(pathList);
            //4、把补丁包变成Element数组:patchElement(反射执行makePathElements)
            Object[] patchElements = null;
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                Method makePathElements = ShareReflectUtil.findMethod(pathList, "makePathElements",
                        List.class, File.class,
                        List.class);
                ArrayList<IOException> ioExceptions = new ArrayList<>();
                patchElements = (Object[])
                        makePathElements.invoke(pathList, patchs, application.getCacheDir(), ioExceptions);
                        //兼容4.4的虚拟机,因为Framework api有变化
            } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
                Method makePathElements = ShareReflectUtil.findMethod(pathList, "makeDexElements",
                        ArrayList.class, File.class, ArrayList.class);
                ArrayList<IOException> ioExceptions = new ArrayList<>();
                patchElements = (Object[])
                        makePathElements.invoke(pathList, patchs, application.getCacheDir(), ioExceptions);
            }

            //5、合并patchElement+oldElement = newElement (Array.newInstance)
            //创建一个新数组,大小 oldElements+patchElements
//                int[].class.getComponentType() ==int.class
            Object[] newElements = (Object[]) Array.newInstance(oldElements.getClass().getComponentType(),
                    oldElements.length + patchElements.length);

            System.arraycopy(patchElements, 0, newElements, 0, patchElements.length);
            System.arraycopy(oldElements, 0, newElements, patchElements.length, oldElements.length);
            //6、反射把oldElement赋值成newElement
            dexElementsField.set(pathList, newElements);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
复制代码

做完兼容后运行App,虽然Dex是已经合成到Element数组中了,但是系统会抛出如下异常:

12-04 17:17:40.969 6981-6981/com.jackie.plugingodemo E/AndroidRuntime: FATAL EXCEPTION: main
    Process: com.jackie.plugingodemo, PID: 6981
    java.lang.IllegalAccessError: Class ref in pre-verified class resolved to unexpected implementation
        at com.jackie.plugingodemo.HotFixTestActivity.onCreate(HotFixTestActivity.java:24)
        at android.app.Activity.performCreate(Activity.java:5231)
        at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1087)
        at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2159)
        at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:2245)
        at android.app.ActivityThread.access$800(ActivityThread.java:135)
        at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1196)
        at android.os.Handler.dispatchMessage(Handler.java:102)
        at android.os.Looper.loop(Looper.java:136)
        at android.app.ActivityThread.main(ActivityThread.java:5017)
        at java.lang.reflect.Method.invokeNative(Native Method)
        at java.lang.reflect.Method.invoke(Method.java:515)
        at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:779)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:595)
        at dalvik.system.NativeStart.main(Native Method)
复制代码

这个时候,我们的HotFixTestActivity和修复的Utils不在同一个dex文件中。为什么会报这个异常呢,原因是在类校验的时候,其中的一个调用流程:

1\. 验证clazz->directMethods方法,directMethods包含了以下方法:

    1\. static方法

    2\. private方法

    3\. 构造函数

2\. clazz->virtualMethods

    1\. 虚函数=override方法?
复制代码

如果以上方法中直接引用到的类(第一层级关系,不会进行递归搜索)和clazz都在同一个dex中的话,那么这个类就会被打上CLASS_ISPREVERIFIED的标志,如果已经打上该标志的类去引用不同dex的class文件就会抛出该异常,这也是dalvik虚拟机做的一个优化,也就是说打上该标记就以为这这个类所引用的类都在同一个dex中了,不用跨dex查找。

所以为了实现补丁方案,所以必须从这些方法中入手,也就是说在这些方法中去引用不同dex中的类,防止类被打上CLASS_ISPREVERIFIED标志。注意要解决的话,必须正向调用(有引用),有import *,不能用反射。才不会有这个标记。所以QQ空间的解决方案是在所有类的构造函数中插入了一段代码。为什么要有这个一个判断呢,其实就是一个boolean值,用来控制是否插入而已。

if (ClassVerifier.PREVENT_VERIFY) {
        System.out.println(AntilazyLoad.class);
}
复制代码

这个AntilazyLoad类会被打包成单独的hack.dex文件,这样当安装apk的时候,classes.dex内的类都会引用在一个不相同的dex的AntilazyLoad类,这样就防止了类被打上CLASS_ISPREVERIFIED标志,只要没被打上这个标志的类都可以进行补丁操作。

然后在应用启动的时候加载进来。AntilazyLoad类所在的dex包必须被先加载进来,不然AntilazyLoad类会被标记为不存在,即使后续加载了hack.dex包,那么它也是不存在的,这样就会出现很多类类AntilazyLoad找不到的log。

所以Application作为应用的入口不能插入这段代码。(因为载入hack.dex的代码是在Application中onCreate中执行的,如果在Application的构造函数里面插入了这段代码,那么就是在hack.dex加载之前就使用该类,该类一次找不到,会被永远的打上找不到的标志),以上内容QZone热修复相关内容引用自这里

模拟QZone实现方案

下面我们来模拟QZone在Android 4.4 上的热修复实现,我们新建一个pluginlib(library),项目结构如下

image

然后创建AntilazyLoad类

public class AntilazyLoad {
}
复制代码

然后找到build目录下生成的AntilazyLoad.class文件,打包成一个hack.dex,然后直接放到主项目的asset目录下面(读取方便)。

接下来我们利用字节码插桩技术去给每一个类的无参构造器中打入如下代码,不懂字节码插桩的可以看看我的这篇文章

Class var10000 = AntilazyLoad.class;
复制代码

这里我们使用的是ASM进行插桩,需要添加如下依赖

implementation 'org.ow2.asm:asm:5.0.4'
implementation 'org.ow2.asm:asm-util:5.0.1'
implementation 'org.ow2.asm:asm-commons:5.0.1'
复制代码

我们在app/build.gradle中插入如下代码,最终会给指定类中插入我们的代码,下面的代码整体流程就是在解析完build.gralde文件后,在具体的打包流程中对.class文件进行处理,也就是在对应的Task(Transform)中可以拿到.class文件,然后用字节码插桩插入代码,可以仔细看看代码中的注释。

//gradle执行会解析build.gradle文件,afterEvaluate表示在解析完成之后再执行我们的代码
afterEvaluate({
    android.getApplicationVariants().all {
        variant ->
            //获得: debug/release
            String variantName = variant.name
            //首字母大写 Debug/Release
            String capitalizeName = variantName.capitalize()

            //这就是打包时,把jar和class打包成dex的任务
            Task dexTask =
                    project.getTasks().findByName("transformClassesWithDexBuilderFor" + capitalizeName);

            //在他打包之前执行插桩
            dexTask.doFirst {

                //任务的输入,dex打包任务要输入什么? 自然是所有的class与jar包了!
                FileCollection files = dexTask.getInputs().getFiles()
                println("============doFirst=========1============")
                for (File file : files) {
                    //.jar ->解压-》插桩->压缩回去替换掉插桩前的class
                    // .class -> 插桩
                    String filePath = file.getAbsolutePath();
                    //依赖的库会以jar包形式传过来,对依赖库也执行插桩
                    if (filePath.endsWith(".jar")) {
                        println("============doFirst=========2============")
                        processJar(file);

                    } else if (filePath.endsWith(".class")) {
                        //主要是我们自己写的app模块中的代码
                        processClass(variant.getDirName(), file);
                    }
                }
            }
    }
})

static boolean isAndroidClass(String filePath) {
    return filePath.startsWith("android") ||
            filePath.startsWith("androidx");
}

static byte[] referHackWhenInit(InputStream inputStream) throws IOException {
    // class的解析器
    ClassReader cr = new ClassReader(inputStream)
    // class的输出器
    ClassWriter cw = new ClassWriter(cr, 0)
    // class访问者,相当于回调,解析器解析的结果,回调给访问者
    ClassVisitor cv = new ClassVisitor(Opcodes.ASM5, cw) {

        //要在构造方法里插桩 init
        @Override
        public MethodVisitor visitMethod(int access, final String name, String desc,
                                         String signature, String[] exceptions) {

            MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
            mv = new MethodVisitor(Opcodes.ASM5, mv) {
                @Override
                void visitInsn(int opcode) {

                    //在构造方法中插入AntilazyLoad引用
                    if ("<init>".equals(name) && opcode == Opcodes.RETURN) {
                        println("============visitInsn=========referHackWhenInit============")
                        //引用类型
                        //基本数据类型 : I J Z
                        super.visitLdcInsn(Type.getType("Lcom/jackie/pluginlib/AntilazyLoad;"));
                    }
                    super.visitInsn(opcode);
                }
            };
            return mv;
        }

    };
    //启动分析
    cr.accept(cv, 0);
    return cw.toByteArray();
}

/**
 * linux/mac: /xxxxx/app/build/intermediates/classes/debug/com/jackie/plugingodemo/MainActivity.class
 * windows: \xxxxx\app\build\intermediates\classes\debug\com\jackie\plugingodemo\MainActivity.class
 * @param file
 * @param hexs
 */
static void processClass(String dirName, File file) {

    String filePath = file.getAbsolutePath();
    //注意这里的filePath包含了目录+包名+类名,所以去掉目录
    String className = filePath.split(dirName)[1].substring(1);
    //application或者android support我们不管
    if (className.startsWith("com/jackie/plugingodemo/MyApplication") || isAndroidClass(className)) {
        return
    }

    try {
        println("============processClass=====================")
        // byte[]->class 修改byte[]
        FileInputStream is = new FileInputStream(filePath);
        //执行插桩  byteCode:插桩之后的class数据,把他替换掉插桩前的class文件
        byte[] byteCode = referHackWhenInit(is);
        is.close();

        FileOutputStream os = new FileOutputStream(filePath)
        os.write(byteCode)
        os.close()
    } catch (Exception e) {
        e.printStackTrace();
    }
}

static void processJar(File file) {
    try {
        //  无论是windows还是linux jar包都是 /
        File bakJar = new File(file.getParent(), file.getName() + ".bak");
        JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(bakJar));

        JarFile jarFile = new JarFile(file);
        Enumeration<JarEntry> entries = jarFile.entries();
        while (entries.hasMoreElements()) {
            JarEntry jarEntry = entries.nextElement();

            // 读jar包中的一个文件 :class
            jarOutputStream.putNextEntry(new JarEntry(jarEntry.getName()));
            InputStream is = jarFile.getInputStream(jarEntry);

            String className = jarEntry.getName();
            if (className.endsWith(".class") && !className.startsWith
                    ("com/jackie/plugingodemo/MyApplication")
                    && !isAndroidClass(className) && !className.startsWith("com/jackie" +
                    "/pluginlib")) {
                byte[] byteCode = referHackWhenInit(is);
                jarOutputStream.write(byteCode);
            } else {
                //输出到临时文件
                jarOutputStream.write(IOUtils.toByteArray(is));
            }
            jarOutputStream.closeEntry();
        }
        jarOutputStream.close();
        jarFile.close();
        file.delete();
        bakJar.renameTo(file);
    } catch (IOException e) {
        e.printStackTrace();
    }
}
复制代码

可以看到每个类的构造方法中都插入了该代码,除了MyApplication

public class MainActivity extends AppCompatActivity {
    private static final String TAG = "MainActivity";

    public MainActivity() {
        Class var10000 = AntilazyLoad.class;
    }

 public class HotFixUtils {
    private static final String TAG = "HotFixUtils";

    public HotFixUtils() {
        Class var10000 = AntilazyLoad.class;
    }   

   public class MyApplication extends Application {
    public MyApplication() {
    }
复制代码

然后下次我们再启动App的时候,就不会出现上面所报的异常了。这个还需要注意一下gradle的版本是

classpath 'com.android.tools.build:gradle:3.1.3'
复制代码

这个很重要,因为不同的gralde版本的task不同,所以需要进行不同版本的适配,适配也是一个很复杂的工作,市面上主流的热修复框架应该也已经适配了,同时它们运行在成千上万的App中,也保证足够稳定,这也是我们使用它们的原因。

总结

我们讲解了类加载方法,Native Hook,插桩方案,也讲解的Tinker在Android N上的解决方案,QZone的超级补丁对Dalvik虚拟机的适配方案,最后自己实现了一波。如果你一步步认真看下去,并且照着我的文章把方案实现了一遍,相信你对热修复的理解一定会更深一层,真的,看十遍不如自己亲自实现一遍。

你再回顾上面热修复需要解决的问题,几乎所有的问题都应该有答案了,除了开启混淆后的解决方案,下篇文章我们将会讲解这部分的内容,同时讲解一些更深入的内容,敬请期待,喜欢的点个赞吧~

相关文章

网友评论

    本文标题:06 项目架构-热修复-主流框架分析

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