← All problems

Mobile Core Concepts

Build Systems

Why the build is an architecture decision - the module graph and tooling knobs that decide whether a team ships weekly or watches CI all day.

Free~20 min

The build system is the longest-running, most-frequently-invoked program in a mobile engineer's day. A 90-second incremental build is a 90-second context switch every line changed, and at fleet scale it is the largest single drag on engineering throughput. This page covers Gradle as the Android default (AGP, version catalogs, the configuration cache, build variants), Bazel as the monorepo alternative a handful of large orgs adopt, the perf knobs that decide whether the dev loop is two seconds or two minutes, and how the DI graph and module split from architecture line up with the build graph. Packaging - APK vs AAB, dynamic features, R8 - lives in the sibling app-size-and-delivery entry.

Gradle on Android

Gradle is what Android Studio ships with, and the only system AGP (the Android Gradle Plugin) targets. AGP is authored by the Android team and adds the android { ... } extension, the build-variant model, R8/ProGuard, lint, packaging, and signing. Every build.gradle.kts in an Android project is a Gradle script driving AGP tasks.

The script is Kotlin by default in 2025-era projects. Kotlin DSL (build.gradle.kts) replaced Groovy DSL (build.gradle) as the recommended dialect around AGP 7.0 because the Kotlin compiler gives the IDE autocompletion, type-checked property access, and refactor-safety. Groovy still works on legacy projects, but new projects choose Kotlin.

plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
}
 
android {
    namespace = "com.example.app"
    compileSdk = 35
    defaultConfig { minSdk = 26; targetSdk = 35 }
}

The alias(libs.plugins.x) shape is a version catalog reference, covered below.

Version catalogs

A version catalog (gradle/libs.versions.toml) is the canonical place to declare every dependency, library, and plugin version. Before catalogs, the same dependency string would be copy-pasted across ten build.gradle.kts files and drift; the catalog gives one declaration and one upgrade point.

[versions]
kotlin = "2.1.0"
agp = "8.7.0"
hilt = "2.52"
 
[libraries]
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
 
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }

Modules declare implementation(libs.hilt.android). Renovate / Dependabot bump versions by editing one file, the IDE autocompletes libs.x.y from the catalog, and a typo'd dependency name is a build-time error rather than a download-the-wrong-jar surprise.

Build variants and product flavors

AGP models the cross-product of build types (debug, release, custom) and product flavors (free vs paid, prod vs staging, googlePlay vs huawei). The cross-product produces variants: freeDebug, paidRelease, prodGooglePlayRelease. Each variant gets its own assembleVariant task and can override resources, BuildConfig fields, and source sets.

Keep flavor count low (every flavor multiplies build time by N) and prefer build-time BuildConfig constants over runtime feature flags for things that genuinely differ per build (the backend URL for staging vs prod), runtime flags for everything else.

The Gradle daemon

Gradle runs as a long-lived background JVM daemon. The first invocation pays JVM startup and AGP class-loading; subsequent invocations reuse the daemon and skip that cost. A killed daemon (machine sleep, OOM, --stop) pays cold-start again on the next build - 5 to 15 seconds for a non-trivial project. The daemon caches the project model across invocations; the configuration cache (next section) extends that to the entire task graph.

The configuration cache

The configuration cache is the single biggest dev-loop perf knob in modern Gradle. Without it, every build re-runs every build.gradle.kts script top-to-bottom to compute the task graph, even when no script changed - the "configuration phase" routinely takes 5-20 seconds on a multi-module Android project before any compilation starts.

With org.gradle.configuration-cache=true, Gradle serializes the configured task graph to disk and reuses it on subsequent invocations. A cache hit skips the configuration phase entirely. On a typical multi-module project this turns a 25-second incremental build into a 5-second one.

The cache invalidates when any build.gradle.kts, settings.gradle.kts, gradle.properties, or the version catalog changes, when an environment variable a script reads changes, or when any fingerprinted input is mutated. Plugins must be CC-compatible: they cannot capture Project instances into task actions or use the deprecated Task.project accessor at execution time. AGP has been CC-compatible since 8.0.

# gradle.properties
org.gradle.configuration-cache=true
org.gradle.configuration-cache.problems=warn  # report incompatibilities without failing

The trap: a single non-compliant third-party plugin disables the cache silently for the whole project. Check build/reports/configuration-cache/ after enabling - the HTML report names every problem and the plugin responsible. Once clean, switch to org.gradle.configuration-cache.problems=fail so a future regression breaks the build instead of silently re-accumulating.

Dependency resolution

Gradle resolves the transitive dependency graph at configuration time. Conflicts (two libraries pulling different OkHttp versions) resolve by "newest wins" by default; force a specific version with a resolution rule:

configurations.all {
    resolutionStrategy {
        force("com.squareup.okhttp3:okhttp:4.12.0")
        failOnVersionConflict()  // surface conflicts loudly instead of papering over
    }
}

implementation vs api is the visibility rule deciding whether a transitive dependency leaks. A library declared implementation("retrofit") in :core-network is invisible to :feature-feed even though feature-feed depends on core-network; :feature-feed must declare Retrofit itself. api("retrofit") would expose it transitively. The default implementation keeps module boundaries enforced by the compiler instead of by code review.

Sources: Android Gradle Plugin release notes - Gradle configuration cache docs

Bazel for Android

Bazel is Google's open-source build system, derived from the internal Blaze. It targets hermetic, reproducible, remote-cached, remote-executed builds at monorepo scale. The Android users in production - Google itself, Square, Dropbox, Pinterest, Lyft - share one trait: build time gates productivity for hundreds of engineers and Gradle's incremental story stops scaling.

The mental model differs from Gradle's. A Gradle module is a collection of source folders configured by a script. A Bazel target is a node in a graph declared in a BUILD file. Every target is hermetic - its inputs and outputs are fully declared, so Bazel can hash inputs, look up the hash in a remote cache, and download the prebuilt output instead of rebuilding. A CI machine that just built :feed populates the cache; every developer pulling main gets the prebuilt artifact.

Remote execution goes further: Bazel ships the entire compilation action to a fleet of remote workers, fans out across the dependency graph, and downloads results. A 10-minute serial Gradle build becomes a 90-second parallel Bazel build when 50 workers run in parallel.

# BUILD.bazel
kt_android_library(
    name = "feed",
    srcs = glob(["src/main/kotlin/**/*.kt"]),
    deps = [
        "//core/network",
        "@maven//:com_squareup_retrofit2_retrofit",
    ],
)

rules_kotlin and rules_android are the rule sets that make Kotlin + Android targets buildable; they wrap kotlinc, AGP's resource processing, dexing, and packaging into Bazel actions.

Rule of thumb: migration pays back when build time gates productivity for more than 50 engineers, or when the org already runs a multi-language monorepo (server + mobile + web) and wants one build tool across all of it. Below that threshold, the migration cost dominates the savings.

Build performance knobs

In rough order of impact:

1. Configuration cache

Covered above. Single biggest knob; turn it on, fix incompatible plugins, watch the configuration phase disappear.

2. Parallel project execution and the build cache

org.gradle.parallel=true lets Gradle execute tasks from independent modules in parallel. org.gradle.caching=true enables the build cache - a content-addressable store of task outputs keyed by input hashes. A clean build that hits the cache for unchanged modules skips re-compilation. The cache can be local (per developer) or remote (shared across CI and developers, the typical setup for orgs >20 engineers).

org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=true

3. KSP vs KAPT

KAPT (Kotlin Annotation Processing Tool) is the legacy generator pipeline: compile Kotlin to Java stubs, run Java annotation processors against the stubs, then re-run kotlinc against the original sources. The double compilation is expensive - on a Hilt-heavy module it routinely doubles compilation time.

