R8 / Proguard — The Mental Model, Keep Rules, and a Debugging Workflow That Actually Works
Every Android developer, eventually, ships a release build that crashes on launch. Debug works perfectly. Internal testing passes. The Play Store rollout starts — and Crashlytics lights up with NoSuchMethodError or ClassNotFoundException. The stack trace points to a class that exists in your code but apparently doesn’t exist in the APK. You roll back, ship a hotfix two days later, and learn the hard way that R8 stripped something it shouldn’t have.
This post is the R8 mental model that prevents that situation. We’ll cover what R8 actually does (it’s four jobs, not one), why it strips classes that “obviously” aren’t used, how to write keep rules that work, what changed when R8 replaced ProGuard, and the patterns that bite even experienced teams. By the end you’ll be able to read R8 errors and fix them, not just copy-paste rules from Stack Overflow until release builds work.
R8 Does Four Jobs — Know Which Is Causing Your Bug
People say “R8 minified my code” as if it’s one operation. It’s actually four, and they cause different symptoms when they go wrong:
1. Shrinking (tree-shaking). R8 walks your code starting from the entry points (Application, Activity, Service, content provider, manifest-declared receivers) and finds every class and method reachable from there. Anything not reachable is removed. This is why your NewsItem class — which you only construct via Gson’s reflection — appears to be unused: R8 can’t see the reflection call site.
2. Optimization. R8 inlines short methods, removes dead branches, replaces final field reads with their compile-time values, and merges classes when it’s safe. This is what makes R8 different from old ProGuard — ProGuard mainly shrunk and obfuscated; R8 actually transforms code.
3. Obfuscation. Class, method, and field names get renamed to a, b, c. This is what makes Crashlytics stack traces unreadable without a mapping file. Obfuscation also strips out unused method parameters and reorders things.
4. Resource shrinking. Drawables, layouts, strings that aren’t referenced anywhere get dropped from the APK. This catches dead resources but also breaks anything you reference dynamically (getIdentifier, by-name lookups).
When release crashes, the question isn’t “is R8 broken?” — it’s “which of the four operations stripped something I needed?” NoClassDefFoundError usually means shrinking removed a class. NoSuchMethodError usually means optimization changed a signature or shrinking removed a method. android.content.res.Resources$NotFoundException usually means resource shrinking removed an asset.
The Reflection Problem — Why R8 Strips Classes That Are “Used”
R8 is a static analyzer. It can prove a class is used if your code calls a constructor, references a field, or invokes a method on it. It cannot prove a class is used if you reference it via reflection — Class.forName("com.example.podcast.Episode"), Gson reading a JSON field name back into a Kotlin property, Retrofit instantiating your service interface, Hilt looking up @Inject-annotated constructors.
// A typical podcast app data class
data class Episode(
@Json(name = “episode_id”) val id: String,
@Json(name = “title”) val title: String,
@Json(name = “duration_seconds”) val duration: Long
)
// Used like this
val episode = moshi.adapter(Episode::class.java).fromJson(jsonString)
// 💥 In release: Episode might be stripped, or its fields renamed,
// or both — and Moshi looks up fields by name via reflection
What R8 sees: Episode class is constructed via reflection (which R8 can’t trace), accessed via field reflection (which R8 also can’t trace). From R8’s perspective, no one constructs Episode directly, no one reads its fields directly — so the class might be removable, the fields can be renamed, the constructor might be inlined or removed. Result: Moshi looks for a field called title, finds a field called a, gets null or throws.
This is why you need keep rules — explicit hints to R8 that “don’t touch this, even though it looks unused.”
Keep Rules — The Five Patterns You Actually Need
The full ProGuard/R8 syntax has dozens of options, but in practice 95% of real keep rules fall into five patterns. Understand these and you can write the rest.
Pattern 1: Keep a specific class entirely.
-keep class com.example.podcast.api.dto.** { *; }
// Keeps every class in the dto package and ALL their members
// Use for DTOs that are serialized/deserialized via reflection
The ** matches the package and all sub-packages. *; inside the braces means “all members” (fields and methods). This is the most common rule and the right one for most data classes hit by Moshi/Gson/Kotlinx Serialization.
Pattern 2: Keep classes with specific annotations.
-keep @com.squareup.moshi.JsonClass class * { *; }
// Keeps any class annotated with @JsonClass and all its members
Better than the package-level rule because it’s tied to actual semantic intent. If a developer adds a new DTO outside the dto package, this rule still protects it. The annotation-based form is generally preferred when libraries provide it.
Pattern 3: Keep classes that extend or implement something.
-keep class * extends android.app.Activity
-keep class * implements android.os.Parcelable {
public static final ** CREATOR;
}
// Keeps every Activity (in case the manifest references one R8 doesn’t see)
// Keeps Parcelable’s CREATOR field that the OS looks up reflectively
The Parcelable rule is in every default ProGuard config but worth understanding — the OS calls YourClass.CREATOR.createFromParcel via reflection, so you need both the class and that specific field.
Pattern 4: Keep specific members but allow the class to be obfuscated.
-keepclassmembers class com.example.podcast.player.PlayerService {
public void seekTo(long);
public void setPlaybackSpeed(float);
}
// The CLASS can be renamed, but these specific methods must keep their names
// Useful for methods exposed via JNI, JavaScript bridges, or AIDL
-keepclassmembers is subtler than -keep: it keeps the listed members only if the class itself survives shrinking. -keep would also force the class to survive even if nothing references it. Use -keepclassmembers when you’re shaping members on classes that are otherwise referenced.
Pattern 5: Keep names but allow inlining/optimization.
-keepnames class com.example.podcast.analytics.AnalyticsEvent
// Don’t rename this class, but everything else (optimization, removal) is fair game
// Useful when something compares classnames as strings
For 90% of apps, you only need patterns 1, 2, and 3. Most modern libraries (Retrofit, Moshi with codegen, Hilt, kotlinx.serialization) ship their own consumer ProGuard rules so you don’t have to write them — but knowing what they do is what lets you debug when something exotic breaks.
@Keep — The Annotation Alternative
Maintaining a global keep-rules file gets unwieldy. The cleaner alternative for app-specific code: the androidx.annotation.Keep annotation.
import androidx.annotation.Keep
@Keep
data class Episode(
val id: String,
val title: String,
val duration: Long
)
// ✅ R8 treats this class as a keep target
// Equivalent to writing -keep class ... in proguard-rules.pro
The advantage: the keep instruction lives next to the code it protects. When someone deletes the class, the annotation goes with it — no orphaned proguard rules. When someone renames the package, the annotation still works.
The trade-off: @Keep on a class keeps the class and its no-arg constructor, but not necessarily its fields. For classes hit by reflective field access (most JSON DTOs), pair @Keep with library-specific annotations like Moshi’s @JsonClass(generateAdapter = true) — codegen-based serialization sidesteps reflection entirely and avoids the keep-rules problem altogether.
The 2026 best practice: use codegen-based serialization (Moshi codegen, kotlinx.serialization) which generates real Kotlin code that R8 can analyze, eliminating the reflection problem. Keep rules become a fallback for libraries that don’t support codegen, not a primary tool.
Reading R8 Errors — What the Build Output Actually Tells You
When R8 strips something it shouldn’t, the symptom is usually a runtime crash. But R8 also produces build-time warnings — and configuring R8 to fail on warnings catches problems before they ship.
// In proguard-rules.pro — turn warnings into build failures during configuration audit
-dontwarn javax.annotation.**
-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement
// ❌ Don’t do this for things you don’t understand — you’re hiding real problems
The pattern many teams use: maintain a narrow list of -dontwarn rules for known-safe missing-class warnings (e.g., libraries that have optional dependencies), and treat any unexpected warning as a release blocker. R8’s default in modern AGP is to be strict.
For runtime crashes, the workflow:
- Get the obfuscated stack trace from Crashlytics
- Find the corresponding
mapping.txtfile (auto-generated inapp/build/outputs/mapping/release/) - Run
retrace:retrace -verbose mapping.txt obfuscated_trace.txt - The de-obfuscated trace points to the actual class/method that failed
If you uploaded the mapping file to Crashlytics (Firebase Gradle plugin does this automatically), the de-obfuscation happens server-side and you see real names in the dashboard. Always upload mapping files for release builds — debugging without them is hours-of-pain hard.
Configuration — The Sane Defaults for an App in 2026
Here’s the build.gradle.kts shape I’d recommend for a production app today:
android {
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
// Both default to false — you have to opt in.
// Resource shrinking requires minification (no shrinking-without-R8)
proguardFiles(
getDefaultProguardFile(“proguard-android-optimize.txt”),
// ✅ Use the “-optimize” variant, not “proguard-android.txt”
// The non-optimize version disables R8’s optimization phase
“proguard-rules.pro”
)
}
debug {
isMinifyEnabled = false
// ✅ Don’t enable R8 in debug — build times balloon, debugging gets harder
}
// Optional: a “benchmark” build type that’s release-like for profiling
create(“benchmark”) {
initWith(getByName(“release”))
signingConfig = signingConfigs.getByName(“debug”)
isDebuggable = false
matchingFallbacks += “release”
}
}
}
The benchmark build type is worth setting up early — it’s what you’ll point Macrobenchmark at when measuring real release-like performance. Profiling a debug build gives misleading numbers because R8 didn’t run.
Library Keep Rules — What You Get for Free
Modern Android libraries ship their own ProGuard rules in their AAR/JAR. AGP automatically merges them into your app’s rule set. You don’t need to write keep rules for:
- Retrofit — ships rules that keep your API interfaces
- Moshi (with codegen) — codegen creates real classes, no reflection needed
- Gson — you DO need rules; Gson works purely via reflection
- kotlinx.serialization — codegen-based, mostly works automatically
- Hilt / Dagger — ships rules; codegen-based DI is R8-friendly
- Room — codegen, R8-friendly, no rules needed for entity classes
- WorkManager — ships rules for the worker class registry
- Compose — ships rules for the runtime
Where you DO need to write rules:
- Gson (every model class)
- Anything with custom JNI — native code accesses Java fields by name
- Anything with WebView JavaScript bridges (
@JavascriptInterface— AndroidX has rules but custom usages need care) - Manual
Class.forNameorMethod.invokeusage - Sealed class hierarchies you serialize/deserialize as polymorphic JSON (Moshi/kotlinx need to know about all subtypes)
The migration tip if your codebase uses Gson: switching to Moshi with codegen, or kotlinx.serialization, eliminates an entire category of R8 bugs. The migration is mechanical — same JSON format, same shape — and the win is real. I’ve seen apps drop their proguard-rules.pro by 80% after this migration.
Measuring R8’s Impact — What You Actually Get
Before/after numbers from a typical mid-sized podcast app:
┌─────────────────────────────────┬─────────────┬──────────────┐
│ Metric │ R8 disabled │ R8 enabled │
├─────────────────────────────────┼─────────────┼──────────────┤
│ APK size (universal) │ 28.4 MB │ 12.8 MB │
│ Method count (DEX) │ 78,400 │ 41,200 │
│ Cold start (P50) │ 1240 ms │ 980 ms │
│ Cold start (P95) │ 2300 ms │ 1700 ms │
│ Build time (clean release) │ 42 s │ 78 s │
└─────────────────────────────────┴─────────────┴──────────────┘
The headline: half the size, a third faster cold start, but builds take ~80% longer. The startup improvement isn’t magic — less code to dex-load, fewer classes to verify, smaller method tables. The build cost is the trade-off; do release builds in CI, not on developer laptops, and it stops mattering.
R8 also enables the App Bundle delivery model to work well — the per-device APKs Google Play generates from your AAB are smaller because there’s less code to start with. App Bundle + R8 + baseline profiles are the three Performance levers that compound; this post is the third one.
Pitfalls Worth Calling Out
Disabling R8 instead of fixing the rule. “Tests pass with isMinifyEnabled = false” isn’t a fix. It’s ~50% size regression and removes every optimization. Find the missing keep rule.
Over-broad keep rules that defeat the point. -keep class com.example.** { *; } keeps your entire app from being optimized. You’ll see release builds shrink by 5% instead of 50%. Scope keep rules tightly.
Forgetting to upload mapping files. Crashlytics shows at a.a.b.c(:0) stack traces that you cannot debug. Configure the Firebase Gradle plugin to upload automatically on every release build.
Not testing the release build before shipping. R8 only runs on release builds. If your CI only tests debug, R8-related crashes ship to production. Add at least a smoke test on a release build — install it on a real device, walk the main flow, exit.
Using -dontoptimize as a workaround. Disables one of R8’s four jobs. You’re leaving real performance on the table. Find what optimization is breaking and write a narrower rule.
Mixing manual ProGuard rules with library-provided ones inconsistently. If a library breaks under R8, file a bug or check their existing consumer-rules — usually the library already shipped a rule and you missed enabling it. Don’t blindly copy random rules from Stack Overflow.
A Debugging Workflow That Actually Works
When release crashes and debug doesn’t:
- Build a release variant locally:
./gradlew assembleRelease - Install it on a device, reproduce the crash
- Check the de-obfuscated stack trace (use the local mapping file or Crashlytics)
- Identify the operation: missing class → shrinking; missing method → optimization or shrinking; missing resource → resource shrinking; ClassCastException with weird types → class merging from optimization
- Look at
app/build/outputs/mapping/release/usage.txt— lists every class/method R8 removed. Search for the symbol that’s missing at runtime - Write the narrowest keep rule that fixes it
- Verify with another release build that the crash is gone AND APK size hasn’t regressed significantly
That usage.txt file is genuinely useful and underused. It’s R8’s diary of what it removed. When something is missing at runtime, the answer is usually in there.
Closing
R8 isn’t magic and it isn’t hostile — it’s a static analyzer with strong opinions about what code is dead. The crashes that ship from R8-stripped code happen because reflection is invisible to static analysis, not because R8 is buggy. Once you can read the build output, write narrow keep rules, and prefer codegen over reflection where libraries support it, R8 becomes a tool that gives you smaller APKs and faster cold starts — not a black box that randomly breaks release builds.
Pair this with the App Startup post and the Memory Leaks post and you’ve got the three highest-leverage performance interventions for any production Android app. Smaller, faster to start, doesn’t leak. Everything else is polish.
Happy coding!
Comments (0)
Sign in to leave a comment.
No comments yet. Be the first to share your thoughts.