Traditionelle Gradle-Skripte verwendeten String-basierte Pfadangaben
mit Variablen wie $buildDir oder
${project.buildDir}. Diese Ansätze führten zu mehreren
Problemen: Pfade wurden während der Konfigurationsphase aufgelöst,
Änderungen am Build-Verzeichnis nach der Konfiguration wurden ignoriert,
und Task-Abhängigkeiten mussten manuell deklariert werden. Die
String-Interpolation verhinderte zudem Configuration Caching, da die
aufgelösten Pfade im Cache gespeichert wurden.
Ein typisches Anti-Pattern war die direkte Pfadkonstruktion:
// Problematisch - Eager Evaluation
tasks.register<Copy>("copyResources") {
from("src/main/resources")
into("${buildDir}/resources") // String wird sofort aufgelöst
}Diese Konfiguration evaluiert buildDir während der
Konfigurationsphase. Wenn sich das Build-Verzeichnis später ändert,
verwendet der Task weiterhin den ursprünglichen Pfad. Zusätzlich kann
Gradle keine impliziten Abhängigkeiten zwischen Tasks erkennen, die über
Dateipfade verbunden sind.
Die Layout-API löst diese Probleme durch typsichere, lazy Directory- und File-Provider. Diese Provider repräsentieren Speicherorte ohne sofortige Pfadauflösung. Task-Verbindungen über Input/Output-Properties etablieren automatisch korrekte Abhängigkeiten. Die Configuration Cache kann diese Strukturen effizient serialisieren, da sie keine aufgelösten Pfade enthält.
Die Layout-API bietet zwei zentrale Interfaces: ProjectLayout für projektbezogene Verzeichnisse und BuildLayout für build-weite Strukturen. Jedes Projekt erhält eine Layout-Instanz, die Zugriff auf wichtige Verzeichnisse ermöglicht:
val projectLayout = layout // Injected property in Tasks
// Projekt-Verzeichnisse
val projectDir = layout.projectDirectory
val buildDir = layout.buildDirectory
// Dateien und Unterverzeichnisse
val configFile = layout.projectDirectory.file("config.properties")
val outputDir = layout.buildDirectory.dir("generated")Die Layout-API arbeitet mit Provider-basierten Types. Ein
DirectoryProperty oder RegularFileProperty
repräsentiert einen konfigurierbaren Speicherort. Diese Properties
können an Provider gebunden werden, was automatische Updates bei
Änderungen ermöglicht:
abstract class ProcessTask : DefaultTask() {
@get:InputDirectory
abstract val sourceDir: DirectoryProperty
@get:OutputDirectory
abstract val targetDir: DirectoryProperty
@TaskAction
fun process() {
val source = sourceDir.get().asFile
val target = targetDir.get().asFile
// Verarbeitung
}
}
tasks.register<ProcessTask>("process") {
sourceDir.set(layout.projectDirectory.dir("src/data"))
targetDir.set(layout.buildDirectory.dir("processed"))
}Die Layout-API unterstützt relative Pfadauflösung ohne String-Manipulation:
val baseDir = layout.buildDirectory.dir("base")
val subDir = baseDir.map { it.dir("sub/nested") }
val file = subDir.map { it.file("data.json") }Diese Provider-Chains bleiben lazy und werden erst bei Bedarf aufgelöst.
Das Wire Protocol ist Gradles Mechanismus zur automatischen Erkennung
von Task-Abhängigkeiten durch Input/Output-Verbindungen. Wenn ein
Task-Output als Input eines anderen Tasks verwendet wird, etabliert
Gradle automatisch die korrekte Ausführungsreihenfolge. Dies eliminiert
fehleranfällige manuelle dependsOn Deklarationen.
Ein praktisches Beispiel demonstriert das Wire Protocol:
abstract class GenerateTask : DefaultTask() {
@get:OutputFile
abstract val outputFile: RegularFileProperty
@TaskAction
fun generate() {
outputFile.get().asFile.writeText("Generated content")
}
}
abstract class ProcessTask : DefaultTask() {
@get:InputFile
abstract val inputFile: RegularFileProperty
@get:OutputFile
abstract val outputFile: RegularFileProperty
@TaskAction
fun process() {
val content = inputFile.get().asFile.readText()
outputFile.get().asFile.writeText(content.uppercase())
}
}
val generateTask = tasks.register<GenerateTask>("generate") {
outputFile.set(layout.buildDirectory.file("temp/generated.txt"))
}
tasks.register<ProcessTask>("process") {
inputFile.set(generateTask.flatMap { it.outputFile })
outputFile.set(layout.buildDirectory.file("final/processed.txt"))
}Gradle erkennt, dass process von generate
abhängt, ohne explizite dependsOn Deklaration. Die
flatMap Operation verbindet die Tasks über ihre Properties
und behält die lazy Evaluation bei.
Task-Output-Collections werden durch ConfigurableFileCollection verbunden:
val sourceFiles = files()
tasks.register<JavaCompile>("compileExtra") {
source = sourceFiles
destinationDirectory.set(layout.buildDirectory.dir("classes/extra"))
}
tasks.register<Jar>("packageExtra") {
from(tasks.named<JavaCompile>("compileExtra").flatMap { it.destinationDirectory })
archiveFileName.set("extra.jar")
destinationDirectory.set(layout.buildDirectory.dir("libs"))
}Die Migration von String-basierten Pfaden zur Layout-API erfolgt
systematisch. Direkte buildDir Referenzen werden durch
Layout-API-Aufrufe ersetzt:
// Alt - String-basiert
tasks.register<Delete>("cleanTemp") {
delete("${buildDir}/temp")
}
// Neu - Layout-API
tasks.register<Delete>("cleanTemp") {
delete(layout.buildDirectory.dir("temp"))
}File-Collections migrieren zu Provider-basierten Konstrukten:
// Alt - Eager file collection
val configs = fileTree("${buildDir}/configs") {
include("*.xml")
}
// Neu - Lazy provider-basiert
val configs = layout.buildDirectory.dir("configs").map { configDir ->
fileTree(configDir) {
include("*.xml")
}
}Task-Inputs und -Outputs nutzen Property-Types statt Strings:
// Alt - String-basierte Inputs/Outputs
task.inputs.dir("${projectDir}/templates")
task.outputs.file("${buildDir}/report.html")
// Neu - Typsichere Properties
abstract class ReportTask : DefaultTask() {
@get:InputDirectory
abstract val templateDir: DirectoryProperty
@get:OutputFile
abstract val reportFile: RegularFileProperty
init {
templateDir.convention(project.layout.projectDirectory.dir("templates"))
reportFile.convention(project.layout.buildDirectory.file("report.html"))
}
}Cross-Task-Wiring ersetzt manuelle Pfadweitergabe:
// Alt - Manuelle Pfadweitergabe
val generateTask = tasks.register("generate") {
ext.set("outputPath", "${buildDir}/generated/data.json")
doLast {
file(ext.get("outputPath")).writeText("{}")
}
}
tasks.register("consume") {
dependsOn(generateTask)
doLast {
val path = generateTask.get().ext.get("outputPath") as String
println(file(path).readText())
}
}
// Neu - Wire Protocol
abstract class GenerateJsonTask : DefaultTask() {
@get:OutputFile
abstract val jsonFile: RegularFileProperty
}
val generateTask = tasks.register<GenerateJsonTask>("generate") {
jsonFile.set(layout.buildDirectory.file("generated/data.json"))
}
tasks.register<DefaultTask>("consume") {
val jsonInput = generateTask.flatMap { it.jsonFile }
inputs.file(jsonInput)
doLast {
println(jsonInput.get().asFile.readText())
}
}Die Layout-API integriert nahtlos mit Gradles FileOperations. Copy-Tasks nutzen Directory- und File-Provider als Sources und Destinations:
tasks.register<Copy>("copyDocs") {
from(layout.projectDirectory.dir("docs"))
into(layout.buildDirectory.dir("documentation"))
// Dynamische Filterung basierend auf Properties
val includePattern = providers.systemProperty("docs.include").orElse("**/*.md")
include(includePattern)
}Sync-Tasks verwenden Layout-Provider für konsistente Verzeichnis-Synchronisation:
tasks.register<Sync>("syncResources") {
from(layout.projectDirectory.dir("src/main/resources"))
// Destination kann von anderen Tasks abhängen
val processedDir = tasks.named<ProcessResources>("processResources")
.flatMap { it.destinationDir }
into(layout.buildDirectory.dir("synchronized").map { syncDir ->
if (processedDir.isPresent) syncDir else layout.buildDirectory.dir("fallback").get()
})
}Archive-Tasks nutzen Provider für dynamische Namensgebung:
tasks.register<Zip>("packageSources") {
from(layout.projectDirectory.dir("src"))
// Archivname basierend auf Version
archiveBaseName.set(project.name)
archiveVersion.set(providers.provider { version.toString() })
archiveExtension.set("zip")
destinationDirectory.set(layout.buildDirectory.dir("distributions"))
}Die konsequente Nutzung der Layout-API verbessert Build-Performance
und Wartbarkeit. Provider sollten so lange wie möglich lazy bleiben. Die
get() Methode sollte nur in Task-Actions aufgerufen werden,
niemals während der Konfiguration. Dies maximiert Configuration
Avoidance und reduziert Konfigurationszeit.
Convention-basierte Defaults reduzieren Boilerplate:
abstract class StandardTask : DefaultTask() {
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
init {
outputDir.convention(
project.layout.buildDirectory.dir("output/${this.name}")
)
}
}Task-Konfiguration nutzt Provider-Mapping für dynamische Werte:
tasks.withType<JavaCompile>().configureEach {
options.generatedSourceOutputDirectory.set(
layout.buildDirectory.dir("generated/sources/${name}")
)
}Fehlerbehandlung berücksichtigt nicht-existente Verzeichnisse:
val sourceDir = layout.projectDirectory.dir("optional-sources")
val safeSource = providers.provider {
sourceDir.asFile.takeIf { it.exists() }
}.map { dir ->
fileTree(dir)
}.orElse(files())Performance-Monitoring validiert Wire-Protocol-Nutzung. Build Scans zeigen Task-Dependencies und deren Ursprung. Implizite Dependencies durch Wire Protocol werden als “WIRED” markiert. Dies hilft bei der Identifikation fehlender Verbindungen oder unnötiger expliziter Dependencies.
Die Migration zur Layout-API ist eine Investition in Build-Qualität. Configuration Caching funktioniert zuverlässiger, Task-Dependencies sind korrekt, und Builds werden reproduzierbarer. Die initiale Umstellung erfordert Aufwand, zahlt sich aber durch reduzierte Wartung und bessere Performance langfristig aus.
Verstehen, wie man mit der Layout-API typsicher und
lazy arbeitet, anstatt $buildDir-Strings zu verwenden.
Außerdem erleben, wie Gradle über das Wire Protocol
automatisch Task-Abhängigkeiten erkennt, wenn Outputs und Inputs
verbunden sind.
mkdir layout-demo && cd layout-demo
gradle init --type basic --dsl kotlinDatei build.gradle.kts mit folgendem Inhalt:
import org.gradle.api.DefaultTask
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.TaskAction
import org.gradle.kotlin.dsl.register
import org.gradle.api.tasks.bundling.Zip
plugins {
java
}
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(17))
}
}
repositories {
mavenCentral()
}
// Task 1: Datei erzeugen
abstract class GenerateTask : DefaultTask() {
@get:OutputFile
abstract val outputFile: RegularFileProperty
@TaskAction
fun generate() {
outputFile.get().asFile.writeText("Hello Layout API!")
println("Datei erzeugt: ${outputFile.get().asFile}")
}
}
val generateTask = tasks.register<GenerateTask>("generateMessage") {
outputFile.set(layout.buildDirectory.file("generated/message.txt"))
}
// Task 2: Datei verarbeiten
abstract class ProcessTask : DefaultTask() {
@get:InputFile
abstract val inputFile: RegularFileProperty
@get:OutputFile
abstract val outputFile: RegularFileProperty
@TaskAction
fun process() {
val text = inputFile.get().asFile.readText()
outputFile.get().asFile.writeText(text.uppercase())
println("Datei verarbeitet: ${outputFile.get().asFile}")
}
}
val processTask = tasks.register<ProcessTask>("processMessage") {
inputFile.set(generateTask.flatMap { it.outputFile })
outputFile.set(layout.buildDirectory.file("processed/result.txt"))
}
// Task 3: Archiv erstellen
tasks.register<Zip>("archiveMessage") {
from(processTask.flatMap { it.outputFile })
archiveFileName.set("message.zip")
destinationDirectory.set(layout.buildDirectory.dir("archives"))
}Nur den letzten Task starten:
./gradlew archiveMessage→ Gradle erkennt automatisch die Abhängigkeiten:
generateMessage → processMessage →
archiveMessage.
Ergebnis prüfen:
unzip -l build/archives/message.zip
cat build/processed/result.txtInhalt der Datei:
HELLO LAYOUT API!"$buildDir/...") mehr
nötig.Ja, hier ergibt eine weitere Aufgabe Sinn – und zwar als Vertiefung.
Die bestehende Übung zeigt bereits den klassischen Flow Generate → Process → Archive mit Layout-API und Wire Protocol. Eine zusätzliche Aufgabe könnte die Teilnehmer dazu bringen, die Layout-API flexibler einzusetzen:
Erweitern Sie das Projekt um einen Task copyDocs,
der alle .md-Dateien aus einem Verzeichnis
docs/ ins Build-Verzeichnis kopiert.
layout.projectDirectory.dir("docs") als
Quelle.layout.buildDirectory.dir("documentation")
sein.Ergänzen Sie einen zweiten Task zipDocs, der das
Ergebnis von copyDocs automatisch als Input nimmt und
daraus ein ZIP-Archiv erstellt.
flatMap, um die
implizite Abhängigkeit herzustellen (kein dependsOn).Legen Sie ein paar Testdateien in docs/ an, führen
Sie ./gradlew zipDocs aus und überprüfen Sie, dass die
Dateien im Archiv landen.
Damit wird deutlich:
dependsOn braucht.