The Android build process takes your source code and turns it into an installable APK (or AAB) through several steps. Gradle orchestrates everything. AAPT2 compiles resources and generates R.java. The Kotlin/Java compiler produces .class files. D8 converts those to Dalvik bytecode (.dex files). R8 shrinks and obfuscates the code. Everything gets packaged into an APK, aligned with zipalign, and signed with your key. The whole process takes seconds for debug builds and a bit longer for release builds with optimization enabled.
You write your Kotlin code, hit the Run button in Android Studio, and a few seconds later your app is running on your phone. But what actually happens in between? There is a whole pipeline of tools that compile, transform, package, and sign your code before it becomes an installable app.
Understanding this pipeline makes you a better Android developer. You will know why builds are slow, how to fix them, why ProGuard breaks things, what actually goes inside an APK, and how to configure your build for different environments.
This guide walks through every step of the Android build process, from source code to a running app.
The Big Picture
Before we get into the details, here is the full build pipeline at a glance:
flowchart TD
subgraph Input["fa:fa-code Source Files"]
direction LR
A1[Kotlin / Java]
A2[XML Resources]
A3[AndroidManifest.xml]
A4[Assets]
A5[Libraries / AARs]
end
A2 & A3 --> B1[fa:fa-cogs AAPT2]
B1 -->|R.java| B2
A1 & A5 --> B2[fa:fa-cogs Kotlin / Java Compiler]
B2 -->|.class files| B3[fa:fa-cogs D8 / R8]
B3 -->|.dex files| C1
B1 -->|compiled resources| C1
A4 --> C1[fa:fa-box APK Packager]
C1 --> C2[fa:fa-box zipalign]
C2 --> C3[fa:fa-lock APK Signing]
C3 -->|Signed APK| D[fa:fa-mobile-alt Device]
style A1 fill:#dbeafe,stroke:#3b82f6,stroke-width:2px
style A2 fill:#dbeafe,stroke:#3b82f6,stroke-width:2px
style A3 fill:#dbeafe,stroke:#3b82f6,stroke-width:2px
style A4 fill:#dbeafe,stroke:#3b82f6,stroke-width:2px
style A5 fill:#dbeafe,stroke:#3b82f6,stroke-width:2px
style B1 fill:#fef3c7,stroke:#f59e0b,stroke-width:2px
style B2 fill:#fef3c7,stroke:#f59e0b,stroke-width:2px
style B3 fill:#fef3c7,stroke:#f59e0b,stroke-width:2px
style C1 fill:#dcfce7,stroke:#16a34a,stroke-width:2px
style C2 fill:#dcfce7,stroke:#16a34a,stroke-width:2px
style C3 fill:#dcfce7,stroke:#16a34a,stroke-width:2px
style D fill:#fee2e2,stroke:#dc2626,stroke-width:2px
Source files go in. A signed APK comes out. Everything in the middle is handled by Gradle and the Android SDK tools.
Let’s walk through each step.
Step 1: Resource Compilation (AAPT2)
The first thing that happens during a build is resource compilation. AAPT2 (Android Asset Packaging Tool 2) takes all the resources from your res/ directory and the AndroidManifest.xml and processes them.
What AAPT2 Does
AAPT2 works in two phases:
Compile phase: Each resource file (layouts, drawables, strings, etc.) is individually compiled into a binary format. This is where AAPT2 validates your XML and catches errors like referencing a color that doesn’t exist.
Link phase: All the compiled resources are merged together. AAPT2 generates two things:
- The resource table (
resources.arsc) containing all resource values - The R.java file with integer IDs for every resource
flowchart TD
subgraph Resources["fa:fa-folder res/ Directory"]
L[layout/activity_main.xml]
D[drawable/icon.png]
S[values/strings.xml]
C[values/colors.xml]
end
subgraph AAPT2["fa:fa-cogs AAPT2"]
CP[Compile Phase]
LP[Link Phase]
end
M[AndroidManifest.xml]
L --> CP
D --> CP
S --> CP
C --> CP
CP -->|binary resources| LP
M --> LP
LP --> R[R.java]
LP --> RT[resources.arsc]
style L fill:#dbeafe,stroke:#3b82f6,stroke-width:2px
style D fill:#dbeafe,stroke:#3b82f6,stroke-width:2px
style S fill:#dbeafe,stroke:#3b82f6,stroke-width:2px
style C fill:#dbeafe,stroke:#3b82f6,stroke-width:2px
style M fill:#dbeafe,stroke:#3b82f6,stroke-width:2px
style CP fill:#fef3c7,stroke:#f59e0b,stroke-width:2px
style LP fill:#fef3c7,stroke:#f59e0b,stroke-width:2px
style R fill:#dcfce7,stroke:#16a34a,stroke-width:2px
style RT fill:#dcfce7,stroke:#16a34a,stroke-width:2px
Understanding R.java
When you write R.layout.activity_main or R.drawable.icon in your code, you are referencing an integer ID from the R.java file. Here is what a simplified R.java looks like:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public final class R {
public static final class layout {
public static final int activity_main = 0x7f030001;
public static final int fragment_home = 0x7f030002;
}
public static final class drawable {
public static final int icon = 0x7f020001;
public static final int background = 0x7f020002;
}
public static final class string {
public static final int app_name = 0x7f040001;
public static final int welcome_message = 0x7f040002;
}
}
The Android runtime uses these IDs to look up the actual resources from the compiled resource table at runtime. This is why you never edit R.java manually. It gets regenerated every time you build.
AAPT2 vs the Original AAPT
The original AAPT (which came with older Android SDK versions and was located at $ANDROID_HOME/platform-tools/) compiled all resources in a single step. AAPT2 split this into compile and link phases, which means:
- Incremental resource compilation. If you change one layout file, only that file gets recompiled.
- Better error messages. AAPT2 catches more issues at compile time.
- Faster builds. The two-phase approach plays well with Gradle’s caching.
AAPT2 became the default in Android Gradle Plugin 3.0 and the original AAPT was removed entirely in later versions.
Step 2: Source Code Compilation
Once AAPT2 generates R.java, the build moves to source code compilation. This is where your Kotlin and Java files get turned into bytecode.
The Compilation Flow
flowchart TD
K[Kotlin Source Files .kt] --> KC[Kotlin Compiler kotlinc]
J[Java Source Files .java] --> JC[Java Compiler javac]
R[R.java from AAPT2] --> JC
KC --> CL[.class Files]
JC --> CL
AIDL[AIDL Interfaces] --> AG[AIDL Generator]
AG --> JC
style K fill:#dbeafe,stroke:#3b82f6,stroke-width:2px
style J fill:#dbeafe,stroke:#3b82f6,stroke-width:2px
style R fill:#dbeafe,stroke:#3b82f6,stroke-width:2px
style AIDL fill:#dbeafe,stroke:#3b82f6,stroke-width:2px
style KC fill:#fef3c7,stroke:#f59e0b,stroke-width:2px
style JC fill:#fef3c7,stroke:#f59e0b,stroke-width:2px
style AG fill:#fef3c7,stroke:#f59e0b,stroke-width:2px
style CL fill:#dcfce7,stroke:#16a34a,stroke-width:2px
If your project has both Kotlin and Java files, the Kotlin compiler runs first. It understands Java, so it can reference Java classes. Then the Java compiler runs and can reference the Kotlin classes that were already compiled. The output is standard Java bytecode in .class files.
If you use AIDL (Android Interface Definition Language) for inter-process communication, those .aidl files get converted to Java interfaces before the Java compiler step.
What About Annotation Processors?
If you use libraries like Dagger, Room, or Glide, annotation processors run during compilation. They read your annotations and generate additional Java or Kotlin source files, which then get compiled along with the rest of your code.
For Kotlin projects, kapt (Kotlin Annotation Processing Tool) bridges the gap between Kotlin and Java annotation processors. The newer KSP (Kotlin Symbol Processing) is faster because it works directly with Kotlin code instead of going through Java stubs.
Step 3: DEX Compilation (D8)
Android does not run standard Java bytecode. It runs Dalvik bytecode, which is optimized for mobile devices with limited memory and battery. The D8 compiler handles this conversion.
Why Not Just Use Java Bytecode?
The JVM was designed for desktop and server environments where memory and power are not a concern. Android devices, especially older ones, have much tighter constraints. Dalvik bytecode is:
- More compact than Java bytecode, so APKs are smaller
- Optimized for register-based execution, which is faster on ARM processors
- Designed for low-memory environments, using less RAM at runtime
What D8 Does
D8 takes all the .class files (your code, library code, everything) and converts them into .dex files containing Dalvik bytecode:
1
2
3
4
5
6
7
.class files (Java bytecode)
|
v
D8 Compiler
|
v
classes.dex (Dalvik bytecode)
D8 replaced the older dx tool starting with Android Gradle Plugin 3.1. It produces better bytecode and compiles faster.
The 65K Method Limit and Multidex
A single .dex file can reference at most 65,536 methods. That sounds like a lot, but once you add popular libraries like Google Play Services, Firebase, and AndroidX, you can easily hit that limit.
When your app exceeds 65K methods, the build produces multiple dex files: classes.dex, classes2.dex, classes3.dex, and so on. This is called multidex.
For apps with minSdk 21 or higher (Android 5.0+), multidex works out of the box because ART natively supports loading multiple dex files. For older versions, you need the multidex support library.
You can check your method count by running:
1
./gradlew app:dependencies
Or use tools like APK Analyzer in Android Studio to see the exact method count per dex file.
Step 4: Code Shrinking and Obfuscation (R8)
For release builds, R8 steps in after (or instead of) D8. R8 does three things:
1. Code Shrinking (Tree Shaking)
R8 analyzes your code starting from the entry points (activities, services, etc.) and removes everything that is not reachable. If you include a library with 10,000 methods but only use 50, R8 strips out the other 9,950.
2. Obfuscation
R8 renames classes, methods, and fields to short, meaningless names:
1
2
com.myapp.data.UserRepository --> a.b.c
getUserById() --> a()
This makes reverse engineering harder and reduces the size of the dex files (shorter names = smaller bytecode).
3. Optimization
R8 applies bytecode optimizations like inlining short methods, removing dead branches, and merging classes that are always used together.
R8 vs ProGuard
R8 replaced ProGuard as the default code shrinker starting with Android Gradle Plugin 3.4. R8 uses the same proguard-rules.pro configuration file, so the migration was seamless for most projects. The key differences:
| Feature | ProGuard | R8 |
|---|---|---|
| Shrinking | Yes | Yes |
| Obfuscation | Yes | Yes |
| Optimization | Yes | Yes, and better |
| DEX compilation | No (separate step) | Built in |
| Build speed | Slower (two steps) | Faster (single step) |
| Output quality | Good | Better (smaller APKs) |
Enable R8 in your build.gradle:
1
2
3
4
5
6
7
8
9
10
android {
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
'proguard-rules.pro'
}
}
}
Setting minifyEnabled true turns on R8. Setting shrinkResources true also removes unused resources (layouts, drawables, etc. that no code references).
The Keep Rules Headache
R8 is aggressive about removing code. Sometimes it removes things it should not, like classes referenced via reflection, or classes used by JSON serialization. This is where keep rules come in:
1
2
3
4
5
# Keep model classes for Gson serialization
-keep class com.myapp.data.model.** { *; }
# Keep classes with @Keep annotation
-keep @androidx.annotation.Keep class * { *; }
Getting keep rules right is one of the trickiest parts of Android development. If your release build crashes but debug works fine, chances are R8 removed or renamed something it should not have. The mapping.txt file generated during the build maps obfuscated names back to the originals, which is how you decode crash reports from production.
Step 5: Packaging
After compilation and optimization, everything needs to be packed into a single file.
What Goes Inside an APK
An APK is just a ZIP file with a specific structure:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
my-app.apk
|-- AndroidManifest.xml (binary XML, app metadata)
|-- classes.dex (compiled Dalvik bytecode)
|-- classes2.dex (multidex, if needed)
|-- resources.arsc (compiled resource table)
|-- res/ (non-compiled resources like images)
|-- assets/ (raw files, accessed via AssetManager)
|-- lib/ (native .so libraries per CPU architecture)
| |-- armeabi-v7a/
| |-- arm64-v8a/
| |-- x86/
| |-- x86_64/
|-- META-INF/ (signing information)
|-- MANIFEST.MF
|-- CERT.SF
|-- CERT.RSA
You can inspect any APK using the APK Analyzer in Android Studio or by renaming the .apk file to .zip and extracting it. If you want to go further and convert an APK back to Java code, there are tools for that too.
APK vs Android App Bundle (AAB)
Google introduced the Android App Bundle format as a replacement for APKs when publishing to the Play Store. Here is the difference:
flowchart TD
subgraph APK_Path["fa:fa-file APK Distribution"]
APK[Single APK] --> ALL[Contains ALL resources<br/>for ALL devices]
ALL --> D1[fa:fa-mobile-alt Device downloads<br/>everything, 30MB]
end
subgraph AAB_Path["fa:fa-file AAB Distribution"]
AAB[App Bundle .aab] --> GP[Google Play]
GP --> OPT1[APK for Device A<br/>arm64, xxhdpi, en<br/>12MB]
GP --> OPT2[APK for Device B<br/>x86, hdpi, fr<br/>10MB]
GP --> OPT3[APK for Device C<br/>arm64, mdpi, ja<br/>11MB]
end
style APK fill:#fee2e2,stroke:#dc2626,stroke-width:2px
style ALL fill:#fee2e2,stroke:#dc2626,stroke-width:2px
style D1 fill:#fee2e2,stroke:#dc2626,stroke-width:2px
style AAB fill:#dcfce7,stroke:#16a34a,stroke-width:2px
style GP fill:#dcfce7,stroke:#16a34a,stroke-width:2px
style OPT1 fill:#dbeafe,stroke:#3b82f6,stroke-width:2px
style OPT2 fill:#dbeafe,stroke:#3b82f6,stroke-width:2px
style OPT3 fill:#dbeafe,stroke:#3b82f6,stroke-width:2px
With an APK, every device downloads the same file, including resources it will never use (like xxxhdpi images on an hdpi screen, or x86 libraries on an ARM device).
With an AAB, Google Play generates optimized APKs per device. Each device downloads only what it needs. This can cut download sizes by 15% or more.
Since August 2021, Google Play requires AAB for all new app submissions. You build an AAB the same way you build an APK, just with a different Gradle task:
1
2
3
4
5
# Build APK
./gradlew assembleRelease
# Build AAB
./gradlew bundleRelease
Step 6: Alignment and Signing
The final steps before your APK is ready to install.
zipalign
zipalign is a tool that aligns uncompressed data within the APK to 4-byte boundaries. This lets the Android system read data directly from the APK using memory mapping (mmap) without copying it into RAM first. It is a small optimization but matters on devices with limited memory.
APK Signing
Every APK must be signed before it can be installed on a device. Android uses the signature to verify that future updates come from the same developer.
sequenceDiagram
participant B as Build System
participant KS as Keystore
participant APK as APK File
participant D as Device
B->>KS: Request signing key
KS-->>B: Private key
B->>APK: Sign with private key
Note over APK: Contains signature<br/>in META-INF/
APK->>D: Install
D->>D: Verify signature
Note over D: On update: verify<br/>same signing key
There are two types of builds:
Debug builds: Android Studio automatically signs with a debug keystore located at ~/.android/debug.keystore. Every developer gets one automatically. These APKs cannot be uploaded to the Play Store.
Release builds: You sign with your own keystore containing a private key that you control. If you lose this key, you can never update your app on the Play Store (unless you use Google Play App Signing, which is strongly recommended).
Android supports multiple signature schemes:
- v1 (JAR signing): The original, works on all Android versions
- v2 (APK Signature Scheme): Faster verification, protects against more tampering (Android 7.0+)
- v3: Supports key rotation (Android 9.0+)
- v4: Incremental install support (Android 11+)
Modern builds sign with all schemes for backward compatibility.
Gradle: The Orchestrator
All the steps above are individual tools. Gradle is the build system that ties them all together. The Android Gradle Plugin (AGP) tells Gradle which tools to run, in what order, and with what settings.
Gradle Build Phases
Every Gradle build runs in three phases:
flowchart TD
subgraph P1["fa:fa-search Initialization"]
I1[Parse settings.gradle] --> I2[Determine which projects to build]
end
subgraph P2["fa:fa-sitemap Configuration"]
C1[Evaluate build.gradle for each project] --> C2[Create task graph]
end
subgraph P3["fa:fa-play Execution"]
E1[Run tasks in dependency order]
end
P1 --> P2
P2 --> P3
style I1 fill:#dbeafe,stroke:#3b82f6,stroke-width:2px
style I2 fill:#dbeafe,stroke:#3b82f6,stroke-width:2px
style C1 fill:#fef3c7,stroke:#f59e0b,stroke-width:2px
style C2 fill:#fef3c7,stroke:#f59e0b,stroke-width:2px
style E1 fill:#dcfce7,stroke:#16a34a,stroke-width:2px
- Initialization: Gradle reads
settings.gradleto figure out which modules are part of the build - Configuration: Gradle evaluates
build.gradlefor each module and builds a directed acyclic graph (DAG) of tasks - Execution: Gradle runs the tasks in the right order, skipping tasks whose inputs have not changed
This is important to understand because code you put in your build.gradle files runs during the configuration phase, even for tasks you are not executing. Expensive operations in build.gradle slow down every single build.
The Build Files
A typical Android project has these build-related files:
| File | Purpose |
|---|---|
settings.gradle |
Lists all modules in the project |
build.gradle (root) |
Plugin versions and project-wide configuration |
app/build.gradle |
App module configuration (SDK versions, dependencies, build types) |
gradle.properties |
JVM settings and Gradle feature flags |
local.properties |
Machine-specific settings (SDK path), not checked into git |
gradle/libs.versions.toml |
Version catalog for dependency management (newer projects) |
Build Types
Build types define how your app is compiled and packaged for different situations:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
android {
buildTypes {
debug {
applicationIdSuffix ".debug"
debuggable true
minifyEnabled false
}
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
'proguard-rules.pro'
}
staging {
initWith debug
applicationIdSuffix ".staging"
// Use staging API endpoint
}
}
}
- debug: Fast builds, no optimization, debuggable. What you use during development.
- release: Slower builds, R8 optimization enabled, signed with your release key. What goes to production.
- custom types: You can create your own. A
stagingtype is common for testing with production-like settings.
Product Flavors
Product flavors let you create different versions of your app from the same codebase:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
android {
flavorDimensions "tier"
productFlavors {
free {
dimension "tier"
applicationIdSuffix ".free"
buildConfigField "boolean", "IS_PREMIUM", "false"
}
paid {
dimension "tier"
applicationIdSuffix ".paid"
buildConfigField "boolean", "IS_PREMIUM", "true"
}
}
}
Each flavor can have its own source directory (src/free/, src/paid/) with different code, resources, or even different dependencies.
Build Variants
Build variants are the cross product of build types and product flavors:
| debug | release | |
|---|---|---|
| free | freeDebug | freeRelease |
| paid | paidDebug | paidRelease |
Each variant is a separate build that Gradle can produce. You can build a specific variant with:
1
2
./gradlew assembleFreeDebug
./gradlew assemblePaidRelease
Speeding Up Your Builds
Slow builds kill productivity. Here are practical ways to make your Android builds faster.
1. Enable the Gradle Build Cache
The build cache stores task outputs and reuses them when inputs have not changed. Add this to gradle.properties:
1
org.gradle.caching=true
This is especially useful on CI/CD pipelines where you can share the cache across builds. If you are setting up CI/CD, check out how to set up CI/CD for Android using GitHub Actions.
2. Use Parallel Execution
Let Gradle build independent modules at the same time:
1
org.gradle.parallel=true
This helps a lot in multi-module projects where modules do not depend on each other.
3. Increase JVM Heap Size
The default JVM heap is often too small for large projects:
1
org.gradle.jvmargs=-Xmx4g -XX:+HeapDumpOnOutOfMemoryError
4GB is a reasonable starting point. Monitor your build and increase if you see GC pressure.
4. Enable Configuration Cache
The configuration cache saves the result of the configuration phase so it can be skipped on subsequent builds:
1
org.gradle.configuration-cache=true
This can save several seconds on each build, especially in projects with many modules.
5. Avoid Dynamic Dependency Versions
1
2
3
4
5
// Bad: Gradle checks for new versions every build
implementation 'com.squareup.retrofit2:retrofit:2.+'
// Good: Fixed version, Gradle can cache this
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
Dynamic versions force Gradle to check for updates on every build, adding network overhead.
6. Modularize Your Project
Split your app into Gradle modules. When you change code in one module, only that module and its dependents get recompiled. The rest are served from cache.
Build Speed Comparison
Here is a rough idea of what each optimization does:
| Optimization | First Build Impact | Incremental Build Impact |
|---|---|---|
| Build cache | None | 20-40% faster |
| Parallel execution | 10-30% faster | 10-30% faster |
| Configuration cache | None | 5-15% faster |
| Fixed dependency versions | 2-5% faster | 2-5% faster |
| Modularization | Slower (more modules) | 30-50% faster |
Debug Build vs Release Build
Here is a summary of what changes between debug and release builds:
flowchart TD
SRC[fa:fa-code Same Source Code]
SRC --> D1
SRC --> R1
subgraph Debug["fa:fa-bug Debug Build"]
direction TB
D1[No R8 shrinking] --> D2[Debug keystore signing]
D2 --> D3[Debuggable flag ON]
D3 --> D4[Fast compilation]
D4 --> D5[Instant Run / Apply Changes]
D5 --> D6[fa:fa-mobile-alt Debug APK]
end
subgraph Release["fa:fa-rocket Release Build"]
direction TB
R1[R8 shrinking + obfuscation] --> R2[Release keystore signing]
R2 --> R3[Debuggable flag OFF]
R3 --> R4[Full optimization]
R4 --> R5[ProGuard mapping file generated]
R5 --> R6[fa:fa-mobile-alt Release APK]
end
style SRC fill:#f3f4f6,stroke:#6b7280,stroke-width:2px
style D1 fill:#dbeafe,stroke:#3b82f6,stroke-width:1px
style D2 fill:#dbeafe,stroke:#3b82f6,stroke-width:1px
style D3 fill:#dbeafe,stroke:#3b82f6,stroke-width:1px
style D4 fill:#dbeafe,stroke:#3b82f6,stroke-width:1px
style D5 fill:#dbeafe,stroke:#3b82f6,stroke-width:1px
style D6 fill:#dbeafe,stroke:#3b82f6,stroke-width:2px
style R1 fill:#dcfce7,stroke:#16a34a,stroke-width:1px
style R2 fill:#dcfce7,stroke:#16a34a,stroke-width:1px
style R3 fill:#dcfce7,stroke:#16a34a,stroke-width:1px
style R4 fill:#dcfce7,stroke:#16a34a,stroke-width:1px
style R5 fill:#dcfce7,stroke:#16a34a,stroke-width:1px
style R6 fill:#dcfce7,stroke:#16a34a,stroke-width:2px
The same source code goes through different pipelines depending on the build type. This is why you should always test your release build before shipping. R8 can change behavior in unexpected ways.
What Happens at Runtime
After the build is done and the APK is installed, here is what happens when the user opens your app:
- The Android system reads the
AndroidManifest.xmlto find the launcher activity - ART (Android Runtime) loads the
.dexfiles. On modern Android (5.0+), ART compiles Dalvik bytecode to native machine code during install or idle time (Ahead-of-Time compilation). On older versions, Dalvik used Just-in-Time compilation, converting bytecode to native code on the fly. - Resources are loaded from the resource table (
resources.arsc) using the IDs from R.java - Your Activity’s
onCreate()runs and the app starts
ART replaced the original Dalvik VM starting with Android 5.0 (Lollipop). The main improvement: ART compiles your app to native code ahead of time, so there is no JIT compilation overhead at runtime. Apps start faster and run smoother.
Tools Every Android Developer Should Know
| Tool | What It Does | Where to Find It |
|---|---|---|
| AAPT2 | Compiles resources, generates R.java | $ANDROID_HOME/build-tools/<version>/aapt2 |
| D8 | Converts .class to .dex | Built into AGP |
| R8 | Shrinks, obfuscates, optimizes + dex compilation | Built into AGP |
| zipalign | Aligns APK contents for efficient runtime access | $ANDROID_HOME/build-tools/<version>/zipalign |
| apksigner | Signs APKs | $ANDROID_HOME/build-tools/<version>/apksigner |
| APK Analyzer | Inspects APK contents, size, and dex info | Android Studio > Build > Analyze APK |
| Gradle | Orchestrates the entire build | ./gradlew in project root |
Common Build Errors and What They Mean
1. Duplicate class found
1
Duplicate class com.example.MyClass found in modules module-a and module-b
Two of your dependencies include the same class. Use ./gradlew app:dependencies to find the conflict and exclude one.
2. Method count exceeds 65,536
Your app hit the multidex limit. Set minSdk to 21+ or enable multidex:
1
2
3
4
5
android {
defaultConfig {
multiDexEnabled true
}
}
3. R8 / ProGuard: ClassNotFoundException at runtime
R8 removed a class it thought was unused, but your code accesses it via reflection. Add a keep rule:
1
-keep class com.myapp.data.model.** { *; }
4. Resource not found at runtime
A resource was stripped by shrinkResources true. If you load resources dynamically (by name instead of ID), R8 cannot detect the usage. Add a keep.xml:
1
2
3
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@layout/dynamic_*,@drawable/icon_*" />
Wrapping Up
The Android build process is more than just “compile and run.” Here is what happens under the hood:
- AAPT2 compiles your resources and generates R.java with resource IDs
- Kotlin/Java compilers turn your source code into .class files
- D8 converts .class files into Dalvik bytecode (.dex files)
- R8 (release builds) shrinks, obfuscates, and optimizes the code
- APK Packager bundles dex files, compiled resources, and assets into an APK
- zipalign aligns the APK for efficient memory access
- apksigner signs the APK so it can be installed
Gradle ties all of this together, running each step in the right order with the right configuration for your build type and product flavor.
Understanding this pipeline helps you debug build issues faster, optimize build times, write proper R8 keep rules, and generally make better decisions about your project structure. It is one of those things that separates developers who just use Android Studio from developers who truly understand what their tools are doing.
If you are looking to automate your Android builds, check out CI/CD for Android Using GitHub Actions for a complete pipeline setup, or the GitHub Actions basics guide if you are new to CI/CD. For a deeper look at what is inside your APK, see how to convert an APK file to Java code.
Want to understand how other mobile frameworks handle their build? See Flutter Under the Hood for comparison.
References: Android Developer: Configure Your Build, Android Gradle Build Overview, D8 and R8 Documentation, APK Signature Scheme