7. Variant Aware Dependency Resolution
In Gradle, dependency resolution is often thought of from the standpoint of a consumer and a producer. The consumer declares dependencies and performs dependency resolution, while producers satisfy those dependencies by exposing variants.
Gradle’s resolution engine follows a dynamic approach to dependency resolution called variant-aware resolution, where the consumer defines requirements using attributes, which are matched with the attributes declared by the producer.
Variant-aware resolution allows Gradle to automatically select the correct variant from a producer without the consumer explicitly specifying which one to use.
For instance, if you’re working with different architectures (like arm64 and i386), Gradle can choose the appropriate version of a library (myLib) for each architecture:
-
The producer,
myLib, exposes variants (arm64Elements,i386Elements) with specific attributes (e.g.,ArchType.ARM64,ArchType.I386). -
The consumer,
myApp, specifies the required attributes (e.g.,ArchType.ARM64) in its resolvable configuration (runtimeClasspath). -
If the consumer,
myApp, requires dependencies for thearm64architecture, Gradle will automatically pick thearm64Elementsvariant from themyLibproducer and use its corresponding artifact.
A coded example
Consider a Java library where you create a new variant called instrumentedJars and want to ensure it’s selected for testing:
-
Producer Project: Creates a specialized
instrumentedJarsvariant marked with specific attributes. -
Consumer Project: Configured to request the
instrumented-jarattribute for testing.
Let’s look at the build files of the producer and consumer.
The producer side
1. Create an instrumented JAR:
Our Java library has a task called instrumentedJar which produces a JAR file.
We expect other projects to consume this JAR file.
val instrumentedJar = tasks.register("instrumentedJar", Jar::class) {
archiveClassifier = "instrumented"
}
def instrumentedJar = tasks.register("instrumentedJar", Jar) {
archiveClassifier = "instrumented"
}
2. Create a custom outgoing configuration:
We want our instrumented classes to be used when executing tests, so we need to define proper attributes on our variant.
We create a new configuration named instrumentedJars.
This configuration:
-
Can be consumed by other projects.
-
Cannot be resolved (i.e., it’s meant to be used as an output, not an input).
-
Has specific attributes, including
LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTEset to "instrumented-jar", which explains what the variant contains.
val instrumentedJars by configurations.creating {
isCanBeConsumed = true
isCanBeResolved = false
attributes {
attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category.LIBRARY))
attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.JAVA_RUNTIME))
attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling.EXTERNAL))
attribute(TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, JavaVersion.current().majorVersion.toInt())
attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named("instrumented-jar"))
}
}
configurations {
instrumentedJars {
canBeConsumed = true
canBeResolved = false
attributes {
attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category, Category.LIBRARY))
attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage, Usage.JAVA_RUNTIME))
attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling, Bundling.EXTERNAL))
attribute(TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, JavaVersion.current().majorVersion.toInteger())
attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements, 'instrumented-jar'))
}
}
}
3. Attach the Artifact:
The instrumentedJar task’s output is added to the instrumentedJars configuration as an artifact.
When this variant is included in a dependency graph, this artifact will be resolved during artifact resolution.
artifacts {
add("instrumentedJars", instrumentedJar)
}
artifacts {
instrumentedJars(instrumentedJar)
}
What we have done here is that we have added a new variant, which can be used at runtime, but contains instrumented classes instead of the normal classes. However, it now means that for runtime, the consumer has to choose between two variants:
-
runtimeElements, the regular variant offered by thejava-libraryplugin -
instrumentedJars, the variant we have created
The consumer side
1. Add dependencies:
First, on the consumer side, like any other project, we define the Java library as a dependency:
dependencies {
testImplementation("junit:junit:4.13")
testImplementation(project(":producer"))
}
dependencies {
testImplementation 'junit:junit:4.13'
testImplementation project(':producer')
}
At this point, Gradle will still select the default runtimeElements variant for your dependencies.
This is because the testRuntimeClasspath configuration is requesting artifacts with the jar library elements attribute, while the producer defines the instrumentedJars variant with a different attribute.
2. Adjust the requested attributes:
The testRuntimeClasspath configuration is modified to ask for "instrumented-jar" versions of the dependencies.
This means that when Gradle resolves dependencies for this configuration, it will prefer JAR files that are marked as "instrumented":
configurations {
testRuntimeClasspath {
attributes {
attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements::class.java, "instrumented-jar"))
}
}
}
configurations {
testRuntimeClasspath {
attributes {
attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements, 'instrumented-jar'))
}
}
}
By following these steps, Gradle will intelligently select the correct variants based on the configuration and attributes, while also handling cases where specialized variants are not available.