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
- Stages in R8
- Configuring code shrinking
- Understanding the Proguard rule schema
- Writing your own R8 rules
- Resource shrinking
- Debugging R8 errors
- Aggressive shrinking options
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 shrinkResources
to 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:
Let’s go over what the two rules above mean:
- Retains all functions that getters and setters present in classes that extend the
View
, thus retaining theView
classes as well - 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:
- A keep option: A keep option defines “whom” to retain for, as follows:
keep
ensures we retain the target that matches the rulekeepclass
ensures we retain the class that matches the rulekeepclasswithmembers
retains classes whose members match the rule- Similarly, we have
keepclassmembers
to retain only the members of a class
- A token type: This denotes the type of target entity of our rule, i.e.,
class
,enum
orinterface
- 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 theTaskProvider
andTrapProvider
classes*
: Matches any part of a name excluding the package separator. This ensures a rule likekeep class com.demo.*Provider extends ActionProvider
matchescom.demo.TaskProvider
, but doesn’t matchcom.demo.internal.StorageProvider
**
: Matches any part of a name including the package separator. In the above example, it would even match theStorageProvider
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 thewip
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:
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 generickeepattributes
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!
The post A guide to R8 and code shrinking in Android appeared first on LogRocket Blog.
from LogRocket Blog https://ift.tt/DMzfA1l
Gain $200 in a week
via Read more