What R8 does to your code
When you build a release with isMinifyEnabled = true, a tool called R8 processes your code and libraries:
- Shrinking: removes classes and methods nothing calls. Apps often lose half their size.
- Obfuscation: renames classes, methods and fields to short names like
a,b,c. Smaller, and harder to reverse-engineer. - Optimization: inlines small functions, removes dead branches, merges classes.
R8 decides what’s “used” by following calls in your code. It can’t see code that’s used only by name at runtime, through reflection. So a data class parsed by Gson, whose fields are only ever set by reflection, gets its fields renamed: the JSON says "totalCents", the class now has a field called a, and nothing matches.
You fix that with keep rules (or annotations) that tell R8 “leave these names alone”, or better, by using libraries that don’t need reflection.
R8 performs tree shaking from entry points (manifest components, keep rules, library consumer rules), obfuscation using a dictionary of short names, and whole-program optimization (inlining, class merging, devirtualization, enum unboxing). Since AGP 8.0, full mode is the default: more aggressive assumptions (for example, classes only reachable via reflection may lose their default constructors, and generic signatures are stripped unless kept), which breaks reflection-based libraries without precise rules. Prefer code-generated serialization (kotlinx.serialization with its plugin, Moshi codegen) whose consumer rules ship with the library. For your own reflection, keep the minimum (-keepclassmembers for fields, @Keep for specific classes). The mapping file (build/outputs/mapping/release/mapping.txt) maps obfuscated names back; Play Console deobfuscates crashes and ANRs when it’s uploaded with the bundle (AGP includes it in the AAB automatically), and Crashlytics needs its own upload.
// app/build.gradle.kts
android {
defaultConfig {
applicationId = "com.example.shop"
versionCode = 412 // must increase with every upload
versionName = "4.12.0" // what users see
}
buildTypes {
release {
isMinifyEnabled = true // R8: shrink, obfuscate, optimize
isShrinkResources = true // remove unused resources (needs minify)
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
}# proguard-rules.pro: keep only what is really accessed by name
# Gson maps JSON keys to field names by reflection: keep fields of the network models
-keepclassmembers class com.example.shop.data.network.dto.** {
<fields>;
}
# Gson needs generic type information (List<OrderDto>) at runtime
-keepattributes Signature
-keep class com.google.gson.reflect.TypeToken { *; }
-keep class * extends com.google.gson.reflect.TypeToken
# Keep line numbers for readable stack traces, but hide source file names
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFileversionCodeis a number Google Play uses to order versions. Every upload needs a higher one.isMinifyEnabled = trueturns on R8;isShrinkResourcesalso removes images and layouts nothing uses.- The keep rules say: don’t rename the fields of the network models, because Gson finds them by name. Everything else can still be shrunk and renamed.
- Line numbers are kept, so crash reports point to the right line once deobfuscated.
Tap the line with the bug.
// data/network/OrderDto.kt
data class OrderDto(
val id: String,
val totalCents: Long,
val status: String,
)
// data/network/OrdersApi.kt
interface OrdersApi {
@GET("orders")
suspend fun orders(): List<OrderDto>
}
// Retrofit created with GsonConverterFactory.create(); no keep rules for dto classesHint: Who reads OrderDto's fields by name, and what does R8 do to field names in a release build?
Gson creates OrderDto objects by reflection: it looks up fields by the names in the JSON (id, totalCents, status). Nothing in the code reads those fields by name, so R8 renames them (a, b, c), and in full mode may also remove the constructor Gson needs. Gson finds no matching fields, leaves them at their defaults, and Kotlin’s non-null String fields end up null at runtime anyway, crashing later in code that “can’t” see null.
It works in debug builds because R8 doesn’t run there. Three fixes, from best to quickest:
// 1. Code generation instead of reflection: no keep rules needed
@Serializable
data class OrderDto(val id: String, val totalCents: Long, val status: String)
// Retrofit: .addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
// 2. Keep Gson's model fields by name with annotations
data class OrderDto(
@SerializedName("id") val id: String,
@SerializedName("totalCents") val totalCents: Long,
@SerializedName("status") val status: String,
)
// (plus Gson's own consumer rules; recent Gson versions ship rules that keep @SerializedName fields)
// 3. A keep rule for the dto package in proguard-rules.pro (shown above)And add a test that runs on the release build: an instrumented smoke test of the main screens, or the Play pre-launch report, catches this class of bug before users do.
Predict the output
A crash report from production shows: at a.b.c.d(SourceFile:48). The mapping.txt for that exact build was not saved. Can the team find the crashing function?
R8's naming depends on the whole program and its inputs; rebuilding the same commit with different dependencies, tool versions or even ordering can produce different names. Keep the mapping file of every released build (AGP puts it in the AAB, so Play Console has it for crashes and ANRs there; upload it to Crashlytics or archive it in CI), and keep LineNumberTable so line numbers are meaningful.
- R8: tree shaking from entry points and keep rules, obfuscation, whole-program optimization; full mode default since AGP 8.
- Reflection (Gson, some SDKs) needs precise keep rules or annotations; prefer codegen serialization (kotlinx.serialization, Moshi codegen) with shipped consumer rules.
- Keep
Signaturefor generic reflection,SourceFile,LineNumberTablefor readable traces. - Archive
mapping.txtper release; Play deobfuscates from the AAB, other crash tools need uploads. - Test release builds (instrumented smoke tests, pre-launch report): debug never runs R8.
Check this part
Why can JSON parsing work in debug but fail in release?
What is mapping.txt for?
What's the most robust way to avoid serialization keep rules?