Skip to content
Book Cover of Extending Android Builds

3-1: Variant ​

Recall back in Section 2-4 when we crafted our first fleshed-out Plugin? That's where we got our first taste of the Variant APIs. But before we dive into how these APIs can shape our custom Plugins, we need to unpack the concept of a Variant itself.

So, what are Variants? Think of them as distinct offspring born from a single project. For instance, a free and a paid version of an app, or multiple iterations of a map SDK drawing from different data sources. Each of these examples represents a Variant. They share the same core code, but special requirements often drive unique configurations for each product, which may include additional code and resources. Within the context of the Gradle platform and the Android/JVM build ecosystem, we'll be focusing on two key Variants: the Variant of Gradle Module Metadata and the Variant of AGP.

The source code relevant to this section can be found in the variant project.

3-1-1: The Variant of Gradle Module Metadata ​

Gradle Module Metadata, a type of dependency description file, first appeared on the scene with Gradle 5.3 in 2019. Its purpose is to fill the gaps left by pom.xml or Ivy files. It comes with a *.module file extension and uses JSON as its data format. When published to the common central repository for JVM projects, it still leans on maven-publish or ivy-publish for support. Using maven-publish as an example, you'll see that the *.module file acts as an add-on, getting published to the Maven repository alongside the *.pom file.

β”œβ”€β”€ .m2
β”‚  └── repository                
β”‚    └── me/2bab/extendagp
β”‚     └── library1
β”‚        └── 1.0.1
β”‚          β”œβ”€β”€ ...
β”‚          β”œβ”€β”€ library1-1.0.1.module
β”‚          └── library1-1.0.1.pom

Internal description as follows:

JSON
{
  "formatVersion": "1.1",
  "component": {
  "group": "me.2bab.extendagp",
  "module": "library1",
  "version": "1.0.1",
  "attributes": {
    "org.gradle.status": "release"
  }
  },
  "createdBy": {
  "gradle": {
    "version": "7.3.3"
  }
  },
  "variants": [
  {
    "name": "apiElements",
    "attributes": {
    "org.gradle.category": "library",
    "org.gradle.dependency.bundling": "external",
    "org.gradle.jvm.environment": "standard-jvm",
    "org.gradle.jvm.version": 11,
    "org.gradle.libraryelements": "jar",
    "org.gradle.usage": "java-api",
    "org.jetbrains.kotlin.platform.type": "jvm"
    },
    "files": [
    {
      "name": "library1-1.0.1.jar",
      "url": "library1-1.0.1.jar",
      "size": 2202,
      ...
    }
    ]
  },
  ...
  ]
}

This chapter isn't so much about the file format as it is about the variants defined within the JSON Object. The particular structure allows us to distinguish between products with the same coordinates, enabling the creation of Variants. Now, imagine a scenario where the Library1 SDK needs to offer a version without an HTTP Client and another with a default HTTP Clientβ€”giving users the choice.

Following the traditional Maven POM strategy, we could split the core module (which is required) and the Http Client module (which is optional) into separate GAV (Group, Artifact, and Version) coordinates, letting the user decide whether to include the extra Http Client module or not.

But the Variant scheme offered by Gradle Module Metadata has a different approach: it allows the HTTP Client and the core code to be housed in the same module, making it available as an optional capability to external callers.

Kotlin
dependencies {
  // Local dependency
  implementation(project(":library1")) {
    capabilities {
      requireCapability("me.2bab.extendagp:library1-okhttp")
    }
  }
  // Or remote dependency
  implementation("me.2bab.extendagp:library1:1.0.1") {
    capabilities {
      requireCapability("me.2bab.extendagp:library1-okhttp")
    }
  }
  
}

As shown above, when displaying local or remote dependencies, an additional Capability requirement has been added, which indicates that I need the okhttp variant of Library1. How can you configure the module to isolate two code parts while publishing to a single coordinate? This practice is aligned with the standard implementation provided by the official Java Plugin of Gradle, and Kotlin can accomplish the same.

Kotlin
plugins {
  kotlin("jvm")
  `maven-publish`
}

group = "me.2bab.extendagp"
version = "1.0.1"

val SourceSet.kotlin: SourceDirectorySet  // [I]
  get() = project.extensions
    .getByType<KotlinJvmProjectExtension>()
    .sourceSets
    .getByName(name)
    .kotlin

val okhttp = sourceSets.create("okhttp") {  // [II]
  java.srcDirs("src/okhttp/java", "src/main/java")
  kotlin.srcDirs("src/okhttp/kotlin", "src/main/kotlin")
}

java {  // [III]
  registerFeature("okhttp") {
    usingSourceSet(okhttp)
  }
}

configurations.named("okhttpImplementation")  // [IV]
  .get()
  .extendsFrom(configurations.implementation.get())

dependencies {
  implementation(project(":library1-api"))
  implementation(deps.kotlin.std)
  "okhttpImplementation"(deps.okHttp)  // [V]
}

publishing {  // [VI]
  publications {
    create<MavenPublication>("maven") {
      from(components["java"])
    }
  }
}

Here's the explanation:

  • [I] and [II] add new SourceSets to the module, isolating the code with the built-in HTTP client okhttp.
  • [III] configures the Java Plugin Extension, an implementation of Gradle Module Metadata with its Variant. The registerFeature(...) method registers the "feature" with okhttp as the key, enabling the isolation of the independent build environment. After a Gradle Sync, new Tasks like okhttpClasses and okhttpJar will be visible.
  • Along with this, we have a new Dependency Configuration, okhttpImplementation. Through [IV], it inherits all dependencies from the implementation, so the okhttp variant doesn't need to redeclare basic dependencies but only those specific to deps.okHttp. This is referred to as Variant-Aware Dependency Management.
  • [VI] is about the configuration of maven-publish. Since the Java Plugin manages the source code and dependencies for publishing, we simply configure a standard template here.

The final project structure looks like this:

β”œβ”€β”€ src
β”‚  └── main                
β”‚    └── kotlin
β”‚     └── me/2bab/extendagp.library1
β”‚        └── LibraryApi
β”‚  └── okhttp                
β”‚    └── kotlin
β”‚     └── me/2bab/extendagp.library1
β”‚        └── OkHttpClient
└── library1.gradle.kts

To publish the full structure, use ./gradlew library1:publishMavenPublicationToMavenLocal (the library-api module that library1 depends on must also be published in the same way):

β”œβ”€β”€ .m2
β”‚  └── repository                
β”‚    └── me/2bab/extendagp
β”‚     └── library1
β”‚        └── 1.0.1
β”‚          β”œβ”€β”€ library1-1.0.1.jar
β”‚          β”œβ”€β”€ library1-okhttp-1.0.1.jar
β”‚          β”œβ”€β”€ library1-1.0.1.module
β”‚          └── library1-1.0.1.pom

Lastly, keep in mind that Gradle Module Metadata is still under development. The latest version 1.1 is not yet widely adopted, but a basic understanding is provided here for comparisons with future AGP variants.

3-1-2: The Variant of AGP ​

The Android Gradle Plugin (AGP) works to expand Gradle's capabilities. Even though the AGP Variants have the same requirements, and the basic configurations like SourceSet and Dependency Configuration can work out-of-the-box, the core differences lie in the nuanced build configurations of Application and Library. These include variations in applicationId and resValue, leading to the adoption of a unique variant system.

AGP Variants are statically described in their DSL, and they don't offer direct editing options. Instead, they are constructed from two meta-dimensions: buildTypes and productFlavors.

Here's an example code snippet:

Kotlin
defaultConfig {...}

buildTypes {
  getByName("debug") {
    isMinifyEnabled = false
  }
  getByName("release") {
    isMinifyEnabled = true
    proguardFiles(
      getDefaultProguardFile(β€œβ€¦β€)
    )
  }
  // create("...") { ... }
}

flavorDimensions += "server"
productFlavors {
  create("staging") {
    dimension = "server"
    applicationIdSuffix = ".staging"
    versionNameSuffix = "-staging"
  }
  create("production") {
    dimension = β€œserver"
    applicationIdSuffix = ".production"
    versionNameSuffix = "-production"
    versionCode = 2
  }
}

