Android 热修复使用Gradle Plugin1.5改造Nuwa插件

原创 2016年03月12日 10:13:21

随着谷歌的Gradle插件版本的不断升级,Gradle插件现在最新的已经到了2.1.0-beta1,对应的依赖为com.android.tools.build:gradle:2.0.0-beta6,而Nuwa当时出来的时候,Gradle插件还只是1.2.3版本,对应的依赖为com.android.tools.build:gradle:1.2.3,当时的Nuwa是根据有无preDex这个Task进行hook做不同的逻辑处理,而随着Gradle插件版本的不断增加,谷歌增加了一个新的接口可以用于处理我们的字节码注入的需求。这个接口最早出现在1.5.0-beta1中,官方的描述如下,不想看英文的直接略过看翻译。

Starting with 1.5.0-beta1, the Gradle plugin includes a Transform API allowing 3rd party plugins to manipulate compiled class files before they are converted to dex files.
(The API existed in 1.4.0-beta2 but it's been completely revamped in 1.5.0-beta1)

The goal of this API is to simplify injecting custom class manipulations without having to deal with tasks, and to offer more flexibility on what is manipulated. The internal code processing (jacoco, progard, multi-dex) have all moved to this new mechanism already in 1.5.0-beta1.
Note: this applies only to the javac/dx code path. Jack does not use this API at the moment.

The API doc is here.

To insert a transform into a build, you simply create a new class implementing one of the Transform interfaces, and register it with android.registerTransform(theTransform) or android.registerTransform(theTransform, dependencies).

Important notes:
The Dex class is gone. You cannot access it anymore through the variant API (the getter is still there for now but will throw an exception)
Transform can only be registered globally which applies them to all the variants. We'll improve this shortly.
There's no way to control ordering of the transforms.
We're looking for feedback on the API. Please file bugs or email us on our adt-dev mailing list.

从1.5开始,gradle插件包含了一个叫Transform的API,这个API允许第三方插件在class文件转为为dex文件前操作编译好的class文件,这个API的目标就是简化class文件的自定义的操作而不用对Task进行处理,并且可以更加灵活地进行操作。我们如何注入一个Transform呢,很简单,实现Transform抽象类中的方法,使用下面的两个方法之一进行注入即可。

android.registerTransform(theTransform) 

android.registerTransform(theTransform, dependencies)

那么我们就可以在这个函数中操作之前1.2.3版本中的Nuwa Gradle做的一切事情。在这之前,你最好通读下面三篇文章

现在,新建一个gradle插件项目,如何新建请阅读上面的第一篇文章,这个插件项目中有两个module,一个为app,用于测试插件,一个是插件module,姑且叫hotpatch,用于编写插件。

将你的gradle plugin版本切到1.5

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

然后将gralde wrapper版本改为2.10

distributionUrl=https\://services.gradle.org/distributions/gradle-2.10-all.zip

现在编译运行一下项目下的app module,看下gradle控制台输出的是什么。

这里写图片描述

可以看到,的确没有preDex这个Task,反倒是多了很多transform开头的Task,那么这些Task是怎么来的呢。在gradle plugin的源码中有一个叫TransformManager的类,这个类管理着所有的Transform的子类,里面有一个方法叫getTaskNamePrefix,在这个方法中就是获得Task的前缀,以transform开头,之后拼接ContentType,这个ContentType代表着这个Transform的输入文件的类型,类型主要有两种,一种是Classes,另一种是Resources,ContentType之间使用And连接,拼接完成后加上With,之后紧跟的就是这个Transform的Name,name在getName()方法中重写返回即可。代码如下:

@NonNull
    private static String getTaskNamePrefix(@NonNull Transform transform) {
        StringBuilder sb = new StringBuilder(100);
        sb.append("transform");

        Iterator<ContentType> iterator = transform.getInputTypes().iterator();
        // there's always at least one
        sb.append(capitalize(iterator.next().name().toLowerCase(Locale.getDefault())));
        while (iterator.hasNext()) {
            sb.append("And").append(capitalize(
                    iterator.next().name().toLowerCase(Locale.getDefault())));
        }

        sb.append("With").append(capitalize(transform.getName())).append("For");

        return sb.toString();
    }

ContentType是一个接口,有一个默认的枚举类的实现类,里面定义了两种文件,一种是class文件,另一种就是资源文件。

interface ContentType {
        /**
         * Content type name, readable by humans.
         * @return the string content type name
         */
        String name();

        /**
         * A unique value for a content type.
         */
        int getValue();
    }

    /**
     * The type of of the content.
     */
    enum DefaultContentType implements ContentType {
        /**
         * The content is compiled Java code. This can be in a Jar file or in a folder. If
         * in a folder, it is expected to in sub-folders matching package names.
         */
        CLASSES(0x01),

        /**
         * The content is standard Java resources.
         */
        RESOURCES(0x02);

        private final int value;

        DefaultContentType(int value) {
            this.value = value;
        }

        @Override
        public int getValue() {
            return value;
        }
    }

说到ContentType,顺便把另一个枚举类带掉,叫Scope,翻译过来就是作用域,关于详细的内容,请看下面的注释。

enum Scope {
        /** Only the project content */
        PROJECT(0x01),
        /** Only the project's local dependencies (local jars) */
        PROJECT_LOCAL_DEPS(0x02),
        /** Only the sub-projects. */
        SUB_PROJECTS(0x04),
        /** Only the sub-projects's local dependencies (local jars). */
        SUB_PROJECTS_LOCAL_DEPS(0x08),
        /** Only the external libraries */
        EXTERNAL_LIBRARIES(0x10),
        /** Code that is being tested by the current variant, including dependencies */
        TESTED_CODE(0x20),
        /** Local or remote dependencies that are provided-only */
        PROVIDED_ONLY(0x40);

        private final int value;

        Scope(int value) {
            this.value = value;
        }

        public int getValue() {
            return value;
        }
    }

ContentType和Scope,一起组成输出产物的目录结构。可以看到下图transforms下有很多莫名其妙的目录,比如1000,1f,main,3,等等,这些目录可不是随机产生的,而是根据上面的两个值产生的。

这里写图片描述

举个例子,上面的文件夹中有个proguard的目录,这个目录是ProGuardTransform产生的,在源码中可以找到其实现了getName方法,返回了proguard。这个getName()方法返回的值就创建了proguard这个目录。

public String getName() {
  return "proguard";
}

然后再看这个Transform的输入文件类型

public Set<ContentType> getInputTypes() {
  return TransformManager.CONTENT_JARS;
}

TransformManager.CONTENT_JARS是什么鬼呢,跟进去一目了然

public static final Set<ContentType> CONTENT_JARS = ImmutableSet.<ContentType>of(CLASSES, RESOURCES);

因此Proguard这个Transform有两种输入文件,一种是class文件(含jar),另一种是资源文件,这个Task是做混淆用的,class文件就是ProGuardTransform依赖的上一个Transform的输出产物,而资源文件可以是混淆时使用的配置文件。

因此根据上面的规则,这个Transform最终在控制台显示的名字就是

transformClassesAndResourcesWithProguardForDebug

For后面跟的是buildType+productFlavor,比如QihooDebugXiaomiReleaseDebugRelease

那么上面输出产物的目录/proguard/release/jars/3/1f/main.jar是怎么来的呢?proguard上面说了,是getName()方法返回的,而release则是buildType的名字,注意这里不一定是只有buildType,如果你的项目中指定了productFlavor,那么可能release的上一个节点还有productFlaovor,就像这样/proguard/qihoo/release/。可以看到ProGuardTransform中重写了getScopes方法,我们先忽略isLibrary的情况,因为我们的app module不是library,是一个app。可以看到最终返回的是TransformManager.SCOPE_FULL_PROJECT

public Set<Scope> getScopes() {
  if (isLibrary) {
      return Sets.immutableEnumSet(Scope.PROJECT, Scope.PROJECT_LOCAL_DEPS);
  }

  return TransformManager.SCOPE_FULL_PROJECT;
}

TransformManager.SCOPE_FULL_PROJECT的值为多少呢?跟进去看看。

public static final Set<Scope> SCOPE_FULL_PROJECT = Sets.immutableEnumSet(
            Scope.PROJECT,
            Scope.PROJECT_LOCAL_DEPS,
            Scope.SUB_PROJECTS,
            Scope.SUB_PROJECTS_LOCAL_DEPS,
            Scope.EXTERNAL_LIBRARIES);

然后你把这5个Scope的值加起来算一算,刚刚好是1f,于是目录中的1f就产生了。那么3是什么呢,还记得上面提到的Proguard的输入文件吗,既有class文件又有资源文件,这两个值加起来就是3。接着在源码中查找。找到了这样一段代码

File outFile = output.getContentLocation("main", outputTypes, scopes,asJar ? Format.JAR : Format.DIRECTORY);

上面的代码中使用到了一个变量asJar,这个变量在构造函数中赋值为true,因此这段代码可以简化为

File outFile = output.getContentLocation("main", outputTypes, scopes,Format.JAR)

Format.JAR是什么意思呢,它代表的输出文件有一个后缀jar,如果是Format.DIRECTORY则代表输出文件是目录结构的。而从上面这段代码还可以看到输出文件的文件名为main,于是最终输出文件是main.jar,并且是在jars目录下面的子目录中,当然如果是Format.DIRECTORY,就是在folders目录下的子目录中。

这时候你把这段代码里的值都连接起来,文件目录=》jars,outputTypes=》3,scopes=》1f,文件名=》main.jar,见证奇迹的时候到了,jars/3/1f/main.jar,怎么样,这就是图中的目录结构,即ProGuardTransform的产物。上面也提到过,这个文件路径中可能还会包含buildType和productFlavor,当然是这两个被定义的情况下,比如下面的几个组合。

/proguard/qihoo/release/jars/3/1f/main.jar
/proguard/qihoo/debug/jars/3/1f/main.jar
/proguard/xiaomi/release/jars/3/1f/main.jar
/proguard/xiaomi/debug/jars/3/1f/main.jar

这个Transform的输出产物,会作为下一个依赖它的Transform的输入产物。当然,输入产物是根据getInputTypes方法中返回的文件类型去对应的目录拿文件的,同时如果你定义了输入文件为class文件,那么资源文件就会被过滤然后传递到下一个Transform中去(个人的猜测观点,不一定正确)。

在没有开启混淆的情况下,ProguardTransform的下一个Transform是DexTransform,我们现在来看看ProguardTransform的输入文件和输出文件,以及DexTransform的输入文件和输出文件。记得开启混淆。

 minifyEnabled true
project.afterEvaluate {
    project.android.applicationVariants.each { variant ->
        def proguardTask = project.tasks.findByName("transformClassesAndResourcesWithProguardFor${variant.name.capitalize()}")
        if (proguardTask) {
            project.logger.error "proguard=>${variant.name.capitalize()}"

            proguardTask.inputs.files.files.each { File file->
                project.logger.error "file inputs=>${file.absolutePath}"
            }

            proguardTask.outputs.files.files.each { File file->
                project.logger.error "file outputs=>${file.absolutePath}"
            }
        }

        def dexTask = project.tasks.findByName("transformClassesWithDexFor${variant.name.capitalize()}")
        if (dexTask) {
            project.logger.error "dex=>${variant.name.capitalize()}"

            dexTask.inputs.files.files.each { File file->
                project.logger.error "file inputs=>${file.absolutePath}"
            }

            dexTask.outputs.files.files.each { File file->
                project.logger.error "file outputs=>${file.absolutePath}"
            }
        }
    }
}

这里写图片描述

可以看到proguard的产物transform/proguard/qihoo/release目录变成了dex的输入文件了。

因此,我们自己向gradle plugin注册一个Transform,这个Transform注册进去后,编译成字节码后就会被执行,之后接着执行混淆的ProguardTransform,于是原来ProguardTransform的输入文件就变成了我定义的Transform的输入文件,我定义的Transform的输出文件就变成了ProguardTransform的输入文件了,就像一个链表一样,我插入了一个节点。当然,这个结果是我在测试之后得出的结论,而且这是在开启了混淆的情况下,没有开启混淆也是同样的道理,把ProguardTransform换成了DexTransform而已,我的输出产物变成了DexTransform的输入文件罢了。

现在我们注册一个Transform,在插件的apply方法最前面注册

/**
* 注册transform接口
*/
def isApp = project.plugins.hasPlugin(AppPlugin)
if (isApp) {
      def android = project.extensions.getByType(AppExtension)
      def transform = new TransformImpl(project)
      android.registerTransform(transform)
}

TransformImpl的实现暂时为空,这时候可能会报错误,姑且不去理会。

class TransformImpl extends Transform {
    Project project
    public TransformTest(Project project) {
        this.project = project
    }

    @Override
    String getName() {
        return "TransformImpl"
    }

    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS;
    }


    @Override
    Set<QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT;
    }


    @Override
    boolean isIncremental() {
        return false;
    }


    @Override
    void transform(Context context, Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException {


    }

}

这时候再看看Proguard的输入文件,很显然的看到我们的输出产物变成了Proguard的输入产物了。

这里写图片描述

那么我们的输入文件变成了什么呢。编码看看TransformImpl的输入文件变成了什么。

def testTask = project.tasks.findByName("transformClassesWithTransformImplFor${variant.name.capitalize()}")

if (testTask) {

  Set<File> testTaskInputFiles = testTask.inputs.files.files
  Set<File> testTaskOutputFiles = testTask.inputs.files.files

  project.logger.error "Name:transformClassesWithTransformImpl=====>${testTask.name} input"
  testTaskInputFiles.each { inputFile ->
      def path = inputFile.absolutePath
      project.logger.error path
  }

  project.logger.error "Name:transformClassesWithTransformImpl=====>${testTask.name} output"
  testTaskOutputFiles.each { inputFile ->
      def path = inputFile.absolutePath
      project.logger.error path
  }
}

这里写图片描述

这不就是ProguardTransform的输入文件吗,现在变成了我们的,真是偷天换柱啊。知道了这些后,我们就可以在系统的Transform之前插入我们的Transform做字节码修改,然后之后我们修改后的产物会被继续处理,最终打包成apk。

将插件的实现改为下面的代码

public class PluginImpl implements Plugin<Project> {
    public void apply(Project project) {
        /**
         * 注册transform接口
         */
        def isApp = project.plugins.hasPlugin(AppPlugin)
        if (isApp) {
            def android = project.extensions.getByType(AppExtension)
            def transform = new TransformImpl(project)
            android.registerTransform(transform)
        }
    }
}

TransformImpl的实现改成如下

class TransformImpl extends Transform {
    private final Project project
    public TransformImpl(Project project) {
        this.project = project
    }

    @Override
    String getName() {
        return "Hotpatch"
    }

    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS;
    }


    @Override
    Set<QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT;
    }



    @Override
    boolean isIncremental() {
        return false;
    }


    @Override
    void transform(Context context, Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException {
        /**
         * 遍历输入文件
         */
        inputs.each { TransformInput input ->
            /**
             * 遍历目录
             */
            input.directoryInputs.each { DirectoryInput directoryInput ->
                /**
                 * 获得产物的目录
                 */
                File dest = outputProvider.getContentLocation(directoryInput.name, directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY);
                String buildTypes = directoryInput.file.name
                String productFlavors = directoryInput.file.parentFile.name
                //这里进行我们的处理 TODO
                project.logger.error "Copying ${directoryInput.name} to ${dest.absolutePath}"
                /**
                 * 处理完后拷到目标文件
                 */
             FileUtils.copyDirectory(directoryInput.file, dest);
            }

            /**
             * 遍历jar
             */
            input.jarInputs.each { JarInput jarInput ->
                String destName = jarInput.name;
                /**
                 * 重名名输出文件,因为可能同名,会覆盖
                 */
                def hexName = DigestUtils.md5Hex(jarInput.file.absolutePath);
                if (destName.endsWith(".jar")) {
                    destName = destName.substring(0, destName.length() - 4);
                }
                /**
                 * 获得输出文件
                 */
                File dest = outputProvider.getContentLocation(destName + "_" + hexName, jarInput.contentTypes, jarInput.scopes, Format.JAR);

                //处理jar进行字节码注入处理TODO

                FileUtils.copyFile(jarInput.file, dest);
                project.logger.error "Copying ${jarInput.file.absolutePath} to ${dest.absolutePath}"
                }
            }
        }
    }
}

之后,你只需要在上面的代码的两个TODO的地方进行扩展即可,必要时在对应的地方进行初始化变量。

跟Nuwa一样,需要定义一些扩展参数

public class PluginExtension {
    HashSet<String> includePackage = []
    HashSet<String> excludeClass = []
    String oldNuwaDir

    PluginExtension(Project project) {
    }
}

之后你可以这样使用

hotpatch {
    includePackage = []
    excludeClass = []
    oldNuwaDir = "/Users/lizhangqu/AndroidStudioProjects/Hotpatch/bak/nuwa"
}

includePackage和excludeClass的定义和Nuwa是一样的,可以看到我删了一个debugOn,然后加了一个oldNuwaDir目录,其实这个oldNuwaDir在Nuwa中是通过命令行输入的,我这里直接定义在gradle中了而已,之后如果需要打补丁,加上这个变量,不需要的情况下注释掉即可。然后在PluginImpl中创建扩展

project.extensions.create("hotpatch", PluginExtension, project)

接着在TransformImpl的transform中就可以拿到这些扩展的值

def extension = project.extensions.findByName("hotpatch") as PluginExtension
includePackage = extension.includePackage
excludeClass = extension.excludeClass
oldNuwaDir = extension.oldNuwaDir

和Nuwa一样,需要定义一系列的变量及初始化一些文件夹

    private final Project project
    static HashSet<String> includePackage
    static HashSet<String> excludeClass
    static String oldNuwaDir
    private static final String NUWA_PATCHES = "nuwaPatches"
    private static final String MAPPING_TXT = "mapping.txt"
    private static final String HASH_TXT = "hash.txt"
    private static final String PATCH_FILE_NAME = "patch.jar"

变量的初始化

/**
 * 一些列变量定义
 */
String buildAndFlavor = context.path.split("transformClassesWithHotpatchFor")[1];
File nuwaDir = new File("${project.buildDir}/outputs/nuwa")
def outputDir = new File("${nuwaDir}/${buildAndFlavor}")
def destHashFile = new File(outputDir, "${HASH_TXT}")
def destMapFile = new File("${nuwaDir}/${buildAndFlavor}/${MAPPING_TXT}");
def destPatchJarFile = new File("${nuwaDir}/${buildAndFlavor}/patch/${PATCH_FILE_NAME}");
def patchDir = new File("${context.temporaryDir.getParent()}/patch/")
Map hashMap

/**
 * 创建文件
 */
NuwaFileUtils.touchFile(destHashFile.getParentFile(), destHashFile.name)
NuwaFileUtils.touchFile(destMapFile.getParentFile(), destMapFile.name)
NuwaFileUtils.touchFile(destPatchJarFile.getParentFile(), destPatchJarFile.name)

不要忘记了Nuwa中Application的子类是不能进行字节码注入的,否则一运行就会报错ClassNotFound,我们也要将Application的子类加入excludeClass

/**
 * 找到manifest文件中的application加入 excludeClass
 */
def processManifestTask = project.tasks.findByName("process${buildAndFlavor}Manifest")
def manifestFile = processManifestTask.outputs.files.files[0]
def applicationName = NuwaAndroidUtils.getApplication(manifestFile)
if (applicationName != null) {
    excludeClass.add(applicationName)
}

打补丁的时候需要进行hash校验,我们需要把上一次发版的hash文件解析出来

/**
 * 将上一次发版时的mapping文件解析成map
 */
if (oldNuwaDir) {
    def hashFile = NuwaFileUtils.getVariantFile(new File("${oldNuwaDir}"), buildAndFlavor, HASH_TXT)
    hashMap = NuwaMapUtils.parseMap(hashFile)
}   

这之后,就是字节码注入,hash校验,打补丁,拷贝mapping和hash文件的事了。我们先以目录为例。

/**
 * 遍历目录
 */
input.directoryInputs.each { DirectoryInput directoryInput ->
    /**
     * 获得产物的目录
     */
    File dest = outputProvider.getContentLocation(directoryInput.name, directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY);
    String buildTypes = directoryInput.file.name
    String productFlavors = directoryInput.file.parentFile.name
    /**
     * 遍历文件夹,进行字节码注入
     */
    traverseFolder(project, directoryInput.file, destHashFile, hashMap, buildTypes, productFlavors, patchDir)
    project.logger.error "Copying ${directoryInput.name} to ${dest.absolutePath}"
    /**
     * 处理完后拷到目标文件
     */
    FileUtils.copyDirectory(directoryInput.file, dest);
}

这里面一个关键的方法就是遍历文件夹traverseFolder()方法,下面请忽略我这种遍历方法,因为完全是用java的方式遍历的,后来发现在groovy中遍历文件夹是一件极其简单的事。。。。。知道真相的我真是无言以对。那么现在就姑且以java的方式来遍历吧。。。

/**
 * 遍历文件夹进行字节码注入
 * @param project
 * @param rootFile
 * @param destHashFile
 * @param hashMap
 * @param buildType
 * @param productFlavors
 * @param patchDir
 */
public
static void traverseFolder(Project project, File rootFile, File destHashFile, Map hashMap, String buildType, String productFlavors, File patchDir) {

    if (rootFile != null && rootFile.exists()) {
        File[] files = rootFile.listFiles();
        if (files == null || files.length == 0) {
            project.logger.warn "文件夹是空的!"
            return;
        } else {
            for (File innerFile : files) {
                if (innerFile.isDirectory()) {
                    project.logger.warn "不需要处理文件夹:${innerFile.absolutePath},进行递归"
                    traverseFolder(project, innerFile, destHashFile, hashMap, buildType, productFlavors, patchDir);

                } else {
                    if (NuwaProcessor.shouldProcessClass(innerFile.absolutePath)) {
                        if (NuwaSetUtils.isIncluded(innerFile.absolutePath, includePackage) && !NuwaSetUtils.isExcluded(innerFile.absolutePath, excludeClass)) {
                            def bytes = NuwaProcessor.processClass(innerFile);
                            def hash = DigestUtils.shaHex(bytes)
                            def classFile = innerFile.absolutePath.split("${productFlavors}/${buildType}/")[1]
                            destHashFile.append(NuwaMapUtils.format(classFile, hash))

                            if (NuwaMapUtils.notSame(hashMap, classFile, hash)) {
                                project.logger.warn "Hash值不一样,做为patch:${classFile}"
                                NuwaFileUtils.copyBytesToFile(innerFile.bytes, NuwaFileUtils.touchFile(patchDir, classFile))
                            }
                            project.logger.warn "需要处理文件:${innerFile.absolutePath}"

                        }
                    } else {
                        project.logger.warn "不需要处理文件:${innerFile.absolutePath}"
                    }
                }
            }
        }
    } else {
        project.logger.warn "文件不存在!"
    }
}

这里面的操作和Nuwa是基本一致的,只不过Nuwa hook了task,把task的输入文件拿来进行处理,这些输入文件直接是class文件的绝对路径和jar文件的绝对路径,但是这里不同,这里是一个文件夹,文件夹下面是包名,包名里面才是class文件,因此这里需要遍历文件夹拿到class文件,对class文件单独进行字节码注入,注入的过程还是一样,先判断是否需要注入,是否在includePackage并且不在excludeClass中,满足了这些条件后才会进行字节码注入操作,之后就是hash校验,将hash值写入新的文件,并且与上一次发版时的hash值进行校验,如果不一样,则复制到patch目录,后面再进行打补丁操作。

文件夹处理完了,之后就是一系列的jar了。jar的处理流程就完全和Nuwa一样了。因为输入的也是jar文件,唯一需要注意的是,jar文件输入的名字可能都是classes.jar,拷贝到目标目录的时候需要重命名一下,可以加上文件路径的md5以区分,不然拷到目标文件同名文件会被覆盖。

/**
 * 遍历jar
 */
