O(1) Android build time at Tiki

The problem

Build time has always been a big issue for Android developers. In order to reduce build time, developers usually split the project into multiple modules to take advantage of the Gradle’s parallel execution feature. At Tiki, we modularize our project with the “per feature” approach, which means each feature will sit in its own module.

When developing new features or maintaining old ones, Tiki developers usually have to make changes in the :data module which causes the whole project to recompile because most of our feature modules are based upon it. The whole process would take about 5–8 minutes. The more modules/features you have, the more time the process will take to build. We call this O(n) build time.

We want to move fast, hence developing new features must be fast, which leads to the core issue that our build time has to be fast. 5–8 minutes is nowhere near fast because every day there are around 10–20 builds to be made. Wasting a total of 50–160 minutes per day is just not productive at all.

The solution

As it turns out, Gradle doesn’t need to recompile affected modules if they are not included in the project in the first place.

Say you are working on the Home Screen in :home module, you only care about the changes only on the Home Screen (because recompiling other feature modules makes no sense to you, it is just a waste of time!). For that reason, we decided to exclude other feature modules for the time being so that Gradle doesn’t have to recompile them.

The idea is simple, we will use the Gradle command line with a customized parameter to know which module we should include in that build.

./gradlew :app:installDebug -Ponly="home"
// app/build.gradle
apply plugin: 'com.android.application'

def include(moduleName) {
    if (hasProperty('only')) {
        return only.split(",").contains(moduleName)
    }
    return true
}

dependencies {
    implementation 'androidx.appcompat:appcompat:1.0.2'
    if (include("home")) {
        implementation project(":home")
    }
    if (include("detail")) {
        implementation project(":detail")
    }
    if (include("search")) {
        implementation project(":search")
    }
}

For this particular build command, only 3 modules get recompiled if something changes in the :data module.

Implementation detail

In this Github project shown below, I have made an example project to show you how this idea can be implemented. In the next section, I will discuss some of the challenges we face while trying to apply this idea into our codebase.

https://github.com/nlgtuankiet/modularization

First and foremost, we have to make the app compiled. Since all of the feature modules are excluded from the project, :app module can’t resolve the class you referred to in the feature module. The number of references may vary depends on the codebase. For our codebase, there are only 2 references:

The activity class: we use a static method in activity class to retrieve the starter intent for navigation. To remove this reference, the most simple and straightforward way is using reflection.

// :detail module

class DetailActivity : DaggerAppCompatActivity() {
    // omitted

    companion object {
        @JvmStatic
        @Keep
        fun starterIntent(context: Context, productId: String): Intent {
            return Intent(context, DetailActivity::class.java).apply {
                putExtra("productId", productId)
            }
        }
    }
}

Although this approach is simple to implement but is come with some reflection costs, in the example project we take another approach which leverages Dagger 2’s @BindOptionalOf to get rid of the cons with reflection. I won’t go any further since this relates to Dagger 2, but you can check out the source code to see how it’s done.

The activity’s dagger module: resulting fromDagger 2 best practice, each activity has the corresponding module and AppComponent in :app module references to all of these modules.

// :app
@Singleton
@Component(
    modules = [
        AndroidSupportInjectionModule::class,
        HomeActivityModule::class,
        DetailActivityModule::class,
        SearchActivityModule::class
    ]
)
interface AppComponent

// :home
@Module
interface HomeActivityModule {
    @ContributesAndroidInjector
    fun activity(): HomeActivity
}

In the beginning, we try to make a stand-alone Component for each activity instead of Subcomponent from AppComponent, therefore AppComponent, no longer needs to reference the activity’s module. Refactor from Subcomponent to Component is time-consuming and we have a lot of modules to refactor. This approach would solve the problem but it will also require a lot of time (and we don’t want that!).

In the end, we come up with a simple technique called fake source, creating a fake source set to mirror the missing real source

and include these source sets corresponding to the excluded module.

// app/build.gradle
def include(moduleName) {
    if (hasProperty('only')) {
        return only.split(",").contains(moduleName)
    }
    return true
}
android {
    sourceSets {
        if (project.hasProperty("only")) {
            new File(projectDir, "src/fake").listFiles().each { f ->
                if (!include(f.name)) {
                    // include fake source set
                    main.java.srcDir "src/fake/${f.name}"
                }
            }
        }
    }
}

Fake source compared to the real source:

// real source
package com.nlgtuankiet.modularization.home

import dagger.Module
import dagger.android.ContributesAndroidInjector

@Module
interface HomeActivityModule {
    @ContributesAndroidInjector
    fun activity(): HomeActivity
}


// fake source (app/src/fake/home/HomeActivityModule.kt)
package com.nlgtuankiet.modularization.home

import dagger.Module

@Module
interface HomeActivityModule

This way, AppComponent now reference an empty module and compile successfully. Problem solved!

There is still one more issue. Since most all of the screens are excluded from the debug APK, how can we open the screen that we are developing?
This can easily be done with a deep link using ADB command:

adb shell am start -a android.intent.action.VIEW -d sample://sample

The result

After implementing this idea to the Tiki Android project, the build time has reduced by half. One of the bonus from this is the debug APK size become really small since all of the app’s feature is excluded, this will also save a lot of time when deploying the APK to test device.

As you can see, with this setup, it doesn’t matter how big your project is or how many modules you have, your build time still remains the same, and that is why we call this O(1) build time.

The next step

1–2 minutes is quite good, but that we think we can always do better than that.15 seconds is the number we are aiming for, how can we do that? Stay tuned for the next part!

Author: Tuan Kiet

Please follow and like us:

Leave a Reply

Your email address will not be published. Required fields are marked *