build: build-logic 约定插件 (mcl.*)

This commit is contained in:
AndyChen1024
2026-09-03 19:03:34 +08:00
parent 1b433e2b0e
commit 41a8a7a5f3
18 changed files with 621 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
# DESIGN_NOTES — 约定插件
## 为什么存在
多模块项目里,`compileSdk`/`minSdk`/Java target、Compose、Hilt、Room、Lint、Spotless 等配置若在每个模块重复手写,必然漂移。约定插件把这些配置**集中编码**,模块只声明"我是什么类型"(`mcl.*` 插件),不关心"怎么配"。
这也是 harness-engineering 的"严谨性转移":把约束写进确定性规则,而不是依赖记忆。
## 插件映射
| 插件 id | 实现类 | 作用 |
|---------|--------|------|
| `mcl.kotlin.library` | `JvmLibraryConventionPlugin` | 纯 JVM(kotlin-jvm + jvmTarget 11 + spotless + junit) |
| `mcl.android.library` | `AndroidLibraryConventionPlugin` | Android 库(application/library 插件的库版 + lint + spotless + compileSdk/minSdk) |
| `mcl.android.library.compose` | `AndroidLibraryComposeConventionPlugin` | = library + Compose |
| `mcl.android.application.compose` | `AndroidApplicationComposeConventionPlugin` | = application + Compose |
| `mcl.android.feature` | `AndroidFeatureConventionPlugin` | = library.compose + hilt + 导航/lifecycle/共享模块依赖 |
| `mcl.android.hilt` | `AndroidHiltConventionPlugin` | KSP + Hilt(hilt-android + hilt-compiler) |
| `mcl.android.room` | `AndroidRoomConventionPlugin` | KSP + Room(runtime/ktx/compiler + schema 目录) |
| `mcl.android.lint` | `AndroidLintConventionPlugin` | lint 配置(checkDependencies、disable GradleDependency) |
| `mcl.root` | `RootPlugin` | 注册 `verifyArchitecture` |
## 关键实现要点
- **内置 Kotlin**:AGP 9 内置 Kotlin,因此**不**显式应用 `org.jetbrains.kotlin.android`;Kotlin 编译选项通过 `KotlinAndroidProjectExtension` 的 `compilerOptions` 配置(见 `KotlinAndroid.kt`)。
- **compose 插件自包含**:`*.compose` 插件先 `apply(mcl.android.library/application)` 再叠加 Compose,所以模块只需声明 compose 插件一个。
- **helper 函数**:`KotlinAndroid.kt`(SDK/Java/Kotlin)、`AndroidCompose.kt`(compose feature + BOM + tooling + material3)、`Spotless.kt`(ktlint,无 license header)拆成独立函数,插件只做编排。
- **`libs` 访问器**:`ProjectExtensions.kt` 里的 `val Project.libs` 让插件能引用版本目录(`libs.findLibrary/findVersion`)。
## 如何新增
1. **新模块**:建 `build.gradle.kts`(选对插件 + namespace)+ 空 `AndroidManifest.xml`(Android 模块),在 `settings.gradle.kts` include,在 `RootPlugin.kt` 的 allow-list 加一行。
2. **新插件**:在 `convention/build.gradle.kts` 的 `gradlePlugin { plugins { register(...) } }` 注册,并在 `gradle/libs.versions.toml` 的 `[plugins]` 里加一个 `mcl-*` 条目(无 version)。
3. **改配置**:优先改 helper 函数,让所有适用模块一并生效。
> 注意:`mcl-android-application` 与 `mcl-android-library` 因有 `-compose` 变体(叶子也是前缀),在 `convention/build.gradle.kts` 里引用它们的 plugin id 时要用 `.asProvider().get().pluginId`,其余用 `.get().pluginId`。

View File

