Bringing Android app build times down by 95% at Zomato

Originally posted on Zomato Blog on April 24, 2019. Also check out how we reduced iOS App Build times by 99%

How much time does it take you to rebuild your Android app every time you make some changes ?

How much time do you think the Zomato app takes to build ? Some statistics for your perspective

  • We have 29 submodules
  • There are 6400 java files, 1300 kotlin files and over 17000 xml files
  • That is around 1.2M lines of Java and 64k lines of Kotlin
Time taken to rebuild our app (not clean build) after changing for example just a single variable

If everyone on a 10 member Android team builds the app 5 times a day on average, we are wasting 2 × 5 × 10 = 100min = 1.6 man-hours at Zomato every day simply waiting for builds.

Slow build times make developers avoid building — which results in less tested patches, and ultimately your users being exposed to less stable app. Keeping builds times low is important for developer happiness and also for user satisfaction.

Understanding the Problem — Gradle Build Profile and Scans

The first step is to run gradle assembleDebug --profile to get a breakdown of which parts of your build time took how much time. If you want detailed results of why each step took place, and a thread-wise report of tasks getting executed, use gradle assembleDebug --scan

The Gradle scan of our build, thread-wise. Notice how we are unable to leverage the 12 threads, and most of the build time is spent building the main app module on a single thread

BUD principle of optimisation — remove bottlenecks, unnecessary work and duplicated work

Unnecessary Work

We can remove a lot of unneeded steps from the build when making debug builds

  • Do not run firebase-perf or newrelic plugins on debug builds. These transform your classes for performance tracking and increase Java/Kotlin compiles times a lot
  • If your development team is mostly using similar test devices and testing only English languages, you can set resConfig to only en, xxhdpi which will reduce resource packaging for other dpis and languages.

Removing these steps from our debug builds dropped 40s from our build.

Some people asked me how exactly remove a plugin from a particular build variant — so here is how.

def taskName = getGradle().getStartParameter().getTaskRequests().toString()
// toLowerCase because it might be assembleDebug
if (!taskName.toLowerCase().contains("debug")) {
apply plugin: 'com.google.firebase:firebase-perf'
}

Bottlenecks

If your project has a lot of modules, it is important to make sure the dependency graph is properly created. Once that is done, it is also important to decide where to use implementation and api when creating module-to-module dependencies. 
If we have the following setup — 
app -> api(modA) -> api(modB) -> api(modC) 
then, any changes to modC will lead to recompilation of modB, modA and finally, app

In contrast, if we have this — 
app -> implementation(modA) -> implementation(modB) -> implementation(modC) 
then changes to modC will only read to modB recompilation.

Duplicated Work

We noticed that even without a single line of change, the tasks kotlinCompile and javaCompile were still running for the entire duration it usually takes in a clean build. That made us suspect, somehow, the compiled jars are not saved for incremental builds.

In the Gradle build scan you can click on each task and get to know the reason why that task had been run

The culprit ? We had fields in BuildConfig.java that were changing in every build — for example saving the build timestamp. Changes in BuildConfig invalidates all the incremental build jars from last iteration, and also stops Android Instant Run from working!

Results ?

Our builds are now heavily parallelised, leveraging Gradle parallel processing, because our dependency graph is properly setup now.

Our new gradle build scan

And now, when there are only some changed to our consumer app codebase (without any changes to inner modules), it takes only a 7 seconds to build. Making changes to some deep-down library modules that are used across the entire app in multiple modules still take more time, but at least changing the padding of a button and waiting 2 minutes to see the change reflect is a thing of the past.

Not having ever-changing variables in BuildConfig.java in our debug builds means that Instant Run now works, and greatly improves iteration time of UI changes.

Part of our build time optimisation efforts also brought us up to date to Android Gradle Build plugin version 3.3 (we were on 3.0 till now). Which means we can release our app as app bundles and reduce download sizes by 20% for our users.