Skip to content
academia.sh

Lesson 13 / 15

Product Flavors

A model of the build variants that arise from the product of product flavors and build types, the variant filter, and source set precedence.

Published Updated

In this lesson

  • Distinguishes the concepts of product flavor, flavor dimension and build variant
  • Measures that a variant in the build variant set is full, and one that is absent or excluded by the filter is unmet
  • Shows that with several flavor dimensions the variant name is built in dimension order
  • Verifies with the bounding measurement that a flavor source set takes precedence over the main source set and that the same source code class in two sets is a conflict
Contents

The previous lesson measured how the build system resolves dependency versions. The build system does not have to produce a single app from the same source. Free and paid editions, packages for different markets, or builds that connect to different servers come out of the same code base. Product flavors provide this. This lesson measures which build variants are produced and what the platform gives when a variant is requested.

The measurement axis of the course does not change: how does the platform fulfill this request. The file below carries the rule that gives the fulfillment and is repeated verbatim in every lesson.

// Fulfillment.kt — classifies how the platform fulfills a request
sealed interface Outcome {
    data class Given(val value: String) : Outcome
    data class Waiting(val on: String) : Outcome
    data class Refused(val reason: String) : Outcome
}

class Ask(val label: String, val wanted: String, val outcome: Outcome) {
    val fulfillment: String = when (outcome) {
        is Outcome.Waiting -> "pending"
        is Outcome.Refused -> "unmet"
        is Outcome.Given -> if (outcome.value == wanted) "full" else "substitute"
    }

    fun report() {
        val detail = when (outcome) {
            is Outcome.Given -> "given=${outcome.value}"
            is Outcome.Waiting -> "waiting-on=${outcome.on}"
            is Outcome.Refused -> "reason=${outcome.reason}"
        }
        println("$label: wanted=$wanted | $detail -> $fulfillment")
    }
}

fun tally(asks: List<Ask>) {
    for (name in listOf("full", "substitute", "pending", "unmet")) {
        println("$name: ${asks.count { it.fulfillment == name }}")
    }
}

Flavors and Variants

A product flavor is a named version of an app derived from the same code base. Flavors are bound to a flavor dimension: the dimension says along which axis the flavors differ. The free and paid flavors sit in a pricing dimension. The build types debug and release are another axis. The build system takes the product of these axes; each combination is called a build variant. Two flavors and two build types give four variants: freeDebug, freeRelease, paidDebug, paidRelease. The fragment below shows this definition and a filter that removes one variant from production.

// module build script
android {
    flavorDimensions += "tier"
    productFlavors {
        create("free") { dimension = "tier" }
        create("paid") { dimension = "tier" }
    }
}

androidComponents {
    beforeVariants { variant ->
        if (variant.name == "paidDebug") variant.enable = false
    }
}

The script is one that the platform tools read and is not run. The filter removes the named variant from production; that variant can be neither built nor selected. The model builds this rule of the platform. In this fragment the filter leaves out the paidDebug variant.

The Variant Set

The model builds the variant name from the flavor name and the build type name joined: the flavor as it is, the build type with a capitalized first letter. When there are several dimensions, the flavors are joined in the order of the dimensions. If the requested variant is in the set and has not been excluded by the filter, the given one is the same as the wanted one. If it is excluded by the filter or is not in the set, no variant is given.

// Variants.kt — a model of the variant set
fun variantNames(dimensions: List<List<String>>, buildTypes: List<String>): List<String> {
    var combos = listOf("")
    for (dimension in dimensions) {
        combos = combos.flatMap { prefix ->
            dimension.map { flavor ->
                if (prefix.isEmpty()) flavor else prefix + flavor.replaceFirstChar { it.uppercase() }
            }
        }
    }
    return combos.flatMap { combo -> buildTypes.map { type -> combo + type.replaceFirstChar { it.uppercase() } } }
}

fun resolveVariant(label: String, wanted: String, variants: List<String>, filtered: Set<String>): Ask {
    val outcome = when {
        wanted in filtered -> Outcome.Refused("excluded-by-filter")
        wanted in variants -> Outcome.Given(wanted)
        else -> Outcome.Refused("no-variant")
    }
    return Ask(label, wanted, outcome)
}

The first measurement sets up two flavors in one dimension and two build types, and paidDebug is excluded by the filter. Four requests are asked: a variant for development, a variant for distribution, the variant excluded by the filter, and a build type that was never defined.

// Main.kt — the variant set and the filter
fun main() {
    val variants = variantNames(listOf(listOf("free", "paid")), listOf("debug", "release"))
    println(variants.joinToString())
    val filtered = setOf("paidDebug")
    val asks = listOf(
        resolveVariant("run", "freeDebug", variants, filtered),
        resolveVariant("publish", "paidRelease", variants, filtered),
        resolveVariant("filtered", "paidDebug", variants, filtered),
        resolveVariant("undefined", "freeStaging", variants, filtered),
    )
    for (ask in asks) ask.report()
    tally(asks)
}
freeDebug, freeRelease, paidDebug, paidRelease
run: wanted=freeDebug | given=freeDebug -> full
publish: wanted=paidRelease | given=paidRelease -> full
filtered: wanted=paidDebug | reason=excluded-by-filter -> unmet
undefined: wanted=freeStaging | reason=no-variant -> unmet
full: 2
substitute: 0
pending: 0
unmet: 2

