๐Ÿš€ The Art of Narrow Scoping: Maximizing Optimization with Annotation-Driven Keep Rules

 Use custom Kotlin annotations and marker interfaces to craft precise, maintainable ProGuard rules that achieve ultra-narrow keep scopes.



The Art of Narrow Scoping: Maximizing Optimization with Annotation-Driven Keep Rules


The world of mobile development demands speed and efficiency. For Android developers, this means leveraging powerful tools like R8, Google’s compiler and code shrinker, to minimize APK size and improve runtime performance.

While simply adding a generic *-keep class ... rule gets the job done, a true artisan of optimization knows that the real gains come from narrow scoping. The narrower the keep rule, the more aggressively R8 can strip unused code and inline members, leading to the smallest possible binary.

This post dives into an advanced, pattern-based approach to defining the narrowest possible keep scope using custom annotations and interface-based rules — a technique often overlooked in favor of generic, less maintainable proguard-rules.pro files.

Why Generic Keep Rules Fall Short

Many developers rely on broad, “safe” keep rules, especially when dealing with reflection, JSON serialization, or third-party libraries:

# The "Safe, but inefficient" approach
-keep class com.example.model.** { *; } # Keeps ALL classes and ALL members
-keep class com.thirdparty.library.* { *; } # Keeps the entire library structure

While these rules prevent runtime crashes, they act like a blanket, preventing R8 from optimizing large swaths of code, even when only a few methods or fields are actually accessed dynamically.

The Annotation-Driven Scoping Strategy

Our goal is to tag specific classes or members that must be kept — and nothing more. We will achieve this using a simple, custom Kotlin annotation.

Step 1: Define the Custom Annotation

Let’s create an annotation, say @RetainMember, to mark classes or members that are accessed via reflection (e.g., for a lightweight event bus or a simple data mapping utility).

// RetainMember.kt
@Retention(AnnotationRetention.RUNTIME) // Critical: R8 checks for source retention,
// but the runtime code needs the RUNTIME retention to access it.
@Target(
AnnotationTarget.CLASS,
AnnotationTarget.FIELD,
AnnotationTarget.FUNCTION,
AnnotationTarget.CONSTRUCTOR
)
annotation class RetainMember

Step 2: Implement Annotation-Based Keep Rules

Now, we write the R8 rule that specifically targets this annotation. This rule tells R8: “Only keep the classes/members that have the @RetainMember annotation."

# proguard-rules.pro

# Rule 1: Keep classes annotated with @RetainMember, but only their annotated members
-keep class * {
@com.example.app.annotations.RetainMember <members>;
}

# Rule 2: Keep the annotation itself (crucial for runtime reflection checks)
-keepnames @interface com.example.app.annotations.RetainMember

# Rule 3: Keep a class if it is directly annotated with @RetainMember
-keepnames @com.example.app.annotations.RetainMember class * { *; }

What this achieves: R8 can now aggressively strip fields and methods from a class unless they are explicitly tagged with @RetainMember.

Step 3: Applying the Narrow Scope in Kotlin

Consider a data model for a network request where only the constructor and specific fields are needed for serialization/deserialization:

// DataModel.kt
data class UserProfile(
// @RetainMember on the constructor keeps it intact for reflection-based instantiation
@RetainMember val id: String,

// Keep this field because a dynamic mapper reads it
@RetainMember val username: String,

// R8 is free to rename, optimize, or even strip this field if unused elsewhere!
val internalCacheKey: String,

// This function is called by a retained library, so we must keep it
@RetainMember fun computeDisplayName(): String {
return "User: $username"
}
)

๐Ÿ”‘ Developer Comment: Notice how internalCacheKey and any non-annotated members can be optimized by R8. We’ve defined the scope down to the field and function level within a single class!

The Enhanced Technique: Interface-Based Scoping

A powerful, less-known pattern is to use marker interfaces for grouping, especially when dealing with entire sets of classes that belong to a single, reflected system (like all Fragments/ViewModels in a DI setup).

Step 1: Define the Marker Interface

// OptimizableComponent.kt
// No functions needed, it's purely a type marker.
interface OptimizableComponent

Step 2: Implement Interface-Based Keep Rules

Instead of keeping every class that implements the interface (which is still a broad rule), we combine it with member-level retention using another interface.

The “Keep-Only-The-Interface-Members” Rule:

# proguard-rules.pro

# This rule keeps *only* the members defined in the OptimizableComponent interface.
# Since OptimizableComponent is empty, this rule is a NO-OP, but we use it
# as a pattern for more complex interfaces.

# The real power: Keep all public members of classes that implement the interface
# (This is often used for classes that are created by a DI framework)
-keep class * implements com.example.app.OptimizableComponent {
public <methods>; # Keep all public methods for interface implementation
public <fields>; # Keep all public fields (e.g., if used by a serializer)
}

Step 3: Applying Interface Scoping in Kotlin

// MyFragment.kt
class SettingsFragment : Fragment(), OptimizableComponent {
// This class is now tagged. Since it is a Fragment, its lifecycle methods
// (onCreateView, etc.) are kept implicitly by the Android framework rules.
// The OptimizableComponent tag lets our DI framework find it via reflection
// without us having to write a separate keep rule for *every* Fragment.

fun shouldBeRemovedIfUnused() {
// ... if R8 determines this is only called from within the fragment
// and no external calls are made, this is a strong candidate for removal.
}
}

๐Ÿ’ก Frequently Asked Questions (FAQs)

What is the difference between R8 and ProGuard?

R8 is the modern default for Android code shrinking, optimization, and desugaring. ProGuard is the original tool. R8 is backwards compatible with existing ProGuard rules, but it provides superior optimization capabilities, making narrow scoping even more critical to maximize its potential.

How does @Retention(AnnotationRetention.RUNTIME) relate to R8?

When you use reflection to look for an annotation at runtime (e.g., Class.getAnnotation(...)), the annotation itself must have a runtime retention policy. If it only had a source or binary retention, the JVM would discard the annotation information before your code could access it, causing your reflection-based logic to fail.

Can I use this technique with third-party libraries?

Yes, but with caution. You can’t modify the source code of a third-party library to add your custom annotations. This technique is best suited for your own application code where you have control over the source. For third-party libraries, you’ll still need to rely on the aars' embedded ProGuard files or manual, but highly specific, proguard-rules.pro entries.

What happens if I use @RetainMember on a private field?

R8 will keep the private field/method from being renamed or stripped. This is precisely what you need if your reflection code uses getDeclaredField() and setAccessible(true) to access private members. The narrow scope is preserved, but the reflection logic is stabilized.

What’s Your Next Optimization Step?

Mastering R8 scoping is a skill that separates good developers from great ones. By moving from broad, generic rules to fine-grained, annotation-driven ones, you unlock a higher level of optimization for your application.

  • Have you used custom annotations for R8 scoping before? Share your most successful use case in the comments below!
  • What is the trickiest reflection-based library you’ve had to stabilize with keep rules?

If you find my content useful, feel free to support my work here. Every contribution is greatly appreciated! ๐Ÿ™

Comments

Popular posts from this blog

Stop Writing Massive when Statements: Master the State Pattern in Kotlin

Coroutines & Flows: 5 Critical Anti-Patterns That Are Secretly Slowing Down Your Android App

Code Generation vs. Reflection: A Build-Time Reliability Analysis