Interview QuestionPractical QuestionFollow-up Questions

R8 Tree Shaking, Optimization, and Full Mode

skydovesJaewoong Eum (skydoves)||12 min read

R8 Tree Shaking, Optimization, and Full Mode

R8 is the default code shrinker, optimizer, and obfuscator for Android applications. It runs at build time, taking compiled bytecode and producing a smaller, faster APK by removing unused code, applying optimizations like inlining and devirtualization, and renaming symbols to reduce binary size. While most developers interact with R8 only through ProGuard rules and the occasional ClassNotFoundException in release builds, understanding how R8 decides what to keep and what to remove is essential for debugging shrinking issues and writing correct keep rules. By the end of this lesson, you will be able to:

  • Explain how R8 performs reachability analysis from entry points to identify dead code.
  • Distinguish between -keep, -keepclassmembers, -keepnames, and -keepclasseswithmembers.
  • Describe the key optimizations R8 applies: inlining, devirtualization, class merging, and enum unboxing.
  • Explain the difference between R8 compat mode and full mode, and what assumptions full mode makes.
  • Identify common scenarios where R8 breaks runtime behavior and how to prevent them.

Tree Shaking: Reachability from Entry Points

R8 removes code through a process called tree shaking. It starts from a set of entry points, classes and methods that must be preserved because they are called by the Android framework at runtime, and traces every reference reachable from those entry points. Anything not reachable is dead code and gets removed.

Entry points come from several sources. The Android build tools automatically generate keep rules for components declared in AndroidManifest.xml: Activities, Services, BroadcastReceivers, and ContentProviders. These must be kept because the system instantiates them by name via reflection. The generated rules also preserve Application subclasses, View constructors used by XML inflation, and any class referenced in layout XML.

The tracing works conceptually like a graph traversal. Starting from entry points, R8 follows every field access, method call, type reference, and annotation to build a set of "live" classes and members. Everything outside this set is removed:

class AppDatabase {
    fun query(): List<User> { ... }
    fun migrate(): Unit { ... }    // never called from any entry point
}

class UserRepository(private val db: AppDatabase) {
    fun getUsers(): List<User> = db.query()
}

If UserRepository.getUsers() is reachable but nothing ever calls AppDatabase.migrate(), R8 removes migrate() from the final bytecode entirely. The method never existed as far as the runtime is concerned.

The problem arises when code is reached through paths R8 cannot see: reflection, Class.forName(), JNI, serialization libraries, and service loaders. If your code calls Class.forName("com.example.MyDriver"), R8 has no way to know that MyDriver must be kept, because the class name is a runtime string, not a compile time reference. This is the fundamental reason keep rules exist.

Keep Rules: Controlling What Survives

ProGuard/R8 rules tell the shrinker which classes and members to preserve. The four primary keep directives differ in what they protect and how:

-keep preserves the class itself and the specified members from both removal and renaming. This is the broadest protection. Use it for classes instantiated by the framework through reflection:

-keep class com.example.MyApplication { <init>(); }

-keepclassmembers preserves members of a class, but only if the class itself survives tree shaking through other references. If no code references the class, the entire class (including the kept members) is removed. Use this for members accessed via reflection on classes that are already referenced in code:

This interview continues for subscribers

Subscribe to Dove Letter for full access to exclusive interviews about Android and Kotlin development.

Become a Sponsor