A variant name written exactly as it is in the set is given; no other variant is given in place of a name that is not there. That is why no substitute comes out in the variant measurement: a variant either exists or it does not. paidDebug stays in the variants list but the filter excludes it; the rule does not separate the reasons of the two unmet results, and the reason is told by the string the model carries.

PS1. The model joins the variant name from the flavor and the build type. The naming and the dimension precedence of the real build system have other details; the measurement does not cover them.

PS2. The filter looks only at the name of the variant. A real filter can read other properties of the variant as well.

Multiple Dimensions

The second measurement has two dimensions: the pricing dimension (free, paid) and the market dimension (global, local). The product with the build types gives eight variants. The flavors are joined in dimension order: pricing first, market second. Three requests are asked: a name that follows the order, a name with the order reversed, and a name excluded by the filter.

// Main.kt — two dimensions
fun main() {
    val variants = variantNames(
        listOf(listOf("free", "paid"), listOf("global", "local")),
        listOf("debug", "release"),
    )
    println(variants.size)
    val filtered = setOf("paidLocalDebug")
    val asks = listOf(
        resolveVariant("ordered", "freeLocalDebug", variants, filtered),
        resolveVariant("reversed", "localFreeDebug", variants, filtered),
        resolveVariant("filtered", "paidLocalDebug", variants, filtered),
    )
    for (ask in asks) ask.report()
    tally(asks)
}
8
ordered: wanted=freeLocalDebug | given=freeLocalDebug -> full
reversed: wanted=localFreeDebug | reason=no-variant -> unmet
filtered: wanted=paidLocalDebug | reason=excluded-by-filter -> unmet
full: 1
substitute: 0
pending: 0
unmet: 2

When the same two flavors were written in reverse order, the variant was not found: the name is a result of dimension order. The size of the set is the product of the flavor counts and the number of build types: 2 × 2 × 2.

Dimension Order and Product

The order of flavor dimensions matters in two places. The first is the variant name: the name is built in dimension order, as the measurement above showed. The second is conflict: if flavors in two dimensions define the same setting, which one takes precedence comes from the dimension order. This lesson does not measure the second rule; it measures only the first. The script fragment below declares the two dimensions in order.

// module build script
android {
    flavorDimensions += listOf("tier", "market")
    productFlavors {
        create("free") { dimension = "tier" }
        create("paid") { dimension = "tier" }
        create("global") { dimension = "market" }
        create("local") { dimension = "market" }
    }
}

The number of variants is the product of the flavor counts in the dimensions and the number of build types. Adding a third dimension multiplies the number: three dimensions with two flavors each and two build types give sixteen variants. Every variant is built, tested and signed separately; that is why the product calculation is done at the design stage. The filter is the way to remove unnecessary combinations at the start. Details such as whether binding a flavor to a dimension is mandatory in the platform’s flavor definition are outside this model.

PS4. The model takes the dimension order from the order of the flavor list; it does not read the dimension declaration in the build settings.

Source Set Precedence Order

When a variant is built, the source sets that enter it are lined up in an order. The model builds the order from lowest precedence to highest: first the main set, then the flavors’ sets, then the build type’s set, and last the set of the compound variant. Among flavors, the flavor of the dimension declared first has higher precedence, so it enters the list in reverse order. If a file is in more than one set, the last set wins. layersFor builds the order, and resolveLayered searches for the file from the end toward the start.

// Layers.kt — a model of the source set order
fun layersFor(flavors: List<String>, buildType: String): List<String> {
    val variant = flavors.joinToString("") { it.replaceFirstChar { c -> c.uppercase() } }
        .replaceFirstChar { it.lowercase() } + buildType.replaceFirstChar { it.uppercase() }
    return listOf("main") + flavors.reversed() + listOf(buildType, variant)
}

fun resolveLayered(path: String, wanted: String, order: List<String>, sets: Map<String, Set<String>>): Ask {
    val owner = order.reversed().firstOrNull { path in (sets[it] ?: emptySet()) }
    val outcome = if (owner == null) Outcome.Refused("no-file") else Outcome.Given(owner)
    return Ask(path, wanted, outcome)
}

The first measurement has the free flavor and the release build type. The order is built from five sets and three files are asked: one only in the main set, one in both the main set and the flavor set, and one in both the main set and the compound variant set.

// Main.kt — the source set order
fun main() {
    val order = layersFor(listOf("free"), "release")
    println(order.joinToString())
    val sets = mapOf(
        "main" to setOf("res/logo.xml", "res/title.xml", "res/banner.xml"),
        "free" to setOf("res/title.xml"),
        "freeRelease" to setOf("res/banner.xml"),
    )
    val asks = listOf(
        resolveLayered("res/logo.xml", "main", order, sets),
        resolveLayered("res/title.xml", "main", order, sets),
        resolveLayered("res/banner.xml", "main", order, sets),
    )
    for (ask in asks) ask.report()
    tally(asks)
}
main, free, release, freeRelease
res/logo.xml: wanted=main | given=main -> full
res/title.xml: wanted=main | given=free -> substitute
res/banner.xml: wanted=main | given=freeRelease -> substitute
full: 1
substitute: 2
pending: 0
unmet: 0