KSP (Kotlin Symbol Processing) is the modern replacement, native to the Kotlin compiler. It reads Kotlin source directly without the stub round-trip and typically halves annotation-processing time. Hilt, Room, Moshi, kotlinx-serialization, and DataStore all support KSP as of 2024-25; migration is mostly mechanical (swap the plugin id, swap kapt(...) for ksp(...)). Migrate every annotation processor in a module in one PR - KAPT and KSP running in the same module fight each other for generated-source directories and ordering, so leaving Hilt on KAPT while Room moves to KSP is worse than either alone.

4. Keep compileSdk and the Kotlin version current

Every AGP and Kotlin release lands compiler perf work. A project pinned to AGP 7.x and Kotlin 1.8 leaves 20-30% on the table that AGP 8 and Kotlin 2.x deliver via the K2 frontend, faster incremental compilation, and improved CC support.

5. Configuration-time work is the hidden tax

Plugins doing real work during configuration (resolve all dependencies eagerly, scan the source tree, hit the network) inflate the configuration phase even with the cache enabled, because cache misses re-pay it. The --scan flag generates a build scan naming every plugin's configuration cost; check it any time configuration exceeds a few seconds.

Multi-module concerns

A non-trivial Android app splits into modules: :app, :feature-feed, :feature-chat, :core-network, :core-database, :core-ui. The split parallelizes compilation, enforces architectural boundaries (a feature cannot accidentally import another feature's internals), and enables on-demand delivery via dynamic feature modules.

The module graph must be a DAG. Circular dependencies fail at sync time; the fix is almost always to extract the shared piece into a :core- module or an :api/:impl split (interface-only :feature-chat-api consumed by other features, implementation :feature-chat-impl consumed only by :app). See architecture for the layer model the split enforces.

The DI graph from dependency-injection overlays the build graph. Hilt's @HiltAndroidApp lives only in :app - the aggregating root that pulls together every @InstallIn(SingletonComponent::class) module declared anywhere in the build. KSP runs in every module declaring Hilt bindings, but the cross-module aggregation runs once at :app. That is why :app builds last and why a binding error in any module surfaces as an :app compile failure.

R8 (the optimizer/shrinker that replaced ProGuard) runs only on :app during release builds. Library modules ship un-shrunken; the app shrinks the transitive closure. A misconfigured consumer-rules.pro in a library is the canonical cause of "release build crashes but debug works" - the library declared rules its consumers needed, but they did not propagate.

See also

  • Architecture - the module split (:app, :feature-*, :core-*) is the build graph's shape; the layer model decides what depends on what.
  • Dependency injection - Hilt's @HiltAndroidApp is the build's aggregating root; KSP vs KAPT for the DI processor is the biggest single annotation-processing cost in a typical app.
  • App Size and Delivery - APK vs AAB, dynamic feature modules, R8 shrinking, baseline profiles. The build-and-delivery story continues with what comes out of the build.
  • Performance and profiling - Macrobenchmark lives in a com.android.test module that depends on the release app; the build-system story is the substrate for that setup.

Used in

  • Photo Gallery App - the multi-module split (:feature-gallery, :feature-upload, :core-media, :core-network) is a build-graph exercise; Hilt aggregates at :app.
  • Messenger App - feature modules per surface (chat, contacts, settings) with :core-network and :core-database shared; the version catalog pins OkHttp / Retrofit / Room versions in one place.
  • Doc Editor App - if the renderer splits into a second process, two app components share :core-crdt; module boundaries enforce the process boundary at compile time.
  • Crash Reporter SDK - shipped as a library AAR with a consumer-rules.pro propagating R8 keep rules to host apps; the build configures both the published artifact and the demo :app.
  • Analytics SDK - ships as a multi-module AAR (:analytics-api, :analytics-impl); host apps depend on :analytics-api to enforce interface stability across SDK versions.

Done reading? Mark it so it sticks in your dashboard.