Two targets, one arrow: Cutting down our app size and startup time on Android
As the Gojek app scaled, so did the app size. Our teams got together to improve app performance. Here's how it went.
By Jitin Sharma
Gojek helps millions in Southeast Asia to get to places, satiate hunger, send payments, and much more. Needless to say, building and maintaining such a huge ecosystem is no easy job. 😅
Over time as we scaled, our app size had increased considerably as well. Early last year, we started out a project where all of our mobile engineering teams came together to improve app performance in general. Here’s the story.
App size worries
Our app size was touching ~90 MB and had not shown any kind of downward trend in the last few months. To fix this, we started by looking at duplicate/unused libraries, images as well as optimising PNGs which gave us 5–6 MBs of reduction.
All of our apps inherit a singular design system called Aloha, which has an illustration system to provide region aware images.
Illustrations are context aware images which may change according to region. A default static set is shipped as vector drawables on Android.
While the Design System Library ships with a large set of illustrations, only a subset of it is used with each application. We discovered that our consumer facing app was not using close to 600 of these, but they were being packaged in the .apk. This was particularly puzzling since we have code and resource shrinking in place for our release apps.
We calculated that we can easily save ~4MB on app size by removing these unused assets. Little did we know the rabbit hole we were getting into 😬
Wrangling code minification
We started debugging why unused vector drawables for these illustrations aren’t getting deleted. We use Dexguard for obfuscation and minification. Dexguard is paid version of widely used Proguard tool(now replaced by R8).
Upon a closer look we found this rule
-keepresources drawable/default_**
Since all of our drawables start with default_
prefix, removing this rule should have fixed our problems. We removed this rule but unfortunately all unused drawables were still in the apk.
After countless cycles of changing rules and CI builds 😬, we stumbled upon this in our CI build
If you look closely at build output, the number of resources before and after minification are identical, which means resource minification isn’t happening at all. Classes were still getting minified so the whole process wasn’t broken. We recognised that we found a much larger problem at play than what we started with.
We started talking to Dexguard engineers and took a much stricter look at our rules as well as rules being inherited from libraries integrated in our project. Finally we found these faulty rules
-keep class **.R
-keep class **.R$* {
<fields>;
}
-keepclassmembers class **.R$* {
public static <fields>;
}
Since these are wildcard rules on generated R.class
file, they would end up keeping all R class references irrespective of packages. R classes reference resources directly (R.drawable
), so presence of these files caused Dexguard to not delete any resource at all!
We found these rules being inherited from 3 libraries, 2 internal and 1 external. We reached out library authors and got these rules fixed. Finally we built our app with rules and got hit with new surprises. 😂
- Our app size reduced by ~17 MB instead of ~4MB we were looking at initially 🙂
- Unused drawables which we wanted to delete were still present in the apk 🙈
So, what actually happened?
Transitive R classes
If you have developed Android apps, you have probably used R.
references all over your code base. These nifty references are auto generated as soon as a resource is added and allow you to access them in your code.
R class references are generated at various levels — for your third party libraries, your modules and then finally your app module. At each point of integration whether you are adding dependency for a third party library in your module or adding dependency of another module, these classes are copied over with duplication from the dependency, and it happens over and over again, until the app module. Here’s a graphical representation
Why you ask, since references are copied over you can reference any resource from dependency using your own package’s R class. Makes it easy, but it takes a toll on the amount of references being created. As the project becomes larger, these references can grow significantly.
That’s exactly what happened to us, since our project has 200+ modules and hundreds of third party libraries being imported across, our R class references ballooned into millions and to make matters worse, we had a rule which was asking Dexguard not to delete them!
Now since we fixed the problematic rules, all these unused references got deleted — 2.5 million fields deleted to be exact!
This is where much of our ~17MB size reduction came from(15MB worth of duplicated references + 2MB of resources they were referring too). Each of these R.class reference is a field and this code gets packaged into a dex file. Dex files have a limit of references it can hold which is 65k. All these irrelevant fields were packaged into multiple dex files which was causing size increase. Dex file count in our apk reduced from 50 to 10!
Unused assets
While we were happy with all these results, we still didn’t figure out our original problem 😅, unused drawables. After some more email debugging sessions with friendly Dexguard engineers, we found more problematic rules
-keep,allowmultidexing class com.gojek.app.package1.** { *; }
-keep,allowmultidexing class com.gojek.app.package2.** { *; }
-keep,allowmultidexing class com.gojek.app.package3.** { *; }
We found wildcard rules for module package names, which would cause R.class references for these modules to be kept by Dexguard. Since our Design System library was a dependency of all these modules, they were copying over all drawables references which would then not get deleted, causing all those drawables files to not get deleted as well.
Once we fixed these rules, our unused assets started getting deleted as well giving us another 4MB of size save. Overall we reduced ~21MB of app size with this exercise 🚀
Performance implications
Once we realised our dex count had reduced by 40, we started benchmarking new builds. Dex files are loaded directly while the app starts especially on fresh launches so we expected to see some improvements in application load time. Since we had to benchmark release builds and check how fast the OS itself is loading our application, our options were limited on what we could use. We captured systrace on various devices and then visualised it on Perfetto UI.
Our local tests confirmed that fresh launches became 30–50% faster on certain devices, but since dex files often get compiled and optimised over time on device, we didn’t expect such metric from production.
Eventually we got ~20% reduction in slow app launch metric on Android vitals after the new release was adopted by the majority of our users 😀. Our ANRs also suspiciously went down in that release.
End notes
This whole exercise was extremely adventurous and yet it was a reminder of what kind of bugs may lie in your source code. If you’ve reached here, I can leave you with following points of caution
- Keep an eye on proguard rules being imported by libraries. You can easily check them by adding
-printconfiguration
in your proguard rule file. - At all costs, avoid wildcard keep rules, especially at module package level. You might end up keeping far more than intended for.
- Check apk size at pull request branch vs your main branch, a simple foolproof way to avoid size increase scares.
I also gave a talk on this topic in Android Worldwide conference. Check out the slides here and video below:
Here are more stories on how we do what we do.
Oh, we’re hiring! 👇