gradle自定义插件

需求

现在我有那么一种需求,得把项目中的每个module打成jar包然后转换为安卓可用的jar包,并通过adb上传到某个机器中。
分解步骤:

  1. 将module打为jar包
  2. 转换jar包
  3. 上传jar包

每个步骤实现都很简单,但不能放在一起使用就会变得繁琐。

之前我是这么做的:

  1. 使用IDE本身artifacts生成jar包
  2. 使用cmd命令行dx工具转换jar包
  3. 使用cmd命令行adb工具push上传jar包

直到我想起了使用gradle

Task

task是gradle构建的核心之一,是任务执行的真正实体,理论上可以使用task实现任何对项目的操作。一般日常使用build/clean操作也各自对应着自己的task。

使用Task实现

定义对应的task来实现是个不错的选择,只需要在module相应的build.gradle里面写下这么一串东西就行了。

def dir1 = 'build/intermediates/compile_library_classes/debug/'
def dir2 = 'build/intermediates/packaged-classes/debug/'
def jarName = "compare.jar"
def dexName = "compare_dex.jar"

task deleteOldJar(type: Delete) {
    delete 'dex/' + jarName
    delete 'dex/' + dexName
}

//复制本身编译好的jar包,更换路径即可
task exportJar(type: Copy) {
//原地址
    if (file(dir1).exists()) {
        println(dir1)
        from(dir1)
    }
    if (file(dir2).exists()) {
        println(dir1)
        from(dir2)
    }
    into('dex/')
    include('classes.jar')
    //重命名jar
    rename('classes.jar', jarName)
}

//将普通jar包用dx工具转换为Android可识别的jar包
task jar2dex(type: Exec) {
    workingDir 'dex/'
    commandLine "cmd", "/c", "dx", "--dex", "--output", dexName, jarName
}

//push到网关盒子
task pushDex(type: Exec) {
    workingDir 'dex/'
    commandLine "cmd", "/c", "adb", "push", dexName, "/sdcard/jinxingateway/module/" + dexName
}

//if (file(dir1).exists() || file(dir2).exists()) {
//    exportJar.dependsOn(deleteOldJar)
//} else {
    exportJar.dependsOn(deleteOldJar, build)
//}
if (!file('dex/' + jarName).exists()) {
    jar2dex.dependsOn(exportJar)
}
if (!file('dex/' + dexName).exists()) {
    pushDex.dependsOn(jar2dex)
}

看起来还不错。
不过问题来了,我有多个module

自定义Task

每个module需要变化的其实只有jar包名字和一些路径,理论自定义task可以解决问题。
于是踌躇满志写下这么一个Task:

class DeleteOldTask extends Delete{

    String dexDir
    String jarName
    String dexName

    @TaskAction
    def cleanOld(){
        Deleter deleter = this.getDeleter()
        deleter.delete(new File("${dexDir}${jarName}"))
        deleter.delete(new File("${dexDir}${dexName}"))
    }
}

等一下,就算是这样,每个module中还是得配置每一个task:

task deleteOld(type:DeleteOldTask){
    dexDir 'dex/'
    jarName 'compare.jar'
    dexName 'compare_dex.jar'
}

这才一个任务,理论上还得有其他对应的任务。
那不行,得找个简洁的方式,只配置一次的。
于是只有自定义一个插件了。

Plugin

plugin在这里可以理解为一个task集合,与project相关联,可以更广泛更灵活的调用项目资源。

官方文档给出了3种自定义方式,第一种在build.gradle中直接新建,但只仅限于当前module;第二种在buildSrc目录中新建,可以在整个rootproject中使用;第三种单独打为jar包上传maven,可以在任意项目中使用。

第一种适用范围太小,无法满足需求;
第三种适用范围大,但打成jar包上传maven又变得繁琐,且无法实时修改;
综合考虑还是使用第二种buildSrc方式

自定义插件

首先明确环境,因为gradle更新较为频繁,新版本可能支持新的语法,IDE支持的版本各自不同,这也是为了避免语法或API不同带来的困扰。

