The build pipeline
What ./gradlew assembleRelease actually does: eleven stages from resource merge to signature, which of them are cacheable, and which one is responsible when a release build behaves differently from a debug build.
A release build is not one program. It is eleven programs passing files to each other, and knowing which one failed is most of debugging a build problem.
TL;DR#
assembleReleaseruns a task graph, not a script. Resources are processed before code is compiled, because the generated R class is an input to the compiler.- AAPT2 does resources in two steps —
compileper file, thenlinkonce — which is what makes incremental resource builds possible. - kotlinc and javac produce JVM class files. They do not know what an APK is.
- D8 converts bytecode to DEX and performs desugaring. R8 then shrinks, optimises, obfuscates, and dexes again.
- Packaging, alignment and signing come last. Signing covers the whole archive, so any edit after signing invalidates the APK.
- Almost every "works in debug, crashes in release" bug comes from R8 or from a resource that was shrunk away.
Mental model#
Think of a bakery with a very particular order of operations.
The recipe folder is your res/ directory: lots of separate ingredients in separate files.
Merging gathers them from every source set into one pile. AAPT2 compile preps each one
individually — chopped, labelled, ready. AAPT2 link is the moment they are combined into a single
dish and given names: this is where a resource name becomes a numeric ID.
The cooking is compilation: Kotlin and Java become class files. The kitchen does not care about the recipe folder at all, except that it needs the menu (R class) to know what to call things.
The portioning is DEX — converting class files into something ART can serve. The diet is R8: removing everything nobody ordered, and renaming the rest so competitors cannot read the menu.
Finally the boxing and sealing: package, align, sign. Once the box is sealed, opening it and putting something back in breaks the seal.
Where the analogy breaks#
A bakery throws away the recipe folder once the dish is made. The build keeps every intermediate
around in build/, which is why incremental builds can skip work — and why ./gradlew clean is the
first thing anyone tries when the build is confused. It is also why a build can be fast the second
time and inexplicably slow the third: one input changed, and everything downstream of it is invalid.
The big picture#
Text description of this diagram
An eleven-stage pipeline from source to a signed APK. Stage one is Gradle configuration, which produces a task graph. Stage two merges resources from all source sets. Stage three is AAPT2 compile, per file. Stage four is AAPT2 link, producing resources.arsc and a generated R class. Stage five compiles Kotlin; stage six compiles Java. Stage seven is D8 dexing. Stage eight is R8 shrinking, optimisation and obfuscation. Stage nine packages the APK. Stage ten aligns it. Stage eleven signs it with apksigner. Each stage's output is the next stage's input, and the tool reports what to watch at each step.
Use the player to step through it. The stage that surprises people is AAPT2 link, at position
four: it happens before Kotlin compilation, because the generated R class is what your code
references.1
Deep dive#
Stage 1 — configuration#
Gradle does not start by compiling. It starts by building a model: which projects exist, which plugins apply, which variants exist, what dependencies resolve to. Only then does it compute the task graph.
This is why a Gradle build can spend fifteen seconds doing nothing visible. It is also why the configuration cache exists: if no build script changed, the graph can be reused without re-running any of that logic. See the Gradle mental model.
Stages 2–4 — resources before code#
Android resources are not files copied into a ZIP. They are an indexed, configuration-aware database, and building that database is a small compiler of its own.
Merge (mergeReleaseResources) collects res/ from the main source set, the build type, each
flavour, and every library, in priority order. A resource defined twice is a conflict unless the
priority resolves it.
Compile (compileReleaseResources) runs AAPT2 over each file independently. An XML layout
becomes a binary representation; an image stays an image but gets a flat container. The key property
is that this step is per-file and cacheable: change one drawable and only that drawable is
recompiled.
Link (processReleaseResources) merges all the flat files and the manifest into
resources.arsc, assigns IDs, and emits the generated R class. This is the step that fails when
you reference a resource that does not exist, and the error names the resource, not the code.
Stages 5–6 — Kotlin and Java#
compileReleaseKotlin runs kotlinc against the Android bootclasspath plus the generated R jar.
It produces JVM class files. compileReleaseJavaWithJavac does the same for Java sources and for
generated sources such as kapt stubs.
Two facts worth internalising:
- Kotlin and Java compile against
android.jar, not a real Android device. That jar contains stubs that throw at runtime; it exists to give the compiler signatures, not behaviour. - Annotation processing runs here. kapt generates Java stubs, runs the processor, then compiles them — a full extra round trip. KSP avoids the stub generation for Kotlin processors, which is why it is faster and why Room and Hilt moved to it.
// Annotation processing happens in this stage, not at runtime.
@Entity
data class Note(
@PrimaryKey val id: Long,
val body: String,
)
// Room's processor reads this at build time and generates NoteDao_Impl.
@Dao
interface NoteDao {
@Query("SELECT * FROM Note ORDER BY id")
fun all(): Flow<List<Note>>
}Stage 7 — D8 and desugaring#
D8 converts class files to DEX. Two things happen that are easy to miss.
Desugaring. A lambda, a default method, a try-with-resources block — none of these exist on old Android versions in the form the compiler emits. D8 rewrites them into equivalent constructs the oldest supported API level understands. That is the mechanism behind using Java 8 language features on devices with a much older platform.2
Sharding for multidex. A single DEX file can reference at most 65,536 methods. D8 splits large
apps across several DEX files. On API 21+ the platform loads them natively; below that, the
multidex support library had to be involved, which is why multidexEnabled was once a common
build flag.3
Stage 8 — R8#
R8 is the stage that changes behaviour. It runs three passes that are conceptually separate but implemented together:
- Shrinking — remove unreachable classes, fields and methods.
- Optimisation — inline, merge, propagate constants, remove unused parameters.
- Obfuscation — rename everything that survives.
Then it dexes the result. Because the three passes share one view of the program, R8 can do things ProGuard could not, such as removing a class after inlining has made it unreachable.
The full story is the next page.
Stages 9–11 — package, align, sign#
Packaging writes the ZIP: AndroidManifest.xml (binary XML), resources.arsc, res/, one or more
classes.dex, lib/<abi>/*.so, assets/, and META-INF/ for signatures.
Alignment (zipalign) puts uncompressed entries on 4-byte boundaries so the loader can mmap them
instead of reading them into memory. With 16 KB page-size devices, native libraries need page
alignment too; the build tool handles it, but a hand-packaged APK will not.
Signing (apksigner) writes the v2/v3 signature blocks. Those schemes cover the whole archive, not
just META-INF/, which is why you cannot edit a signed APK and keep the signature valid.
apksigner verify is the check, and it is the last thing to run when a build produces an APK that
will not install.4
Which stage to suspect#
When a release build misbehaves, the stage narrows the search faster than reading code.
- Crash on a class that exists — R8 removed it, or renamed it out from under a reflective lookup.
- Crash on a resource that exists — the resource shrinker removed it; check
keep.xml. - Works on one device, not another — desugaring or a native ABI split, not the pipeline logic.
- Installs but will not launch — packaging or signing, not the code.
- Different behaviour from debug with no crash — an
if (BuildConfig.DEBUG)branch, or a minification-related difference in a serialisation library.
Stage attribution is a skill, and it is learned by breaking builds on purpose: turn on shrinking, remove a keep rule, and watch where the failure appears.
How it evolved#
The pipeline's shape has been stable since the D8/R8 era. What changed is which tool does which step.
| API | Version | What changed |
|---|---|---|
| 1 | 1.0 |
|
| 8 | 2.2 Froyo |
|
| 9 | 2.3 Gingerbread |
|
| 19 | 4.4 KitKat |
|
| 21 | 5.0 Lollipop |
|
| 23 | 6.0 Marshmallow |
|
| 24 | 7.0 Nougat |
|
| 33 | 13 Tiramisu |
|
| 37 | 17 Cinnamon Bun |
|
Measured on this machine#
The pipeline above is not a diagram drawn from memory. A Compose app in this repository
(builder/_template/) was built with AGP 9.4.1, Kotlin 2.4.21 and compileSdk 36, and the real task
names and outputs were captured.
./gradlew :app:assembleDebug --dry-run prints the task graph. The stages that matter, in the order
Gradle runs them:
:app:preBuild
:app:mergeDebugResources → merged res across source sets
:app:compileDebugKotlin → .class files
:app:dexBuilderDebug → per-class DEX
:app:mergeProjectDexDebug → classes.dex + classes2.dex …
:app:mergeExtDexDebug → dependency DEX merged in
:app:packageDebug → the APKAnd the effect is measurable rather than asserted. The same source produced:
| Variant | APK | DEX inside it |
|---|---|---|
| Debug (no R8) | 11.8 MB | 28.2 MB uncompressed across 5 files |
| Release (R8 on) | 1.19 MB | 1.1 MB uncompressed in 1 file |
That is a factor of about ten on a hello-world with no application code of substance: the difference is framework code that R8 removes because nothing reaches it.
Under the hood#
The stages leave fingerprints in the build directory. These are the four files worth knowing:
- the merged resource tree, before AAPT2 compile
- R.txtthe resource id map produced at link time
- mapping.txtR8's rename map; without it a release stack trace is unreadable
- app-release.apkthe signed artifact
The mapping.txt file is the one that matters most in production. Every obfuscated release build
produces a new one, and uploading it to Play is what makes crash reports symbolicated.
Curiosity corner#
assembleDebugis not faster thanassembleReleasebecause of optimisation. It is faster because R8 does not run, and because the debug build skips resource shrinking and signing with a real key.- AAPT2 compiles images too, not just XML. That is why a 4 MB PNG slows the resource stage noticeably and a vector drawable does not.
- The build cache is content-addressed. Two developers with the same inputs can share outputs across machines; a different absolute path does not invalidate the cache entry.
Try it#
./gradlew :app:assembleRelease --console=plain
Example output
> Task :app:mergeReleaseResources
> Task :app:processReleaseResources
> Task :app:compileReleaseKotlin
> Task :app:minifyReleaseWithR8
> Task :app:packageReleaseapksigner verify --verbose app/build/outputs/apk/release/app-release.apk
Example output
Verifies
Verified using v2 scheme (APK Signature Scheme v2): true
Verified using v3 scheme (APK Signature Scheme v3): trueunzip -l app/build/outputs/apk/release/app-release.apk | head -30
Example output
classes.dex
resources.arsc
AndroidManifest.xml
res/
META-INF/Build it#
Take any project and set isMinifyEnabled = true on the release build type. Build both variants,
install both, and exercise the app. Whatever breaks is an R8 story, and the fix begins with a keep
rule.
Check yourself#
Check yourself
Put these build stages in order, from first to last.
Drag, or use the arrows to reorder.
- 1AAPT2 link produces resources.arsc and the R class
- 2D8 converts class files to DEX
- 3kotlinc compiles Kotlin to JVM class files
- 4apksigner writes the v2/v3 signature blocks
Sources#
Sources
Every version number, API level, class name and date on this page is traced to one of these. Pages are re-verified against them; last_verified above says when.
- docsConfigure your builddeveloper.android.comaccessed 2026-10-27↩
- docsJava 8+ API desugaring supportdeveloper.android.comaccessed 2026-10-27↩
- docsAndroid Runtime (ART) and Dalviksource.android.comaccessed 2026-10-27↩
- docsapksignerdeveloper.android.comaccessed 2026-10-27↩
- docsShrink, obfuscate, and optimize your appdeveloper.android.comaccessed 2026-10-27↩
- docsOptimize your build speeddeveloper.android.comaccessed 2026-10-27↩
- docsGradle user manualdocs.gradle.orgaccessed 2026-10-27↩