Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 059 / 100

R8 and ProGuard: a Android & Kotlin guide for developers

Smaller, harder-to-reverse apps.

Expona Admin8 min read

R8 and ProGuard: a Android & Kotlin guide for developers

Your Android app ships with everything: unused dependencies, legacy code paths, test utilities. Worse, your code is human-readable in the APK, which means reverse engineers can extract your business logic with free tools. R8 and ProGuard shrink your app, obfuscate sensitive names, and make reverse engineering significantly harder—but only if you configure them correctly.

We've shipped production apps across e-commerce, SaaS, and ride-hailing platforms where misconfigured obfuscation caused crashes in release builds. This guide walks through the practical reality of R8 and ProGuard so your release APK stays both small and functional.

Understanding R8 and ProGuard

ProGuard is the original Java bytecode optimizer and obfuscator, released in 2002. R8 is Google's newer replacement, built into the Android Gradle plugin since Android Studio 3.4. R8 combines ProGuard's functionality with better performance and built-in support for desugaring Java 8+ features.

By default, Android Gradle plugin uses R8 for release builds. Both tools work by analyzing your bytecode to identify which classes, methods, and fields are actually reachable from your entry points (activities, services, receivers, providers). Unreachable code gets stripped. Reachable code gets its names renamed to short identifiers like a, b, c.

The configuration is nearly identical between the two, though R8 is what you should target for new projects. If your build.gradle doesn't explicitly disable R8, you're already using it.

Shrinks unused code and resources

Code shrinking removes unreachable methods and classes. Resource shrinking removes unused drawables, layouts, and other resources. Together, they reduce your APK size by 20–40% on typical production apps, sometimes more.

Enable code shrinking in your release build type:

kotlin
android {
    buildTypes {
        release {
            minifyEnabled true
            shrinkResources true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
}

proguard-android-optimize.txt is Google's standard rule set. getDefaultProguardFile loads it from the Android SDK. Your proguard-rules.pro file adds project-specific rules.

Resource shrinking is separate from code shrinking. It examines your compiled code, identifies which resources are actually referenced, and removes the rest. This catches unused drawables from old features, unused color definitions, and language-specific strings you don't support.

In a real scenario, we had an e-commerce app with nine unused language resources and fifty drawable variants for an old UI. Shrinking recovered 2.3 MB—enough to matter for markets with slow networks or 100 MB app store limits.

One caveat: resource shrinking only removes resources that are not referenced by name in code or XML. If you dynamically reference a drawable with getResources().getIdentifier("drawable_" + name, "drawable", packageName), the shrinker can't prove it's used and will remove it. Use the @Keep annotation or add explicit -keep rules for dynamic references.

Obfuscates names

Obfuscation renames classes, methods, and fields to meaningless identifiers. A method like validateCreditCardNumber becomes a. A class like PaymentGateway becomes b. This makes reverse engineering orders of magnitude harder because there's no semantic information in the binary.

Here's what a simple Kotlin class looks like before obfuscation:

kotlin
class UserAuthenticator {
    fun validatePassword(input: String): Boolean {
        return input.length > 8 && input.contains(Regex("[0-9]"))
    }

    fun getSessionToken(): String {
        return generateSecureToken()
    }

    private fun generateSecureToken(): String {
        // implementation
    }
}

After R8 obfuscation, a decompiler sees something like:

kotlin
class a {
    fun b(c: String): Boolean {
        return c.length > 8 && c.contains(Regex("[0-9]"))
    }

    fun d(): String {
        return e()
    }

    private fun e(): String {
        // implementation
    }
}

The logic is intact, but the structure is unreadable. An attacker can still reverse-engineer the behavior, but it requires significantly more effort and skill.

By default, R8 also removes line number information from stack traces, making crash reports harder to trace. You can preserve line numbers if your crash reporting tool supports mapping files (discussed below).

Keep rules for reflection

The biggest issue with obfuscation is reflection. When your code calls Class.forName("com.example.MyClass"), R8 can't know that name will be loaded at runtime. It treats MyClass as unreachable and either removes it or renames it. Both break your app.

You use -keep rules to tell R8 which classes, methods, and fields must not be removed or renamed. The syntax is inherited from ProGuard:

kotlin
// Keep a specific class and all its members
-keep class com.example.serialization.PaymentData { *; }

// Keep a class and its public methods, but allow renaming
-keep class com.example.api.ApiResponse { public *; }

// Keep classes matching a pattern
-keep class com.example.models.** { *; }

// Keep a specific method
-keep class com.example.Database {
    public android.database.Cursor query(...);
}

// Keep classes extending a base class
-keep class * extends com.example.BaseViewModel { *; }

// Don't obfuscate, but allow removal if unused
-keepnames class com.example.models.User { *; }

// Keep inner classes
-keep class com.example.Parent$Inner { *; }

The three most common scenarios:

  1. Serialization: Classes deserialized with Gson, Moshi, Kotlinx Serialization, or other JSON libraries must be kept. Gson ships with its own proguard.cfg that handles @SerializedName, but if you define custom classes, add explicit rules.
  2. Reflection: Any class you load via reflection, including those dynamically created by dependency injection frameworks, needs a -keep rule.
  3. Native code: Classes called from C++ via JNI must be kept. Methods called from native code need explicit -keep declarations.

Here's a practical example for a Retrofit API service:

kotlin
// Your data models
data class LoginRequest(val email: String, val password: String)
data class LoginResponse(val token: String, val userId: String)

// Your service interface
interface AuthService {
    @POST("login")
    suspend fun login(@Body request: LoginRequest): LoginResponse
}

// In proguard-rules.pro
-keep class com.example.api.** { *; }
-keep class com.example.models.** { *; }

If your library dependencies include proguard.cfg or consumer rules, Gradle merges them automatically. You can see the merged rules in the build output at build/intermediates/proguard-rules/release/aapt_rules.txt.

For Kotlin data classes, R8 is smart enough to preserve the synthetic accessor methods needed for destructuring and default parameters. But it's safer to be explicit in your rules.

Test release builds before shipping

This is not optional. Obfuscation and shrinking frequently introduce bugs that don't exist in debug builds. You must install and test the actual release APK on real devices.

Build a release APK locally:

kotlin
./gradlew assembleRelease

Install it on a physical device or emulator:

kotlin
adb install -r build/outputs/apk/release/app-release.apk

Test these scenarios specifically:

  • Cold start: Force-stop the app, then launch it fresh. Many crashes happen during initialization when R8 has removed or renamed a class you didn't keep.
  • Navigation flows: Tap through every screen and every dialog. Crashes from missing kept rules often appear deep in the app.
  • API calls: Make actual network requests. If deserialization fails silently or throws an exception, your JSON parser encountered an obfuscated class.
  • Login and authentication: This is where reflection bugs bite hardest, especially with OAuth or third-party auth SDKs.
  • Database queries: If your ORM or database library uses reflection, exercise queries that haven't been tested with the debug build.
  • Third-party SDKs: Analytics, crash reporting, ads, payment processing—all of these often rely on reflection or class name strings.

Enable verbose logging so you see exactly what R8 kept and what it removed:

kotlin
android {
    buildTypes {
        release {
            minifyEnabled true
            shrinkResources true
            proguardFiles(...)
            // See full mapping in build/outputs/mapping/release/
        }
    }
}

After the build, examine build/outputs/mapping/release/mapping.txt. This file maps obfuscated names back to original names. You'll use it when crashes happen in production.

We've seen releases where a typo in a -keep rule removed an entire SDK that was only used at runtime. Test release builds in a staging environment if possible, or at least manually test critical flows.

Common mistakes

Mistake 1: Overly broad keep rules. Writing -keep class * { *; } or -keep class com.** { *; } disables shrinking entirely for large portions of your code. Be specific. Keep only what you need to keep.

Mistake 2: Forgetting to keep API response classes. You keep the Retrofit service interface but forget to keep the response data classes. The API call succeeds, but deserialization fails silently or throws an exception you didn't anticipate.

Mistake 3: Not testing release builds. You think your keep rules are right, but you never actually install and test the release APK. A crash in production reveals the mistake.

Mistake 4: Ignoring mapping files. You don't generate or store mapping files from your build server. A crash happens in production, but you can't map the obfuscated stack trace back to source code.

Mistake 5: Forgetting to update rules when dependencies change. You upgrade Gson, Retrofit, or another library. Their shipped proguard rules change or become incompatible with your existing rules. Now you have conflicts or missing dependencies.

Key takeaways

  • Enable minifyEnabled true and shrinkResources true in your release build type. R8 is the default and is actively maintained by Google.
  • Code shrinking removes unreachable classes and methods; resource shrinking removes unused drawables, strings, and layouts. Both significantly reduce APK size.
  • Obfuscation renames classes and methods to meaningless identifiers, making reverse engineering harder but not impossible.
  • Write -keep rules for classes that are referenced by name via reflection, loaded by serialization libraries, called from native code, or part of public APIs.
  • Always test the release APK on real devices before shipping. Crashes from missing keep rules are common and caught only by testing.
  • Store mapping files from your build server so you can deobfuscate stack traces in crash reports from production.
  • Keep keep rules narrow and specific. Overly broad rules defeat the purpose of shrinking.
  • Document why each keep rule exists. It prevents future maintainers from deleting rules they think are obsolete.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles