はじめに
この記事は前回の続きで、今回はGradleが実際にビルドを実行するときに何をしているのか についてまとめています。
ビルドが裏で何をやっているのか、build.gradle.ktsに書いたコードがいつ実行されるのかを掴みたい方はぜひお読みください!
前回がGradleファイルの役割編だったのに対し、今回はその一歩先、書いたビルド設定がどのように実行されるかという実行モデル側の話になります。
↓前回の記事はこちら
https://qiita.com/TJ_droid/items/99953b76b545c16068c7
Gradleにおける「タスク」とは
Gradleのビルドは、内部的にはGradleが実行するタスク(Task)の集まりとして表現されます。assembleDebugもcleanもtestもすべてタスクで、それぞれが「何かしらの処理を行う1単位」として定義されています。
タスクの依存関係(dependsOn)
タスクには依存関係があります。たとえばassembleDebugは内部的に多数のタスクに依存しており、それらが順番に実行されることで最終的にAPKが生成されます。
おおまかには以下のような流れです。
assembleDebug
└─ packageDebug
└─ compileDebugKotlin
└─ checkDebugAarMetadata
└─ preDebugBuild
特定のタスクを実行した際にどんなタスクが芋づる式に呼ばれるかは、--dry-runオプションで確認できます。
実行しても実際にタスクは走らず、実行予定のタスク一覧だけが表示されるので、依存関係の把握にとても便利です。
./gradlew assembleDebug --dry-run
Androidプロジェクトでよく使うタスク
Android開発をする中で日常的に使われるタスクです。
これらは「ビルドバリアント名」と組み合わさることで自動的にタスクが生成されます。たとえばdebug / releaseのほかにstagingDebugのようなフレーバーを定義すればassembleStagingDebugが生えてくる、という仕組みです。
| タスク | 役割 |
|---|---|
assembleDebug / assembleRelease
|
指定したバリアントのAPKを生成 |
bundleRelease |
リリース用のAAB(Android App Bundle)を生成 |
installDebug |
ビルドして接続中の端末/エミュにインストール |
test |
JVM上で動くユニットテストを実行 |
connectedAndroidTest |
実機/エミュ上で動くインスツルメンテーションテストを実行 |
lint |
静的解析を実行 |
clean |
build/ディレクトリを削除 |
Android Studioを使っている場合には直接Gradleタスクを入力する機会はあまりないですが、これはGUI上のボタンがショートカットとして機能しているためです。
GUIが隠蔽していますが、ボタンを押すと内部的には上記のタスクが実行されています。
ビルドライフサイクル
Gradleのビルドは大きく3つのフェーズに分かれて実行されます。全体図は以下のようになっています。
Initialization フェーズ
「どのプロジェクトをビルド対象とするか」を決定するフェーズ。settings.gradle.ktsが評価され、ルートプロジェクトとサブモジュール(:app、:coreなど)が認識されます。
include(":app")
include(":feature:login")
include(":core:network")
このincludeの数だけProjectオブジェクトがメモリ上に生成されます。逆にincludeされていないフォルダは、たとえディレクトリ構成上に存在していてもGradleの認識外となります。
この段階では各モジュールのbuild.gradle.ktsはまだ評価されません。あくまで「ビルド対象のプロジェクトの骨組み」を作る段階です。
Configuration フェーズ
初期化フェーズで生成されたProjectオブジェクトに対して、全モジュールのbuild.gradle.ktsが評価され、タスクグラフが構築されるフェーズ。プラグインが適用され、android { ... }ブロックやdependencies { ... }ブロックの中身が読まれ、最終的に「このビルドではどんなタスクが、どんな依存関係で実行されるか」が決定されます。
そして見落とされがちなのが、実行対象のタスクとは無関係なタスクの定義もすべて評価されるという点です。./gradlew cleanを打った場合でも、Configurationフェーズではプロジェクト全体のタスクが構成されます。マルチモジュール構成でビルドが遅いと感じるとき、実はこのフェーズに時間がかかっていることが多いです。
Configurationフェーズはタスクを実行するわけではない。タスクの「定義」と「依存関係の組み立て」を行うだけで、実際の処理はまだ走りません。
タスクグラフとは
Configurationフェーズの中で構築されるタスクグラフは、タスク同士の依存関係を表現した 有向非巡回グラフ(DAG: Directed Acyclic Graph) です。矢印が一方向かつ循環しないという特徴を持ち、Gradleはこれを使い「どのタスクをどの順で実行するか」を決定します。
assembleDebug
└─ packageDebug
└─ compileDebugKotlin
└─ checkDebugAarMetadata
└─ preDebugBuild
循環依存(A→B→A)があるとここでエラーになります。
Execution フェーズ
Configurationフェーズで組み立てたタスクグラフを実際に実行するフェーズです。./gradlew assembleDebugを実行した場合、assembleDebugに至るまでに必要なタスクが依存関係に沿って順次実行されます。
ログでよく見る> Task :app:compileDebugKotlinのような表示が出るのは、このExecutionフェーズです。
タスクの入力が前回の実行から変更されていない場合、Gradle はそのタスクをスキップします。
Configuration と Execution の違いを理解する
3つのフェーズの中で、特にハマりやすいのが Configuration と Execution の境界です。
よくある勘違い:build.gradle.kts はいつ動くのか
build.gradle.ktsのトップレベルに書いたコードは、cleanのような無関係なタスクを実行したときでもConfigurationフェーズで毎回実行されます。
// ❌ ./gradlew clean を叩いただけでもファイルI/Oが走る
val releaseNotes = File("CHANGELOG.md").readText()
android {
defaultConfig {
versionName = "1.0.0-${releaseNotes.lines().first()}"
}
}
android { }とdependencies { }の設定を書く場所だと割り切って、ファイルの読み込みや外部コマンドの実行をトップレベルに書かないのが基本です。
処理をExecutionフェーズに寄せる: doLast / doFirst
「ビルド時に何らかの処理を走らせたい」というケースでは、トップレベルに書くのではなくタスクの中身として定義するのが定石です。doLast { }やdoFirst { }で包んだ処理は、そのタスクが実行対象に含まれたときだけ、Executionフェーズで走ります。
tasks.register("printReleaseNotes") {
doLast {
// このタスクが実行されたときだけ動く(Executionフェーズ)
println(File("CHANGELOG.md").readText())
}
}
./gradlew cleanでは走らず、./gradlew printReleaseNotesを実行したときだけファイルが読まれます。「設定を書く=Configuration」「処理を走らせる=Execution」という切り分けを意識すると、副作用が無関係なビルドにまで波及するのを防げます。
Configuration Cache の効果
Configurationフェーズは原理上、設定の内容が変わらなければ毎回同じ結果になります。これを利用してフェーズの結果をキャッシュし、2回目以降は丸ごとスキップするのがConfiguration Cacheです。
# gradle.properties
org.gradle.configuration-cache=true
有効化することでビルド時間が大きく短縮されますが、build.gradle.kts内で動的に環境変数やDate()などを参照していると、キャッシュが効かなかったり警告が出たりします。「Configurationフェーズに副作用を書かない」という設計が、結果としてConfiguration Cacheにも効いてくるという構造です。
タスクが「実行される / されない」を決めるもの
Gradleが速いと言われる理由の1つに「変更のないタスクをスキップする」仕組みがあります。これを支えているのがUP-TO-DATEチェックとビルドキャッシュです。
UP-TO-DATE の仕組み(インプットとアウトプット)
各タスクはインプット(ソースファイル、依存ライブラリなど)とアウトプット(.classファイル、APKなど)を宣言しています。Gradleは前回ビルド時のインプットのハッシュとアウトプットを記憶しており、次回ビルド時にインプットが変わっていなければ「実行する必要なし」と判断します。
ビルドキャッシュとの違い
UP-TO-DATEは「同じプロジェクト内で前回と同じ状態か」のチェックですが、ビルドキャッシュは「過去に同じインプットで実行した結果がどこかにあるか」を見ます。たとえばCIで生成した.classファイルを他の開発者のローカルでも再利用する、といったチーム単位での共有が可能になります。
# gradle.properties
org.gradle.caching=true
ローカルキャッシュは~/.gradle/caches/build-cache-1/に保存されます。チーム開発ではリモートキャッシュを構成することで、開発者間でビルド成果物を共有することも可能です。
まとめ
今回はGradleの実行モデルとして、タスクとビルドライフサイクルを整理しました。
- Gradleのビルドはタスクの集まりとして表現され、タスク同士は依存関係でつながっている
- ビルドはInitialization → Configuration → Executionの3フェーズで進む
-
build.gradle.ktsのトップレベルに書いたコードはConfigurationフェーズで毎回実行される - タスクの中身は
doLast { }などに包んで、Executionフェーズで動くようにする - 変更のないタスクはUP-TO-DATEとして自動的にスキップされ、ビルドキャッシュで過去の結果を再利用できる
「build.gradle.ktsに書いたコードがいつ走るのか」が腑に落ちると、ビルドが遅い原因を切り分けたり、Configuration Cacheを安全に有効化したりといった一歩踏み込んだ改善に進めるようになります。
次回は 依存関係の解決(implementation / api / runtimeOnlyなどの違い) について書こうと思います。
最後に
株式会社ジャンボでは、一緒に働くエンジニアを募集しています!
少しでも興味がある方は、ぜひカジュアル面談でお話しましょう!

