Anvith Bhat I'm passionate about creating stuff around Android. Be wary, observations may be interlaced with humor.

A guide to R8 and code shrinking in Android

6 min read 1896

A guide to R8 and code shrinking in Android

Code shrinking is an approach that allows us to generate smaller APKs by removing unused code or refactoring existing code, resulting in a smaller footprint. In addition to shrinking, obfuscating is another tactic that allows us to guard our Android apps against reverse engineering.

Using both of these strategies will ensure that your app is faster to download and more difficult to modify by others.

In this post, we’ll cover:

R8 vs. Proguard

In the early versions of Android, code shrinking and optimization were delegated to a tool called Proguard. However, since Android Gradle Plugin (AGP) v 3.4.0, Android has used the R8 compiler.

While both tools help with code compaction, R8 has richer functionality than code shrinking. For starters, R8 has limited support for Kotlin, whereas Proguard was built for Java toolchains. R8 achieves better inlining and outlining (extracting common code into a function) than Proguard, whereas the latter is better at propagating constant arguments.

Speaking of the actual code compaction process, R8 performs better by achieving 10 percent compaction, as opposed to 8.5 percent for Proguard.

Stages in R8

The R8 compiler does various things to reduce the size of your final APK. Some of these include:

  • Desugaring: This allows us to use Java 8 and above API features without worrying about support, the R8 compiler handles back-porting newer features used in your code to older java APIs.
  • Code shrinking: This is the stage where R8 removes unused code from your app, including unused code in library dependencies
  • Resource shrinking: Once it’s done shrinking code, R8 identifies resources that are unused and eliminates unused strings, drawables, etc.
  • Obfuscation: At this stage, R8 ensures your classes and their fields are renamed and possibly repackaged as well in order to protect it from reverse engineering. This process generates a mapping file, which can be used to reobtain the actual entity names if needed
  • Optimizing code: During code optimization, R8 looks to reduce your app footprint and/or improve efficiency further, by removing branches of your code that are not reachable (as opposed to classes/files). It uses advanced optimization rules, like inlining a method at the call site when it was only called from one place
    • Other techniques include vertical class merging, where, if an interface has only one implementation, it merges both of them under a single class

Once all of the above steps are completed, R8 converts the bytecode into dexcode by a process called dexing. Earlier, this was a part of D8 compiler, but has now been integrated into the R8 compiler.

Now that we know a bit about the R8 compiler, let’s see how code shrinking actually works.

Configuring code shrinking

In Android, we can configure code shrinking by setting the minifyEnabled flag as true in your build.gradle file. Optionally, you may also enable shrinkResourcesto remove unneeded resources.

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

Code shrinking starts by examining what are called entry points. Entry points are declared in a config file and made available via the proguardFiles parameter in the build.gradle.

Once R8 has a set of entry points, it begins searching for all classes and entities that are reachable from these entry points. It proceeds to build a list of such tokens. Any token that isn’t reachable is stripped from the final output.

This process is generally not foolproof because:

  • Some of our code may use reflection to lookup classes, which makes it difficult for the compiler to know whether a particular class is used or not
  • Your app may call a method from the native side via JNI. Since R8 is designed to work with Kotlin/Java code rather than native, we need to direct it to keep these classes

Many of these entry points are defined in the proguard-android-optimize.txt file made available via the AGP plugin. Here’s a partial snapshot of what it looks like:

The entry points are defined in the proguard-android-optimize file

Let’s go over what the two rules above mean:

  1. Retains all functions that getters and setters present in classes that extend the View, thus retaining the View classes as well
  2. Retain all functions of Activities that match the signature of receiving a single View parameter, namely click listeners used in the XML, which are looked up reflectively

Next, let’s get to know the schema that powers R8.



Understanding the Proguard rule schema

Though we’ll be discussing these as Proguard rules, they are the same rules that configure R8 as well. Let’s take deeper dive into how to write them.

A typical R8 or Proguard rule consists of three sections:

  1. A keep option: A keep option defines “whom” to retain for, as follows:
    • keep ensures we retain the target that matches the rule
    • keepclass ensures we retain the class that matches the rule
    • keepclasswithmembers retains classes whose members match the rule
    • Similarly, we have keepclassmembers to retain only the members of a class
  2. A token type: This denotes the type of target entity of our rule, i.e., class, enum or interface
  3. Wild cards: These allow us to define different formats to match different tokens, as follows:
    • ?: Matches a single character in a name. So, for a rule like
      keep class T???Provider, we must ensure we retain both the TaskProvider and TrapProvider classes
    • *: Matches any part of a name excluding the package separator. This ensures a rule like keep class com.demo.*Provider extends ActionProvider matches com.demo.TaskProvider, but doesn’t match com.demo.internal.StorageProvider
    • **: Matches any part of a name including the package separator. In the above example, it would even match the StorageProvider class
    • <n>: Allows us to match dynamic elements within our rule. For example, if we wish to have classes that fit the following template:
      class TaskProvider { 
         fun getTaskKey():  String
      }
      
      class StorageProvider { 
         fun getStorageKey(): String
      }
      

      we can write:

      -keepclasseswithmembers class *Provider {
         public java.lang.String get<1>Key();
      }
      

This is because our first wildcard matcher, *, matches Task and Storage, which we can reuse to define the dynamic parts of our function’s name.

Writing your own R8 rules

The R8 or Proguard rules shipped via AGP are generally sufficient, however, a need may arise to write your own rules. While writing R8 rules, we should strive to avoid including more than what’s needed in our keep rules to ensure that we can compress most of our code. Also, all classes we specify need to be fully qualified, i.e., they must include the package name.

Typically, enums used in XML files are the culprits stripped away by R8. But we can define our own rule to keep them, as follows:

-keep enum com.demo.main.MediaType{ *; }

Note: The {*;} in the braces implies that we intend to preserve all members of the class/enum.

Another rule is to preserve class constructors; here’s how you’d do it using the keyword init:

-keep public class * extends android.view.View {
    public <init>(android.content.Context);
}

Occasionally, there may be entities included in your app that are reflectively looked up via their fully qualified name within jars. You’d want to preserve only the names and prevent R8 from obfuscating or renaming the class. You can retain names by using the keepnames qualifier:

-keepnames class com.ext.library.ServiceProvider

Another way to retain classes is to annotate them with the @Keep annotation. These classes are retained via the androidx.annotation library Proguard rule. However, you can only use this on source code you control; additionally, this is a more generic solution and will result in the inclusion of members that aren’t used.

Resource shrinking

Resource shrinking is typically done after code shrinking, but instead of using Proguard rules, we can specify resource retention using a keep.xml in our res/raw folder. We generally do not need this unless we are looking for resources via Resources.getIdentifier().

In such cases, the resource shrinker behaves conservatively. Below is an example:

val name = String.format("ic_%1d", angle + 1)
val res = resources.getIdentifier(name, "drawable", packageName)

The shrinker uses pattern matching and retains all assets, starting with ic_. We can also retain some assets explicitly in our keep.xml, as follows:

<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"
    tools:keep="@drawable/ic_sport*, @drawable/ic_banner_option, @layout/item_header"
    tools:discard="@drawable/wip" />

Note: The discard option ensures the wip is removed from the final build if unused.

Debugging R8 errors

Occasionally while using R8, you’ll end up with a missing resource error in the form of ClassNotFoundException or FieldNotFoundException. However, since the trace is obfuscated, we’ll need to use a tool called retrace.

Retrace is usually present on the following path: Android/sdk/tools/proguard/bin. You may optionally use the GUI-based route by using the proguardgui.sh command, as shown below:

Using the Proguard GUI shell command

Once you’ve figured out which class or member is causing this issue, you can easily fix this by including a specific keep rule for it:

-keep class com.demo.activities.MainActivity

R8 generally strips meta properties like line numbers and source file names. We can retain this information by using keepattributes, as shown in the below rule:

-keepattributes SourceFile, LineNumberTable

You can find the complete list of attributes here.

Occasionally, you may see that a member that was supposed to be removed from the final APK has not actually been removed. We can figure out why by using whyareyoukeeping:

-whyareyoukeeping class com.android.AndroidApplication

This will print the below output:

com.android.AndroidApplication
|- is referenced in keep rule:
|  /Users/anvith/Development/Android/project-demo/app/build/intermediates/aapt_proguard_file/release/aapt_rules.txt:3:1

Another useful tool while debugging is to list all the unused classes. This can be done using printusage, as follows:

-printusage

A quick note about R8 rules: The most broad rules take precedence. So, if libraryA ships with a rule to include one method of a class, and `libraryB` is shipped with a rule to include all members, libraryB’s rule takes precedence.

Lastly, if you wish to see classes matched by your rules, you may use the following command to observe the matched results:

-printseeds

Aggressive shrinking options

We can instruct R8 to be more aggressive by letting it run in non-compat mode and declaring the following property in the gradle.properties file:

android.enableR8.fullMode=true

This flag results in some of the more rigorous optimizations, like:

  • Avoid retaining the default constructor unless specified explicitly
  • Attributes (like Signature, Annotations, etc.) are only retained for matching classes, even if we specify the generic keepattributes for all entities

Similar to the code shrinking option, there is an aggressive resource shrink mode that can be added to the keep.xml:

<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"
    tools:shrinkMode="strict" />

Conclusion

In this article, we learned about R8 and how to configure rules for it. In the process, we also covered various debugging options to address the perils of aggressive shrinking.

I hope you’ve found the information in this article useful for addressing your code shrinking concerns and are ready to leverage the R8 toolchain!

LogRocket: Instantly recreate issues in your Android apps.

LogRocket is an Android monitoring solution that helps you reproduce issues instantly, prioritize bugs, and understand performance in your Android apps.

LogRocket also helps you increase conversion rates and product usage by showing you exactly how users are interacting with your app. LogRocket's product analytics features surface the reasons why users don't complete a particular flow or don't adopt a new feature.

Start proactively monitoring your Android apps — .

Anvith Bhat I'm passionate about creating stuff around Android. Be wary, observations may be interlaced with humor.

Leave a Reply