“External” navigation in a multi-module Kotlin project

doubletappin this article I will tell you how we did navigation in Yandex Travel. With navigation in Android, everything seems to be clear for a long time: take jetpack navigation, read the official documentation and follow it – and everything will work out. If the recommended library is not suitable – take Fragment Manager, prescribe your own implementation and go to brag to your colleagues. If there is no desire to write your own implementation, and the official library does not correspond to fashion trends, you supplement your resume with the ability to work with Cicerone. If your tastes are specific – why not surprise people with an unexpected addition to the project Alligator?

In one short paragraph, it was possible to identify 4 options for implementing navigation at once, and, it would seem, what is the question? Everyone chooses the option that suits him best for his project. Everything is so, but exactly until the moment when there is a need to “share” a part of the application – to integrate into another application, and there, as it turns out, there is another implementation of navigation. And here it begins: “What are we going to do? Let’s try to write a bridge? Or maybe we should rewrite the navigation better?

At the design stage, it is better to immediately clarify the possibility of further integration with other applications and prepare for this in advance. Among other things, you need to prepare and navigation, and make it “external” – one of the possible solutions to this problem.

What is “external” navigation?

If we are writing a multi-module application, we are faced with the question of how to organize communication between these modules, and everything seems to be clear here: no matter how the features are divided into modules, they will have some kind of API (one or more) and its implementation ( again, one or more). However, there is a question: should navigation be part of this API? The answer is simple: no, it shouldn’t. After all, then the feature itself will have to be able to perform navigation, and this violates the principles of single responsibility and encapsulation. A separate or dedicated part of the application should be responsible for implementing navigation, and a feature should only be given the opportunity to “declare” that it intends to navigate to another feature. From this it is easy to conclude: navigation should be external dependence features, and this dependency must be implemented navigation feature – a separate application module, which we will call “external” navigation.

That is, instead of implementing a specific navigation mechanism inside each feature, dependencies that implement navigation should be supplied to the features. Then each feature will be abstracted from a specific implementation of navigation, which will make it easy to integrate it into other applications in the future.

Of course, it will be possible to “easily” integrate your modules somewhere only if you follow many other rules, in addition to abstracting navigation, but a full description of all the nuances draws on a separate series of articles, so in this one we will consider only navigation.

So, we figured out the input data, which means it’s time to consider the implementation details.

Implementing “external” navigation

So, you have figured out that the features of the application can be integrated into another service in the future. You are faced with the task of minimizing the connectivity and dependence of modules among themselves. Among other things, we will solve the navigation problem by closing it behind “pure” interfaces, so that when moving modules anywhere, it remains only to implement interfaces in our own way. Before proceeding with the implementation and coding, let’s outline and formulate the upcoming solution. We will consider the implementation using the example of a classic Android application. Below is a solution diagram, let’s analyze it.

Remark: the arrows on the diagram indicate the dependence of the modules (they go from the dependent module to the module on which it depends). At the top we see the familiar app module. Suppose we have some conditional Feature 1, Feature 2 and Feature 3 that are connected to the app module. These will be 3 screens that represent a certain flow (for example, it can be an authorization flow or any other). On the diagram, features are represented as solid blocks, but here we make a reservation: features can be organized as really solid modules, as well as a set of modules: for example, as api and impl modules, or as a set of several domain, data and presentation modules in accordance with a clean architecture. Now for us it is not important, we will consider these features as abstract concepts.

What’s really important to us about these features is that they have external dependencies, and one of those dependencies is the navigation interface. It lies in the navigation-api module, to which all features refer. This is a pure Kotlin module, inside which the following abstraction is declared:

interface NavigationApi <DIRECTION> {
   fun navigate(direction: DIRECTION)
}

We deliver this interface in features. As a generic of DIRECTION, each feature declares public abstractions that correspond to the possible “directions” in which that feature can navigate. Suppose, for example, that feature 1 can navigate to feature 2. Then its “directions” can be represented by the following abstraction:

sealed interface Feature1Directions {
   object ToFeature2 : Feature1Directions
}

Then the external dependencies for feature 1 will look like this:

interface Feature1Dependencies {
   val navigationApi: NavigationApi<Feature1Directions>
   // Другие зависимости ...
}

In turn, feature 2 can navigate to feature 3 and perform a “back” action. Then her navigation directions would look like this:

sealed interface Feature2Directions {
   object Up : Feature2Directions
   data class ToFeature3(val args: Feature2To3Args) : Feature2Directions
}
data class Feature2To3Args(
   val someArg1: Int,
   val someArg2: String,
)

And finally, let the third feature perform the “back” action and return to the first feature, that is, do a “reset” of the entire flow from three screens to the initial state – the first screen. Then her navigation directions would look like this:

sealed interface Feature3Directions {
   object Up : Feature3Directions
   object UpToFeature1 : Feature3Directions
}

Below is a diagram of the screen interaction just described:

At this stage, we will finish considering the features, since we have already described everything related to them. All that remains to be done inside these features is, if necessary, to perform navigation by calling a method from the navigation interface, passing the appropriate direction to it, like this:

fun someFunction() {
   // Предшествующий код ...
   navigationApi.navigate(Feature1Directions.ToFeature2)
}

Implementing the navigation module

Let’s start with something simple and clear: organizing dependencies. It is clear that the navigation module, in order to implement the interaction of features, must refer to modules with features and to the navigation API. And for the navigation module to be included in the application itself, it must be referenced by the app module. Got it sorted out here.

