8、自定义任务:DefaultTask与自定义任务类、@TaskAction、任务分组与描述、跳过条件、任务超时

说实话,Gradle 脚本写多了,你会发现内置任务虽然够用,但总有那么些场景——比如自动生成代码、批量处理资源、或者对接内部工具链——非得自己动手写任务不可。我记得刚接手一个遗留项目时,构建脚本里塞了三百多行 Groovy,全是各种 doLast 和 doFirst 嵌套,看得我头皮发麻。后来我下定决心,把所有逻辑都抽成了自定义任务类,脚本瞬间清爽了,维护起来也舒服多了。

这一节,我们就来聊聊怎么用 Kotlin 写一个正经的自定义任务。不是那种在 build.gradle.kts 里随便写几行的临时任务,而是有类、有注解、有分组、有超时控制的「正规军」。

8.1 从 DefaultTask 开始

Gradle 里所有任务都继承自 DefaultTask。你平时在脚本里用 tasks.register 创建的任务,底层其实也是它的子类实例。但如果你想封装逻辑、复用代码,就得自己写一个类。

abstract class MyCustomTask : DefaultTask() {
    @TaskAction
    fun doWork() {
        println("Hello from MyCustomTask!")
    }
}

然后在 build.gradle.kts 里注册:

tasks.register<MyCustomTask>("helloTask")

跑一下 ./gradlew helloTask,控制台就会打印那句话。嗯,就这么简单。

但这里有个细节:@TaskAction 注解标记的方法,才是任务真正执行时调用的入口。一个任务类里可以有多个方法,但 @TaskAction 只能有一个。我在项目中见过有人把业务逻辑写在构造函数里,结果每次配置阶段都跑一遍,构建慢得离谱。记住,@TaskAction 只在执行阶段触发。

8.2 任务分组与描述

光有任务名还不够。你想想看,当项目大了,任务列表一长串,没有分组和描述,找起来跟大海捞针似的。Gradle 提供了两个属性来帮我们整理:

  • group:任务所属分组,比如 "build"、"verification"、"documentation"
  • description:任务的简短说明

我习惯在注册任务时直接指定:

tasks.register<MyCustomTask>("helloTask") {
    group = "my-tools"
    description = "打印一条问候信息"
}

这样你执行 ./gradlew tasks 时,就能看到它乖乖待在 "my-tools" 分组下面,后面还跟着描述文字。说白了,这就是给任务贴了个标签,方便自己和队友快速定位。

如果你想让分组更清晰,还可以在任务类里用 @TaskGroup@TaskDescription 注解来声明默认值:

abstract class MyCustomTask : DefaultTask() {
    @get:TaskGroup
    val groupName: String = "my-tools"

    @get:TaskDescription
    val taskDesc: String = "打印一条问候信息"

    @TaskAction
    fun doWork() {
        println("Hello from MyCustomTask!")
    }
}

这样注册时就不用每次都写了。不过我个人更倾向于在注册处显式指定,因为不同模块可能对同一个任务有不同的分组需求。

8.3 跳过条件:让任务「偷懒」

有时候任务不需要每次都执行。比如,只有某个文件发生变化时才运行,或者某个环境变量没设置时就跳过。Gradle 提供了两种跳过方式:

8.3.1 使用 onlyIf

onlyIf 是一个闭包,返回 true 才执行任务。我在项目中经常用它来判断输入文件是否存在:

tasks.register<MyCustomTask>("conditionalTask") {
    onlyIf {
        val inputFile = file("input.txt")
        if (!inputFile.exists()) {
            logger.warn("input.txt 不存在,跳过任务")
            false
        } else {
            true
        }
        // 或者更简洁:file("input.txt").exists()
    }
}

注意,onlyIf 是在任务执行前判断的。如果返回 false,任务会被标记为 SKIPPED,不会执行 @TaskAction 里的代码。

8.3.2 使用 StopExecutionException

另一种方式是在任务执行过程中主动抛出 StopExecutionException。这适合那些需要在运行时动态判断的场景:

abstract class MyCustomTask : DefaultTask() {
    @TaskAction
    fun doWork() {
        if (!project.file("input.txt").exists()) {
            throw StopExecutionException("input.txt 不存在,跳过执行")
        }
        println("开始处理...")
    }
}

