Android 热修复Nuwa的原理及Gradle插件源码解析

现在,热修复的具体实现方案开源的也有很多,原理也大同小异,本篇文章以Nuwa为例,深入剖析。
Nuwa的github地址
https://github.com/jasonross/Nuwa
以及用于hotpatch生成的gradle插件地址
https://github.com/jasonross/NuwaGradle

而Nuwa的具体实现是根据QQ空间的热修复方案来实现的。安卓App热补丁动态修复技术介绍。在阅读本篇文章之前,请先阅读该文章。

从QQ空间终端开发团队的文章中可以总结出要进行热更新只需要满足下面两点就可以了:

  • 动态加载补丁dex,并将补丁dex插入到dexElements最前面
  • 要实现热更新,需要热更新的类要防止被打上ISPREVERIFIED标记,关于这个标记,请阅读上面QQ空间团队的文章。

对于第一点,实现很简单,通过DexClassLoader对象,将补丁dex对象加载进来,再通过反射将补丁dex插入到dexElements最前面即可。具体可参考谷歌的Multidex的实现。

而对于第二点,关键就是如何防止类被打上ISPREVERIFIED这个标记。

简单来说,就是将所有类的构造函数中,引用另一个hack.dex中的类,这个类叫Hack.class,然后在加载补丁patch.dex前动态加载这个hack.dex,但是有一个类的构造函数中不能引用Hack.class,这个类就是Application类的子类,一旦这个类的构造函数中加入Hack.class这个类,那么程序运行时就会找不到Hack.class这个类,因为还没有被加载。也就是说,一个类直接引用到的类不在同一个dex中即可。这样,就能防止类被打上ISPREVERIFIED标记并能进行热更新。

我们先来看Nuwa的实现,再去看Nuwa的插件的实现。

使用Nuwa的时候需要在attachBaseContext方法中初始化

@Override
protected void attachBaseContext(Context base) {
    super.attachBaseContext(base);
    Nuwa.init(this);
}

Nuwa预先将Hack.class这个类(空实现)打成apk文件,放在asserts目录中,在init方法中,做的就是将asserts目录中的这个文件拷贝到文件目录下。

public static void init(Context context) {
        File dexDir = new File(context.getFilesDir(), DEX_DIR);
        dexDir.mkdir();

        String dexPath = null;
        try {
            dexPath = AssetUtils.copyAsset(context, HACK_DEX, dexDir);
        } catch (IOException e) {
            Log.e(TAG, "copy " + HACK_DEX + " failed");
            e.printStackTrace();
        }

        loadPatch(context, dexPath);
    }

首先创建文件目录将asserts目录下的hack.apk拷到该目录,然后调用loadPatch方法将该apk动态加载进来。loadPatch方法也是之后进行热修复的关键方法,你的所有补丁文件都是通过这个方法动态加载进来。

 public static void loadPatch(Context context, String dexPath) {

        if (context == null) {
            Log.e(TAG, "context is null");
            return;
        }
        if (!new File(dexPath).exists()) {
            Log.e(TAG, dexPath + " is null");
            return;
        }
        File dexOptDir = new File(context.getFilesDir(), DEX_OPT_DIR);
        dexOptDir.mkdir();
        try {
            DexUtils.injectDexAtFirst(dexPath, dexOptDir.getAbsolutePath());
        } catch (Exception e) {
            Log.e(TAG, "inject " + dexPath + " failed");
            e.printStackTrace();
        }
    }

loadPatch方法中主要是调用DexUtils.injectDexAtFirst()方法将dex插入到dexElements最前面。该方法如下。

public static void injectDexAtFirst(String dexPath, String defaultDexOptPath) throws NoSuchFieldException, IllegalAccessException, ClassNotFoundException {
        DexClassLoader dexClassLoader = new DexClassLoader(dexPath, defaultDexOptPath, dexPath, getPathClassLoader());
        Object baseDexElements = getDexElements(getPathList(getPathClassLoader()));
        Object newDexElements = getDexElements(getPathList(dexClassLoader));
        Object allDexElements = combineArray(newDexElements, baseDexElements);
        Object pathList = getPathList(getPathClassLoader());
        ReflectionUtils.setField(pathList, pathList.getClass(), "dexElements", allDexElements);
    }

根据传入的dex的文件目录defaultDexOptPath,构造DexClassLoader对象dexClassLoader,然后通过getDexElements方法获得原来的dexElements对象,之后拿到dexClassLoader对象中的dexElements对象,调用combineArray方法将这两个对象进行结合,将我们传进来的dex插到该对象的最前面,之后调用ReflectionUtils.setField()方法,将dexElements进行替换。combineArray方法中做的就是扩展数组,将第二个数组插入到第一个数组的最前面

private static Object combineArray(Object firstArray, Object secondArray) {
        Class<?> localClass = firstArray.getClass().getComponentType();
        int firstArrayLength = Array.getLength(firstArray);
        int allLength = firstArrayLength + Array.getLength(secondArray);
        Object result = Array.newInstance(localClass, allLength);
        for (int k = 0; k < allLength; ++k) {
            if (k < firstArrayLength) {
                Array.set(result, k, Array.get(firstArray, k));
            } else {
                Array.set(result, k, Array.get(secondArray, k - firstArrayLength));
            }
        }
        return result;
    }

之后如果你有补丁要应用,直接调用Nuwa.loadPatch()方法,传入补丁的目录,重启应用之后就可以进行热更新了。这是Nuwa应用层的实现,可以看到,并不复杂。相对复杂的是Gradle插件层的实现。Gradle插件要做的事就是拿到所有class,在其构造函数中注入Hack.class,使其直接引用另一个dex中的文件,防止被打上ISPREVERIFIED标记。并且混淆的时候要应用上一次release版本的mapping文件。现在有两点关键内容:

  • 如何拿到所有的class
  • 如何在构造函数中注入代码

我们先来解决第二点,如何注入代码,Nuwa使用的是asm注入代码。

现在假设我们已经存在了hack.apk,并且里面已经有了Hack.class文件,其源代码如下

package cn.edu.zafu.hotpatch.asm;

/** * @author lizhangqu * @since 2016-03-06 10:31 */
public class Hack {
}

我们编写一个测试类Test,里面有一个测试方法,我们需要将Hack.class注入到Test的构造函数中,让其直接引用另一个dex中的类。

public class Test {
    public void method1(){
        String str="111";
    }
}

我们编译一下,得到Test.clss,将其复制到一个目录dir。然后终端进入到该目录,使用javap命令查看字节码

Android 热修复Nuwa的原理及Gradle插件源码解析_第1张图片

可以看到图中有 < init >字样,该处就是构造函数,然后看到4:return,这是构造函数的结束的地方。现在我们读入该文件,并对其进行字节码修改,然后写入该目录下dest目录下。在这之前,需要加入asm的依赖,至于asm的使用,请自行查询。

 compile 'org.ow2.asm:asm:5.0.4'

我们先将该文件读入,获得输入流,调用referHackWhenInit方法,将输入流传入,用ClassVisitor对象访问该对象,实现MethodVisitor方法,在该方法中访问对象中的方法,对方法名进行判断,如果是构造函数,则对其进行字节码注入操作,接下来运行main方法,查看dest目录下生成的文件。

public class Main {

    public static void main(String[] args) throws IOException {
        File srcFile = new File("/Users/lizhangqu/AndroidStudioProjects/Hotpatch/bak/Test.class");
        File destDir = new File("/Users/lizhangqu/AndroidStudioProjects/Hotpatch/bak/dest/");

        if (!destDir.exists()) {
            destDir.mkdirs();
        }
        InputStream is = new FileInputStream(srcFile);
        byte[] bytes = referHackWhenInit(is);


        File destFile = new File(destDir, "Test.class");
        FileOutputStream fos = new FileOutputStream(destFile);
        fos.write(bytes);
        fos.close();

    }

    private static byte[] referHackWhenInit(InputStream inputStream) throws IOException {
        ClassReader cr = new ClassReader(inputStream);
        ClassWriter cw = new ClassWriter(cr, 0);
        ClassVisitor cv = new ClassVisitor(Opcodes.ASM4, cw) {
            @Override
            public MethodVisitor visitMethod(int access, final String name, String desc,
                                             String signature, String[] exceptions) {

                MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
                if ("<init>".equals(name)) {
                    mv = new MethodVisitor(Opcodes.ASM4, mv) {
                        @Override
                        public void visitInsn(int opcode) {
                            if (opcode == Opcodes.RETURN) {
                                super.visitLdcInsn(Type.getType("Lcn/edu/zafu/hotpatch/asm/Hack"));
                            }
                            super.visitInsn(opcode);
                        }
                    };
                }
                return mv;
            }

        };
        cr.accept(cv, 0);
        return cw.toByteArray();
    }

}

生成的Test.class文件内容如下

Android 热修复Nuwa的原理及Gradle插件源码解析_第2张图片

可以看到构造函数中直接引用了Hack.class,然后我们使用javap命令查看字节码

可以看到return之前,插入了我们的字节码,直接引用了Hack.class

字节码注入的问题解决了,接下来就是找到要注入字节码的所有class。

接下来分析Nuwa的Gradle插件,在分析之前,请先了解一下Gralde插件的开发流程,可以阅读这篇文章如何使用Android Studio开发Gradle插件。下面的内容的gradle版本是基于1.2.3,高版本的可能有所差异。请查看项目依赖的是否是下面的这个版本

classpath 'com.android.tools.build:gradle:1.2.3'

为了找到这些class,实际上,分为了两种情况

  • 开启了Multidex的项目
  • 没有开启Multidex的项目

如果使用了MultiDex,并且没有混淆,这种情况很简单,dex任务之前会生成一个jar文件,包含了所有的class,所以做起来很容易。但是如果添加了混淆怎么办?试了一下,也是proguard后也是生成了一个jar包,也没啥问题

为了验证作者的论证,我们编写一个插件来验证一下,关于如何编写插件,请查看上面贴的文章。

我们先在项目中开启Multidex

multiDexEnabled true

对于release的构建,开启混淆,对于debug,关闭混淆

buildTypes {
        release {
            minifyEnabled true
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
        debug{
            minifyEnabled false
        }
    }

这个插件的作用是什么的,其实很简单,就是输出preDex,dex,proguard这三个Task的输入文件,当然前提是Task存在。代码如下

public class PluginImpl implements Plugin<Project> {
    public void apply(Project project) {

        project.afterEvaluate {
            project.android.applicationVariants.each { variant ->
                def preDexTask = project.tasks.findByName("preDex${variant.name.capitalize()}")
                def dexTask = project.tasks.findByName("dex${variant.name.capitalize()}")
                def proguardTask = project.tasks.findByName("proguard${variant.name.capitalize()}")


                if (preDexTask) {
                    Set<File> preDexTaskInputFiles = preDexTask.inputs.files.files

                    project.logger.error "Name:preDexTaskInputFiles=====>${preDexTask.name}"
                    preDexTaskInputFiles.each { inputFile ->
                        def path = inputFile.absolutePath
                        project.logger.error path
                    }
                }

                if (dexTask) {
                    Set<File> dexTaskInputFiles = dexTask.inputs.files.files

                    project.logger.error "Name:dexTaskInputFiles=====>${dexTask.name}"
                    dexTaskInputFiles.each { inputFile ->
                        def path = inputFile.absolutePath
                        project.logger.error path

                    }
                }

                if (proguardTask) {
                    Set<File> proguardTaskInputFiles = proguardTask.inputs.files.files

                    project.logger.error "Name:proguardTask=====>${proguardTask.name}"
                    proguardTaskInputFiles.each { inputFile ->
                        def path = inputFile.absolutePath
                        project.logger.error path
                    }
                }
            }
        }
    }
}

应用插件然后查看插件输出的日志

Android 热修复Nuwa的原理及Gradle插件源码解析_第3张图片

可以看到,对于debug的构建,我们没有开启混淆,dex的Task的输入文件是一个allclasses.jar,而release版本的构建,dex的Task的输入文件是混淆之后的文件classes.jar。并且无论是debug还是release,对于这种开启了Multidex的情况下,是不存在preDex这个Task的,对于这种情况,我们可以判断preDex这个Task是否存在进行操作。查看NuwaGradle的源码。相关解释,我已经加入到注释中了。

/** * 了解到preDex会在dex任务之前把所有的库工程和第三方jar包提前打成dex, * 下次运行只需重新dex被修改的库,以此节省时间。 * dex任务会把preDex生成的dex文件和主工程中的class文件一起生成class.dex */
if (preDexTask){
    //这个Task存在的情况,即没有开启Multidex
}else {
    /** * 如果preDexTask这个task不存在,即开启了Multidex * dex任务之前会生成一个jar文件,包含了所有的class,即使做了混淆也是一个jar * 这种情况下只对jar进行处理即可 */
    def nuwaJarBeforeDex = "nuwaJarBeforeDex${variant.name.capitalize()}"
    project.task(nuwaJarBeforeDex) << {
        /** * 获得所有输入文件 */
        Set<File> inputFiles = dexTask.inputs.files.files
        /** * 遍历所有文件 */
        inputFiles.each { inputFile ->
            def path = inputFile.absolutePath
            /** * 如果是以jar结尾,则对jar进行字节码注入处理 */
            if (path.endsWith(".jar")) {
                NuwaProcessor.processJar(hashFile, inputFile, patchDir, hashMap, includePackage, excludeClass)
            }
        }
    }
    /** * 处理task依赖 * nuwaJarBeforeDexTask在dexTask之前,在dexTask原来之前所有task之后 */
    def nuwaJarBeforeDexTask = project.tasks[nuwaJarBeforeDex]
    nuwaJarBeforeDexTask.dependsOn dexTask.taskDependencies.getDependencies(dexTask)
    dexTask.dependsOn nuwaJarBeforeDexTask

    /** * nuwaJarBeforeDexTask开始时执行nuwaPrepareClosure闭包 * 这个闭包做的就是创建文件夹等初始化话操作 * 结束时执行copyMappingClosure拷贝mapping文件和hash.txt文件 */
    nuwaJarBeforeDexTask.doFirst(nuwaPrepareClosure)
    nuwaJarBeforeDexTask.doLast(copyMappingClosure)

    /** * patch的dex生成是在=》操作依赖字节码修改之后的task执行完毕后再执行 */
    nuwaPatchTask.dependsOn nuwaJarBeforeDexTask
    beforeDexTasks.add(nuwaJarBeforeDexTask)

    /** * 以上task的总结 * dex之前的所有task->获得dex之前的所有输入文件->字节码注入->dex */
}

而上面使用到了nuwaPrepareClosure和copyMappingClosure这两个闭包。以及Gradle插件的初始化操作如下,详情见注释

/** * hash值对应的map */
Map hashMap
/** * nuwa的输出产物目录 */
File nuwaDir
/** * 需要打patch的classes文件目录,会对比hash值,如果hash值不一样,会拷到这个目录 */
File patchDir

/** * 找到preDex,dex,proguard这三个task */
def preDexTask = project.tasks.findByName("preDex${variant.name.capitalize()}")
def dexTask = project.tasks.findByName("dex${variant.name.capitalize()}")
def proguardTask = project.tasks.findByName("proguard${variant.name.capitalize()}")

/** * 找到manifest文件 */
def processManifestTask = project.tasks.findByName("process${variant.name.capitalize()}Manifest")
def manifestFile = processManifestTask.outputs.files.files[0]
/** * 这个属性是从控制台输入的,代表之前release版本生成的混淆文件和hash文件目录,这两个文件发版时需要保持 * ./gradlew clean nuwaQihooDebugPatch -P NuwaDir=/Users/jason/Documents/nuwa */
def oldNuwaDir = NuwaFileUtils.getFileFromProperty(project, NUWA_DIR)
if (oldNuwaDir) {
    /** * 如果文件夹存在的话混淆的时候应用mapping */
    def mappingFile = NuwaFileUtils.getVariantFile(oldNuwaDir, variant, MAPPING_TXT)
    NuwaAndroidUtils.applymapping(proguardTask, mappingFile)
}
if (oldNuwaDir) {
    /** * 如果文件夹存在的话获得各个class的hash */
    def hashFile = NuwaFileUtils.getVariantFile(oldNuwaDir, variant, HASH_TXT)
    /** * 将文件中的hash存入这个map */
    hashMap = NuwaMapUtils.parseMap(hashFile)
}

/** * /qihoo/debug */
def dirName = variant.dirName
/** * /build/outputs/nuwa/ */
nuwaDir = new File("${project.buildDir}/outputs/nuwa")
/** * 不同variant对应nuwa目录下不同文件夹 * /build/outputs/nuwa/qihoo/debug */
def outputDir = new File("${nuwaDir}/${dirName}")
/** * hash文件 * /build/outputs/nuwa/qihoo/debug/hash.txt */
def hashFile = new File(outputDir, "hash.txt")

/** * 创建相关文件的闭包 */
Closure nuwaPrepareClosure = {
    /** * 获得application类 */
    def applicationName = NuwaAndroidUtils.getApplication(manifestFile)
    if (applicationName != null) {
        /** * 如果已经定义了application类,则加入excludeClass列表,不执行字节码修改 */
        excludeClass.add(applicationName)
    }

    /** * 创建对应的文件夹及hash文件 */
    outputDir.mkdirs()
    if (!hashFile.exists()) {
        hashFile.createNewFile()
    }

    /** * 创建patch文件夹 */
    if (oldNuwaDir) {
        /** * 此目录存patch的classes * /build/outputs/nuwa/qihoo/debug/patch/ */
        patchDir = new File("${nuwaDir}/${dirName}/patch")
        patchDir.mkdirs()
        patchList.add(patchDir)
    }
}
/** * 注入nuwaPatch的task * nuwaQihooDebugPatch */
def nuwaPatch = "nuwa${variant.name.capitalize()}Patch"
project.task(nuwaPatch) << {
    if (patchDir) {
        /** * 执行patch的dex操作 */
        NuwaAndroidUtils.dex(project, patchDir)
    }
}
/** * 获得打patch的task */
def nuwaPatchTask = project.tasks[nuwaPatch]

/** * 拷贝mapping的闭包 */
Closure copyMappingClosure = {
    /** * 将构建产生的mapping文件拷贝至目标nuwa目录 */
    if (proguardTask) {
        def mapFile = new File("${project.buildDir}/outputs/mapping/${variant.dirName}/mapping.txt")
        def newMapFile = new File("${nuwaDir}/${variant.dirName}/mapping.txt");
        FileUtils.copyFile(mapFile, newMapFile)
    }
}

而对于没有开启Multidex的情况,则会存在一个preDex的Task。preDex会在dex任务之前把所有的库工程和第三方jar包提前打成dex,下次运行只需重新dex被修改的库,以此节省时间。dex任务会把preDex生成的dex文件和主工程中的class文件一起生成class.dex,这样就需要针对有无preDex,做不同的修改字节码策略即可。源码解释如下。

/** * 了解到preDex会在dex任务之前把所有的库工程和第三方jar包提前打成dex, * 下次运行只需重新dex被修改的库,以此节省时间。 * dex任务会把preDex生成的dex文件和主工程中的class文件一起生成class.dex */
if (preDexTask) {
    /** * 处理jar文件,这些jar是所有的库工程和第三方jar包,是preDexTask的输入文件 */
    def nuwaJarBeforePreDex = "nuwaJarBeforePreDex${variant.name.capitalize()}"
    project.task(nuwaJarBeforePreDex) << {
        /** * 获得preDex的所有jar文件 */
        Set<File> inputFiles = preDexTask.inputs.files.files
        /** * 遍历jar文件 */
        inputFiles.each { inputFile ->
            def path = inputFile.absolutePath
            /** * 如果是以classes.jar结尾的文件并且路径中不包含com.android.support且路径中中不包含/android/m2repository */
            if (NuwaProcessor.shouldProcessPreDexJar(path)) {
                /** * 处理classes.jar,注入字节码 */
                NuwaProcessor.processJar(hashFile, inputFile, patchDir, hashMap, includePackage, excludeClass)
            }
        }
    }
    /** * 处理task依赖 * nuwaJarBeforePreDexTask依赖preDexTask之前所有的task * preDexTask依赖nuwaJarBeforePreDexTask */
    def nuwaJarBeforePreDexTask = project.tasks[nuwaJarBeforePreDex]
    nuwaJarBeforePreDexTask.dependsOn preDexTask.taskDependencies.getDependencies(preDexTask)
    preDexTask.dependsOn nuwaJarBeforePreDexTask

    /** * 这个task之前进行这个闭包处理,主要做创建文件的操作 */
    nuwaJarBeforePreDexTask.doFirst(nuwaPrepareClosure)

    /** * 处理classes文件,注意这里是主工程的class文件,是dexTask的输入文件 */
    def nuwaClassBeforeDex = "nuwaClassBeforeDex${variant.name.capitalize()}"
    project.task(nuwaClassBeforeDex) << {
        Set<File> inputFiles = dexTask.inputs.files.files
        /** * 遍历所有class文件 */
        inputFiles.each { inputFile ->
            def path = inputFile.absolutePath
            /** * 以class结尾,不包含R路径,不是R.class,不是BuildConfig.class文件 */
            if (path.endsWith(".class") && !path.contains("/R\$") && !path.endsWith("/R.class") && !path.endsWith("/BuildConfig.class")) {
                /** * 包含在includePackage内 */
                if (NuwaSetUtils.isIncluded(path, includePackage)) {
                    /** * 不包含在excludeClass内 */
                    if (!NuwaSetUtils.isExcluded(path, excludeClass)) {
                        /** * 往class中注入字节码 */
                        def bytes = NuwaProcessor.processClass(inputFile)
                        path = path.split("${dirName}/")[1]
                        /** * hash校验 */
                        def hash = DigestUtils.shaHex(bytes)
                        /** * 往hash.txt文件中写入hash值 */
                        hashFile.append(NuwaMapUtils.format(path, hash))
                        /** * 与上一个release版本hash值不一样则复制出来,作为patch.jar的组成部分 */
                        if (NuwaMapUtils.notSame(hashMap, path, hash)) {
                            /** * 拷贝到patch目录 */
                            NuwaFileUtils.copyBytesToFile(inputFile.bytes, NuwaFileUtils.touchFile(patchDir, path))
                        }
                    }
                }
            }
        }
    }
    /** * 重新处理task依赖关系 * nuwaClassBeforeDexTask依赖dexTask这个task之前依赖的所有Task * dexTask这个Task依赖 nuwaClassBeforeDexTask这个Task */
    def nuwaClassBeforeDexTask = project.tasks[nuwaClassBeforeDex]
    nuwaClassBeforeDexTask.dependsOn dexTask.taskDependencies.getDependencies(dexTask)
    dexTask.dependsOn nuwaClassBeforeDexTask

    /** * 最后拷贝mapping文件备份 */
    nuwaClassBeforeDexTask.doLast(copyMappingClosure)


    /** * patch的dex操作依赖字节码修改之后的task,即nuwaClassBeforeDexTask */
    nuwaPatchTask.dependsOn nuwaClassBeforeDexTask
    beforeDexTasks.add(nuwaClassBeforeDexTask)
}

这样就完成了字节码的修改,至于字节码修改的函数,其实就和最开始的测试asm修改字节码的例子差不多,对于jar文件,需要将jar文件中的所有class遍历一遍处理。字节码的注入操作全在NuwaProcessor这个类中。源码解析如下

class NuwaProcessor {
    /** * 处理jar * @param hashFile * @param jarFile * @param patchDir * @param map * @param includePackage * @param excludeClass * @return */
    public static processJar(File hashFile, File jarFile, File patchDir, Map map, HashSet<String> includePackage, HashSet<String> excludeClass) {
        if (jarFile) {
            /** * classes.jar dex后的文件 */
            def optJar = new File(jarFile.getParent(), jarFile.name + ".opt")

            def file = new JarFile(jarFile);
            Enumeration enumeration = file.entries();
            JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(optJar));

            /** * 枚举jar文件中的所有文件 */
            while (enumeration.hasMoreElements()) {
                JarEntry jarEntry = (JarEntry) enumeration.nextElement();
                String entryName = jarEntry.getName();
                ZipEntry zipEntry = new ZipEntry(entryName);

                InputStream inputStream = file.getInputStream(jarEntry);
                jarOutputStream.putNextEntry(zipEntry);
                /** * 以class结尾的文件并且在include中不在exclude中,并且不是cn/jiajixin/nuwa/包中的文件 */
                if (shouldProcessClassInJar(entryName, includePackage, excludeClass)) {
                    /** * 构造函数中注入字节码 */
                    def bytes = referHackWhenInit(inputStream);
                    /** * 写入子杰 */
                    jarOutputStream.write(bytes);

                    /** * hash校验 */
                    def hash = DigestUtils.shaHex(bytes)
                    /** * 加入hash值 */
                    hashFile.append(NuwaMapUtils.format(entryName, hash))
                    /** * hash值与上一release版本不一样则拷到对应的目录,作为patch的类 */
                    if (NuwaMapUtils.notSame(map, entryName, hash)) {
                        NuwaFileUtils.copyBytesToFile(bytes, NuwaFileUtils.touchFile(patchDir, entryName))
                    }
                } else {
                    /** * 否则直接输出文件不处理 */
                    jarOutputStream.write(IOUtils.toByteArray(inputStream));
                }
                jarOutputStream.closeEntry();
            }
            jarOutputStream.close();
            file.close();
            /** * 删除jar文件 */
            if (jarFile.exists()) {
                jarFile.delete()
            }
            /** * dex后的文件重命名为jar文件 */
            optJar.renameTo(jarFile)
        }

    }

    //refer hack class when object init
    private static byte[] referHackWhenInit(InputStream inputStream) {
        ClassReader cr = new ClassReader(inputStream);
        ClassWriter cw = new ClassWriter(cr, 0);
        ClassVisitor cv = new ClassVisitor(Opcodes.ASM4, cw) {
            @Override
            public MethodVisitor visitMethod(int access, String name, String desc,
                                             String signature, String[] exceptions) {

                MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
                mv = new MethodVisitor(Opcodes.ASM4, mv) {
                    @Override
                    void visitInsn(int opcode) {
                        /** * 如果是构造函数 */
                        if ("<init>".equals(name) && opcode == Opcodes.RETURN) {
                            /** * 注入代码 */
                            super.visitLdcInsn(Type.getType("Lcn/jiajixin/nuwa/Hack;"));
                        }
                        super.visitInsn(opcode);
                    }
                }
                return mv;
            }

        };
        cr.accept(cv, 0);
        return cw.toByteArray();
    }

    /** * 是否需要在preDex前处理 * @param path * @return */
    public static boolean shouldProcessPreDexJar(String path) {
        return path.endsWith("classes.jar") && !path.contains("com.android.support") && !path.contains("/android/m2repository");
    }

    /** * jar中的文件是否需要处理 * @param entryName * @param includePackage * @param excludeClass * @return */
    private static boolean shouldProcessClassInJar(String entryName, HashSet<String> includePackage, HashSet<String> excludeClass) {
        return entryName.endsWith(".class") && !entryName.startsWith("cn/jiajixin/nuwa/") && NuwaSetUtils.isIncluded(entryName, includePackage) && !NuwaSetUtils.isExcluded(entryName, excludeClass) && !entryName.contains("android/support/")
    }

    /** * 处理class * @param file * @return */
    public static byte[] processClass(File file) {
        def optClass = new File(file.getParent(), file.name + ".opt")

        FileInputStream inputStream = new FileInputStream(file);
        FileOutputStream outputStream = new FileOutputStream(optClass)
        /** * 对class注入字节码 */
        def bytes = referHackWhenInit(inputStream);
        outputStream.write(bytes)
        inputStream.close()
        outputStream.close()
        if (file.exists()) {
            file.delete()
        }
        optClass.renameTo(file)
        return bytes
    }
}

字节码的注入需要将Application类排除在外,这个类如果在Manifest文件中设置了,我们需要将其拿到,并加入到excludeClass中,这个操作在nuwaPrepareClosure闭包中已经处理了。

    /** * 获得application的名字 * @param manifestFile * @return */
    public static String getApplication(File manifestFile) {
        def manifest = new XmlParser().parse(manifestFile)
        def androidTag = new groovy.xml.Namespace("http://schemas.android.com/apk/res/android", 'android')
        def applicationName = manifest.application[0].attribute(androidTag.name)

        if (applicationName != null) {
            return applicationName.replace(".", "/") + ".class"
        }
        return null;
    }

然后如果开启了混淆,我们需要应用上一次发release版本的mapping文件进行混淆

/** * 混淆时使用上次发版的mapping文件 * @param proguardTask * @param mappingFile * @return */
    public static applymapping(DefaultTask proguardTask, File mappingFile) {
        if (proguardTask) {
            if (mappingFile.exists()) {
                proguardTask.applymapping(mappingFile)
            } else {
                println "$mappingFile does not exist"
            }
        }
    }

Nuwa除了支持某个构建执行打Patch的操作之外,还支持批量生产所有构建的Patch,该Task的名字为nuwaPatches,这个Task的依赖关系还要处理一下

/** * 下面是nuwaPatches的处理,即所有的构建都打patch */

/** * nuwaPatches执行dex操作 */
project.task(NUWA_PATCHES) << {
    /** * 对需要patch的classes执行dex操作 */
    patchList.each { patchDir ->
        NuwaAndroidUtils.dex(project, patchDir)
    }
}


/** * 处理依赖nuwaPatches这个Task的依赖,也就是注入字节码的Task之后执行dex操作 */
beforeDexTasks.each {
    /** * 打patch的task依赖这些task */
    project.tasks[NUWA_PATCHES].dependsOn it
}

所有需要注入字节码的类处理完毕后,我们需要将其进行dex操作,使其能够运行在Android系统上。这个方法在NuwaAndroidUtils类中。

/** * 对jar进行dex操作 * @param project * @param classDir * @return */
public static dex(Project project, File classDir) {
    if (classDir.listFiles().size()) {
        def sdkDir
        /** * 获得sdk目录 */
        Properties properties = new Properties()
        File localProps = project.rootProject.file("local.properties")
        if (localProps.exists()) {
            properties.load(localProps.newDataInputStream())
            sdkDir = properties.getProperty("sdk.dir")
        } else {
            sdkDir = System.getenv("ANDROID_HOME")
        }
        if (sdkDir) {
            /** * 如果是windows系统,加入后缀.bat */
            def cmdExt = Os.isFamily(Os.FAMILY_WINDOWS) ? '.bat' : ''
            def stdout = new ByteArrayOutputStream()
            /** * 拼接命令 * dx --dex --output=patch.jar classDir * classDir是注入字节码后的补丁目录 */
            project.exec {
                commandLine "${sdkDir}/build-tools/${project.android.buildToolsVersion}/dx${cmdExt}",
                        '--dex',
                        "--output=${new File(classDir.getParent(), PATCH_NAME).absolutePath}",
                        "${classDir.absolutePath}"
                standardOutput = stdout
            }
            def error = stdout.toString().trim()
            if (error) {
                println "dex error:" + error
            }
        } else {
            throw new InvalidUserDataException('$ANDROID_HOME is not defined')
        }
    }
}

还有一个hash值的工具类,包括从文件中解析hash值到map,将hash值格式化写入文件,判断hash值知否一样。

class NuwaMapUtils {
    private static final String MAP_SEPARATOR = ":"

    /** * 判断hash值是否一样 * @param map * @param name * @param hash * @return */
    public static boolean notSame(Map map, String name, String hash) {
        def notSame = false
        if (map) {
            def value = map.get(name)
            if (value) {
                if (!value.equals(hash)) {
                    notSame = true
                }
            } else {
                notSame = true
            }
        }
        return notSame
    }

    /** * 从hash.txt文件中解析内容到map * @param hashFile * @return */
    public static Map parseMap(File hashFile) {
        def hashMap = [:]
        if (hashFile.exists()) {
            hashFile.eachLine {
                List list = it.split(MAP_SEPARATOR)
                if (list.size() == 2) {
                    hashMap.put(list[0], list[1])
                }
            }
        } else {
            println "$hashFile does not exist"
        }
        return hashMap
    }
    /** * 根据传入的键值对其进行格式化(用:分割) * @param path * @param hash * @return */
    public static format(String path, String hash) {
        return path + MAP_SEPARATOR + hash + "\n"
    }
}

以及一个文件操作的工具类

class NuwaFileUtils {
    /** * 创建文件 * @param dir * @param path * @return */
    public static File touchFile(File dir, String path) {
        def file = new File("${dir}/${path}")
        file.getParentFile().mkdirs()
        return file
    }