input.jarInputs.each { JarInput jarInput ->

    proguardLibfiles.add(jarInput.file)

    String destName = jarInput.name;
    /**
     * 重名名输出文件,因为可能同名,会覆盖
     */
    def hexName = DigestUtils.md5Hex(jarInput.file.absolutePath);
    if (destName.endsWith(".jar")) {
        destName = destName.substring(0, destName.length() - 4);
    }
    /**
     * 获得输出文件
     */
    File dest = outputProvider.getContentLocation(destName + "_" + hexName, jarInput.contentTypes, jarInput.scopes, Format.JAR);

    /**
     * 处理jar进行字节码注入
     */
    if (NuwaProcessor.shouldProcessJar(jarInput.file.absolutePath)) {
        NuwaProcessor.processJar(project, destHashFile, jarInput.file, patchDir, hashMap, includePackage, excludeClass, dest)
    } else {
        FileUtils.copyFile(jarInput.file, dest);
        project.logger.error "Copying ${jarInput.file.absolutePath} to ${dest.absolutePath}"
    }
}

到了这里,还没有结束,我们需要将产物mapping文件和hash文件拷到我们的目标目录/build/outputs/nuwa下,hash文件可以不用拷贝了,因为创建的时候就是建在这个目录下的,而mapping文件是需要拷贝的,mapping文件的产生是混淆完成后输出的,因此我们需要hook混淆的task,在task完成的时候拷贝它输出的文件,这个操作我们在PluginImpl中完成。

project.extensions.create("hotpatch", PluginExtension, project)
project.afterEvaluate {
    project.android.applicationVariants.each { variant ->
        def extension = project.extensions.findByName("hotpatch") as PluginExtension
        def oldNuwaDir = new File("${extension.oldNuwaDir}")
        String variantName = variant.name
        variantName = variantName.replaceFirst(variantName.substring(0, 1), variantName.substring(0, 1).toUpperCase())

        def proguardTask = project.tasks.findByName("transformClassesAndResourcesWithProguardFor${variant.name.capitalize()}")

        Closure copyMappingClosure = {
            if (proguardTask) {
                def mapFile = new File("${project.buildDir}/outputs/mapping/${variant.dirName}/mapping.txt")
                def newMapFile = new File("${project.buildDir}/outputs/nuwa/${variantName}/mapping.txt")
                FileUtils.copyFile(mapFile, newMapFile)
            }
        }

        if (proguardTask) {
            proguardTask.doLast(copyMappingClosure)
        }
    }
}

说到混淆,我们打补丁的时候还需要应用上一次发版的mapping文件,这一步也在PluginImpl中完成,加入一个公共静态变量,这个变量在TransformImpl中会用到。

/**
 * 存对应的构建的混淆配置文件
 */
public static Map<String, List<File>> proguardConfigFile = new HashMap<String, List<File>>()

然后在doLast之后加入一段代码,用于记录这些混淆的配置文件

if (proguardTask) {

    proguardTask.doLast(copyMappingClosure)

    if (oldNuwaDir) {
        def mappingFile = NuwaFileUtils.getVariantFile(oldNuwaDir, variantName, "mapping.txt")

        ProGuardTransform transform = proguardTask.getTransform();//哈哈,这里有坑

        NuwaAndroidUtils.applymapping(transform, mappingFile)//后来想了想,这一步是不用的,为什么呢?因为我们产生的字节码后面我做了单独的混淆处理,没必要对后面系统自带的混淆应用mapping文件,但是应用了也不影响,就先留着了,但是这个方法不是Nuwa原来的方法,我做了一层修改,就是applymapping的入参是ProGuardTransform

        def files = transform.getAllConfigurationFiles()
        //获得transform的配置文件,为什么是这么获取的下面再说嘛
        proguardConfigFile.put(variantName, files)
        //记录这些混淆文件后面再使用
    }
}       

上面的那个修改过的applymapping方法如下

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

下面我们讲讲混淆的配置文件的获取。首先你肯定要先拿到这个task对不对

 def proguardTask = project.tasks.findByName("transformClassesAndResourcesWithProguardFor${variant.name.capitalize()}")

但是这个proguardTask拿到之后,并不是Transform的实现类,你打印它的类型后会发现它是一个TransformTask类,里面包装了Transform,真是神坑啊,当时为了拿到这个Transform真是煞费苦心,

public class TransformTask extends StreamBasedTask implements Context {

    private Transform transform;

    public Transform getTransform() {
        return transform;
    }

}

transform拿到了之后,就可以调用ProGuardTransform的父类的父类中的一个方法getAllConfigurationFiles()拿到所有的配置文件了,这些配置文件包含了你在build.gradle中定义的混淆配置以及aapt的混淆配置.

proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'

之后就是暂时存起来,后面TransformImple中再用。

这时候,如果需要打补丁的话,我将需要打补丁的一些class文件拷贝到临时目录中去,我们对这个目录进行dex操作即可。

/**
 * 没有混淆的步骤直接执行dex操作
 */
NuwaAndroidUtils.dex(project, patchDir)
File patchFile = new File("${patchDir.getParent()}/${PATCH_FILE_NAME}")
if (patchFile.exists()) {
    FileUtils.copyFile(patchFile, destPatchJarFile)
    FileUtils.deleteDirectory(patchDir)
    FileUtils.forceDelete(patchFile)
}

前方有坑,当然如果你的项目没有开启混淆,到这一步是完全没有什么问题的,但是一旦你开启了混淆,那么就是神坑了,为什么这么说呢,因为我们定义的Transform是在混淆的Transform之前执行的,我们拷贝出来的class是没有经过混淆的,这时候你打补丁,肯定是热修复失败的。因此我们需要判断是不是存在混淆的task,如果存在的话,我们需要手动进行混淆。混淆的时候应用我们上面记录下来的配置文件,并且还需要应用上次发版时的mapping文件来保持类与类的对应。好了,坑我都给你踩过了,直接看代码吧。。。。

/**
 * 如果需要打patch
 */
if (patchDir.exists() && patchDir.listFiles() != null && patchDir.listFiles().size() != 0) {
    /**
     * 是否混淆
     */
    def proguardTask = project.tasks.findByName("transformClassesAndResourcesWithProguardFor${buildAndFlavor}")
    if (proguardTask) {

        /**
         * 进行混淆
         */
        def mappingFile = NuwaFileUtils.getVariantFile(new File("${oldNuwaDir}"), buildAndFlavor, "mapping.txt")
        Configuration configuration = new Configuration()

        configuration.useMixedCaseClassNames = false
        configuration.programJars = new ClassPath()
        configuration.libraryJars = new ClassPath()
        /**
         * 应用mapping文件
         */
        configuration.applyMapping = mappingFile;
        configuration.verbose = true
        /**
         * 输出配置文件
         */
        configuration.printConfiguration = new File("${patchDir.getParent()}/dump.txt")

        /**
         * 不过滤没有引用的文件,这里一定要不过滤,不然有问题
         */
        configuration.shrink = false
        /**
         * android 和 apache 包的依赖
         */

        /**
         * 获得sdk目录
         */
        def sdkDir
        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")
        }
        /**
         * 将android.jar和apache的库加入依赖
         */
        if (sdkDir) {
            def compileSdkVersion = project.android.compileSdkVersion
            ClassPathEntry androidEntry = new ClassPathEntry(new File("${sdkDir}/platforms/${compileSdkVersion}/android.jar"), false);
            configuration.libraryJars.add(androidEntry)

            File apacheFile = new File("${sdkDir}/${compileSdkVersion}/platforms/optional/org.apache.http.legacy.jar")
            //android-23下才存在apache的包
            if (apacheFile.exists()) {
                ClassPathEntry apacheEntry = new ClassPathEntry(apacheFile, false);
                configuration.libraryJars.add(apacheEntry)
            }
        }

        /**
         * 将这个task的输入文件全都加入到混淆依赖的jar
         */
        if (proguardLibfiles != null) {
            ClassPathEntry jarFile = null
            for (File file : proguardLibfiles) {
                jarFile = new ClassPathEntry(file, false);
                configuration.libraryJars.add(jarFile)
            }
        }

        /**
         * 待dex未混淆的patch目录
         */
        ClassPathEntry classPathEntry = new ClassPathEntry(patchDir, false);
        configuration.programJars.add(classPathEntry)

        /**
         * 定义混淆输出文件
         */
        File proguardOutput = new File("${patchDir.getParent()}/proguard/")
        ClassPathEntry classPathEntryOut = new ClassPathEntry(proguardOutput, true);//第二个参数true代表是输出文件
        configuration.programJars.add(classPathEntryOut)

        /**
         * 外部定义的混淆文件的获取并应用
         */
        project.logger.error buildAndFlavor

        def file = PluginImpl.proguardConfigFile.get(buildAndFlavor);
        //这里就用到了上面一步记录下来的配置文件
        //遍历并应用
        file.each {
            project.logger.error "proguard配置文件应用==>${it.absolutePath}"
            ConfigurationParser proguardConfig = new ConfigurationParser(it, System.getProperties());
            try {
                proguardConfig.parse(configuration);
            } finally {
                proguardConfig.close();
            }
        }

        /**
         * 执行混淆
         */
        ProGuard proguard = new ProGuard(configuration)
        proguard.execute()

        /**
         * 对产物执行dex操作,并删除临时文件
         */
        if (proguardOutput.exists()) {
            NuwaAndroidUtils.dex(project, proguardOutput)
            File patchFile = new File("${proguardOutput.getParent()}/${PATCH_FILE_NAME}")
            if (patchFile.exists()) {
                FileUtils.copyFile(patchFile, destPatchJarFile)
                FileUtils.deleteDirectory(proguardOutput)
                FileUtils.forceDelete(patchFile)
            }

            FileUtils.deleteDirectory(patchDir)
        }
    } else {
        /**
         * 没有混淆的步骤直接执行dex操作
         */
        NuwaAndroidUtils.dex(project, patchDir)
        File patchFile = new File("${patchDir.getParent()}/${PATCH_FILE_NAME}")
        if (patchFile.exists()) {
            FileUtils.copyFile(patchFile, destPatchJarFile)
            FileUtils.deleteDirectory(patchDir)
            FileUtils.forceDelete(patchFile)
        }
    }
}

上面的代码关键的一点就是我们需要将我们混淆的代码加入到configuration.programJars中去,我们混淆的依赖代码加入到configuration.libraryJars中去,而我们依赖的代码就是我们的transform的输入文件,我们需要将这些输入文件一一保存起来,这样我们混淆的时候才能拿到。我们只需在遍历输入文件的时候加入到一个变量中即可。

/**
* 定义混淆时需要依赖的库
*/
List<File> proguardLibfiles = new ArrayList<>();

/**
 * 遍历输入文件
 */
inputs.each { TransformInput input ->
    /**
     * 遍历目录
     */
    input.directoryInputs.each { DirectoryInput directoryInput ->

        /**
         * 加入到混淆时的依赖
         */
        proguardLibfiles.add(directoryInput.file)
        //其他处理
    }

    /**
     * 遍历jar
     */
    input.jarInputs.each { JarInput jarInput ->

        proguardLibfiles.add(jarInput.file)

        //其他处理
    }
}

别问我上面的混淆的代码是怎么来的,我不会告诉你的,自己看gradle的源码实现去吧。

代码就差不多是这样了,如何打补丁呢?打补丁的过程没有像Nuwa那样麻烦,你只需要正常的进行发版,这时候在build/outputs/nuwa目录下就会有mapping(混淆存在的情况下)和hash文件的产物,你需要将这个nuwa目录拷贝到一个地方保持起来后续打补丁时使用,这一步和Nuwa是没有差别的。接着打补丁的时候,你需要在gradle中定义你保存的上一次发版时留下来的文件的绝对路径,就像这样子.

oldNuwaDir = "/Users/lizhangqu/AndroidStudioProjects/Hotpatch/bak/nuwa"

之后怎么做呢,修改了bug之后还是正常的执行gradle clean assemble。然后就会在build/outputs/nuwa/buildTypeAndFlavors/patch目录下产生patch.jar文件,这个文件就是补丁文件了,下发到客户端就可以打补丁了,就是这么简单,有木有。

最后说一句

Nuwa有坑!!!

Nuwa有坑!!!

Nuwa有坑!!!

重要的事当然要说三遍了。

源码下载
木有源码下载,源码都在上面了,自行组织吧,gradle这东西,只有自己踩过坑之后才会有所成长

另外附上另一种gradle plugin 1.5下的Nuwa解决方法,你完全可以hook DexTransform这个task,将它的输入文件拿到做处理。主要就是提醒思维不要被我的文章所局限。这种hook的实现方式github上,这个地址可以有 https://github.com/Livyli/AndHotFix

版权声明:本文为博主原创文章,未经博主允许不得转载。

构造多dex的apk

步骤 1. 在IntelliJ IDEA中创建一个Android项目。 2. 修改AndroidManifest.xml文件,在``标签中添加"android:name="android.suppor...
  • zylc369
  • zylc369
  • 2015年03月25日 10:24
  • 7177

Android热修复技术选型——三大流派解析

原文来源:阿里百川 2015年以来,Android开发领域里对热修复技术的讨论和分享越来越多,同时也出现了一些不同的解决方案,如QQ空间补丁方案、阿里AndFix以 及微信Tinke...

Android热修复 — Nuwa Gradle 插件核心源码分析

简介  Nuwa是比较流行的一种Android热补丁方案的开源实现,它的特点是成功率高,实现简单。当然,热补丁的方案目前已经有很多了,AndFix, Dexposed, Tinker等,之所以要分析N...

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

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

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

转自 http://blog.csdn.net/sbsujjbcy/article/details/50812674 现在,热修复的具体实现方案开源的也有很多,原理也大同小异,...

Android Nuwa 热修复原理和的gradle插件详解并怎么修改gradle插件

Android Nuwa 热修复原理和的gradle插件详解并怎么修改gradle插件最近项目要用到热修复(Hotfix),就调研了一些热修复方案,最后选择了Nuwa,但是因为Nuwa不在维护了,就去...

使用Nuwa实现Android热修复

  • 2016年07月22日 16:12
  • 9.51MB
  • 下载

Android热修复-Nuwa使用

一、在project下的build.gradle中添加classpath,需要注意修改项目的gradle版本,改成“com.Android.tools.build:gradle:1.2.3”,因为版本...

(4.2.32.1)android热修复之ClassLoader方式:安卓App热补丁动态修复技术介绍(QQ控件||Nuwa)

1.背景 当一个App发布之后,突然发现了一个严重bug需要进行紧急修复,这时候公司各方就会忙得焦头烂额:重新打包App、测试、向各个应用市场和渠道换包、提示用户升级、用户下载、覆盖安装。有时候仅仅...
内容举报
返回顶部
收藏助手
不良信息举报
您举报文章:Android 热修复使用Gradle Plugin1.5改造Nuwa插件
举报原因:
原因补充:

(最多只允许输入30个字)