Skip to content
Book Cover of Extending Android Builds

2-6: Gradle Task Essential ​

In this section, we will illuminate the core principles of Gradle tasks, task classification, input-output definitions, and state analysis. While we won't cover every facet of Gradle tasks in this section, our goal is to clarify their pivotal components, enhancing your comprehension of task internals for the ensuing chapters.

The source code for this section is available in the task-essentials project.

2-6-1: Task Classification ​

Gradle spawns two types of task registration:

  • Instant: symbolized by tasks.create(...), instantaneously generate and configure a Task type result.
  • Lazy: portrayed by the Configuration Avoidance API tasks.register(...), postpone the creation and configuration of the task, returning a TaskContainer type result. As a rule of thumb, leveraging the register(...) API minimizes unnecessary task configuration. For example, with the Android Variant mechanism, the Plugin may register both debug and release tasks; however, when assembleDebug is typed in, only debug related tasks will proceed to configure and execute.

Thus, tasks.register(...) is the recommended way for all scenarios.

In the *.gradle.kts script, you might also notice the subsequent Proxy Creation patterns, which essentially simplify the aforementioned code styles (the nature of the created task remains the same).

Kotlin
val myCheck by tasks.registering {
  doLast {...}
}

Certain tasks also bear distinct traits in sequencing or executing target:

  • Finalized Tasks: If task A tends to yield unpredictable errors, we can assign a finalizedBy(taskB) to it, ensuring B consistently executes after A, perfect for resource clean-up.
  • Lifecycle Tasks: These tasks have no Task Action, yet conventionally serve as common building or testing anchors. They act as pure pivot points in the task DAG (Directed Acyclic Graph) to sequence the entire graph, like compileXxxx, buildXxxx, assembleXxx, etc. This design synchronizes with the backward link of dependsOn(...), for instance, the classes task relies on the compileJava and processResources tasks.

2-6-2: Task Input and Output ​

Back in Section 2-4, we highlighted the importance of defining inputs and outputs when authoring tasks. Gradle employs these to determine the task state and ascertain if the task should run at this build. For Task caching to work, an input is optional while output is compulsory; Tasks lacking output can cripple the cache mechanism, leading to complex scenarios, as will be discussed in Section 3-2.

There are eight categories for Gradle Task input and output parameters. The following types are generically referenced, such as java.io.File, we also have Gradle's FileCollection and FileTree in similar usage.

To be noticed, our introduction and examples focus on original data types and file APIs like JDK java.io.File (which is not the best practice but easy to understand before we introduce more Gradle File containers), excluding other Gradle APIs and the Provider lazy types. For more details, refer to Section 4-2.

Parameter TypeInputOutput
String or any class implementing JDK Serializable or dependency resolution resultY
FileYY
Iterable file container, Iterable<File> such as FileTree FileCollectionYY
Map type file container, Map<String, File>Y
DirectoryYY
Iterable folder container Iterable<File>Y
Map type folder container Map<String, File>Y
JVM ClasspathY

There are five acceptable input types and six acceptable output types. These parameter types should align with relevant annotations in practical usage, specifying whether it is an input or output, whether the parameter is plural (multiple files, folders), and if nesting is optional, etc. The subsequent table only extracts commonly referenced annotations (within this book) from the document.

AnnotationSupported Attribute TypesDescription
@InputAny type that implements JDK Serializable or dependency resolution results/
@InputFileFile*Single regular file input (not folder)
@InputDirectoryFile*Single regular folder input
@InputFilesIterable<File>*Iterable type container containing file and folder input
@OutputFileFile*Single regular file output (not folder)
@OutputDirectoryFile*Single regular folder output
@OutputFilesMap<String, File>* or Iterable<File>*Iterable or Map type container containing file output
@OutputDirectoriesMap<String, File>* or Iterable<File>*Iterable or Map type container containing folder output
@NestedAny typeAny custom data type, and the attributes inside must be marked with any annotation supported in the full version of the table (refer to the official document).
@InternalAny typeNeither input nor output, only for internal use, and the change of this attribute does not affect the UP-TO-DATE check.
@SkipWhenEmptyFile or Iterable<File>*Used with @InputFiles or @InputDirectory, indicating that when the input is empty, the execution may be skipped (but the container exists, not null) - When all inputs of the current Task are empty, skip the execution and assign the Task a NO-SOURCE status.
@IgnoreEmptyDirectoriesFile or Iterable<File>*Used with @InputFiles or @InputDirectory, indicating that when there are other new, deleted empty folders in the input folder, they will be directly ignored and will not cause the Task cache to fail.
@PathSensitiveFile or Iterable<File>*Used with the above input-related annotations, to tell Gradle the path change sensitivity level of the input file (folder), a PathSensitivity enumeration instance needs to be passed in, such as @PathSensitive(PathSensitivity.NAME_ONLY), and the four sensitivity descriptions are as follows:
1. ABSOLUTE: Consider the complete path, any path changes (even if the content is not modified) will trigger the cache to fail (OUT OF DATE).
2. RELATIVE: Consider the file name, root folder, and internal file relative relationships.
3. NAME_ONLY: Consider the file name change only.
4. NONE: Ignore all related path changes and only consider the final content.
@OptionalAny type mentioned in the right sideUsed with the following six annotations: Input, InputFile, InputDirectory, InputFiles, OutputFile, OutputDirectory, it will skip the file related verification, and can also be correctly executed without passing in.
@IncrementalProvider<FileSystemLocation> or FileCollectionUsed with @InputFiles or @InputDirectory, telling Gradle to track the latest changes of the file attribute, and get the change details through InputChanges.getFileChanges(), see Section 4-5 Incremental Build.

By synthesizing the information from two tables, we can discern certain associations. For instance, in accordance with the output annotation type, there is no @Output annotation. Instead, we have four file-centric annotations: @OutputFile, @OutputFiles, @OutputDirectory, and @OutputDirectories. These annotations correspond to the parameter type's "output cannot use an ordinary serialized data type" condition. For unfeasible input and output types, a workable alternative can be employed, for instance, we can exemplify this by using InputFiles coupled with FileTree to mimic input directories.

As of Gradle version 7.4, additional input and output annotations like Classpath, CompileClasspath, Destroys, LocalState, Console, ReplaceBy, NormalizeLineEndings, and more are accessible. Readers are encouraged to consult the most recent Gradle documentation for updated information.

* The general File support types include CharSequence, File, Path, URI, alongside more Gradle file types delineated in Section 4-2, other Provider wrapper types, Groovy closures, Kotlin Lambdas, and so on. For a comprehensive list, please refer to the current API documentation for Project.file(java.lang.Object) and Project.files(java.lang.Object). From my perspective, this is a pragmatic solution to the early API design of Gradle, supplanting the method overload with an Object parameter, thereby obfuscating the supported types for developers through the method's signature and the IDE's autocompletion.

Next, we will explore the utilization of the aforementioned supported types via a simple example:

Kotlin
abstract class TaskEssentialsTask : DefaultTask() {

  /*Inputs*/
  @get:Input
  var inputString: String = ""

  @get:InputFile
  var inputFile: File? = null

  @get:InputFiles
  var inputFiles: List<File>? = null

  @get:InputFiles
  abstract val inputFileCollection: ConfigurableFileCollection
  
  // This is not supported by Gradle
  //  @get:InputFiles
  //  var inputFileMap: Map<String, File>? = null

  @get:InputDirectory
  var inputDir: File? = null


  /*Outputs*/
  @get:OutputFile
  var outputFile: File? = null

  @get:OutputFiles
  var outputFiles: List<File>? = null

  @get:OutputFiles
  abstract val outputFileCollection: ConfigurableFileCollection

  @get:OutputFiles
  var outputFileMap: Map<String, File>? = null

  @get:OutputDirectory
  var outputDir: File? = null

  @get:OutputDirectories
  abstract val outputDirCollection: ConfigurableFileCollection

  @get:OutputDirectories
  var outputDirMap: Map<String, File>? = null

  @TaskAction
  fun apply() {
    inputFile!!.copyTo(outputFile!!)

    inputFiles!!.forEachIndexed { i, file ->
      file.copyTo(outputFiles!![i])
    }

    val outputCollection = outputFileCollection.files.toList().sorted()
    inputFileCollection.files.toList().sorted()
      .forEachIndexed { i, file ->
      file.copyTo(outputCollection[i])

      // Because 'inputFileMap' is not supported by Gradle, 
      // the copy logic of outputFileMap is also put into 
      // the process of FileCollection
      file.copyTo(outputFileMap!![i.toString()]!!)
    }

    inputDir!!.copyRecursively(outputDir!!)

    val outputDirCollection = outputDirCollection.files.toList()
    outputDirCollection.forEach {
      inputDir!!.copyRecursively(it)
    }

    for (i in 0 until outputDirMap!!.size) {
      inputDir!!.copyRecursively(outputDirMap!![i.toString()]!!)
    }
  }
}

