Gradle系列一 -- Groovy、Gradle和自定义Gradle插件

1. 概述

Android项目的构建过程是由Gradle插件完成的,Gradle 插件是在Gradle框架的基础上实现的,Gradle框架是使用Groovy语言实现的。因此学习一下Groovy语言的一些常用语法是有必要的。

Gradle插件源码下载:
gradle_3.0.0

2. Groovy语法

Groovy语言对Java语言的进行了拓展,它提供了更简单、更灵活的语法,可以在运行时动态地进行类型检查;因此Java语言语法都适用于Groovy语言。

关于Groovy语法可以参考精通 Groovy,我这里就不再讲解了。

3. 配置Gradle

3.1 安装Gradle

对于安装gradle,大家可以参考官方文档:
gradle Installation

3.2 Android 项目中配置Gradle

Android studio中的android 项目具体用什么版本的gradle,可以在android项目的根目录下的gradle/wraaper/gradle-wrapper.properties文件中进行配置:

#Tue Nov 03 16:49:32 CST 2015
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-4.1-all.zip

上面最后一行配置该android项目中使用的gradle版本为4.1

注意:gradle与com.android.tools.build:gradle插件库之间版本的对应关系


上图是官网Android Plugin for Gradle Release Notes给出的。

4. Gradle

4.1 概述

Gradle中的所有内容都基于两个基本概念:project和task

Project
这个接口是build file与Gradle交互的主要API。 通过Project接口可以访问所有Gradle的功能。

Project的生命周期

Task
一个Project本质上是Task对象的集合。 每个Task都执行一些基本的工作,比如编译类,运行单元测试,或者压缩WAR文件。 可以使用TaskContainer上的某个create()方法(如TaskContainer.create(java.lang.String))将任务添加到Project中,可以使用TaskContainer上的某个查找方法(如TaskCollection.getByName(java.lang.String))查找现有Task。

4.2 build的生命周期

在Gradle中,你可以定义task和task之间的依赖关系。 Gradle保证这些task按照它们的依赖关系执行,并且每个task只执行一次,这些task形成有向无环图。 Gradle在执行任何任务之前完成了对完整的依赖关系图的构建,这是Gradle的核心,使许多事情成为可能,否则将是不可能的。

Gradle build包含三个的阶段:

  1. Initialization
    Gradle支持单个和多个Project的build。 在初始化阶段,Gradle确定哪些Project将参与build,并为每个Project创建一个Project实例。
    除了build script文件外,Gradle还定义了一个settings文件,settings文件在初始化阶段执行。 多Project buiid必须在多Project层次结构的根Project中具有settings.gradle文件。 这是必需的,因为settings文件定义了哪些Project正在参与多Project构建。 对于单Project build,settings文件是可选的。
    对于build script,属性访问和方法调用被委托给一个Project对象。 同样,settings文件中的属性访问和方法调用被委托给settings对象。
  2. Configuration
    在这个阶段,通过Project对应的构建脚本(比如Android项目的build.gradle文件)的执行来配置该Project对象,Task形成的有向无环图就是在这个阶段被创建。
  3. Execution
    首先确定在配置阶段创建和配置的Task的子集,以便执行, 该子集由传递给gradle命令的Task名称和参数和当前目录确定。 Gradle然后执行集合中的Task。

build script执行过程的监听
build script可以在build script执行过程中收到通知。 这些通知通常采用两种形式:实现特定的监听接口,或者在触发通知时提供一个闭包去执行。 下面以Android项目HotFix(该项目包含app、patchClassPlugin、hackdex 3个module)为例并且使用闭包的方式处理通知:

  1. 在Project执行前后(即Project对应的build script执行前后)立即收到通知。 这可以用来执行一些事情,例如在build script执行后执行额外的配置、打印自定义日志或者分析:
allprojects {
    afterEvaluate { project ->
        println "Adding smile task to $project"
        project.task('smile') {
            doLast {
                println "Running smile task for $project"
            }
        }
    }
}

上面的代码被添加在Android项目HotFix的根目录的build.gradle中的。
运行结果:

chenyangdeMacBook-Pro:HotFix chenyang$ ./gradlew smile
> Configure project :
Adding smile task to root project 'HotFix'
> Configure project :app
Adding smile task to project ':app'
> Configure project :hackdex
Adding smile task to project ':hackdex'
> Configure project :patchClassPlugin
Adding smile task to project ':patchClassPlugin'
> Task :smile
Running smile task for root project 'HotFix'
> Task :app:smile
Running smile task for project ':app'
> Task :hackdex:smile
Running smile task for project ':hackdex'
> Task :patchClassPlugin:smile
Running smile task for project ':patchClassPlugin'

