Just recently, I was assigned the task of documenting the Android app architecture for the ioki app. Since this was on my list of blogs I wanted to write anyway, I thought I would seize the opportunity to do so 🙃.
But before we dive into it, let me make one thing clear: even though I am writing about “the ioki app”, there is no single Android app. Instead, we have several apps on white-label basis. For inspiration, you can check out our Play Store page to see how many apps we have produced.
White labeling means that we have a single code base but can produce multiple apps from it. Check out the Wikipedia article on a white-label product if you’re curious about the details.
Now that you understand, let’s dive in!
There are several reasons why I believe it makes sense to share this with the world:
- I’m satisfying my boss with this documentation 😀.
- I’m proud of the architecture we’ve built over the last few years.
- Because we are engaged in white-labeling and not just a single app, it is something special.
- It serves as a public documentation of our architecture. Even if it changes in the future, some parts might be around for a while. New hires could benefit from this documentation.
- Maybe other white-label app factories can benefit from it.
- Why not?
A picture is worth a thousand words. Let’s start with one:
Essentially, we have two different (sub-)projects, wrapped under another “umbrella project”. Specifically, we have three independent (Gradle) composite builds.
The ioki Project, the umbrella, simply merge the other two to work nicely with both at the same time in Android Studio.
While the App contains all of our white-label app implementations, the Library contains the heart — or core — of our app.
The App project
As mentioned above, the App contains white-label app specifics. Here, we define all the build flavors and override various resources, primarily from the Library. This includes colors, strings, images, icons, and app links. Additionally, we have a Kotlin class there (
AppConfig), that defines some keys, secrets, and URLs needed to communicate with different services we use. Furthermore a
google-services.json, to communicate with Firebase, for each build flavor is also defined here.
Apart from our build flavors, we also have additional libraries such as
lib-tickeos. These specific libraries can be optionally added to any build flavor (or white-label app) in case they are needed.
Not all of our customers want to use Airship, while others do. Instead of adding the Airship dependency to our Library code, and then shipping it with all of our apps, we can add the
lib-airship library only for those white-label apps that want this feature enabled.
The arrows in the image define the dependency structure. All white-labels add the
Library:libs:core (we come to this later), and some additionally incorporate the optional libraries mentioned above.
The Library project
Since the image above doesn’t show the full picture of our architecture, here is another one for you 😁:
For architectural and structuring reasons, the Library distinguishes between two different topics: Features and Libs.
While a Lib is usually a thing that can be used in multiple places and defines service-like things, a Feature defines a property of the app itself.
We have a Feature called
feature:ride-creation. This (sub-)project contains everything needed to create a ride. It contains multiple
Views, and whatnot while using different things from Libs. For example, it uses the generic
lib:api (sub-)project, which contains the code to communicate with our backend. But it also uses the
lib:errorlogging which simply logs to
LogCat on a debug build or to
Sentry on a production build.
The “entry point” to the Library is the
libs:core project. This project mainly serves to tie everything in the Library together. It adds all Features as dependencies and a lot of Libs to make the communication between Features easy, while abstracting away the details for each individual Feature.
The dependency flow is well-defined. No Feature is allowed to add another Feature, but as many Libs as needed. While it is not allowed to have cross-dependencies between Features, Libs are free to use any other Libs as dependencies.
Note: All the dependency arrows in the images are examples. They are not true and may not even make sense. They are there for illustration purposes only.
I also wanted to list a few advantages of this architecture here that were once even requirements for us:
- Even though we have a single code base, in the sense of a single Git repository, our projects, App and Library, are logically separate. This is intentional because we have two different teams working on these two different things. We have a Project-Implementation-Team (PIT), which is a cross-platform team that (currently) includes two Android developers. Their main responsibility is to create new white-label apps, so they mainly work on the App part. On the other hand, the Android core team works exclusively on the Library part.
- However, our projects do live in the same repository; they are two projects that are 100% independent of each other. So it is quite easy to also publish stable releases of our Library to our internal Nexus, and consume it from the App via Nexus when needed. This usually happens every two weeks 😉. However, during development, we use the Library project directly and don’t consume the pre-build Library from our Nexus. This separation makes it possible for PIT to create and publish new white-label apps on-demand, without changing the core logic or without shipping new features. As a result, they don’t have to wait for the next release of the Library, which may contain bugs and that may not have been thoroughly tested (yet), to create and publish new apps.
- This separation also makes it a little easier to plug in third-party dependencies only in certain white-label apps — as seen in the
lib-airshipexample above. We don’t need to ship these dependencies with all of our white-label apps. For such dependencies, the Library provides an interface as well as a No-Operation implementation. The NoOp implementation is included by default and doesn’t do anything and certainly doesn’t add any third-party dependencies. However, the interface can be implemented by other projects (such as
lib-airship) and include whatever they want, including third-party dependencies. If a white-label app wants to have this feature enabled, we simply exchange our NoOp implementation with the concrete implementation.
I didn’t go into technical details in this blog because I wanted to create a documentation that can also be read and understood (hopefully 😅) by non-Android developers. I could definitely tell you more technical details, such as; how we manage feature flags, or which build flavors are available in the Library part (hint: only two), and how and why we created our
com.ioki.android.library custom Gradle plugin, and so on. But that was not the goal of this blog, so I left it out.
Anyway, I hope I could give you a short but nice overview of our architecture. This only scratches the surface and the real implementation contains many more details. But for now, this should be enough to not get lost in the details and still have an interesting read on this matter.
Who knows, maybe one day I‘ll publish another blog with more technical details about this 🤓.