R8 Keep Rule Resolution: How the Android Compiler Decides What Lives and What Dies
R8 Keep Rule Resolution: How the Android Compiler Decides What Lives and What Dies
Every Android release build passes through R8, the whole-program optimizing compiler that shrinks, obfuscates, and optimizes your code before it ships to users. At the center of R8's decision-making are keep rules, the declarative specifications that tell the compiler which classes and members must survive the optimization pipeline. When you write -keep class com.example.MyClass, you're interacting with a sophisticated rule resolution engine that matches patterns against the entire class graph, feeds matched items into a worklist-based reachability analyzer, and ultimately determines what lives and what dies in your final APK.
In this article, you'll dive deep into how R8 resolves keep rules under the hood, exploring how the six keep options form a semantic matrix controlling shrinking, obfuscation, and optimization independently, how the RootSetBuilder matches rule patterns against every class in the application, how the Enqueuer performs worklist-based reachability analysis from matched roots, how the Minifier renames surviving classes and members, how conditional -if/-keep pairs enable annotation-driven rule activation, how full mode differs from compatibility mode at the behavioral level, how libraries bundle consumer rules through META-INF directories, and how DI frameworks like Dagger and Hilt interact with R8's tree shaking. This isn't a guide on writing keep rules, it's an exploration of the compiler machinery that resolves, matches, and enforces them.
The fundamental problem: Static analysis meets dynamic access
Consider a simple Android application using reflection:
public class PluginLoader {
public Plugin loadPlugin(String className) throws Exception {
Class<?> clazz = Class.forName(className);
return (Plugin) clazz.getDeclaredConstructor().newInstance();
}
}
R8 performs static analysis. It reads every instruction in every method and builds a graph of what references what. But Class.forName(className) is a string argument known only at runtime. R8 cannot see this usage statically, so className's target class appears unreferenced. Without intervention, R8 removes it.
The naive approach would keep everything:
-keep class ** { *; }
This defeats the purpose of R8 entirely, no shrinking, no obfuscation, no size reduction. The challenge is surgical precision: keep exactly what dynamic code needs, nothing more.
Keep rules solve this by providing a declarative specification language that tells R8: "These classes and members are entry points. Trace from here, and remove everything else." The keep rule system is the interface between your knowledge of dynamic access patterns and R8's static analysis engine.
The R8 pipeline: Where keep rules fit
Before examining keep rules in detail, understanding R8's position in the build pipeline provides essential context.
Unlike ProGuard, which operated as a separate step producing optimized Java bytecode that was then dexed, R8 is a unified step that reads Java bytecode and directly outputs optimized DEX:
ProGuard (legacy): .java → javac → .class → ProGuard → optimized .class → dx → .dex
R8 (current): .java → javac/kotlinc → .class → R8 → optimized .dex
Internally, R8 maintains three code representations. CfCode represents JVM classfile bytecode from input .class files. DexCode represents Dalvik DEX bytecode for the output. IRCode is R8's high-level intermediate representation, a register-based SSA (Static Single Assignment) form that both CfCode and DexCode are lifted into for optimization.
The master pipeline in R8.java orchestrates the following phases:
1. Input Reading (ApplicationReader)
2. Type Hierarchy (AppInfoWithSubtyping)
3. Root Set Building (RootSetBuilder) ← Keep rules matched here
4. Enqueuing/Tracing (Enqueuer) ← Reachability analysis
5. Tree Pruning (TreePruner) ← Dead code removed
6. Annotation Removal (AnnotationRemover)
7. Member Rebinding (MemberRebindingAnalysis)
8. Class Merging (SimpleClassMerger)
9. IR Conversion (IRConverter.optimize()) ← SSA-based optimizations
10. Second Enqueuer Pass (Enqueuer) ← Re-traces after optimization
11. Minification (Minifier.run()) ← Name obfuscation
12. Output Writing (ApplicationWriter.write())
Keep rules enter the pipeline at phase 3, where the RootSetBuilder matches them against the full class graph. The matched items become "roots" that seed the Enqueuer's reachability analysis in phase 4.
The keep rule taxonomy: Six options, three axes
Keep rules control three independent axes of R8's behavior: shrinking (removing unreachable code), obfuscation (renaming identifiers), and optimization (rewriting code). The six keep options form a 2×3 matrix:
The six keep options
-keep: Prevents matched classes and specified members from being shrunk, obfuscated, or optimized. This is the broadest protection:
-keep class com.example.MyClass {
public void myMethod();
}
Both MyClass and myMethod() are fully protected across all three axes.
-keepclassmembers: Protects only the specified members, not the class itself. If the class is unreachable, it's removed along with its members:
-keepclassmembers class com.example.MyClass {
public void myMethod();
}
If MyClass survives shrinking through some other reference, then myMethod() is protected. If MyClass itself is unreachable, everything is removed.
-keepclasseswithmembers: Protects the class and specified members, but only if all specified members actually exist in the class:
-keepclasseswithmembers class * {
native <methods>;
}
This matches only classes that have native methods. Classes without native methods are unaffected.
-keepnames: Shorthand for -keep,allowshrinking. Members can be removed if unreachable, but if they survive, their names are preserved:
-keepnames class com.example.MyClass
-keepclassmembernames: Shorthand for -keepclassmembers,allowshrinking. Member names are preserved only if the members survive shrinking.
-keepclasseswithmembernames: Shorthand for -keepclasseswithmembers,allowshrinking. Conditional name preservation.
The effect matrix makes the differences precise:
| Option | Shrink Class | Obfuscate Class | Shrink Members | Obfuscate Members |
|---|---|---|---|---|
-keep | No | No | No | No |
-keepclassmembers | Yes | Yes | No | No |
-keepclasseswithmembers | No | No | No | No |
-keepnames | Yes | No | Yes | No |
-keepclassmembernames | Yes | Yes | Yes | No |
-keepclasseswithmembernames | Yes | No | Yes | No |
Modifiers: Fine-grained control
Modifiers further refine keep behavior by selectively unlocking individual axes:
-keep,allowshrinking,allowobfuscation class com.example.MyClass
This is equivalent to: "Don't optimize this class, but shrinking and obfuscation are fine."
| Modifier | Effect |
|---|---|
allowshrinking | Matched items may be removed if unreachable |
allowoptimization | Matched items may have their code rewritten |
allowobfuscation | Matched items may be renamed |
includedescriptorclasses | Also keeps all classes appearing in method/field type signatures |
The includedescriptorclasses modifier is critical for JNI. When native C++ code calls a Java method, it references parameter types by name. Without this modifier, R8 might rename the parameter types while keeping the method itself, causing a NoClassDefFoundError at the native boundary.
The wildcard system: Pattern matching for class graphs
Keep rules use a wildcard system that operates at two levels: class name patterns and type patterns.
Name wildcards
| Wildcard | Meaning |
|---|---|
? | Matches a single character, excluding the package separator (.) |
* | Matches zero or more characters, excluding the package separator |
** | Matches zero or more characters, including the package separator |
This article continues for subscribers
Subscribe to Dove Letter for full access to 40+ deep-dive articles about Android and Kotlin development.
Become a Sponsor