Whichever set the file is found in, the result is only the name of the highest-precedence set that carries the file. That is why a file requested from the main set counts as a substitute when another set gives it. The second measurement is an edge case: a file found in no set, and a file found in the sets of two flavors.

// Main.kt — edge cases
fun main() {
    val order = layersFor(listOf("free", "local"), "debug")
    println(order.joinToString())
    val sets = mapOf(
        "free" to setOf("res/promo.xml"),
        "local" to setOf("res/promo.xml"),
    )
    val asks = listOf(
        resolveLayered("res/promo.xml", "local", order, sets),
        resolveLayered("res/missing.xml", "main", order, sets),
    )
    for (ask in asks) ask.report()
    tally(asks)
}
main, local, free, debug, freeLocalDebug
res/promo.xml: wanted=local | given=free -> substitute
res/missing.xml: wanted=main | reason=no-file -> unmet
full: 0
substitute: 1
pending: 0
unmet: 1

When the same file was in the sets of two flavors, the model chose the flavor of the dimension declared first. The file requested from the local set was given from the free set: substitute. The platform also looks at the declaration order of the dimensions in this case and gives the win to the flavor of the dimension declared first; a resource file found in the sets of two flavors is not counted as a conflict for this reason.

PS5. The model builds the platform’s four-step order: variant, build type, flavors, main set. The detailed rules of merging resources were not measured; the measurement shows only that the model applies this order.

Source Sets

Every flavor has a source set (source set) that carries its own source code and resources. Directories are built from flavor and variant names:

  • src/main enters every variant.
  • src/free and src/paid enter the respective flavors.
  • src/paidRelease enters only that variant.

The source sets of the same variant are merged in the build, and if a file is in more than one set, the flavor set takes precedence over the main set. For resource files this rule works this way. For source code classes, if the same class is in two sets the result is a conflict and the build stops. The model builds these two distinctions in a single function.

The Bounding Measurement

resolveSourceFile compares a file path against the main set and the flavor set. A file found only in the flavor set comes from the flavor, and one found only in the main set comes from the main set. For a resource file found in both, the flavor wins; a .kt class found in both is a conflict.

// Sources.kt — a model of source set precedence
class SourceSet(val name: String, val files: Set<String>)

fun resolveSourceFile(path: String, wanted: String, main: SourceSet, flavor: SourceSet): Ask {
    val inMain = path in main.files
    val inFlavor = path in flavor.files
    val outcome = when {
        inMain && inFlavor && path.endsWith(".kt") -> Outcome.Refused("class-conflict")
        inFlavor -> Outcome.Given(flavor.name)
        inMain -> Outcome.Given(main.name)
        else -> Outcome.Refused("no-file")
    }
    return Ask(path, wanted, outcome)
}

There are four files in the measurement. An ads class only in the flavor set, a shared image only in the main set, a text resource in both sets, and a billing class in both sets.

// Main.kt — source set precedence
fun main() {
    val main = SourceSet("main", setOf("Billing.kt", "res/logo.xml", "res/strings.xml"))
    val free = SourceSet("free", setOf("Ads.kt", "Billing.kt", "res/strings.xml"))
    val asks = listOf(
        resolveSourceFile("Ads.kt", "free", main, free),
        resolveSourceFile("res/logo.xml", "main", main, free),
        resolveSourceFile("res/strings.xml", "main", main, free),
        resolveSourceFile("Billing.kt", "main", main, free),
    )
    for (ask in asks) ask.report()
    tally(asks)
}
Ads.kt: wanted=free | given=free -> full
res/logo.xml: wanted=main | given=main -> full
res/strings.xml: wanted=main | given=free -> substitute
Billing.kt: wanted=main | reason=class-conflict -> unmet
full: 2
substitute: 1
pending: 0
unmet: 1

The text resource was requested from the main set and given from the flavor set: substitute. For the same pair, Billing.kt was left as a conflict with nothing given. The rule separates the two results only by the given value; the rule does not know that the substitute is a deliberate override in a real build and the conflict is an error.

PS3. The model compares only one main set with a single flavor set. The relative precedence of the variant and build type sets was not measured.

Summary

  • This lesson’s criterion: fulfillment; whether the requested variant or file is given as it is, not whether the input changes. This lesson’s fulfillment: substitute.
  • A product flavor is a named version derived from the same code base; a flavor dimension is the axis along which flavors differ.
  • A build variant is the product of flavors and build types; the name is built in dimension order.
  • A variant in the set is given as full; a variant that is not in the set or is excluded by the filter is unmet.
  • A flavor source set takes precedence over the main set; for a resource file this is a substitute, and for the same source code class it is a conflict.

Next Step

Once the build variants are ready, the app has to be run and tried. The next lesson covers the development environment, the emulator, the device connection tool and the debugging tools.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close