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 aTask
type result. - Lazy: portrayed by the Configuration Avoidance API
tasks.register(...)
, postpone the creation and configuration of the task, returning aTaskContainer
type result. As a rule of thumb, leveraging theregister(...)
API minimizes unnecessary task configuration. For example, with the Android Variant mechanism, the Plugin may register both debug and release tasks; however, whenassembleDebug
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).
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 ofdependsOn(...)
, for instance, theclasses
task relies on thecompileJava
andprocessResources
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 Type | Input | Output |
---|---|---|
String or any class implementing JDK Serializable or dependency resolution result | Y | |
File | Y | Y |
Iterable file container, Iterable<File> such as FileTree FileCollection | Y | Y |
Map type file container, Map<String, File> | Y | |
Directory | Y | Y |
Iterable folder container Iterable<File> | Y | |
Map type folder container Map<String, File> | Y | |
JVM Classpath | Y |
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.
Annotation | Supported Attribute Types | Description |
---|---|---|
@Input | Any type that implements JDK Serializable or dependency resolution results | / |
@InputFile | File* | Single regular file input (not folder) |
@InputDirectory | File* | Single regular folder input |
@InputFiles | Iterable<File>* | Iterable type container containing file and folder input |
@OutputFile | File* | Single regular file output (not folder) |
@OutputDirectory | File* | Single regular folder output |
@OutputFiles | Map<String, File>* or Iterable<File>* | Iterable or Map type container containing file output |
@OutputDirectories | Map<String, File>* or Iterable<File>* | Iterable or Map type container containing folder output |
@Nested | Any type | Any 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). |
@Internal | Any type | Neither input nor output, only for internal use, and the change of this attribute does not affect the UP-TO-DATE check. |
@SkipWhenEmpty | File 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. |
@IgnoreEmptyDirectories | File 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. |
@PathSensitive | File 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. |
@Optional | Any type mentioned in the right side | Used 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. |
@Incremental | Provider<FileSystemLocation> or FileCollection | Used 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:
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 Map
s 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:
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:
withPropertyName
is utilized solely to allocate a unique identifier. However, when the task is actually executed, onlyoutputs.files
can be used to sift through and locate the target file.outputs.files
returns aFileCollection
instance, which typically invokes its member methodSet<File> getFiles()
to subsequently access all thejava.io.File
collections, that's why we saw a confusedoutputs.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:
Label | Description |
---|---|
No label or EXECUTED | Signifies 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-DATE | Shows 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-CACHE | Indicates that the Task doesn't need execution in this build as its state is retrieved from the Build Cache. |
SKIPPED | Denotes 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-SOURCE | Suggests 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.