Gradle Composite Build を用いたビルドロジックの共通化について

はじめに

こんにちは。レシピ事業部で長期インターン中の松本 (@matsumo0922) です。先日このブログでも公開した通り、クックパッドでは日本とグローバルで体験を統一する One Experience というプロジェクトを行っています。

One Experience 以前では Android 開発においても日本とグローバルでコードベースが異なり、それぞれ使用している技術やライブラリが異なる状態でした。特にグローバルのコードベース (以下 global-android と呼びます) では AGP のバージョンも低く、加えて groovy + buildSrc と言った旧世代のビルドロジックを用いていたため、プロジェクトの進行に支障がある状態でした。本記事では、これらの問題を踏まえ One Experience をより円滑に進めるために施したビルドロジックの改善についてお話しします。

TL;DR

  • global-android では旧世代のビルドロジックを使っていたため One Experience 用の機能開発に支障がある状態だった
  • ライブラリ管理を VersionCatalog へ、スクリプトを Kotlin DSL へ移行すると共に、Gradle Composite Build + Convention Plugins を用いてビルドロジックの共通化を行った
  • 最終的に、各モジュールの build.gradle.kts は非常に簡潔になり、大抵のビルドロジックが共通化され、開発をより効率的に進められるようになった

問題点

global-android の Gradle ビルドには以下のような問題点がありました。

  • dependencies.gradle を用いた手動のライブラリ & バージョン管理
  • groovy で書かれたビルドロジック
  • 大量にモジュールがあるにも関わらず、一部ロジックが共通化されていない

global-android でのライブラリ管理

まず、ライブラリのバージョン管理について。上の画像のように、global-android ではライブラリの定義(バージョンも含む)を gradle の ExtraPropertyExtension を用いて記述し、各モジュールに配布していました。この手法の詳細については割愛しますが、ご想像の通りライブラリ管理 & バージョン管理が複雑になり、かつ IDE の手助けや renovate や dependabot と言ったサービスも使うことができないため、開発者の体験を悪くしていました。

次にビルドロジックについて。global-android には buildSrc が導入されていましたが、記述されているのは CI/CD で用いられる共通タスクの定義のみであり、実際のビルドロジックは各モジュールの .gradle ファイルに分散していました。というのも、buildSrc はビルドロジックを記述するのに最適な場所ではあるものの、すべての gradle build のホットパスであるため、あらゆるビルドでコードをコンパイル & チェックを行ってしまうためです。もちろん初回以外はキャッシュが用いられますが、global-android のような巨大プロジェクトでは無視できないコストが発生します。加えて、buildSrc への変更はプロジェクト全体の classpath 変更、つまりキャッシュが無効になるという意味を持つため、buildSrc へビルドロジックを追加するのは慎重にならざるを得ません。

$ find . -type f -name '*.gradle' -exec wc -l {} + | tail -n1  # プロジェクト全体の Groovy ファイルの行数
3736 total

そこで、ライブラリの管理を VersionCatalog に移行しつつ、 gradle の Conposite BuildConvention Plugins と言った手法を用い、ビルドロジックのみをプロジェクトから分離することでコストの削減、ビルドロジックの共通化を計ることにしました。

Composite Build とは

一言で言えば、「プロジェクトから切り離されたビルド」のことです。Composite Build1 内の各 build は include build と呼ばれ、include build 同士はロジックを共有せず、個別に構成 & 実行されます。

Convention Plugins を用いたプロジェクトの例2

上の例では my-appmy-utils を Composite Build を用いて一つのプロジェクトにまとめています。my-appmy-utils に依存を持つことができますが、この場合の依存は「直接的な依存」にはなりません。これらのモジュールは include build であるため、直接的に my-utils を参照することはできず、build を通して生成された実行可能ファイル(バイナリ)を参照することになります。そのため、buildSrc のような実行速度やキャッシュの問題を発生させずに、あらゆるロジックを記述することが可能になります。

Convention Plugins とは

Convention Plugins3 とはビルドロジックを Gradle Plugin System を用いて共通化し、Plugin として配布する手法のことを指します。Plugin の作成方法については Standalone Gradle PluginPrecompiled Script Plugin が存在します。それぞれの Plugin の作成方法については割愛しますが、今回は Composite Build を使用する都合上、直接スクリプトファイルを参照できないため、Standalone Gradle Plugin を用いることにしました。

まとめると、Composite Build を用いてモジュールを作成し(build-logic モジュールとします)、中で Convention Plugins を用いてビルドロジックを共通化する、と言った手法を取ることにしました。

実装

構成

最初に、最終的なディレクトリ構成を示します。

global-android/
├── build-logic/
│   ├── src/
│   │   └── main/
│   │       └── java/
│   │           ├── convention/
│   │           │   └── FeaturePlugin.kt
│   │           └── primitive/
│   │               ├── ApplicationPlugin.kt
│   │               ├── ComposePlugin.kt
│   │               ├── CommonPlugin.kt
│   │               └── FlavorPlugin.kt
│   ├── build.gradle.kts
│   └── settings.gradle.kts
└── cookpad/
    └── ...

大まかには一般的なモジュールの構成と変わりありません。ただ一つ注意が必要な点は、build-logiccookpad モジュールに include build されるため、Gradle 的には一つのプロジェクトとして扱われるということです。そのため、直下に settings.gradle.kts を配置して依存の解決方法や Library Repository を宣言する必要があります。今回は build-logic 内で VersionCatalog も用いるため、VersionCatalog の記述も必要です。

src/ ディレクトリ以下に Plugin を配置します。クックパッドでは、単一の機能や構成を提供する Plugin を Primitive Plugin、Primitive Plugin をまとめ一般化した Plugin を Convention Plugin と呼ぶことにし、それぞれの Plugin をディレクトリを分けて配置しています。

build-logic モジュールの作成

ルート直下に build-logic モジュールを作成します。モジュールの作成方法は問いませんが、AndroidStudioのコンテキストメニューから作成してしまうと、不要な proguard ファイルなどが生成されたり、settings.gradle.kts にモジュールとして追加されるなどのお節介が発生するため、あまりおすすめできません。前述の通り、build-logic は Gradle 的には一つのプロジェクトであるため settings.gradle.kts を配置してください。

dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
    }
    versionCatalogs {
        create("libs") {
            // アプリプロジェクト側の VesionCatalog を参照する
            from(files("../gradle/libs.versions.toml"))
        }
    }
}

rootProject.name = "build-logic"

内容は通常のプロジェクトの settings.gradle.kts と同様です。Content Filtering などの記述もここに行います。一つ例外的なことは VersionCatalog を明示的に記述していることです。本来 Gradle は./gradle/libs.versions.toml にファイルが配置されていると自動的に libs という名前で拡張プロパティを生成しますが、build-logic から見れば toml ファイルは ../gradle/libs.versions.toml に配置されているため、明示的に記述する必要があります。もちろん、build-logic 固有の toml ファイルを別途用意する場合はこの記述は必要ありません。

build.gradle.kts についても、通常のプロジェクトとなんら変わりません。

plugins {
    `kotlin-dsl`
}

java {
    sourceCompatibility = JavaVersion.VERSION_17
    targetCompatibility = JavaVersion.VERSION_17
}

dependencies {
    implementation(libs.android.gradlePlugin)
    implementation(libs.kotlin.gradlePlugin)
}

拡張関数の作成

build-logic 内では VersionCatalog の拡張プロパティが用意されていなかったり、implementation()api() と言った DSL が用意されていないため、自力で実装する必要があります。

// VersionCatalog を取得するための拡張プロパティ
internal val Project.libs: VersionCatalog
    get() = extensions.getByType<VersionCatalogsExtension>().named("libs")

internal fun VersionCatalog.version(name: String): String {
    return findVersion(name).get().requiredVersion
}

internal fun VersionCatalog.library(name: String): MinimalExternalModuleDependency {
    return findLibrary(name).get().get()
}

internal fun VersionCatalog.plugin(name: String): PluginDependency {
    return findPlugin(name).get().get()
}

internal fun VersionCatalog.bundle(name: String): Provider<ExternalModuleDependencyBundle> {
    return findBundle(name).get()
}
// 通常のライブラリを implementation するための拡張関数
internal fun DependencyHandlerScope.implementation(artifact: Project) {
    add("implementation", artifact)
}

// bundle などを implementation するための拡張関数
internal fun DependencyHandlerScope.implementation(artifact: MinimalExternalModuleDependency) {
    add("implementation", artifact)
}

// 通常のライブラリを api するための拡張関数
...

加えて、Plugin 内で Extension を便利に利用するための拡張関数も用意しておきます。Gradle 7.1 より BaseAppModuleExtensionLibraryExtension の基底クラスが CommonExtension に変更されているため、実装を共通化できるようになっています。

// 通常の build.gradle.kts の android ブロックに相当
internal fun Project.androidExt(configure: BaseExtension.() -> Unit) {
    (this as ExtensionAware).extensions.configure("android", configure)
}

// Android Project の build.gradle.kts のスコープ
internal fun Project.commonExt(configure: CommonExtension<*, *, *, *, *, *>.() -> Unit) {
    val plugin = if (isApplicationProject()) BaseAppModuleExtension::class.java else LibraryExtension::class.java
    (this as ExtensionAware).extensions.configure(plugin, configure)
}

// この Project が Application Project であるか判定
internal fun Project.isApplicationProject(): Boolean {
    return project.extensions.findByType(BaseAppModuleExtension::class.java) != null
}

// この Project が Library Project であるか判定
internal fun Project.isLibraryProject(): Boolean {
    return project.extensions.findByType(LibraryExtension::class.java) != null
}

Plugin の実装

global-android では最終的に以下のような Plugin 構成になっています。

  • Convention Plugin
    • cookpad.convention.android.feature
  • Primitive Plugin
    • cookpad.primitive.android.application
    • cookpad.primitive.android.library
    • cookpad.primitive.android.compose
    • cookpad.primitive.android.flavor
    • cookpad.primitive.android.lint
    • cookpad.primitive.detekt
    • cookpad.primitive.common

Primitive Plugin では単一の機能や構成を提供し、他の Primitive Plugin に依存することがないようにする必要があります。Convention Plugin は Primitive Plugin を参照することはできますが、その逆はできません。また、Convention Plugin 同士の参照がないように注意してください。

global-android での Plugin 実装例をいくつか挙げていきます。

FeaturePlugin

cookpad.convention.android.feature は global-android に大量に存在する Feature モジュールが参照する Convention Plugin として実装されています。Primitive Plugin を纏めるだけでなく、モジュールの依存関係やライブラリの依存もここで定義することにより、Feature モジュールとしての実装を強制することが可能になっています。

package convention

class FeaturePlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            with(pluginManager) {
          // 必要な Primitive Plugin を記述
                apply("cookpad.primitive.android.library")
                apply("cookpad.primitive.android.compose")
                apply("cookpad.primitive.android.flavor")
                apply("cookpad.primitive.android.lint")
                apply("cookpad.primitive.common")
                apply("cookpad.primitive.detekt")
            }

            dependencies {
          // Feature モジュールが依存すべきモジュールを記述
                implementation(project(":core"))
                implementation(project(":entity"))
                implementation(project(":usecase"))
                implementation(project(":repository"))
                implementation(project(":view-components"))

                // 共通のライブラリなどを記述
                implementation(libs.bundle("kotlin"))
                implementation(libs.bundle("koin"))

                implementation(libs.library("androidx-appcompat"))
                implementation(libs.library("google-material"))

                testImplementation(libs.bundle("test"))
            }
        }
    }
}

LibraryPlugin

cookpad.primitive.android.library は Library モジュールが利用する Plugin です。Convention Plugin からも参照されていますが、この Plugin では targetSdkVersioncompileSdkVersion、その他様々なビルドオプションなどを記述しています。CommonExtension を利用して設定できる項目は configureAndroid という関数に切り出し、ApplicationPlugin と共通化しています。

package primitive

class LibraryPlugin: Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            // LibraryPlugin は "com.android.library" のみを apply
            // その他の機能が必要な場合は別の Plugin を作成する
            pluginManager.apply("com.android.library")

            extensions.configure<LibraryExtension> {
                // Android Project に必要な設定
                configureAndroid(this)

                defaultConfig.targetSdk = libs.version("targetSdk").toInt()
                buildFeatures.viewBinding = true
                buildFeatures.buildConfig = true
            }
        }
    }
}

internal fun Project.configureAndroid(commonExtension: CommonExtension<*, *, *, *, *, *>) {
    commonExtension.apply {
        defaultConfig {
       // global-android では minSdkVersion や compileSdkVersion なども VersionCatalog に記述している
            minSdk = libs.version("minSdk").toInt()
            compileSdk = libs.version("compileSdk").toInt()
        }

        testOptions {
            animationsDisabled = true
        }

        compileOptions {
            sourceCompatibility = JavaVersion.VERSION_17
            targetCompatibility = JavaVersion.VERSION_17
            encoding = "UTF-8"
        }

        packaging {
            resources.excludes.addAll(
                listOf(...)
            )
        }
    }
}

FlavorPlugin

cookpad.primitive.android.flavor は Build Flavor を設定する Plugin です。先日このブログにも投稿4された通り、 global-android では日本のコードベースを統合する際に applicationId などの都合上、 Build Flavor を用いて日本向けビルドをグローバル向けビルドに切り替える方式を採用しています。デバッグ用、Global Production用、JP Production 用などと複数のビルド設定を各モジュールに記載するのは極めて非効率であるため、Plugin に纏めるようにしています。

package primitive

class FlavorPlugin: Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
           // Build Flavor を設定
            configureFlavors()
            configureVariantFilter()
        }
    }
}

private fun Project.configureFlavors() {
    androidExt {
        // グローバルと日本でバージョニングが異なるため、それぞれのバージョンを取得
        val globalVersion = GlobalVersion.build(project)
        val jpVersion = JpVersion.build(project)

        flavorDimensions(Dimension.REGION, Dimension.DEPLOYMENT_TRACK)

        productFlavors {
            // Global リージョンを設定
            create("global") {
                dimension = Dimension.REGION
                isDefault = true
            }

            // JP リージョンを設定
            create("jp") {
                dimension = Dimension.REGION

                extra.apply {
                    set(ExtraKey.ApplicationId, "com.cookpad.android.activities")
                    set(ExtraKey.VersionCode, jpVersion.getVersionCode(project))
                    set(ExtraKey.VersionName, jpVersion.getVersionName(project))
                }
            }

            // Production トラックを設定
            create("production") {
                dimension = Dimension.DEPLOYMENT_TRACK
                isDefault = true
            }

            ...
        }

        productFlavors.all {
            if (isApplicationProject()) {
                if (extra.has(ExtraKey.ApplicationId)) {
                    applicationId = extra[ExtraKey.ApplicationId].toString()
                }
                if (extra.has(ExtraKey.VersionCode)) {
                    versionCode = extra[ExtraKey.VersionCode].toString().toInt()
                }
                if (extra.has(ExtraKey.VersionName)) {
                    versionName = extra[ExtraKey.VersionName].toString()
                }

                ...
            }
        }
    }
}

private fun Project.configureVariantFilter() {
    androidExt {
        variantFilter {
            if (flavors.map { it.name }.contains(Flavor.DEVELOPERS_SANDBOX)) {
                ignore = true
            }
        }
    }
}

Tips: KTS ファイルを用いて Convention Plugins を作る

今回は org.gradle.Plugin を実装する手法をとっていますが、*.gradle.kts ファイルを用いて Convention Plugins を作る方法も存在します。

実装は非常に簡単で my-convention-plugin.gradle.kts と言ったファイルを build-logic 内の src/ ディレクトリに配置するだけです。src/ 内に配置された *.gradle.kts ファイルは Plugin クラスにプリコンパイルされ、例の場合は my-convention-plugin の部分を key として使うことができるようになります。加えて、後述する Plugin の登録が不要だったり、settings.gradle.kts のロジックを共通化して Convention Plugins にすることができたりなど、様々なメリットも存在します。

通常のビルドロジック共通化には飽きた!という方は、kotlinx-rpc のリポジトリの中で具体的な実装例を見れますので、ぜひ参考にしてください。

github.com

Plugin の登録

作成した Plugin はスタンドアローンの JAR ファイルとしてプリコンパイルされる Binary Plugin であるため、アプリプロジェクトから参照するためには Gradle に Plugin を登録してあげる必要があります。

前述した build-logicbuild.gradle.kts 内で以下のようにして Plugin を登録します

gradlePlugin {
    plugins {
        // Convention Plugins
        register("ConventionFeature") {
            id = "cookpad.convention.android.feature"
            implementationClass = "convention.FeaturePlugin"
        }

        // Primitive Plugins
        register("PrimitiveApplication") {
            id = "cookpad.primitive.android.application"
            implementationClass = "primitive.ApplicationPlugin"
        }
        register("PrimitiveLibrary") {
            id = "cookpad.primitive.android.library"
            implementationClass = "primitive.LibraryPlugin"
        }
        ...
    }
}

Include Build の設定

以上で build-logic モジュールの記述はほぼ完了したため、最後にアプリプロジェクト側で build-logic を include build として指定し、加えてこれまで記述した Plugin に実装を移していきます。

include build として指定する方法は簡単で、アプリプロジェクト側の settings.gradle.kts に以下のように変更を加えるだけです。間違って通常のモジュールと同様に include しないように気をつけてください。

pluginManagement {
    includeBuild("build-logic") // build-logic モジュールを include build する
    repositories {
        google()
        mavenCentral()
    }
}

dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.PREFER_PROJECT)
    repositories {
        google()
        mavenCentral()
    }
}

最後に各モジュールの build.gradle に記述していたビルドロジックを Plugin に移行します。以下の画像はあるモジュールの build.gradle にあった記述を Plugin に移行した例です。サイズの関係上、特に複雑なロジックが記述されていないモジュールの build.gradle を例としていますが、build variant や build flavor を記述しているモジュールの場合、さらに多くの行数を削減することができました。ほぼ全ての記述を Plugin を用いて共通化できているため、最終的な build.gradle.kts のモジュール固有の記述は namespace のみとなっています。

あるモジュールの build.gradle.kts

まとめ

最終的なプロジェクト全体の build.gradle.kts の行数は以下のとおりです。

$ find . -type f -name '*.gradle.kts' -exec wc -l {} + | tail -n1  # 全体の行数
1823 total

Gradle Composite Build + Convention Plugins を用いたビルドロジックの共通化により、記述量を半分近く削減することが可能となりました。

この改善は私が One Experience の開発に携わる際に一番最初に行ったものです。前述した Build Flavor の設定に加え、CI、リリースなどコードベースが統合することでプロジェクトとしてもより複雑なビルドロジックを持たざるを得なくなるだろうと予想し、本格的な開発に入る前に今回の改善を行いました。実際、プロジェクトを進めるにあたって今回お話ししたような複雑なロジックが幾度となく追加されており、今回の改善がこれらのロジックをより簡潔かつ簡易に導入することができる状態としています。global-android のような巨大なプロジェクトでビルドロジックに手を入れるのは非常に困難でしたが、今回の改善が開発者自身の体験、引いては One Experience プロジェクト全体の進行にも向上にも大きく貢献することができたと自負しています。