From Kotlin source to a running app: what happens when you press Build
Understanding how Android turns your code into a running app demystifies build errors, explains why optimisations like R8 exist, and clarifies why certain language constructs behave the way they do at runtime. This closing chapter traces the full journey from a .kt file to native machine code executing on a device.
The Full Pipeline at a Glance
Source -> DEX -> APK/AAB -> native code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Kotlin / Java source (.kt, .java)
|
v kotlinc / javac (+ KAPT / KSP annotation processing)
JVM bytecode (.class files)
|
v D8 (debug) / R8 (release: shrink, optimise, obfuscate)
DEX bytecode (.dex files) <- Dalvik Executable format
|
v AAPT2 (compiles resources, generates R class)
v packaging
APK / AAB (Android App Bundle)
|
v ART (Android Runtime)
Native machine code (AOT at install + JIT at runtime)
Stage by Stage
1 — Kotlin / Java compilation
The Kotlin compiler (kotlinc) and the Java compiler (javac) both produce standard JVM .class bytecode — the very same format any JVM application uses. At this stage Android is not yet involved. Annotation processing also runs here: KAPT (the older Java annotation-processing bridge) and KSP (the faster, Kotlin-native successor) generate the code that annotation-based libraries such as Room (database) and Moshi (JSON) rely on.
2 — D8 / R8 (DEX compilation)
D8 is Google’s modern DEX compiler. It converts .class files into .dex (Dalvik Executable) bytecode, the format the Android Runtime understands, and it desugars Java 8+ features (lambdas, default methods, some stream APIs) so they run on older Android versions. R8 is the full-mode replacement for D8 plus the old ProGuard: it does everything D8 does and adds shrinking (removes unused classes and methods), minification (renames identifiers to short names like a, b, c), and optimisation (inlining, dead-code elimination, class merging). R8 runs in release builds by default.
3 — AAPT2 (resource processing)
AAPT2 (Android Asset Packaging Tool 2) compiles your XML resources — layouts, drawables, strings, the manifest — into an efficient binary form, and generates the R class that maps every resource to an integer ID. It also merges resources from your app and all its library dependencies.
4 — Packaging into APK / AAB
The final assembly combines the DEX files, compiled resources, native libraries (.so), assets, and the merged manifest into a single artifact. An APK is self-contained and installs directly — ideal for local testing and sideloading. An AAB (Android App Bundle) is uploaded to Google Play, which then generates optimised, device-specific APKs on demand, splitting by screen density, CPU architecture (ABI), and language so each user downloads only what their device needs.
5 — ART (Android Runtime)
ART replaced the original Dalvik VM in Android 5.0. It executes DEX bytecode using a blend of strategies, which together give both fast cold starts and smooth steady-state performance.
- AOT (Ahead-Of-Time): frequently used code is compiled to native machine code at install or idle-charging time, so later launches skip interpretation.
- JIT (Just-In-Time): code not yet AOT-compiled is interpreted or JIT-compiled at runtime, and execution profiles are recorded.
- Profile-Guided Optimisation: ART feeds those runtime profiles back into AOT compilation, compiling the genuinely hot paths first. Baseline Profiles let you ship this data with the app.
Pipeline Summary
| Stage | Tool | Input | Output |
|---|---|---|---|
| Kotlin compile | kotlinc | .kt sources | .class (JVM bytecode) |
| Java compile | javac | .java sources | .class (JVM bytecode) |
| Annotation processing | KAPT / KSP | source + annotations | generated sources |
| DEX compile (debug) | D8 | .class files | .dex files |
| DEX + optimise (release) | R8 | .class + ProGuard rules | minified .dex |
| Resource compile | AAPT2 | XML resources | binary resources + R class |
| Package | Gradle / bundletool | .dex + resources + manifest | APK / AAB |
| Run | ART | .dex | native code (AOT + JIT) |
DEX Format Facts Worth Knowing
- DEX is register-based bytecode, whereas JVM bytecode is stack-based — generally more efficient for mobile execution.
- A single DEX file can reference at most 65,536 methods (the ‘64K method limit’). Larger apps need multidex, splitting across multiple .dex files.
- Android 5.0+ supports multidex natively through ART; pre-5.0 required the multidex support library and a special Application class.
- D8’s library desugaring brings newer Java APIs (java.time, some streams) to older API levels by rewriting them at compile time.
R8 vs ProGuard
| Feature | ProGuard | R8 |
|---|---|---|
| Integrated with D8 | No (separate step) | Yes (single pass) |
| Build speed | Slower | Faster |
| Shrinking | Yes | Yes (more aggressive) |
| Minification | Yes | Yes |
| Optimisation | Limited | Extensive (inlining, class merging) |
| Rule format | ProGuard rules | Same ProGuard rules |
| Default in modern Android | Legacy | Default since AGP 3.4 |
Build Variants & Flavors
Gradle supports multiple build types (debug, release) and product flavors (e.g. free, paid). The combination of a build type and a flavor is a variant. R8 is typically enabled only in release variants, keeping debug builds fast and debuggable.
build types, flavors, and the resulting variants
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
android {
buildTypes {
debug {
isMinifyEnabled = false // fast, debuggable
applicationIdSuffix = ".debug"
}
release {
isMinifyEnabled = true // R8 on
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
flavorDimensions += "tier"
productFlavors {
create("free") { dimension = "tier" }
create("paid") { dimension = "tier" }
}
}
// Variants: freeDebug, freeRelease, paidDebug, paidRelease
Interview Tip:
Build types are also where your security configuration takes effect: secrets are injected per build type here — placeholder values in debug, and real values pulled from CI/CD environment variables in release — so that no credential is ever hard-coded into the source or committed to version control.
NDK & JNI
The NDK (Native Development Kit) lets you write parts of an Android app in C/C++. The JNI (Java Native Interface) is the bridge that lets Kotlin/Java code call into native code and vice versa. Both are part of the compilation pipeline because native code is compiled separately (by Clang/LLVM) and packaged as .so (shared library) files inside the APK.
When to Use the NDK
| Reason | Example |
|---|---|
| Performance-critical computation | Signal processing, image codecs, game physics |
| Reuse existing C/C++ libraries | OpenCV, SQLCipher, BoringSSL |
| Harder to reverse-engineer | Native code requires disassemblers (Ghidra, IDA) rather than Smali decompilers |
| Direct syscalls | Bypassing Java layer for low-level operations |
JNI Bridge — How Kotlin Calls Native
1
2
3
4
5
6
7
8
// Kotlin side — declare external function
class CryptoEngine {
external fun encryptBytes(data: ByteArray, key: ByteArray): ByteArray
companion object {
init { System.loadLibrary("cryptoengine") } // loads libcryptoengine.so
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// C side — function name must match exactly, or use RegisterNatives
// Pattern: Java_<package_underscored>_<class>_<method>
JNIEXPORT jbyteArray JNICALL
Java_com_example_CryptoEngine_encryptBytes(
JNIEnv *env,
jobject thiz,
jbyteArray data,
jbyteArray key) {
jbyte *dataPtr = (*env)->GetByteArrayElements(env, data, NULL);
jsize dataLen = (*env)->GetArrayLength(env, data);
// ... do encryption ...
(*env)->ReleaseByteArrayElements(env, data, dataPtr, JNI_ABORT);
// return result as jbyteArray
}
RegisterNatives — Dynamic Method Registration
The default name-based lookup ties your C function names to your Java package structure. RegisterNatives lets you map arbitrary C function pointers to JNI methods at runtime — commonly used to make the mapping harder to discover by static analysis:
1
2
3
4
5
6
7
8
9
10
11
12
static const JNINativeMethod methods[] = {
{"encryptBytes", "([B[B)[B", (void*)nativeEncrypt},
{"decryptBytes", "([B[B)[B", (void*)nativeDecrypt},
};
JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void *reserved) {
JNIEnv *env;
(*vm)->GetEnv(vm, (void**)&env, JNI_VERSION_1_6);
jclass cls = (*env)->FindClass(env, "com/example/CryptoEngine");
(*env)->RegisterNatives(env, cls, methods, 2);
return JNI_VERSION_1_6;
}
JNI Type Signatures
1
2
3
Z → boolean B → byte C → char S → short
I → int J → long F → float D → double
L<class>; → object [<type> → array
([B[B)[B reads as: method taking two byte[] arguments, returning a byte[].
NDK Build Integration
1
2
3
4
5
6
7
8
9
10
11
12
// build.gradle.kts
android {
defaultConfig {
externalNativeBuild {
cmake { cppFlags("-std=c++17", "-O2") }
}
ndk { abiFilters += listOf("arm64-v8a", "x86_64") }
}
externalNativeBuild {
cmake { path("src/main/cpp/CMakeLists.txt") }
}
}
1
2
3
4
5
# CMakeLists.txt
cmake_minimum_required(VERSION 3.22.1)
project("cryptoengine")
add_library(cryptoengine SHARED cryptoengine.cpp)
target_link_libraries(cryptoengine android log)
Reflection
Reflection lets you inspect and invoke code at runtime by name, bypassing the compile-time visibility checks. It is available via java.lang.reflect and Kotlin’s kotlin.reflect packages.
Core Operations
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Get a class by name (breaks under R8 if class is renamed)
val cls = Class.forName("com.example.MyClass")
// Access a private field
val field = cls.getDeclaredField("secret")
field.isAccessible = true
val value = field.get(instance)
// Invoke a private method
val method = cls.getDeclaredMethod("internalProcess", String::class.java)
method.isAccessible = true
val result = method.invoke(instance, "arg")
// Kotlin-idiomatic reflection (type-safe, but same runtime mechanism)
val prop = MyClass::class.memberProperties.find { it.name == "secret" }
Reflection and Obfuscation — the Critical Interaction
When R8 renames a class from MyClass to a, any Class.forName("com.example.MyClass") call breaks at runtime with ClassNotFoundException. R8 cannot know about string literals that happen to be class names.
1
2
3
4
5
6
7
8
// BREAKS under R8 (string is not tracked by R8)
val cls = Class.forName("com.example.MyRepository")
// SAFE — R8 tracks class references, not string literals
val cls = MyRepository::class.java
// Also safe — KClass reference is tracked
val cls = MyRepository::class
Rule: if you must use reflection, use class literals (MyClass::class.java) or add a -keep ProGuard rule. Never use string-based lookup on a class you own unless it is explicitly kept.
Uses in Android
- Dependency injection frameworks: Dagger/Hilt use KSP (compile-time), not reflection — this is why they’re faster than older DI frameworks
- JSON serialization: Gson uses reflection; Moshi uses either reflection or code generation (code gen is faster and R8-safe)
- Testing:
@VisibleForTesting+ reflection to access internals; Mockito uses reflection to create mocks - Plugin systems: loading classes dynamically from a secondary DEX or downloaded module