Recently at work, my team started taking on the challenge of modularising our app, one of the first things we started grappling with conceptually was navigation; more specifically, how do we perform top level navigation or cross feature module navigation in a dynamic feature module setup? Before making any rash decisions I wanted to gain a better understanding of this problem by trying it out on a smaller scale first, hence I decided to use a pet project of mine as a guinea pig.

In this post I will outline the results of this little experiment and go through some navigation patterns that I think worked out well when navigating between destinations that are located within dynamic feature modules. I will not cover what dynamic feature modules are in any great detail, if you want to know more about them, please refer to here.

Top level navigation

When I say top level navigation I mean the different destinations that the user can navigate to from the home screen of an application, for example, if the home screen has a BottomNavigationView it would be the destinations one could navigate to through it.

This is commonly a master-detail type screen, where the master displays common app chrome (such as BottomNavigationView , DrawerLayout , Toolbar etc.) and the detail displays the actual content of the destination. In this type of flow there should be a certain fluency in terms of the UX — common app chrome should stay where it is while the content is replaced, preferably in conjunction with some nice animation. This is quite simple to achieve with a single activity setup, but what if the top level fragment destinations in this flow are located in dynamic feature modules? We’ll answer this question soon enough.

When starting to think about modularisation, the dependency relationship between modules that intuitively comes to mind is something like the following:

App module → Feature modules → Common libraries

That is, the main app module knows about all feature modules, and feature modules are completely sandboxed and work without dependencies on other feature modules or the main app module. With this setup it is easy for the app module to stitch together single activity top level navigation by virtue of having access to the exposed API:s of a feature module.

However, dynamic feature modules flips this dependency relationship on its head — the main app module can not depend on the dynamic feature modules, and the dynamic feature modules must depend on the app module, like so:

Feature modules →App module → Common libraries

Dynamic feature modules can also be installed on-demand, that is, they may not be included in the APK that the user initially downloads, but installed at runtime. This setup therefore poses some new problems:

How do we access code in a dynamic feature module for usage in our top level navigation?

How do we maintain a smooth user experience when navigating to a dynamic feature that is not installed?

Accessing code in a dynamic feature module

One approach to this is of course going balls to the walls with reflection — whenever you need something from a dynamic feature module, just resolve it with reflection. Very unappealing indeed.

Another approach I like much more is outlined nicely in this article — having dynamic features defined through public interfaces in a common library module and loading their actual implementations (located in the dynamic feature modules) at runtime with a ServiceLoader .

Using a ServiceLoader normally comes with a performance hit on Android because it has to do a reflective lookup at runtime, however, this problem can be addressed by using R8 with code shrinking enabled. As explained in the documentation for ServiceLoaderRewriter (part of the R8 source code):

ServiceLoaderRewriter will attempt to rewrite calls on the form of: ServiceLoader.load(X.class, X.class.getClassLoader()).iterator() … to Arrays.asList(new X[] { new Y(), …, new Z() }).iterator() for classes Y..Z specified in the META-INF/services/X.

Very nifty. The only downside to this is having to manually add the service definitions in META-INF/services/ — what this means is that if we have a service definition interface, VideoFeature , and an implementation of it called VideoFeatureImpl , we have to add a registration file to enable lookup for a ServiceLoader , like so:

VideoFeatureImpl registration

This file then contains the fully qualified name of the implementing class, in this case: com.jeppeman.jetpackplayground.video.platform.VideoFeatureImpl .

An error prone practice that is susceptible to refactoring. We can get rid of this issue by using Google’s AutoService — AutoService is an annotation processor that will scan the project for classes annotated with @AutoService , for any class it finds it will automatically generate a service definition file for it. Much better, now we no longer have to create these ourselves, we can refactor and move our service classes freely without having to worry about breaking lookups for a ServiceLoader .

Here is what a dynamic feature definition from a common library module looks like in the project:

Dynamic feature definition from a common library module

The getMainScreen() method is used to get the UI entry point of the feature, this can then be used in a master-detail screen— we’ll revisit this soon. The inject() method is used to inject any dependencies that the feature needs in order to function, this can be wired up nicely with Dagger; if you’re curious to know more about the dagger setup you can have a look at the project.

The Feature -interface is then implemented in the dynamic feature module, like so:

Dynamic feature implementation

The way we get an actual instance of a feature is then through the previously mentioned ServiceLoader , this is facilitated by a class located in the common library module called FeatureManager , which essentially is responsible for installing dynamic feature modules (more on this soon) as well as providing the feature instances. The code for getting a feature instance looks like this:

FeatureManager.getFeature

One more note about ServiceLoader before moving on. As explained here, the following three conditions have to met in order for the R8 optimisation to actually kick in:

You must call the two-argument version of load() Both arguments must use class constants ( .class in Java or ::class.java in Kotlin) You must not call any methods on the returned ServiceLoader other than iterator()

That is why the getFeature function must be inline and must have a reified type parameter for the feature class — the kotlin compiler will then inline the method body at call site and replace T::class.java with the class constant that was used as a type argument, for example VideoFeature::class.java .

Maintaining a smooth user experience when navigating to a dynamic feature module that is not installed

Now we know how to access the UI entry point of a dynamic feature module in a sound manner once it has been installed — unfortunately, there will be no code accessing until we have made the actual installation, obviously.

The best way to test behaviour with dynamic feature modules is to upload the bundle to an internal test track on Google Play, the bundletool can also be used to test certain cases locally, but if you want fidelity, use the Play Store.

The Play Core Library provides API:s to download and install a dynamic feature APK at runtime without having to relaunch the app, we can therefore stay in the same activity and condition navigation on whether the feature is installed or not, and in case it is not installed, display a progress bar while we are downloading and installing. Here is some sample code from the project which does just that:

And below is a gif that displays navigation to a dynamic feature module that has not yet been installed in a master-detail flow:

Top level navigation with on-demand dynamic feature modules as destinations

Through these means we can perform top-level navigation while maintaining a smooth user experience (relatively, admittedly the install dialog could be a tad less intrusive) and using sound access patterns for destinations located in dynamic feature modules.

Launching features as activities

It could certainly be the case that a feature does not lend itself well to a master-detail type flow — it might need to have more fine-grained control of an activity in order to function correctly, or it might have an internal master-detail flow of its own. The observant reader may have noticed that the Feature interface also exposes a getLaunchIntent() method, this is to provide the ability to launch a feature in a completely sand-boxed manner in the form of an activity if need be. It can be used like this:

App Links

Another elegant way of doing navigation in a modularised setup is through App Links (deep links) — we can link to different parts of our application in a REST-like manner, e.g. https://jeppeman.com/video would open the video feature. This also has the nice benefit of enabling destinations to be directly linked to from a web site if the user is browsing on an Android device.

However, as you might suspect, there is a problem with App Links when used in conjunction with dynamic feature modules: if we declare an intent-filter to handle app links in the AndroidManifest.xml of a dynamic feature module, and then try to open an app link that matches this intent-filter before the dynamic feature module is installed, the following happens:

adb shell am start -W -a android.intent.action.VIEW -d “http://jeppeman.com/video" before dynamic feature is installed

Boom. This is because the activity declaration from the dynamic feature module has been merged into the base manifest, but the actual dynamic feature APK is not yet installed.

To tackle this we can have centralised handling of app links in our app module, like so:

AppLinkActivity

A bit simplified, but essentially we match the first path segment with the name of a feature, and then condition the navigation on whether the dynamic feature module is installed or not, if it is not, we launch the installer dialog — here is the result of running adb shell am start -W -a android.intent.action.VIEW -d "http://jeppeman.com/video" before the video feature has been installed: