zoukankan      html  css  js  c++  java
  • 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

  • 相关阅读:
    登陆SharePoint站点出现service unavailable----Http错误503
    SharePoint 配置站点导航栏顶级菜单栏的下拉菜单
    修改SharePoint列表中“添加新项目”链接
    大数据应用中如何进行技术选型?
    leetcode的注意点
    架构中的权衡
    数学与计算机
    论一个技术管理者的(工作中)无中生有的能力
    通过日志分析程序的问题的原因
    后续使用dubbo的必要性的思考
  • 原文地址:https://www.cnblogs.com/mthoutai/p/7257412.html
Copyright © 2011-2022 走看看