知Convention Plugins然且知Convention Plugins所以然

最近终于狠下心来,认真研究了Gradle Convention Plugins的Android项目实现。本文一行行地分析了其实现、原理与理由。如果只是上手,直接去扒Now in Android(nia)的实现更为快捷。

选择的理由

为了在多模块项目中共享Gradle配置,最直接的方式当然是———— 不用多模块! 在根build.gradle中写subprojects.afterEvaluate。但时至今日,这种方法没有扩展度且显得很业余。

几年前就有很多人转向了buildSrc作为项目构建最前期的运行内容,但是这个方案性能差:类似问题在重要内容养大象Herding Elephants亦有记载,而且限制多:实际上手后会发现各种依赖不兼容问题。虽然最近的Gradle的教程提到该方法适用于中小项目,但既生瑜何生亮。

后来Herding Elephants横空出世,似乎有承前启后之作用。它提到了使用build-logic。一句题外话,我虽然一直有订阅Square的博客,并在这篇文章发布时,在第一时间也看过其内容,但由于其博客内容大多抽象,所以一直未能领略其精髓。

随着Kotlin DSL和Version Catalog推出,Convention Plugins成了管理配置的最佳实践。这也是开头推荐直接抄Now in Android的实现的原因。

联想到当年Uncle Bob提出Clean Architecture,Android开发的一些最佳实践或默认方案总有一些前人栽树之意味,而这棵树也是充满了前人之个性。

前期的准备

Gradle太灵活了,一个问题有八种解决方法。考虑到Pre-compiled script plugin性能差且Kotlin真好用的情况,我们直接使用最好的Kotlin + Version Catalog + Binary plugin。这意味着在实现前,要先把Gradle迁移到Kotlin DSL。

好在build.gradle.kts的重写可以一个文件一个文件地进行,所以先把文件语法优化,再修改后缀,然后解决报错,最后Sync。如此这般不断重复。

实现的细节

我们以Now in Android的实现为基础来一点点讲解。

我们先从根目录的settings.gradle.kts开始:

1
2
3
pluginManagement {
includeBuild("build-logic")
repositories {

这里要在pluginManagementincludeBuild,说明我们要写Plugin。这与另一个可顶层调用的方法includeBuild是不同的。

其他内容原封不动。

接下里的内容都在build-logic目录中,打包地很好。

build-logic/settings.gradle.kts开始:

1
2
3
pluginManagement {
...
}

这一部分对于大多数项目是多余的。之所以这里有一块,是因为nia额外用到了一个Plugin。之后会讲。

接下来的内容:

1
2
3
4
5
6
7
8
9
10
11
dependencyResolutionManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
}

这里的dependencyResolutionManagement.repositories中内容应修改为和根目录的settings.gradle.kts中的pluginManagement.repositories一致,因为我们要引用Plugin作为依赖,或者说,Convention Plugins中,我们要使用Plugin的代码而非其实现。对于常见的三个Plugin来源:googlegradlePluginPortalmavenCentral,有的插件会出现在其中某一个或某两个中,所以一致的配置确保我们可以找到同样的内容。

接下来:

1
2
3
4
5
6
7
8
9
    versionCatalogs {
create("libs") {
from(files("../gradle/libs.versions.toml"))
}
}
}

rootProject.name = "build-logic"
include(":convention")

该内容是在Gradle的Version Catalog的文档中提及的。从buildSrcbuild-logic的实现,最简单的情况只需要改一下文件夹名称,因此这里Version Catalog的声明可以被共享也不足为奇。

build-logic/gradle.properties中:

1
2
3
4
5
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configureondemand=true
org.gradle.configuration-cache=true
org.gradle.configuration-cache.parallel=true

org.gradle.configureondemand=true:这么多年了还在测试,而且我还能记得它当年跟Instant Run闹了不小的冲突。我推荐还是删了这行吧。

org.gradle.configuration-cache.parallel=true:死活没找到文档。处于安全起见,建议先删掉。

其他内容照抄。

接下来是build-logic/convention/build.gradle.kts

1
2
3
4
plugins {
`kotlin-dsl`
alias(libs.plugins.android.lint)
}

第二个Plugin就是那个导致要在build-logic/settings.gradle.kts声明pluginManagement的Plugin。考虑到大多数项目不会用,这里就删掉好了。如果要使用其他Plugin,那在settings.gradle.kts就要写好对应的pluginManagement.repositories,写法和普通Plugin一样。

接下来:

1
group = "com.google.samples.apps.nowinandroid.buildlogic"

虽说声明group是个好习惯,但我至今也没搞明白不这么做会有什么后果。

继续:

1
2
3
4
5
6
7
8
9
10
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}

kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_17
}
}

既然有了Version Catalog,那我就不允许版本号出现在这里,这里修改为使用Version Catalog的风格:

首先在Version Catalog中增加一个版本号:

1
jvmTarget = "17"

然后:

1
2
3
4
5
6
7
8
9
10
11
12
java {
val javaVersion = JavaVersion.toVersion(libs.versions.jvmTarget.get())
sourceCompatibility = javaVersion
targetCompatibility = javaVersion
}

kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget
.fromTarget(libs.versions.jvmTarget.get())
}
}

接下来:

1
2
3
4
dependencies {
compileOnly(libs.android.gradleApiPlugin)
compileOnly(libs.android.tools.common)
compileOnly(libs.compose.gradlePlugin)

这里写明我们在Convention Plugins中会用到的Plugin。之所以是compileOnly,我猜是因为我们这里只需要Plugin代码,而具体的运行是在项目各Module中的。

这里专门把Plugin的依赖再在Version Catalog中写一遍就不漂亮了。同样在Gradle的Version Catalog的文档中,我们可以找到不用再写一遍的方法。这里稍加修改成为:

1
2
3
4
5
6
7
8
9
dependencies {
compileOnly(plugin(libs.plugins.kotlin.android))
compileOnly(plugin(libs.plugins.android.application))
compileOnly(plugin(libs.plugins.android.library))
}

private fun plugin(plugin: Provider<PluginDependency>): Provider<String> {
return plugin.map { "${it.pluginId}:${it.pluginId}.gradle.plugin:${it.version}" }
}

这样就不用专门为了Convention Plugins而修改Version Catalog的结构了。

然后就是照猫画虎了。从Plugin的实现到注册,都可以根据自己偏好删删改改了。

1
2
3
4
5
6
7
8
gradlePlugin {
plugins {
register("androidApplicationCompose") {
id = libs.plugins.nowinandroid.android.application.compose.get().pluginId
implementationClass = "AndroidApplicationComposeConventionPlugin"
}
}
}

之所以用register而不用create是因为其方法文档中提到前者懒加载,后者总是加载。

未解的疑问

  • 为什么Plugin是在根目录完成的呢?Implementing Binary Plugins明明是在对应包名下完成的。不过似乎没什么影响?
  • 为什么要叫做build-logic呢?该不会因为养大象的文章中就是这么称呼的?
  • 一个功能的实现散落在Gradle文档网站的数个页面中,没有谁来优化一下阅读体验吗?
  • 虽说gradlePluginPortal可以方便Plugin管理,但现在感觉情况更混乱了。
  • 就像当年发布第三方Library时,大伙儿都会到处抄袭一些几乎没人能懂但能跑的起来的Maven发布代码,如今的Convention Plugins看起来也是如此,尤其在AI都不知道最佳实践,只能乱生成代码的阶段。