Removing Kotlin bloat on Android

Removing Kotlin bloat on Android

App size is an important metric for Android apps and directly correlates with business metrics. Knowing this, we should aim to optimize it as best as we can. The adoption of Kotlin on Android brings many benefits, but it can also lead to some unintended bloat in your app. In this blog post, we’re going to explore where this bloat comes from and how we can get rid of it.

Kotlin metadata

Let’s start by taking a look at some of the metadata Kotlin uses. Because Kotlin has features that are not supported by the JVM, it has to store some supplementary metadata in addition to what’s normally present in compiled class files. This metadata enables things like extension functions and the internal modifier. Unless you’re using reflection and kotlin-reflect, this metadata is not used at runtime and we can safely remove it.

The @Metadata annotation

Most of the metadata is stored inside the @Metadata annotation, which the compiler adds to each Kotlin class. And because this annotation is present in every single class, it can affect the app size quite a bit. Luckily, R8 should get rid of this annotation automatically if you don’t have any explicit rules keeping it. If you want to make sure the annotation is removed, you can include the following rule in your R8 configuration:

-checkdiscard class kotlin.Metadata

.kotlin_builtins and .kotlin_module files

Some additional metadata is stored in .kotlin_builtins and .kotlin_module files. .kotlin_builtins files contain information about the mapping between Kotlin and Java types, while .kotlin_module files contain metadata related to top-level functions and properties. Similar to the @Metadata annotation, both types of files aren’t needed unless you use reflection, so they can safely be excluded from the app:

packagingOptions {
    exclude "**/*.kotlin_builtins"
    exclude "**/*.kotlin_module"
}

Kotlin Intrinsics checks

One of Kotlin’s biggest advantages is the null safety that’s built into the type system. The compiler makes guarantees about which type can and can’t take on null values. But how does this interop with Java? Since the Java type system does not support null safety, Kotlin can’t make any guarantees here. So it’s entirely possible to assign null values to non-nullable Kotlin types from Java (although you might get some warnings because of the @NonNull annotations).

So what does the Kotlin compiler do instead? It generates runtime checks for every non-nullable parameter, field, etc. And while these runtime checks can help you catch issues while developing, they don’t add too much value in the finished app. In the end, those checks still throw an exception and therefore cause a crash for the user. Additionally, those nullability checks can add up quite quickly if you have a large Kotlin codebase. So it might be worth removing them from release builds with this R8 rule:

-assumenosideeffects class kotlin.jvm.internal.Intrinsics {
    public static void checkNotNull(java.lang.Object);
    public static void checkNotNull(java.lang.Object, java.lang.String);
    public static void checkExpressionValueIsNotNull(java.lang.Object, java.lang.String);
    public static void checkNotNullExpressionValue(java.lang.Object, java.lang.String);
    public static void checkReturnedValueIsNotNull(java.lang.Object, java.lang.String, java.lang.String);
    public static void checkReturnedValueIsNotNull(java.lang.Object, java.lang.String);
    public static void checkFieldIsNotNull(java.lang.Object, java.lang.String, java.lang.String);
    public static void checkFieldIsNotNull(java.lang.Object, java.lang.String);
    public static void checkParameterIsNotNull(java.lang.Object, java.lang.String);
    public static void checkNotNullParameter(java.lang.Object, java.lang.String);
}

This optimization is not as hands-off as the others, as it changes the runtime behavior a bit. If you ever assign a null value to a non-nullable type, it will no longer fail immediately. Instead, the failure might occur somewhere else in the code, where this type is first used. This can make bugs harder to chase down. Given the fact that those checks are still present in the debug variants, we decided it was worth taking the risk. But you should think about that before applying this trick blindly.

Kotlin Coroutines

Another great feature of Kotlin is structured concurrency using coroutines. If you’re using coroutines in your app, chances are you’re shipping some unnecessary debug information to your users. Let’s take a look.

Each class containing a coroutine contains a @DebugMetadata annotation to store some additional debug information. This information is used while debugging coroutines, but it’s unnecessary bloat in a released app. Unlike the @Metadata annotation, R8 does not remove this annotation by default. So we have to add the following rule to the R8 configuration to make sure this annotation does not make it to the end-user:

-assumenosideeffects public class kotlin.coroutines.jvm.internal.BaseContinuationImpl {
    public java.lang.StackTraceElement getStackTraceElement() return null;
}
-checkdiscard class kotlin.coroutines.jvm.internal.DebugMetadata

Similarly, the kotlinx-coroutines-core artifact contains a DebugProbesKt.bin file, which is only used by the coroutine debugger of IDEA (which doesn’t work for Android yet). We should also make sure that this file gets excluded from our packaged app:

packagingOptions {
    exclude "**/DebugProbesKt.bin"
}

How much did we save?

Using these tricks, we could reduce the app size of our adidas Running and adidas Training apps by around 1.5% to 2%, without touching any functionality. Are you going to see similar numbers if you apply these tricks? That’s highly dependent on the size and shape of your Kotlin codebase, but trying it could be worth a shot. Feel free to share the results you’re seeing on Twitter, as well as any other tricks you might be using to optimize app size!