We build our software with a focus on Continuous Integration and quick deployment lifecycles. Naturally, speed is a critical component, allowing faster iterations and quick feedback.
Going from a 20 minute build to a 11 minute build time on our CI servers makes a dramatic difference in day-to-day work on our android app. The quick build time helps us stay focused and sharp. We've still got a long way to go until we get to a build time we're happy with, but we've made some good steps in the right direction.
How we build our Android app
We use Gradle, which is a standard build tool for Android projects. Here is what our Android build pipeline looks like:
- Make some cool changes
- Build an
apk
locally to test things on the Android emulator/test devices- It takes a significant amount of time to build the sub-modules, wait for the apk to compile, etc
- We sped this up by using a Gradle daemon process. This is a standard practice for local gradle dev environments that everyone should follow
- Push changes to git
- Jenkins polls for changes and starts building the commit. This includes the following targets:
- Clean something like
./gradlew clean
- Assemble Tests — some flavour of assemble that will help us run the tests
- Assemble Remote — some flavour of assemble that can run on a remote testing platform (we use Google's gcloud)
- Run Unit Tests
- Assemble Release — a production/beta ready assembly flavour. This includes tasks such as Proguard shrinking, zipAligning, minification, etc
- Clean something like
- Finally, a new Android
apk
bundle is ready for rollout
Fine-tuning the build performance step-by-step:
Don't spawn multiple new gradle JVMs [Easy]
Don't spawn multiple gradle JVMs during the make
process for different gradle targets. This step gave us a pretty decent speed-up shaving almost 5 minutes off our build time. Initially, writing the Makefile
with a non-gradle mindset lead us to separating the tasks in a generic way. Here is what it looked like before:
print-dependency-tree:
./gradlew app:dependencies
test-unit:
./gradlew check assembleIntegrationtest
./gradlew assembleAndroidTest
./gradlew test
test-integration-remote:
gcloud beta test android run --project <projectname> <other opts>
test: test-unit test-integration-remote
build: clean print-dependency-tree test
./gradlew assembleJenkins
Note that gradle is invoked multiple times with different targets in each of these sub-steps. Invoking make build
would end up calling ./gradlew
6 times as it went through each of the sub-targets in the make process. This spawns a new JVM 6 times during the entire process.
Gradle will print a message like this every time a new JVM is spawned:
To honour the JVM settings for this build a new JVM will be forked.
To fix this, compose the make targets in a way that minimizes the number of JVMs that get spawned:
build-without-predex: clean
./gradlew check print-dependency-tree assembleIntegrationtest assembleAndroidTest test assembleJenkins -PpreDexEnable=false
build-ci: build-without-predex test-integration-remote
./publish
Now, a new Gradle JVM is spawned only once because we have chained successive gradle tasks in a single invocation.
Avoid building the project's modules every time [Difficult]
We are experimenting with using an internal artifactory server where we can host our Android project's modules. These modules don't really change that often, so building them once and using an artifactory server as a cache speeds up both local & CI server builds significantly. This is a really good guide for using Artifactory to host your modules. This sped up our build by another 2-3 minutes.
Use the latest version of Gradle [Easy]
Gradle 3.3 was recently released and it includes a good number of performance improvements. So we shifted to 3.3 and saved some time off our builds. To do this, you should update the gradle wrapper version reference in your top-level build.gradle config:
task wrapper(type: Wrapper) {
gradleVersion = '3.3'
}
Understanding the gradle lifecycle is helpful for some further tuning.
Following this really nice guide written by the Gradle folks, these were some key take-aways for us:
These steps helped us shave off another few minutes from our build times.
Keep the bare minimum set of repositories in your config
Gradle will scan repositories in declaration order to find what it needs so keep the most highly populated repositories up top and remove the unused ones.
Use the Build parallel option
Add org.gradle.parallel=true
to your gradle.properties.
Disable Pre-dexing on CI servers
This one is a bit of a hidden gem. Since our CI build server (Jenkins) doesn't run a Gradle daemon to ensure a clean build environment for every run, disabling pre-dex is a good idea. Predexing is mainly used to speed up incremental builds.
Once you add the snippet into your build.gradle at the top level, you can then make a new build target in your Makefile
for example:
build-ci:
./gradlew check assembleIntegrationtest assembleAndroidTest test assembleJenkins -PpreDexEnable=false
and then you can use this target during your CI server builds as make build-ci
.
Conclusion:
With all these optimizations, our build times went from around 20 minutes to about 11 minutes on our CI servers & down to about 1 minute from a little more than 4 minutes for local builds.
Caveat: Our local builds usually don't include running the remote integration tests.
Relevant xkcd: