Android热修复学习之旅——HotFix完全解析

在上一篇博客Android热修复学习之旅开篇——热修复概述中,简单介绍了各个热修复框架的原理,本篇博客我将详细分析QQ空间热修复方案。

Android dex分包原理介绍

QQ空间热修复方案基于Android dex分包基础之上,简单概述android dex分包的原理就是:就是把多个dex文件塞入到app的classloader之中,但是android dex拆包方案中的类是没有重复的,如果classes.dex和classes1.dex中有重复的类,当classes.dex和classes1.dex中都具有同一个类的时候,那么classloader会选择加载哪个类呢?这要从classloader的源码入手,加载类是通过classloader的loadClass方法实现的,所以我们看一下loadClass的源码:

/**
 * Loads the class with the specified name. Invoking this method is
 * equivalent to calling {@code loadClass(className, false)}.
 * <p>
 * <strong>Note:</strong> In the Android reference implementation, the
 * second parameter of {@link #loadClass(String, boolean)} is ignored
 * anyway.
 * </p>
 *
 * @return the {@code Class} object.
 * @param className
 *            the name of the class to look for.
 * @throws ClassNotFoundException
 *             if the class can not be found.
 */
public Class<?> loadClass(String className) throws ClassNotFoundException {
    return loadClass(className, false);
}
protected Class<?> loadClass(String className, boolean resolve) throws ClassNotFoundException {
    Class<?> clazz = findLoadedClass(className);

    if (clazz == null) {
        ClassNotFoundException suppressed = null;
        try {
            clazz = parent.loadClass(className, false);
        } catch (ClassNotFoundException e) {
            suppressed = e;
        }

        if (clazz == null) {
            try {
                clazz = findClass(className);
            } catch (ClassNotFoundException e) {
                e.addSuppressed(suppressed);
                throw e;
            }
        }
    }

    return clazz;
}

classloader是基于 双亲代理模型的,具体关于classloader的详细解析,可以查看我的这篇文章:Android插件化学习之路(二)之ClassLoader完全解析

简单来说就是ClassLoader用loadClass方法调用了findClass方法,点进去发现findClass是抽象方法,而这个方法的实现是在它的子类BaseDexClassLoader中,而BaseDexClassLoader重载了这个方法,得到BaseDexClassLoader,进入到BaseDexClassLoader类的findClass方法中

#BaseDexClassLoader
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
    Class clazz = pathList.findClass(name);

    if (clazz == null) {
        throw new ClassNotFoundException(name);
    }

    return clazz;
}

#DexPathList
public Class findClass(String name) {
    for (Element element : dexElements) {
        DexFile dex = element.dexFile;

        if (dex != null) {
            Class clazz = dex.loadClassBinaryName(name, definingContext);
            if (clazz != null) {
                return clazz;
            }
        }
    }

    return null;
}

#DexFile
public Class loadClassBinaryName(String name, ClassLoader loader) {
    return defineClass(name, loader, mCookie);
}
private native static Class defineClass(String name, ClassLoader loader, int cookie);

一个ClassLoader可以包含多个dex文件,每个dex文件是一个Element,多个dex文件排列成一个有序的数组dexElements,当找类的时候,会按顺序遍历dex文件,然后从当前遍历的dex文件中找类,如果找类则返回,如果找不到从下一个dex文件继续查找。

理论上,如果在不同的dex中有相同的类存在,那么会优先选择排在前面的dex文件的类

所以,QQ空间正是基于ClassLoader的这个原理,把有问题的类打包到一个dex(patch.dex)中去,然后把这个dex插入到Elements的最前面

关于如何进行dex分包后面再单独开一篇博客进行分析。

CLASS_ISPREVERIFIED的问题

采用dex分包方案会遇到的问题,也就是CLASS_ISPREVERIFIED的问题,简单来概括就是:

在虚拟机启动的时候,当verify选项被打开的时候,如果static方法、private方法、构造函数等,其中的直接引用(第一层关系)到的类都在同一个dex文件中,那么该类就会被打上CLASS_ISPREVERIFIED标志。

那么,我们要做的就是,阻止该类打上CLASS_ISPREVERIFIED的标志。

注意下,是阻止引用者的类,也就是说,假设你的app里面有个类叫做AClass,再其内部引用了BClass。发布过程中发现BClass有编写错误,那么想要发布一个新的BClass类,那么你就要阻止AClass这个类打上CLASS_ISPREVERIFIED的标志。

也就是说,你在生成apk之前,就需要阻止相关类打上CLASS_ISPREVERIFIED的标志了。如何阻止,简单来说,让AClass在构造方法中,去引用别的dex文件,比如:C.dex中的某个类即可。

所以总结下来,防止这个错误,只需要:

1、动态改变BaseDexClassLoader对象间接引用的dexElements;2、在app打包的时候,阻止相关类去打上CLASS_ISPREVERIFIED标志。

热修复框架HotFix解析

采用QQ空间的热修复方案而实现的开源热修复框架就是HotFix,说到了使用dex分包方案会遇到CLASS_ISPREVERIFIED问题,而解决方案就是在dx工具执行之前,将所有的class文件,进行修改,再其构造中添加System.out.println(dodola.hackdex.AntilazyLoad.class),然后继续打包的流程。注意:AntilazyLoad.class这个类是独立在hack.dex中。

dex分包方案实现需要关注以下问题:

1.如何解决CLASS_ISPREVERIFIED问题

2.如何将修复的.dex文件插入到dexElements的最前面

那么如何达到这个目的呢?在HotFix中采用的javassist来达到这个目的,以下是HotFix中的PatchClass.groovy代码

public class PatchClass {
    /**
     * 植入代码
     * @param buildDir 是项目的build class目录,就是我们需要注入的class所在地
     * @param lib 这个是hackdex的目录,就是AntilazyLoad类的class文件所在地
     */
    public static void process(String buildDir, String lib) {

        println(lib)
        ClassPool classes = ClassPool.getDefault()
        classes.appendClassPath(buildDir)
        classes.appendClassPath(lib)

        //下面的操作比较容易理解,在将需要关联的类的构造方法中插入引用代码
        CtClass c = classes.getCtClass("dodola.hotfix.BugClass")
        if (c.isFrozen()) {
            c.defrost()
        }
        println("====添加构造方法====")
        def constructor = c.getConstructors()[0];
        constructor.insertBefore("System.out.println(dodola.hackdex.AntilazyLoad.class);")
        c.writeFile(buildDir)

        CtClass c1 = classes.getCtClass("dodola.hotfix.LoadBugClass")
        if (c1.isFrozen()) {
            c1.defrost()
        }
        println("====添加构造方法====")
        def constructor1 = c1.getConstructors()[0];
        constructor1.insertBefore("System.out.println(dodola.hackdex.AntilazyLoad.class);")
        c1.writeFile(buildDir)

    }

    static void growl(String title, String message) {
        def proc = ["osascript", "-e", "display notification \"${message}\" with title \"${title}\""].execute()
        if (proc.waitFor() != 0) {
            println "[WARNING] ${proc.err.text.trim()}"
        }
    }
}

其实内部做的逻辑就是:通过ClassPool对象,然后添加classpath。然后从classpath中找到LoadBugClass,拿到其构造方法,在其中插入一行代码。

到这里插入代码的操作已经完成,但是还存在另外一个问题,那就是如何在dx之前去进行上述脚本的操作?

答案就在HotFix的app/build.gradle中

apply plugin: ‘com.android.application‘

task(‘processWithJavassist‘) << {
    String classPath = file(‘build/intermediates/classes/debug‘)//项目编译class所在目录
    dodola.patch.PatchClass.process(classPath, project(‘:hackdex‘).buildDir
            .absolutePath + ‘/intermediates/classes/debug‘)//第二个参数是hackdex的class所在目录

}
buildTypes {
    debug {
        minifyEnabled false
        proguardFiles getDefaultProguardFile(‘proguard-android.txt‘), ‘proguard-rules.pro‘
    }
    release {
        minifyEnabled false
        proguardFiles getDefaultProguardFile(‘proguard-android.txt‘), ‘proguard-rules.pro‘
    }
}
applicationVariants.all { variant ->
    variant.dex.dependsOn << processWithJavassist //在执行dx命令之前将代码打入到class中
}

可以看到在build.gradle中,在执行dx之前,会先执行processWithJavassist这个任务。这样会执行PatchClass.groovy的脚本,在构造方法中进行注入

将修复的.dex文件插入dexElements

寻找class是遍历dexElements;然后我们的AntilazyLoad.class实际上并不包含在apk的classes.dex中,并且根据上面描述的需要,我们需要将AntilazyLoad.class这个类打成独立的hack_dex.jar,注意不是普通的jar,必须经过dx工具进行转化。

具体做法:

    jar cvf hack.jar dodola/hackdex/*
    dx  --dex --output hack_dex.jar hack.jar 

还记得之前我们将所有的类的构造方法中都引用了AntilazyLoad.class,所以我们需要把hack_dex.jar插入到dexElements,而在hotfix中,就是在Application中完成这个操作的

ublic class HotfixApplication extends Application {

    @Override
    public void onCreate() {
        super.onCreate();
        File dexPath = new File(getDir("dex", Context.MODE_PRIVATE), "hackdex_dex.jar");
        Utils.prepareDex(this.getApplicationContext(), dexPath, "hackdex_dex.jar");
        HotFix.patch(this, dexPath.getAbsolutePath(), "dodola.hackdex.AntilazyLoad");
        try {
            this.getClassLoader().loadClass("dodola.hackdex.AntilazyLoad");
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }

    }
}

在app的私有目录创建一个文件,然后调用Utils.prepareDex将assets中的hackdex_dex.jar写入该文件。 Utils.prepareDex中其实就是文件的读写操作,注意:前提是你把hackdex_dex.jar放入到assets中

public class Utils {
    private static final int BUF_SIZE = 2048;

    public static boolean prepareDex(Context context, File dexInternalStoragePath, String dex_file) {
        BufferedInputStream bis = null;
        OutputStream dexWriter = null;

        try {
            bis = new BufferedInputStream(context.getAssets().open(dex_file));
            dexWriter = new BufferedOutputStream(new FileOutputStream(dexInternalStoragePath));
            byte[] buf = new byte[BUF_SIZE];
            int len;
            while ((len = bis.read(buf, 0, BUF_SIZE)) > 0) {
                dexWriter.write(buf, 0, len);
            }
            dexWriter.close();
            bis.close();
            return true;
        } catch (IOException e) {
            if (dexWriter != null) {
                try {
                    dexWriter.close();
                } catch (IOException ioe) {
                    ioe.printStackTrace();
                }
            }
            if (bis != null) {
                try {
                    bis.close();
                } catch (IOException ioe) {
                    ioe.printStackTrace();
                }
            }
            return false;
        }
    }
}

接下来HotFix.patch就是去反射去修改dexElements了

public static void patch(Context context, String patchDexFile, String patchClassName) {
    if (patchDexFile != null && new File(patchDexFile).exists()) {
        try {
            if (hasLexClassLoader()) {
                injectInAliyunOs(context, patchDexFile, patchClassName);
            } else if (hasDexClassLoader()) {
                injectAboveEqualApiLevel14(context, patchDexFile, patchClassName);
            } else {

                injectBelowApiLevel14(context, patchDexFile, patchClassName);

            }
        } catch (Throwable th) {
        }
    }
}

可以看到patch方法中有几个分支,说白了是根据不同的系统中ClassLoader的类型来做相应的处理

private static void injectInAliyunOs(Context context, String patchDexFile, String patchClassName)
    throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException,
    InstantiationException, NoSuchFieldException {
    PathClassLoader obj = (PathClassLoader) context.getClassLoader();
    String replaceAll = new File(patchDexFile).getName().replaceAll("\\.[a-zA-Z0-9]+", ".lex");
    Class cls = Class.forName("dalvik.system.LexClassLoader");
    Object newInstance =
        cls.getConstructor(new Class[] {String.class, String.class, String.class, ClassLoader.class}).newInstance(
            new Object[] {context.getDir("dex", 0).getAbsolutePath() + File.separator + replaceAll,
                context.getDir("dex", 0).getAbsolutePath(), patchDexFile, obj});
    cls.getMethod("loadClass", new Class[] {String.class}).invoke(newInstance, new Object[] {patchClassName});
    setField(obj, PathClassLoader.class, "mPaths",
        appendArray(getField(obj, PathClassLoader.class, "mPaths"), getField(newInstance, cls, "mRawDexPath")));
    setField(obj, PathClassLoader.class, "mFiles",
        combineArray(getField(obj, PathClassLoader.class, "mFiles"), getField(newInstance, cls, "mFiles")));
    setField(obj, PathClassLoader.class, "mZips",
        combineArray(getField(obj, PathClassLoader.class, "mZips"), getField(newInstance, cls, "mZips")));
    setField(obj, PathClassLoader.class, "mLexs",
        combineArray(getField(obj, PathClassLoader.class, "mLexs"), getField(newInstance, cls, "mDexs")));
}

上述方法中的LexClassLoader应该是阿里自己的ClassLoader,可以看到上面将修复的文件的结尾都换成了.lex的结尾,这些文件就是专门需要通过LexClassLoader进行加载的

我们分 API 14以上和以下进行分析

API 14以下

private static void injectBelowApiLevel14(Context context, String str, String str2)
    throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
    PathClassLoader obj = (PathClassLoader) context.getClassLoader();
    DexClassLoader dexClassLoader =
        new DexClassLoader(str, context.getDir("dex", 0).getAbsolutePath(), str, context.getClassLoader());
    dexClassLoader.loadClass(str2);
    setField(obj, PathClassLoader.class, "mPaths",
        appendArray(getField(obj, PathClassLoader.class, "mPaths"), getField(dexClassLoader, DexClassLoader.class,
                "mRawDexPath")
        ));
    setField(obj, PathClassLoader.class, "mFiles",
        combineArray(getField(obj, PathClassLoader.class, "mFiles"), getField(dexClassLoader, DexClassLoader.class,
                "mFiles")
        ));
    setField(obj, PathClassLoader.class, "mZips",
        combineArray(getField(obj, PathClassLoader.class, "mZips"), getField(dexClassLoader, DexClassLoader.class,
            "mZips")));
    setField(obj, PathClassLoader.class, "mDexs",
        combineArray(getField(obj, PathClassLoader.class, "mDexs"), getField(dexClassLoader, DexClassLoader.class,
            "mDexs")));
    obj.loadClass(str2);
}

通过setField方法将mPaths属性,修改为通过appendArray方法创造的新元素

private static Object getField(Object obj, Class cls, String str)
    throws NoSuchFieldException, IllegalAccessException {
    Field declaredField = cls.getDeclaredField(str);
    declaredField.setAccessible(true);
    return declaredField.get(obj);
}
private static Object appendArray(Object obj, Object obj2) {
    Class componentType = obj.getClass().getComponentType();
    int length = Array.getLength(obj);
    Object newInstance = Array.newInstance(componentType, length + 1);
    Array.set(newInstance, 0, obj2);
    for (int i = 1; i < length + 1; i++) {
        Array.set(newInstance, i, Array.get(obj, i - 1));
    }
    return newInstance;
}

而appendArray中就是创建一个新的Array,把obj2插入到obj的前面,注意这里的obj2长度只有1

所以,在injectBelowApiLevel14的以下方法中,就是把mRawDexPath的元素插入到mPaths中所有元素之前,而重新组合而成的新mPaths替换掉旧的mPaths

setField(obj, PathClassLoader.class, "mPaths",
    appendArray(getField(obj, PathClassLoader.class, "mPaths"), getField(dexClassLoader, DexClassLoader.class,
            "mRawDexPath")
    ));

接下来的替换,是通过combineArray生成的新元素替换掉旧元素,这里分别是mFiles,mZips,mDexs

setField(obj, PathClassLoader.class, "mFiles",
    combineArray(getField(obj, PathClassLoader.class, "mFiles"), getField(dexClassLoader, DexClassLoader.class,
            "mFiles")
    ));
setField(obj, PathClassLoader.class, "mZips",
    combineArray(getField(obj, PathClassLoader.class, "mZips"), getField(dexClassLoader, DexClassLoader.class,
        "mZips")));
setField(obj, PathClassLoader.class, "mDexs",
    combineArray(getField(obj, PathClassLoader.class, "mDexs"), getField(dexClassLoader, DexClassLoader.class,
        "mDexs")));

于是我们需要看一下combineArray方法里面做了什么

private static Object combineArray(Object obj, Object obj2) {
    Class componentType = obj2.getClass().getComponentType();
    int length = Array.getLength(obj2);
    int length2 = Array.getLength(obj) + length;
    Object newInstance = Array.newInstance(componentType, length2);
    for (int i = 0; i < length2; i++) {
        if (i < length) {
            Array.set(newInstance, i, Array.get(obj2, i));
        } else {
            Array.set(newInstance, i, Array.get(obj, i - length));
        }
    }
    return newInstance;
}

逻辑也很简单,也就是两个数组的合并而已

API14以上

private static void injectAboveEqualApiLevel14(Context context, String str, String str2)
    throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
    PathClassLoader pathClassLoader = (PathClassLoader) context.getClassLoader();
    Object a = combineArray(getDexElements(getPathList(pathClassLoader)),
        getDexElements(getPathList(
            new DexClassLoader(str, context.getDir("dex", 0).getAbsolutePath(), str, context.getClassLoader()))));
    Object a2 = getPathList(pathClassLoader);
    setField(a2, a2.getClass(), "dexElements", a);
    pathClassLoader.loadClass(str2);
}

根据context拿到PathClassLoader,然后通过getPathList(pathClassLoader),拿到PathClassLoader中的pathList对象,在调用getDexElements通过pathList取到dexElements对象。

private static Object getDexElements(Object obj) throws NoSuchFieldException, IllegalAccessException {
    return getField(obj, obj.getClass(), "dexElements");
}
private static Object getPathList(Object obj) throws ClassNotFoundException, NoSuchFieldException,
    IllegalAccessException {
    return getField(obj, Class.forName("dalvik.system.BaseDexClassLoader"), "pathList");
}

同样是通过combineArray方法,对数组进行合并,合并完成后,将新的数组通过反射的方式设置给pathList.

通过上面的一系列流程,那么hack_dex.jar已经插入到dexElements最前面了,补丁插入的过程也和hack_dex.jar的插入流程是一致的

到这里,dex分包方案实现热修复的HotFix的分析就已经完毕了。

参考文献:

https://mp.weixin.qq.com/s?__biz=MzI1MTA1MzM2Nw==&mid=400118620&idx=1&sn=b4fdd5055731290eef12ad0d17f39d4a&scene=1&srcid=1106Imu9ZgwybID13e7y2nEi#wechat_redirect

时间: 2024-10-13 11:44:18

Android热修复学习之旅——HotFix完全解析的相关文章

Android热修复:Andfix和Hotfix,两种方案的比较与实现

Andfix和hotfix是两种android热修复框架. android的热修复技术我看的最早的应该是QQ空间团队的解决方案,后来真正需要了,才仔细调查,现在的方案中,阿里有两种Dexposed和Andfix框架,由于前一种不支持5.0以上android系统,所以阿里系的方案我们就看Andfix就好.Hotfix框架算是对上文提到的QQ空间团队理论实现.本文旨在写实现方案,捎带原理. Andfix 引入 框架官网:https://github.com/alibaba/AndFix 介绍是用英文

Android热修复框架汇总整理(Hotfix)

??Android平台出现了一些优秀的热更新方案,主要可以分为两类:一类是基于multidex的热更新框架,包括Nuwa.Tinker等:另一类就是native hook方案,如阿里开源的Andfix和Dexposed. 基于native hook的方案 ??需要针对dalvik虚拟机和art虚拟机做适配,需要考虑指令集的兼容问题,需要native代码支持,兼容性上会有一定的影响: 基于Multidex的方案 ??需要反射更改DexElements,改变Dex的加载顺序,这使得patch需要在下

Android热修复——Tinker微信解决方案

Android的热修复 前言: 随着时代的发展,由于公司的项目需要去求变化平凡计划总赶不上变化,H5的高灵活性,开发周期短,更新速度快H5以及一些混合开发越来越被看好,然而主要原因之一:这种混合开发的方式容错率大,更新和修复BUG快.不用发布版本就可以让用户不觉的情况下就更新对应的内容或者BUG,我们不能否认混合开发的快捷,正在此前提下热修复和热更新技术也得到了非常大的发展,不管热修复还是热更新,都是对app的内容或者逻辑变化做出像web页面更新一样的体验.而本文只对热修复进行探索,不对H5进行

Android 热修复方案分析

绝大部分的APP项目其实都需要一个动态化方案,来应对线上紧急bug修复发新版本的高成本.之前有利用加壳,分拆两个dex结合DexClassLoader实现了一套全量更新的热更方案.实现原理在Android 基于Proxy/Delegate 实现bug热修复这篇博客中有分解.因为这套方案是在Java端实现,并且是全量更新所以兼容性较好,成功率较高.但是在线上跑了几个月之后就碰到了瓶颈,因为随着业务的增长分拆过之后的dex文件方法数也超过65535个,更换拆包方案的话维护成本太高.同时由于没有做差异

Android热修复技术专题:来自微信、淘宝、支付宝、QQ空间的热修复方案

最近好多人都讨论关于热更新的话题,所以查询了一些资料看看 当一个App发布之后,突然发现了一个严重bug需要进行紧急修复,这时候公司各方就会忙得焦头烂额:重新打包App.测试.向各个应用市场和渠道换包.提示用户升级.用户下载.覆盖安装.有时候仅仅是为了修改了一行代码,也要付出巨大的成本进行换包和重新发布. 这时候就提出一个问题:有没有办法以补丁的方式动态修复紧急Bug,不再需要重新发布App,不再需要用户重新下载,覆盖安装?答案当然是有的,那就是最近涌现出来得热补丁方案,主要包括淘宝的Dexpo

Android 热修复 Tinker 源码分析之DexDiff / DexPatch

在上一篇文章中,我们介绍了Android 热修复 Tinker接入及源码浅析,里面包含了热修的一些背景知识,从tinker对dex文件的处理来看,源码大体上可以分为3部分阅读: 在应用中对patch的合并与加载,已经在上篇文章中详细介绍过了Android 热修复 Tinker接入及源码浅析 详细的dex patch,dex diff算法 tinker gradle plugin相关知识 tinker有个非常大的亮点就是自研发了一套dex diff.patch相关算法.本篇文章主要目的就是分析该算

全面了解Android热修复技术

WeTest 导读 本文探讨了Android热修复技术的发展脉络,现状及其未来. 热修复技术概述 热修复技术在近年来飞速发展,尤其是在InstantRun方案推出之后,各种热修复技术竞相涌现.国内大部分成熟的主流APP都拥有自己的热修复技术,像手淘.支付宝.QQ.饿了么.美团等等. 目前能搜集到的资料,大多简单罗列每个方案的特点并进行横向比较,而其中技术发展的脉络往往被掩盖了.热修复技术从何而来,又将往何处去?在这些资料中都找不到答案. 我认为,走马观花地看一遍各家的热修复方案并不能找到答案,所

Android热修复与插件化实践之路

第1章 class文件与dex文件解析本章通过从java最基本的class文件与android最基本的dex文件进行对比,并不借助IDE去生成及执行class与dex文件,通过讲解class与dex的手动生成,执行, 格式对比,让学生明白二者的相同与不同.1-1 课程项目整体介绍1-2 本章概述1-3 class文件详解上1-4 class文件详解下1-5 dex文件详解上1-6 dex文件详解下 第2章 虚拟机深入讲解本章主要介绍jvm,dvm,art.通过对这三个虚拟机的介绍让学生明白,an

聊聊Android 热修复Nuwa有哪些坑

原创地址:http://blog.csdn.net/sbsujjbcy/article/details/51028027 前面写了两篇关于Nuwa的文章 Android 热修复Nuwa的原理及Gradle插件源码解析 Android 热修复使用Gradle Plugin1.5改造Nuwa插件 然后我说了Nuwa有坑,有人就问Nuwa到底有哪些坑,这篇文章对自己在Nuwa上走过的坑做一个总结,如果你遇到了其他坑,欢迎留言,我会统一加到文章中去.当然有些也不算是Nuwa的坑,算是ClassLoade