Piotr Zawadzki
StepStone Services
Piotr ZawadzkiPrincipal Android Developer @ StepStone Services

How we managed to build 18 android apps from a single project

Check how Gradle flavors and automation allows you to build multiple apps from one codebase.
24.01.201913 min
How we managed to build 18 android apps from a single project

StepStone wants to be the job market of choice for customers and candidates. We have a simple ambition — to enable organisations achieve better talent success than any other company. We operate over 10 top brands with 60 million visits and more than 600,000 jobs per month.

In the Mobile Apps team we’ve created a single framework to build separate Android (and iOS) applications for most of the brands the company owns.

In short — we have a single project from which we’re building 18 different apps. The backbone of these apps is very similar. Some features are different or enabled only on some of the brands, but all in all the apps look alike.

The biggest gains of having a single project for this is that it’s relatively easy to add a yet another brand and support all of them rather than having 18 separate projects. The dev team is also smaller and all brands can benefit from the new features introduced into the project. With our setup, adding a new flavor takes 2–4 weeks when 2 team members are working on it. Most of the work involves manual and automatic testing though, development effort is much smaller.

Here are some of our findings which helped us tame this beast ;)

Use Gradle flavors!

This should obvious for most of you. To handle multiple variants of the same application we’re using Gradle’s product flavors. It is a great way to have slightly different versions of the app as Gradle together with Android Gradle Plugin handles all the hard stuff for us. This includes e.g. having different package names, drawables, layouts, strings and classes in each flavor. You can have some classes which are not in the base/core/main version of the app (code under the main folder). This is useful when a certain feature is only needed in a single flavor and therefore it shouldn’t be included in others.

See the documentation on Android website for more info on how to use it.

Leverage the power of Continuous Integration to build all flavors

In general, CI is a great way to save time and increase product quality. With multiple flavors the benefits are even higher because building & testing takes more time. In our case before sending a build to QAs we have to build the apps, run tests, run lint with other static code analysis tools and, at the end, upload the APKs to Fabric Beta for distribution. This takes ~10 minutes on each flavor. Currently we have 18 flavors so this would take ~3 hours to build on developer’s machine! We use Atlassian Bamboo with multiple agents so that we can run a build plan on each agent simultaneously (we have separate build plans for each flavor). This becomes ~10–15 minutes instead of 3 hours.

You might ask yourself — if the code is almost the same and I change a single label then should I build all the flavors all the time? What can go wrong, right?

Well, this is the approach we took at the beginning i.e. we would only build flavors we thought would be affected by the changes. This seemed to work for most cases, however there were situations when we accidentally merged some changes which were not working or even compiling on some of the flavors. After internal discussions we decided to play it safe and simply build all flavors. We even enforced this via Atlassian Bitbucket Server so that we cannot merge changes in Pull Requests unless (almost) all builds pass. Building all the brands also makes sure that all the tests pass on all of them.

Test automation

You should write at least unit tests. Period.

The point I wanted to make here is that automatic tests play an even more important role when you have multiple versions of your app as manual testing effort rises together with the number of apps supported.

Currently in Stepstone we have ~3500 unit/Robolectric tests for common components and some flavor-specific integration tests for crucial functionalities in the app.

Regarding integration tests… Let’s say we have a class which uses some Strings placed in strings.xml in Android resources and these Strings are different in each flavor. To test a class like that we typically create a unit test in which we stub the actual values returned via Context#getString(id), but if the actual values are crucial to the app we also add integration tests in relevant flavors with Robolectric. This is so that we check the actual values returned from strings.xml. As these Strings are different in each flavor having a single test in main is not possible.

We don’t do it everywhere though. E.g. if a String resource is some sort of ID/API key/some other constant we don’t write tests for each flavor and verify if it is in fact that value. This would be very time-consuming, would increase maintenance cost and it’s not really worth it even considering the extra layer of protection.

I guess you need to find the right balance here — what’s worth the extra effort of integration tests and what is not. We usually do this for business-critical stuff.

The key point here is to write tests so that you don’t have to test each app manually.

Override strings, icons, etc. per flavor

This is useful when you have an application which looks alike on each flavor with mostly some icons, colors and labels being different. As mentioned earlier you can have these defined in each flavor separately. This makes sense e.g. for application names, launcher icons or any resource which is always different on each brand. In this case it’s best not to put default resources in main so that we know that we need these to be provided (build would fail without them).

Alternatively, you can override resources from the main folder. This can be useful when e.g. you have a label which is the same for 17 out of 18 of your flavors, but in a certain flavor you would like to have it named differently. In this case you just create a new strings.xml file in your flavor’s res/valuesdirectory and put the new String under the same identifier as in main. Gradle will replace this String automatically when building the flavor. Same applies for drawables, layouts, etc.

There is a good documentation on how overriding of resources works on developer.android.com.

Android lint is your friend

Lint is a great tool to ensure the quality of Android applications and avoid errors. According to the doc:

