· 6 min read Posted by Jigar Brahmbhatt

Optimizing Gradle Builds in Multi-module Projects

Explore practical use cases and strategies for speeding up Gradle builds in your multi-module projects.
Image from https://www.flickr.com/photos/chberge/3803475294
Credit: Image from https://www.flickr.com/photos/chberge/3803475294

You’re not alone if you’ve also struggled with sluggish Gradle builds in a multi-module project. Recently, we undertook the challenge of significantly reducing build times for a client with over 100 Kotlin Multiplatform modules, and achieved a more than 50% boost in speed across platforms. In this post, we’ll walk you through the steps we took. We hope it proves valuable to fellow developers facing similar challenges.

Benchmark Your Builds

Before diving into optimizations, it’s crucial to understand the baseline. Gather build times from all team members, ensuring the Gradle build cache is disabled. Use handy --no-build-cache option to any Gradle task command to run without build cache. Unsurprisingly, in our case, the Intel-based mac machines were really slow compared to Apple chip ones and we didn’t have anyone with a Windows machine. This information is key for later comparison and improvement assessment.

Note: One thing we realized that, some optimizations below might be more applicable to legacy or tech-debt-laden projects.

Tools for Insight

Gradle Build Scan

Utilize the power of Gradle Build Scan to delve into the details of your builds. In my opinion, it’s not just a diagnostic tool; it’s a learning tool.

In our case, we uncovered insights into the sluggishness of ios link tasks in the Kotlin Multiplatform setup. Comparing build scans on different CI machines helped identify the fastest configuration for a machine to use on our CI. Enabling parallel builds and analyzing the results through build scan reports further validated our improvements.

Android Studio Build Analyser

Android Studio provides a built-in analyser tool. It inspects build performance and provides warning around potential issues.

Disable Jetifier

Using this, we noticed the lingering android.enableJetifier=true usage due to outdated Picasso library that didn’t use AndroidX libraries. That might have hindered build speed too. Addressing this involved updating Picasso and removing the jetifier flag from gradle.properties.

MultiDex

Through the Build Analyser, we also found unnecessary use of multiDexEnabled in few modules along with some old Gradle configurations. MultiDex was unnecessary with minSdkVersion as 21.

Dependency Visibility

We observed a significant number of modules relying on other modules via api dependency. The problem with having api dependencies is that Gradle would recompile them when implementation details change. It’s because they appear on compile classpaths. This can have significant ripple of recompilations in a multi-module setup and affect the build times.

To mitigate this, we shifted to using implementation for most dependencies, reserving api only for those exported as part of XCFramework.

Enable Gradle Parallel Execution

In a multi-subproject setup, Gradle may benefit greatly from parallel execution. With the shift from api to implementation, enabling parallel execution further slashed build times.

Mindful Use of External Gradle Plugins

Some Gradle plugins can be the culprits behind slower builds. Our project had a couple of Gradle plugins like that. They used to execute on every sync, every task and run through all the modules in the project.

Scrutinize third-party plugins before integration, especially in a multi-module setup. Avoid unnecessary global application using subproject or allprojects blocks; apply plugins only where needed.

Unnecessary KMP targets

When managing Kotlin Multiplatform (KMP) projects, it’s crucial to evaluate the necessity of added KMP targets. In our experience, we found that inadvertently adding targets that aren’t required for the project can introduce inefficiencies.

For instance, we had inherited some modules with added JVM targets for certain KMP modules from older test modules. Over time, more modules were introduced, and the JVM target persisted without any actual use. This led to unnecessary JVM-related build and test tasks executing during the build phase.

To address this, we recommend reviewing your KMP setup and removing any targets that don’t contribute to the project’s functionality. This not only streamlines the build process but also reduces unnecessary overhead, especially in a large multi-module project like ours.

Build only required XCFramework

In Kotlin Multiplatform (KMP) projects with iOS components, the iOS build task can be a significant contributor to extended build times. Often, KMP project script files include configurations for multiple iOS architectures using iOSX64(), iosArm64(), and iosSimulatorArm64() targets, or the shorthand ios() that enables both iosArm64 and iOSX64.

However, it’s essential to optimize this setup, especially considering that building for unnecessary architectures can consume substantial time and resources. For example, even if your local development machine has an ARM architecture, building the X64 framework unnecessarily adds to build times, and vice versa for other architectures.

Generally speaking, if things work on one architecture, then it’s high likely that it would work on other architectures too. So it’s fine if you at least keep building only one architecture locally. One approach is to introduce a boolean flag in your gradle.properties file, creating custom logic to enable a specific iOS target.

Are you on a team where not everybody calling the Kotlin code wants or needs to build it locally? We’ve built KMMBridge, a tool to help streamline the iOS dev flow in KMP.

Be Careful with Custom Gradle Tasks

While creating custom Gradle tasks might seem like a convenient way to handle common requirements, it’s crucial to exercise caution, especially in a multi-module setup. In our experience, some custom tasks had unintended consequences on build times.

For instance, we had custom tasks responsible for copying resources to another folder, but they ran across all modules, resulting in a noticeable slowdown during the build. To address this issue, we reconsidered our approach and opted for a different strategy to achieve the desired outcome without compromising build efficiency.

The key takeaway is to ensure that custom tasks are appropriately configured to avoid unnecessary repetition across modules or tasks

Keep build tools up-to-date

Always stay current with Android Studio, Android Gradle Plugin, and Gradle itself. Each iteration brings improvements, potentially enhancing build speed.

AGP 8.+

Upgrade to Android Gradle Plugin 8.0.0 for default behaviors that optimize builds (i.e. non-transitive R classes), especially for apps with multiple modules.

Configuration Cache

Newer build tools have better support for configuration cache. It seems like a very promising feature that can drastically improve build speeds in certain cases.

While configuration cache is a promising feature for faster builds, ensure compatibility with all your tools. As of Kotlin 1.9.10, the Kotlin Multiplatform plugin still lacks full support, limiting its effectiveness.

Kotlin 1.9.20 is supposed to being full support for configuration cache. We’re pretty pumped about it.

Additional Considerations

  • Ensure Gradle Build Cache is enabled.
  • Amount of code can be overwhelming in Gradle files with multi-module setup. Simplify Gradle scripts using the Gradle convention plugin to minimize duplicate configuration.

By following these steps, you may turbocharge your Gradle builds, regardless of the project size or complexity. Happy coding!