为你的 Android 项目创建一个 monorepo

1,229 阅读15分钟

背景

这篇文章描述了如何在没有代码重复、远程托管或版本控制的情况下在你的辅助项目之间共享代码。使用 Gradle 构建系统和一些魔法创建您自己的 Android monorepo 存储库!
什么是monorepo?

在版本控制系统中,monorepo(“mono”表示“单一”,“repo”是“存储库”的缩写)是一种软件开发策略,其中许多项目的代码存储在同一个存储库中。
作为开发人员,我们可以有许多(未完成的)副项目,而且很多时候我们一遍又一遍地做同样的事情。大多数应用程序需要日志记录,大多数应用程序访问网络端点,大多数应用程序加载图像等。
我们的副项目通常涉及复制和粘贴代码,然后在最新版本中修改该代码以进一步改进它(或创建一个库并托管它)。有时我会回到一个旧项目,不得不将我更新的功能复制粘贴回来。其他时候我会忘记我在上一个项目中添加的很棒的功能(例如网络请求的回退),并且新的副项目在那个领域不会那么“花哨”。
monorepo 可以提供帮助。当开始一个新的副项目时,如果你把所有的东西都变成一个模块,那么你就包含了你想要的模块,然后你就可以启动并运行并专注于这个新的副项目的新内容,而不是每次都搭建脚手架和重新发明你的个人轮子。
这是你使用 monorepo 时所拥有的:

image.png

我们可以通过一些解释性的命名来看看项目结构是什么。希望看到两者都能让您在为自己做这件事时理解并映射到两者。这是我们的 monorepo 文件夹结构的样子,以及所涉及的 Gradle 文件的位置,当它完成后:

image.png

为了比较,这是一个“典型”的副项目单个应用程序项目的样子:

image.png

我们可以看到,单独的副项目中的 modules 变成了 monorepo 中的 shared-libraries 。 Gradle 构建逻辑通常在 app 和 moduleN 文件夹中移动到它自己的 “project” 中。在 Gradle 中,这被称为复合构建,是 monorepo 工作方式的关键。
monorepo 从根文件夹开始,根文件夹包含 3 个东西:
1) 副项目
– 曾经/正在处理的每个应用程序(副项目)(每个项目 1 个文件夹)
2) Gradle 构建逻辑
– 我们的 monorepo 和应用程序的构建逻辑(1 个文件夹,每个应用程序都有子文件夹)
- 在应用程序之间共享构建逻辑模块
3) 共享库
- 要在项目之间共享的库(每个库 1 个文件夹)
复合构建只是包含其他构建的构建。在许多方面,复合构建类似于 Gradle 多项目构建,不同之处在于它不包含单个 projects ,而是包含完整的 builds 。

先决条件:文件夹结构

创建一个名为 monorepo 的文件夹,在此文件夹内(即在 monorepo 根文件夹中)创建另一个名为 mono-libraries 的文件夹和另一个名为 mono-build-logic 的文件夹。现在我们可以开始使用 Gradle 配置文件填充这些文件夹。

image.png

如果您想将此 monorepo 添加到版本控制,请确保您从 monorepo 文件夹而不是任何子文件夹中调用 git init

1) 副项目创建

image.png

重要的提示
使用 monorepo,其想法是为每个副项目(每个应用程序)打开一个 IDE(Android Studio)实例。 IDE 使用“gradle roots”原理工作,我们将让每个项目应用程序成为 Gradle 根。也就是说,如果您的 monorepo 有两个项目并且您想同时处理这两个项目,那么您应该运行 IDE 的两个实例。

/tut-app-1/settings.gradle.kts

编辑 /monorepo/tut-app-1/settings.gradle.kts 并在 pluginManagement 块中添加一个 includeBuild 方法调用,这被 Gradle 构建系统称为 Composite Build,它将允许我们将 Android 构建逻辑与我们正在制作的应用程序分离。 includeBuild 将引用我们的 mono-build-logic 文件夹。像这样:

image.png

