从精准化测试看ASM在Android中的强势插入-Gradle插件

1,620 阅读8分钟

Gradle Plugin是我们在编译期修改代码的重要武器,也是我们精准化测试的核心组成部分。

官网镇楼:

docs.gradle.org/current/use…

developer.android.com/studio/buil…

Gradle Plugin有三种存在形式:

  • 在构建脚本中:直接写在项目当前的build.gradle中
  • buildSrc:项目根目录下的buildSrc文件夹,是一个系统保留目录,可以直接运行插件代码而不用引用插件包
  • 独立项目:类似module,单独编译成jar使用

创建

Gradle中自带了创建模板项目的方法——gradle init,通过这个指令,可以引导我们创建一个完整的插件项目。

在相应的目录下执行该指令,如下所示。

  qdplugin /Users/xuyisheng/Downloads/gradle-6.5/bin/gradle init

Select type of project to generate:
  1: basic
  2: application
  3: library
  4: Gradle plugin
Enter selection (default: basic) [1..4] 4

Select implementation language:
  1: Groovy
  2: Java
  3: Kotlin
Enter selection (default: Java) [1..3] 1

Select build script DSL:
  1: Groovy
  2: Kotlin
Enter selection (default: Groovy) [1..2] 1

Project name (default: qdplugin):
Source package (default: qdplugin):

> Task :init
Get more help with your project: https://guides.gradle.org?q=Plugin%20Development

BUILD SUCCESSFUL in 12s
2 actionable tasks: 2 executed

Gradle会自动帮我们创建好所需要的文件。

新版本的Gradle插件结构已经和之前的不太一样了,新版本的Gradle插件不再需要resources目录来申明插件的入口meta-info文件,而是直接写在了build.gradle里面,类似这样。

gradlePlugin {
    // Define the plugin
    plugins {
        coverage {
            id = 'com.yw.coverage'
            implementationClass = 'com.yw.coverage.Coverage_pluginPlugin'
        }
    }
}

为了避免在编译时遇到一些奇奇怪怪的问题,这里建议大家增加指定Java8编译的指令。

sourceCompatibility = 1.8
targetCompatibility = 1.8

发布

Gradle Plugin的前两种使用方式,都不用发布插件,可以直接使用,但大部分情况下,一般先在项目根目录下创建buildSrc目录,再通过gradle init生成插件需要的文件,这样开发完后,再迁移到单独项目。

在buildSrc中,不用每次publish到App,可以直接参与编译,调试比较方便,但是等插件稳定后,通过独立的插件项目,可以让插件的集成和管理更加方便。

一般来说,我们会使用本地Maven库来调试插件,借助Gradle的maven-publish插件,我们可以和方便的发布插件到本地Maven库。

首先,引入插件:

plugins {
    id 'java-gradle-plugin'
    id 'java'
    id 'maven-publish'
    id 'groovy'
    id 'maven'
}

使用MavenLocal,编译后publish的插件位于:/Users/用户名/.m2/repository目录下。

继续创建发布脚本,代码如下:

publishing {
    publications {
        coverage(MavenPublication) {
            groupId = 'com.yw.coverage'
            artifactId = 'coverage'
            version = '0.0.1'
            from components.java
        }
    }
}

其中:

  • coverage:是task name可以随意指定
  • groupId、artifactId和version:这3个东西组成了引用的id,在根目录的build.gradle中使用。

独立的插件项目,需要执行publish task,在Gradle标签卡中找到publishCoveragePublicationToMavenLocal这样一个Task,发布插件到MavenLocal,编译成功即可使用。

使用

在使用插件的项目根目录Gradle文件中,指定访问mavenLocal,同时,使用groupId、artifactId和version组成对插件的引用,如下所示。

buildscript {
    ext.kotlin_version = "1.4.21"
    repositories {
        google()
        mavenCentral()
        mavenLocal()
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:4.1.1'
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
        classpath "com.yw.coverage:coverage:0.0.1"
        // NOTE: Do not place your application dependencies here; they belong
        // in the individual module build.gradle files
    }
}

在主module中,引用插件,代码如下。

plugins {
    id 'com.android.application'
    id 'kotlin-android'
    id 'com.yw.coverage'
}

这里的id,就是插件的plugin配置中定义的id。

gradlePlugin {
    // Define the plugin
    plugins {
        coverage {
            id = 'com.yw.coverage'
            implementationClass = 'com.yw.coverage.Coverage_pluginPlugin'
        }
    }
}

sync项目后,在task中就可以找到新建的task,这个task是在代码中注册的(默认生成的代码)。

public class Coverage_pluginPlugin implements Plugin<Project> {
    public void apply(Project project) {
        // Register a task
        project.tasks.register("coverage") {
            doLast {
                println("Hello from plugin 'com.yw.coverage'")
            }
        }
    }
}

其实很简单就创建了一个能用的Gradle插件,插件的入口就是implementationClass中申明的类,implements Plugin并实现apply方法即可。

兼容

Gradle虽然好用,但是API的变化非常频繁,而且兼容性做的不是很好,所以大家经常在网上搜到的一些脚本,可能在你的环境下就无法执行,所以,通过官方文档查看最新的使用手册,才是最稳的方式。

Transform

Transform才是Gradle Plugin的核心。

Transform是Gradle Plugin提供的在编译过程中对class做dex打包之前的一个处理流水线。官方有很多任务,也是基于Transform实现的,自定义Gradle Plugin,配合Transform做代码的修改,是对编译过程进行干预的一般方法。

一个最简单的Transform如下所示。

package com.yw.coverage

import com.android.build.api.transform.QualifiedContent
import com.android.build.api.transform.Transform
import com.android.build.api.transform.TransformException
import com.android.build.api.transform.TransformInvocation
import com.android.build.gradle.internal.pipeline.TransformManager
import org.gradle.api.Project

public class CoverageInjectTransform extends Transform {
    private Project project

    CoverageInjectTransform(Project project) {
        this.project = project
    }

    // 申明Transform的Name,也就是Transform的TaskName,例如当返回值为CoverageInjectTransform时,
    // Sync后可以看到名为transformClassesWith[Name]ForDebug的task
    @Override
    String getName() {
        return "Coverage"
    }

    // 指定Transform处理的输入类型,通常针对Class类型
    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS
    }

    // 指定Transform输入文件所属的范围,通常指定为全部工程
    @Override
    Set<? super QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT
    }

    // 是否支持增量编译
    @Override
    boolean isIncremental() {
        return true
    }

    // 具体的执行逻辑
    @Override
    void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        super.transform(transformInvocation)
        println("------------------------")
    }
}

但是要注意的是,上面的Transform实际上是无法执行的,因为前面我们说了,Transform是一个处理流水线,每个Transform都是一个Gradle Task,编译器中的TaskManager将每个Transform串联起来,Transform会拿到上一个Transform编译后的class文件,以及jar和aar资源、和asset目录下的资源文件作为自己的输入,同时做好处理后,也需要将这些内容作为自己的输出内容输出给下一个Transform,直到所有的Transform执行完毕。

用户自定义的Transform,会比系统的Transform先执行

Transform有两种,即「消费型Transform」和「引用型Transform」。

  • 消费型Transform:这种Transform需要将每个jar、aar和class中间产物复制到Transform dest目录。这个目录实际上就是下一个Transform的输入目录。在复制中间产物的过程中,就是我们对产物进行修改的时机。我们可以对jar、aar、class文件的字节码做一些修改,再交给下一个Transform

  • 引用型Transform:当前Transform可以读取这些输入,但是不能修改,也不需要输出中间产物给下一个Transform

所以说,消费型Transform必须将输入的中间产物输出到下一个Transform,否则就无法继续编译了。

在Transform中有几个重要的概念:

  • TransformInput:所谓Transform就是对输入的class文件转变成目标字节码文件,TransformInput就是这些输入文件的抽象。目前它包括两部分:DirectoryInput集合与JarInput集合。

  • DirectoryInput:它代表着以源码方式参与项目编译的所有目录结构及其目录下的源码文件,可以借助于它来修改输出文件的目录结构、目标字节码文件。

  • JarInput:它代表着以jar包方式参与项目编译的所有本地jar包或远程jar包,可以借助于它来实现动态添加jar包操作。

  • TransformOutputProvider:它代表的是Transform的输出,例如可以通过它来获取输出路径。

对于TransformInput来说,Gradle通过下面两个维度来控制输入的文件。

  • Scope:过滤的是输入文件的来源
  • ContentType:过滤的是输入文件类型

Scope的取值有下面这些:

EXTERNAL_LIBRARIES:只有外部库
PROJECT:只有项目内容
PROJECT_LOCAL_DEPS:只有项目的本地依赖(本地jar)
PROVIDED_ONLY:只提供本地或远程依赖项
SUB_PROJECTS:只有子项目
SUB_PROJECTS_LOCAL_DEPS:只有子项目的本地依赖项(本地jar)
TESTED_CODE:由当前变量(包括依赖项)测试的代码

ContentType的取值有下面这些:

CONTENT_CLASS:class类型
CONTENT_JARS:jar
CONTENT_RESOURCES:asset
CONTENT_NATIVE_LIBS:native

任何消费型的Transform,都可以通过Gradle的API来获取输出目录,将中间产物Copy到输出目录:

// class
def outputDirFile = transformInvocation.outputProvider.getContentLocation(
    directoryInput.name, directoryInput.contentTypes, directoryInput.scopes,
    Format.DIRECTORY
)
FileUtils.copyDirectory(directoryInput.getFile(), outputDirFile);

// jar            
def outputFile = transformInvocation.outputProvider.getContentLocation(
    jarInput.name, jarInput.contentTypes, jarInput.scopes,
    Format.JAR
)
FileUtils.copyFile(jarInput.getFile(), outputFile);

所有自定义的Transform中间产物,都会在build/intermediates/transforms下找到(Kotlin文件在build/tmp/kotlin-classes目录下),你可以查看这些中间产物是否符合了自己的预期。

注册

Transform需要在Plugin中进行注册才能生效,注册的方式有两种,如下所示。

//注册方式1        
AppExtension appExtension = project.extensions.getByType(AppExtension)        appExtension.registerTransform(new MethodTimeTransform())         
//注册方式2        
//project.android.registerTransform(new MethodTimeTransform())

Kotlin化

Gradle插件经历了Java、Grovvy的版本变迁,迎来了全面Kotlin化的新浪潮,新版本的官方Gradle插件,都已经全部使用Kotlin来编写,借助Kotlin,我们可以很方便的统一代码编写环境,借助不输于Grovvy的语法糖,可以很方便的来写Gradle Plugin。

在Gradle中使用Gradle需要对原有脚本做一些改造,首先,要将build.gradle脚本改为buld.gradle.kts,然后将Kotlin代码放到src/man/kotlin目录下,最后,脚本中的代码也要做相应的更新,kts脚本如下所示。

plugins {
    `java-gradle-plugin`
    id("org.jetbrains.kotlin.jvm") version "1.3.72"
}

repositories {
    mavenCentral()
    google()
    jcenter()
}

dependencies {
    implementation(platform("org.jetbrains.kotlin:kotlin-bom"))
    implementation("com.android.tools.build:gradle:4.1.1")
    implementation("org.ow2.asm:asm:9.1")
}

gradlePlugin {
    val greeting by plugins.creating {
        id = "asmtest"
        implementationClass = "com.yw.asm.MyPlugin"
    }
}

java.sourceCompatibility = JavaVersion.VERSION_1_8
java.targetCompatibility = JavaVersion.VERSION_1_8

更简单一点,通过gradle init生成Kotlin版本的插件默认代码,Copy过去即可。

Gradle插件是我们后续做字节码修改的基础,一定要熟练掌握插件的开发和调试,这样才能避免后续在开发字节码插件的时候遇到各种插件问题而不能专心于字节码开发。

向大家推荐下我的网站 xuyisheng.top/ 专注 Android-Kotlin-Flutter 欢迎大家访问