R8 for Library Authors
Library authors ship consumer-rules.pro files inside their AARs. Every consumer of the library merges those rules into their own R8 invocation. A -keep class com.mylib.** { *; } added years ago to fix a crash is still applied to every release build of every app depending on the library, blocking R8 from shrinking and optimizing the code in those apps. Most of us write these rules without thinking too hard about what they cost.
I recently audited a library’s consumer rules and ended up reading a fair amount of R8’s source to understand what each rule was doing. This post is the version of that walk-through I wish I had at the start. It covers R8’s pipeline pass by pass with real class names from the source, how keep rules become bits inside KeepInfo, the difference between consumer rules and the library’s own build rules, and the AAR/JAR packaging layout for shipping rules.
Source references point at the main branch of r8.googlesource.com/r8. Documentation references are the official Android library optimization pages: Optimization for library authors, Enable app optimization, Add keep rules, Use R8 in full mode, Add global options, and Follow the best practices. The ProGuard manual is cited where its definitions remain canonical for syntax R8 inherited.
R8 is a compiler, not just an obfuscator
The most common framing of R8, repeated across older Android books and StackOverflow answers, is that it renames things to a, b, c. That framing misses what R8 mostly does. The library-optimization page puts it bluntly:
“Contrary to popular understanding, R8’s optimizations are not limited to just obfuscation, but also include code shrinking and logical optimizations with method inlining and class merging techniques.”
R8 is a whole-program optimizing compiler that also does renaming. Its phase order is roughly:
- Shrink. Trace reachability from entry points and delete everything else.
- Optimize. Inline methods, merge classes, devirtualize calls, unbox enums, fold switch tables.
- Minify. Rename whatever survived to short identifiers. Secondary, not primary.
- Dex. Emit
.dex(or.classfor intermediate steps).
Two things follow from this framing.
First, -keep does much more than “stop renaming.” It blocks all four phases on the matched item: don’t shrink it, don’t inline it, don’t merge it, don’t widen its access, don’t rename it. R8 represents this internally as a small set of allow-flags on a KeepInfo object, and a bare -keep flips off most of them. We’ll see exactly how below.
Second, library-level optimization is structurally weaker than app-level optimization, and that’s exactly why your library’s rules matter. At the app level R8 has the whole program: every callsite, every reflection root in the manifest, every concrete subclass. It can prove that a method has a single caller and inline it, or that an interface has exactly two implementers and devirtualize. None of that is possible at library build time, because the library cannot see who will call it. So if your consumer rules keep too much, you don’t only bloat your own surface area. You disable whole-program optimization on your library inside every consumer’s app build.
R8’s pipeline
R8’s entry point is R8.java, specifically R8.runInternal(). Reading through it gives a canonical sequence of phases. Trimmed and simplified, the order looks like this:
R8.runInternal(). Real class names from com.android.tools.r8.*. The actual source has more sub-phases (nest reduction, redundant bridge removal, switch-map collection, Kotlin metadata rewriting, desugaring) interleaved between these.A few things stand out.
Tree-shaking happens twice. R8 runs an initial Enqueuer pass to compute reachability, then an entire optimization phase that may make additional code unreachable (inlining a single-caller method, for instance, makes the original definition removable), then a second final Enqueuer to clean up. A keep rule broad enough to “fix a crash” can hide the fact that R8 would have eliminated the dead code. You’ve blocked both passes.
Class merging happens twice as well, in two flavors. Vertical merging collapses a subclass into its superclass when the hierarchy is degenerate (a one-line override, say). Horizontal merging collapses sibling classes that are structurally equivalent. Both are blocked by -keep on either of the merge candidates, because the keep rule freezes the class identity.
Minification is near the end. By the time R8 starts allocating new names, it has already deleted, inlined, and merged everything it can. Minification operates on the survivors. So -keepnames com.mylib.PublicApi { *; } is not “preserving the API for users to find.” It says: if this class somehow survives shrinking, don’t rename it. Whether it survives is determined entirely by reachability.
Tree-shaking
The shrinking phase is driven by the Enqueuer class at com.android.tools.r8.shaking.Enqueuer. The class-level Javadoc:
“Approximates the runtime dependencies for the given set of roots. The implementation filters the static call-graph with liveness information on classes to remove virtual methods that are reachable by their static type but are unreachable at runtime.”
The algorithm is a worklist-based reachability trace.
Build the initial root set. Roots are entry points: items the program must keep regardless of what calls them. For an Android app these come from manifest-declared components (Activities, Services, Providers, Receivers; AGP generates keep rules from the manifest and feeds them in), native methods, anything matched by a
-keeprule, and items reflectively reachable from already-live code that R8 can statically prove. The rule-based portion of the root set is built byRootSetUtilsfrom the parsedProguardConfiguration.Push roots onto the
EnqueuerWorklist. Each work item is an action:enqueueMarkInstantiatedAction,enqueueMarkMethodLiveAction,enqueueMarkFieldLiveAction. The worklist is a private field onEnqueuer.Drain the worklist. Each action triggers a series of
traceXxxcallbacks:traceInvokeVirtual,traceInvokeDirect,traceInvokeStatic,traceInvokeInterface,traceNewInstance,traceInstanceFieldRead/Write,traceStaticFieldRead/Write,traceConstClass,traceCheckCast,traceCallSite(forinvoke-dynamicand lambda metafactory). Each trace marks the discovered item live and pushes more actions onto the worklist.Repeat until the worklist is empty. What’s marked live is the live set. Everything else is dead.
The output is an AppInfoWithLiveness object holding liveTypes, liveMethods, liveFields, and a fieldAccessInfoCollection recording every read and write site for every surviving field. Subsequent passes consume this.
How R8 sees reflection
R8 cannot, in general, see through reflection. If your library calls Class.forName("com.mylib.SecretImpl"), R8 won’t notice that string is a class name and won’t keep SecretImpl. That is the entire reason consumer rules exist: you, the library author, must tell R8 about the reflective entry points your code uses.
R8 supports some reflection patterns better than others. The Google guidance is that if you must reflect, reflect into “specific targeted types (specific interface implementers or subclasses)” or annotated types, because those patterns can be expressed as a single keep rule covering all the targets:
-keep class * extends com.mylib.MyBase { ... }
-keep @com.mylib.RuntimeAnnotation class * { ... }
This is why frameworks expose narrow reflection contracts. WorkManager reflectively constructs subclasses of androidx.work.ListenableWorker, and its consumer rules pin that exact relation. Open-ended reflection (Gson reflecting on whatever type the developer passes in) cannot be expressed this way, which is why Gson is a known sore spot and why most modern serialization libraries have moved to codegen.
The library-author corollary: prefer codegen to reflection where you can. Build-time code generators (annotation processors via kapt, or KSP) emit ordinary classes that R8 traces normally, with no keep rules required.
Optimization
After the first tree-shake, R8 lifts the surviving bytecode into its IR and runs the heavy optimizer at com.android.tools.r8.ir.conversion.PrimaryR8IRConverter. This is the part that looks the most like a compiler. The optimizations include:
| Optimization | Class | What it does |
|---|---|---|
| Method inlining | Inliner | Replaces a call site with the callee’s body when profitable: single caller, simple body, marked @AlwaysInline, etc. |
| Vertical class merging | VerticalClassMerger | Collapses a subclass into its parent when the hierarchy is degenerate. |
| Horizontal class merging | HorizontalClassMerger | Merges sibling classes with the same shape. |
| Member rebinding | MemberRebinder | Rewrites references to point at the most-derived class that actually defines the member. |
| Enum unboxing | EnumUnboxer | Replaces enums with raw ints when the enum identity isn’t observed. |
| Switch optimization | SwitchMapCollector and friends | Folds Kotlin’s when and Java’s switch-on-enum patterns. |
| Nest desugaring | NestReducer | Rewrites Java 11+ nest-mate access into something the older verifier accepts. |
| Bridge removal | RedundantBridgeRemover | Deletes synthetic bridge methods that no longer serve a purpose. |
| Access widening | AccessModifier | Bumps private/protected to public to enable inlining and merging. |
Inliner.java is worth a read. It enumerates inlining Reason values: ALWAYS, SINGLE_CALLER, SIMPLE, MULTI_CALLER_CANDIDATE, NEVER. Each invocation runs through an InliningOracle that checks budget (don’t exceed the instruction-count budget for the caller), constraint (visibility, instruction legality), and keep rules. The keep-rule check looks roughly like this:
KeepMethodInfo keepInfo = appView.getKeepInfo(method);
if (!keepInfo.isInliningAllowed(options, method)
&& !keepInfo.isClassInliningAllowed(options)) {
return ConstraintWithTarget.NEVER;
}
isInliningAllowed is gated on the per-item allowOptimization bit. So -keep class Foo { *; } sets allowOptimization = false on every method of Foo, and Inliner returns NEVER for all of them, in every consumer’s app build. That is the specific cost of broad keep rules: a literal NEVER that turns off a specific compiler pass on the items the rule matches.
After IR optimization, R8 runs the second Enqueuer pass (because optimizations may have created newly dead code), then horizontal class merging, then renaming.
Minification
The renamer lives at com.android.tools.r8.naming.Minifier. It runs in three sub-phases (ClassNameMinifier, MethodNameMinifier, FieldNameMinifier), each producing a NamingLens that downstream phases use.
The name-allocation strategy is deceptively simple. From BaseMinificationNamingStrategy.nextString():
if (state.getDictionaryIndex() < obfuscationDictionary.size()) {
nextString = obfuscationDictionary.get(...);
} else {
nextString = SymbolGenerationUtils.numberToIdentifier(...);
}
If you provided an obfuscation dictionary, R8 consumes that first; otherwise it generates names by mapping integers to identifiers (a, b, …, z, aa, ab, …). Java keywords are skipped via RESERVED_NAMES. Each allocation checks for collisions with what’s in scope. Methods that override or implement another method must share a name across the entire hierarchy, so the allocator coordinates renames across inheritance.
Whether an item participates in renaming at all is gated by KeepInfo.isMinificationAllowed():
return configuration.isMinificationEnabled()
&& internalIsMinificationAllowed();
There are two gates: a global one (the -dontobfuscate global option flips this off for the entire build), and a per-item one (set by keep rules with the allowobfuscation bit). The same two-level structure shows up across KeepInfo: global config × per-item flag. Holding that lens helps when you’re trying to figure out what a particular rule does.
Keep rules: from text to bits
When R8 parses your proguard-rules.pro, the ProguardConfigurationParser builds a ProguardConfiguration containing a list of ProguardKeepRule objects. Each rule has three pieces: a type (one of the -keep* variants), a modifier set (allowshrinking, allowobfuscation, etc.), and a class spec (the class { … } body).
RootSetUtils then walks the program, matches each rule’s class spec against the program, and for every match calls a Joiner to update the KeepInfo for that class, method, or field.
The KeepInfo class at com.android.tools.r8.shaking.KeepInfo is the per-item flag carrier:
“Keep information that can be associated with any item, i.e., class, method or field.”
It carries (among others) these per-item allow-flags:
Each defaults to true (R8 may do this transformation). A keep rule’s job is to flip some subset to false. The Joiner is monotonic: it can only turn bits off, never on. That’s what lets R8 combine multiple keep rules from multiple files (your app’s rules, your library’s consumer rules, every other library’s consumer rules, the platform defaults) and arrive at a deterministic per-item state.
So conceptually, -keep class Foo does this for each matched member m:
KeepInfo.Joiner(KeepInfo[m])
.disallowShrinking()
.disallowMinification()
.disallowOptimization()
// ...and a few more
-keep,allowshrinking class Foo does the same minus the first line:
KeepInfo.Joiner(KeepInfo[m])
.disallowMinification()
.disallowOptimization()
allowShrinking stays at its default true, so R8 may delete the class if nothing references it. The modifiers are opt-outs of the default disallow, not separate flags. Once you have that mental model, the variant table starts looking less arbitrary.
The six -keep variants
Most of the variants are shorthand for common modifier combinations. The semantics, drawn from the Android docs and the ProGuard manual:
| Variant | Shrinks classes | Obfuscates classes | Shrinks members | Obfuscates members | Equivalent expression |
|---|---|---|---|---|---|
-keep | ✗ | ✗ | ✗ | ✗ | (canonical form) |
-keepclassmembers | ✓ | ✓ | ✗ | ✗ | (members only) |
-keepclasseswithmembers | ✗ | ✗ | ✗ | ✗ | conditional on members existing |
-keepnames | ✓ | ✗ | ✓ | ✗ | -keep,allowshrinking |
-keepclassmembernames | ✓ | ✓ | ✓ | ✗ | -keepclassmembers,allowshrinking |
-keepclasseswithmembernames | ✓ | ✗ | ✓ | ✗ | -keepclasseswithmembers,allowshrinking |
A ✗ means R8 is prevented from doing the transformation; ✓ means R8 is still allowed.
The Android docs are unusually direct on this point:
“Avoid using
keepwithout keep option modifiers and avoid options ending with ’names’. Usekeepclassmembersprimarily, as it enables the most optimizations.”
-keep is the default reflex but rarely the right tool. The case for it is the textbook reflective load: a class fetched by FQCN that R8 can’t see referenced anywhere else. For “preserve some specific methods on a class that’s instantiated normally” (most generated-code preservation), -keepclassmembers is the right tool, and it lets R8 still shrink and rename the class itself if no callers survive.
The modifiers
The modifier definitions, from the ProGuard manual that R8 inherits:
allowshrinking
"R8 may delete this if unused"- Bit:
allowShrinkingstays attrue. - Use: name preservation for reflection, but only if the item survives shrinking. Equivalent to
-keepnames. - Useful for library APIs that not every consumer uses.
allowobfuscation
"R8 may rename this"- Bit:
allowMinificationstays attrue. - Use: keep an item alive (so reflection by signature works) but allow renaming.
- Pairs with annotation-driven preservation: keep that the method exists, allow it to be renamed.
allowoptimization
"R8 may inline / merge / optimize"- Bit:
allowOptimizationstays attrue. - Use: keep something for build-time queries (an attribute, say) but let R8 optimize the body.
- Often combined with the other two for the lightest-touch keep.
includedescriptorclasses
"transitively keep parameter / return / field types"- When you keep a method
foo(Bar), this also keepsBar. - Without it, R8 may rename or remove
Bar, breaking reflection that walks the method signature. - Needed more often under R8 full mode than under compat.
The Gson TypeToken example from the Use R8 in full mode page stacks three of these:
-keep,allowobfuscation,allowshrinking,allowoptimization class * extends com.google.gson.reflect.TypeToken
Read at face value: match every class extending TypeToken, but don’t add restrictions on shrinking, obfuscation, or optimization. Looks like the rule does nothing. What it actually does is put each anonymous TypeToken<…> subclass on the keep list so its Signature attribute is retained (in full mode, Signature is only kept on items explicitly kept). The three modifiers say “don’t otherwise restrict R8.” Maximum signal preservation, minimum optimization cost. This is the right shape for most library keep rules that aren’t pinning a real reflection target.
R8 full mode
For library authors writing consumer rules today, full mode is the world to design for. From the official docs:
“R8’s full mode is the recommended mode to use R8, and is the default since AGP 8.0, which was made stable in 2023.”
The history:
useProguard false; ProGuard was still the default.useProguard true / android.enableR8 = false.useProguard DSL property and the android.enableR8 / android.enableR8.libraries escape hatches are deleted. Java 11 minimum. Full mode introduced as opt-in via android.enableR8.fullMode=true.android.enableR8.fullMode=false. android.r8.failOnMissingClasses=true is the default, so missing classes are now errors and AGP writes a starter file at app/build/outputs/mapping/release/missing_rules.txt you can crib from.-allowaccessmodification on by default when full optimization is enabled. SourceFile attribute kept by default for retracing.LineNumberTable kept by default.android.r8.optimizedResourceShrinking=true. Resources participate in R8's reference graph.proguard-android.txt support dropped (it disabled optimization). Global options in library consumer rules are filtered out (android.r8.globalOptionsInConsumerRules.disallowed=true). Stricter full-mode keep semantics via android.r8.strictFullModeForKeepRules=true: bare -keep class A no longer implicitly keeps the default constructor. Optimized Kotlin null-checks via -processkotlinnullchecks.-repackageclasses on by default for apps. Use -dontrepackage to opt out.!*ForTesting). Wildcard patterns like -keepattributes *Annotation* stop matching the RuntimeInvisible* family; list those explicitly if you need them.The four behavioral differences between full and compat mode worth knowing:
① Attribute retention
- Compat:
Signature,InnerClasses,EnclosingMethodkept by default. - Full: kept only for explicitly-kept classes and members.
- Library impact: reflection on generic types fails silently unless you keep both the right attributes and the items they're attached to.
② Default constructor
- Compat: implicitly retained when the class is retained.
- Full: not retained unless explicitly kept.
- Library impact: any
Class.newInstance()orgetDeclaredConstructor().newInstance()needs an explicit<init>()keep rule.
③ Access modification
- Compat: visibility unchanged.
- Full:
privatemay becomepublicto enable inlining. - Library impact: reflection on private members breaks. Spell out
private <methods>;in keep specs to preserve visibility.
④ Optimization aggressiveness
- Compat: ProGuard-equivalent assumptions (most things possibly-instantiated, possibly-reflected).
- Full: default-non-instantiated, single-implementation devirtualization, more inlining.
- Library impact: if your library secretly depended on a class being kept "just in case," it can vanish.
The official guidance for libraries: your library should not crash under R8 full mode. If it does, the fix is to identify the specific reflection or JNI entry point and add a targeted rule, not to keep the entire package.
How libraries ship rules
A library has two distinct rule files, configured in build.gradle.kts:
consumerProguardFiles
"Rules that travel with the library"- Bundled into the AAR / JAR.
- Applied during the app's R8 invocation, alongside the app's own rules.
- Purpose: tell consumers what to keep so your library works.
proguardFiles
"Rules for the library's own R8 build"- Applied only when the library itself is built with
minifyEnabled = true. - Not bundled into the AAR.
- Purpose: protect this build's public API from being shrunk away.
android {
buildTypes {
release {
isMinifyEnabled = true // optional; only if you optimize the library itself
proguardFiles( // applied to THIS build
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
configureEach {
consumerProguardFiles("consumer-rules.pro") // travels with the AAR
}
}
}
The two have completely different audiences and completely different rules. The most common library-author mistake is putting build-time concerns into consumer rules. Build-time rules protect your public API during your build. Consumer rules are instructions for someone else’s R8 invocation against the artifact you’ve shipped.
Where rules live in the artifact
The on-disk layout is finicky. AAR and JAR have different conventions, and there’s a newer versioned location alongside the legacy one:
JAR (Kotlin/Java library) . └── META-INF/ ├── proguard/ ← LEGACY consumer rules │ └── consumer-rules.pro └── com.android.tools/ ← VERSIONED consumer rules └── r8-from-8.0.0/ └── …rules…
Two things to note:
The legacy AAR location is proguard.txt at the AAR root. Not inside META-INF/, not under any subdirectory. The filename is fixed. Build an AAR with consumerProguardFiles("consumer-rules.pro") and AGP renames the file to proguard.txt for you when packaging.
The versioned AAR location lives inside classes.jar, at META-INF/com.android.tools/. The JAR layout is more uniform: legacy rules at META-INF/proguard/<filename>.pro, versioned rules at META-INF/com.android.tools/.
The versioned location is what lets a library ship one set of rules for older R8 versions and a stricter set for newer ones. The directory naming convention:
| Directory name | R8 versions it applies to |
|---|---|
r8 | All R8 versions |
r8-upto-8.2.0 | R8 versions before 8.2.0 |
r8-from-8.0.0 | R8 8.0.0 and later |
r8-from-8.0.0-upto-8.2.0 | R8 ≥ 8.0.0 and < 8.2.0 |
-upto- is exclusive. -from- is inclusive. AGP picks the matching directories at app build time based on the R8 version it’s about to invoke and merges those rules with the legacy proguard.txt rules.
This pays off when you ship a rule that depends on R8 features only available after a certain version. Older R8 won’t see it, newer R8 will.
Anti-patterns the library-optimization page calls out
The library-optimization guide is unusually direct. The list:
- Broad / package-wide keep rules.
-keep class com.mylibrary.** { *; }is the textbook anti-pattern. The page: “Such rules limit optimization for these packages across all the apps that consume your library.” - Global optimization-disabling flags in consumer rules.
-dontoptimize,-dontshrink,-dontobfuscate,-dontpreverify,-allowaccessmodification,-renamesourcefileattribute,-ignorewarnings,-printmapping,-printseeds,-applymapping,-obfuscationdictionary, and so on. Since AGP 9.0 these are filtered out of consumer rules entirely. Even before that, they signal a misunderstanding worth fixing. - Keeping a library because it was already optimized at build time. “Whole-program optimization (at the app level) is significantly more effective than optimization done at the library level.” Library-level optimization can’t see the call graph; app-level can. Don’t
-keepyour dependencies to “preserve their work.” - Open-ended reflection patterns. Gson reflecting on any user-supplied type is the standard example. Codegen alternatives (KSP-based, Moshi-codegen) exist for most reflection-driven libraries now.
- Keeping entire packages to fix a crash. “If your library crashes under R8, the solution is to identify the specific reflection or JNI entry point and add a targeted rule, not to keep the entire package.”
-repackageclassesin consumer rules. This is a global option. If your library wants its own classes repackaged at its build, do that inproguardFiles. Never instruct consumers to repackage.- Keeping classes you don’t own. Don’t write keep rules for someone else’s library inside your consumer rules. If you depend on Library X, let Library X ship its own consumer rules.
The full filter-list for consumer rules (the options AGP either drops or warns about) is:
-include -basedirectory -injars -outjars
-libraryjars -repackageclasses -flattenpackagehierarchy
-allowaccessmodification -renamesourcefileattribute
-ignorewarnings -addconfigurationdebugging
-printconfiguration -printmapping -printusage -printseeds
-applymapping -obfuscationdictionary
-classobfuscationdictionary -packageobfuscationdictionary
If any of these appears in a consumer-rules.pro, delete it.
What consumer rules should contain
Three categories cover almost everything:
- Targeted keeps for reflection or JNI entry points. Specific classes, specific members, with modifiers (
allowshrinking,allowobfuscation,allowoptimization) where possible. -keepattributesfor attributes the library actually reads at runtime.RuntimeVisibleAnnotationsis the common one, sometimesSignatureif you walk generic types, occasionallyExceptionsif your reflection inspects checked exceptions. Note that under AGP 9.2, the wildcard*Annotation*no longer matches theRuntimeInvisible*family, so list those explicitly if you need them.-dontwarnlines for transitive references your library doesn’t actually use at runtime (optional dependencies behindClass.forNameguards, for instance). Each-dontwarnshould have a comment explaining the absent dependency. Since AGP 8.0,android.r8.failOnMissingClasses=trueis the default, so any missing reference is a build error and libraries with optional deps must ship-dontwarnfor them.
If your consumer-rules.pro is more than around fifty lines, it’s probably wrong.
A cheat sheet for common patterns
# 1. Reflection-loaded classes (loaded by FQCN at runtime).
# Keep class identity & name; let R8 optimize the body.
-keep,allowoptimization class com.lib.spi.SecretImpl
# 2. Annotation-driven preservation (Gson, Moshi).
# Keep classes with @SerializedName fields, plus their fields and ctors.
-if class ** { @com.google.gson.annotations.SerializedName <fields>; }
-keepclassmembers class <1> {
@com.google.gson.annotations.SerializedName <fields>;
<init>(...);
}
# 3. Generated synthetic helpers (kotlinx.serialization, Room, Moshi codegen).
-keepclasseswithmembers,allowobfuscation,allowshrinking class **$$serializer { *; }
-keep,allowobfuscation,allowshrinking class **JsonAdapter { *; }
-keep,allowobfuscation,allowshrinking class **_Impl { *; }
# 4. Public API surface protection (when partially optimizing the library).
# Keep the public API but allow R8 to do everything else.
-keep,allowoptimization,allowobfuscation public class com.lib.api.** {
public protected *;
}
# 5. JNI entry points. Native methods are entry points; the descriptor types must survive.
-keepclasseswithmembers,includedescriptorclasses class * {
native <methods>;
}
# 6. Optional / compile-only deps.
-dontwarn javax.annotation.**
-dontwarn org.jetbrains.annotations.**
# 7. Attributes the library reads at runtime.
-keepattributes RuntimeVisibleAnnotations, Signature
The R8 Compatibility FAQ recommends a specific minimal-touch keep rule:
-keep[classmembers],allowshrinking,allowoptimization,allowobfuscation,allowaccessmodification <class-spec>
(The bracket notation means either -keep or -keepclassmembers with these modifiers.) It says: match this item, but give R8 permission to do everything else. That’s the right shape when the goal is purely to retain an attribute (Signature, an annotation) on a kept item, without otherwise constraining the item. It’s also the shape of the Gson TypeToken rule above.
A few patterns from a real cleanup
I went through this exercise on a library, auditing the consumer rules accumulated over years and applying the rules above. A few patterns I’d generalize beyond the specific case.
“Fixes a crash” rules are usually too broad. Someone reports ClassNotFoundException, an engineer adds -keep class com.mylib.foo.** { *; }, ships it, moves on. Five years later nobody remembers what the original crash was, and the rule is keeping classes R8 would happily shrink. The fix is archaeology: bisect the rule, identify the actual reflection target, write a one-line rule for that, delete everything else. Sometimes the original crash was already fixed by a refactor and the rule has been pure dead weight.
Global options leak into consumer rules. A proguard-rules.pro and a consumer-rules.pro that look similar tend to drift toward each other. Engineers copy-paste between them. A -dontshrink written for a debug build sneaks into the consumer rules. The cure is structural: auto-generate consumer rules from a different source, or have a CI step that diffs consumer-rules.pro against the global-options denylist and fails the build.
Keep rules outlive the dependencies they protected. A library removed years ago can still have -keep class some.removed.dep.** { *; } in the rules file, because nobody ever audited. Pure cost.
-keepattributes accumulates. Engineers add attributes defensively (-keepattributes *Annotation*,Signature,Exceptions,InnerClasses,EnclosingMethod) without checking which ones the library actually reads. The more attributes are kept, the less R8 can optimize. List only the attributes your library’s runtime code depends on.
Auto-generation is the long-term answer. Metalava (Google’s API-tracking tool) has a --proguard flag that emits a proguard.txt containing keep rules for every public API symbol. That covers the “preserve the API surface during partial library optimization” case exactly. Wiring it into your build means the keep rules for your public API are derived from the API itself, never drift, and shrink automatically when you remove a symbol. The minimal version is a Gradle task that calls Metalava --proguard and writes the result into the AAR.
A library author’s checklist
When I’m about to add or change a consumer rule, I ask:
Targeting
- Is the class spec a specific class, base class, or annotation? Or a wildcard?
- Is the member spec specific methods/fields, or
{ *; }? - Could this be expressed as
-if <condition>+ keep, so it only fires when the consumer actually uses the relevant code path?
Modifiers
- Does the item need to survive shrinking, or only retain its name? (Only the name: add
allowshrinking.) - Does the identifier need to survive, or only the existence? (Existence only: add
allowobfuscation.) - Can R8 still optimize the body? (Almost always yes: add
allowoptimization.) - Are descriptor types referenced reflectively? (Yes: add
includedescriptorclasses.)
Scope
- Does this rule belong in
consumer-rules.proorproguard-rules.pro? (About the library's own build: the latter.) - Is this a global option? (Yes: not consumer rules. Period.)
- Does this rule depend on an R8 version? (Yes: use
META-INF/com.android.tools/r8-from-X-upto-Y/.)
Attributes
- Is the library code reading this attribute at runtime? (No: don't keep it.)
- Is this attribute already kept by the consumer's
proguard-android-optimize.txt? (Usually yes, but a library should still declare what it needs in case the consumer doesn't include the default file.)
The hard part of writing good consumer rules isn’t the syntax. It’s resisting the urge to keep more than you need. Every additional rule is a contract you’ve imposed on every consumer’s build, and it’s much easier to add rules than to remove them once they ship. R8 full mode is more aggressive than compat mode, and the trend is unambiguously toward more aggressive optimization. A consumer rule that “works” today by being conservative will, in a future R8 version, just be the rule that prevents a new optimization from running on your library inside everyone’s app.
Auditing what actually shipped
Two artifacts under app/build/outputs/mapping/<variant>/ are worth knowing about once you start caring about consumer rules:
| File | What it is | What you use it for |
|---|---|---|
configuration.txt | The fully merged R8 configuration for this build: your app’s rules, every dependency’s consumer rules, every default file (proguard-android-optimize.txt), all concatenated. Since AGP 9.0, dropped global options appear annotated as # REMOVED CONSUMER RULE: -dontoptimize. | Verify your library’s consumer rules actually shipped, and that nothing got filtered out. Search for your library’s package; every rule applied to it appears here. |
missing_rules.txt | A starter file generated by R8 listing the -dontwarn lines that would have prevented the missing-class errors that broke this build. | Triage failOnMissingClasses failures. Don’t paste it whole. Use it as a reference and write narrow -dontwarn rules for the patterns you actually need. |
The mapping file at app/build/outputs/mapping/<variant>/mapping.txt records the rename decisions R8 made (com.example.Foo -> a.b:) and is what retrace consumes to deobfuscate stack traces. For library authors, the interesting use is to grep it for your package and check how much of your code R8 actually renamed. If the mapping shows your library’s classes preserved 1:1, that’s a sign your consumer rules are too broad.
Closing
What stands out reading R8’s source is how careful the keep model is. The set of allow-flags on KeepInfo, the Joiner pattern, the modifier-as-opt-out semantics. None of it is accidental. R8’s authors knew that engineers would treat -keep as a magical incantation, and built a structure where the magical-incantation usage works (because the bits flip to the conservative answer), but the considered usage works much better (because each modifier flips exactly one bit back).
The ask of library authors is the same ask R8’s design makes of every keep rule writer: think about what the rule is doing, which bit on which item, and write the rule that flips only that bit. Everything else is borrowed cost charged to your consumers’ apps.
Some references I leaned on:
- Optimization for library authors. The page to read twice.
- Add keep rules. Reference for syntax and the variant table.
- Use R8 in full mode. The four behavioral changes worth knowing.
- Add global options. What not to put in consumer rules.
- R8 Compatibility FAQ. Probably the single most underrated R8 doc; the answer key for “why does this rule that worked under ProGuard break under full mode?”
- R8 source. Surprisingly readable.
R8.java,Enqueuer.java,KeepInfo.java, in that order, will make most of the abstractions in this post concrete. - ProGuard manual: configuration usage. Canonical syntax reference for the
-keep*family. R8 inherits the syntax verbatim. - AGP release notes. Every minor release has at least one R8-relevant line. The 8.0, 9.0, 9.1, and 9.2 notes in particular are worth reading if you ship libraries.
R8 isn’t a black box. It’s a compiler with a published source tree, a public design rationale, and a thin abstraction over the things you’d want to control.
#android #r8 #proguard #library #optimization #agp