    /** * 写入字节码到文件 * @param bytes * @param file * @return */
    public static copyBytesToFile(byte[] bytes, File file) {
        if (!file.exists()) {
            file.createNewFile()
        }
        FileUtils.writeByteArrayToFile(file, bytes)
    }
    /** * 获得控制台传入的属性对应的文件夹 * @param project * @param property * @return */
    public static File getFileFromProperty(Project project, String property) {
        def file
        if (project.hasProperty(property)) {
            /** * ./gradlew clean nuwaQihooDebugPatch -P NuwaDir=/Users/jason/Documents/nuwa * 获得NuwaDir对应的目录,即上次发包的mapping和hash文件所在目录 */
            file = new File(project.getProperties()[property])
            if (!file.exists()) {
                /** * 文件夹不存在扔异常 */
                throw new InvalidUserDataException("${project.getProperties()[property]} does not exist")
            }
            if (!file.isDirectory()) {
                /** * 不是目录扔异常 */
                throw new InvalidUserDataException("${project.getProperties()[property]} is not directory")
            }
        }
        return file
    }

    /** * 获得不同variant对应的目录下的文件 * 如/qihoo/debug * @param dir * @param variant * @param fileName * @return */
    public static File getVariantFile(File dir, def variant, String fileName) {
        return new File("${dir}/${variant.dirName}/${fileName}")
    }

}