Android Studio provides a code scanning tool called lint that can help you to identify and correct problems with the structural quality of your code without your having to execute the app or write test cases. Each problem detected by the tool is reported with a description message and a severity level so that you can quickly prioritize the critical improvements that need to be made. Also, you can lower the severity level of a problem to ignore issues that are not relevant to your project, or raise the severity level to highlight specific problems.

The lint tool checks your Android project source files for potential bugs and optimization improvements for correctness, security, performance, usability, accessibility, and internationalization.

For use, it is especially useful when dealing with many flavors.

Sometimes we would rename a String/drawable/color resource in XML and forget to rename it in one of the other flavors (don’t trust Android Studio’s assisted refactoring too much…). This could get easily unnoticed if you have a default value somewhere in main. So e.g. you have a String in main’s strings.xml like this:

<string name=”old_name”>Some name</string>

And an overridden version of this string in one of the flavors:

<string name=”old_name”>Some new name</string>


Now, you’ve renamed the name to something else in main, but forgot to rename it in your flavor (as you were working on a different build variant when doing this refactoring). Android lint would report this as a warning. This would not fail the build though, since warnings by default are just reported and don’t stop build execution. You can and should change that by adding in your module’s build.gradle though:

android {
  //...
  
  lintOptions {
    warningsAsErrors true
  }
}

Dependency Injection with custom module bindings

One of the neat features of most DI libraries is the ability to bind classes which implement an interface to that interface (classes to classes too). Among other things, it helps to define a clear API of a class.

With multiple flavors sometimes you’re in a situation where a class encapsulating some business logic has to do something on flavor A and something else on flavor B. In a situation like this you can create an interface (or an abstract class) and two separate implementations — one in flavor A and another in flavor B. Then in each flavor you need to bind the interface to the class you want to use. It’s best to keep these classes in flavor directories — no need for both of them to be in the main folder as only one implementation in a flavor.

To do so with Toothpick (something similar can be done with Dagger) you need to create a flavor-specific module and bind classes in there. So in flavor A you might have a Module like this:

public class MyApplicationModule extends SmoothieApplicationModule {
public MyApplicationModule(Application application) {
    super(application);
    bind(IBar.class).to(BarForFlavorA.class);
  }
}


And in flavor B:

public class MyApplicationModule extends SmoothieApplicationModule {
public MyApplicationModule(Application application) {
    super(application);
    bind(IBar.class).to(BarForFlavorB.class);
  }
}

In main you would also have to install this module like this:

Scope appScope = getBaseScope();

appScope.installModules(new SCApplicationModule(application));

Please check out Toothpick documentation for more details.

Use feature switching via boolean flags

In Stepstone we currently support 18 different apps. When writing a new feature in the app we usually write it for a single flavor, see how it works out for you and then enable it for the rest of the brands. If this is a feature which requires some UI it’s also faster as we do not have to style it across all the flavors at the very beginning before checking its performance.

We solve this by adding boolean flags in Android resources. We create default flags in main and overwrite them in each flavor depending on the required feature set.

E.g. we would create a settings.xml file in main with some new feature disabled by default:

<?xml version=”1.0" encoding=”utf-8"?>
<resources xmlns:tools=”http://schemas.android.com/tools">

<bool name=some_feature_A”>false</bool>

</resources>

and in a flavor which wants this feature we would have:

<?xml version=”1.0" encoding=”utf-8"?>
<resources xmlns:tools=”http://schemas.android.com/tools">

<bool name=some_feature_A”>true</bool>

</resources>

Later in code we would use Resources#getBoolean(R.bool.some_feature_A) to reference this flag and usually have an if statement for either showing this feature or not.

Prepare custom source sets for common stuff

If you can identify groups of flavors which share common resources such as drawables, fonts, classes etc. you might have an issue we also had. In our case, with 18 brands some of the flavors had a lot of code & resources which were duplicated. There was a need to group them.

How to solve this?

One option is to copy-paste these resources in each applicable flavor and have duplicates (which is never nice).

A second option is to keep all of these resources in main, which would lead to redundant resources shipped with each app.

A third option is to create more flavor dimensions and filter them to only contain specific combinations.

How would that work?

Let’s assume we have 3 flavors: flavor A, flavor B & flavor C. Flavor A & flavor B share some common resources. We could create a separate flavor dimension for the shared part. E.g.:

android {

flavorDimensions "brand", "type"

productFlavors {
    flavorA {
      dimension "brand"
    }
    flavorB {
      dimension "brand"
    }
    flavorC {
      dimension "brand"
    }

baseType {
      dimension "type"
    }
    sharedType {
      dimension "type"
    }
  }
}


Valid flavor combinations here would be flavorASharedType, flavorBSharedType & flavorCBaseType. We would put the shared resources in sharedType flavor.

This has some downsides though (even if you filter only valid filter combinations)…

First of all, the more shared resources you create (the more flavor dimensions you create) the harder it is to remember the Gradle task to execute. Imagine having a task like assembleFlavorASharedTypeCustomFontWithBackground. Yikes…

