This is a repository for experimenting with Android Build options. Different providers, build tools, and methods are used to showcase the different options available as well as best practices for workflow and performance. I have no intention of building an actual UX of any kind in this repository. This is where I catch and solve bugs that arise from the Android, Gradle, and Kotlin ecosystems or try out build concepts.
Every project's performance on G1GC vs ParallelGC seems to have slightly or significant characteristics. It is impossible to reliably test these algorithms with caching enabled due to the variances in network and IO bottlenecks, so I test these algorithms on clean builds with no caching. Android projects have a complicated memory footprint that can grow very quickly and as of JDK 17 most of the issues with G1GC have been fixed. This means that G1GC is the reliable option for returning memory and a good default for Android projects to stick with who aren't going to delve into JVM tuning. Also every JDK version since 17 has released iterations to improve upon G1GC to bring it closer and closer to Parallel's throughput performance levels. I still recommend testing GC algorithms on a case-by-case basis for JVM tuning.
Since we're on the GitHub actions free tier we have roughly 16GB of memory available in the worker. We therefore have plenty of resources available to us, but profiling shows we just don't use much in this build.
Read my article about SoftRefLRUPolicyMSPerMB in JVM Builds
Read my article about Metaspace in JVM Builds for my reasoning and approach metaspace.
Read my articles about CodeCache for JVM Builds and Kotlin JVM arg Inheritance & Defaults.
If your build does have an OOM and you want to analyze why it happened you're going to want the heap dump file. Of course you'd have to setup saving this file as a job artifact to make it accessible.
The full set lives in gradle.properties with inline commentary. The performance-relevant choices break down into three groups.
org.gradle.caching=true— the local (and, if configured, remote) build cache. Every task with deterministic outputs for the same inputs is skipped on subsequent builds.org.gradle.configuration-cache=truewithconfiguration-cache.problems=warn— caches the whole configuration phase, so an unchanged build graph skips configuration entirely.org.gradle.configuration-cache.parallel=true— loads config cache entries in parallel (Gradle 8.11+).
org.gradle.parallel=true— runs decoupled projects in parallel.org.gradle.unsafe.isolated-projects=true— Isolated Projects lets each project configure and produce tooling models in parallel, cached and invalidated independently. This is the ceiling that most "parallel sync" advice is chasing, and it is the reason the module build files here avoid cross-projectsubprojects {}/allprojects {}wiring — those blocks are incompatible with it.org.gradle.vfs.watch=true— file-system watching keeps change detection cheap between builds.kotlin.compiler.execution.strategy=daemonandkotlin.incremental=true— daemon reuse and incremental Kotlin compilation.
android.nonTransitiveRClass=true— each library'sRclass holds only its own resources, shrinking the generatedRclasses across a module graph.android.enableBuildConfigAsBytecode=truewithbuildFeatures.buildconfig=false— skips theBuildConfigsource-gen round trip.android.r8.maxWorkers=2— bounds R8 parallelism so it coexists with parallel Gradle tasks without over-committing memory. R8 full mode is the AGP 9.0+ default.android.lint.useK2Uast=trueand the disabledbuildfeatures(aidl, renderscript, resvalues, shaders) trim work this project never needs.
Kotlin compilation is configured once per module (see app/build.gradle.kts,
and once the module graph lands, the androidbuild.kotlin-common convention plugin):
languageVersionandjvmTargetare pinned from the version catalog (gradle/libs.versions.toml) rather than the developer's default JDK, so./gradlewbehaves identically across machines.freeCompilerArgsusesaddAll(not assignment) so plugin-contributed args — Compose, Metro,-Xcontext-parameters— survive alongside the project's-opt-inlist.coreLibraryDesugaringis enabled so the app can target a modern JDK while still running on the project'sminSdk.
This project includes a comprehensive CI setup that showcases typical automated checks with a focus on speed. I use a fan-in approach where it makes sense so the commit workflow on a PR quickly checks and provides as much feedback as possible on the change being tested. This is not overly resource intensive due to the combination of caching every part of the Gradle build process possible (build cache, dependency cache, and configuration cache) as well as the performance tuning. I'm also showing how to easily integrate with Emulator.wtf which is currently the fastest and most reliable Android UI test platform.
Build APK: Generates an artifact for debug build that could be shared within a development team and dependency for UI test job.
Build Base APK: Checks out source from base commit and builds a debug build artifact.
Build Test APK: Dependency for UI test job, no artifact.
Unit Tests: Regular Android JVM Unit tests.
Spotless: Performs all configured Spotless plugin checks. This mostly validates that copyright headers have been applied to source files.
Module Graph: Validates the module graph, checks that it adheres to the existing rules and limits the depth of the graph.
Android Lint: Runs an Android Lint check on the Release variant. Exports in HTML and SARIF formats.
Android UI Tests: Runs Build & Test APKs on Emulator.wtf.
Diff APK from Base: Uses Diffuse against the current and base APK artifacts and comments on the relevant PR.
studio.vmoptions: I've included a sample file in this repo with some decent options. Since this is not the only project I work on with Android Studio I set my heap size a bit higher, but otherwise it matches the Gradle & Kotlin Daemon JVM args.
The single biggest sync win is not a Gradle setting at all — it's an IDE one. Enable parallel model fetch under Settings → Build, Execution, Deployment → Gradle — it fetches each project's Tooling API model in parallel instead of serially. Block reported a ~57% reduction in sync duration from this one switch in their Shrinking Elephants writeup. It is experimental and can be less stable on very large builds, but for most projects it is a free win and pairs naturally with the Isolated Projects flag documented above.
Beyond that, sync cost scales with how many Gradle projects the IDE has to configure. The techniques for cutting that down at scale — project focusing, pre-compiled artifact substitution, and intransitive sync — are being adopted incrementally in this repo and tracked in docs/build-optimization-roadmap.md. Artifact substitution is live: see docs/artifact-swap.md for how unchanged modules are swapped for pre-compiled GitHub-Packages artifacts during IDE sync, and what that took on a Kotlin-DSL build.
The Spotlight Gradle plugin moves the project
includes out of settings.gradle.kts into a flat
gradle/all-projects.txt and computes the dependency graph by parsing
build scripts. To load only the projects you're working on into the IDE, list them in
gradle/ide-projects.txt (git-ignored, per-developer) and re-sync — Spotlight resolves their
transitive dependencies for you, so the IDE configures a focused subset instead of all 17 projects.
Install the companion IDE plugin to manage
the focus set from the UI. ./gradlew :checkAllProjectsList guards that no stray includes creep
back into the settings file.