9、自定义扩展:ExtensionContainer、创建DSL扩展、嵌套扩展、延迟配置与Provider、扩展与任务联动

各位同学,今天我们来聊聊 Gradle 插件开发里最核心、也最有趣的部分——自定义扩展。

说实话,我刚开始学 Gradle 插件时,最头疼的就是 DSL 那套东西。看着 build.gradle.kts 里那些花里胡哨的配置块,心想这玩意儿到底是怎么跟底层代码连起来的?后来踩了不少坑,才慢慢摸清楚门道。

今天我就把这块内容掰开揉碎了讲给你听。你学完之后,不仅能看懂别人的 DSL,还能自己写一套出来。

9.1 ExtensionContainer 是什么?

先问一个问题:你在 build.gradle.kts 里写的 android { ... } 或者 dependencies { ... },Gradle 是怎么知道这些配置块对应哪个对象的?

答案就是 ExtensionContainer

ExtensionContainer 是 Gradle 项目的一个容器,专门用来管理所有扩展对象。每个 Project 都有一个 ExtensionContainer,你可以通过 project.extensions 拿到它。

我个人习惯把 ExtensionContainer 理解成一个「注册中心」。你写的每一个 DSL 块,本质上都是向这个中心注册了一个扩展对象,然后 Gradle 在解析脚本时,会根据名称找到对应的对象并执行配置闭包。

核心方法就三个:

  • create(name, type, ...args) — 创建并注册一个扩展
  • findByName(name) — 按名称查找扩展,找不到返回 null
  • getByName(name) — 按名称获取扩展,找不到抛异常

举个例子,你在插件里这样写:

// 插件 apply 时调用
override fun apply(project: Project) {
    project.extensions.create("myConfig", MyExtension::class.java)
}

然后在 build.gradle.kts 里就能这样用:

myConfig {
    name = "Hello"
    version = 1
}

是不是很简单?说白了,就是注册一个名字,绑定一个类,剩下的交给 Gradle 去解析。

9.2 创建 DSL 扩展

刚才的例子是最基础的用法。但实际项目中,我们往往需要更复杂的 DSL 结构。比如你希望用户这样写:

myConfig {
    name = "App"
    version = 2
    enableLog = true
}

对应的扩展类长这样:

open class MyExtension {
    var name: String = ""
    var version: Int = 0
    var enableLog: Boolean = false
}

这里有个小细节:类必须用 open 修饰。为什么?因为 Gradle 在创建扩展对象时,可能会用动态代理去包装它。如果你用 final 类,Gradle 会直接报错。

避坑指南:我曾经在项目里忘了加 open,结果编译时一切正常,运行到配置阶段直接崩了。排查了半天才发现是类没开放继承。所以记住,扩展类一定要 open。

另外,如果你希望 DSL 支持 lambda 配置块(也就是花括号那种写法),扩展类需要实现一个带 Action<T> 参数的方法。不过大多数情况下,直接用属性赋值就够了。

9.3 嵌套扩展

真实项目里,配置往往不是扁平的。比如 Android 的 defaultConfig { ... } 里面还有 buildConfigFieldndk { ... } 这种嵌套结构。

嵌套扩展的实现方式有两种:

  • 内部类方式:在扩展类里定义内部类,通过方法返回子扩展对象
  • 独立类方式:每个嵌套块对应一个独立的类,通过容器注册

我个人更推荐独立类方式,因为代码更清晰,也方便单元测试。

来看一个例子:

// 主扩展
open class MyExtension {
    val server = ServerConfig()
    
    fun server(action: Action<ServerConfig>) {
        action.execute(server)
    }
}

// 嵌套扩展
open class ServerConfig {
    var url: String = ""
    var port: Int = 8080
    var timeout: Long = 5000
}

用户使用时:

myConfig {
    server {
        url = "https://api.example.com"
        port = 443
    }
}

注意看 server { ... } 这个写法。它其实是一个方法调用,参数是一个 Action 闭包。闭包内部执行的 url = ... 实际上是在配置 ServerConfig 对象的属性。

注意:嵌套扩展的类也必须是 open。另外,如果嵌套层数超过三层,建议重新考虑设计——太深的 DSL 会让用户崩溃的。

9.4 延迟配置与 Provider

这是很多新手容易忽略的点。Gradle 的构建过程分为三个阶段:初始化 → 配置 → 执行

如果你在配置阶段就读取了某个文件或计算了某个值,但那个值其实要到执行阶段才确定,那就会出问题。

举个例子:

// 错误做法
val buildTime = System.currentTimeMillis()
myConfig {
    timestamp = buildTime  // 这个值在配置阶段就固定了
}

为什么说这是错的?因为 Gradle 的配置阶段可能因为缓存、增量构建等原因被跳过。如果你把时间戳写死在配置阶段,那每次构建的时间戳都一样,缓存就失效了。

正确的做法是用 Provider

// 扩展类里用 Provider
open class MyExtension {
    val timestamp: Provider<Long> = project.provider {
        System.currentTimeMillis()
    }
}

Provider 是一个延迟计算的容器。它只在被访问时才执行内部的 lambda,而且 Gradle 会帮你管理它的依赖关系。如果某个输入没变,Provider 甚至可以返回缓存的结果。

核心原则:能用 Provider 的地方,就别用普通值。尤其是在扩展属性里,尽量用 Property<T>Provider<T>

Gradle 提供了几种常用的延迟类型:

类型 说明 使用场景
Property<T> 可读写的延迟属性 用户配置的值,比如版本号
Provider<T> 只读的延迟计算 从其他 Provider 转换而来
ListProperty<T> 可读写的列表 多个输入文件列表
SetProperty<T> 可读写的集合 去重的配置项
MapProperty<K, V> 可读写的映射 键值对配置

你想想看,如果不用 Provider,你在配置阶段就计算了某个值,但执行阶段这个值依赖的文件变了,那你的计算结果就是错的。Provider 就是为了解决这种「配置时不知道,执行时才确定」的问题。

9.5 扩展与任务联动

扩展写好了,Provider 也用了,但扩展里的配置怎么传递给任务呢?

这里有个关键点:任务是在配置阶段创建的,但任务的输入输出是在执行阶段才使用的

所以,你不能在创建任务时直接读取扩展的值,而应该把扩展的 Provider 传递给任务。

来看一个完整的例子:

// 插件类
class MyPlugin : Plugin<Project> {
    override fun apply(project: Project) {
        // 1. 注册扩展
        val extension = project.extensions.create("myConfig", MyExtension::class.java)
        
        // 2. 注册任务
        project.tasks.register("myTask", MyTask::class.java) { task ->
            // 3. 将扩展的 Provider 连接到任务的输入
            task.message.set(extension.message)
            task.outputFile.set(extension.outputFile)
        }
    }
}

// 扩展类
open class MyExtension {
    val message: Property<String> = ObjectFactory.property(String::class.java)
    val outputFile: Property<File> = ObjectFactory.property(File::class.java)
}

// 任务类
abstract class MyTask : DefaultTask() {
    @get:Input
    abstract val message: Property<String>
    
    @get:OutputFile
    abstract val outputFile: Property<File>
    
    @TaskAction
    fun doWork() {
        val msg = message.get()
        val file = outputFile.get()
        file.writeText(msg)
    }
}

注意看任务注册时的 lambda:task.message.set(extension.message)。这里 set 的是一个 Provider,而不是具体的值。Gradle 会在执行任务时,自动解析这个 Provider 的值。

个人经验:我曾经在项目里直接传了字符串值,结果每次改配置都要 clean 才能生效。后来改成 Provider 传递,增量构建就正常了。所以记住,任务输入输出一定要用 Provider 或 Property。

另外,如果你希望任务在配置阶段就依赖扩展的值(比如根据扩展的值决定是否注册任务),那可以用 extension.message.orNull 来判断。但这种情况比较少,大多数时候还是延迟到执行阶段再取值。

9.6 知识体系总览

说了这么多,我们来画一张图,把整个知识体系串起来。

自定义扩展知识体系 ExtensionContainer 创建 DSL 扩展 嵌套扩展 延迟配置 & Provider 扩展与任务联动 open class 属性赋值 Property<T> Provider<T> @Input @OutputFile 核心:配置延迟化,输入输出用 Provider

这张图把今天讲的内容都串起来了。你看,ExtensionContainer 是中心,它负责管理所有扩展。扩展可以简单(直接属性),也可以嵌套(内部再分块)。配置值尽量用 Provider 延迟计算,最后通过任务注册时的 set 方法联动到任务输入输出。

嗯,到这里,自定义扩展的核心内容就讲完了。你可能会问:这些知识点在实际项目中怎么用?其实你去看 Android 官方的 Gradle 插件源码,里面全是这套模式。理解了今天的内容,你就能看懂官方插件是怎么设计的,甚至能自己写一个类似 android { ... } 的 DSL。

记住一句话:扩展是配置的入口,Provider 是延迟的保障,任务是执行的终点。这三者配合好了,你的插件就能既灵活又高效。