参考谷歌示例项目(NIA)复用 build.gradle.kts

背景

我的个人项目 译站 近来 全量迁移至 KMP 跨平台,其中 Gradle 的各项配置也有所升级,比如升级到了新推荐的 Version Catalog (基于 toml 管理版本)、build.gradle 迁移至了 build.gradle.kts 等。由于项目是多模块的,因此存在多个 build.gradle.kts,而且内容具有高度相似性。对于这些重复的配置文件,以往我采用的复用办法是,新建了一个 base.gradle 写上基本配置,然后其他模块 apply;但迁移至 kts 脚本后,我发现单独写 base.gradle.kts 似乎总是被当做 standalone script ,享受不到 IDE 的各种智能提示。显然这不是最佳的方法,在历经几番折腾后,我最终参照 android/nowinandroid: A fully functional Android app built entirely with Kotlin and Jetpack Compose 的方式,通过自定义 Gradle Plugin 的方式完成了构建脚本的复用。本文记录此过程。

项目基本环境:

  • Gradle: 8.4
  • Android Gradle Plugin (AGP):8.1.4
  • Compose BOM: 2024.01.00
  • Kotlin: 1.9.21

Now In Android

Now In Android 是一个完全使用 Kotlin 和 Jetpack Compose 构建的 Android 应用。它遵循 Android 设计和开发最佳实践,旨在成为开发人员的有用参考。作为一个正在可用的应用程序,它旨在通过提供定期的新闻更新来帮助开发人员跟上 Android 开发的世界。

Now In Android 项目的 build.gradle.kts 文件非常简洁,基本上只有寥寥几行:

kotlin 复制代码
plugins {
    alias(libs.plugins.nowinandroid.android.feature)
    alias(libs.plugins.nowinandroid.android.library.compose)
    alias(libs.plugins.nowinandroid.android.library.jacoco)
}

android {
    namespace = "com.google.samples.apps.nowinandroid.feature.settings"
}

dependencies {
    implementation(libs.androidx.appcompat)
    implementation(libs.google.oss.licenses)
    implementation(projects.core.data)

    testImplementation(projects.core.testing)

    androidTestImplementation(projects.core.testing)
}

可以看到,除了配置个 namespace 之外,基本上就是引入了一些插件和依赖。而最引人瞩目的是上面引入的依赖,全部是 nowinandroid 开头的,这是因为 Now In Android(NIA) 项目自定义了一些 Gradle Plugin,用于统一配置。这些插件的定义在 build-logic 目录下。这也就是我们今天的主题了。NIA 专门写了一个 Markdown 介绍这个 build-logic 文件夹,翻译如下:

约定插件(Convention Plugins)

build-logic 文件夹定义了项目特定的约定插件,用于保持通用模块配置的单一真实来源。

这种方法在很大程度上基于 herding-elephantsidiomatic-gradle

通过在 build-logic 中设置约定插件,我们可以避免重复的构建脚本设置、混乱的子项目配置,而不会出现 buildSrc 目录的缺陷。

build-logic 在根 settings.gradle.kts 中使用 includeBuild 配置

build-logic 中有一个约定模块,它定义了一组所有普通模块都可以使用的插件,以配置自己。

build-logic 还包括一组 Kotlin 文件,用于在插件之间共享逻辑,这对于配置具有共享代码的 Android 组件(库与应用程序)非常有用。

这些插件是可相加的、可组合的,它们试图只完成一个单一的责任。然后模块可以挑选并选择它们需要的配置。如果有一个模块的一次性逻辑没有共享代码,最好将其直接定义在模块的 build.gradle 中,而不是创建具有特定于模块的设置的约定插件。

依葫芦画瓢,开始动手吧!

实践

基本流程

先以 NIA 为例,看看我们要做什么。

写一个自定义 Plugin

新建一个类,继承自 Plugin<Project>,然后实现想要的逻辑。我们随便举一个例子:

kotlin 复制代码
import androidx.room.gradle.RoomExtension
import com.google.samples.apps.nowinandroid.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies

class AndroidRoomConventionPlugin : Plugin<Project> {

    override fun apply(target: Project) {
        with(target) {
            pluginManager.apply("androidx.room")
            pluginManager.apply("com.google.devtools.ksp")

            extensions.configure<RoomExtension> {
                // The schemas directory contains a schema file for each version of the Room database.
                // This is required to enable Room auto migrations.
                // See https://developer.android.com/reference/kotlin/androidx/room/AutoMigration.
                schemaDirectory("$projectDir/schemas")
            }

            dependencies {
                add("implementation", libs.findLibrary("room.runtime").get())
                add("implementation", libs.findLibrary("room.ktx").get())
                add("ksp", libs.findLibrary("room.compiler").get())
            }
        }
    }
}

需要注意的是,在这个过程中,如果涉及到了需要访问插件的类,那么需要在 build-logic/build.gradle.kts 引入一下对应插件:

kotlin 复制代码
dependencies {
    // room-gradlePlugin = { group = "androidx.room", name = "room-gradle-plugin", version.ref = "room" }
    compileOnly(libs.room.gradlePlugin)
}

注册这个 Plugin

写完后,在 build-logic/build.gradle.kts下注册这个插件,并赋予唯一的 ID:

kotlin 复制代码
gradlePlugin {
    plugins {
        register("androidRoom") {
            id = "nowinandroid.android.room"
            implementationClass = "AndroidRoomConventionPlugin"
        }
    }
}

因为 NIA 也是通过 VersionCatalog 管理的,因此在 libs.versions.toml 配置一下依赖的声明:

toml 复制代码
nowinandroid-android-room = { id = "nowinandroid.android.room", version = "unspecified" }

使用

最后在需要使用的模块使用就行了:

kotlin 复制代码
plugins {
    alias(libs.nowinandroid.android.room) // 相当于 id("nowinandroid.android.room")
}

接下来我们就来试试

三方 Gradle Plugin

我先处理的部分是一些三方插件(类似于 KSP 之类的),它们在每个模块都启用了,然后相关的代码都得复制一遍。参照上面的流程,先编写自定义 Class

比如说对于原来的 BuildKonfig(一个在 KMP 中生成类似 BuildConfig 的东东的三方库) 的配置:

kotlin 复制代码
buildkonfig {
    packageName = "com.funny.translation"
    objectName = "BuildConfig"
    // exposeObjectWithName = 'YourAwesomePublicConfig'

    defaultConfigs {
        buildConfigField(STRING, "FLAVOR", "common")
        buildConfigField(STRING, "VERSION_NAME", libs.versions.project.versionName.get())
        buildConfigField(FieldSpec.Type.INT, "VERSION_CODE", libs.versions.project.versionCode.get())
        buildConfigField(STRING, "BUILD_TYPE", "debug")
    }

    defaultConfigs("common") {
        buildConfigField(STRING, "FLAVOR", "common")
    }

    defaultConfigs("google") {
        buildConfigField(STRING, "FLAVOR", "google")
    }
}

就写成

kotlin 复制代码
import com.codingfeline.buildkonfig.compiler.FieldSpec
import com.codingfeline.buildkonfig.gradle.BuildKonfigExtension
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure

fun Project.setupBuildKonfig() {
    pluginManager.apply("com.codingfeline.buildkonfig")

    configure<BuildKonfigExtension> {
        objectName = "BuildConfig"
        // exposeObjectWithName = 'YourAwesomePublicConfig'

        defaultConfigs {
            buildConfigField(FieldSpec.Type.STRING, "FLAVOR", "common")
            buildConfigField(FieldSpec.Type.STRING, "VERSION_NAME", libs.findVersion("project.versionName").get().toString())
            buildConfigField(FieldSpec.Type.INT, "VERSION_CODE", libs.findVersion("project.versionCode").get().toString())
            // DEBUG
            val debug = System.getProperty("TranslationDebug")?.toBoolean() ?: true
            buildConfigField(FieldSpec.Type.BOOLEAN, "DEBUG", debug.toString())
            val buildType = if (debug) "Debug" else "Release"
            buildConfigField(FieldSpec.Type.STRING,  "BUILD_TYPE", buildType)
        }

        defaultConfigs("common") {
            buildConfigField(FieldSpec.Type.STRING, "FLAVOR", "common")
        }

        defaultConfigs("google") {
            buildConfigField(FieldSpec.Type.STRING, "FLAVOR", "google")
        }
    }
}

这里用到了 BuildKonfigExtension,也就是对应插件提供的类。类一般就是 插件名+Extension,不过也可能是别的。不确定的话可以去相应的代码仓库找(通过搜索 Extension)。

找到类的名字后,我们就可以通过这个类做配置,NIA 的做法是针对每个库写了个拓展函数,我们也可以仿照一下(如上);然后别的内容整体来说基本就是复制粘贴,不过原先的 libs.version.xxx 在这里无法使用,需要改成 libs.findVersion("xxx").get().toString()(这个 libs 也是拓展属性,val Project.libs get(): VersionCatalog = extensions.getByType<VersionCatalogsExtension>().named("libs"))。后面因为类似用法太多,写了个简单的拓展函数完成

kotlin 复制代码
fun VersionCatalog.findVersionAsString(alias: String) = findVersion(alias).get().toString()

fun VersionCatalog.findVersionAsInt(alias: String) = findVersionAsString(alias).toInt()

然后如此类推,完成其他三方 plugin 的配置。我因为这几个 plugin 基本都是一起用的,所以把它们写到了同一个 Plugin 里面

kotlin 复制代码
import com.funny.translation.buildlogic.setupBuildKonfig
import com.funny.translation.buildlogic.setupLibres
import com.funny.translation.buildlogic.setupSqlDelight
import org.gradle.api.Plugin
import org.gradle.api.Project

class ThirdPartyPluginsCP : Plugin<Project> {
    override fun apply(target: Project) {
        /**
         * libres {
         *     generatedClassName = "Res" // "Res" by default
         *     generateNamedArguments = true // false by default
         *     baseLocaleLanguageCode = "zh" // "en" by default
         *     camelCaseNamesForAppleFramework = false // false by default
         * }
         *
         * buildkonfig {
         *     packageName = "com.funny.translation"
         *     objectName = "BuildConfig"
         *     // exposeObjectWithName = 'YourAwesomePublicConfig'
         *
         *     // ...
         * }
         *
         * sqldelight {
         *     databases {
         *         create("Database") {
         *             packageName.set("com.funny.translation.database")
         *         }
         *     }
         * }
         */

        with(target) {
            setupLibres()
            setupBuildKonfig()
            setupSqlDelight()
        }
    }
}

然后在 build-logic/build.gradle.kts 里面注册一下:

kotlin 复制代码
gradlePlugin {  
    plugins {  
        val prefix = "transtation"  
        register("thirdPartyPlugins") {  
            id = "$prefix.kmp.thirdpartyplugins"  
            implementationClass = "ThirdPartyPluginsCP"  
        }
    }
}

// libs.versions.toml
[plugins]
# Plugins defined by this project
transtation-kmp-thirdpartyplugins = { id = "transtation.kmp.thirdpartyplugins", version = "unspecified" }

然后,原先的几个 plugins 都可以不需要了

diff 复制代码
- alias(libs.plugins.libres)  
- alias(libs.plugins.buildKonfig)  
- alias(libs.plugins.sqlDelight)

直接改成我们的插件

diff 复制代码
+ alias(libs.plugins.transtation.kmp.thirdpartyplugins)

原先的大段配置也可以删除,只留下不同 module 之间有差异的部分

kotlin 复制代码
// 其他都可以删了,除了这个 packageName 不同 module 不一样外
buildkonfig {  
    packageName = "com.funny.translation.ai"  
}

其他通用配置

然后就是其他通用配置,我的项目是 Kotlin Multiplatform,所以有一些通用的配置,比如说 sourceSets 的配置,kotlinOptions 的配置,dependencies 的配置等等,如下

kotlin 复制代码
kotlin {
    androidTarget {
        compilations.all {
            kotlinOptions {
                jvmTarget = "11"
            }
        }
    }

    // 下面的配置是关于 KMP 的,参见 https://juejin.cn/post/7324384083428835367 | 【长文】记一次个人 Android 项目全量迁移至 KMP 跨平台的过程
    compilerOptions {
        freeCompilerArgs.addAll("-Xmulti-platform", "-Xexpect-actual-classes")
    }
    
    jvm("desktop")
    
    sourceSets {
        val desktopMain by getting
        
        androidMain.dependencies {
            // ...
        }
        commonMain.dependencies {
            implementation(project(":base-kmp"))
            // ...
        }
        desktopMain.dependencies {
            // ...
        }
    }
}

android {
    namespace = "com.funny.translation.ai"
    compileSdk = libs.versions.android.compileSdk.get().toInt()

    sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")
    sourceSets["main"].res.srcDirs("src/androidMain/res")
    sourceSets["main"].resources.srcDirs("src/commonMain/resources")

    defaultConfig {
        minSdk = libs.versions.android.minSdk.get().toInt()
        targetSdk = libs.versions.android.targetSdk.get().toInt()
    }
    packaging {
        resources {
            excludes += "/META-INF/{AL2.0,LGPL2.1}"
        }
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_11
        targetCompatibility = JavaVersion.VERSION_11
    }
    dependencies {
        debugImplementation(libs.compose.ui.tooling)
    }
}

这其中大部分都是各模块一致的,因此完全可以抽出来。NIA 的项目抽取的非常细致,举点例子:

kotlin 复制代码
// 配置 Android application 模块且使用 Compose
class AndroidApplicationComposeConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            pluginManager.apply("com.android.application")

            val extension = extensions.getByType<ApplicationExtension>()
            configureAndroidCompose(extension)
        }
    }
}

// 配置 Android application 模块的其他通用配置
class AndroidApplicationConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            with(pluginManager) {
                apply("com.android.application")
                apply("org.jetbrains.kotlin.android")
                apply("nowinandroid.android.lint")
                apply("com.dropbox.dependency-guard")
            }

            extensions.configure<ApplicationExtension> {
                configureKotlinAndroid(this)
                defaultConfig.targetSdk = 34
                configureGradleManagedDevices(this)
            }
            extensions.configure<ApplicationAndroidComponentsExtension> {
                configurePrintApksTask(this)
                configureBadgingTasks(extensions.getByType<BaseExtension>(), this)
            }
        }
    }
}

// 配置 Lint
class AndroidLintConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            when {
                pluginManager.hasPlugin("com.android.application") ->
                    configure<ApplicationExtension> { lint(Lint::configure) }

                pluginManager.hasPlugin("com.android.library") ->
                    configure<LibraryExtension> { lint(Lint::configure) }

                else -> {
                    pluginManager.apply("com.android.lint")
                    configure<Lint>(Lint::configure)
                }
            }
        }
    }
}

private fun Lint.configure() {
    xmlReport = true
    checkDependencies = true
}

// 省略更多

对于我自己的项目呢,由于各个模块都是 KMP 的,且都需要 CMP(Compose Multiplatform),因此相似度非常高。因此就不做如此的细分了,只是分别针对主模块(引入 id 为 com.android.application 的插件)和其他模块(引入 id 为 com.android.library 的插件)做差异;别的配置则基本相似,写成一个方法,方便拓展。写出来的对于 Library 的插件如下:

kotlin 复制代码
import com.android.build.api.dsl.CommonExtension
import com.android.build.gradle.LibraryExtension
import com.funny.translation.buildlogic.findVersionAsInt
import com.funny.translation.buildlogic.libs
import org.gradle.api.JavaVersion
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.get
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension

class KMPLibraryCP: Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            with(pluginManager) {
                apply("com.android.library")
            }

            val android = extensions.getByType(LibraryExtension::class.java).apply {
                packaging {
                    resources {
                        excludes += "/META-INF/{AL2.0,LGPL2.1}"
                    }
                }

                compileSdk = libs.findVersionAsInt("android.compileSdk")
            }

            setupCommonKMP(android)
        }
    }
}

