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

随着谷歌的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呢,非常easy,实现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文件,这个文件就是补丁文件了,下发到client就能够打补丁了。就是这么简单,有木有。

最后说一句

Nuwa有坑!

!!

Nuwa有坑。!!

Nuwa有坑!

!!

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

源代码下载

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

另外附上还有一种gradle plugin 1.5下的Nuwa解决方法。你全然能够hook DexTransform这个task。将它的输入文件拿到做处理。

主要就是提醒思维不要被我的文章所局限。

这样的hook的实现方式github上,这个地址能够有 https://github.com/Livyli/AndHotFix

时间: 2024-10-11 22:56:36

Android 热修复使用Gradle Plugin1.5改造Nuwa插件的相关文章

聊聊Android 热修复Nuwa有哪些坑

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

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热修复:Andfix和Hotfix,两种方案的比较与实现

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

Android 热修复方案分析

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

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

在上一篇博客Android热修复学习之旅开篇--热修复概述中,简单介绍了各个热修复框架的原理,本篇博客我将详细分析QQ空间热修复方案. Android dex分包原理介绍 QQ空间热修复方案基于Android dex分包基础之上,简单概述android dex分包的原理就是:就是把多个dex文件塞入到app的classloader之中,但是android dex拆包方案中的类是没有重复的,如果classes.dex和classes1.dex中有重复的类,当classes.dex和classes1

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

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

Android热修复之微信Tinker使用初探

文章地址:Android热修复之微信Tinker使用初探 前几天,万众期待的微信团队的Android热修复框架tinker终于在GitHub上开源了. 地址:https://github.com/Tencent/tinker 官方介绍:https://my.oschina.net/shwenzhang/blog/751618 接入指南:https://github.com/Tencent/tinker/wiki/Tinker-%E6%8E%A5%E5%85%A5%E6%8C%87%E5%8D%9

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相关算法.本篇文章主要目的就是分析该算