Modernizing our Android build system: Part II, the execution

What does an engineer do after planning and decision on an approach? More planning! This time, the planning was laser-focused on what features to build into the new Android build system. Our team created a doc which outlined the milestones of the project and shared it to our internal customers for feedback. Subsequently, we agreed on the project scope and the key ideas for the migration.

Foundation for migration

Our goal in implementing a Gradle-only solution was to introduce flexibility, but also keep some of the great features BMBF had: guaranteed layered dependency order and reduced boilerplate in build files. We had to lay down the foundation before we could remove BMBF…

Layered Dependencies
At Dropbox, we add our modules into different layers. This is different from a MVx layer that one might be familiar with.

Each module is in a layer, determined by the direct subdirectory of dbx/ that it’s in. Layers are used to give a quick broad sense of the scope of a module and to enforce high-level dependency constraints.

Layers are ordered from “top” to “bottom,” which gives some high-level structure to the app and its dependencies. The layers also indicate the scope of the modules in them. The higher layers tend to be more specific and narrow in scope, while the lower layers are more general and broader.

The 4 valid layers are listed below, in order from top to bottom, along with descriptions of the scope that modules in each layer should have.

Layer Name Layer Description
product Modules relating to a single Product (eg Paper or Dropbox). Modules in this layer will typically be under a subdirectory specifying which product they’re part of.
core Dropbox-related modules that are shared between multiple products. For example, Stormcrow (our gating library) lives in this layer.
base Non-Dropbox-specific modules that are common utilities. For instance, our HTTP libraries are in this layer.
external Code not written at Dropbox which cannot be pulled in as a library binary.

To give a concrete example, the module at dbx/core/stormcrow is in the core layer (because that is the immediate subdirectory of dbx/ that it’s in). It’s in core because Stormcrow is a Dropbox-specific concept, but is used by both DBApp and Paper. Since it is in core, this module cannot depend on a module in product, but can depend on other core modules, as well as base and external modules.

In BMBF, the layered verifier was written in Python (since BMBF was written in Python) and there was a Gradle task that made a call to this Python script every time the app was built. This was a waste of resource because there was no good way to cache the task. We wanted to make the code maintainable by any mobile engineer, so we decided to add a layered verifier in buildSrc written in Kotlin. In addition, we got Gradle UP-TO-DATE checks for free.

Reducing Boilerplate
We wanted to allow an engineer to create a new module and not have to copy and paste a block of Gradle boilerplate from another module. We were able to achieve this by having a common Gradle file that defined what to apply for subprojects. In the end, most engineers can now create a new module and only need to focus on adding dependencies they require to their projects. Since the build.gradle files are no longer autogenerated by BMBF, engineers can now also define logic in their scripts that are not accounted for by the build system.

common.gradle

subprojects { Project project ->
    project.apply from: xplatRoot.absolutePath + "/tools/gradle/test_results_formatter.gradle"
    project.plugins.withId('com.android.library') {
        project.apply plugin: 'kotlin-android'

        if (project.hasProperty('apply_jacoco_plugin') {
            // Runs in CI or when a local dev enables this property
            project.apply from: xplatRoot.absolutePath + "/tools/gradle/jacoco_test_coverage.gradle"
        }

        project.android {
            compileSdkVersion androidCompileSdkVersion
            buildToolsVersion androidBuildToolsVersion

            lintOptions {
                ignore 'MissingTranslation'
            }

            defaultConfig {
                minSdkVersion androidMinSdkVersion
                targetSdkVersion androidTargetSdkVersion
                testInstrumentationRunner 'com.dropbox.base.test.runner.DbxBaseTestRunner'
                // This is needed for when we build this as a standalone target,
                // which will typically be in tests.
                multiDexEnabled true
            }

            compileOptions {
                sourceCompatibility androidSourceCompatibility
                targetCompatibility androidTargetCompatibility
            }
        }
    }

Link to gist

With the common Gradle code in place, an engineer can write simple build.gradle files and we don’t need to copy and paste boilerplate around. Also, if we decide to make changes to the common code, it’s very simple since we can do it in one place.

build.gradle

apply plugin: 'com.android.library'

dependencies {
    api project(':dbx:base:analytics_gen')
    implementation project(':dbx:base:error')
    implementation project(':dbx:base:json')
    implementation project(':dbx:base:oxygen')
    implementation commonlibs.guava
}

Link to gist

Migration

After laying down the foundation, we were ready to remove BMBF from autogenerating Gradle build files. This meant we needed to check-in all the autogenerated Gradle files. We made changes to the generators so that they would create the simplified versions of build.gradle based on the foundational work we did on the common Gradle code.

This was a high-impact change, so we decided to do it immediately after a release went out, and we forbid changes to the repository until this change was landed. As one could imagine, some engineers had created some new modules while we were migrating. For those engineers, we had a back-channel to allow them to force migrate their BMBF config files to build.gradle. The transition was pretty smooth. Looking back, we realize that overcommunication was the key to our success. At every stage of our project, we communicated with our customers (mobile engineers) about our goals and what we planned to do. For high-impact changes like this, we sent out emails, Slack messages, and mentioned it in our weekly cross-functional mobile meetings**.

We planned out our work so that for the week after we made this transition we were sure to reserved some capacity to deal with issues that engineers might face.

Post-migration

At this point, you might think, “You already migrated all the code to Gradle and got rid of BMBF build.gradle generation… all done!”

The migration work unlocked the potential to clean up our directory structure—which was also the biggest time sink in our build times—so it allowed us to find other ways to improve build times.

BMBF used a non-standard directory structure for Android. Also the BMBF modules were not in the same root as our Android project, which meant additional boilerplate to our settings.gradle files.

/xplat/dbx/… → BMBF modules
/xplat/android/… → Android projects

BMBF structure (Before) Gradle default structure (After)
java/src src/main/java
android/src/AndroidManifest.xml src/main/AndroidManifest.xml
jvm_test/src test/main/java
android_test/src androidTest/main/java

There were 75 modules written in BMBF and migrating them by hand would not have been very efficient. Mobile Platform could have required all product teams to migrate their modules to standard Gradle. Instead, we chose to tackle this problem by writing a script that would move all of the code into the right directory. In the end, this migration script was a 500 line Python file that accurately determined which BMBF modules needed to be migrated, updated the imports in the source files, and updated the project dependencies in the build.gradle files.

BMBF-lite
We made the decision to not completely remove BMBF because it was still responsible for code generation for Djinni, gating (Stormcrow) ADL files, and analytics ADL files. Previous to our project, the code generation was called every…single…time an engineer triggered the build, even though no changes were made to the source files. Another optimization we made was to introduce Watchman to watch the source directory and use it as an @Input and @Ouptut for our Gradle task. Watchman tells Gradle when to re-run the task and when the task is up to date.

This along with a few other minor improvements reduced our P50 local build times by 20%.

watchman.gradle

/*
 * Copyright (c) 2019, Dropbox, Inc. All rights reserved.
 */

// Watchman generates a json file to depict the xplat file structure filtered on "bmbf source" files
task watchmanCheckIfCodegenNeeded(type:Exec) {
    File outputJson = new File(project.buildDir, "changed-files.json")
    File watchmanJson = new File(xplatRoot, "tools/watchman/watchman-bmbf.json")

    workingDir xplatRoot
    commandLine "bash", "-c", "watchman watch-project $xplatRoot"
    commandLine "bash", "-c", "watchman -j < $watchmanJson.absolutePath"

    doFirst {
        standardOutput new ByteArrayOutputStream()
    }

    doLast {
        // Remove this piece of data that changes on every run (even with no modifications to the files)
        def filteredText = standardOutput.toString().replaceFirst(".*\"clock\".*\n", "")
        if (outputJson.exists()) {
            outputJson.delete()
        }
        outputJson << filteredText
        logger.lifecycle("Watchman query for BMBF files done: " + outputJson)
    }

    // Save the json as the output so other tasks can reference it easily
    outputs.files { outputJson }
    // Always run this task
    outputs.upToDateWhen { false }
}

Link to gist

Conclusion

Initially, we had a custom build system that solved our use cases across both platforms. Over time the customizations and costs outweighed the gains. Rather than continuing with BMBF we decided to split iOS and Android build systems to better take advantages of platform specific needs and tools. While this meant we lost some shared patterns and code we gained flexibility in not having a shared build system. Therefore, we made a decision to closer align with industry standards by going to Gradle and leaving the door open to re-evaluate Bazel in the future. As the mobile world continues to move at a blinding pace we want to be ready to adapt our architecture to support it for years to come. Finally, we were able to do this all with minimal interruption to Android engineers and without any additional boilerplate.

We’re Hiring! We hope to have you on board to help us make such dramatic changes in the future. If you are an Android or iOS engineer who gets excited about solving problems at scale and sharing your findings with the community we’d love for you to come join the team!

Started in the middle? You can find part I here.