Accelerate Your Android Development: Top Techniques to Reduce Gradle Build Time (Part I of II)

rolgalan
The Glovo Tech Blog
11 min readOct 23, 2023

--

Introduction

Everyone wants faster builds, don’t they? Reducing build time is one of the most important actions you can take to improve the Developer Experience, as it reduces the feedback loop and helps the engineers to iterate faster, reducing their idle time and allowing your team to keep focused on delivering new features, adding business value to your app.

Accelerating the build process helps your developers in two ways: first while working locally they can compile and execute tests faster; and second for the CI checks to pass, so they don’t need to wait for a long times before PRs are mergeable (besides review times, of course, but that’s a human problem that won’t be solved by this). Both are important, and good news: most of the techniques to get faster build time are impacting positively both local and CI builds.

Over a year ago, our CI checks executed in the Pull Requests took around 1 hour of time. By analyzing our builds and applying many different improvements, we managed to reduce them to around 15 min in average.

Gradle offers nice documentation to improve the performance of the builds, and also Android documentation provides some good tips to optimize your build speed. However most of these recommendations are not as straightforward as enabling a flag or changing a value. In this series of articles we’ll explain with more details the most important ones and how to leverage them, but also go beyond them and highlight other techniques and findings that helped Glovo to reduce the build times by 75%.

This article is divided into two posts: In this first one we will discuss the most impactful options that you can enable to cut your build times significantly. In the next one, we will talk about other less impactful actions, but still quite relevant and highly recommended to apply after you have done the first ones.

Even if all learnings are extracted from a long journey optimizing Android projects, all of the Gradle techniques discussed here can be applied to any other Gradle project unrelated with mobile.

Parallel tasks

This is principles of computation 101: executing multiple tasks simultaneously rather than sequentially will expedite the entire process.

It really helps to make the build process faster, as many tasks can be executed in parallel, reducing the time significantly. Some of our projects are built in around 6 min, whereas in serial they would take around 30 min.

This is disabled by default in Gradle. You should add org.gradle.parallel=true to your properties file.

This flag will allow Gradle to build independent subprojects in parallel. Projects are the Gradle terminology for what we usually refer to as modules. Fortunately modularizing your application is part of the modern Android development practices, so I am not going to get into details about it. However, these two topics are highly connected.

Please do not think that enabling this flag, and having modules, is all that is required, as this is not true. The reality is that you need to build your modules architecture in a conscious way to leverage all the parallelism that Gradle can provide. This is so because each module depends on other modules, and they cannot start building until the ones they depend on are completed.

For this reason you should flatten your modules graph, reducing the height and the cross dependencies. Once you get this, is when you can get the real benefit of parallel tasks execution.

Besides the architecture, parallelization will be constrained by the hardware: not only the number of cores, but also the memory available for the system. We will review the hardware configuration in detail in the following article.

Note that some parallelization, at different levels, might still happen even without this flag disabled, see Common Gradle misconceptions to learn more..

Cacheability

Another fundamental concept from computation is to reuse previously executed tasks.

The fundamental part of Gradle to improve the performance of the build system is “the ability to avoid doing work that has already been done”. Gradle bases this in the concept of incremental builds and the build cache.

Both are interconnected as they are based on the same principle of fingerprinting each task inputs and storing the task outputs. The only difference is that incremental builds live in the project scope (if the output exists in the build folder, so it doesn’t survive a clean), whereas the cache is persisted somewhere else, allowing you to recycle the tasks outputs of previously executed tasks even if you cleanup the project completely (as long as their inputs don’t change).

Build Cache itself has two levels: local and remote. Local caches are usually stored in your home directory (typically your ~/.gradle/caches unless you declared it somewhere else). This really helps for local development, but for most CI executions this will barely help, especially if you are using ephemeral agents, which is usually the most common case (unless you have some shared “local” disk).

Incremental builds work out of the box in Gradle. In order to enable (local) caching just add org.gradle.caching=true in your gradle.properties.

