Cacheable Custom Gradle Tasks

Cacheable Custom Gradle Tasks

As software developers, we are always looking for ways to optimize our build processes and make them as efficient as possible. As an Android engineer specifically, this often means interacting with Gradle and the tasks within its task graph to optimize your build. This problem becomes especially challenging when you define custom Gradle tasks for your project and want to ensure they play nicely inside Gradle's caching mechanism so that extra work isn't done every build.

Have you ever noticed the output of a build, how some tasks have UP-TO-DATE next to the task and some don't? The Gradle build system provides a powerful mechanism for defining and executing custom tasks. However, when we define a custom task, it is often necessary to ensure that it only runs when something has changed. If a task is executed every time we build our project, it can significantly slow down our build process and waste valuable time.

Usecase for a Custom Gradle Task

On a recent project I was working on, there was a bash script that was executed before every commit. Git's pre-commit hook was utilized for this task and it worked well. However, for this to work on a developer's machine, they had to manually symlink the bash script to the pre-commit hook.

import org.gradle.api.Plugin
import org.gradle.api.Project

class PreCommitPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            tasks.register("installPreCommitHook") {

                doLast {
                    exec {
                        // make the bash script executable
                        commandLine("chmod", "+x", "../pre-commit.sh")
                    }
                    exec {
                        // sym link it to the the pre-commit hook
                        commandLine("ln", "-s", "-f", "../../pre-commit.sh", "../.git/hooks/pre-commit")
                    }
                    exec {
                        // make the symlink executable
                        commandLine("chmod", "+x", "../.git/hooks/pre-commit")
                    }
                }
            }

            // execute every time `preBuild` runs
         tasks.getByPath(":app:preBuild").dependsOn("installFormattingPreCommitHook")
        }
    }
}

On a first pass, the custom Gradle plugin I wrote worked very well. However, after a few weeks in the wild, one of the developers pointed out to me that the task was running every build. Using Android Studio's Build Analyzer, I could confirm it was indeed running every build when it didn't necessarily need to.

(Of course, in the grand scheme of things, <0.1s isn't a huge concern, but an app's poor build time is often the result of a death by a thousand cuts)

To avoid this, we need to define the outputs for our custom Gradle tasks so that they are cacheable. This means that if the inputs for a task have not changed since the last time it was run, Gradle can skip the task and use the cached output instead. This can save us a lot of time and speed up our builds.

For this particular use case, the output of the task is the existence of the pre-commit hook. To define the outputs for a custom Gradle task, we need to use the outputs property of the Task object. We can define the output files, directories, or even properties that our task generates. Here is an example of how to define the output files for our custom task:

val gitHookPreCommitFile = file("../.git/hooks/pre-commit")
outputs.file(gitHookPreCommitFile.absolutePath)

By defining the outputs for our task in this way, Gradle can automatically check whether the input file has changed since the last time the task was run. If the input file has not changed, Gradle can skip the task (and mark it UP-TO-DATE) and use the cached output file instead. This can significantly speed up our builds and make our build process more efficient.

In addition to defining output files, we can also define output directories and properties for our custom Gradle tasks. This allows us to cache more complex data structures from our custom Gradle tasks and avoid executing our tasks unnecessarily.

In conclusion, defining outputs for custom Gradle tasks is an important step in optimizing our build processes. By doing so, we can make our tasks cacheable and avoid executing them unnecessarily on every incremental build. This can save us time and make our build process more efficient by not wasting CPU cycles on unnecessary tasks.

Full source code:

import org.gradle.api.Plugin
import org.gradle.api.Project

class PreCommitPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            tasks.register("installPreCommitHook") {

                // define outputs of this task so that it is not executed every time
                val gitHookPreCommitFile = file("../.git/hooks/pre-commit")
                outputs.file(gitHookPreCommitFile.absolutePath)

                doLast {
                    exec {
                        // make the bash script executable
                        commandLine("chmod", "+x", "../pre-commit.sh")
                    }
                    exec {
                        // sym link it to the the pre-commit hook
                        commandLine("ln", "-s", "-f", "../../pre-commit.sh", "../.git/hooks/pre-commit")
                    }
                    exec {
                        // make the symlink executable
                        commandLine("chmod", "+x", "../.git/hooks/pre-commit")
                    }
                }
            }

            // execute every time `preBuild` runs
            tasks.getByPath(":app:preBuild").dependsOn("installPreCommitHookmai")
        }
    }
}