Skip to content
Commitwise
Menu
Android Development

Google Play release and R8

Ship an Android app to Google Play safely, with R8 shrinking and obfuscation, keep rules for code used through reflection, mapping files to read crash reports, App Bundles and Play App Signing, version codes, testing tracks, staged rollouts you can halt, and the target API level and vitals Google enforces.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

The new version passed QA on the debug build, went to 100% of users on Google Play on Friday, and on Saturday the orders screen was empty for everyone. The release build had R8 turned on, which renamed the fields of the classes used to parse the server’s JSON, so every order came back with null values. The crash reports that followed were unreadable: a.b.c(Unknown Source:12). And because the release went to everyone at once, there was no small group to notice it first.

Shipping an Android app is mostly about the release build and the release process: what R8 does to your code, how to tell it what to leave alone, how to read crashes from obfuscated code, and how to roll out gradually so problems reach 1% of users instead of all of them.

New to some of these? Open what you need, or skip ahead.

Basics:Debug and release builds

Android Studio builds the debug variant when you press Run: unoptimized, debuggable, signed with a debug key. The release variant is what users get: optimized, not debuggable, and signed with your real key. They can behave differently, which is why release builds need testing too.

Basics:What is reflection?

Reflection is code that inspects classes at runtime, by name: “find a field called totalCents and set it”. JSON libraries like Gson, and some DI and serialization tools, use it. It breaks when a tool renames or removes the field, because the name in the JSON no longer matches anything.

Part Not started

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 SourceFile
  • versionCode is a number Google Play uses to order versions. Every upload needs a higher one.
  • isMinifyEnabled = true turns on R8; isShrinkResources also 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.
Spot the bugEvery order has a null total in the release build

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 classes

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: 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 Signature for generic reflection, SourceFile,LineNumberTable for readable traces.
  • Archive mapping.txt per 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

  1. Why can JSON parsing work in debug but fail in release?

  2. What is mapping.txt for?

  3. What's the most robust way to avoid serialization keep rules?

R8 shrinks (removes unused code), obfuscates (renames classes and members) and optimizes release builds. Anything accessed by name at runtime (reflection-based JSON, some SDKs) needs keep rules or annotations, or it's renamed or removed. Test the release build, and upload the mapping file so crash stack traces can be deobfuscated.

Part Not started

Shipping on Google Play

Google Play wants an Android App Bundle (.aab), not an APK. From the bundle, Play builds smaller APKs for each device, with only the screen density, CPU type and languages that device needs.

Signing proves an update comes from you. With Play App Signing (required for new apps), there are two keys:

  • the app signing key, kept by Google, which signs what users install;
  • your upload key, which you use to sign bundles you upload. If you lose it, Google can reset it, so losing a laptop doesn’t lose the app.

Releases go through tracks:

  1. Internal testing: up to 100 testers, available within minutes.
  2. Closed testing: groups you invite (QA, beta users).
  3. Open testing: anyone can join from the store page.
  4. Production: everyone, ideally as a staged rollout: 1%, then 5%, 20%, 50%, 100%, watching crashes in between. If something’s wrong, you halt the rollout.

Mechanics worth knowing in depth: versionCode is a strictly increasing integer across all tracks (a code used on any track can’t be reused); staged rollouts select users randomly by percentage and can be halted or resumed, but not reverted: users who updated keep the bad version, and the fix is a new upload with a higher versionCode (some teams keep a hotfix branch and a pre-approved process for this). Release management: managed publishing lets you control when an approved release goes live; review times vary. Policy: new apps and updates must target an API level within a year of the latest Android release (the deadline is usually 31 August), or they can’t be published; data safety forms must match what the app and its SDKs collect. Quality signals: Android vitals bad-behavior thresholds (user-perceived crash rate around 1.09% and ANR rate around 0.47% overall, lower per device model) reduce store visibility when exceeded. In-app updates (flexible or immediate) let you push users off a broken or deprecated version; for server-side breaking changes, also keep a minimum-supported-version check.