As mentioned above, any mechanism can be chosen for navigation. For simplicity, we use the standard approach: the official library jetpack navigation. Everything is simple here: we create a navigation graph, prescribe transitions between screens, specify arguments, for convenience we immediately connect Safe Args. Since the module with external navigation connects modules for implementing specific features (see the dependency diagram above), fragments are available to us in this module, from which we will create familiar Destinations.

Next, you should implement the same navigation interfaces for features that were announced earlier. For example, let’s take the navigation implementation for the second feature:

internal class Feature2NavigationImpl @Inject constructor(
   private val navController: Provider<NavController>,
): NavigationApi<Feature2Directions> {

   override fun navigate(direction: Feature2Directions) {
       when (direction) {
           is Feature2Directions.ToFeature3 -> {
               navController.get().navigate(
                   Feature2FragmentDirections.fromFeature2ToFeature3(
                       args = direction.args.toFeature3Args(),
                   )
               )
           }
           is Feature2Directions.Up -> {
               navController.get().navigateUp()
           }
       }
   }

   companion object {
       private fun Feature2To3Args.toFeature3Args(): Feature3Args = Feature3Args(
           value = "$someArg2 : $someArg1"
       )
   }
}

In the overridden navigate() method, iterate through each of the directions and perform the appropriate action. For the Up direction, we simply call the usual navigateUp() on the NavController, and for the ToFeature3 direction, we call navigate().

Of interest is the extension function for the toFeature3Args() arguments. It turns out that the second feature passes its model with arguments – Feature2To3Args – to the ToFeature3 direction, and the function maps the arguments to the model from feature 3 – Feature3Args. Thus, the features remain as independent as possible from each other.

As you can see, nothing complicated: we went through all the directions, mapped the arguments, made the appropriate NavController calls, and the resulting class was injected into the component if you use dependency injection, as in this example. In this case, the most interesting thing here is how to pass the abstraction (or implementation) of the navigation mechanism to the constructor. Let’s figure it out.

In any case, the navigation abstraction must be taken from the presentation layer: fragment, activity. The view in Android has the property of “dying” – it’s not easy to inject, but there are still ways. So, fragments and activities tend to be re-created, which means that simply transferring the instance will not work, you need to transfer the “method of receiving”. That is why in the example above the NavController is not directly injected into the constructor – it is used Provider. Where can a provider get a NavController from? From a fragment that lies in the navigation module and contains NavHost. Well, or from an activity that contains NavHost, but here we take out navigation into a separate module, and in general we have a Single Activity, so let’s focus on a fragment.

So, in the app-module is MainActivity, which displays a NavigationFragment, which lies in the navigation module and contains NavHost. Here is such a scheme:

It follows that in order to access navigation from NavHost, we need to “feed” the source, that is, the activity. First of all, let’s declare an interface in the navigation module:

interface NavigationActivity {

   fun getNavigationFragment(): NavigationFragment?
}

This interface will allow access to the navigation fragment, and it is implemented by MainActivity as follows:

override fun getNavigationFragment(): NavigationFragment? = supportFragmentManager.fragments
   .filterIsInstance<NavigationFragment>()
   .firstOrNull()

Now we can get the navigation fragment from the activity. We need to find a way to get the activity itself. This can be done with Application.ActivityLifecycleCallbacks. Let’s declare the following class as an external dependency for the navigation module:

class NavigationActivityProvider(application: Application) {

   private var activityReference: WeakReference<NavigationActivity>? = null

   fun get(): NavigationActivity? = activityReference?.get()

   init {
       registerActivityCallbacks(application)
   }

   private fun registerActivityCallbacks(application: Application) {
       application.registerActivityLifecycleCallbacks(
           object : Application.ActivityLifecycleCallbacks {
               override fun onActivityCreated(activity: Activity, p1: Bundle?) {
                   if (activity is NavigationActivity) {
                       activityReference = WeakReference(activity)
                   }
               }

               override fun onActivityDestroyed(activity: Activity) {
                   if (activity is NavigationActivity) {
                       activityReference = null
                   }
               }

               …
           }
       )
   }
}

Then, passing to the Application constructor, in the navigation module we can access the NavigationActivity, from it – to the NavigationFragment, and from the NavigationFragment already directly to the NavController, like this:

class NavigationFragment : Fragment() { 

   val navController: NavController by lazy {
       (childFragmentManager.findFragmentById(R.id.nav_host) as NavHostFragment).navController
   } 

   …
}

All that remains for us is to provide this “access method” in the navigation module:

@Module
internal class NavigationImplModule {

   @Provides
   fun provideNavController(
       activityProvider: NavigationActivityProvider,
   ): NavController = activityProvider.get()
       ?.getNavigationFragment()
       ?.navController
       ?: error("Do not make navigation calls while activity is not available")
}

And that’s it – we can use it in NavigationApi implementations for various features!

A small remark: if you still need to make “internal” navigation in some feature module, you can implement it inside a specific feature module – modify or expand the approach to suit your tasks.

In conclusion

It’s better to see once than hear a hundred times, so here you can find an example of the “external” navigation described in the article. There is nothing superfluous in it, but there is dependency injection implemented on Dagger 2 with Component Holders, the base classes for which are in the di module – this is just one of the options for how you can organize dependencies in a multi-module project. Something similar can be found In this article. You can read more about the case on our website or download the app for iOS or Android and test it yourself. If you have any questions or want to tell us which navigation you use on projects, we will discuss it in the comments.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *