Further simplification of Android app distribution with Beta by Crashlytics

A while back, I wrote about simplifying our app distribution process using Beta by Crashlytics.

Simplifying App Distribution with Beta by Crashlytics 

Since that time, I had been thinking (and receiving questions) about how to handle multiple buildTypes and productFlavors more gracefully. When I originally described our approach we only needed to worry about a single build target. After a while, we added a second productFlavor and the fastest solution was to simply copy our custom gradle tasks and make new versions for the new build target.

That solution got us up and running quickly, but it always bothered me that we now had a sizable chunk of duplicate code in our gradle file. When it came time to add yet another product flavor, the time had come to think about a better solution

Thankfully, it was pretty easy to leverage the power of gradle to create custom distribution tasks for each buildType/productFlavor combination without having to manually duplicate any code.

 

Updated Nov 29, 2016:

A helpful redditor pointed out that there was a much more elegant way of achieving the same functionality than my previous example. Using their suggestion as a starting point, I was able to further simplify the generation of our tasks. In fact, this new version doesn’t need to generate tasks at all. It simply modifies the behavior of the existing crashlyticsUploadDistribution${variantName} tasks that already exist.

android.applicationVariants
    .matching{variant -> variant.name.capitalize().contains("Release")}
    .all { variant ->
        def variantName = variant.name.capitalize()
        def task = tasks["crashlyticsUploadDistribution${variantName}"]
        task.mustRunAfter("clean")
        task.dependsOn("assemble${variantName}")
        task.setGroup("crashlytics")
        task.setDescription("Performs a clean, then assembles ${variantName} and uploads it to Crashlytics")
    }

We now use the android.applicationVariants DomainObjectCollection to get all application variants (thereby handling multi-dimensionality), filter that collection to only Release variants, and then modify the corresponding crashlyticsUploadDistribution${variantName} task to fit our needs.

We start by ensuring that clean runs before we build/upload. We then make the upload task depend on assemble${variantName} so the desired apk is built.

Setting the group and description for the task allow us to more easily find the modified tasks, and provides a more useful description for those unfamiliar with them.

 


Deprecated method of simplification. Check above update

** If you want to jump straight to the new code, scroll to the bottom to skip the explanation.

To start, we still define a custom group name so we can easily view our tasks in the “Gradle Projects” window in Android Studio. We then get lists of names of each release buildType and every productFlavor. We will use those names to build the names of our custom tasks.

*To use all buildTypes, not just Release, simply omit the call to findAll { ... }.

def GROUP_NAME = "custom_group_name"
// get names of release build types and all product flavors
def buildTypes = android.buildTypes.collect { type -> type.name }.findAll {
    it.toLowerCase().contains("release")
}
def productFlavors = android.productFlavors.collect { flavor -> flavor.name }

Next, we iterate over the names and build up the targetName for each task. If you have a buildType named release and a productFlavor named mainApp the targetName will look like MainAppRelease. This is done so we can reference that name in calling/creating tasks such as assembleMainAppRelease.

** This version doesn’t handle multi-dimensional product flavors. View the update above for a version that does

productFlavors.each { productFlavorName ->
  buildTypes.each { buildTypeName ->
    // Create variant and target names
    def flavNameCapitalized = "${productFlavorName.capitalize()}"
    def buildNameCapitalized = "${buildTypeName.capitalize()}"
    def targetName = "${flavNameCapitalized}${buildNameCapitalized}"
    .
    .
    .
}
}

We then create a task that performs a clean then use our target name to generate an assemble command to build our output apk. If our target name is MainAppRelease our task will be finalized by assembleMainAppRelease.

productFlavors.each { productFlavorName ->
  buildTypes.each { buildTypeName ->
    .
    .
    .
    
    // Create task to clean, then assemble a release variant
    //
    def cleanAndBuildName = "cleanAndAssemble${targetName}"
    def cleanAndBuildTask = tasks.create(name: cleanAndBuildName) {
      group = GROUP_NAME
      description = "cleans and assembles ${targetName}"
    }
    cleanAndBuildTask.dependsOn("clean")
    cleanAndBuildTask.finalizedBy("assemble${targetName}").
    .
    .
    .
}
}

Lastly, we create a task that depends on the previous one, then calls the appropriate crashlyticsUploadDistribution command for the build target.

productFlavors.each { productFlavorName ->
  buildTypes.each { buildTypeName ->
    .
    .
    .

    // Create a task to send release variant to Crashlytics
    //
    def sendToCrashName = "send${targetName}ToCrashlytics"
    def sendToCrash = tasks.create(name: sendToCrashName) {
      group = GROUP_NAME
      description = "sends ${targetName} to crashlytics"
    }
    sendToCrash.dependsOn(cleanAndBuildTask)
 sendToCrash.finalizedBy("crashlyticsUploadDistribution${targetName}")
}
}

The full code to generate our distribution commands looks like this:

def GROUP_NAME = "custom_group_name"
// get names of release build types and all product flavors
def buildTypes = android.buildTypes.collect { type -> type.name }.findAll {
    it.toLowerCase().contains("release")
}
def productFlavors = android.productFlavors.collect { flavor -> flavor.name }
productFlavors.each { productFlavorName ->
  buildTypes.each { buildTypeName ->
    // Create variant and target names
    def flavNameCapitalized = "${productFlavorName.capitalize()}"
    def buildNameCapitalized = "${buildTypeName.capitalize()}"
    def targetName = "${flavNameCapitalized}${buildNameCapitalized}"

    // Create task to clean, then assemble a release variant
    //
    def cleanAndBuildName = "cleanAndAssemble${targetName}"
    def cleanAndBuildTask = tasks.create(name: cleanAndBuildName) {
        group = GROUP_NAME
        description = "cleans and assembles ${targetName}"
    }
    cleanAndBuildTask.dependsOn("clean")
    cleanAndBuildTask.finalizedBy("assemble${targetName}")

    // Create a task to send release variant to Crashlytics
    //
    def sendToCrashName = "send${targetName}ToCrashlytics"
    def sendToCrash = tasks.create(name: sendToCrashName) {
      group = GROUP_NAME
      description = "sends ${targetName} to crashlytics"
    }
    sendToCrash.dependsOn(cleanAndBuildTask)
 sendToCrash.finalizedBy("crashlyticsUploadDistribution${targetName}")
    }
}

Now, when we add a new productFlavor we will have our Crashlytics distribution task ready for us without having to recreate any code. This is less error prone, and reduces the size of our gradle file which are both nice wins.

 

Subscribe

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.