R8, Shrinking & App Size Explained

BUILD & TOOLING › Gradle

R8 is the default code shrinker for Android release builds. It replaced the older standalone ProGuard tool, and because it's built directly on top of D8, it folds shrinking, obfuscation, optimization, and dexing into a single compiler pass instead of running separate tools one after another. You switch it on for a build type, almost always release, with minifyEnabled true. Interviewers ask about R8 constantly because a misconfigured release build can crash in production while the debug build works perfectly, since debug builds skip R8 entirely.

buildTypes {
    release {
        minifyEnabled = true
        proguardFiles(
            getDefaultProguardFile("proguard-android-optimize.txt"),
            "proguard-rules.pro"
        )
    }
}

Turning on minifyEnabled hands R8 three jobs on your bytecode. Code shrinking walks outward from your manifest's entry points, activities, services, and so on, and deletes anything unreachable, that's tree-shaking. Obfuscation renames classes, methods, and fields to short meaningless names, which is what makes a decompiled release APK hard to read. Optimization inlines methods, merges classes, and removes dead branches, but only if you're using the right base rules file. The plain proguard-android.txt that ships with older templates historically suppresses those optimizations, while proguard-android-optimize.txt removes that restriction and lets R8 actually optimize, not just shrink and obfuscate. Always reach for the optimize variant on a modern project.

// proguard-android.txt, shrink + obfuscate only (legacy default)
// proguard-android-optimize.txt, also enables inlining, class merging, dead-branch removal
proguardFiles(
    getDefaultProguardFile("proguard-android-optimize.txt"),
    "proguard-rules.pro"
)

Resource shrinking is a fourth job, but it isn't independent, it rides on top of code shrinking. Set shrinkResources true and R8 walks the same code reference graph it built for shrinking classes, then drops any resource nothing in your surviving code refers to. That dependency means shrinkResources does nothing at all unless minifyEnabled is also true, it's silently a no-op otherwise. And because it only understands references it can see in code, a resource pulled in only through reflection, say a drawable name built from a string at runtime, won't be recognized as used. You have to protect it explicitly with a keep.xml entry.

buildTypes {
    release {
        minifyEnabled = true    // required
        shrinkResources = true  // no-op without minifyEnabled
    }
}

R8 only understands references it can see statically in your bytecode. Reflection is invisible to it, so a class or field touched only through reflection can get renamed or deleted with no compile-time warning, it just breaks at runtime. Libraries that generate code at compile time solve this for you: Hilt, Moshi's codegen adapter, and kotlinx.serialization each ship their own consumer ProGuard rules bundled inside their AAR, so their generated code is automatically protected. Gson is the classic exception. It resolves your model classes' fields purely through runtime reflection and ships no consumer rules for your classes, only its own internals, so you have to hand-write the keep rule yourself or JSON parsing silently breaks in release builds only.

# proguard-rules.pro, Gson ships no consumer rules for your model classes
-keep class com.example.myapp.model.** { *; }

Keep rules come in different strengths, and mixing them up is a common interview stumble. -keep protects both the class and its listed members from removal and renaming, unconditionally, the class survives even if nothing in your own code ever references it. -keepclassmembers is narrower: it only protects the members you list, R8 is still free to delete the entire class if nothing reaches it. In practice, use -keep for classes accessed by name from outside your code, like a class loaded by reflection with Class.forName, and -keepclassmembers for members inside a class that's already reachable another way.

# retains the class AND its fields, unconditionally
-keep class com.example.MyModel {
    <fields>;
}

# retains only the fields; the class itself can still be stripped
-keepclassmembers class com.example.MyModel {
    <fields>;
}

There's a code-local shortcut for the same idea: the androidx.annotation @Keep annotation. Annotate a class or a member with @Keep and R8's default rules honor it the same way a -keep entry in proguard-rules.pro would, protecting it from both shrinking and renaming. It's useful when the reflective access lives right next to the class it's protecting, a callback invoked by name from a native library, say, since the protection travels with the code instead of living in a separate rules file that's easy to forget about.

import androidx.annotation.Keep

@Keep  // R8 won't remove or rename this class
class ReflectedHelper {

    @Keep  // protect this specific member too
    fun invokedViaReflection() { /* ... */ }
}

Since AGP 8, R8's full mode is the default, and it behaves differently from the older compatibility mode. Full mode assumes there is no unexpected reflection anywhere in your app, which lets it make much more aggressive shrinking and optimization decisions, merging and inlining more freely than compatibility mode dared to. The cost is that code compatibility mode tolerated silently, a Retrofit response model referenced only by generic type, say, can get stripped or renamed under full mode, and you'll need to add keep rules you never needed before. If a release-only crash appears right after an AGP upgrade with no other changes, full mode's stricter assumptions are a prime suspect.