To pare down the example, we have limited the Task Action to simply copying from input to output. Along with the pure java.io.File file type, we are able to perform an output test for single files, lists of files, and file Maps among others.

Moreover, Gradle not only has the static input and output annotations stated above, but it can also handle situations where input and output are added to existing tasks dynamically during configuration. In the following example, we employ Gradle's Task Runtime API to incorporate an extra set of input and output to a Task that originally had no input:

Kotlin
abstract class NoInputTask : DefaultTask() {
  @get:OutputFile
  var final: File? = null
  
  @TaskAction
  fun writeFile() {
    final?.writeText("Dummy Text")
  }
}
...
project.tasks.register<NoInputTask>("noInputTask") {
  final = File(project.buildDir,
    "outputs/no-input-task.txt")

  inputs.file(File(project.projectDir, 
    "inputs/single-file.txt"))
    .withPropertyName("injectedInput")
    .withPathSensitivity(PathSensitivity.RELATIVE)
    .skipWhenEmpty()
  inputs.property("custom-key", "custom-value")
  
  outputs.file(File(project.buildDir, 
    "outputs/injected-output.txt"))
    .withPropertyName("injectedOutput")

  doLast("injectedAction"){
    val i = inputs.files.singleFile
    val o = outputs.files.files.find { it.name == "injected-output.txt" }!!
    i.copyTo(o)
    o.appendText(inputs.properties["custom-key"].toString())
  }
}

We leveraged Task#inputs and Task#outputs to retrieve the TaskInputs and TaskOutputs objects, and then call related APIs, akin to static annotations, to infuse additional outputs into an existing Task. Finally, we append a Task Action to demonstrate how to fetch and utilize the extra properties by files.find(...). Here are a couple of puzzling points clarified:

  1. withPropertyName is utilized solely to allocate a unique identifier. However, when the task is actually executed, only outputs.files can be used to sift through and locate the target file.
  2. outputs.files returns a FileCollection instance, which typically invokes its member method Set<File> getFiles() to subsequently access all the java.io.File collections, that's why we saw a confused outputs.files.files.

We will further see the importance of such runtime APIs in the AGP hook Tasks within the design of the Polyfill toolkit in Section 3-6.

2-6-3: Task Status ​

For each Gradle build, Tasks do not invariably run. They can exist in one of the following five states, including EXECUTED:

LabelDescription
No label or EXECUTEDSignifies that the Task was executed in this build. In the absence of caching or cache invalidation, the two tasks described at the start of this section will appear in this state:
1. Ordinary tasks with Task Action
2. Lifecycle tasks (without Task Action)
UP-TO-DATEShows that the Task doesn't need execution in this build as it is up-to-date.
1. For ordinary Tasks with TaskAction, UP-TO-DATE is displayed when the input remains unchanged and the output is neither deleted nor altered
2. All dependencies of the Lifecycle Tasks are in the UP-TO-DATE state
FROM-CACHEIndicates that the Task doesn't need execution in this build as its state is retrieved from the Build Cache.
SKIPPEDDenotes that the Task doesn't require execution in this build. That is, it's marked as excluded(...) or the evaluation result of onlyIf(...) is false.
NO-SOURCESuggests that the Task doesn't need to be run in this build, even though its inputs and outputs have already been defined. But they are marked with the @SkipWhenEmpty annotation, and the real value is empty (like an empty folder).

It's straightforward to grasp the logic behind states 1, 3, and 4 from the table. States 2 and 3 represent two levels of cache in the Gradle execution phase, specifically Incremental Build (or UP-TO-DATE Checks) and Build Cache. For further information on Task caching and related runtime APIs, refer to Sections 4-5.

2-6-4: In Summary ​

Mastering the definition as well as the inputs and outputs of typical Tasks is an essential skill for writing Plugins. But it's not necessary to memorize the above terms word for word. Instead, work with a lot of cases to solidify what you know and get hands-on experience connecting problem scenarios with their answers.