接下来,我们将让我们的项目包含我们的 shared-library-1(想想 http 或来自 Git 存储库的日logging)。这就是你通常使用 include 方法所做的,你可以看到 app 模块是这样被包含的。对于 monorepo,包含的库必须略有不同,但如果您创建一个辅助方法 (monoInclude),则可以轻松实现。像这样:
include("app")
monoInclude("shared-library-1")
fun monoInclude(name: String) {
    include(":$name")
    project(":$name").projectDir = File("../mono-libraries/$name")
}
我们已经声明我们想要构建 monorepo/mono-libraries/shared-library-1 但我们还没有创建那个模块。

/tut-app-1/build.gradle.kts

对于 monorepo 设置,项目构建文件没有任何异常变化,除了一个。在这里,我们将为 compose_version 声明一个项目额外属性。这将允许我们稍后在我们的应用程序的所有依赖项中引用 compose 的版本号。如果你将你的 monorepo 提升到一个新的水平,你可能会将这个版本控制移动到 mono-build-logic 中。
buildscript {
    extra.apply {
        set("compose_version", "1.0.5")
    }
 
    repositories {
        google()
        mavenCentral()
    }
    dependencies {
       classpath("com.android.tools.build:gradle:7.0.4")
       classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.31")
    }
}
接下来是声明我们的应用程序模块构建文件,同时将大部分工作委托给我们的单一构建逻辑。

/tut-app-1/app/build.gradle.kts

这个文件通常是你的应用程序的构建逻辑所在的地方,但是我们将把它委托给 mono-build-logic 项目,以减少重复。
plugins {
    id("tut-app-1")
}
 
dependencies {
    val implementation by configurations
    val debugImplementation by configurations
 
    implementation(project(":logging"))
 
    val composeVersion = rootProject.extra["compose_version"]
    implementation("androidx.core:core-ktx:1.7.0")
    implementation("androidx.appcompat:appcompat:1.4.1")
    implementation("com.google.android.material:material:1.5.0")
    implementation("androidx.compose.ui:ui:$composeVersion")
    // + other dependencies; see git repo
    debugImplementation("androidx.compose.ui:ui-tooling:$composeVersion")
}
请注意,我们没有像通常的独立应用程序那样声明对 id("com.android.application") 插件的依赖。我们将使用我们自己的插件 tut-app-1 这将有助于减少重复;我们将在那里声明对android插件的依赖(没有魔法,只是移动!)。
为了让 tut-app-1 模块依赖于 shared-library-1(即 Git 存储库中的logging模块),我们使用 implementation(project( 嵌套方法调用。像这样:
dependencies {
 ...
 implementation(project(":shared-library-1"))
 ...
}
请注意,依赖项块使用:

val implementation by configurations
这是因为我们已将 Gradle 构建逻辑从应用程序移至复合构建插件(为避免在 monorepo 中重复),因此,Gradle Kotlin DSL 文件在脚本编译时无法知道应用脚本时这些配置是否可用。因此,这些配置的 Kotlin 扩展(implementation())在脚本编译时不可用。您必须在运行时按名称引用配置。
这就是 /tut-app-1/app/build.gradle.kts 文件完成,这也是 tut-app-1 设置完成。接下来是通过在我们的 mono-build-logic 项目中创建插件来重新创建我们从该文件中省略的构建逻辑。

image.png

2) 共享 Gradle 构建逻辑

我们的构建逻辑项目是我们可以声明构建如何运行的地方。 (好消息是这个部分是一次性的,所以它很多,但我们不需要再做一次!)我们想要声明我们的项目使用 Android,它是一个应用程序并且有可用的库,我们声明通常的东西,比如 minSDK 版本和应用程序版本等。
将此构建逻辑文件夹本身视为项目,它使我们能够制作单独的 Gradle 插件并让我们的每个应用程序项目模块使用这些插件。因此,我们将有一个用于 Android 库的插件,一个用于 Android 应用程序的插件,然后是应用程序特定的插件,然后可以为单个应用程序自定义此共享构建逻辑。
这个构建逻辑项目的第一件事是创建文件结构。我们已经有了一个 mono-build-logic 文件夹。在该文件夹中创建三个子文件夹,分别命名为 android-plugins、android-plugins-k、tut-app-1-plugins。这些将成为该项目中的模块。

image.png

/mono-build-logic/android-plugins/build.gradle.kts

首先,在 android-plugins 文件夹中创建一个名为 build.gradle.kts 的新文件。这是用于声明如何构建此模块(android-plugins)的文件。对于这个模块,我们将使用 Groovy 语言作为插件,monorepo 的其余部分全部使用 Kotlin 语言。我们在这里使用 Groovy,因为它具有更强大的反射语法,因此我们可以配置 Android 构建系统,而无需自己依赖 Android 插件。 id("groovy-gradle-plugin") 使我们能够编写 Groovy Gradle 插件。您的文件最终应如下所示:
plugins {
    id("groovy-gradle-plugin") // This enables src/main/groovy
}
 
dependencies {
    implementation("com.android.tools.build:gradle:7.0.4")
}
现在我们有一个项目来创建 groovy 插件。接下来是在这个模块中声明一个 Groovy 插件,它将设置我们所有 Android 构建的默认值。

/mono-build-logic/android-plugins/src/main/groovy/android-module.gradle

在 android-plugins/src/main/groovy 文件夹中创建一个 android-module.gradle。这将是一个脚本插件,构成将包含在 monorepo 中的任何侧面项目(应用程序)中每个 Android 模块的基础。
当你有这样的脚本插件时,它们在通过 id 引用它们时使用它们的文件名作为名称。例如使用这个插件看起来像 id("android-module") 因为文件名是 android-module.build.kts。
正如你在下面看到的,我们使用 afterEvalute 来检查这个插件附加到的项目(模块)是否是一个 Android 项目。如果是,我们使用所有默认值配置 android 闭包,我们不想在每个应用程序中重复。
我们在这里使用了 Groovy 插件,因为它具有更强大的反射语法,因此我们可以配置 Android 构建系统,而无需直接依赖 Android 应用程序或库插件(因为我们希望它同时适用于两者)。
afterEvaluate { project ->
    if (project.hasProperty("android")) {
        android {
            compileSdk 31
 
            defaultConfig {
                minSdk 28
                targetSdk 30
 
                vectorDrawables {
                    useSupportLibrary true
                }
 
                testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
            }
 
            testOptions {
                unitTests.returnDefaultValues = true
            }
            composeOptions {
                kotlinCompilerExtensionVersion compose_version
            }
            packagingOptions {
                resources {
                    excludes += "/META-INF/{AL2.0,LGPL2.1}"
                }
            }
            compileOptions {
                sourceCompatibility JavaVersion.VERSION_11
                targetCompatibility JavaVersion.VERSION_11
            }
            kotlinOptions {
                jvmTarget = "11"
                useIR = true
            }
            buildFeatures {
                compose true
            }
        }
    }
}
这完全取决于您在这里的默认设置,我会说从这个开始并迁移/覆盖属性,当您在某个应用程序(例如 minSDK)中找到了特定的差异。
这就是 /mono-build-logic/android-plugins/src/main/groovy/android-module.gradle 文件完成。我们现在有一个可重用的插件,它声明了一些基本的共享 android 属性。接下来,我们将创建另一个共享插件项目,以便我们可以声明可专门用于 android 库或 android 应用程序模块的各个插件。

/mono-build-logic/android-plugins-k/build.gradle.kts

我们已经创建了 android-plugins 构建逻辑项目,现在我们需要创建另一个项目 android-plugins-k。在 /mono-build-logic/android-plugins-k/ 文件夹中创建一个名为 build.gradle.kts 的文件。唯一的区别是这个模块包含 Kotlin 脚本插件,而之前的模块包含一个 Groovy 脚本插件。
这个项目(模块)依赖于 kotlin-dsl 插件,这将允许我们编写 Kotlin 脚本插件。请注意,我们还在 Groovy android-plugins 模块上添加了一个 api 依赖项。这允许当前模块访问后一个模块的插件,并使它们可用于任何依赖项目。
plugins {
    `kotlin-dsl` // This enables src/main/kotlin
}
 
dependencies {
    api(project(":android-plugins"))
    implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.31")
    implementation("com.android.tools.build:gradle:7.0.4")
}
现在我们有一个 Kotlin 项目来创建插件。接下来是在这个模块中声明两个 Kotlin 插件,它们将为我们的 Android 库和 Android 应用程序构建设置默认值。

/mono-build-logic/android-plugins-k/src/main/kotlin/app-android-module.gradle.kts

创建 /src/main/kotlin 文件夹结构并在其中创建一个名为 app-android-module.gradle.kts 的文件。这将是我们用于每个 Android 应用程序的插件。我们将在这里声明与应用程序模块有关的细节,但我们不会与任何一个特定的应用程序有关的细节
这个插件依赖于我们已经创建的 android-module 插件。它还声明了对 com.android.application(因为这个插件预计将用于 android 应用程序)和 kotlin-android(因为我们在我们的应用程序中使用 Kotlin)的依赖关系。
然后,该插件为任何依赖它的 android 应用程序声明了两种构建类型。该插件确保发布版本类型已打开minification,并且调试版本已将“debug”添加到其应用程序 ID(即安装包)中。
plugins {
    id("android-module")
    id("com.android.application")
    id("kotlin-android")
}
 
android {
    buildTypes {
        getByName("release") {
            isMinifyEnabled = true
            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
        }
        getByName("debug") {
            isDebuggable = true
            applicationIdSuffix = ".debug"
        }
    }
}
我们现在有一个可重用的 android 应用程序插件,它声明了应用程序模块特定的共享 android 属性。接下来我们将创建另一个共享插件,这次是为共享的 android 库模块。

/mono-build-logic/android-plugins-k/src/main/kotlin/library-android-module.gradle.kts

除了应用插件之外,在 src/main/kotlin 中,创建一个名为 library-android-module.gradle.kts 的文件。这将是我们用于每个 Android 库模块的插件。我们将在这里声明与库模块有关的细节,但我们不会有任何特定库的细节——这将在共享库本身中。
这个插件依赖于我们已经创建的 android-module 插件。它还声明了对 com.android.library(因为这个插件预计将用于 android 库模块)和 kotlin-android(因为我们在我们的应用程序中使用 Kotlin)的依赖关系。
对于库模块,我们在此插件中没有任何其他额外配置。
plugins {
    id("android-module")
    id("com.android.library")
    id("kotlin-android")
}
我们现在有一个可重用的 android 库插件,它声明了库模块特定的共享 android 属性。接下来,我们将为 tut-app-1 端项目创建一个特定的插件来依赖。

image.png

/mono-build-logic/tut-app-1-plugins/build.gradle.kts

在 tut-app-1 文件夹中创建一个名为 build.gradle.kts 的新文件。这是用于声明如何构建此模块 (tut-app-1) 的文件。我们将使用这个模块来创建一个特定于应用程序的构建脚本,允许我们将构建逻辑分离到它自己独立的可测试项目中,并简化 side-project-1 的 Gradle 文件。请注意,我们还添加了对 Kotlin android-plugins-k 模块的实现依赖项。这允许当前模块访问后一个模块的插件(及其 api 声明的 groovy 模块)。
plugins {
    `kotlin-dsl` // This enables src/main/kotlin
}
 
dependencies {
    implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.31")
    implementation("com.android.tools.build:gradle:7.0.4")
    implementation(project(":android-k-plugins"))
}
现在我们有一个构建逻辑项目来创建一个应用程序特定的插件。接下来是为 tut-app-1 创建插件本身。

/mono-build-logic/tut-app-1-plugins/src/main/kotlin/tut-app-1.gradle.kts

src/main/kotlin/ 中创建一个新文件 tut-app-1.gradle.kts,这里是我们依赖 app-android-module 的地方(因为这个插件适用于 Android 应用模块)。
我们设置了特定于此副项目(应用程序)的详细信息。因此我们声明安装的applicationId是什么以及应用的版本。
其余的 Android 构建逻辑在插件中向上处理:app-android-module 和 android-module(我们之前配置过)。如果您想知道,唯一需要声明(即将推出)的是 dependencies 块。这将在副项目(应用程序)本身中处理。
plugins {
    id("app-android-module")
}
 
android {
    defaultConfig {
        applicationId = "com.blundell.tut1"
        versionCode = 1
        versionName = "1.0.0"
    }
}
现在我们有了一个特定于应用程序的插件,它允许我们从构建应用程序中抽象出构建项目的复杂性。接下来是更新 settings.gradle.kts 文件以使这个项目(mono-build-logic)能够构建我们刚刚创建的每个模块(项目)。

/mono-build-logic/settings.gradle.kts

现在让我们在这个项目中让我们创建的所有上述模块都能被 Gradle 识别和编译。在 /mono-build-logic 根文件夹中创建 settings.gradle.kts。创建后,同步您的 IDE,它将识别所有声明的模块。
dependencyResolutionManagement {
    repositories {
        gradlePluginPortal()
        mavenCentral()
        google()
    }
}
 
include("android-plugins")
include("android-k-plugins")
include("tut-app-1-plugins")
接下来是最后一部分,创建可供副项目(应用程序)使用的共享库。

image.png

3) 共享库

创建一个 Android 库项目(或移动一个已有的项目),确保它是在 /mono-repo/mono-libraries 文件夹中创建的。从现在开始,我们称之为 shared-library-1(在 GitHub 存储库示例中,我们有一个 http 和一个日志模块;它们的工作方式完全相同)。

image.png

/mono-libraries/shared-library-1/build.gradle.kts

我们必须启用此模块才能使用 Gradle 构建系统。在 shared-library-1 中创建一个 build.gradle.kts 文件。
在这里,我们想让我们的模块构建一个 Android 库,因此我们依赖于我们新创建的 library-android-module 插件。这允许从我们的共享构建逻辑插件继承 targetSDK、testOptions、compileOptions、packingExcludes 等。
现在对每个库模块唯一要做的就是声明要使用的依赖项。如下:
plugins {
    id("library-android-module")
}
 
dependencies {
    val implementation by configurations
    val testImplementation by configurations
 
    implementation("androidx.core:core-ktx:1.7.0")
    // + other dependencies; see git repo
    implementation("com.squareup.okhttp3:okhttp:4.9.1")
 
    testImplementation("junit:junit:latest.release")
}
对于每个共享库,在构建文件中添加一行以依赖于我们的 library-android-module 插件,声明您的依赖项,然后您就可以启动并运行了。

