はじめに
こんにちは。レシピ事業部で長期インターン中の松本 (@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 ではライブラリの定義(バージョンも含む)を 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 Build
と Convention Plugins
と言った手法を用い、ビルドロジックのみをプロジェクトから分離することでコストの削減、ビルドロジックの共通化を計ることにしました。
Composite Build とは
一言で言えば、「プロジェクトから切り離されたビルド」のことです。Composite Build1 内の各 build は include build
と呼ばれ、include build 同士はロジックを共有せず、個別に構成 & 実行されます。
上の例では my-app
と my-utils
を Composite Build を用いて一つのプロジェクトにまとめています。my-app
は my-utils
に依存を持つことができますが、この場合の依存は「直接的な依存」にはなりません。これらのモジュールは include build であるため、直接的に my-utils
を参照することはできず、build を通して生成された実行可能ファイル(バイナリ)を参照することになります。そのため、buildSrc のような実行速度やキャッシュの問題を発生させずに、あらゆるロジックを記述することが可能になります。
Convention Plugins とは
Convention Plugins3 とはビルドロジックを Gradle Plugin System を用いて共通化し、Plugin として配布する手法のことを指します。Plugin の作成方法については Standalone Gradle Plugin
と Precompiled 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-logic
は cookpad
モジュールに 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 より BaseAppModuleExtension
と LibraryExtension
の基底クラスが 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 では targetSdkVersion
や compileSdkVersion
、その他様々なビルドオプションなどを記述しています。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 のリポジトリの中で具体的な実装例を見れますので、ぜひ参考にしてください。
Plugin の登録
作成した Plugin はスタンドアローンの JAR ファイルとしてプリコンパイルされる Binary Plugin であるため、アプリプロジェクトから参照するためには Gradle に Plugin を登録してあげる必要があります。
前述した build-logic
の build.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
の行数は以下のとおりです。
$ 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 プロジェクト全体の進行にも向上にも大きく貢献することができたと自負しています。