ProAndroidDev

The latest posts from Android Professionals and Google Developer Experts.

Follow publication

Inverting the Dependency — Independent Features in Android Applications

--

Working in a cross-functional team on an Android application imposes certain requirements on the code-base. The less structured the codebase is, the more conflicts will surface during the development cycles. Resolving these conflicts takes a lot of time, which could be utilized in a more productive way. Let’s take a look at how modularization and the independence of feature modules can assist cross-functional teams in working on the same code-base.

Looking at the dependency graph of most Android applications, we quickly notice that in most applications there is an app module on the top that knows about all feature modules. The feature modules have dependencies on shared common modules or on a shared core layer. This results in a fairly static architectural shape that makes it easy to introduce hard dependencies between features: A monolith.

It is perfectly fine to build monolithic applications and in some use-cases it is the obvious and preferred choice, but with growing teams and an increasing amount of features within applications, breaking the dependency chain makes it easier to understand parts of the code-base. This belongs in the same group as inheritance. It can make things easier and faster to develop, but if overused introduces code smells that will come back to haunt the developers at some time (if they are still in the company). Unexpected side-effects can be mitigated by ensuring there are proper test-suites in place covering the different layers.

With inter-feature dependencies, the graph turns from a once manageable pyramid into a messy knot of arrows. Depending on the navigation pattern used throughout the application, it becomes as simple as requiring to display a user interface element or a screen within a certain flow to introduce inter-feature dependencies. For more information regarding navigation patterns in modular applications, read this comparison of different approaches.

To avoid long-term problems with an intertwined dependency graph and to move away from a monolithic architecture, we are going to re-imagine features as satellites instead of pillars in our architecture. Features should be outside of our core application, in a place where they can be reached by other features and the core application, but also where it is easy to replace or even remove them.

The purpose of this architectural change is to make the base application independent of features. By removing the features from the base (app — common — core) we gain a couple of things:

Our base application is compilable and runnable with as few or as many of our feature modules as we wish. This means we can build a variant of our application that contains only one feature module to enable encapsulated testing or in a freemium business model we can build variants of the application that contain premium features.

The feature modules are reachable from within the application as well as other features without any hard (or compile-time) dependencies. This allows us to utilize hot loading — features can be added (or removed) seamlessly during runtime (plug and play).

To make full use of this feature-satellite approach, we also need to re-think the way we are accessing features in general. This refers to both accessing features from the base application as well as inter-feature communication. We have imposed a couple of constraints onto ourselves regarding the usage of features:

  • Features may not be in the build variant
  • Features may be loaded during run-time
  • There can be different features implementing the same functionality
  • There can be multiple features implementing the same functionality

To understand how we can use feature satellites while respecting these constraints, we will create a couple of imaginary feature modules with different capabilities to show how to handle different use-cases.

Scenario 1: The feature-satellite provides a service to manipulate data.

Scenario 2: The feature-satellite provides us with certain Views or other UI elements.

Scenario 3: The feature-satellite provides a complex UI flow with multiple screens.

There are multiple ways of achieving our goals. Both Java and the Android SDK offer us tools to achieve our goals, so we will first take a look at how we can express these requirements in code. In each of these scenarios the feature is a service providing a certain action or implementing a certain capability. In the coming examples we will look at this from a Capability point of view, to keep the naming consistent.

Scenario 1: The feature’s capability is to manipulate data.

Scenario 2: The feature’s capability is to create Views or other UI elements.

Scenario 3: The feature’s capability is to guide the user through a complex UI flow.

To express this in a programmatic way, we will create a base interface for any of these capabilities:

To make the examples more specific, we will narrow down our scenarios a bit:

Scenario 1: The feature can remove the saturation of a given Bitmap.

For this scenario we simply create a new capability interface that expresses this:

The feature-satellite would then contain an implementation of this capability.

Scenario 2: The feature can create a button with a text displaying the headline.

With an implementation of the interface that simply inflates an XML contained within the feature module.

Scenario 3: The feature exposes an Activity for a certain UI flow.

To implement the third scenario, we can either make the feature satellite expose an Intent-Filter through the AndroidManifest that matches a certain deep-link configuration, or we can follow the same capability-driven approach:

Again within our feature module we’d provide an implementation that simply starts the activity.

This capability is simply a wrapper around the Activity start. It defers the responsibility of locating the target Activity and instantiating the Intent to the capability’s implementation, thus keeping the base application (or other feature) clean of the implementation details. The actual feature would be within the Activity's scope — it can guide the user through a sign-up process or simply display information. All the base application cares about is that the capability is available and executed.

Each of these interfaces has to be made available in the application’s scope. The interfaces can be for example in the app module or to follow separation of concerns more strictly we can extract them into a capability layer next to the common module layer.

The application (or base module) knows about (i.e. has a compile-time dependency) which capabilities are in the scope of the project. It knows about interfaces, and how to use capabilities — but it doesn’t know where / if these capabilities are implemented.

To find available implementations we can use multiple approaches. The first one would be to fall back to a ServiceLoader . A ServiceLoader finds, loads and assists in the usage of services within the application. The discovery is executed on-demand. If the application needs to be aware of a newly loaded module it has to poll for implementations of a capability in a certain interval or execute the discovery whenever the user wants to execute one of the capabilities.

Moving on from the approaches offered by Java we also have some options provided to us by the Android SDK. Within an Android application there is a central (merged) way of declaring the application’s services: The Android manifest. It contains a list of Activities, Services, Providers (and a lot more). Modules can expose their own manifest, which will be merged into a master manifest either during compile-time or once the module is loaded.

We can now add Intent-Filters to expose certain actions or UI elements and make them accessible via for example deep linking or we make use of other Android features. Our goal is to inject the capability into the application. The application already knows there is for example a GreyscaleBitmapCapability interface. What it doesn’t know is if and where the implementation of this interface is. So whenever the feature satellite that implements this interface is loaded into our application, we want to be aware of the implementation it provides. Within our application we create a capability registry. The application is accessible from all feature satellites which means they can inject themselves and their capabilities into the registry without additional dependencies.

For the injection of our capabilities we will fall back to the Android component ContentProvider. It can be used to capture the start point of an application (or of a feature satellite).

By using a ContentProvider we have switched from a capability polling system to a push-based system. Whenever a new feature satellite is loaded and offers a new implementation of a capability it simply registers itself within our registry. Other satellites (or the application’s base) can register listeners in the registry to be notified of changed capability implementations.

The ContentProvider approach is already in use within the Firebase SDK. In the blog post, the following reasons are stated:

1. They are created and initialized (on the main thread) before all other components, such as Activities, Services, and BroadcastReceivers, after the app process is started.

2. They participate in manifest merging at build time, if they are declared in the manifest of an Android library project. As a result, they are automatically added to the app’s manifest.

Also stated in the blog post is one of the drawbacks of the ContentProvider approach: If one of the Android components within the application are running in another process, that process doesn’t create ContentProviders, thus the approach will not work in multi-process applications.

There are multiple approaches to modularize Android applications — most of them have limitations or drawbacks. All of them aim to reduce the code’s complexity and to make working on the codebase, especially long-term, easier. As a starting point it makes sense to modularize by layers and features. Identify core-parts of your application and extract them into smaller modules to make them more maintainable. In an existing application or with a small development team there may not be enough time or capacity to refactor the application into smaller feature satellites, but luckily this is not needed. New features can be built as satellites if the foundation of the application has already been modularized.

If a core and a common layer have successfully been identified, it is easy to add feature satellites outside of the current application’s scope. With minimal changes in the application setup — setting up the registry for feature satellites and a capability handling system — it is already possible to utilize feature satellites.

If you are interested in feature satellites and seeing a repository with this already set-up, check out my GitHub repository:

I’m always interested in discussions about this topic, feel free to contact me on twitter or post a comment below.

--

--

Published in ProAndroidDev

The latest posts from Android Professionals and Google Developer Experts.

Responses (1)

Write a response