// app/build.gradle.kts: the upload key comes from CI secrets, never from the repository
android {
    signingConfigs {
        create("upload") {
            storeFile = file(System.getenv("UPLOAD_KEYSTORE_PATH") ?: "upload.jks")
            storePassword = System.getenv("UPLOAD_KEYSTORE_PASSWORD")
            keyAlias = System.getenv("UPLOAD_KEY_ALIAS")
            keyPassword = System.getenv("UPLOAD_KEY_PASSWORD")
        }
    }
    buildTypes {
        release { signingConfig = signingConfigs.getByName("upload") }
    }
    defaultConfig {
        // Derived from CI, so it always increases and is never edited by hand
        versionCode = (System.getenv("BUILD_NUMBER") ?: "1").toInt()
    }
}
# CI: build the bundle (mapping.txt is included in it) and send it to the internal track
./gradlew :app:bundleRelease
# then upload app/build/outputs/bundle/release/app-release.aab with the Play Developer API
# (for example through Gradle Play Publisher or fastlane supply), track = internal

Production puzzles

Predict the output

Version 4.12 is at a 20% staged rollout when crash rate spikes. The team halts the rollout. What about the 20% of users who already have 4.12?

Predict the output

A developer's laptop with the only copy of the upload keystore is stolen. The app uses Play App Signing. What can the team do?

Kotlin note

R8 also runs on library code. If you publish an Android library, ship consumer rules (consumerProguardFiles("consumer-rules.pro")) for anything your library accesses by reflection, so apps using it don’t have to guess. Don’t put broad rules like -keep class com.yourlib.** { *; } there: they disable shrinking for your whole library in every app that uses it.

A release checklist

  • versionCode from CI; versionName from the release tag.
  • Release build tested: instrumented smoke tests on the minified build, Play pre-launch report clean.
  • Mapping file archived (and uploaded to any non-Play crash tool).
  • Release notes per language; data safety form updated if SDKs changed.
  • Internal → closed testing for a few days → production at 1–5%, watching crash-free users and ANRs, then widen.
  • A known path for a hotfix: branch, version bump, expedited rollout, remote kill switches for risky features.
Exerciseeasy

Plan a rollout

You’re releasing a version that changes how payments work. Describe how you’d move it from your machine to 100% of users, and what you’d watch at each step.

›

Use each testing track for a different kind of feedback, and think about what “halt” can and can’t fix.

SolutionTry it yourself first
  1. Internal track: the team installs the release build within minutes; check payments end to end with test cards.
  2. Closed testing: QA and beta testers for a few days; collect crashes and feedback.
  3. Production at 1%: watch crash-free users, ANRs and payment success rate (from your backend metrics) for a day.
  4. 5% → 20% → 50% → 100%, a day or so between steps, as long as the numbers stay normal.

If something goes wrong: halt (stops more users getting it), turn off the new payment flow with a remote flag if you built one, and ship a fixed version with a higher versionCode. Users who already updated can’t be rolled back by Play.

  • AAB uploads; Play generates split APKs. Play App Signing: Google holds the app signing key; the upload key is resettable.
  • versionCode strictly increasing across all tracks; derive it from CI.
  • Tracks: internal → closed → open → production staged rollout; halting stops new installs but never reverts; fixes need a higher version.
  • Policy and quality: annual target API level deadline, data safety forms, vitals thresholds (crash ~1.09%, ANR ~0.47%) affecting visibility; in-app updates and min-version checks.
  • Library authors ship narrow consumer R8 rules.

Check this part

  1. What happens to users who already installed a release when you halt its rollout?

  2. What must change with every upload to Play?

  3. With Play App Signing, what happens if the upload key is lost?

Upload Android App Bundles; Google signs the APKs it generates with your app signing key (Play App Signing) while you sign uploads with a separate upload key that can be reset. Every upload needs a higher versionCode. Release through internal, closed and open testing tracks, then a staged rollout to production that you can halt. You can't roll back: fixes ship as a new, higher version.

Key takeaways

  • R8 shrinks, renames and optimizes release builds; test the release build.
  • Code that’s used by name (reflection) needs keep rules, or switch to code generation.
  • Keep the mapping file of every release to read crash reports.
  • Release through testing tracks and staged rollouts; fixes ship as new versions.
  • Full-mode R8 with precise keep rules and codegen serialization.
  • Mapping files archived per build; line numbers kept.
  • Play App Signing with a resettable upload key; CI-derived version codes.
  • Staged rollouts, kill switches, target API deadlines and vitals thresholds.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.