This is good already, but what really pays off is the remote cache, which allows caching tasks from a different machine, so if you have ephemeral builds this is a great way to reduce the execution time by reusing what was already built in previous jobs. Engineers running local builds can also get the benefit of this remote cache (specially when changing branches or fetching changes from the upstream repository). Among all the techniques described in these articles, remote cache was by far the most successful one for us, helping us to cut our build times in the CI by half when it was introduced.

In order to do this, you will need to maintain a remote Gradle build cache node somewhere, and declare it in your Gradle build files. You can either run your own remote Gradle build cache (we had this for a while through our own internal Artifactory instance that we use for internal dependencies) or buy some of the services that are offering it (we currently get it from Develocity, among other features).

The cache node needs to be configured to have enough disk to storage to artifacts generated/used by your organization, at least in the last 24 hours. Otherwise you won’t leverage this completely, as “old” cache entries will be evicted too soon, getting a higher misses-rate than you should have. When we first introduced the remote cache, we didn’t notice that it was using only 10 gb of space by default. When increased it to 100gb we increased the remote hit-rate from 82% to 96%, with a ~20% build time reduction in all our projects.

Graph of build time showing a more or less stable line of around 28–30 min median time, going down to around 18min after the mentioned changes increasing the remote node disk storage were applied.

Please note that modularization also plays an important role in terms of cacheability as well, because Gradle’s basic building blocks for cacheability tasks are the modules. So the more you modularize, the more chances you will have to reuse code, and the faster builds you will have.

Also make sure you do an adequate usage of api vs implementation (as rule of thumb: always use implementation) to ensure you don’t invalidate the cache unnecessarily: implementation only requires recompiling the modules depending on the changed module, whereas api will invalidate also those depending on the parent.

Optimizing Cacheability

Similar to the case of parallelization, one might assume that simply enabling the cache and setting up a remote cache node would suffice. However the devil is in the details and cacheability is not trivial at all: tasks need to be carefully designed with cacheability in mind and there might be many reasons why it gets invalidated.

Even if you don’t create many tasks yourself, you might still be impacted by third party tasks added to your build. In those cases you might not be able to fix the issue, but if you detect it you should have enough data to report this to the library authors (or propose a fix yourself if it’s an open source project).

One of the most common reasons to fail is that some tasks requiring files as their input declare them as absolute paths, making them mutually incompatible. Usually there is not much you can do when you face these cases other than reporting to the original author hoping they fix it fast.

One minor optimization you can do here is verify that your CI agents are always using the same path for the project. This might sound crazy, but Jenkins by default loads the project in a folder with the name of the branch or the PR number. You can use a specific fixed workspace with the ws command of the Pipeline Step.

Fortunately most of the common libraries used for Android development are currently properly implemented with relative inputs (thanks to all the people constantly watching this and reporting to the library owners), but it is good to keep this in mind when analyzing the reasons for failed cache tasks.

Besides the file paths, there are many other reasons for the tasks to fail caching, and you will need to keep reviewing your builds, debugging and diagnosing cache misses.

In my experience, some of the other most common reasons for Android are those introducing dynamic values in the Manifest or the BuildConfig files, as these files are the input for many other tasks down the line, so introducing any variability in those would invalidate all the subsequent tasks. For example:.

  • Version name/code. Some projects automatically increase the version code for each commit, which will easily invalidate most of your tasks between executions.
  • BuildConfig values. You might be getting the commit hash for some verification/tracking purposes. Or updating any other value from the BuildConfig in a dynamic way.

It is quite possible that you cannot completely get rid of these values in all of your flavors, but you should ensure you use fixed values in your CI and in your development flavor for all the above examples and any other dynamic property that could change frequently during development.

However, failures are not the sole issue. It’s crucial to evaluate the effectiveness of any caching mechanism used. Why? Because storing and retrieving cache items introduces some overhead. Some tasks are so simple that caching them introduces negative savings, so it’s better to always run them. You can override any task behavior by setting specific conditions in task.outputs.doNotCacheIf(). There is a really good plugin, maintained by Gradle engineers, that automatically disables the most common Android tasks known to have negative savings.

In general you could get a lot of insights from your builds from the free Gradle build scans. However, if you want to go deeper you would need to use the paid Develocity (formerly Gradle Enterprise), which allows deeper analysis (particularly comparing two build scans to review what was the change in the inputs that triggered the task rerun instead of reusing it from the cache).

I would also encourage you to run the Gradle build validation scripts regularly to get a complete overview of your tasks’ cacheability and detect any regressions quickly.

Configuration Cache

You might be wondering if this is about configuring the cache… However, Configuration Cache is totally unrelated to Cache Configuration 😅.

In order to execute your task, Gradle needs to create a tasks graph to know what the dependencies of your project are, by evaluating your build scripts. Depending on your structure, how many plugins you have, and how big your project is, this process could be time-consuming.

Fortunately Gradle now can cache the outputs from this Configuration phase allowing us to reuse it and get some time back in the next builds. In general we had this enabled only for local builds, as due to the ephemeral nature of the CI builds it is most probably not worth wasting time caching this. You can do this easily by adding the following line to the gradle.properties file org.gradle.configuration-cache=true.

As with the previous cases, this is not as easy as enabling the flag. What a surprise, huh?

Initially, this necessitates adherence to really strict rules for your Gradle scripts, which you may not have been implementing (unless you’ve been keeping up with the latest Gradle best practices). Depending on how many customizations you have in Gradle you would need to invest a significant amount of time rewriting some tasks to follow the new rules that ensure that all tasks are really independent of each other and do not rely on “global” inputs that might change due to side effects.

With that you would be able to start using Configuration Cache. However, this might still not be enough. This one is funny, because usually we just care about Configuration Cache rules to not be violated, however it might happen that the way we define the tasks, makes them to be frequently invalidated in any case. Not leveraging this feature at all.

A good example of this happened to us quite recently. One of our custom tasks was ensuring that some lints run only in the modified code, so it was using as an input the result of git status. Therefore, anytime a new file was modified, conf cache got invalidated. Same happens if you are reading the head hash (maybe you want to keep it for some verification/tracking), or if you are counting the commits (for example to automate the versionCode). In all of these examples you will see some warning like:

Calculating task graph as configuration cache cannot be reused because output of the external process ‘git’ has changed.

There is another advantage of enabling this flag. This was one of the most surprising things that I learnt recently: Configuration Cache also allows tasks parallelization inside each module! Without Configuration Cache, even if your module has some independent tasks, they will always run serially.

I am not familiar with the Gradle internals, but Configuration Cache requires really strict rules to avoid access to the project settings during execution, so my guess is that this “safeguard” also guarantees that independent tasks are not changing any setting that would be required for other tasks, allowing Gradle to run them in parallel.

Conclusion

In this article, we’ve outlined the most effective strategies to significantly decrease your Gradle build times.

If you think about it, it’s all based on a few basic, yet powerful concepts: reusing what has already been done, and executing several tasks at the same time.

The beauty of this is that it all combines together: proper modularization allows better parallelization and caching/reusing more tasks; when you reuse most of your tasks, you have more cores available, so you have idle resources to parallelize at module level.

Even if the underlying concepts might be quite common, optimizing them requires constant dedication and a good understanding of their fundamentals. For this reason in Glovo we have a Mobile Platform team, monitoring and ensuring fast builds for the mobile projects both in the CI and locally; this way the product engineers can focus on delivering business value fast, with quick feedback loops, without worrying about their build time.

In a few days we will share a second part, reviewing some other important techniques and settings to ensure you keep your build times low, helping to speed-up the build duration. Stay tuned!

[UPDATE] Continue reading the second part in Accelerate Your Android Development: Essential Tips to Minimize Gradle Build Time (Part II of II)

--

--