Secondly, adding a new flavor dimension changes the task names e.g. for building an APK. In our case this would also mean updating 17 build plans on our CI as CI needs to know what task to execute…

Lastly, in my opinion flavor dimensions aren’t simply the right tool to achieve this. They should be used if we can create different combinations of all dimensions e.g. the first dimension could be whether the app is “free” or “paid” and the second could be whether it should be published to Google Play Store or to Samsung Apps.

So… what else is there?

Gradle offers something even simpler which we could leverage here and that’s something called source sets.

In the case described above we could put the resources shared among flavor A & flavor B to a new folder called e.g. sharedType and reference this folder in the source sets like this:

android {
  productFlavors {
    flavorA {}
    flavorB {}
    flavorC {}
  }
  
  sourceSets {
    flavorA {
      java.srcDirs = [‘src/flavorA/java’, ‘src/sharedType/java’]
      res.srcDirs = [‘src/flavorA/res’, ‘src/sharedType/res’]
      assets.srcDirs = [‘src/flavorA/assets’, ‘src/sharedType/assets’]
    }
    flavorB {
      java.srcDirs = [‘src/flavorB/java’, ‘src/sharedType/java’]
      res.srcDirs = [‘src/flavorB/res’, ‘src/sharedType/res’]
      assets.srcDirs = [‘src/flavorB/assets’, ‘src/sharedType/assets’]
    }
  }
}


Can we do better?

Of course! The more flavors you have & the more different shared resources you identify the harder this can get. Let’s say you have 5 different base flavors and among these flavors you identified 3 different groups of shared resources. Also, each flavor uses different shared resources groups:

Setting this up manually in source sets can get very painful at this point…

Luckily, we’re using Gradle so we can do almost anything! For instance create source sets dynamically like this:

def CONFIGURATION_COMMON_SOURCES_1 = 'commonsources1'
def CONFIGURATION_COMMON_SOURCES_2 = 'commonsources2'
def CONFIGURATION_COMMON_SOURCES_3 = 'commonsources3'

android {
  productFlavors {
    flavorA {
      ext.configurations = [CONFIGURATION_COMMON_SOURCES_1, CONFIGURATION_COMMON_SOURCES_2]
    }
    flavorB {
      ext.configurations = [CONFIGURATION_COMMON_SOURCES_2]
    }
    flavorC {
      ext.configurations = [CONFIGURATION_COMMON_SOURCES_1, CONFIGURATION_COMMON_SOURCES_3]
    }
    flavorD {
      ext.configurations = [CONFIGURATION_COMMON_SOURCES_2, CONFIGURATION_COMMON_SOURCES_3]
    }
    flavorE {
      ext.configurations = [CONFIGURATION_COMMON_SOURCES_2, CONFIGURATION_COMMON_SOURCES_3]
    }
  }

//Configure common source set configurations per flavor
  productFlavors.all { flavor ->
    if (flavor.hasProperty('configurations')) {
      def flavorSourceSet = project.android.sourceSets.getByName(flavor.name)
      for (String config : flavor.configurations) {
        flavorSourceSet.java.srcDirs "src/${config}/java"
        flavorSourceSet.res.srcDirs "src/${config}/res"
        flavorSourceSet.assets.srcDirs "src/${config}/assets"
      }
    }
  }
}


With this approach adding new common source sets is fast and requires little changes. You just need to define a new name for the common configuration in build.gradle, put the common resources in a folder named like it and in selected flavors in build.gradle add it to the configuration array (ext.configurations= […]).

Use resConfigs to limit language resources

If you’re developing a number of apps for different audiences in different countries you might be in a situation where you support a lot of locales in the app. However, as these are apps for different markets maybe it doesn’t make sense to have Japanese resources in an app built specifically for the German market?

You might simply put Japanese translations in a separate flavor, but what if 2 out of 10 flavors support Japanese? You might argue that you could put those in a common source set, but there is an easier way…

Android Gradle Plugin has an option to add resource configuration filters on your flavors:

/**
* Adds several resource configuration filters.
*
* <p>If a qualifier value is passed, then all other resources using a qualifier of the same
* type but of different value will be ignored from the final packaging of the APK.
*
* <p>For instance, specifying ‘hdpi’, will ignore all resources using mdpi, xhdpi, etc…
*
* <p>To package only the localization languages your app includes as string resources, specify
* ‘auto’. For example, if your app includes string resources for ‘values-en’ and ‘values-fr’,
* and its dependencies provide ‘values-en’ and ‘values-ja’, Gradle packages only the
* ‘values-en’ and ‘values-fr’ resources from the app and its dependencies. Gradle does not
* package ‘values-ja’ resources in the final APK.
*/
public void resConfigs(@NonNull Collection<String> config) {
  addResourceConfigurations(config);
}


So you could put all your translations in main and then limit the needed resources for each flavor by adding a single line, e.g.

android {
  productFlavors {
    someGermanApp {
      resConfigs "en", "de"
    }
  }
}


TL;DR

Use Gradle flavors, Android lint and automate!

<p>Loading...</p>