Android Navigation Study Case

Android Navigation Study Case

Notes on how to implement a navigation between multiple modules on Android.

This study aims to find a better way to create navigation between different feature modules, enabling low coupling between modules, easy maintainability and testability. These notes are from a study that I've doing about navigation, and it considers at some parts an existing project. If you are starting a new project, the compose section, for example, does not make sense.

Also, some of the concepts here are abstract, there are some references at the end of the article and a sample project with the composition root solution. All the possible solutions consider the following architecture of modules:


On some of the approaches, we need to share code between different modules, and the way that I imagine doing that is like:

  • Impl: Feature implementation classes;
  • Public: Feature public / open classes;

Screen Shot 2022-10-26 at 10.42.43 PM.png

So basically you will add the public module as a dependency and import any args, navigation …

Navigation Framework

The examples consider the following case:

All feature module and the composition root, use jetpack navigation, basically each module has its own navigation graph with all the fragments, dialogues … declared on it, and then we include all the feature navigation files into the main navigation file, which is inside the composition root (application module).

Then the jetpack navigation would serve only as a source of truth of navigation paths and handling the transactions of each navigation.

<?xml version="1.0" encoding="utf-8"?>  
<navigation xmlns:android="<http://schemas.android.com/apk/res/android">  
    xmlns:app="<http://schemas.android.com/apk/res-auto">  
    android:id="@+id/app_navigation"  
    app:startDestination="@id/home_navigation">  

    <include app:graph="@navigation/home_navigation"/>  

    <include app:graph="@navigation/restaurant_navigation"/>  

    <include app:graph="@navigation/checkout_navigation"/>  

</navigation>

I believe that all the options can be implemented without the jetpack navigation, so if we want to change the navigation system, all we have to do is replace the navigation on the interface implementation. We have a similar approach of the composition root with the Scalable Navigation article, check references for more details, but the person shows to us a more granular implementation using the FragmentManager, instead of jetpack navigation.

A good reason to use jetpack navigation is that compose navigation is based on it so, when we try it, it will be easier to support.

Internal Navigation

For the internal navigation, considering only the options that use the composition root, I think we still add the implementation of the delegates on the composition root layer because, we do not know inside the feature module where to go to.

So if you have a screen A, that can navigate to, screen B (part of the same module) and screen C part of another module, we would need to use ISP so split the delegate into internal and external, does it make sense to that? Open to discussions, but I rather have a single interface and everything following the same pattern.

Tested solutions:

  1. Deep link / intents;
  2. Source module knows target module;
  3. Source module knows target module with composition root;
  4. Composition root with delegation;

I did not like this solution because we need to navigate using URI's, and this implicates on the source feature knowing details about the target feature, example, feature A needs to know the URI of feature B, to navigate to it.

We can either have the deep links on a navigation module, and this will bring problems with invalidating cache since all the URI will be here. Or each feature has its own URI inside a public module.

Normally people tend to use this approach or intent like navigation with multiple activities, and the ideal would be having fewer activities, and each module only having fragments. The implementation for this approach would not differ much from the others, basically at the end we just need to pass a URI to the navController, so a guess we can implement it using the composition of the feature module option. But the problems mentioned above remains.

You can have more details about this approach on the Google documentation for multi modules navigation

Source knows target

For this approach, each module will declare a navigation interface like:

interface ICheckoutNavigator {  
    fun openCheckout(args: CheckoutArgs, context: Fragment)  
}

On the public module, all modules that want to navigate to the checkout feature, needs to declare the :checkout:public module as dependency to access this interface or any argument needed for navigation.

The implementation of this interface will reside inside the checkout impl module.

class CheckoutNavigator : ICheckoutNavigator {  

    override fun openCheckout(args: CheckoutArgs, context: Fragment) {  
        context.findNavController().navigate(R.id.checkout_navigation, bundleOf("args" to args))  
    }  
}

I see some problems with this approach:

  1. I see some problems with this approach: We expose details about the checkout to any other feature that use this interface, example if we have more screen, the source feature module will know all the screen that the checkout contain.

  2. All screens need to know about navigation rules, so we couple the screens with details of the navigation or the application, each feature module / screen should be isolated enough to run alone independent of the system. Basically every screen should have some sort of event that she fires up and someone that knows about navigation rules handle that event and navigate.

For this approach we can also have a navigation module where we define args and navigation interfaces for every module, but we end up with the same problem of on change for feature X invalidate the cache for the entire module and maybe others dependents. This could be suitable for small projects, but as soon as your project starts to grow you need to start thinking on other solutions.

Source knows target with composition root

This approach is the same as previous one but, instead of the implementation of the navigation interface be on each feature module, it would be inside the composition root (application module).