此示例使用Project.afterEvaluate方法添加一个闭包,当这个Project的build.gradle已经被执行后该闭包立即会被调用。 如果想在指定的Project上添加smile任务,则可以直接在Project对应的build.gradle中添加如此代码:

afterEvaluate { project ->
    println "Adding smile task to $project"
    project.task('smile') {
        doLast {
            println "Running smile task for $project"
        }
    }
}

除了上面提供的方式,还可以使用下面这种方式:

gradle.afterProject {project, projectState ->
    if (projectState.failure) {
        println "Evaluation of $project FAILED"
    } else {
        println "Evaluation of $project succeeded"
    }
}

上面的代码被添加在Android项目HotFix的子项目app根目录的build.gradle中的,运行结果:

chenyangdeMacBook-Pro:HotFix chenyang$ ./gradlew clean

> Configure project :app
Evaluation of project ':app' succeeded

> Configure project :hackdex
Evaluation of project ':hackdex' succeeded

> Configure project :patchClassPlugin
Evaluation of project ':patchClassPlugin' succeeded
  1. 将Task添加到Project后可以立即收到通知。 这可以用来设置一些默认值或者在build文件中的Task可用之前添加行为。
    以下示例在添加每个Task后设置srcDir属性。
tasks.whenTaskAdded { task ->
    task.ext.srcDir = 'src/main/java'
}

task a

println "source dir is $a.srcDir"

运行结果:

chenyangdeMacBook-Pro:HotFix chenyang$ ./gradlew a

> Configure project :app
source dir is src/main/java
  1. Task有向无环图构建完成后可以立即收到通知
    举例如下:
gradle.taskGraph.whenReady {
    println "task graph build completed"
}
  1. 可以在执行任何Task之前和之后立即收到通知
    以下示例记录每个Task执行的开始和结束。 请注意,无论Task是成功完成还是失败并发生异常,都会收到afterTask通知:
task ok

task broken(dependsOn: ok) {
    doLast {
        throw new RuntimeException('broken')
    }
}

gradle.taskGraph.beforeTask { Task task ->
    println "executing $task ..."
}

gradle.taskGraph.afterTask { Task task, TaskState state ->
    if (state.failure) {
        println "FAILED"
    }
    else {
        println "done"
    }
}

运行结果:

chenyangdeMacBook-Pro:HotFix chenyang$ ./gradlew broken

> Task :app:ok
executing task ':app:ok' ...
done

> Task :app:broken
executing task ':app:broken' ...
FAILED
  1. build完成后可以立即接到通知
    监听方法如下:
gradle.buildFinished {result ->
    println "buildResult = $result.failure"
}

运行结果:

chenyangdeMacBook-Pro:HotFix chenyang$ ./gradlew clean

BUILD SUCCESSFUL in 2s
4 actionable tasks: 3 executed, 1 up-to-date
buildResult = Build

上面讲解了Gradle build的生命周期和对生命周期中重要节点的监听,下面通过一张图来概括一下:


对于Android项目,在Configuration阶段会解析Android项目根目录下的build.gradle文件:

// Top-level build file where you can add configuration options common to all sub-projects/modules.

buildscript {

    repositories {
        google()
        jcenter()
    }

    dependencies {
        classpath 'com.android.tools.build:gradle:3.0.0'

        // NOTE: Do not place your application dependencies here; they belong
        // in the individual module build.gradle files
    }
}

allprojects {
    repositories {
        google()
        jcenter()
    }
}

task clean(type: Delete) {
    delete rootProject.buildDir
}

上面的classpath 'com.android.tools.build:gradle:3.0.0'就是用来导入用于构建Android项目的Gradle 插件库的,classpath后面的参数由三部分组成,下面就来看一下该插件库的构建脚本:


看到上面红框的内容,相信大家应该明白了classpath后面三部分的由来。

导入了用来构建Android项目的Gradle 插件库后,下一步就是在Android项目的子module的build.gradle中应用该插件库中的插件:

apply plugin: 'com.android.application'

上面的这句话大家应该非常熟悉,这句话会执行com.android.application插件的apply方法,从而创建build Android项目的所需要的Gradle Task,具体是怎么创建的,在下面讲解Transform API时会详细说明。

4.3 Android项目的build过程

build Android 项目也会经历上面的3个阶段,而build Android 项目是通过执行一个Task完成的:

