How to build 17 apps with a single Android project and not go crazy
Our background story…
Stepstone Group is an international company connecting employers with employees. It has a wide portfolio of companies which help us reach different markets in different countries. In Mobile Apps team we’ve developed a single framework to build APKs for almost all of the brands the company owns.
So in short — we have a single project from which we’re building 17 different apps. The backbone of these apps is more or less the same. Some features are different or enabled only on some of the brands, but overall the apps look very similar to each other.
The benefits 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 17 separate projects. The development team is also smaller and all brands can benefit from the new features introduced into the project. In our case setting up a new flavor/brand takes ~2–4 weeks when 2 team members are working on it. Most of the work involves testing though, development effort is much smaller.
Below you can find some of our findings which helped us tame this beast ;)
Gradle flavors
This is probably a no-brainer for most of you but 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, version names, strings, drawables, layouts and Java/Kotlin 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 certain flavor and therefore it won’t be included when building other flavors. You can also override the default resources from the main folder.
Please check out the documentation on Android developers website for more info on how to set this up.
Build all flavors on CI
Continuous Integration is a great way to save time and increase product quality in general. When supporting multiple flavors the benefits are even higher due to the volume of building & testing to be done. In our case before sending a build to be verified by QA we have to build the APKs, run static code analysis, run tests and at the end upload the APK to Crashlytics. This takes ~10 minutes on each flavor. Currently we have 17 flavors so this would take almost 3 hours to build on developer’s machine! In Stepstone we use Atlassian Bamboo with multiple machines so that we can run a build plan on each machine simultaneously (we have separate build plans for each flavor). So this becomes ~10–15 minutes instead of 3 hours.
Even if you don’t have enough CI machines to build all the flavors in parallel it’s still better than running this on your own Mac/PC as you can develop other features while CI does the heavy lifting for you!
Now, you might argue that this is not really necessary and running this for ALL the flavors is just an unnecessary waste of time. If the code is almost the same and I change a single label then what can go wrong, right?
Well, we actually took this approach at the beginning i.e. we would only build the flavors we thought would be affected by the changes. While this seemed to work for a vast majority of cases, there were some cases 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 Request unless all builds pass. Building all the brands also makes sure that all the tests pass on all of them. We also run lint, which saved us from making simple mistakes on a number of occasions (read on to see more on that).
Automate!
This hardly needs any explanation and I hope everyone is writing at least unit tests in their app. 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 ~2600 unit/Robolectric tests for base components (stuff in the main folder) and some flavor-specific integration tests for crucial functionalities in the app. This gives us ~40% code coverage, which is not that high but still ensures a decent level of confidence in code reliability and helps us to avoid many mistakes.
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 an 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 and other resources in flavors
Overriding Android resources is useful when you have an app which looks more or less the same on each flavor, but some icons and names are different. As mentioned earlier you can have these defined in each flavor separately, which makes sense e.g. for application names, launcher icons or in general 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 9 out of 10 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/values directory 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.
Don’t trust Android Studio’s assisted refactoring too much…
Android Studio offers a lot of useful tools to make development easier and faster. One of them is a Refactoring tool which will help you move/extract classes and much, much more. The simplest refactoring you can actually do is called Rename (Shift + F6) which allows you to rename any class/method/variable/Android resource. While this is great and useful in general, Android Studio has some problems when dealing with multiple flavors i.e. it will only do the refactoring on the currently selected Build Variant (the one in Build Variants side panel). This can be sometimes painful e.g. if you have a String defined in strings.xml for each brand and you want to rename it’s identifier. In cases like this (when I know the resource I’m renaming is used/overridden in multiple flavors) I usually run an additional check by looking for occurences of the old String identifier via Find in Path in the project (Cmd + Shift + F on Mac).
This is also where having a CI server and building all the flavors really shines as this should pick up the change and fail a build if something is wrong. The next point also really helps with this.
Use lint
Lint is a great tool to ensure the quality of Android applications and avoid errors. As explained in 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.
In here, I would like to highlight what we found specifically 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 flavors (again, 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_categories_name”>Sectors</string>
And an overridden version of this string in one of the flavors:
<string name=”old_categories_name”>Categories</string>
Now, you’ve renamed the identifier 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). Lint would report this as a warning and it would look like this in the HTML report:
Note: this would not fail a build as warnings by default are just reported but don’t stop the build execution. You can change that by adding in your module’s build.gradle:
android {
//...
lintOptions {
warningsAsErrors true
}
}
That’s the setting we use internally to ensure there are no errors of this kind in the project. By doing so if there’s a warning, the entire build fails (which is a good thing!).
Use Dependency Injection with custom module bindings on each flavor
Using Dependency Injection is a great way to make your code clean and testable.
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.
When dealing 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 — see this) 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!
Prefer feature switching via boolean flags
As mentioned at the beginning, in Stepstone we currently support 17 different apps. When writing a new feature in the app we usually write it for a single brand (flavor), see how it performs 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 verifying its performance. Also, sometimes the feature has some backend dependencies and Web Services for some of the flavors are not ready yet. So there are many reasons why we usually start with a single brand.
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=settings_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=settings_feature_A”>true</bool></resources>
Later in code we would use Resources#getBoolean(R.bool.settings_feature_A) to reference this flag and usually have an if statement for either showing this feature or not.
With this approach it’s very easy to enable this feature on the rest of the flavors as you just need to flip the flag (and usually check if the styling isn’t off).
Enable features remotely with Firebase Remote Config
Sometimes you might have a feature ready in the app but you’re waiting for the backend to complete the work on their side. With Firebase Remote Config you can ship the feature enabled on some of the flavors only or even disabled by default. Once the Web Services are ready and deployed you can flip the switch in the Firebase Console and have the feature automatically turned on for your users without uploading a new APK to Google Play!
This is also useful if you discover that there is a bug in one of the features you’ve developed. Worst-case scenario — it’s crashing a lot. With Remote Config you disable this feature and deploy the fix later!
Create custom source sets for common resources
If you can identify groups of flavors which share common resources such as drawables, fonts, colors, classes etc. you might have a dilemma which we had. In our case with 17 flavors some of the flavors had a lot of code & resources which was identical across them. Naturally, some groups started crystallizing…
So how do we group the flavors?
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 as described here and filter them to only contain specific combinations as described here.
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’]
}
}
}
But can we do better?
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= […]).
Limit language resources with resConfigs
If you’re developing a series of apps for different markets in different countries you might be in a situation where you support a lot of different locales in the app. However, as these are separate apps for different markets maybe it doesn’t make much sense to have Chinese resources in an app built specifically for the Polish market?
You might simply put Chinese translations in a separate flavor, but what if 2 out of 10 flavors support Chinese? 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 {
somePolishApp {
resConfigs "en", "pl"
}
}
}
Using resConfigs is recommended even if you don’t use flavors. E.g. you have some third-party UI dependendencies such as AppCompat and you reference an Android String which is defined in one of these dependencies. Your app only supports English, but is also available in non-English speaking countries. If the user’s phone has language set to English it would look like this:
However, if the user’s phone has language set to Spanish this would look like this:
And this doesn’t look good…
To fix this just set:
android {
defaultConfig {
resConfigs "en"
}
}
You also probably shouldn’t reference library Strings in your project directly…
TLDR
Use Gradle flavors & lint and all the great stuff that comes with them!
This is my first post on Medium so please leave feedback so that I can improve in the future ;)
Read more about the technologies we use or take an inside look at our organisation & processes. Interested in working at StepStone? Check out our careers page.