Normally the composition root knows about every feature, then she could be the layer that receives the events coming from screens but, we still have the problem of the screen knowing about navigation details and also every screen that depends on the navigation.

Composition root with delegation

This is in my opinion the solution that we should explore more and try to adopt. It is kind of inversion of control but with navigation components.

Basically, every screen will declare a delegate, containing a contract of events that the composition root layer will listen to navigate to other screens.

interface IRestaurantCatalogDelegate {  
    fun checkoutButtonClick(args: CheckoutArgs)  

    fun itemDetailsClick()  
}  

class RestaurantCatalogFragment : Fragment {
    private val delegate by inject<IRestaurantCatalogDelegate> { parametersOf(activity) }
// ...
}

With this contract, we can avoid the responsibility of the view to know where she needs to go, or any details about other features. Since all she has to do is call a method and someone else will take care of the navigation or any related logic. This delegate can also be implemented using an event base with sealed classes, like:

sealed class RestaurantCatalogEvents {  
    data class CheckoutClick(args: CheckoutArgs) : RestaurantCatalogEvents()  
    object DetailsClick : RestaurantCatalogEvents()  
}

And the delegate will have a single method receiving events as parameters, depending on the amount of events this approach can be a problem, having a when with numerous cases. And with the one method per event, we can use ISP to split the interface in several ones if it grows.

Another benefit of this approach is, if your project has a demo application (a module where you can run the feature in isolation), you can change the behavior of your navigation for test purposes without the need to change any code inside your feature module

On the composition root, all we have to do is:

class RestaurantCatalogDelegate(private val activity: AppCompatActivity) :  
    INavigatorProvider by DefaultNavigatorProvider(  
        activity  
    ), IRestaurantCatalogDelegate {  

    override fun checkoutClick(args: CheckoutArgs) {  
        controller.navigate(checkout.id.checkout_navigation, bundleOf("args" to args))  
    }  

    override fun itemDetailsClick() {  
        controller.navigate(restaurant.id.itemDetailFragment)  
    }  
}

Navigation arguments

Here we have an attention point which is the CheckoutArgs object being used inside the Restaurant feature, coupling one feature to another, this object exists inside the public module of the checkout feature.

Something that we can do to avoid this problem would be, to create some sort of mapper, which will be executed on the composition root before navigating to the checkout screen, for example. So we will have something like:

// Restaurant module
data class RestaurantCatalogDTO(val items: Int)

// Composition root
...
override fun checkoutButtonClick(args: RestaurantCatalogDTO) {
    val checkoutArgs = RestaurantMapper.dtoToCheckoutArgs(args)
    controller.navigate(checkout.id.checkout_navigation, bundleOf("args" to checkoutArgs))  
}
...

Doing this, we can avoid having the public dependency of the checkout inside the restaurant feature module.

The question is, having the args from checkout inside the restaurant is bad for the project / architecture? And, should we do the parsing on the implementation of the delegate or should we find a more suitable layer for that before the navigation. We still have another option which is to stop using complex objects to navigate between screens, and use language types, like restaurant ID (String), item ID (Int) … This way we can have a better support for deep links, since normally we have only the ID of something to load the screen.

But, this imposes some problems like:

  1. Increase the load on the API to retrieve objects;
  2. Having some sort of local cache, so you can retrieve it between screens, and deal with cache policies ...

Compose

In case that you want to use jetpack compose with the composition root approach, we can easily create an activity that supports compose layouts or, in existing projects, just create a fragment, which returns a composable function for the view.

...

override fun onCreateView(  
    inflater: LayoutInflater,  
    container: ViewGroup?,  
    savedInstanceState: Bundle?  
) = setContent {  
    BuildOrderTrackerScreen()  
}  

@Preview  
@Composable  
fun BuildOrderTrackerScreen() {  
    Column(  
        modifier = Modifier  
            .fillMaxSize(),  
        Arrangement.Center,  
        Alignment.CenterHorizontally,  
    ) {  
        Text("Hello from Compose")  
    }  
}

And all the rest remains the same, create the jetpack navigation graph and include this fragment there, and all we have to do to navigate to it is call the navigation like we did on other examples.

If you decide that the entire feature will be built using compose, I believe that we can use this fragment as a backbone for the whole feature, no need to create a new fragment per screen, since compose does not need fragments.

Unfortunately, compose handle the navigation differently, so the navigation within composable screens will be a topic for another study case.


Reference: Composition Root Navigation best practices for multi-module projects Scalable Navigation in multi-module projects Structural and navigation anti-patterns in multi-module

Thanks to: Willian Policiano and Nicholas Richard on the great discussions on the matter

Sample Project: github.com/Rodrigolmti/navigation_sample

Did you find this article valuable?

Support Engineer Journal by becoming a sponsor. Any amount is appreciated!