Back to all blog-posts...

Developing Two Individual Products From A Single Shared Codebase

by David Mcauslan on July 13, 2021

Back in 2018 we released our new flagship display unit for ground agricultural vehicles - TML. TML was a modern, touchscreen device that was built on Android. The release of TML went so well that in 2020 we decided to build on the success of this product and develop an aviation version, which we would end up calling TML-A. Aviation customers needed new features such as improved guidance, hazard placement and alerts, and integration with lightbars; also, it was a good opportunity to create an updated UI which was better suited for aviation.

TML Screen
Our existing product for agricultural vehicles on ground - TML.
TML Screen
TML-A - our new aviation product based off TML.

We wanted to use the existing TML code as a starting point, so that we didn’t have to build a new product from scratch. This left us with two options, we could either fork the existing code base and have a separate repository for each project, or we could use the same code base to build both products. Both approaches have their pros and cons. Forking the code base means any changes or bug fixes to common code has to be duplicated in both repositories, and unless you are diligent in keeping both products up to date one runs the risk of falling behind. However; if you keep the code shared, then you add complexity in development and add an extra testing burden e.g. if you add a new feature to one product, you have to test both to make sure it doesn’t accidentally cause a bug in the other. We decided to go for option 2 - sharing the code.

Modularize the App

The current TML codebase was structured into a single App module. The first step in being able to build TML-A on top of the existing TML codebase was to refactor the code into multiple modules so that the ground only code could be kept separate from the aviation only code. At a minimum we decided we needed separate Ground and Aviation modules as well as a Core module for containing code that was common between both products.

We started by creating the Ground module and moving any code related to features that would not be in TML aviation into it. Since we were in the process of modularizing the app we then decided to see whether there were any other parts of the codebase where it made sense to split it off into its own module. We ended up with the module structure shown in the diagram below, where the upper modules are dependent on the modules below them:

Diagram

App - small module which is the entry point to the application.

Ground - code for features that are only on the ground version of TML.

Aviation - code for features that are only on the aviation version of TML.

Core - the largest module which contains features that are common to both ground and aviation TML versions.

Database - code for accessing the database. This was split from the core module to speed up build times as well as decrease test times in CI.

Utilities - a module to contain utility functions.

CommonLibraries - many of the modules rely on the same third party libraries, so they are included here to ensure that different modules are using the same versions.

Product Flavors

Now that our code had been rearranged into modules, we needed a way to be able to create separate builds for both ground and aviation, so we can build either a ground apk, or an aviation apk. As well as this, the last thing we wanted to do was have if-else statements scattered throughout the code where aviation and ground required different logic. This would be a nightmare for maintainability e.g:

if (build == AVIATION) {
  // Run aviation logic
} else {
  // Run ground logic
}

Luckily Android has a feature called Product Flavors that is perfect for this use case. To use product flavors all you need to do is add a productFlavors block to your build.gradle file e.g.

flavorDimensions 'default'
productFlavors {
  ground.dimension 'default'
  aviation.dimension 'default'
}

After doing this, when you go to create a release build Android Studio will ask you whether you want to make a Ground or Aviation release build. Using product flavors you can include code in a specific build by placing it in a different folder.

For a simple app with no product flavours you would typically have the following directory structure:

|-- moduleName
    |-- src
        |-- main
        |   |-- java
        |   |   |-- appPackageName      // Java/Kotlin code
        |   |-- res                     // Xml resources
        |-- test                        // Unit tests

When you add product flavors to your project the directory structure becomes:

|-- moduleName
   |-- src
       |-- main
       |   |-- java
       |   |   |-- appPackageName      // Common Java/Kotlin code
       |   |-- res                     // Common xml resources
       |-- aviation
       |   |-- java
       |   |   |-- appPackageName      // Aviation Java/Kotlin code
       |   |-- res                     // Aviation xml resources
       |-- ground
       |   |-- java
       |   |   |-- appPackageName      // Ground Java/Kotlin code
       |   |-- res                     // Ground xml resources
       |-- test                        // Common unit tests
       |-- testAviation                // Aviation unit tests
       |-- testGround                  // Ground unit tests

When you do a build it searches first in the appropriate folder for that product flavour and if it doesn’t find the file it needs it searches in the main directory. For example, if I want to use a different layout for the MainActivity of the ground and aviation builds I would have two different layout_main.xml files; one in src/aviation/res/layout and the other in src/ground/res/layout.

Inject Dependencies

If most of the shared code is in the Core module, how do we call code from either the Ground or Aviation modules, considering that Core is a dependency of both Ground and Aviation. As a simple example, if in the Core module we have a Map class which calls a Guidance class to render guidance lines e.g.

class Map() {
  private val guidance = Guidance()

  fun render() {
      guidance.renderLines()
  }
}

After adding the product flavors, if we now require a different implementation of renderLines() for the ground and aviation builds we can extract an interface from Guidance, and use dependency injection to inject the correct Guidance class based on product flavor e.g.

interface IGuidance {
  fun renderLines()
}

We were already using Koin as our dependency injection framework. We can create separate modules.kt files in the Ground and Aviation modules - this is where we define how the dependencies are constructed. This lets us use a different implementation of IGuidance for the ground and aviation build types e.g.

val aviationModule = module {
  factory<IGuidance> { Guidance() }
}

And then Map can be altered to use the implementation that is provided by Koin e.g.

class Map() {
  private val guidance: IGuidance by inject()

  fun render() {
      guidance.renderLines()
  }
}

So now the common Map class which is in the Core module will load either the Ground or Aviation Implementation of IGuidance depending on whether the aviation or ground product flavor is compiled.