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 thearm64
architecture, Gradle will automatically pick thearm64Elements
variant from themyLib
producer 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
instrumentedJars
variant marked with specific attributes. -
Consumer Project: Configured to request the
instrumented-jar
attribute 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_ATTRIBUTE
set 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-library
plugin -
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.