buildTypes focuses on development perspectives, while productFlavors extends from the product viewpoint. If you visualize buildTypes as the horizontal baseline, then productFlavors act as the vertical axis, orthogonal to buildTypes. Together, they create four variants:

debugrelease
stagingStagingDebugStagingRelease
productionProductionDebugProductionRelease

You can use specific Gradle commands to output single or multiple APKs. For example, ./gradlew assembleStagingDebug will output a single variant APK. ./gradlew assemble can create four different products at once:

β”œβ”€β”€ app
β”‚  └── outputs                
β”‚    └── apk
β”‚      └── production
β”‚        └── debug
β”‚          └── app-production-debug.apk
β”‚        └── release
β”‚          └── app-production-release.apk
β”‚      └── staging
β”‚        └── debug
β”‚          └── app-staging-debug.apk
β”‚        └── release
β”‚          └── app-staging-release.apk

The final products of AGP, such as .apk and .aab files, are typically stored in the outputs folder, while intermediate products like resource.arsc files reside in the intermediates folder. They can all be referred to as Artifacts, a term you'll encounter in subsequent sections.

A quick note on the conventions of these configurations:

  • The default debug and release of buildTypes are added by AGP. To create additional types, you would use the create(...) or register(...) API.
  • The defaultConfig {...} is the baseline for productFlavors, with an additional flavorDimensions configuration. The overriding sequence between them is productFlavors -> buildTypes -> defaultConfig {...}.

It's important to remember that combining multiple flavorDimensions within productFlavors can increase the complexity of variant's Cartesian product, potentially impacting performance at both the Configuration and Execution stages.

3-1-3: Android Variant API and Variant-Aware Task ​

For Android developers who are using more AGP Variants, understanding different configurations through buildTypes and productFlavors and how they differentiate into different variants is essential. But what happens in the subsequent building process?

Assuming you wish to "add an extra Task only for release variant". Many newcomers, including myself when first learning Gradle and AGP, might attempt the following code:

Kotlin
if (gradle.startParameter.taskNames.toString()
    .contains("release", ignoreCase = true)) {
  tasks.create("runOnReleaseVariantOnly") {
    doFirst {
      println("Task runOnReleaseVariantOnly: running...")
    }
  }
}

This approach aims to add a Task if the startParameter contains the release keyword without case-sensitive. However, this is a common mistake since it's challenging to anticipate the actual input, and this method doesn't always work. For instance, it won't correctly insert a custom Task with the commonly used assemble command: it produces four artifacts, including release, but it does not encompass the release keyword in the command.

The precise way to handle this is to utilize Variant-Aware Tasks.

Kotlin
androidExtension.applicationVariants.configureEach {
  if (this.name.contains("release", ignoreCase = true)) {
    tasks.create("runOnReleaseVariantOnly") {
      doFirst {
        println("runOnReleaseVariantOnly: running...")
      }
    }
  }
}

In AGP, Tasks that indirectly or directly depend on variant properties are referred to as Variant-Aware Tasks. They can be distinguished from normal Gradle tasks that lack this awareness.

Apart from common configurations for Application and Library modules, you should be aware that a set of Variant API configurations has been added when publishing AAR from AGP 7+ for Library modules specifically. Here's an illustrative example:

Kotlin
android {
  publishing {
    multipleVariants("custom") {
      includeBuildTypeValues("debug", "release")
      includeFlavorDimensionAndValues(
        dimension = "color",
        values = arrayOf("blue", "pink")
      )
      includeFlavorDimensionAndValues(
        dimension = "shape",
        values = arrayOf("square")
      )
    }
  }
}

The AGP Variant API has two versions: v1 (still available in 7.x but marked as deprecated) and v2 (incubated since 4.1 and officially released in 7.0). We will see more information in the next few sections.

3-1-4: In Summary ​

Android Variants, composed of BuildTypes and ProductFlavors, provide a powerful and flexible way to manage different build scenarios, and Variant-Aware Tasks play an essential role in this process. (Figure 3.1.1)

Figure 3.1.1: Android Variants Essentials

Overall, the configuration and utilization of Android variants provide a powerful framework for customizing the build process. Both Plugin developers and users should learn these concepts to enable precise customization and effective use of AGP.