Using Navigation Component to Navigate Between Dynamic Feature Modules

Viktor Petrovski

Viktor Petrovski

Senior Android Developer

Recently I’ve been working on a brand new application that is fairly complex and has over 30 different screens to start with. We decided that we are going to take advantage of the Dynamic Feature Modules and ended up having an architecture like this: 

The app module is the core module of the application. It serves as a starting point and contains some common classes used along with modules. Each new feature is a new dynamic module and acts like it's living in its own world without any knowledge of other features. They only know about the app module.


The benefit of using this approach is huge. For one, we have an architecture that will be so easy to scale because adding a new feature will not affect other parts of the code. Working in a team? Each team member can work on separate feature reducing the chances of something breaking in other places. I can write a whole new article about the benefits of this approach, but let's stick to our topic and see how we can navigate in our app.

In theory, everything looks good so far: we have our app module, a lot of feature modules and everything is nicely separated. But, like in every application we need to be able to navigate between screens. We decided to use the Navigation Architecture Component offered by Jetpack. For the sake of simplicity, this article assumes you have at least some knowledge in the Navigation Component. If you are not familiar with it, I strongly encourage you to give it a try.

https://developer.android.com/images/topic/libraries/architecture/navigation-graph_2x-callouts.png

Now that we covered the basics of our project structure, the big question arises: how are we going to navigate from featureA to featureB if they are not aware of each other?

Using the God Module to navigate between features

We can approach this problem by creating a new module, so-called God, that will be on top of everything and will be aware of each module that we have in the app. We can place our navigation graph inside this module and we will be able to navigate everywhere inside the app since now we have an actual module that is aware of every other module. 

I find this approach to be cheating. What is the point of having separate feature modules if you are going to have one that knows about them all? It might not be a big deal at the beginning, but it can complicate things as your project progresses further. You will most likely find it necessary to put more and more things inside your God Module, which will result in a bad project architecture after all the effort you've put in separating the modules.

Dynamically adding routes for navigating between features

Let's see how we can keep the same architecture structure and still navigate between screens.

Since I want to keep things separated I want to have a navigation_graph.xml file for each independent module. The end goal is to have a big Navigation Graph that will contain sub-graphs for all feature modules. Lucky for us the Navigation Component allows us to add destinations dynamically, so we can add the destination targets to our primary graph by inflating them, using directly the package name.

 ÐµÐ·ÑƒÐ»Ñ‚ат со слика за enough talk show me the code mem


Let’s assume the navigation graph that we have in FeatureA is named feature_a_navigation_graph.xml and is under com.naviagion.featurea package name.

We can simply do the following:

private fun addDestinationGraph(
    navController: NavController,
    context: Context
): NavGraph {
    val destinationGraphName = "feature_a_navigation_graph"
    val destinationPackageName = "com.navigation.featurea"
    
    // Find the resourceId of the graph we want to attach
    val navigationId =
        context.resources.getIdentifier(destinationGraphName, "navigation", destinationPackageName)

    // inflate the graph using the id obtained above
    val destinationGraph = navController.navInflater.inflate(navigationId)
    
    // Dynamically add the destination target to our primary graph
    navController.graph.addDestination(destinationGraph)
    
    return destinationGraph
}

Boom! Now we have a connected graph and we can easily navigate using the id of the destinationGraph.

fun navigateTo(navController: NavController, navGraph: NavGraph){
     navController.navigate(navGraph.id)
}

If your features are on-demand you should not call the addDestinationGraph method when your application launches, because it is going to crash. Using on-demand features means that your feature modules are not included in the base apk and the package name you want to add still is not available. If this is the case we should call this method once we are sure that we've downloaded the future bundle and it is attached to our project.

If you are not using the on-demand feature, you are safe to dynamically inflate the navigation graph once your activity launches.

Now that we’ve seen the benefits, let’s talk about the downsides of this approach. 

You've noticed how we had to hardcode the package name and the name of the navigation graph. This is error-prone and you should be very careful when implementing it. Making a typo or trying to inflate the navigation graph before the feature is added will result in a crash on runtime. 

I’m going to show you what I did to decrease the chances of making a mistake. Please note that the following code snippet is completely unrelated to Navigation Component and you can adjust it to your needs.

I’m going to create an interface NavigationGraphRoute that will hold all the information for the destination route.

/**
 Provides necessary information for NavGraph in other modules
 / interface NavigationGraphRoute { /*
 The inflated NavGraph
 */
 var navGraph: NavGraph
 /**
 The .xml name for the nav-graph
 */
 val graphName: String
 /**
 The full package name where the nav-graph is located
 */
 val packageName: String
 } 

Now let’s put the information we have for FeatureA in a separate singleton called FeatureARoute.

object FeatureARoute : NavigationGraphRoute {

    /**
     * Contains the destination graph once inflated
     * */
    override lateinit var navGraph: NavGraph

    override val graphName: String
        get() = "feature_a_navigation_graph"

    override val packageName: String
        get() = "${BuildConfig.APPLICATION_ID}.featurea"
}

We can then modify the code a bit and have something like this:

private fun addNavGraphDestination(
    navigationGraphRoute: NavigationGraphRoute,
    navController: NavController,
    context: Context
): NavGraph {
    val navigationId =
        context.resources.getIdentifier(navigationGraphRoute.graphName, "navigation", navigationGraphRoute.packageName)

    val newGraph = navController.navInflater.inflate(navigationId)

    navController.graph.addDestination(newGraph)

    return newGraph
}

and simply use this to navigate where we want to.

findNavController().navigate(FeatureARoute.navGraph.id)

It’s definitely not perfect, but we are minimizing the hardcoded strings only inside FeatureARoute and using them as variables where needed.

Conclusion

There is no such thing as one size fits all architecture. Something that works for one project may seem like an overkill for a different type of project.

Dynamic feature modules are a nice way to keep things isolated, making it easier to scale and organize your app. Navigation component takes care of all the boilerplate we need to write to implement navigation from one screen to another. I hope this article will help you to easily implement the navigation if you decide to use dynamic feature modules in your next project.