· 6 min read Posted by Jigar Brahmbhatt
Optimizing Gradle Builds in Multi-module Projects
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!