这里使用的是Intellij IDEA 2018,gradle插件版本3.5.4,gradle版本6.1.1,groovy版本3.0.8(编译时可能会让配置groovySDK)。

构建buildSrc

在项目根目录新建buildSrc目录,据官方文档所言,gradle会自动编译此目录中的文件,我们只需要添加固定的一些配置即可。
目录结构是这样的:
buildSrc目录
明确包名为com.xter.plugin,主类为DexPlugin,DexPlugin的相对目录为
【项目根目录\buildSrc\src\main\groovy\com\xter\plugin】;

明确属性配置为com.xter.plugin.properties,其相对目录为
【项目根目录\buildSrc\src\main\resources\META-INF\gradle-plugins\com.xter.plugin.properties】;

com.xter.plugin.properties文件内容为:

implementation-class=com.xter.plugin.DexPlugin

build.gradle文件内容为:

apply plugin: 'groovy'

repositories {
    mavenCentral()
}
dependencies {
    testImplementation 'junit:junit:4.13.2'
}

sourceSets {
    main {
        groovy {
            srcDir 'src/main/groovy'
        }
        resources {
            srcDir 'src/main/resources'
        }
    }
}

注意resources中的properties文件名最好使用代码包名命名,也就是src/main/groovy下的目录来命名,某些情况下使用自定义properties文件名会导致无法识别;
这上面的配置完成后,就可以在项目中的任意module中导入了:

apply plugin: 'com.xter.plugin'
编写extension

为什么不先写插件呢,因为插件是主体,要用到extension传入的参数;
extension在这里可以理解为配置参数的一种手段,我想在module中配置这么一段参数就能实现上面所有task:

dexPlugin{
    local{
        dexDir "dex/"
        jarName 'compare.jar'
        dexName "compare_dex.jar"
    }
    remote{
        dirName "sdcard/jinxingateway/module/"
        dexName "compare_dex.jar"
    }
}

那么首先就得编写相应的extension,这里建立了3个类,DexConfig/LocalConfig/RemoteConfig;

package com.xter.plugin

import org.gradle.util.ConfigureUtil

class DexConfig {

    LocalConfig localConfig = new LocalConfig()
    RemoteConfig remoteConfig = new RemoteConfig()

    void local(Closure c) {
        ConfigureUtil.configure(c, localConfig)
    }

    void remote(Closure c) {
        ConfigureUtil.configure(c, remoteConfig)
    }
}

DexConfig嵌套了其他2个类,local和remote方法传入闭包函数,这决定了可以使用两个关键字来配置下一级的节点参数;

    local{
    }
    remote{
    }

接下来是LocalConfig:

package com.xter.plugin

class LocalConfig {
    String dexDir
    String jarName
    String dexName

    void dexDir(String dexDir) {
        this.dexDir = dexDir
    }

    void jarName(String jarName) {
        this.jarName = jarName
    }

    void dexName(String dexName) {
        this.dexName = dexName
    }
}

同样是对应方法名,使其可以配置相应参数,每个方法相当于一个set方法

        dexDir "dex/"
        jarName 'compare.jar'
        dexName "compare_dex.jar"

最后是RemoteConfig:

package com.xter.plugin

class RemoteConfig {
    String dirName
    String dexName

    void dirName(String dirName) {
        this.dirName = dirName
    }

    void dexName(String dexName) {
        this.dexName = dexName
    }
}

同LocalConfig,不再赘述。
那么使这个配置的根节点dexPlugin生效呢呢?就要在插件源码中去配置了。

编写插件主体

插件均实现Plugin接口,一般情况下都是Plugin,覆写apply方法即可:

class DexPlugin implements Plugin<Project> {
    @Override
    void apply(Project project) {
        //配置extension,用于传参
        def ex = project.extensions.create("dexPlugin", DexConfig)
    }
}

这里的project就指的是引入插件的module所在了。
使用project.extensions.create即可配置上面的根节点,只要名称对应即可生效,拿到的ex就能获取配置参数。
在moudle配置的dexPlugin是这样的:

dexPlugin{
    local{
        dexDir "dex/"
        jarName "compare.jar"
        dexName "compare_dex.jar"
    }
    remote{
        dirName "sdcard/jinxingateway/module/"
        dexName "compare_dex.jar"
    }
}

那么可以根据这个ex来获取相应的值:

ex.localConfig.jarName //值为"compare.jar"
ex.remoteConfig.dexName //值为"compare_dex.jar"

其实就是调用类的成员变量。
接下来在apply方法中创建任务:

        //删除旧有jar包
        Task taskCleanOld = project.tasks.create(group: group, name: 'cleanOld', type: Delete, action: {
            delete jarPath
            delete dexPath
            println("delete ${jarPath}")
            println("delete ${dexPath}")
        })

        //将jar包转换为Android可用的jar包
        Task taskJar2Dex = project.tasks.create(group: group, name: 'jar2dex', type: Exec, action: {
            println("start jar2dex")
            workingDir ex.localConfig.dexDir
            commandLine "cmd", "/c", "dx", "--dex", "--output", ex.localConfig.dexName, ex.localConfig.jarName
            println("jar2dex end")
        })

        //push相应dex包
        Task taskPushDex = project.tasks.create(group: group, name: 'pushDex', type: Exec, action: {
            workingDir ex.localConfig.dexDir
            commandLine "cmd", "/c", "adb", "push", ex.localConfig.dexName, "${ex.remoteConfig.dirName}${ex.remoteConfig.dexName}"
        })

1个Delete任务,2个Exec任务,使用的是

project.tasks.create((Map<String, ?> options)

来创建,其他参数大同小异,重点是这个action,action代表着在执行阶段才真正执行。

我们知道gradle的构建分为三个阶段:

1.Initialization | 初始化
2.Configuration | 配置
3.Execution | 执行

extension参数是在配置阶段传入的,所以理论上需要用到extension参数的任务,最好都放在执行阶段再去使用其参数。

但有一个任务却无法使用action方式使其在执行阶段再使用参数,那就是上面的生成jar包任务exportJar,这个任务本身是一个Copy任务,但Copy任务只能在配置阶段就生成,不能在执行阶段再添加参数,因此只能放在配置阶段,但extension参数也是在配置阶段才传入的,所以这里要求必须在配置阶段完成后,再创建Copy任务,否则无法获取传入的extension参数。

于是就用到了afterEvaluate监听:

        project.afterEvaluate {
            println("configured done")
            def jarPath = "${ex.localConfig.dexDir}${ex.localConfig.jarName}"
            def dexPath = "${ex.localConfig.dexDir}${ex.localConfig.dexName}"

            //生成新Jar包
            taskExportJar = project.tasks.create(group: group, name: 'exportJar', type: Copy) {
                if (srcClassesDir != null) {
                    println(srcClassesDir)
                    from(srcClassesDir)
                    into(ex.localConfig.dexDir)
                    include('classes.jar')
                    rename('classes.jar', ex.localConfig.jarName)
                    duplicatesStrategy('include')
                    println(jarPath)
                }
            }
        }

project.afterEvaluate代表着配置阶段完成,因此将Copy任务放到这里进行生成是最佳选择。
这里使用的是

project.tasks.create(Map<String, ?> options, Closure configureClosure)

来创建任务,从API也可以看出,后面跟的闭包其实就是配置语句

整个插件代码是这样的:

/**
 * 用于生成、转换、上传jar包
 */
class DexPlugin implements Plugin<Project> {

    //定义group,若不自行定义会自行分到'other'组
    String group = 'xter'

    @Override
    void apply(Project project) {
        //配置extension,用于传参
        def ex = project.extensions.create("dexPlugin", DexConfig)

        //编译目录,用于判断是否需要build生成
        def srcClassesDir = getClassesDir()

        def jarPath
        def dexPath

        //因为是copy任务,copy任务只能在配置阶段完成编写
        Task taskExportJar

        //配置阶段完成后的监听,可用来取参数赋值
        project.afterEvaluate {
            println("configured done")
            jarPath = "${ex.localConfig.dexDir}${ex.localConfig.jarName}"
            dexPath = "${ex.localConfig.dexDir}${ex.localConfig.dexName}"

            //生成新Jar包
            taskExportJar = project.tasks.create(group: group, name: 'exportJar', type: Copy) {
                if (srcClassesDir != null) {
                    println(srcClassesDir)
                    from(srcClassesDir)
                    into(ex.localConfig.dexDir)
                    include('classes.jar')
                    rename('classes.jar', ex.localConfig.jarName)
                    duplicatesStrategy('include')
                    println(jarPath)
                }
            }
            //如果尚未build,先行build,build之后需要再次手动执行此任务
            if (srcClassesDir == null) {
                //查找build任务,在未build之前要先行依赖
                Task taskBuild = project.tasks.findByName('build')
                taskExportJar.dependsOn(taskBuild)
            }
        }

        //删除旧有jar包
        Task taskCleanOld = project.tasks.create(group: group, name: 'cleanOld', type: Delete, action: {
            delete jarPath
            delete dexPath
            println("delete ${jarPath}")
            println("delete ${dexPath}")
        })

        //将jar包转换为Android可用的jar包
        Task taskJar2Dex = project.tasks.create(group: group, name: 'jar2dex', type: Exec, action: {
            println("start jar2dex")
            workingDir ex.localConfig.dexDir
            commandLine "cmd", "/c", "dx", "--dex", "--output", ex.localConfig.dexName, ex.localConfig.jarName
            println("jar2dex end")
        })


        //push相应dex包
        Task taskPushDex = project.tasks.create(group: group, name: 'pushDex', type: Exec, action: {
            workingDir ex.localConfig.dexDir
            commandLine "cmd", "/c", "adb", "push", ex.localConfig.dexName, "${ex.remoteConfig.dirName}${ex.remoteConfig.dexName}"
        })

        project.afterEvaluate {
            //jar不存在,先行生成jar
            if (!new File(jarPath).exists()) {
                taskJar2Dex.dependsOn(taskExportJar)
            }
            //dex不存在,先行转换
            if (!new File(dexPath).exists()) {
                taskPushDex.dependsOn(taskJar2Dex)
            }
        }
    }


/**
 * 版本较高的AS中没有bundles目录
 * @return classes.jar所在目录
 */
    static String getClassesDir() {
        def dir1 = 'build/intermediates/compile_library_classes/debug/'
        def dir2 = 'build/intermediates/packaged-classes/debug/'
        File file1 = new File(dir1)
        if (file1.exists()) {
            return dir1
        }
        File file2 = new File(dir2)
        if (file2.exists()) {
            return dir2
        }
    }
}

最后应用在某个module:

apply plugin: 'com.android.library'
apply plugin: 'com.xter.plugin'

dexPlugin{
    println("dexxxx")
    local{
        dexDir "dex/"
        jarName 'compare.jar'
        dexName "compare_dex.jar"
    }
    remote{
        dirName "sdcard/jinxingateway/module/"
        dexName "compare_dex.jar"
    }
    println("dexxxx")
}

编译通过后,在IDE右方gradle即可找到相应任务了:
gradle任务表
双击执行即可。

注意事项

由于gradle版本更迭较快,容易踩坑,加之gradle的报错信息有限,有时候会导致找不到问题来源,所以这里也记录一下编写本插件的踩过一些坑。

重名

自定义的任务名最好不要与系统内置任务重名,比如build、compile;
自定义的变量名也最好不要与内置的一些字符重名,比如srcDir、outputDir;

生命周期

明确gradle构建的生命周期三个阶段,初始化、配置、执行。
初始化阶段,即读取setting.gradle创建项目project以及各module的project实体;
配置阶段,所有project包括module中的task将被创建,task拓扑图将建立,配置项将生效,配置语句将被执行;
执行阶段,各任务的执行语句将被执行,大多是标记了@TaskAction注解的方法语句;
以本插件为例,另行添加一个task:

task testJar(){
    group 'xter'
    println("----")
}

运行结果是这样的

> Configure project :compare
dexxxx
dexxxx
----
configured done
build/intermediates/packaged-classes/debug/
dex/compare.jar

> Task :compare:testJar UP-TO-DATE

可以看到配置阶段就已经把配置语句执行完了,而本身并没有执行语句。
这里也引出了另一个事项,就是哪些是配置语句,哪些是执行语句。

配置语句与执行语句

哪些是配置语句,哪些是执行语句,其实从源码就可以得见一二:

Task task(Map<String, ?> args, String name, Closure configureClosure);
Task task(String name, Closure configureClosure)

一般使用上述API创建Task的,都是在写配置语句,可以看到API名称已经命名为configureClosure了,所只要API中的闭包参数为这个名称的,基本都是配置语句;

而如本文开头的自定义任务,标记了@TaskAction注解的,一般就是执行语句;

    @TaskAction
    def cleanOld(){
    }

也包括创建任务时使用map传入的action参数:

Task task(Map<String, ?> args, String name)

最终都会变为执行语句。

这里可以看一个完整的task.create调用的doCreate方法:

    private Task doCreate(Map<String, ?> options, final Action<? super Task> configureAction) {
        //略
        if (!GUtil.isTrue(name)) {
            throw new InvalidUserDataException("The task name must be provided.");
        } else {
            final Class<? extends TaskInternal> type = (Class)Cast.uncheckedCast(actualArgs.get("type"));
            final TaskIdentity<? extends TaskInternal> identity = TaskIdentity.create(name, type, this.project);
            return (Task)this.buildOperationExecutor.call(new CallableBuildOperation<Task>() {
                public Builder description() {
                    return DefaultTaskContainer.realizeDescriptor(identity, replace, true);
                }

                public Task call(BuildOperationContext context) {
                    try {
                        //略
                        //获取执行操作语句
                        Object action = actualArgs.get("action");
                        if (action instanceof Action) {
                            Action<? super Task> taskAction = (Action)Cast.uncheckedCast(action);
                            //插入执行之前
                            task.doFirst(taskAction);
                        } else if (action != null) {
                            Closure closure = (Closure)action;
                            //插入执行之前
                            task.doFirst(closure);
                        }

                        DefaultTaskContainer.this.addTask(task, replace);
                        //配置任务会在创建的时候就执行
                        configureAction.execute(task);
                        context.setResult(DefaultTaskContainer.REALIZE_RESULT);
                        return task;
                    } catch (Throwable var9) {
                        throw DefaultTaskContainer.this.taskCreationException(name, var9);
                    }
                }
            });
        }
    }

主要关注其中两种语句,一是doFirst:

task.doFirst(taskAction);

doFirst和doLast都是执行阶段的语句,前者会在任务本身执行语句之前执行,后者会在任务本身执行语句之后执行;
也就是一个任务可以有3个序列:

1 doFirst   
2 @TackAction  
3 doLast 

比如创建下面这个任务:

task testJar(){
    group 'xter'
    println("----")
    doFirst{
        println("exec first")
    }
    doLast{
        println("exec last")
    }
}

在配置语句中配置了doFirst和doLast,执行输出是这样的:

> Configure project :compare
dexxxx
dexxxx
----
configured done

> Task :compare:testJar
exec first
exec last
:compare:testJar spend 2ms

因为这个任务本身未配置@TaskAction,即本身并没有执行语句,所以只有doFirst和doLast以及配置语句执行了。
另一种就是配置语句了:

configureAction.execute(task);

显然这就是为什么创建任务时配置语句会被执行的元凶了,当然我们也可以理解为创建任务需要对任务进行配置,因此配置语句会被执行。

extension写法

如果extension的配置类如下面所写:

class LocalConfig {
    String dexDir
    String jarName
    String dexName
}

那么是在build.gradle中的配置中是不能省略参数的等号的,也就是说

    local{
        dexDir "dex/"
        jarName 'compare.jar'
        dexName "compare_dex.jar"
    }

这样进行赋值传参会报错:

Could not find method dexDir() for arguments [dex/] on object of type com.xter.plugin.LocalConfig.

必须使用等号:

    local{
        dexDir="dex/"
        jarName='compare.jar'
        dexName="compare_dex.jar"
    }

这就是需要设定同名方法的原因,同名方法本质上可以理解为一个setter方法,而没有方法就变成了直接赋值,因此需要等号。

task创建

别看上面的task创建花里胡哨的,其实严格来讲就两种。
第一种实现的Project接口:

public interface Project extends Comparable<Project>, ExtensionAware, PluginAware {
    Task task(String var1) throws InvalidUserDataException;
    Task task(Map<String, ?> var1, String var2) throws InvalidUserDataException;
    Task task(Map<String, ?> var1, String var2, Closure var3);
    Task task(String var1, Closure var2);
    Task task(String var1, Action<? super Task> var2);
}

在自家project创建就是这样:

task testJar(){}
task testTar(type:Delete){}

在其他地方自定义插件中,需要project参数就是这样:

project.task("testJar"){}

第二种实现的TaskContainer接口:

public interface TaskContainer extends TaskCollection<Task>, PolymorphicDomainObjectContainer<Task> {
    Task create(Map<String, ?> var1) throws InvalidUserDataException;
    Task create(Map<String, ?> var1, Closure var2) throws InvalidUserDataException;
    Task create(String var1, Closure var2) throws InvalidUserDataException;
    Task create(String var1) throws InvalidUserDataException;
}

这里的create就对应着上面一段doCreate方法的源码实现;
具体运用就如本文中一样:

project.tasks.create(name:"testJar"){}

在自家project也照样可以使用:

tasks.create("testJar"){}

当然TaskContainer接口中还有register方法其实也可以用来创建任务,不过这种方法相对来说可传参数不多,但有一个特点就是一定会排在任务列表的最末端

DefaultTaskContainer.this.addTask(task, replace); //create最终会调用addInternal
DefaultTaskContainer.this.addLaterInternal(provider); //register调用
Copy任务的特殊性

在了解其特殊性之前我尝试过这样去创建:

project.tasks.create(group: group, name: 'exportJar', type: Copy, action: {
                if (srcClassesDir != null) {
                    println(srcClassesDir)
                    from(srcClassesDir)
                    into(ex.localConfig.dexDir)
                    include('classes.jar')
                    rename('classes.jar', ex.localConfig.jarName)
                    duplicatesStrategy('include')
                    println(jarPath)
                }
            })

同样是使用action去创建,期待能与其他任务类型比如Delete在执行阶段生效,以防止参数未传入时为空,以及更方便地调用;
但失败了,action并未执行
就目前来看,Copy任务只能在配置阶段去配置参数,也就是本文中要将其放入project.afterEvaluate监听中的原因;
也就是说,如果在配置阶段配置了相关语句,再在action中添加相同的语句,两者就均会得到执行了,当前这个时候的执行语句已经没有必要编写了;
在官方文档中暂时没有找到相关说明,但在源码中发现一些端倪:

    protected AbstractCopyTask() {
        this.rootSpec = createRootSpec();
        rootSpec.addChildSpecListener((path, spec) -> {
            if (getState().getExecuting()) {
                throw new GradleException("You cannot add child specs at execution time. Consider configuring this task during configuration time or using a separate task to do the configuration.");
            }
            //略
    }

Copy继承自AbstractCopyTask,而后期构造方法中有上面这么一段代码,会监听配置的子节点,也就是from/into这些语句,如果判断为执行阶段,那么就会抛出相关异常
换言之,如果在执行阶段发现有配置(节点)语句,就会抛出异常;
这或许能解释会什么在action中配置一些语句并未生效,不过尚有疑问的是,在实际运行中并未看到异常,只是单纯的未执行。但未抛出异常也有可能是IDE对gradle的异常支持不理想的缘故。

此外,Copy任务在gradle7.0以上是要显式配置duplicatesStrategy参数,否则会报错,当然本文是使用的6.1.1,尚无这个问题,反正先配上再说。

以上。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值