Skip to content

Build1 publisher3 min readPublished

R8 is entitled to delete the class that only a Class.forName string reaches

When a screen crashes only in the minified release build, the missing piece is a runtime contract static analysis never saw. A dev.to walkthrough runs a three-way build comparison and a retrace before touching a keep rule.

The Engineer · Build desk

Illustration accompanying R8 is entitled to delete the class that only a Class.forName string reaches

What happened

  • A dev.to walkthrough treats a crash that appears only once minification is enabled as an indirect-reference problem, with the aim of preserving the single runtime contract static analysis missed.
  • It rejects both standard reflexes, a wildcard keep on the application package and shipping with R8 switched off, on the grounds that each removes the symptom without identifying the broken reference.
  • Its triage compares three builds, debug, release with R8 on, and release with R8 off, and the pattern points at optimization, shrinking, obfuscation or a rule consumed from a dependency.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • constraint A package-wide keep rule buys a working release build by taking the optimizer off the code you wrote, and the broken reference stays in the binary for whoever hits it next.
  • exposure A crash report from a shipped build whose mapping file was overwritten arrives with its class labels gone, so the engineer holding it cannot say which member the runtime wanted.
  • decision Teams whose pipeline only compiles debug APKs now have to decide whether to spend CI minutes assembling real release variants, since the configuration that fails is the one users install.
  • cost Because the retention diagnostic slows builds, the reference chain is something you buy per investigation and then delete.

`Class.forName(className)` hands the optimizer a string. To the runtime, the class that string names is required. To static analysis, a string containing a class name is not necessarily a normal code reference. If nothing else reaches the class, removing or renaming it is perfectly logical from R8's point of view [11]. The crash is what happens when the optimizer cannot see a contract the runtime depends on [1]. The dev.to post lists the other places that contract hides: reflection, classes loaded from string names, JNI, serialization frameworks that inspect members, and libraries that discover implementations dynamically [9].

The three-way comparison comes first. Debug works, release with R8 on fails, release with R8 off works [4]. The third row is what implicates the optimizer. If the release build fails with R8 off as well, the comparison does not point at shrinking or obfuscation. The difference sits elsewhere in the release variant: build type, flavor, dependency graph, resource shrinking, signing configuration, generated code [3][18]. Even when the third row holds, it does not prove which class needs to be kept [4].

R8 writes `mapping.txt` for the optimized variant, and the post's hygiene rule is to preserve it for every published build, because a later build can overwrite the local copy [6]. Android's documentation recommends `retrace` when the original stack trace is not automatically deobfuscated [7]. "An obfuscated stack trace is evidence with its labels removed," the author wrote [12].

For `-keep class com.example.** { *; }` to be the honest fix, near enough every class under that package would have to be reached in a way static analysis cannot follow [2]. Otherwise the rule retains code you asked R8 to drop. That is the second failure the post describes: an APK that stays unexpectedly large because a class you expected R8 to remove is still present [19]. The post treats a broad rule as a temporary experiment [16]. It does not measure what the wildcard costs in size or runtime [17].

The configuration R8 evaluated is larger than the `proguard-rules.pro` you edit. Rules arrive from the application, from Android tooling, and as consumer rules packaged inside dependencies. Android's troubleshooting documentation points to the merged R8 configuration under the build outputs, and the Android Developers Blog recommends printing the final configuration when you need to know where a rule came from [13]. A dependency can bring broad rules or global options that keep far more code than you intended. That shows up as optimization weaker than expected [14].

`-whyareyoukeeping class com.example.feature.LegacyEntryPoint` asks R8 to show the reference chain responsible for retaining that code. Android explicitly cautions against checking the rule into the codebase, because it can slow builds [15].

The post gives no benchmark numbers, so there is nothing to normalise before this transfers; what has to be true is that your failure is reflection-shaped. Android's R8 guidance names the signals worth grepping for: `ClassNotFoundException`, `NoSuchMethodException`, `NoSuchFieldException`, `NoClassDefFoundError`, `NoSuchMethodError`, and `NoSuchFieldError` [10]. None of that reaches you if the failing variant never gets built, which is why the post argues Android CI should build the real release variants and not only compile debug APKs [5].

What to watch

  • Whether the Android Gradle Plugin starts emitting the merged R8 configuration by default. That would remove a step from the 'whose rule is this' investigation.
  • Any revision to Android's caution on -whyareyoukeeping, since the build-time cost is the only stated reason not to leave it in the config.
  • Whether widely used dependencies narrow the consumer rules they package, which is the case the post ties to optimization being weaker than expected.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories