Release builds, R8 and shrinking
Mandi Bhav's release APK, as module 13 left it, is 11.6 MB. Most of that is code the app never runs: every class of Compose, Room, Hilt, OkHttp and Coil, whether Mandi Bhav calls it or not, with every name spelt out in full. Arjun's brother downloads it on a 4G connection that drops to 2G at the mandi gate, on a phone with 32 GB of storage that is already full of WhatsApp videos. This lesson turns on R8 for the release build, proves the app still works, measures what it saved, and deals with the four things module 13 said would follow: keep rules, mapping files, the Compose version files, and the Baseline Profile.
What R8 does
R8 is the compiler that turns your app's bytecode into Android's DEX format for a release build. With optimisation on, it does four things on the way:
| Step | What happens | What it costs you |
|---|---|---|
| Shrinking | Starts from what the manifest and your code use, and removes every class, method and field nothing reaches | Code reached only by reflection looks unused and is removed |
| Optimisation | Inlines small methods, merges classes, removes dead branches | Stack traces no longer match your source line for line |
| Obfuscation | Renames what is left to short names: PricesViewModel becomes xb3 |
Stack traces are unreadable without the mapping file |
| Resource shrinking | Removes drawables, layouts and strings nothing references | A resource looked up by a name built at run time looks unused |
R8 is not a security tool. Renaming makes the code harder to read, not impossible; anything secret in an APK is readable by anyone who has it, as module 8 said. And it is for release builds only: debug builds stay unshrunk so the debugger, Layout Inspector and fast rebuilds work.
Turning it on
In app/build.gradle.kts:
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
signingConfig = signingConfigs.findByName("upload")
}
isMinifyEnabled runs R8's shrinking, optimisation and obfuscation; isShrinkResources removes unused resources, which in AGP 9 R8 does together with the code. proguard-android-optimize.txt is the default rule file for Android apps; proguard-rules.pro is yours. The signing line is lesson 2.
A note on a newer form. AGP 9.3 added a shorter way to say the same thing, optimization { enable = true }, with your rules in src/main/keepRules/*.keep, and the Android documentation now shows it first. Mandi Bhav tried it, and it built. But the Baseline Profile plugin from module 13 (Benchmark 1.5.0) creates a nonMinifiedRelease build to generate the profile from, and with the new block that build came out minified too: 2.2 MB and 22,508 method references, where it should have been the unshrunk 11 MB. A profile generated from it would be written in R8's short names. So Mandi Bhav keeps the two lines above, which the documentation still supports, until the plugin catches up. Check for that whenever you change one build tool: the others may not know yet.
Did it break anything?
Shrinking is the step that can break an app: code reached only by reflection, or by a name in a string, looks unused to R8. The only proof is to run the minified build and use it. ./gradlew :app:installBenchmarkRelease installs a build made exactly like release, minified, but signed with the debug key until you set up an upload key (lesson 2); module 13's Baseline Profile plugin adds it. On the emulator, from a fresh install with data cleared:
- the first download, the price list, filters and search, a price's detail with its seven days;
- Refresh, and "Prices are up to date";
- Process death: open a price, press Home,
adb shell am kill, open the app again. The detail screen came back, which means Navigation 3 restored its@Serializablekeys under R8; - an alert saved, with the notification permission dialog;
- the deep link
mandibhav://price?market=Lasalgaon%20APMC&commodity=Onionandmandibhav://alerts; - a sale slip chosen with the photo picker;
- Use my location on the Markets tab, which sorted Pune (Gultekdi) first, 4 km away.
No crash, and nothing in adb logcat -b crash. All of that worked without one line in proguard-rules.pro.
Why Mandi Bhav needs no keep rules
Every reflective trick Mandi Bhav relies on belongs to a library, and the libraries ship their own rules inside their AARs and JARs. R8 reads them by itself, and every release build writes the full merged list to app/build/outputs/mapping/release/configuration.txt, each section headed with the library it came from. Three of them, as they appear there:
# The proguard configuration file for the following section is androidx.hilt:hilt-work:1.4.0
# Keep class names of Hilt injected Workers since their name are used as a multibinding map key.
-keepnames @androidx.hilt.work.HiltWorker class * extends androidx.work.ListenableWorker
# The proguard configuration file for the following section is androidx.room3:room3-runtime-android:3.0.3
-keep class * extends androidx.room3.RoomDatabase { void <init>(); }
# The proguard configuration file for the following section is org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.12.0
# Keep `Companion` object field of serializable classes.
-keepclassmembers @kotlinx.serialization.Serializable class ** {
static ** Companion;
}
The first matters more than it looks. WorkManager stores the worker's class name in its database and Hilt finds the factory by that name, so if R8 renamed PriceRefreshWorker in an update, the daily refresh scheduled by the previous version would look for a class that no longer exists. The mapping file shows the rule working: the worker kept its name, while the navigation keys, which kotlinx.serialization finds without names, did not.
com.riztechacademy.mandibhav.work.PriceRefreshWorker -> com.riztechacademy.mandibhav.work.PriceRefreshWorker:
com.riztechacademy.mandibhav.navigation.PriceDetail -> jc2:
This is one more reason module 9 chose kotlinx.serialization over a reflection-based JSON library: its serializers are generated at compile time, so R8 can see every use. proguard-rules.pro now says, in a comment, that it is empty on purpose and how to add to it.
The trap: a keep rule you do need
To see what a missing rule looks like, a scratch copy of Mandi Bhav swapped kotlinx.serialization for Gson, which reads and writes fields by name through reflection, with @SerializedName on the snake_case fields only, as Gson code usually has. The debug build worked. The minified release build crashed on launch:
FATAL EXCEPTION: main
Process: com.riztechacademy.mandibhav.m14, PID: 2325
java.lang.NullPointerException: Attempt to invoke virtual method 'java.lang.Class java.lang.Object.getClass()' on a null object reference
at gt.f0(r8-map-id-290d2a8183b828b9232517d39135b4d62a6fcb281f3247123fa0c94ea1e68fec:1)
at i42.u(r8-map-id-290d2a8183b828b9232517d39135b4d62a6fcb281f3247123fa0c94ea1e68fec:91)
Retraced with that build's mapping (module 13's retrace):
at kotlin.collections.CollectionsKt__IterablesKt.f0(Iterables.kt:1)
at com.riztechacademy.mandibhav.data.network.NetworkPricesKt.asLatestPrices(NetworkPrices.kt:97)
at com.riztechacademy.mandibhav.data.network.RetrofitPriceNetworkDataSource$fetchLatestPrices$2.invokeSuspend(RetrofitPriceNetworkDataSource.java:27)
R8 had renamed NetworkPrices.markets to d. Gson found no d in the JSON, left the field null, and a Kotlin List<NetworkMarket> that can never be null was null. (Line 97 is past the end of that 95-line file: Kotlin numbers the lines of an inlined function, here associate, after the file's last line. The function name is the part to trust.) Only the fields with @SerializedName survived, because Gson's own rules keep those and nothing else.
The first fix was the narrowest rule that seemed right, keeping the fields' names:
-keepclassmembers class com.riztechacademy.mandibhav.data.network.Network* {
<fields>;
}
It crashed again, differently:
java.lang.ClassCastException
at com.riztechacademy.mandibhav.data.network.NetworkPricesKt.asLatestPrices(NetworkPrices.kt:100)
The names were back, but R8 had also dropped the generic type of List<NetworkMarket> for classes it does not keep, so Gson filled the list with its own maps instead of NetworkMarkets. Keeping the classes themselves fixed it:
# Gson fills these classes' fields by name, by reflection, where R8 cannot see it.
-keep class com.riztechacademy.mandibhav.data.network.Network* {
<fields>;
}
and the prices loaded. Three lessons in one trap. A keep rule is a fix for a crash you have seen, in a class you can name, never a precaution. The first rule that makes one crash go away can reveal the next, so run the minified build again after every rule. And the tempting rule, -keep class com.riztechacademy.** { *; }, does work, because it turns R8 off for your whole app: the same scratch build grew from 2.29 MB to 2.42 MB, and from 28,199 method references to 31,495, mostly Hilt's generated code that no longer shrank.
The mapping file, archived
Module 13 said to keep every release's mapping file. Each release build writes it to app/build/outputs/mapping/release/mapping.txt, about 44 MB for Mandi Bhav, and ./gradlew clean deletes it. Mandi Bhav now has a task that copies it out of build/:
androidComponents {
onVariants(selector().withBuildType("release")) { variant ->
val versionCode = variant.outputs.single().versionCode
tasks.register<Copy>("archive${variant.name.replaceFirstChar(Char::uppercase)}Mapping") {
from(variant.artifacts.get(SingleArtifact.OBFUSCATION_MAPPING_FILE))
into(rootProject.layout.projectDirectory.dir(versionCode.map { "release-mappings/$it" }))
}
}
}
./gradlew :app:archiveReleaseMapping builds the release if it needs to and writes release-mappings/1/mapping.txt, named by versionCode (lesson 4). The folder is in .gitignore: 44 MB per release does not belong in git, it belongs wherever your team keeps release files.
Before you retrace anything, match the ids. Every frame of a minified trace names the build's map id, and the mapping's header names its own. For the second Gson crash:
at ij0.p(r8-map-id-879534a601062431fc4a3acacd2da439760e9dfee0126ee8454443886b7b1e0b:3)
# pg_map_id: 879534a601062431fc4a3acacd2da439760e9dfee0126ee8454443886b7b1e0b
Equal: this is the right file. Module 13 showed what the wrong one gives you, a confident answer six lines off.
The Compose version files
Mandi Bhav's release APK carries 86 small files like META-INF/androidx.compose.ui_ui.version, 617 bytes in all, 18 of them Compose's; the debug APK carries 89. They look like clutter, and a common "tidy the APK" snippet removes them:
packaging {
resources {
excludes += "META-INF/*.version"
}
}
In a scratch copy, that took the debug APK's version files from 89 to 0. The Compose documentation is specific about the cost: if Compose components do not appear in the Layout Inspector, make sure you are not removing the META-INF/androidx.compose.*.version files, because the inspector needs them. packaging applies to every build type, so the snippet breaks the debug build you inspect, to save about 600 bytes. Leave them in; R8 does.
The Baseline Profile, regenerated
Module 13 generated Mandi Bhav's Baseline Profile against an unshrunk release and promised to regenerate it once release was minified. The plugin always generates from nonMinifiedRelease, in your source's names, and R8 rewrites the rules into its short names when it builds the release: the profile inside the APK, assets/dexopt/baseline.prof, went from 27,896 bytes to 10,987.
The trap: a successful build with an empty profile
The first ./gradlew :app:generateBaselineProfile took 16 seconds and said:
java.lang.RuntimeException: Failed to install APK …/baselineprofile-nonMinifiedRelease.apk: …
android.os.ParcelableException: java.io.IOException: Requested internal only, but not enough space
…
No baseline profile rules were generated for the variant `release`.
…
BUILD SUCCESSFUL in 16s
and git diff --stat showed both profile files emptied, 29,365 lines deleted from each. The emulator had about 370 MB free, too little for the 51 MB test APK, and the plugin still wrote its empty result over the committed profile. Committed, that release would start slower, with nothing to say why. Read git diff --stat before committing a regenerated profile; a profile that shrinks to nothing is never right. The fix was room on the emulator. Here, a scratch copy trimmed the test APK to the emulator's one processor type, 20.8 MB instead of 51 MB (most of it is Perfetto's trace processor for four processor types), which changes nothing in the profile; on your machine, freeing space or a fresh emulator does the same. The run took 3 minutes 22 seconds and reported:
Comparison with previous baseline profile:
29365 Old rules
29390 New rules
36 Added rules (0.12%)
11 Removed rules (0.04%)
29354 Unmodified rules (99.84%)
The 47 changes are run-to-run differences (which snackbar happened to show, which methods were hot), not R8: the profile is written before R8 runs. Regenerate when the start-up path changes; turning on R8 does not require it, but now you have checked rather than assumed.
What it saved
apkanalyzer, in the SDK's cmdline-tools/latest/bin, reads an APK without installing it:
apkanalyzer apk file-size app-release-unsigned.apk
apkanalyzer apk download-size app-release-unsigned.apk
apkanalyzer dex references app-release-unsigned.apk
| Release APK | Before R8 | After R8 |
|---|---|---|
| File size | 11,568,527 bytes | 2,280,099 bytes |
| Estimated download size | 11,007,362 bytes | 2,038,744 bytes |
| DEX files | 3, 30.4 MB uncompressed | 2, 3.8 MB uncompressed |
| Method references | 166,552 | 28,280 |
resources.arsc |
475,276 bytes | 103,500 bytes |
Files under res/ |
56 | 11 |
apkanalyzer apk compare before.apk after.apk lists every file's change, which is how you find what grew when a release suddenly gets bigger.
And start-up, measured with module 13's StartupBenchmark, ten cold starts in each mode, the two builds measured one after the other on the same emulator with nothing else running:
| Median time to first frame | Without R8 | With R8 |
|---|---|---|
| No compilation | 319.7 ms | 253.7 ms |
| With the Baseline Profile | 267.5 ms | 226.5 ms |
The same caution as module 13: an emulator, one session, indicative only. But both rows moved the same way, by 15 to 21%, because there is less code to load and what is left has been optimised.
APK or app bundle
./gradlew :app:bundleRelease writes app/build/outputs/bundle/release/app-release.aab, 5.5 MB. An app bundle is not something a phone can install: it is what you upload, and Google Play cuts it into APKs for each phone. New apps have had to use bundles on Play since August 2021.
Two things are inside that matter here. The bundle carries the mapping file, at BUNDLE-METADATA/com.android.tools.build.obfuscation/proguard.map (43.9 MB, compressed to 3.4 MB, which is most of the bundle's size), and Play reads it, so Android vitals shows your class names without you uploading anything. Its pg_map_id was eee0dd9e…, the same as release-mappings/1/mapping.txt, because the APK and the bundle came from the same R8 run. And the splits: Gradle can run Google's bundletool on it (./gradlew :app:makeApkFromBundleForRelease), which cut Mandi Bhav's bundle into 237 APKs: a base for each of three ranges of Android versions, the smallest 2.06 MB; one per processor type, 39 to 43 KB each, carrying DataStore's small native library; and one per language, about 3 KB each, for the languages the libraries ship strings in. An arm64 phone in English downloads the base, one 43 KB split and one 10 KB split instead of every processor's library. For Mandi Bhav, almost all code and no native libraries, the gain over the universal APK is small; for an app with large native libraries it is large.
| APK | App bundle | |
|---|---|---|
| Installable | Yes, adb install |
No, Play (or bundletool) makes APKs from it |
| What Play accepts for new apps | No | Yes |
| Mapping file | Upload it yourself | Inside, used automatically |
| Who signs what phones install | You | Play, with the app signing key (lesson 2) |
Check your work
What did the minified build need in proguard-rules.pro? Nothing. Every library that uses reflection ships its own rules, and the merged list is in configuration.txt.
Why must PriceRefreshWorker keep its name, and what keeps it? WorkManager stores the worker's class name and Hilt looks the factory up by it; androidx.hilt:hilt-work's -keepnames rule keeps it.
Why did the first Gson keep rule not fix the crash? It kept the field names but not the classes, so R8 dropped the generic type of List<NetworkMarket> and Gson filled the list with maps: a ClassCastException. Keeping the classes fixed it.
How do you know a mapping file belongs to a crash? The r8-map-id-… in the trace's frames equals the pg_map_id in the mapping's header.
What did generateBaselineProfile do when the test APK would not install? It printed the install failure, reported BUILD SUCCESSFUL, and emptied both committed profile files.
What did R8 do to the APK's size and start-up? 11.6 MB to 2.3 MB; median cold start from 319.7 to 253.7 ms without compilation and from 267.5 to 226.5 ms with the profile, on the emulator.
Practice
- Turn on R8 for Mandi Bhav, install
benchmarkRelease, and walk every main flow, including process death. Record anything that behaves differently from the debug build. - Find the
hilt-workrule in your ownconfiguration.txt, then findPriceRefreshWorkerandPricesViewModelinmapping.txt. Which kept its name, and why? - Measure your release APK with
apkanalyzerbefore and after R8 and fill in the table above with your numbers. - Break it on purpose: reproduce the Gson trap in a scratch copy with its own
applicationIdSuffix. Addcom.squareup.retrofit2:converter-gson(same version as Retrofit), useGsonConverterFactory.create()inNetworkModule, replace@SerialNamewith Gson's@SerializedNameand remove@Serializable. Run the minified build, retrace both crashes, and check the map ids before each retrace. - Add the
META-INF/*.versionexclusion in a scratch copy and count the version files in the debug APK before and after (an APK is a zip file). If you have Android Studio open, look at the app in the Layout Inspector both ways. - Harder: regenerate the Baseline Profile on your machine. Before committing, read
git diff --stat, then explain every rule in the comparison the plugin prints: which changes came from your code, and which from the run.
Official documentation
- Enable app optimization with R8 — R8's steps, both DSLs, optimised resource shrinking and what changed in each AGP version.
- About keep rules — When keep rules are needed, where they go, and why narrow ones matter.
- apkanalyzer — Every command for reading sizes, DEX, the manifest and resources from the command line.
- About Android App Bundles — What a bundle is, the requirement for new apps, and the download size limits.
- Debug your Compose UI — The Layout Inspector, and the note on the
META-INF/androidx.compose.*.versionfiles.
Next: signing, and the key you must not lose.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.
About the internship