@@ -0,0 +1,79 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
`kotlin-dsl`
}
group = "com.mingyuan.androidclient.buildlogic"
// build-logic targets the JDK used to run Gradle (17), unrelated to the device/runtime JDK.
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_17
}
}
dependencies {
compileOnly(libs.android.gradlePlugin)
compileOnly(libs.kotlin.gradlePlugin)
compileOnly(libs.ksp.gradlePlugin)
compileOnly(libs.room.gradlePlugin)
compileOnly(libs.spotless.gradlePlugin)
}
tasks {
validatePlugins {
enableStricterValidation = true
failOnWarning = true
}
}
gradlePlugin {
plugins {
register("androidApplication") {
id = libs.plugins.mcl.android.application.asProvider().get().pluginId
implementationClass = "AndroidApplicationConventionPlugin"
}
register("androidApplicationCompose") {
id = libs.plugins.mcl.android.application.compose.get().pluginId
implementationClass = "AndroidApplicationComposeConventionPlugin"
}
register("androidLibrary") {
id = libs.plugins.mcl.android.library.asProvider().get().pluginId
implementationClass = "AndroidLibraryConventionPlugin"
}
register("androidLibraryCompose") {
id = libs.plugins.mcl.android.library.compose.get().pluginId
implementationClass = "AndroidLibraryComposeConventionPlugin"
}
register("androidFeature") {
id = libs.plugins.mcl.android.feature.get().pluginId
implementationClass = "AndroidFeatureConventionPlugin"
}
register("androidHilt") {
id = libs.plugins.mcl.android.hilt.get().pluginId
implementationClass = "AndroidHiltConventionPlugin"
}
register("androidRoom") {
id = libs.plugins.mcl.android.room.get().pluginId
implementationClass = "AndroidRoomConventionPlugin"
}
register("androidLint") {
id = libs.plugins.mcl.android.lint.get().pluginId
implementationClass = "AndroidLintConventionPlugin"
}
register("kotlinLibrary") {
id = libs.plugins.mcl.kotlin.library.get().pluginId
implementationClass = "JvmLibraryConventionPlugin"
}
register("root") {
id = libs.plugins.mcl.root.get().pluginId
implementationClass = "RootPlugin"
}
}
}

View File

@@ -0,0 +1,21 @@
import com.android.build.api.dsl.ApplicationExtension
import com.mingyuan.androidclient.buildlogic.configureAndroidCompose
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.getByType
/**
* Compose for the application module. Applies [AndroidApplicationConventionPlugin] (compileSdk,
* lint, formatting) and then adds Compose. Apply this single plugin for the app module.
*/
class AndroidApplicationComposeConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "mcl.android.application")
apply(plugin = "org.jetbrains.kotlin.plugin.compose")
configureAndroidCompose(extensions.getByType<ApplicationExtension>())
}
}
}

View File

@@ -0,0 +1,30 @@
import com.android.build.api.dsl.ApplicationExtension
import com.mingyuan.androidclient.buildlogic.configureKotlinAndroid
import com.mingyuan.androidclient.buildlogic.configureSpotless
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
/**
* Base convention for the application module. Applies the Android application plugin, Kotlin +
* Android SDK defaults, lint, and deterministic formatting. Compose is added by
* [AndroidApplicationComposeConventionPlugin].
*/
class AndroidApplicationConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "com.android.application")
apply(plugin = "mcl.android.lint")
extensions.configure<ApplicationExtension> {
configureKotlinAndroid(this)
defaultConfig.targetSdk = 37
defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testOptions.animationsDisabled = true
}
configureSpotless()
}
}
}

View File

@@ -0,0 +1,30 @@
import com.mingyuan.androidclient.buildlogic.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.dependencies
/**
* Convention for a feature module: Compose + Hilt, plus the shared dependencies every feature
* needs (shared UI, design system, domain models, and Navigation). Feature modules should apply
* only this plugin (plus any feature-specific additions).
*/
class AndroidFeatureConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "mcl.android.library.compose")
apply(plugin = "mcl.android.hilt")
dependencies {
"implementation"(project(":core:ui"))
"implementation"(project(":core:designsystem"))
"implementation"(project(":core:model"))
"implementation"(project(":core:domain"))
"implementation"(libs.findLibrary("androidx-navigation-compose").get())
"implementation"(libs.findLibrary("androidx-lifecycle-runtime-compose").get())
"implementation"(libs.findLibrary("androidx-lifecycle-viewmodel-compose").get())
"implementation"(libs.findLibrary("androidx-hilt-navigation-compose").get())
}
}
}
}

View File

@@ -0,0 +1,22 @@
import com.mingyuan.androidclient.buildlogic.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
/**
* Applies Hilt via KSP (not kapt) and wires the Hilt runtime + compiler.
*/
class AndroidHiltConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("com.google.devtools.ksp")
apply("com.google.dagger.hilt.android")
}
dependencies {
"implementation"(libs.findLibrary("hilt-android").get())
"ksp"(libs.findLibrary("hilt-compiler").get())
}
}
}
}

View File

@@ -0,0 +1,21 @@
import com.android.build.api.dsl.LibraryExtension
import com.mingyuan.androidclient.buildlogic.configureAndroidCompose
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.getByType
/**
* Compose for an Android library module. Applies [AndroidLibraryConventionPlugin] (compileSdk,
* lint, formatting) and then adds Compose. Apply this single plugin for Compose-enabled libraries.
*/
class AndroidLibraryComposeConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "mcl.android.library")
apply(plugin = "org.jetbrains.kotlin.plugin.compose")
configureAndroidCompose(extensions.getByType<LibraryExtension>())
}
}
}

View File

@@ -0,0 +1,34 @@
import com.android.build.api.dsl.LibraryExtension
import com.mingyuan.androidclient.buildlogic.configureKotlinAndroid
import com.mingyuan.androidclient.buildlogic.configureSpotless
import com.mingyuan.androidclient.buildlogic.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
/**
* Base convention for Android library modules. Applies the Android library plugin, Kotlin +
* Android SDK defaults, lint, formatting, and shared test dependencies. Compose is added by
* [AndroidLibraryComposeConventionPlugin].
*/
class AndroidLibraryConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "com.android.library")
apply(plugin = "mcl.android.lint")
extensions.configure<LibraryExtension> {
configureKotlinAndroid(this)
defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
configureSpotless()
dependencies {
"testImplementation"(libs.findLibrary("junit").get())
}
}
}
}

View File

@@ -0,0 +1,27 @@
import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.dsl.LibraryExtension
import com.android.build.api.dsl.Lint
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
/**
* Project-wide Android Lint configuration, applied to application and library modules.
*/
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) }
}
}
}
}
private fun Lint.configure() {
checkDependencies = true
disable += "GradleDependency"
}

View File

@@ -0,0 +1,35 @@
import androidx.room.gradle.RoomExtension
import com.google.devtools.ksp.gradle.KspExtension
import com.mingyuan.androidclient.buildlogic.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
/**
* Applies Room (via KSP) and wires runtime/ktx/compiler, generating Kotlin code and storing
* database schemas for future auto-migrations.
*/
class AndroidRoomConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "androidx.room")
apply(plugin = "com.google.devtools.ksp")
extensions.configure<KspExtension> {
arg("room.generateKotlin", "true")
}
extensions.configure<RoomExtension> {
schemaDirectory("$projectDir/schemas")
}
dependencies {
"implementation"(libs.findLibrary("androidx-room-runtime").get())
"implementation"(libs.findLibrary("androidx-room-ktx").get())
"ksp"(libs.findLibrary("androidx-room-compiler").get())
}
}
}
}

View File

@@ -0,0 +1,25 @@
import com.mingyuan.androidclient.buildlogic.configureKotlinJvm
import com.mingyuan.androidclient.buildlogic.configureSpotless
import com.mingyuan.androidclient.buildlogic.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.dependencies
/**
* Convention for pure Kotlin/JVM modules (no Android dependency) such as :core:model and
* :core:domain. Keeping these modules Android-free keeps them fast to test and forces domain
* logic to stay framework-agnostic.
*/
class JvmLibraryConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "org.jetbrains.kotlin.jvm")
configureKotlinJvm()
configureSpotless()
dependencies {
"testImplementation"(libs.findLibrary("junit").get())
}
}
}
}

View File

@@ -0,0 +1,89 @@
import org.gradle.api.GradleException
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.register
import java.io.File
/**
* Root convention plugin. Registers the `verifyArchitecture` task, which enforces the module
* dependency direction declared in `docs/architecture.md`.
*
* The allow-list lives in ONE place here; adding a module means adding a line to [allowList]
* (and mirroring it in the architecture doc). The check reads each module's `build.gradle.kts`
* as a file (config-cache safe) and rejects any `project(":...")` reference that is not allowed.
*/
class RootPlugin : Plugin<Project> {
override fun apply(target: Project) {
val featureAllowed = setOf(
":core:ui",
":core:designsystem",
":core:domain",
":core:model",
)
val allowList = mapOf(
":app" to setOf(
":core:common", ":core:model", ":core:domain", ":core:designsystem",
":core:ui", ":core:data", ":core:network", ":core:database", ":core:datastore",
":feature:auth", ":feature:checkin", ":feature:plan", ":feature:course",
":feature:survey", ":feature:feedback", ":feature:profile",
),
":core:common" to emptySet(),
":core:model" to emptySet(),
":core:domain" to setOf(":core:common", ":core:model"),
":core:designsystem" to setOf(":core:common"),
":core:ui" to setOf(":core:designsystem", ":core:model"),
":core:network" to setOf(":core:common", ":core:model"),
":core:database" to setOf(":core:model"),
":core:datastore" to setOf(":core:model"),
":core:data" to setOf(
":core:domain", ":core:model", ":core:network", ":core:database", ":core:datastore",
),
":feature:auth" to featureAllowed,
":feature:checkin" to featureAllowed,
":feature:plan" to featureAllowed,
":feature:course" to featureAllowed,
":feature:survey" to featureAllowed,
":feature:feedback" to featureAllowed,
":feature:profile" to featureAllowed,
)
val rootDir = target.rootDir
target.tasks.register("verifyArchitecture") {
group = "verification"
description = "Enforce module dependency direction (see docs/architecture.md)"
doLast {
val projectDependency = Regex("""project\("(:[^"]+)"\)""")
val violations = mutableListOf<String>()
for ((modulePath, allowed) in allowList) {
val buildFile = File(
rootDir,
"${modulePath.removePrefix(":").replace(":", "/")}/build.gradle.kts",
)
if (!buildFile.exists()) {
violations += "$modulePath: missing build.gradle.kts"
continue
}
val declared = projectDependency
.findAll(buildFile.readText())
.map { it.groupValues[1] }
.toSet()
for (dep in declared) {
if (dep !in allowed) violations += "$modulePath -> $dep"
}
}
if (violations.isNotEmpty()) {
throw GradleException(
"Architecture violations (see docs/architecture.md):\n" +
violations.joinToString("\n") { " - $it" },
)
}
logger.lifecycle("Architecture check passed: no forbidden module dependencies.")
}
}
}
}

View File

