可以不会不能不知-依赖集中申明+公约plugins实现可组合的gradle构建逻辑

1,510 阅读10分钟

前言

大多数项目经常最终会出现以下几种情况:

  1. 越来越多的*.gradle(.kts)脚本文件
  2. 非常复杂的subprojects and allprojects代码配置块.
  3. 庞大的构建逻辑放在buildSrc目录下
  4. 散落各地并写法眼花缭乱的ext式申明

前两种可能会带来很多麻烦,因为你要么会有很多代码重复,因为开发人员在创建新模块时往往会复制/粘贴这些脚本,要么会有大量的配置块,不必要地将大部分构建逻辑应用到每个模块,在多模块多人开发时经常会出现这类情况。所以后面出现了通过buildSrc来统一管理的方式,这种方式在代码复制方面稍微好一些,但 buildSrc 也有问题,因为每次对其中的任何内容进行更改时,它都会使构建缓存失效,必须再重新构建才能得到新的构建逻辑。在构建逻辑庞大复杂的时候,这种方式并不高效。也有ext式的kotlin申明依赖,这种方式的申明域不固定,相同命名还会被覆盖,所以多人开发时容易造成零散并不易维护,并且语法规范也放的很开了支持多种格式,可读性上有一定问题。

Gradle为了解决这些痛点,提出了依赖集中申明(VersionCatalog)配合公共约定插件(Convention Plugins)的解决思路。集中申明可以看作是对buildSrcext的合并升级,公约插件是 Gradle 在子模块之间共享构建逻辑并解决上述问题的方式。

集中申明是通过代码或者读取 *.toml 文件来实现依赖的集中申明。

公约插件保证所有的插件都可以随时添加随意组合,也最大程度的遵循了单一职责思想。使用时模块的构建脚本只用从公约插件中按需选择和配置。

关于集中申明的用法这里不再重复讨论了,网上相关的使用教程挺多的,大家可以自行搜索查看。

公约插件

借用google 官方的now in android项目来做演示。项目顶层有两个模块build-logicnowinandroid。这里需要注意的是build-logic项目是作为nowinandroid项目的插件提供模块被复合构建的,这里需要在nowinandroid项目的settings.gradle.kts里面代码设置。

pluginManagement {
    //传入的参数为build-logic模块的settings.gradle.kts指定的rootProject.name
    //这里是“build-logic”
    includeBuild("build-logic")
    repositories {
        google()
        mavenCentral()
        gradlePluginPortal()
    }
}

再来看一下`build-logic项目的settings.gradle.kts

dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
    }
    //通过指定的toml文件来创建命名为libs的VersionCatalog 
    versionCatalogs {
        create("libs") {
            from(files("../gradle/libs.versions.toml"))
        }
    }
}

rootProject.name = "build-logic"
//项目包含子项目convention,也是我们负责公约插件的模块
include(":convention")

convention的build.gradle.kts关键代码

//此代码块完成了公约插件的注册
gradlePlugin {
    plugins {
        //传入自定义的插件名字,因为应用插件使用的是id所以这个地方使用方便自己理解区分的string就行
        register("androidApplicationCompose") {
            //id字段很关键,我们在子项目中想使用的时候 传的就是此id
            id = "nowinandroid.android.application.compose"
            //插件的实现类
            implementationClass = "AndroidApplicationComposeConventionPlugin"
        }
        register("androidApplication") {
            id = "nowinandroid.android.application"
            implementationClass = "AndroidApplicationConventionPlugin"
        }
        register("androidApplicationJacoco") {
            id = "nowinandroid.android.application.jacoco"
            implementationClass = "AndroidApplicationJacocoConventionPlugin"
        }
        register("androidLibraryCompose") {
            id = "nowinandroid.android.library.compose"
            implementationClass = "AndroidLibraryComposeConventionPlugin"
        }
        register("androidLibrary") {
            id = "nowinandroid.android.library"
            implementationClass = "AndroidLibraryConventionPlugin"
        }
        register("androidFeature") {
            id = "nowinandroid.android.feature"
            implementationClass = "AndroidFeatureConventionPlugin"
        }
        register("androidLibraryJacoco") {
            id = "nowinandroid.android.library.jacoco"
            implementationClass = "AndroidLibraryJacocoConventionPlugin"
        }
        register("androidTest") {
            id = "nowinandroid.android.test"
            implementationClass = "AndroidTestConventionPlugin"
        }
        register("androidHilt") {
            id = "nowinandroid.android.hilt"
            implementationClass = "AndroidHiltConventionPlugin"
        }
        register("androidRoom") {
            id = "nowinandroid.android.room"
            implementationClass = "AndroidRoomConventionPlugin"
        }
        register("androidFirebase") {
            id = "nowinandroid.android.application.firebase"
            implementationClass = "AndroidApplicationFirebaseConventionPlugin"
        }
        register("androidFlavors") {
            id = "nowinandroid.android.application.flavors"
            implementationClass = "AndroidApplicationFlavorsConventionPlugin"
        }
    }
}

先停一下,我们来梳理一下上面的内容。

  1. build-logic的settings.gradle.kts配置了versionCatalog
  2. libs.versions.toml里面申明了所有的依赖
  3. convention的build.gradle.kts注册了公约插件,注册的时候指定了id和相应的实现类
  4. 子项目通过选择相应的公约插件的id去配置构建逻辑

OK,那我们现在来看注册函数参数为androidApplication的公约插件。为什么先选它,原因很简单,我们知道所有的app模块在构建的脚本里面的plugin下都需要应用com.android.application插件,相应的如果是库模块的话我们需要应用com.android.library插件,所以我们选个熟悉的来分析。

插件id"nowinandroid.android.application",实现类"AndroidApplicationConventionPlugin",全局搜一下这个id发现两个子项目的build.gradle.kts用到了,分别是appapp-nia-catalog。app-nia-catalog是一个可运行的android app项目,主要展示app项目里面用的的Compose组件,逻辑简单依赖也少很多,我们就选它来接着分析,来看下app-nia-catalog的build.gradle.kts代码

plugins {
    //这里应用了我们上面提到的公约插件
    id("nowinandroid.android.application")
    id("nowinandroid.android.application.compose")
}

android {
    //默认配置里面只保留了此项目独有的配置,没有指定什么目标版本之类的赋值了
    defaultConfig {
        applicationId = "com.google.samples.apps.niacatalog"
        versionCode = 1
        versionName = "0.0.1" // X.Y.Z; X = Major, Y = minor, Z = Patch level

        // The UI catalog does not depend on content from the app, however, it depends on modules
        // which do, so we must specify a default value for the contentType dimension.
        missingDimensionStrategy(FlavorDimension.contentType.name, NiaFlavor.demo.name)
    }

    packagingOptions {
        resources {
            excludes.add("/META-INF/{AL2.0,LGPL2.1}")
        }
    }
    namespace = "com.google.samples.apps.niacatalog"

    buildTypes {
        val release by getting {
            // To publish on the Play store a private signing key is required, but to allow anyone
            // who clones the code to sign and run the release variant, use the debug signing key.
            // TODO: Abstract the signing configuration to a separate file to avoid hardcoding this.
            signingConfig = signingConfigs.getByName("debug")
        }
    }
}
//通过依赖集中声明实现了2个依赖项
dependencies {
    implementation(project(":core:ui"))
    implementation(project(":core:designsystem"))
    implementation(libs.androidx.activity.compose)
    implementation(libs.accompanist.flowlayout)
}

看到这里可能脑中已经有了几个疑问

  1. nowinandroid.android.application插件凭什么可以代替com.android.application插件
  2. 为什么android{}和defaultConfig{}块里面可以不用配置最开始提到的那些重复的属性(最低版本、编译版本,kotlin option java version等)

带着这两个疑问,我们来看一下公约插件的实现类AndroidApplicationConventionPlugin

class AndroidApplicationConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            //关键操作 1
            with(pluginManager) {
                apply("com.android.application")
                apply("org.jetbrains.kotlin.android")
            }
            //关键操作 2
            extensions.configure<ApplicationExtension> {
                configureKotlinAndroid(this)
                defaultConfig.targetSdk = 33
                configureGradleManagedDevices(this)
            }
            //关键操作 3
            extensions.configure<ApplicationAndroidComponentsExtension> {
                configurePrintApksTask(this)
            }
        }
    }
}

很简洁吧。作为公约插件本身肯定还是一个Plugin,那么实现Plugin接口,实现apply方法来实现补丁再被应用时需要做的事情。

关键1:target是应用此公约插件的项目对象,再通过with(pluginManaget)函数应用了两个我们终于熟悉的插件了com.android.applicationorg.jetbrains.kotlin.android。现在第一个疑问解决了,原来是把原来在build.gralde.kts构建脚本的补丁的应用的操作抽离到这个公约插件了。

关键2: defaultConfig.targetSdk = 33指定了目标sdk版本;点击configureKotlinAndroid(this)函数跳转到KotlinAndroid.kt文件

internal fun Project.configureKotlinAndroid(
    commonExtension: CommonExtension<*, *, *, *>,
) {
    commonExtension.apply {
        compileSdk = 33

        defaultConfig {
            minSdk = 21
        }

        compileOptions {
            sourceCompatibility = JavaVersion.VERSION_11
            targetCompatibility = JavaVersion.VERSION_11
            isCoreLibraryDesugaringEnabled = true
        }

        kotlinOptions {
            // Treat all Kotlin warnings as errors (disabled by default)
            // Override by setting warningsAsErrors=true in your ~/.gradle/gradle.properties
            val warningsAsErrors: String? by project
            allWarningsAsErrors = warningsAsErrors.toBoolean()

            freeCompilerArgs = freeCompilerArgs + listOf(
                "-opt-in=kotlin.RequiresOptIn",
                // Enable experimental coroutines APIs, including Flow
                "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
                "-opt-in=kotlinx.coroutines.FlowPreview",
                "-opt-in=kotlin.Experimental",
            )

            // Set JVM target to 11
            jvmTarget = JavaVersion.VERSION_11.toString()
        }
    }
    
    //这里很关键,通过此方法我们可以拿到versionCatalog,也就是可获取到之前申明的依赖
    val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")

    //在公约插件内的函数中添加了依赖
    dependencies {
        add("coreLibraryDesugaring", libs.findLibrary("android.desugarJdkLibs").get())
    }
}

fun CommonExtension<*, *, *, *>.kotlinOptions(block: KotlinJvmOptions.() -> Unit) {
    (this as ExtensionAware).extensions.configure("kotlinOptions", block)
}

可以看出这里又是抽离思想。把之前写在build.gradle.kts脚本文件里面的配置抽离出来了例如defaultConfig{},compileOptions{},kotlinOptions{}等。并且可以通过扩展函数的特性,我们可以拿到project的实力,直接添加依赖,所以这里我们可以把所有app类型的模块需要的公共依赖都放在这里添加。

关键点 3: 眼尖的xdm可能已经注意到了上面extensions.configure<>函数需要传入一个泛型,那么这个泛型我们哪知道传什么呢?为什么关键2和关键3传的不一样呢?这个说实话我也没有找到相关文档,只能反推去看看代码了。先看关键2 的泛型类型ApplicationExtension注释

Extension for the Android Gradle Plugin Application plugin. This is the android block when the com.android.application plugin is applied. Only the Android Gradle Plugin should create instances of interfaces in com.android.build.api.dsl.

Android Gradle 插件应用程序插件(com.android.application)的扩展。这是应用 com.android.application 插件时的 android 块。只有Android Gradle插件才能在com.android.build.api.dsl中创建接口实例。

一句话总结这个泛型可以配置之前android应用程序的构建脚本文件下的android{}块下的所有配置

再来看此类型继承的接口CommonExtension的注释

Common extension properties for the Android Application. Library and Dynamic Feature Plugins. Only the Android Gradle Plugin should create instances of this interface.

Android 应用程序的通用扩展属性。库和动态功能插件。只有Android Gradle插件才能创建此接口的实例。

一句话总结这个泛型一个通用类型,不仅可以配置应用程序插件还能配置库和动态功能模块的插件。所以我们可以通过查看它的子类的命名来判断应该什么情况用什么类型了,例如我们想在公约插件中配置库类型的项目我们可以使用LibraryExtension接口

再来看关键 3的泛型类型ApplicationAndroidComponentsExtension注释

Extension for the Android Application Gradle Plugin components. This is the androidComponents block when the com.android.application plugin is applied. Only the Android Gradle Plugin should create instances of interfaces in com.android.build.api.variant.

Android Application Gradle Plugin 组件的扩展。这是应用 com.android.application 插件时的 androidComponents 块。只有Android Gradle插件才能在com.android.build.api.variant中创建接口实例。

一句话总结这个泛型可以配置之前android应用程序的构建脚本文件下的androidComponents{}块下的所有配置

关键3的扩展方法内容也是配置变体相关的,所以这个时候传的类型是ApplicationAndroidComponentsExtension。不了解变体的xdm可以搜gralde多渠道打包或者参考 官网文档来了解。这里跟本文内容无关,就不做解释了。

以上内容我们挑选了一个相对简单的公约插件的使用来作为例子,是为了方便理解。如果你感兴趣,强烈建议你把其他公约插件也跟着代码过一遍。就如上面提到的,有的插件是为了给库使用的,有的是屏幕适配使用的,有的是给测试使用的,还有是给功能模块使用的。用法和复杂度不一,但是基本上都包括进去了,读完后能对整体使用有个更广更深入的认识。

结论

如果仅仅只用versionCatalog来实现了依赖集中声明,我个人觉得这个跟之前的buildSrc,Kotlin ext申明的方式使用的体验和颠覆性并不是很大,真正吸引更多的开发人员去使用这种构建逻辑的是公约插件配合versionCatalog来抽离构建逻辑和构建逻辑可组合上。

依赖集中声明+公约插件的方式就是完美的吗?肯定不是!比方说我们在开始的时候需要耗费大量的精力和时间去编写这些插件并测试,在项目没有稳定的时候还需要维护迭代这些插件。但是作为目前google官方演示项目推荐的一种构建方式,里面有很多思路是很有价值和值得学习的。大家可能不会马上在项目中使用这种方法,但是我个人觉得还是很有必要去了解的。比方说这里打破了传统的思想,通过公约插件使得之前一个整体的build.gralde配置变得可拆分可组合,其实我觉得这个思路跟Compose的思想相似,每个公约插件像是每个独立的可组合项,我们在构建的时候只用把这些可组合项组合起来就行,这样几乎可以精简90%的模版代码也提高了配置的一致性。

参考文章

nia项目代码

Sharing dependency versions between projects (gradle.org)

Sharing build logic between subprojects Sample (gradle.org)

为什么要选择VersionCatalog来做依赖管理? - 掘金 (juejin.cn)