Optimizing Large Android Project Builds : When Recommended Tunings Aren't Enough - Part 1

Lam PHAMLam PHAM
7 min read

Overview

In large Android projects—or any Gradle-based project—build speed is often a pain point. This is understandable because building a JVM-based application involves multiple steps, including compiling code and resources, minifying and optimizing the output, and assembling everything into the final artifact. These steps are inherently time-consuming, especially in large codebases with hundreds or even thousands of modules.

Gradle is the go-to build tool for these types of project. Over years, it has developed numerous of genius solutions to optimize build performance. Yet, even with all these features enabled, many app engineers still find themselves frustrated by sluggish build times. This is often where Gradle takes the blame—criticized for being overly complex without addressing developers’ expectation.

In this Part 1 of a 2-part series, we’ll explore why, despite benefiting all Gradle’s optimizations, your builds might still feel inefficient. And from there, in Part 2, we will dive into what we could do to actually improve your builds even further.

Why recommended tunings don’t often help?

When searching for ways to optimize a Gradle-based build— whether for Android project or in general— you will often come across these typical suggestions, commonly found in Gradle’s official documentation or shared through articles, blog posts, and conference talks:

  • Update to the latest tools (Gradle, AGP, JDK, Java/Kotlin and so on) version.

  • Enable build cache: org.gradle.caching=true

  • Use incremental build.

  • Use compilation avoidance.

  • Enable parallel execution: org.gradle.parallel=true

  • Enable parallel test.

      tasks.withType<Test>().configureEach {
          maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1)
      }
    
  • Enable Gradle daemon: org.gradle.daemon=true

  • Increase heap size: org.gradle.jvmargs=-Xmx...M

  • Enable some specific Garbarge Collectors, for i.e: parallelGC org.gradle.jvmargs=-XX:+UseParallelGC

  • Fine-tune JVM options

  • and so on

These solutions look appealing— one single line and your builds are supposed to be supercharged. What more could we ask for? Here comes the very common mistake: many developers do no more than appending these lines into there gradle.properties file and expect a dramatic speedup. The hard truth is, while these options are genuinely effective, we won't often see the build performance change much.

Just because you enabled it doesn’t mean you made a difference

In new versions of Gradle, most of the above options are enabled by default. In other word, explicitly adding them— without any deeper tuning— usually doesn’t make any changes. That’s one of the main reasons why your build time often remains unchanged.

And when you think about it, it makes perfect sense. If simply pasting a few lines could boost your builds, wouldn’t Gradle just enable them out of the box?

The only benefit of adding these lines is to give you the confidence that you haven’t missed anything. Ever found yourself hitting CTRL + C dozens of times before finally pressing CTRL + V? Yeah, it’s kind of like that.

One size doesn’t fit all

The majority of Gradle solutions will have an impact in many types of projects. However, some are tailored for specific systems, based on factors like project size, system requirements (e.g, memory efficiency, low latency, high throughput etc.), team size or other considerations.

Let’s take a look at the Gradle official documentation, it recommends running Java compilation in a separate process. However, a crucial information is tucked away at the very end of the section:

Forking compilation rarely impacts the performance of small projects. But you should consider it if a single task compiles more than a thousand source files together.

Or in case of the ParallelGC, Google suggests experimenting and testing with the JVM parallel garbage collector. The emphasis on "experiment" is important—GC performance can vary greatly depending on the specific project. That’s why Google recommends testing it first. However, this nuance is often overlooked, and people tend to misinterpret the advice as simply “use the JVM parallel garbage collector.”

When desperately looking for a quick solution, who really has the patience to read all the way through—especially when a shiny code snippet, seemingly the holy grail to all your problems, has already got your focus?

Tuning isn’t about maxing out settings

I’ve seen many blogs advising to allocate more heap size— which is completely a great suggestion, except they often specify a magic value (usually 4gb) and don’t give any further explanation.

org.gradle.jvmargs=-Xmx4g

Gradle puts 2gb in its recommended code snippet.

In a regular JVM, bigger heap could indeed help reduce garbage collection pause-time, increase application throughput, reduce latency and everything. However, for a given system, increasing the heap size eventually stops yielding benefits. It's also important to keep in mind that device memory sets an upper limit.

Therefore, an efficient max heap size really depends on many factors: the project size, the libraries used (e.g, Robolectric is famous for being memory-hungry), the device memory, the environment: either it’s a local build where you don’t want to sacrifice all your device’s memory to the builds, because you still want your browser to play music— or a CI build in a Linux agent where you can allocate all available memory, but you will have to watch out for the OOM Killer, which can terminate your processes at any time.

That said, allocating too little memory can slow down the build process, while giving too much doesn’t always make an impact— and in certain conditions, it could backfire the builds.

The most important thing is that these articles, blogs, talks shouldn’t be the primary source for defining performance metrics in your own system.

Notes: You may assume that hitting the upper limit (the device memory capacity) is rare. However, in practice, the max heap size is often well below from the total memory the OS allocates to your build at any given time. For instance, on a machine of 32GB of RAM, you may think it’s safe to set the max heap size to 16GB. However, that heap only represents the heap of the main process— typically the Gradle daemon. Your builds may also spin up other processes, such as Kotlin Daemons or Gradle Workers, which collectively can push the peak memory usage much higher and close to the upper bound 32GB.

Not all build flags are plug-and-play—some need your code to meet specific criteria.

Gradle build cache, one of the Gradle greatest features, functions based on a task’s inputs and outputs. If inputs stay unchanged, the task is skipped and cached outputs are reused. However, if you create a task without any inputs or outputs, or inputs are different between builds even though there’s no updates, build cache won’t work.

You may say you never create any custom Gradle tasks in your project. Well, your libraries certainly do!

The same principles apply for some other features such as compilation avoidance (this depends mostly on the languages like Groovy/Java/Kotlin, not Gradle) where you must write your production code in a way that the compiler could skip recompiling as much code as possible.

Therefore, enabling these options without crafting your production and build code won’t yield fruitful results.

Wrong expectation

Sometimes, you just have high expectations for what these options could deliver.

Configuration Cache is another piece of engineering by Gradle. As of the time of writing, it’s still in active development, and not yet enabled by default. Its fundamental is similar to Build Cache, but it targets the configuration phase. If you're hoping it will drastically cut down your build time, like Build Cache does, you're likely to be disappointed.

In general, configuration phase takes up only a small portion of the Gradle build lifecycle. This duration varies based on the complexity of the project and the tasks you invoke, however, it typically ranges from hundreds millisecond— for small projects, to seconds even a few minutes—for large ones. Hence, compared to the execution phase—which can take dozens of minutes or even hours, improvement from configuration caching is often hard to notice.

This is not to say Configuration Cache isn’t worthwhile. It is definitely valuable, especially in large project builds. However, setting wrong expectations would lead to disappointment and misunderstandings about the feature.

Part 1 wrapping up

All the previous points are not meant to deny the great benefits of the recommended tunings. Simply enabling them (which, by the way, are often on by default) could help optimize 70% of your builds. However, the remaining 30% tends to be the most complex and requires a deeper, more targeted approach to truly improve. In small projects, this part is often negligible and has little impact on developers’ productivity. The real problem arises as the projects grow larger and larger.

In part 2, we will dive into some key strategies to tackle that remaining 30%, backed by some real-world examples.

Disclaimer: This 70-30 split is not based on any hard analysis or data. It's just a metaphor, inspired from this blog about AI-assisted coding tools to convey the idea.

6
Subscribe to my newsletter

Read articles from Lam PHAM directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Lam PHAM
Lam PHAM