背景
我的个人项目 译站 近来 全量迁移至 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-elephants 和 idiomatic-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...