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:
.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:
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:
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:
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:
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!