这两种方式有什么区别?onlyIf 更优雅,适合声明式的条件;而 StopExecutionException 更灵活,可以在方法中间任意位置跳出。我曾经在一个代码生成任务里用过后者,因为生成逻辑分好几步,某一步发现前置条件不满足时直接抛异常,比在开头写一大串 if 判断要清晰得多。

注意:StopExecutionException 只会跳过当前任务,不会影响其他任务。别把它和 Gradle 构建失败混为一谈。

8.4 任务超时:防止「死循环」

你有没有遇到过这种情况:某个任务跑着跑着就卡住了,可能是网络请求超时,也可能是逻辑里有死循环。没有超时控制,构建进程就会一直挂在那,浪费 CI 时间。

Gradle 提供了 timeout 属性,单位是 Duration:

tasks.register<MyCustomTask>("longRunningTask") {
    timeout = Duration.ofSeconds(30)
}

如果任务执行超过 30 秒,Gradle 会强制终止它,并标记为失败。我在处理大型资源打包任务时,一定会设置超时。因为有一次,某个资源文件意外变成了 2GB,打包脚本直接跑了一个多小时还没结束,CI 管道被堵得死死的。从那以后,凡是可能耗时的任务,我都加上了超时限制。

你可以在任务类里设置默认超时:

abstract class MyCustomTask : DefaultTask() {
    init {
        timeout = Duration.ofMinutes(5)
    }

    @TaskAction
    fun doWork() {
        // 耗时操作
    }
}

这样所有注册的实例默认都有 5 分钟超时,但注册时仍然可以覆盖:

tasks.register<MyCustomTask>("quickTask") {
    timeout = Duration.ofSeconds(10)
}

8.5 综合示例:一个带跳过和超时的代码生成任务

把上面这些知识点串起来,写一个实际点的任务。假设我们要根据一个配置文件生成 Kotlin 数据类:

abstract class CodeGenTask : DefaultTask() {

    @get:InputFile
    abstract val configFile: RegularFileProperty

    @get:OutputDirectory
    abstract val outputDir: DirectoryProperty

    init {
        // 默认超时 2 分钟
        timeout = Duration.ofMinutes(2)
    }

    @TaskAction
    fun generate() {
        val config = configFile.get().asFile
        if (!config.exists()) {
            throw StopExecutionException("配置文件 $config 不存在,跳过生成")
        }

        val out = outputDir.get().asFile
        out.mkdirs()

        // 模拟生成逻辑
        val content = "data class GeneratedClass(val name: String)"
        val targetFile = File(out, "GeneratedClass.kt")
        targetFile.writeText(content)

        logger.lifecycle("已生成: ${targetFile.absolutePath}")
    }
}

注册时:

tasks.register<CodeGenTask>("generateCode") {
    configFile.set(layout.projectDirectory.file("config.json"))
    outputDir.set(layout.buildDirectory.dir("generated"))
    onlyIf {
        // 只有 release 构建才执行
        project.gradle.startParameter.taskNames.any { it.contains("release") }
    }
}

这个任务有输入输出声明、有超时、有跳过条件,算是一个比较完整的自定义任务了。

8.6 知识体系一览

为了让你更直观地理解这一节的内容,我画了一张图:

自定义任务 继承 DefaultTask @TaskAction 标记执行方法 @Input / @Output 声明输入输出 分组与描述 group = "my-tools" description = "生成代码" 跳过与超时 onlyIf { ... } StopExecutionException timeout = Duration 核心:任务类封装逻辑 + 分组描述组织 + 跳过条件控制执行 + 超时防止卡死 组合使用,让自定义任务既健壮又易维护
小技巧:如果你想让任务在特定条件下跳过,但又不想写复杂的 onlyIf 逻辑,可以试试 Gradle 内置的 @SkipWhen 注解。不过我个人觉得 onlyIf 更直观,团队里其他人一看就懂。

好了,这一节的内容就这些。自定义任务其实不难,关键是把「类结构」、「执行逻辑」、「控制条件」这三块理清楚。下次你在脚本里写 doLast 之前,不妨想想:是不是该抽成一个正经的任务类了?