R8, Shrinking & App Size Explained
BUILD & TOOLING › Gradle
**R8** is the default code shrinker for release Android builds, folded into the same pass as dexing (it's built on top of D8), and it replaced the older standalone ProGuard tool. You turn it on with isMinifyEnabled = true on a build type, almost always release:
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
R8 does three things to your bytecode in one pass: **code shrinking** (tree-shaking anything unreachable from your manifest's entry points), **obfuscation** (renaming classes or methods or fields to short meaningless names), and **optimization** (inlining, class merging, dead-branch removal). shrinkResources = true adds a fourth job, dropping unused resources, but only works alongside minifyEnabled, since it relies on R8's code reference graph to know what's actually used.
R8 can only see what your code references *statically*. Reflection is invisible to it, so a class read only via reflection can get renamed or stripped, breaking at runtime with no compile error. Libraries built around codegen ship their own **consumer ProGuard rules** bundled in their AAR, so you don't have to think about it: Hilt, Moshi's codegen adapter, and kotlinx.serialization all protect their generated code automatically.
**Gson is the exception** worth remembering: it resolves your model classes' fields purely via runtime reflection and ships no consumer rules for *your* classes, so you must hand-write keep rules yourself:
# proguard-rules.pro
-keep class com.example.myapp.model.** { *; }
Without that rule, R8 renames your model's fields, and Gson silently fails to populate them from JSON.
Keep rules come in different strengths. **-keep** protects both the class *and* its listed members from removal and renaming, the class survives even if nothing in your code references it. **-keepclassmembers** is narrower: it only protects the listed members, R8 can still delete the whole class if nothing reaches it.
# retains the class AND its fields, unconditionally
-keep class com.example.MyModel {
<fields>;
}
# retains only the fields IF the class survives; the class itself can still be stripped
-keepclassmembers class com.example.MyModel {
<fields>;
}
The androidx @Keep annotation is a code-local shortcut for the same idea: annotate a class or member and R8's default rules keep it from being shrunk or renamed, no separate .pro file entry needed.
Every R8 run produces a **mapping.txt** for that build, recording the original-to-obfuscated name translation. Since names are renamed differently on every single build, a release crash's stack trace is unreadable without the *exact matching* mapping.txt, one from a different build maps names to the wrong, or nonsensical, originals.
java -jar proguard-retrace.jar mapping.txt stacktrace.txt
In practice you rarely run retrace by hand: uploading mapping.txt to the Play Console (or Crashlytics) lets it automatically deobfuscate crash reports for that build version, turning a.b.c back into com.example.myapp.UserRepository.
Uploading an **Android App Bundle** (.aab) instead of a universal APK lets Google Play generate optimized, per-device **split APKs** at download time, splitting along three dimensions: screen **density**, CPU **ABI**/architecture, and **language**. Each device only downloads the code and resources it actually needs, instead of every density's drawables and every supported CPU's native libraries bundled into one file.
android {
bundle {
density { enableSplit = true }
abi { enableSplit = true }
language { enableSplit = true }
}
}
This is the single biggest lever for real download size, bigger than most code-level shrinking, because native libraries and per-density drawables are often the largest slice of an APK.
Beyond the bundle format itself, **dynamic feature modules** under Play Feature Delivery cut the *initial* download further: install-time, conditional, or on-demand delivery keeps optional features (a rarely-used editor, a big SDK) out of the base install until the user actually needs them, fetched later via SplitInstallManager.
A few smaller, code-and-asset-level wins add up too: prefer **vector drawables** over multiple density-specific PNGs, use **WebP** instead of PNG or JPEG for raster assets, and prefer @IntDef annotations over Kotlin or Java enum class in size-sensitive code, each enum instance costs roughly 1.0-1.4 KB in classes.dex (a full class plus a values array), while @IntDef compiles down to plain Int constants that R8 keeps tiny while still giving you compile-time type checking.
Two release-only defaults are worth knowing precisely. R8's **full mode** (the default since AGP 8) makes stronger assumptions than the older compatibility mode, no unexpected reflection, which enables more aggressive shrinking and optimization but can demand extra keep rules for reflective code that compatibility mode tolerated silently.
Packaging has its own quiet defaults: **resources.arsc** is always stored uncompressed, because ART memory-maps directly into it at runtime, compressing it would force full decompression and hurt performance. Similarly, useLegacyPackaging = false (the modern default) stores native .so libraries uncompressed and page-aligned, so the OS loads them directly from the APK instead of extracting them to disk on install.
Put together: minifyEnabled turns on R8's shrink or obfuscate or optimize pass, keep rules protect reflection, mapping.txt lets you read the result, and App Bundles plus these packaging defaults shrink what actually reaches the device.