# Full mode assumes no unexpected reflection, add explicit keeps for reflective code
-keep class com.example.model.UserResponse { *; }

Every R8 run writes out a mapping.txt for that build, the original-to-obfuscated name translation for every class, method, and field it touched. Once your crash reporting shows you a.b.c instead of com.example.myapp.UserRepository, mapping.txt is what turns it back into something you can read. You can run it by hand through the retrace tool, or, far more commonly, just upload mapping.txt to the Play Console or Crashlytics once per release and let it deobfuscate crash reports for you automatically from then on.

java -jar proguard-retrace.jar \
  app/build/outputs/mapping/release/mapping.txt \
  stacktrace.txt

One detail trips people up: mapping.txt is only valid for the exact build that produced it. R8 doesn't rename things the same way twice, it assigns fresh obfuscated names on every single run, so a mapping file from yesterday's build applied to today's crash report will map names to the wrong classes, or to nothing sensible at all. That's why Play Console and Crashlytics key mapping files to a specific version code and build, and why keeping your CI pipeline's mapping.txt artifacts organized by build number actually matters in production, not just as tidiness.

Shrinking your code only gets you so far, for most apps the biggest single lever on real download size is the Android App Bundle format. Instead of uploading one universal APK containing every density's drawables and every CPU architecture's native libraries, you upload an .aab and let Google Play generate optimized split APKs per device at download time, split along three dimensions: screen density, CPU ABI or architecture, and language. A user on one phone in one locale downloads only the slice that matches their device, not the other four densities or three architectures they'll never use. Native libraries and per-density drawables are often the largest chunk of an APK, so this one format change frequently outweighs everything code shrinking achieves.

android {
    bundle {
        density  { enableSplit = true }   // separate APK per screen density
        abi      { enableSplit = true }   // separate APK per CPU architecture
        language { enableSplit = true }   // separate APK per language
    }
}

App Bundles also unlock Play Feature Delivery, which lets you carve optional parts of your app into dynamic feature modules and control exactly when they download. Install-time delivery bundles a module into the initial install for everyone. Conditional delivery installs it at install time only for devices matching rules you set, a feature gated on API level, say. On-demand delivery is the most aggressive, the module isn't fetched at all until your running app explicitly requests it by calling SplitInstallManager, which is how you'd keep a rarely used editor or a large SDK completely out of the base download until a user actually opens that feature.

val manager = SplitInstallManagerFactory.create(context)

val request = SplitInstallRequest.newBuilder()
    .addModule("my_dynamic_feature")
    .build()

// Module is downloaded only when startInstall() is explicitly called
manager.startInstall(request)

A handful of smaller code and asset choices add up to real savings too. Prefer WebP over PNG or JPEG for raster images, and prefer vector drawables over shipping the same icon at five different densities. Inside your Kotlin code, prefer @IntDef annotations over enum class in size-sensitive paths: each enum constant compiles to a genuine object plus a values array R8 has to keep around, roughly one to one point four kilobytes per enum in classes.dex, while @IntDef gives you the same compile-time type checking but compiles straight down to plain int constants that cost almost nothing.

// BAD, each enum ≈ 1–1.4 KB in DEX (full class object + values array)
enum class Direction { NORTH, SOUTH, EAST, WEST }

// GOOD, same compile-time safety, compiles to plain int constants
@Retention(AnnotationRetention.SOURCE)
@IntDef(NORTH, SOUTH, EAST, WEST)
annotation class Direction

const val NORTH = 0; const val SOUTH = 1
const val EAST  = 2; const val WEST  = 3

Packaging has its own quiet defaults worth knowing precisely, because interviewers like asking why rather than what. resources.arsc, the compiled table mapping resource IDs to their values, is always stored uncompressed inside the package. That's because ART and the framework memory-map straight into that file at runtime instead of reading it sequentially, compressing it would force a full decompression before any lookup could happen, which would hurt startup and runtime performance across the board.

The same logic applies to native libraries. With the modern default, android.packaging.jniLibs.useLegacyPackaging set to false, .so files are stored uncompressed and page-aligned inside the APK too, so the OS can map them directly from the package instead of extracting them to disk during install. Put the whole lesson together: minifyEnabled turns on R8's shrink, obfuscate, and optimize pass, keep rules and @Keep protect reflection, full mode makes that protection stricter, mapping.txt lets you read the result, and App Bundles plus these packaging defaults shrink what actually reaches the device.

android {
    packaging {
        jniLibs {
            // false = .so stored uncompressed and page-aligned inside the APK
            useLegacyPackaging = false
        }
    }
}

Back to R8, Shrinking & App Size