fun Project.setupCommonKMP(
    android: CommonExtension<*, *, *, *, *>
) {
    with(pluginManager) {
        apply("kotlin-multiplatform")
        apply("org.jetbrains.compose")
    }

    val kotlin = extensions.getByType(KotlinMultiplatformExtension::class.java)
    kotlin.apply {
        androidTarget {
            compilations.all {
                kotlinOptions {
                    jvmTarget = "11"
                }
            }
        }

        compilerOptions {
            freeCompilerArgs.addAll("-Xmulti-platform", "-Xexpect-actual-classes")
        }

        jvm("desktop")
    }

    android.apply {
        namespace = "com.funny.translation.kmp"


        sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")
        sourceSets["main"].res.srcDirs("src/androidMain/res")
        sourceSets["main"].resources.srcDirs("src/commonMain/resources")

        defaultConfig {
            minSdk = libs.findVersionAsInt("android.minSdk")
            resourceConfigurations.addAll(arrayOf("zh-rCN", "en"))
            ndk.abiFilters.addAll(arrayOf("armeabi-v7a", "arm64-v8a"))
        }

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

        dependencies {
            add("debugImplementation", libs.findLibrary("compose.ui.tooling").get())
        }
    }
}

另一个 KMPApplicationCP 则基本类似,只是 apply 的 Plugin 有差异,然后对应的 android 变成 ApplicationExtension 类型的。

写完后再注册一下:

kotlin 复制代码
// build-logic/build.gradle.kts
val prefix = "transtation"
register("kmpLibrary") {
    id = "$prefix.kmp.library"
    implementationClass = "KMPLibraryCP"
}
register("kmpApplication") {
    id = "$prefix.kmp.application"
    implementationClass = "KMPApplicationCP"
}

// toml
transtation-kmp-library = { id = "transtation.kmp.library", version = "unspecified" }
transtation-kmp-application = { id = "transtation.kmp.application", version = "unspecified" }

然后就可以把原先又臭又长的直接简化为:

kotlin 复制代码
// ai/build.gradle.kts
plugins {
    alias(libs.plugins.kotlinSerialization)
    alias(libs.plugins.transtation.kmp.thirdpartyplugins)
    alias(libs.plugins.transtation.kmp.library)
}

kotlin {
    sourceSets {
        val desktopMain by getting
        
        androidMain.dependencies {

        }
        commonMain.dependencies {
            implementation(project(":base-kmp"))
            implementation(libs.jtokkit)
        }
        desktopMain.dependencies {

        }
    }
}

val NAMESPACE = "com.funny.translation.ai"

android {
    namespace = NAMESPACE
}

buildkonfig {
    packageName = NAMESPACE
}

现在就非常简洁了。

到这里就迁移完啦,相比于原来省下了大量的空间,而且一些修改也不需要处处进行了,被统一到了一处。

源码

具体源码请参见 github.com/FunnySaltyF...

相关推荐
丘狸尾几秒前
[cisco 模拟器] ftp服务器配置
android·运维·服务器
van叶~2 小时前
探索未来编程:仓颉语言的优雅设计与无限可能
android·java·数据库·仓颉
Crossoads6 小时前
【汇编语言】端口 —— 「从端口到时间:一文了解CMOS RAM与汇编指令的交汇」
android·java·汇编·深度学习·网络协议·机器学习·汇编语言
li_liuliu7 小时前
Android4.4 在系统中添加自己的System Service
android
C4rpeDime9 小时前
自建MD5解密平台-续
android
鲤籽鲲11 小时前
C# Random 随机数 全面解析
android·java·c#
m0_5485147715 小时前
2024.12.10——攻防世界Web_php_include
android·前端·php
凤邪摩羯15 小时前
Android-性能优化-03-启动优化-启动耗时
android
凤邪摩羯15 小时前
Android-性能优化-02-内存优化-LeakCanary原理解析
android
喀什酱豆腐15 小时前
Handle
android