@@ -0,0 +1,29 @@
package com.mingyuan.androidclient.buildlogic
import com.android.build.api.dsl.CommonExtension
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
/**
* Configure Compose-specific options for an Android module: enable the Compose build feature,
* add the Compose BOM so all Compose artifacts share one version, and wire the preview/tooling
* dependencies used by Android Studio.
*/
internal fun Project.configureAndroidCompose(commonExtension: CommonExtension) {
commonExtension.apply {
buildFeatures.apply {
compose = true
}
dependencies {
val bom = libs.findLibrary("androidx-compose-bom").get()
"implementation"(platform(bom))
"androidTestImplementation"(platform(bom))
"implementation"(libs.findLibrary("androidx-compose-ui").get())
"implementation"(libs.findLibrary("androidx-compose-ui-graphics").get())
"implementation"(libs.findLibrary("androidx-compose-material3").get())
"implementation"(libs.findLibrary("androidx-compose-ui-tooling-preview").get())
"debugImplementation"(libs.findLibrary("androidx-compose-ui-tooling").get())
}
}
}

View File

@@ -0,0 +1,66 @@
package com.mingyuan.androidclient.buildlogic
import com.android.build.api.dsl.CommonExtension
import org.gradle.api.JavaVersion
import org.gradle.api.Project
import org.gradle.api.plugins.JavaPluginExtension
import org.gradle.kotlin.dsl.assign
import org.gradle.kotlin.dsl.configure
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinBaseExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension
/**
* Configure base Kotlin + Android options shared by every Android module.
*
* compileSdk / minSdk are the project-wide contract (see docs/architecture.md); Java/Kotlin
* target stays at 11 so library modules and the app agree on bytecode level.
*/
internal fun Project.configureKotlinAndroid(commonExtension: CommonExtension) {
commonExtension.apply {
compileSdk = 37
defaultConfig.apply {
minSdk = 28
}
compileOptions.apply {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}
configureKotlin<KotlinAndroidProjectExtension>()
}
/**
* Configure base Kotlin options for pure JVM (non-Android) modules such as :core:model.
*/
internal fun Project.configureKotlinJvm() {
extensions.configure<JavaPluginExtension> {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
configureKotlin<KotlinJvmProjectExtension>()
}
private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() = configure<T> {
// Treat Kotlin warnings as errors only when the `warningsAsErrors` Gradle property is set.
val warningsAsErrors = providers.gradleProperty("warningsAsErrors").map {
it.toBoolean()
}.orElse(false)
when (this) {
is KotlinAndroidProjectExtension -> compilerOptions
is KotlinJvmProjectExtension -> compilerOptions
else -> TODO("Unsupported project extension $this ${T::class}")
}.apply {
jvmTarget = JvmTarget.JVM_11
allWarningsAsErrors = warningsAsErrors
freeCompilerArgs.add(
// Enable experimental coroutines APIs, including Flow.
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
)
}
}

View File

@@ -0,0 +1,16 @@
package com.mingyuan.androidclient.buildlogic
import org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalog
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.getByType
/**
* Access to the shared `libs` version catalog from within convention plugins.
*
* The catalog is declared in `gradle/libs.versions.toml` and wired into the included
* build via `build-logic/settings.gradle.kts`, so build-logic and the main build share
* one source of truth for versions.
*/
val Project.libs
get(): VersionCatalog = extensions.getByType<VersionCatalogsExtension>().named("libs")

View File

@@ -0,0 +1,29 @@
package com.mingyuan.androidclient.buildlogic
import com.diffplug.gradle.spotless.SpotlessExtension
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
/**
* Enforce deterministic Kotlin formatting with ktlint (rules driven by `.editorconfig`).
*
* No license header is applied — this is a private project. Formatting only; run
* `./gradlew spotlessCheck` (verify) or `./gradlew spotlessApply` (fix).
*/
internal fun Project.configureSpotless() {
apply(plugin = "com.diffplug.spotless")
extensions.configure<SpotlessExtension> {
kotlin {
target("src/**/*.kt")
ktlint(libs.findVersion("ktlint").get().requiredVersion).editorConfigOverride(
mapOf(
"android" to "true",
// 包名 com.mingyuan.android_client 含下划线,刻意保留(见 docs/decisions/0004)。
"ktlint_standard_package-name" to "disabled",
),
)
endWithNewline()
}
}
}