image.png

回顾

现在有一个附带项目 tut-app-1 ,其中包含一个简洁的 build.gradle.kts 文件,该文件仅声明其依赖项,然后通过 Gradle 插件为 Gradle 配置的其余部分进行委托。
tut-app-1 依赖于 app-android-module 插件。
tut-app-1 依赖于我们的共享库,使用如下声明: implementation(project(":shared-library-1"))
shared-library-1 共享库模块与应用程序 build.gradle.kts 文件的简单性相匹配,因为它只声明其依赖项,然后通过 Gradle 插件为 Gradle 配置的其余部分进行委托。
shared-library-1 依赖于 library-android-module
app-android-module 声明任何特定于您的应用程序构建的构建配置(例如 versionCode)。
library-android-module 声明特定于所有共享库的任何构建配置(例如常量构建配置字段)。
app-android-module 和 library-android-module 都依赖于 android-module 插件。
最后,android-module 插件声明了所有共享的 Gradle 配置,这些配置通常在副项目/应用程序和模块之间重复。

结论

虽然 monorepo 的初始设置比单个项目更深入。设置完成后,它为您提供了一种强大的机制,可以在项目之间共享代码,并将构建配置的职责与应用程序开发分开。
使用 monorepo 需要注意的几件事:
使用 monorepo,其想法是为每个副项目(每个应用程序)打开一个 IDE(Android Studio)实例。 IDE 使用“gradle 根”,每个项目应用程序都是一个 Gradle 根。也就是说,如果您的 monorepo 有两个项目并且您想同时处理这两个项目,那么您应该运行 IDE 的两个实例。
共享库在应用程序之间共享。这意味着如果您更改其中一个库中的公共 API,则使用该 API 的其他应用程序将被破坏并需要修复。如果您可以在打开项目时修复它们或忽略旧的损坏项目,这不是什么大问题。另一种解决方案是,当您发现共享库在项目之间被使用和更新很多时;将其分离为自己的构建并进行版本控制以供发布是一个很好的候选者。