Custom Tasks erweitern Gradles Funktionalität für projekt-spezifische Build-Anforderungen. Ein gut implementierter Task kapselt eine spezifische Build-Operation, deklariert Inputs und Outputs explizit und integriert sich nahtlos in Gradles Execution-Model. Die Basis-Klasse DefaultTask bietet die notwendige Infrastruktur, während abstrakte Properties moderne Configuration-Patterns ermöglichen.
Die grundlegende Struktur eines Custom Tasks folgt etablierten Konventionen:
abstract class DocumentationTask : DefaultTask() {
@get:InputDirectory
@get:SkipWhenEmpty
abstract val sourceDirectory: DirectoryProperty
@get:Input
abstract val documentTitle: Property<String>
@get:OutputFile
abstract val outputFile: RegularFileProperty
init {
description = "Generates documentation from source files"
group = "documentation"
}
@TaskAction
fun generate() {
val sources = sourceDirectory.get().asFile
val title = documentTitle.get()
val output = outputFile.get().asFile
// Task-Logik
processDocumentation(sources, title, output)
}
}Die Verwendung abstrakter Properties ermöglicht Gradle die Injection von Property-Instanzen. Diese Injection erfolgt zur Task-Creation-Zeit und eliminiert Boilerplate-Code für Property-Initialisierung. Die Properties nutzen Gradles Provider-API für lazy Configuration und automatisches Task-Wiring.
Der Task-Constructor sollte keine Heavy-Lifting-Operations durchführen. Initialisierung von Properties, Logging oder Netzwerk-Zugriffe gehören nicht in den Constructor. Diese Operationen verzögern die Configuration-Phase unnötig und brechen Configuration Caching. Stattdessen erfolgt Initialisierung lazy in der Task-Action oder über Provider.
Input/Output-Annotations sind fundamental für Gradles Incremental Build-System. Sie definieren, was ein Task konsumiert und produziert, ermöglichen Up-to-Date-Checks und etablieren implizite Task-Dependencies. Die korrekte Annotation ist entscheidend für Build-Performance und Korrektheit.
Die wichtigsten Input-Annotations und ihre Verwendung:
abstract class ComprehensiveTask : DefaultTask() {
@get:Input
abstract val version: Property<String> // Einfacher Wert
@get:InputFile
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val configFile: RegularFileProperty // Einzelne Datei
@get:InputDirectory
@get:IgnoreEmptyDirectories
abstract val sourceDir: DirectoryProperty // Verzeichnis mit Inhalt
@get:InputFiles
@get:PathSensitive(PathSensitivity.NAME_ONLY)
abstract val classpathFiles: ConfigurableFileCollection // Datei-Collection
@get:Classpath
abstract val compileClasspath: ConfigurableFileCollection // Classpath-Semantik
@get:Optional
@get:Input
abstract val optionalParameter: Property<String> // Optionaler Input
@get:Internal
abstract val workingDirectory: DirectoryProperty // Nicht für Up-to-Date-Check
}PathSensitivity kontrolliert, wie Pfadänderungen Up-to-Date-Checks
beeinflussen. ABSOLUTE berücksichtigt den vollständigen
Pfad, RELATIVE nur den relativen Pfad zum Projekt,
NAME_ONLY ignoriert den Pfad komplett, NONE
ignoriert sogar Dateinamen-Änderungen. Die richtige Wahl verhindert
unnötige Task-Executions bei irrelevanten Änderungen.
Output-Annotations definieren Task-Produkte:
abstract class OutputTask : DefaultTask() {
@get:OutputFile
abstract val reportFile: RegularFileProperty
@get:OutputDirectory
abstract val generatedSources: DirectoryProperty
@get:OutputFiles
abstract val multipleOutputs: Map<String, File>
@get:Destroys
abstract val cleanedDirectory: DirectoryProperty // Verzeichnis wird gelöscht
@get:LocalState
abstract val cacheDirectory: DirectoryProperty // Lokaler Cache, nicht relocated
}Die korrekte Output-Annotation ermöglicht Gradle das Caching und die
Incremental Execution. Output-Directories werden automatisch erstellt,
Output-Files erfordern manuelles Erstellen der Parent-Directories. Die
@Destroys Annotation markiert Ressourcen, die der Task
entfernt, was für korrekte Execution-Order wichtig ist.
Incremental Task Execution verarbeitet nur geänderte Inputs seit der letzten Execution. Dies reduziert Execution-Time dramatisch für Tasks, die viele Dateien verarbeiten. Die Implementierung erfordert spezielle Input-Annotations und eine angepasste Task-Action.
Ein incremental fähiger Task implementiert die entsprechende Logik:
abstract class IncrementalProcessingTask : DefaultTask() {
@get:Incremental
@get:InputDirectory
abstract val inputDir: DirectoryProperty
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
@TaskAction
fun execute(inputChanges: InputChanges) {
val incremental = inputChanges.isIncremental
println("Executing incrementally: $incremental")
if (!incremental) {
// Full rebuild - Output-Directory cleanen
outputDir.get().asFile.deleteRecursively()
}
inputChanges.getFileChanges(inputDir).forEach { change ->
val targetFile = computeOutputFile(change.file)
when (change.changeType) {
ChangeType.ADDED, ChangeType.MODIFIED -> {
processFile(change.file, targetFile)
println("Processed: ${change.file}")
}
ChangeType.REMOVED -> {
targetFile.delete()
println("Removed: $targetFile")
}
}
}
}
private fun computeOutputFile(inputFile: File): File {
val relativePath = inputFile.relativeTo(inputDir.get().asFile)
return outputDir.get().asFile.resolve(relativePath.path.replace(".txt", ".processed"))
}
}Die InputChanges Parameter liefert Information über
geänderte Dateien. Bei non-incremental Execution (erste Ausführung oder
nach Clean) muss der Task alle Inputs verarbeiten. Die Change-Types
ADDED, MODIFIED und REMOVED
ermöglichen differenzierte Behandlung.
Incremental Tasks müssen idempotent sein. Mehrfache Ausführung mit identischen Inputs muss identische Outputs produzieren. Side-Effects wie Netzwerk-Calls oder Timestamps in Outputs brechen Idempotenz und führen zu ständigen Rebuilds.
Die strikte Trennung von Configuration und Execution ist fundamental für Gradle-Performance. Configuration erfolgt für alle Tasks im Build, Execution nur für tatsächlich ausgeführte Tasks. Expensive Operations in der Configuration-Phase degradieren Build-Performance signifikant.
Anti-Pattern in der Configuration-Phase:
// FALSCH - Expensive Operation während Configuration
tasks.register<DefaultTask>("badTask") {
val files = fileTree("src").files // Sofortige File-System-Traversierung
val count = files.size
doLast {
println("Found $count files")
}
}
// RICHTIG - Lazy Evaluation in Execution-Phase
tasks.register<DefaultTask>("goodTask") {
val files = providers.provider {
fileTree("src").files
}
doLast {
println("Found ${files.get().size} files")
}
}Task-Configuration sollte nur Property-Bindings und Metadaten-Setup enthalten. File-System-Zugriffe, Netzwerk-Operationen oder komplexe Berechnungen gehören in die Task-Action oder werden über Provider verzögert.
Die @TaskAction annotierte Methode wird während der
Execution-Phase aufgerufen:
abstract class WellBehavedTask : DefaultTask() {
@get:Input
abstract val configuration: Property<String>
private lateinit var processedConfig: Config
@TaskAction
fun execute() {
// Initialisierung in Execution-Phase
processedConfig = parseConfiguration(configuration.get())
// Actual Work
performWork(processedConfig)
}
private fun parseConfiguration(config: String): Config {
// Expensive Parsing nur bei tatsächlicher Execution
return Config.parse(config)
}
}Robuste Tasks validieren Inputs und produzieren aussagekräftige
Fehlermeldungen. Validierung erfolgt idealerweise über
Gradle-Mechanismen statt Custom-Code. Die @Input Validation
stellt sicher, dass Required Properties gesetzt sind. Custom-Validation
erfolgt in der Task-Action.
Validierung mit aussagekräftigen Fehlern:
abstract class ValidatingTask : DefaultTask() {
@get:InputFile
abstract val configFile: RegularFileProperty
@get:Input
abstract val threshold: Property<Int>
@TaskAction
fun execute() {
val file = configFile.get().asFile
val limit = threshold.get()
// Input-Validierung
require(file.extension == "xml") {
"Configuration file must be XML, got: ${file.extension}"
}
require(limit in 1..100) {
"Threshold must be between 1 and 100, got: $limit"
}
try {
processConfiguration(file, limit)
} catch (e: ProcessingException) {
throw TaskExecutionException(this, e).apply {
initCause(e)
}
}
}
}StopExecutionException signalisiert erwartete Fehler ohne Stack-Trace:
if (!preconditionMet()) {
throw StopExecutionException("Skipping task: precondition not met")
}Diese Exception markiert den Task als FAILED, aber Gradle zeigt keinen Stack-Trace, was die Ausgabe übersichtlich hält.
Build Cache speichert Task-Outputs und ermöglicht Wiederverwendung über Build-Grenzen hinweg. Tasks müssen explizit Cache-fähig markiert werden und strikte Anforderungen erfüllen. Cacheable Tasks müssen deterministisch sein, keine absoluten Pfade in Outputs schreiben und reproduzierbare Outputs generieren.
Ein cache-fähiger Task:
@CacheableTask
abstract class CacheableProcessingTask : DefaultTask() {
@get:InputFiles
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val sources: ConfigurableFileCollection
@get:Input
abstract val processingMode: Property<String>
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
@TaskAction
fun process() {
outputDir.get().asFile.mkdirs()
sources.files.forEach { sourceFile ->
val relativePath = sourceFile.relativeTo(sources.files.first().parentFile)
val outputFile = outputDir.get().asFile.resolve(relativePath)
outputFile.parentFile.mkdirs()
// Deterministischer Output ohne Timestamps
val processed = processContent(sourceFile.readText(), processingMode.get())
outputFile.writeText(processed)
}
}
private fun processContent(content: String, mode: String): String {
// Keine Timestamps oder Random-Values
return content.lines()
.map { it.trim() }
.filter { it.isNotEmpty() }
.joinToString("\n")
}
}Normalization entfernt irrelevante Unterschiede aus Inputs:
@get:InputFiles
@get:PathSensitive(PathSensitivity.RELATIVE)
@get:Classpath // Ignoriert Timestamps in JARs
abstract val libraries: ConfigurableFileCollection
@get:Input
@get:Normalizer(TrimWhitespaceNormalizer::class)
abstract val configuration: Property<String>Performance-Profiling identifiziert Bottlenecks. Der
--profile Parameter generiert HTML-Reports mit
Task-Execution-Times. Build Scans visualisieren Task-Parallelität und
Cache-Hits. Diese Metriken leiten Optimierungs-Entscheidungen.
Worker API parallelisiert CPU-intensive Tasks:
abstract class ParallelProcessingTask : DefaultTask() {
@get:Inject
abstract val workerExecutor: WorkerExecutor
@TaskAction
fun process() {
val workQueue = workerExecutor.noIsolation()
inputFiles.forEach { file ->
workQueue.submit(ProcessingWork::class) {
inputFile.set(file)
outputDir.set(this@ParallelProcessingTask.outputDir)
}
}
}
}Diese Patterns und Practices resultieren in robusten, performanten Custom Tasks, die sich nahtlos in Gradles Build-Model integrieren. Die initiale Investition in korrekte Implementation zahlt sich durch Wartbarkeit und Build-Performance langfristig aus.
Einen Custom Task implementieren, der aus Textdateien ASCII-Art-Banner erzeugt, dabei inkrementell arbeitet und sauberes Input/Output-Wiring nutzt.
mkdir custom-task-demo && cd custom-task-demo
gradle init --type basic --dsl kotlin
mkdir -p src/messagesBeispieldateien anlegen:
echo "Gradle Rocks" >src/messages/msg1.txt
echo "Custom Tasks sind mächtig" >src/messages/msg2.txtDatei build.gradle.kts:
import org.gradle.api.DefaultTask
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.tasks.*
import org.gradle.work.InputChanges
import org.gradle.work.ChangeType
import java.io.File
plugins {
java
}
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(17))
}
}
// Custom Task: ASCII-Banner-Generator
@CacheableTask
abstract class BannerTask : DefaultTask() {
@get:Incremental
@get:InputDirectory
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val inputDir: DirectoryProperty
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
init {
description = "Erzeugt ASCII-Banner aus Textdateien"
group = "demo"
}
@TaskAction
fun generate(inputChanges: InputChanges) {
val outDir = outputDir.get().asFile
if (!inputChanges.isIncremental) {
outDir.deleteRecursively()
}
outDir.mkdirs()
inputChanges.getFileChanges(inputDir).forEach { change ->
val rel = change.file.relativeTo(inputDir.get().asFile)
val target = outDir.resolve(rel.nameWithoutExtension + ".banner")
when (change.changeType) {
ChangeType.ADDED, ChangeType.MODIFIED -> {
target.parentFile.mkdirs()
val content = change.file.readText().trim()
target.writeText(renderBanner(content))
println("✨ Banner erzeugt: ${target.name}")
}
ChangeType.REMOVED -> {
if (target.exists()) {
target.delete()
println("🗑️ Banner gelöscht: ${target.name}")
}
}
}
}
}
private fun renderBanner(text: String): String {
val border = "#".repeat(text.length + 6)
return buildString {
appendLine(border)
appendLine("# $text #")
appendLine(border)
}
}
}
// Task registrieren
tasks.register<BannerTask>("generateBanners") {
inputDir.set(layout.projectDirectory.dir("src/messages"))
outputDir.set(layout.buildDirectory.dir("banners"))
}Task ausführen:
./gradlew generateBannersAusgabe:
✨ Banner erzeugt: msg1.banner
✨ Banner erzeugt: msg2.banner
Inhalt von build/banners/msg1.banner:
##################
# Gradle Rocks! #
##################Datei ändern:
echo "Nur Gradle!" > src/messages/msg1.txt
./gradlew generateBannersAusgabe:
✨ Banner erzeugt: msg1.banner
→ Nur die geänderte Datei wurde verarbeitet (inkrementell).
Datei löschen:
rm src/messages/msg2.txt
./gradlew generateBannersAusgabe:
🗑️ Banner gelöscht: msg2.banner@CacheableTask) und produziert reproduzierbare
Outputs.Implementieren Sie einen Custom Task
ValidateConfigTask, der eine Konfigurationsdatei
(config.xml) prüft.
.xml besitzt.Fügen Sie eine zweite Property threshold vom Typ
Property
Werfen Sie bei ungültigen Eingaben eine aussagekräftige Exception mit Hinweisen, was korrigiert werden muss.
Registrieren Sie den Task im build.gradle.kts und
konfigurieren Sie:
configFile →
layout.projectDirectory.file("config.xml")threshold → 42Legen Sie eine Testdatei config.xml im
Projektverzeichnis an, führen Sie den Task mit
./gradlew validateConfig aus und überprüfen Sie die
Konsolenausgabe.