// 这个命令会执行assembleFreeWandoujiaRelease Task,
// FreeWandoujiaRelease代表[build variant](https://developer.android.google.cn/studio/build/build-variants.html)
./gradlew app:aFreeWandoujiaR

执行Task也会经历上面的三个阶段,在Android项目构建过程中,通常需要在某个Task执行开始或者结束时hook指定操作,上面的Task是由一个Task集合组成,为了看到所以Task执行的顺序,我就在该module的build.gradle的文件中加了如下代码:

gradle.taskGraph.beforeTask { Task task ->
    println "executing:  $task.name"
}

上面的作用就是在任务执行之前打印任务的名称,下面就来看看assembleFreeWandoujiaRelease Task和其所依赖的Task的执行顺序:

chenyangdeMacBook-Pro:HotFix chenyang$ ./gradlew app:aFreeWandoujiaR | grep executing
executing:  preBuild
executing:  extractProguardFiles
executing:  preFreeWandoujiaReleaseBuild
executing:  compileFreeWandoujiaReleaseAidl
executing:  compileFreeWandoujiaReleaseRenderscript
executing:  checkFreeWandoujiaReleaseManifest
executing:  generateFreeWandoujiaReleaseBuildConfig
executing:  prepareLintJar
executing:  generateFreeWandoujiaReleaseResValues
executing:  generateFreeWandoujiaReleaseResources
executing:  mergeFreeWandoujiaReleaseResources
executing:  createFreeWandoujiaReleaseCompatibleScreenManifests
executing:  processFreeWandoujiaReleaseManifest
executing:  splitsDiscoveryTaskFreeWandoujiaRelease
executing:  processFreeWandoujiaReleaseResources
executing:  generateFreeWandoujiaReleaseSources
executing:  javaPreCompileFreeWandoujiaRelease
executing:  compileFreeWandoujiaReleaseJavaWithJavac
executing:  compileFreeWandoujiaReleaseNdk
executing:  compileFreeWandoujiaReleaseSources
executing:  mergeFreeWandoujiaReleaseShaders
executing:  compileFreeWandoujiaReleaseShaders
executing:  generateFreeWandoujiaReleaseAssets
executing:  mergeFreeWandoujiaReleaseAssets
executing:  processFreeWandoujiaReleaseJavaRes
executing:  transformResourcesWithMergeJavaResForFreeWandoujiaRelease
executing:  transformClassesAndResourcesWithProguardForFreeWandoujiaRelease
executing:  transformClassesWithDexForFreeWandoujiaRelease
executing:  mergeFreeWandoujiaReleaseJniLibFolders
executing:  transformNativeLibsWithMergeJniLibsForFreeWandoujiaRelease
executing:  transformNativeLibsWithStripDebugSymbolForFreeWandoujiaRelease
executing:  packageFreeWandoujiaRelease
executing:  lintVitalFreeWandoujiaRelease
executing:  assembleFreeWandoujiaRelease
chenyangdeMacBook-Pro:HotFix chenyang$ 

可以看到Android 项目的一次构建过程执行了很多Task,下面解释一些比较关键的Task:
• mergeFreeWandoujiaReleaseResources -- 收集所有的resources
• processFreeWandoujiaReleaseManifest -- 生成最终的AndroidManif.xml文件
• compileFreeWandoujiaReleaseJavaWithJavac -- 编译Java文件
• mergeFreeWandoujiaReleaseAssets -- 收集所有的assets
• transformClassesAndResourcesWithProguardForFreeWandoujiaRelease -- 混淆
• transformClassesWithDexForFreeWandoujiaRelease -- 生成dex
• packageFreeWandoujiaRelease -- 打包生成apk
知道了build Android 项目被执行的Task,接下来的hook按照上一节的讲解做就可以了。

大家可以通过Android Studio右侧的gradle窗口来查看Android 项目中所有的Gradle Task,如下图所示:


所有的Gradle Task被分组列举,看起来更清晰,而且可以通过双击某个Gradle Task来运行该Gradle Task,是不是很爽。

5. 自定义Gradle插件

为了讲解自定义Gradle插件,那我就提出一个问题,然后通过自定义插件的形式解决它。

问题:如何将指定代码注入到class文件的构造方法中?
解决方案:
1 > 通过hook compileFreeWandoujiaReleaseJavaWithJavac解决。
2 > 通过Google专门提供了Transform API来解决。
既然Google专门提供Transform API,那么下面就使用Transform API解决问题,首先来看下Google对Transform API的解释:

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 http://google.github.io/android-gradle-dsl/javadoc/.

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.0-beta1开始,Gradle插件包含了一个Transform API,该API允许第三方插件在已编译的class文件被转换为dex文件之前处理已编译的class文件。
这个API的目标就是简化自定义class文件的操作而不用对Task进行处理,并提供更多的操作灵活性。在1.5.0-beta1版本中,内部代码处理(jacoco,progard,multi-dex)已经全部转移到这个新机制中。为了将Transform插入到构建过程中,只需创建一个继承Transform抽象类的新类,然后使用android.registerTransform(theTransform)或android.registerTransform(theTransform,dependencies)进行注册。

Gradle提供了强大的自定义插件的功能,官方讲解文档
Gradle 一共提供了三种方式创建自定义插件:

  1. 直接在build脚本中包含插件的源代码
    这样做的好处是插件可以自动编译并包含在build脚本的classpath中,而无需执行任何操作。 但是插件在build脚本之外是不可见的,所以就不能在build脚本之外重用插件。
  2. 在buildSrc project中创建自定义插件
    你可以把插件的源代码放在rootProjectDir / buildSrc / src / main / groovy目录下。 Gradle将负责编译和测试插件,并使其在build脚本的classpath中可用。 该插件对build使用的每个build脚本都是可见的。 但是在build之外是不可见的,所以不能在build之外重用该插件。
  3. 在独立的project(指Android项目中的子module)中创建自定义插件
    你可以为你的插件创建一个单独的project。 该project生成并发布一个JAR,然后您可以在多个build中使用JAR并与他人共享。 通常该JAR可能包含一些插件,或将几个相关的Task类捆绑到一个库中,或两者的一些组合。

前两种方式大家可以参考官方讲解文档,我直接使用第三种方式进行举例说明:

1> 首先创建一个名称为injectClassPlugin的子module,然后将该module中(除了build.gradle文件)的文件全部删除,接着按照下图的目录创建:



大家按照上面injectClassPlugin的目录结构(Gradle插件所需要的目录结构)创建就行。

2> 完成了目录结构的创建后,接下来来看看该插件的build.gradle的内容(如上图所示),非常的简单,没什么可说的;现在我们继承Plugin类,实现自定义插件的第一步:

class InjectClassPlugin implements Plugin<Project> {

    @Override
    void apply(Project project) {

        //AppExtension对应build.gradle中android{...}
        def android = project.extensions.getByType(AppExtension)
        //注册一个Transform
        def classTransform = new InjectClassTransform(project)
        android.registerTransform(classTransform)

        // 通过Extension的方式传递将要被注入的自定义代码
        def extension = project.extensions.create("InjectClassCode", InjectClassExtension)
        project.afterEvaluate {
            classTransform.injectCode = extension.injectCode
        }
    }
}

上面的代码很简单,主要完成两个事情:

1 注册InjectClassTransform
上面Google对Transform API的解释提到了注册Transform的方式,即调用
android.registerTransform(theTransform)方法,其中android是AppExtension类型,
并且是通过build.gradle中android{...}配置的,那么上面注册Transform的代码就很好理解了。

2 通过Extension的方式传递将要被注入的自定义代码
Gradle脚本中通过Extension传递一些配置参数给自定义插件,在这个例子中通过InjectClassExtension对象传递要注入的代码。
首先在extensions容器中添加一个名称为InjectClassCode 类型为InjectClassExtension的对象,然后在apply该插件
的app module的build.gradle执行(完成了对InjectClassCode对象的赋值,下面第三步会讲解)完成后将要
注入的代码传递给classTransform对象。

3> 接下来我们来看看app module的build.gradle 和 InjectClassExtension类:

apply plugin: 'com.android.application'
apply plugin: 'com.cytmxk.injectclassplugin'

android {
    compileSdkVersion 26
    buildToolsVersion "26.0.2"

    defaultConfig {
        applicationId "com.cytmxk.hotfix"
        minSdkVersion 16
        targetSdkVersion 26
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
    }

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

        debug {
            signingConfig android.signingConfigs.debug
        }
    }

    flavorDimensions "tier", "channel"

    productFlavors {
        free {
            dimension "tier"
            applicationIdSuffix ".free"
            versionNameSuffix "-free"
        }
        paid {
            dimension "tier"
            applicationIdSuffix ".paid"
            versionNameSuffix "-paid"
        }

        wandoujia {
            dimension "channel"
        }

        market91 {
            dimension "channel"
        }
    }
}

InjectClassCode {
    injectCode = """ android.widget.Toast.makeText(this,"测试Toast代码!!",android.widget.Toast.LENGTH_SHORT).show(); """
}

dependencies {
    implementation fileTree(include: ['*.jar'], dir: 'libs')
    implementation 'com.android.support:appcompat-v7:26.1.0'
    implementation 'com.android.support.constraint:constraint-layout:1.0.2'
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'com.android.support.test:runner:1.0.1'
    androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.1'
}

/--------------------------------------------/
package com.cytmxk

class InjectClassExtension {
    String injectCode
}

上面的InjectClassCode闭包完成了第二步中InjectClassCode对象的赋值。

4> 接下来就来看看InjectClassTransform的实现:

/**
 * 用来向每一个calss文件中注入指定代码
 */
class InjectClassTransform extends Transform{

    Project project
    String injectCode;

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

    /**
     * 设置我们自定义的Transform对应的Task名称, 类似:TransformClassesWithPreDexInjectCodeForXXX
     * @return
     */
    @Override
    String getName() {
        return "PreDexInjectCode"
    }

    /**
     * 需要处理的数据类型,CONTENT_CLASS代表处理class文件
     * @return
     */
    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS
    }

    /**
     * 指定Transform的作用范围
     * @return
     */
    @Override
    Set<? super QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT
    }

    /**
     * 指明当前Transform是否支持增量编译
     * @return
     */
    @Override
    boolean isIncremental() {
        return false
    }

    @Override
    void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        super.transform(transformInvocation)
        transformInvocation.inputs.each { TransformInput input ->
            //遍历文件夹
            input.directoryInputs.each { DirectoryInput directoryInput ->
                //注入代码
                InjectClass.inject(directoryInput.file.absolutePath, project, injectCode)
                // 获取output目录
                def dest = transformInvocation.outputProvider.getContentLocation(directoryInput.name, directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY)
                // 将input的目录复制到output指定目录
                FileUtils.copyDirectory(directoryInput.file, dest)
            }
            //遍历jar文件 对jar不操作,但是要输出到out路径
            input.jarInputs.each { JarInput jarInput ->
                // 重命名输出文件(同名文件copyFile会冲突)
                def jarName = jarInput.name
                println("jar = " + jarInput.file.getAbsolutePath())
                def md5Name = DigestUtils.md5Hex(jarInput.file.getAbsolutePath())
                if (jarName.endsWith(".jar")) {
                    jarName = jarName.substring(0, jarName.length() - 4)
                }
                def dest = transformInvocation.outputProvider.getContentLocation(jarName + md5Name, jarInput.contentTypes, jarInput.scopes, Format.JAR)
                FileUtils.copyFile(jarInput.file, dest)
            }
        }
    }
}

在第一步中注册InjectClassTransform会生成与之对应的名称为TransformClassesWithPreDexInjectCodeForXXX的Task,后面的XXX代表app module的build variant的名称,该Task被执行时InjectClassTransform的transform方法会被调用,上面的注释很清晰,就不再赘叙了,下面看一下注入代码的实现:

class InjectClass {
    //初始化类池
    private final static ClassPool pool = ClassPool.getDefault()
    static void inject(String path,Project project, String injectCode) {
        println("filePath = " + path)
        //将当前路径加入类池,不然找不到这个类
        pool.appendClassPath(path)
        //project.android.bootClasspath 加入android.jar,不然找不到android相关的所有类
        pool.appendClassPath(project.android.bootClasspath[0].toString())

        File dir = new File(path)
        if (dir.isDirectory()) {
            //遍历文件夹
            dir.eachFileRecurse { File file ->
                if (file.getName().equals("MainActivity.class")) {
                    //获取MainActivity.class
                    CtClass ctClass = pool.getCtClass("com.cytmxk.hotfix.MainActivity")
                    println("ctClass = " + ctClass)
                    //解冻
                    if (ctClass.isFrozen()) ctClass.defrost()
                    //获取到OnCreate方法
                    CtMethod ctMethod = ctClass.getDeclaredMethod("onCreate")
                    println("方法名 = " + ctMethod)
                    println "injectCode = " + injectCode
                    //在方法开始注入代码
                    ctMethod.insertBefore(injectCode)
                    ctClass.writeFile(path)
                    ctClass.detach()//释放
                }
            }
        }
    }
}

上面代码将代码注入到了com.cytmxk.hotfix.MainActivity.class的onCreate方法开始,然后通过下面命令build app module:

./gradlew app:assembleFreeWandoujiaRelease

然后我们来看看com.cytmxk.hotfix.MainActivity.class文件:



可以看到代码被注入了。

推荐阅读更多精彩内容