Inside the Android APK Build Pipeline: aapt2, DEX, R8, and Signing
The CI pipeline suddenly failed with No resource found that matches the given name, while local builds still passed. The log pointed to AGP’s resource link phase. It was not an application-code issue; some part of the build toolchain had introduced unexpected behavior. That debugging session pushed me to revisit the full AGP build pipeline.
Those few dozen seconds after clicking Run hide a precise pipeline. Each phase has its own toolchain and optimization strategy.
Resource compilation: aapt2’s two-phase model
Android resource compilation evolved from aapt to aapt2. The key change was splitting the work into two independent phases: compile and link. That split is what makes incremental compilation and parallel processing possible.
Compile phase
AGP runs compile in parallel for each resource module, including AAR dependencies. XML, PNG, and other resources are compiled into .flat intermediate files. This phase parses syntax and converts formats, but it does not assign resource IDs.
aapt2 compile -o build/intermediates/compiled_res/ \
src/main/res/layout/activity_main.xml \
src/main/res/values/strings.xml
Each source file produces one .flat file, stored internally as protobuf binary data. AGP uses the Gradle Worker API to compile multiple modules in parallel; the more modules you have, the more this matters.
Link phase
The link phase merges all .flat files and finishes three jobs: resource ID assignment, symbol table generation, and APK resource packaging.
aapt2 link -o build/outputs/res.apk \
-I ~/android-sdk/platforms/android-33/android.jar \
--manifest AndroidManifest.xml \
--java build/generated/src/ \
build/intermediates/compiled_res/**/*.flat
The link phase produces three important outputs:
res.apk: compiled binary resources, including the resources.arsc resource index tableR.java: resource ID constants referenced by code- ProGuard rules: generated keep rules that prevent R8 from removing classes referenced by resources
A resource ID is made of an 8-bit Package ID, an 8-bit Type ID, and a 16-bit Entry ID. App resources use Package ID 0x7F; system resources use 0x01.
AGP 4.0 introduced Stable IDs for resources. It uses R.txt to record the previous resource ID assignment and asks the link phase to reuse existing IDs first. Without this mechanism, changing one layout file could shift many resource IDs and damage incremental compilation.
DEX generation: d8 and R8 working together
After Java and Kotlin source code is compiled by javac and kotlinc into .class bytecode, the build enters the DEX generation pipeline.
d8 compiler
AGP 3.0 replaced dx with d8. d8 compiles Java bytecode directly into DEX. It is roughly 3 to 5 times faster than dx and can reduce DEX size by about 5%.
d8 --lib ~/android-sdk/platforms/android-33/android.jar \
--output build/intermediates/dex/ \
--min-api 21 \
build/intermediates/classes/**/*.class
d8 also includes desugaring. When minSdk is lower than the API level where a feature was introduced, d8 rewrites higher-level calls into equivalent lower-version implementations. Lambda is the common example: on devices below API 23, d8 compiles a lambda into a static inner class plus method references instead of relying on invokedynamic.
When R8 enters
When minifyEnabled = true, the build inserts R8 before d8. R8 plays three roles at once: shrinking, optimization, and obfuscation.
R8 shrinking is not just deleting unused code. It performs static bytecode analysis and builds a reference graph. When R8 decides a method is unused, it can cascade and remove other methods referenced only by that method. ProGuard rules manually preserve key nodes in that graph.
In one medium-sized project I measured, R8 obfuscation took about 40% of the time ProGuard needed. R8 operates directly at the class level and avoids ProGuard’s conversion overhead between Java and class representations.
MultiDex and main DEX control
When the method count exceeds 65,536, AGP automatically enables multidex packaging. d8 assigns classes based on dependency priority: startup-path classes such as Application, Activity, and Provider go into the main dex, while the rest go into secondary dex files.
After Android 5.0, the ART runtime supports multidex natively, so you no longer need to initialize it manually in Application.attachBaseContext. The main dex size still matters because it directly affects cold-start speed. In practice, a common strategy is to delay nonessential initialization into an IdleHandler, reducing the reference-chain length in the main dex.
Signing evolution: tradeoffs across four generations
APK signing evolved from V1 to V4. Each generation optimizes for a different installation scenario.
V1: JAR signing
This is the basic scheme. It generates MANIFEST.MF, CERT.SF, and CERT.RSA under META-INF. The core drawback is that verification must decompress every ZIP entry, so larger APKs install more slowly.
V2: whole-file signing
Android 7.0 introduced V2. Signature data is inserted before the ZIP central directory as the APK Signing Block. During installation, the system verifies that block instead of walking every entry, which is much faster.
V2 has a hard constraint: after signing, the APK cannot be modified, including ZIP alignment. Any modification changes central directory offsets recorded in EOCD and invalidates the signature. The correct order is align first, then sign.
V3: key rotation
Android 9.0’s V3 builds on the V2 structure and adds key rotation. The APK Signing Block can carry multiple signer records, allowing a new certificate to sign the APK while retaining old certificate history. Users can upgrade through app certificate changes without uninstalling and reinstalling.
V4: incremental installation
Android 11 introduced V4 for Google Play’s incremental install and ADB Incremental flow. V4 signatures are not embedded in the APK. They live in a separate .idsig file. Together with the incfs file system, the installer can verify signatures without reading the full APK.
apksigner sign --ks keystore.jks \
--ks-key-alias key0 \
--v4-signing-enabled true \
app.apk
All four signature schemes can coexist. AGP uses V1+V2 for debug by default and V1+V2+V3 for release. V4 must be enabled explicitly.
Alignment and package optimization
ZIP alignment, or Zipalign, is the final step before release. Uncompressed files inside the APK, such as compiled .arsc and .dex files, are aligned to 4-byte boundaries. This lets the system map them into memory with mmap and avoids extra byte copies.
AGP’s signingConfigs automatically handles alignment and signing order: package, zipalign, then V2/V3 signing. If you do it by hand, remember that the APK structure cannot be changed after V1 signing either.
When shrinkResources = true is enabled, the link phase uses R8’s reference analysis to remove unused resources. In real projects I have seen APK size reductions of 20% to 40%, depending on how much redundant resource content exists.
Two practical tools help when debugging build problems:
- Build Analyzer: built into Android Studio. It analyzes build time and can identify slow dependency downloads, expensive annotation processors, and similar issues.
--profile:./gradlew assembleDebug --profilegenerates an HTML report with task-level timing and dependency details.
If you do not use buildConfigField, disabling buildConfig can remove one incremental-build blocker:
android {
buildFeatures {
buildConfig = false
}
}
After every clean build, BuildConfig.java is regenerated and can trigger a whole compilation chain.
The tools in the APK build pipeline look independent, but the data flowing through them is tightly coupled. The point of understanding this chain is not memorizing every command. It is being able to locate a build failure, package size spike, or installation problem in the correct phase instead of blindly searching through Gradle logs.