R8 and shrinking
R8 is three tools in one overcoat: a shrinker, an optimiser and an obfuscator that also dexes. Almost every release-only crash is a keep rule that was missing, and almost every keep rule that is too broad costs you the shrinking you wanted.
The shrinking step is the only part of the build that can turn working code into a crash without changing a line of source. It is also the only part that turns a 40 MB app into a 25 MB one.
TL;DR#
- R8 does shrinking, optimisation, obfuscation and dexing in a single pass over the program.
- It starts from entry points — manifest components, keep rules, resources — and removes everything that cannot be reached by a path the analysis can see.
- Reflection, JNI and serialisation are invisible edges in the call graph. That is what keep rules are for.
mapping.txtis the rename dictionary. Without it, a release stack trace is unreadable, and it is the file you must upload with a release.minifyEnabled truewith no rules is a reasonable start for a small app and a guaranteed problem for one that uses reflection.
Mental model#
Imagine a factory that has been running for ten years and has accumulated every tool anyone ever ordered.
R8 is a ruthless new plant manager. She starts from the shipping door — the entry points that actually get used — and traces backwards. Anything she cannot reach is scrap. Then she reorganises what remains: two machines that always run together become one, a machine that always receives the same setting gets that setting baked in. Finally she relabels everything with short names, so the next person to look at the plant cannot tell what it produces.
The keep rule is a note pinned to a machine: do not touch this one, it is reachable some other way. Reflection is exactly that: a path that only exists at runtime.
Where the analogy breaks#
The plant manager can be wrong, but she cannot know she is wrong. R8's analysis is static; it
cannot see a Class.forName("com.example.Handler") call whose string was assembled at runtime. The
programmer has to declare those edges explicitly, and the language has no way to check that the
declaration is complete. That is why keep rules are a known source of bugs rather than a solved
problem.
The big picture#
Text description of this diagram
A call graph of eleven classes. MainActivity is the entry point. Reachable through it are UserRepository, ApiClient, JsonParser, User, UserDto and StringUtils. Unreachable from any entry point are LegacySync, AdminPanel, DebugOverlay and AnalyticsV1. The diagram has four phases. Mark: R8 starts at manifest components and keep rules. Shake: the unreachable classes and their edges fade out. Optimise: the surviving code is inlined, merged and simplified. Rename: the survivors are given short names, shown as a, b, c and so on.
Step through the phases. The important thing to watch is not the removal; it is that the removal
is transitive. LegacySync calls UserRepository, but nothing calls LegacySync, so the edge
disappears — and the repo is kept only because MainActivity still reaches it another way.
Deep dive#
What R8 actually runs#
R8 is triggered by isMinifyEnabled = true (and isShrinkResources = true for resources). It runs
four passes over the same in-memory program:1
| Pass | What it decides | What it can break |
|---|---|---|
| Shrink | Which classes, fields and methods are reachable | Reflection, JNI, serialisation |
| Optimise | Inlining, class merging, constant propagation | Code that compares classes or reads member names |
| Obfuscate | Short names for everything that survives | Reflection by name, stack-trace tooling |
| Dex | DEX output, desugared | Nothing new — it is the same bytecode, rewritten |
Because all four share one representation, an inlining decision in pass two can make a class unreachable in pass one. That is why R8's shrinking is more aggressive than ProGuard's was: it is not a linear pipeline.
The rule file, line by line#
R8 reads ProGuard-format rules. The syntax is terse and the mistakes are silent, so it is worth reading one properly.1
# Keep the entry point of a library the manifest never names.
-keep class com.example.sdk.EntryPoint { *; }
# Keep the names of anything annotated, but allow shrinking of unused members.
-keepclassmembers,allowobfuscation class * {
@com.example.KeepName <fields>;
}
# Keep enum values: values() is generated and reflectively enumerated.
-keepclassmembers enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
}
# Keep a class named only in a JSON config.
-keep class com.example.plugins.** { *; }
# Do not warn about a dependency that is only used on one platform.
-dontwarn com.example.optional.**Read the first line as "keep this class and all of its members, and do not rename it". Read the second as "keep member names for annotated fields, but still allow the class to be shrunk if it is unreachable".
Why reflection breaks, precisely#
Consider a plugin loader:
val clazz = Class.forName("com.example.plugins.WeatherPlugin")
val plugin = clazz.getDeclaredConstructor().newInstance() as PluginNothing in the source references WeatherPlugin as a type. R8 sees a string. It cannot know the
string names a class, so WeatherPlugin is unreachable and is removed. On a debug build, R8 is off,
so it works. On release, ClassNotFoundException.
There are three fixes, in increasing order of preference:
- A keep rule for the plugin package.
- A
-keepnamesrule if only renaming is the problem and shrinking can stay on. - Remove the reflection: use an explicit factory map from key to constructor. This is better code and it removes the invisible edge entirely.
The third option is not always available — plugin systems and serialisation libraries are built on reflection by design — but it should be the default answer for code you own.
The mapping file#
Every obfuscated build writes mapping.txt beside the APK:
com.example.app.data.User -> a.b.c:
java.lang.String name -> a
int age -> bThat file is the only way to turn this:1
java.lang.NullPointerException
at a.b.c.d(SourceFile:3)into this:
java.lang.NullPointerException
at com.example.app.data.User.equals(User.kt:12)Play Console accepts an upload of mapping.txt per release. Without it, every crash report from
that version is noise. It is also why the mapping file must be archived with the build, not
regenerated later: a new build produces a different mapping.
Resource shrinking#
isShrinkResources = true is a separate mechanism from code shrinking, and it depends on it. R8
records which resources the code actually references; the resource shrinker removes the rest. A
resource referenced only from a removed code path disappears with it.
The common failure: a resource referenced only by name, through getIdentifier() or a string in a
database, gets removed. The fix is to keep it explicitly:
<!-- res/raw/keep.xml -->
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@layout/plugin_*,@drawable/plugin_*" />Full mode and the future#
R8 has a stricter configuration, usually called full mode, which changes assumptions about default constructors and reflection. It shrinks harder and breaks more. It is enabled by default in newer toolchain versions for some projects, and the migration path is the same as always: run it, find the crash, add the narrowest keep rule that fixes it.
R8 full mode is the default in recent Android Gradle Plugin versions.
unverified(full mode default has changed across AGP and R8 versions; check the release notes for the exact version you use)Debugging a release-only crash#
There is a procedure, and following it saves hours.
- Reproduce with R8 on and obfuscation off. Set
-dontobfuscateand rebuild. If the crash disappears, the problem is a name being looked up reflectively; if it persists, it is shrinking. - Read the mapping. With obfuscation on,
retracethe stack trace before doing anything else. The class name in the trace is meaningless until you do. - Narrow the keep rule. Keep the specific class or member that is looked up, not the package.
Then verify in
seeds.txtthat the rule kept what you expected and nothing more. - Recheck resource shrinking separately. If the crash is a missing resource, the code keep rules
are irrelevant;
tools:keepin akeep.xmlis the tool.
The order matters because the two failure modes look identical from the outside. A
ClassNotFoundException can come from either, and only step one distinguishes them.
The keep-rule review heuristics#
Three questions make a rule review fast.
Does it name a package instead of a class? A package-wide rule usually means someone did not find the specific entry point. It works, and it costs the shrinking you enabled R8 for.
Does it keep * for a class that needs only a name? -keepnames keeps the name and allows
shrinking, which is usually what reflection needs. -keep keeps everything.
Is there a matching -dontwarn for a class that is genuinely absent? -dontwarn silences a
warning about a class that does not exist. If the class exists and is being removed, -dontwarn is
the wrong tool and will hide the real failure until runtime.
How it evolved#
The compatibility decision — keep reading the old rule format — is why a project's
proguard-rules.pro has never needed renaming, and also why stale rules accumulate for years.
Under the hood#
- proguard-rules.proyour rules; the project template creates it empty
- mapping.txtthe rename map; archive it with every release
- usage.txtwhat R8 removed, when it is configured to report it
- seeds.txtthe classes and members kept by your rules
seeds.txt is the underrated one. It answers "what did my keep rules actually protect?" — and a
seed list far larger than expected is the first sign that a rule is too broad.
Curiosity corner#
- R8 was built to be faster than ProGuard on large programs, not to be a better obfuscator. The shrinking and the dexing share one pass, which is where the speed comes from.
- Obfuscation is reversible with enough effort, mapping file or not. It raises cost; it is not encryption.
- The
-dontwarnflag silences a warning, not an error. It is for classes that are genuinely optional at runtime, and using it to silence a missing dependency is how you get aNoClassDefFoundErrorin production.
Try it#
./gradlew :app:assembleRelease
head -20 app/build/outputs/mapping/release/mapping.txt
Example output
com.example.app.MainActivity -> a.a:
void onCreate(android.os.Bundle) -> a
com.example.app.data.User user -> bcat app/build/outputs/mapping/release/usage.txt | head -40
Example output
com.example.app.legacy.LegacySync
com.example.app.debug.DebugOverlayretrace mapping.txt obfuscated-stacktrace.txt
Example output
java.lang.NullPointerException
at com.example.app.data.User.equals(User.kt:12)Build it#
Enable isMinifyEnabled and isShrinkResources on a real project, build release, and install it.
Then add a class that is loaded only by name and watch it disappear. That is the lesson no
documentation can give you as directly as ClassNotFoundException at 2 a.m.
Check yourself#
Check yourself
Put these build stages in order, from first to last.
Drag, or use the arrows to reorder.
- 1AAPT2 link produces resources.arsc and the R class
- 2D8 converts class files to DEX
- 3apksigner writes the v2/v3 signature blocks
- 4kotlinc compiles Kotlin to JVM class files
Sources#
Sources
Every version number, API level, class name and date on this page is traced to one of these. Pages are re-verified against them; last_verified above says when.
- docsShrink, obfuscate, and optimize your appdeveloper.android.comaccessed 2026-10-27↩
- docsConfigure your builddeveloper.android.comaccessed 2026-10-27↩
- docsGradle user manualdocs.gradle.orgaccessed 2026-10-27↩