一、为什么需要约定插件?

说实话,我刚带团队那会儿,最头疼的就是各个模块的Gradle脚本写得五花八门。有人用implementation,有人用api;有人开混淆,有人不开;测试配置更是各写各的。每次Code Review都得盯着build.gradle看半天。

后来我意识到,这其实是个「标准化」的问题。你想想看,一个项目十几个模块,如果每个模块都自己写一遍配置,不仅累,还容易出错。更麻烦的是——哪天你想升级AGP版本,得挨个改十几个文件,漏一个就等着编译报错吧。

Gradle Convention Plugin就是来解决这个问题的。说白了,它就是一个「配置模板」。你把公共的配置抽出来,写成一个插件,然后各个模块apply一下就行了。我习惯叫它「约定插件」,因为它本质上是在建立团队的构建约定。

核心思想: 一次编写,处处复用。把重复的配置逻辑封装成插件,让每个模块的build.gradle变得干净、一致、好维护。

二、Convention Plugin 到底是什么?

从技术上讲,Convention Plugin就是一个Gradle插件。但它不是用来做编译或者打包这种底层事情的,它的职责是「配置」——给项目设置默认值、统一行为。

举个例子:

// 传统的做法:每个模块都写一遍
android {
    compileSdk 34
    defaultConfig {
        minSdk 21
        targetSdk 34
    }
}

// 用了Convention Plugin之后:模块里只需要写
plugins {
    id 'com.example.android-conventions'
}

嗯,差别很明显吧?模块的build.gradle从几十行变成了几行。而且,所有模块的compileSdk、minSdk这些值,都由插件统一管理。改一处,全局生效。

三、如何创建一个 Convention Plugin?

创建过程其实不复杂,我带你走一遍。我个人习惯在项目根目录下建一个 build-logic 目录,专门放这些约定插件。

3.1 目录结构

project-root/
├── build-logic/
│   ├── build.gradle.kts
│   ├── settings.gradle.kts
│   └── src/
│       └── main/
│           └── kotlin/
│               └── com/example/
│                   ├── AndroidApplicationConventionPlugin.kt
│                   ├── AndroidLibraryConventionPlugin.kt
│                   └── KotlinConventionPlugin.kt
├── app/
├── feature/
└── settings.gradle.kts

3.2 配置 build-logic 模块

首先,在 build-logic/build.gradle.kts 里声明依赖:

plugins {
    `kotlin-dsl`
}

repositories {
    google()
    mavenCentral()
}

dependencies {
    implementation("com.android.tools.build:gradle:8.2.0")
    implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.20")
}

然后在 build-logic/settings.gradle.kts 里把它注册为一个独立的构建:

dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
    }
}

rootProject.name = "build-logic"

3.3 编写第一个约定插件

接下来,写一个Android应用模块的约定插件。我习惯用Kotlin来写,因为Gradle Kotlin DSL本身就是Kotlin,写起来很顺畅。

// AndroidApplicationConventionPlugin.kt
class AndroidApplicationConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            plugins.apply("com.android.application")
            plugins.apply("org.jetbrains.kotlin.android")

            extensions.configure<ApplicationExtension>("android") {
                compileSdk = 34

                defaultConfig {
                    minSdk = 21
                    targetSdk = 34
                    versionCode = 1
                    versionName = "1.0"
                }

                compileOptions {
                    sourceCompatibility = JavaVersion.VERSION_17
                    targetCompatibility = JavaVersion.VERSION_17
                }

                buildFeatures {
                    compose = true
                }
            }
        }
    }
}

写完之后,在 build-logic/src/main/resources/META-INF/gradle-plugins/ 下创建一个属性文件,把插件ID和实现类映射起来:

# com.example.android-application.properties
implementation-class=com.example.AndroidApplicationConventionPlugin

3.4 在项目中使用

最后,在根项目的 settings.gradle.kts 里include这个build-logic:

pluginManagement {
    includeBuild("build-logic")
}

然后在app模块的build.gradle.kts里:

plugins {
    id("com.example.android-application")
}

搞定。就这么简单。

小技巧: 我建议把插件ID命名成有意义的格式,比如 com.公司名.模块类型。这样一看就知道这个插件是干什么的。

四、统一 Android 模块配置

实际项目中,我们通常有application和library两种模块。它们的配置大部分相同,只有少数地方不一样。比如application需要applicationId,library不需要。

我一般会写两个插件:一个给app用,一个给library用。但公共部分会抽出来共享。

// 公共配置,放在一个单独的类里
object AndroidConfig {
    const val compileSdk = 34
    const val minSdk = 21
    const val targetSdk = 34
}

// 在插件里引用
class AndroidLibraryConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            plugins.apply("com.android.library")
            plugins.apply("org.jetbrains.kotlin.android")

            extensions.configure<LibraryExtension>("android") {
                compileSdk = AndroidConfig.compileSdk
                defaultConfig {
                    minSdk = AndroidConfig.minSdk
                    targetSdk = AndroidConfig.targetSdk
                }
                // ... 其他公共配置
            }
        }
    }
}

这样做的好处是,哪天你想把minSdk从21升到23,只需要改AndroidConfig这一个地方。我曾经在一个老项目里,因为minSdk分散在十几个模块里,升级的时候改漏了一个,结果低版本手机直接Crash。从那以后,我就再也不敢把配置散着写了。

五、统一 Kotlin 配置

Kotlin的配置其实也有不少坑。比如JVM目标版本、编译器参数、协程的编译优化等等。如果不统一,很容易出现「我这个模块用1.9,那个模块用1.8」的混乱局面。

我习惯写一个专门的Kotlin约定插件:

class KotlinConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            plugins.apply("org.jetbrains.kotlin.android")

            tasks.withType<KotlinCompile>().configureEach {
                kotlinOptions {
                    jvmTarget = "17"
                    freeCompilerArgs = listOf(
                        "-Xopt-in=kotlin.RequiresOptIn",
                        "-Xjvm-default=all"
                    )
                }
            }
        }
    }
}

然后,Android的约定插件里,直接apply这个Kotlin插件就行:

class AndroidApplicationConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            plugins.apply("com.example.kotlin-conventions")
            plugins.apply("com.android.application")
            // ... 其他配置
        }
    }
}

这样,Kotlin的版本和参数就统一了。你想想看,如果每个模块都自己写一遍 jvmTarget = "17",万一有人写成16,编译的时候不会报错,但运行起来就可能出问题。这种隐形的坑,用约定插件就能轻松避免。

六、统一测试配置

测试配置是另一个容易乱的地方。有人用JUnit4,有人用JUnit5;有人用MockK,有人用Mockito。更别说测试覆盖率、测试报告这些了。

我一般会写一个测试约定插件:

class TestConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            dependencies {
                add("testImplementation", "junit:junit:4.13.2")
                add("testImplementation", "org.mockito:mockito-core:5.6.0")
                add("androidTestImplementation", "androidx.test.ext:junit:1.1.5")
                add("androidTestImplementation", "androidx.test.espresso:espresso-core:3.5.1")
            }

            tasks.withType<Test>().configureEach {
                useJUnitPlatform()
                testLogging {
                    events("passed", "skipped", "failed")
                }
            }
        }
    }
}

然后在Android约定插件里,把测试插件也apply进来。这样,所有模块的测试框架和版本就统一了。

注意: 测试依赖的版本号,我建议也放到一个公共常量里管理。比如写一个 Libs.kt 文件,把所有依赖版本集中定义。这样升级依赖的时候,一目了然。

七、知识体系总览

说了这么多,我画了一张图帮你梳理一下整个知识结构。你可以看到,约定插件的核心就是「封装」和「复用」,把散落在各个模块的配置收拢到一处。

Gradle Convention Plugin 统一 Android 配置 统一 Kotlin 配置 统一测试配置 Android 配置项 • compileSdk / minSdk / targetSdk • buildFeatures (Compose, ViewBinding) • compileOptions (Java版本) Kotlin 配置项 • jvmTarget (JVM目标版本) • freeCompilerArgs (编译器参数) • Kotlin 版本统一 测试配置项 • 测试框架 (JUnit / MockK) • 测试依赖版本统一 • testLogging 配置 核心价值:一次编写,处处复用 减少重复代码,统一团队规范,降低维护成本

八、写在最后

约定插件这个东西,说白了就是「把配置当代码来管理」。你把它当成一个独立的模块去设计、去测试、去版本控制,而不是把配置散落在各个build.gradle里。

我刚开始用的时候,也觉得多此一举——「不就是几行配置吗,复制粘贴不就行了?」但项目一大了,模块一多,你就知道统一管理的好处了。尤其是团队协作的时候,新人来了,直接apply插件就行,不用再教他「compileSdk要写多少、minSdk是多少」这些琐碎的事情。

嗯,这就是约定插件的核心思路。下一节我们会深入讲讲如何编写更复杂的插件,包括如何接收外部参数、如何做条件判断等等。但今天的内容,已经足够你开始动手改造自己的项目了。


公众号:蓝海资料掘金营,微信deep3321