如何在 Gradle 中定义自定义构建任务(Task)?

解读

国内 Android 面试中,Gradle 脚本能力被视为“工程化深度”的分水岭。面试官抛出此题,往往不是想听你背出“task hello {}”这种入门语法,而是考察三件事:

  1. 你能否把“自定义 Task”与真实 CI/CD 场景(如字节码插桩、渠道包二次签名、AAR 上传 Maven 仓库、产物自动归档飞书群通知)结合起来;
  2. 你能否区分“DSL 声明式写法”与“Task 类继承式写法”,并说出增量构建、缓存、并行对 Task 的约束;
  3. 你能否解释清 Gradle 生命周期(Initialization → Configuration → Execution)与 Task 依赖顺序,避免在 Configuration 阶段做 IO 或 Git 读取导致构建速度被拉长——国内大厂对“秒级构建”极度敏感,简历上写“优化构建 30%”却答不出生命周期,会被直接判负。

知识点

  1. 三种写法:DSL 声明、Task 容器注册、继承 DefaultTask
  2. 输入输出注解:@InputFiles、@OutputFile、@TaskAction,决定 UP-TO-DATE 与 Build Cache 命中率
  3. 生命周期钩子:afterEvaluate、gradle.projectsEvaluated、taskGraph.whenReady,避免 Configuration 阶段误执行
  4. 增量与并行:@PathSensitive、@IncrementalTaskInputs(Gradle 7.x 已迁移为 InputChanges)
  5. 国内特色:渠道包、R 文件常量内联、AAR 上传阿里云 Maven、企业微信/飞书构建结果机器人,均需自定义 Task 承载
  6. 性能红线:Configuration 阶段禁止 IO、网络、Git 命令;Execution 阶段才能做重活
  7. 缓存合规:自定义 Task 必须声明输出,否则 CI 第二次构建不会命中缓存,浪费云主机费用

答案

在 Android 工程中,自定义 Task 推荐采用“register 方式 + 继承 DefaultTask”组合,既享受 Configuration Cache,又方便单元测试。下面以“自动上传 APK 到阿里云 OSS,并发送飞书卡片消息”为例,给出可直接落地的样板代码,覆盖输入输出、增量缓存、并行与生命周期四个关键点。

步骤 1:在 buildSrc 模块(或 includeBuild 复合构建)中新建 UploadApkTask.kt

abstract class UploadApkTask : DefaultTask() {

    @get:InputFile
    @get:PathSensitive(PathSensitivity.NAME_ONLY)
    abstract val apkFile: RegularFileProperty

    @get:Input
    abstract val ossBucket: Property<String>

    @get:Input
    abstract val ossObjectKey: Property<String>

    @get:Internal   // 飞书 webhook 不参与缓存
    abstract val feishuHook: Property<String>

    @TaskAction
    fun upload() {
        val file = apkFile.get().asFile
        require(file.exists()) { "APK ${file.absolutePath} 不存在" }

        // 1. 上传 OSS(省略 AK/SK 初始化)
        val ossClient = OSSClient(/* ... */)
        ossClient.putObject(ossBucket.get(), ossObjectKey.get(), file)

        // 2. 构造飞书卡片
        val payload = """
        {
          "msg_type": "post",
          "content": {
            "post": {
              "zh_cn": {
                "title": "构建完成",
                "content": [
                  [{"tag": "text", "text": "包名:${file.name}"}],
                  [{"tag": "a", "text": "下载", "href": "https://${ossBucket.get()}.oss-cn-hangzhou.aliyuncs.com/${ossObjectKey.get()}"}]
                ]
              }
            }
          }
        }
        """.trimIndent()

        // 3. 发送飞书
        val conn = URL(feishuHook.get()).openConnection() as HttpURLConnection
        conn.requestMethod = "POST"
        conn.doOutput = true
        conn.setRequestProperty("Content-Type", "application/json")
        conn.outputStream.write(payload.toByteArray())
        check(conn.responseCode == 200) { "飞书通知失败:${conn.responseCode}" }
    }
}

步骤 2:在 app 模块的 build.gradle.kts 中注册任务

android.applicationVariants.all {
    val variant = this
    val uploadTask = tasks.register<UploadApkTask>("upload${variant.name.capitalize()}Apk") {
        apkFile.set(variant.packageApplicationProvider.flatMap { it.apkDirectory.file(variant.outputs.first().outputFileName) })
        ossBucket.set("my-app-releases")
        ossObjectKey.set("android/${variant.name}/${variant.versionName}/app-${variant.name}-${variant.versionName}.apk")
        feishuHook.set(providers.gradleProperty("feishu.hook").orElse(""))
    }

    // 让 uploadTask 依赖于打包任务,确保 APK 已生成
    uploadTask.dependsOn(variant.assembleProvider)
}

步骤 3:CI 调用

./gradlew assembleRelease uploadReleaseApk -Pfeishu.hook=https://open.feishu.cn/.../webhook/...

要点总结

  1. 使用 abstract class + Property<T> 写法,Gradle 可以生成实现类并支持 Configuration Cache,Android Studio 同步速度提升 20% 以上;
  2. @InputFile 与 @OutputFile 必须成对出现,否则 UP-TO-DATE 机制失效,每次 CI 都会重复上传;
  3. 飞书 webhook 属于副作用,标记为 @Internal,避免缓存命中时跳过通知;
  4. 所有 OSS 参数通过 Property 注入,方便在 CI 控制台动态替换,无需改脚本;
  5. 任务注册放在 afterEvaluate 之外,防止 Configuration 阶段触发文件 IO;
  6. 若需增量上传,可进一步监听 InputChanges,仅上传变更的 APK,适合大型 Monorepo。

拓展思考

  1. 国内多渠道场景下,Walle/VasDolly 生成上百个渠道包,如何写一个“批量上传并并发通知”的 Task,避免线程爆炸?(提示:使用 Worker API 把每个渠道包封装为 UnitOfWork,限制最大并行度为 CPU 核心数,防止 OSS 连接数被打满)
  2. 企业合规要求“构建产物必须秒级备份到私有云”,但备份 Task 耗时较长,如何让它与测试任务并行,又不阻塞 assemble?(提示:使用 finalizerTask,让备份任务成为 assemble 的“清理型”后继,Gradle 7.4+ 支持 finalizer 并行)
  3. 自定义 Task 需要读取 Git Commit 数量作为版本号,但 Configuration 阶段读取会导致 Configuration Cache 失效,如何优雅解决?(提示:使用 ValueSource<GitInfo, Parameters>,把 Git 命令延迟到 Execution 阶段,兼顾缓存与动态版本)
  4. 国内部分厂商 ROM 对 APK 签名算法有白名单,如何在 Task 链中插入“校验签名算法”步骤,失败直接中断构建?(提示:注册一个 VerifySignatureTask,依赖 packageApplicationProvider,校验 APK 的 SignatureScheme 版本,若小于 APK Signature Scheme v2 则抛出 GradleException,CI 直接红灯)
  5. 多人协作时,buildSrc 变更会触发全量编译,如何把自定义 Task 拆成独立 includedBuild,实现“二进制复用”与“版本化管理”?(提示:使用 composite build,把 UploadApkTask 发布到内部 Maven,版本号跟随 Git Tag,CI 自动拉取对应版本,避免本地反复编译 buildSrc)

掌握以上思路,面试时不仅能答出“怎么写 Task”,还能把“增量构建、并行、缓存、合规、CI 成本”讲透,直接对标国内一线大厂对 Gradle 工程化的最高要求。