最后,总结一下NuwaGradle的流程。

  • 首先判断preDex这个Task是否存在
  • 如果不存在,则对dex的输入文件进行遍历,这些输入文件是一系列的jar,对这些jar进行判断,看其是否满足注入字节码的条件,如果满足,对jar文件中满足条件的class文件进行遍历注入字节码,然后删除原来的jar,将处理后的文件命名为原来的文件。
  • 如果存在这个preDex,将这个preDexTask的输入文件进行字节码注入操作,这个Task的输入文件是一系列的jar文件,这些jar是所有的库工程和第三方jar包,此外,还需要将主工程的class文件进行处理。
  • 完成了注入字节码操作后,需要对其进行dex操作,也就是最终的patch文件。这个patch文件可以直接被客户端加载并进行热修复。
  • 不能注入字节码的类是Application的子类,因为Hack.apk在程序运行之前没有被加载,所以如果Application类中引用了Hack.apk中的Hack.class文件,则会报Class找不到的异常,之后也永远找不到了。所以这个类不能注入字节码,但是需要提前加载初始化方法中动态加载该Hack.apk。
  • 发版时的mapping文件以及所有class文件的hash值的文件需要保持下来打patch使用。

Gradle可以做很多自动化的东西

Gradle可以做很多自动化的东西

Gradle可以做很多自动化的东西

重要的事说三遍

你可能感兴趣的:(android,动态加载,Hotpatch,Nuwa,热修复)