Scaling Jetpack Compose Navigation


Hi, my name is Anton Shilov and I am an Android developer at Badoo. Recently, my colleague Lachlan McKee wrote an article about working with the Jetpack Compose Navigation library and how he solved the navigation mastab problem. Further – the translation of his text.

In one of my personal projects, I decided to use Jetpack Compose as my main technology. This meant that my application would have one Activity, and all navigation would be done using Compose. When I started planning the project, there was no Compose Navigation library yet, and there was no way to inject a ViewModel into a Composable without using the Activity, Fragment, or View components.

But about half a year before this article was published, the Jetpack Compose Navigation library came out and Dagger-Hilt began to support Compose. Today I will talk about my path: I will share my vision of the navigation scalability problem using Google examples and offer a possible solution.

What is Jetpack Compose Navigation?

Jetpack Compose Navigation Is the Compose equivalent Jetpack Navigation… Developers can define a navigation graph that uses URIs to navigate between screens. The graph can be used to navigate between screens without knowing the details of their implementation / implementation – the URI is enough for this.

Google’s Approach to Jetpack Compose Navigation

At the time of this posting, the following Compose navigation structure was introduced in the Google documentation:

NavHost(navController, startDestination = "profile") {
    composable("profile") { Profile(...) }
    composable("friendslist") { FriendsList(...) }
    ...
}

What this snippet tells us:

  • NavHost must be defined inside a Composable function that hosts the navigation;

  • we define each route inside NavHost using the Composable function;

  • The Composable function takes a string parameter that defines a route, as well as any argument on that string. It can change the navigation behavior using the familiar singleTop mode and manage the stack during navigation (for example, the popTo operation).

Implications of Google’s Compose Navigation Approach

As you may have noticed, each route needs to be defined for NavHost scope. Although one can use to decentralize routing nested navigation, in the scope of NavHost, you still need to declare Composable.

In my opinion, such an implementation is difficult to maintain. NavHost needs to know how to instantiate each “composable” function – and can become a God object as a result. This diagram will help you understand the essence of Google’s approach:

But if we have hundreds of Composable routes, how do we scale it all up? Since Google didn’t offer any solution, I came up with my own.

Scaling Jetpack Compose Navigation with Dagger-Hilt

As the degree of modularity of applications grows, is it not advisable to make one NavHost (depending on the graph nesting) responsible for creating all the Composables? I came up with the following solution: all routes are created inside NavHost using factories, which can be defined in the feature module, keeping the mechanism for defining the routes of Composable functions in one module.

While this solution can add complexity and bulk to your code, with Dagger-Hilt you can significantly reduce the amount of boilerplate and associated problems.

Let me explain a little. Dagger-Hilt no longer requires developers to explicitly create Dagger components: InstallInAnnotation is used to define the scope of modules (Singleton, ActivityScoped, FragmentScoped, ViewModelScoped, etc.). I recently learned that Dagger-Hilt detects Hilt modules in a Gradle module without any explicit reference in the code. This means that the navigation factory we want to create can be bound inside the Gradle module to the Singleton’s scope and accessible from anywhere.

Another major change was the addition of a Kotlin extension function to Dagger-Hilt called hiltViewModel (). This became for me a clue that laid the foundation for my research. hiltViewModel () allows you to get a given ViewModel from a Composable function without explicitly referencing an Activity, Fragment, or View. It does this by extracting an Activity, inside which is a Composable with a little reflection (at the time of this writing).

What do we get

As I mentioned above, I developed this solution for use in a personal project. It turned out to be extremely useful for development, and besides, it turned out that all my functional modules can use the internal access modifier in Kotlin. Thanks for this, you need to place the Composable inside the factory class, which is defined in the Composable function module.

This approach is demonstrated in my project on GitHub… Here are some code snippets that illustrate my idea:

// An interface is created to allow a feature module to add their Composable to the NavGraph.
// Defined within a library module
interface ComposeNavigationFactory {
  fun create(builder: NavGraphBuilder, navController: NavHostController)
}
// An implementation of the interface, as well as a Dagger 2 module installed via hilt.
// Defined within a feature module
internal class Feature1ComposeNavigationFactory @Inject constructor() : ComposeNavigationFactory {
  override fun create(builder: NavGraphBuilder, navController: NavHostController) {
    builder.composable(
      route = "feature1",
      content = {
        Feature1(
          navController = navController
        )
      }
    )
  }
}

// Defined within the 'feature 1' module
@Module
@InstallIn(SingletonComponent::class)
internal interface ComposeNavigationFactoryModule {
  @Singleton
  @Binds
  @IntoSet
  fun bindComposeNavigationFactory(factory: Feature1ComposeNavigationFactory): ComposeNavigationFactory
}
// An example of a set of factories being used to construct a NavHost.
// Potentially defined within the app module
@AndroidEntryPoint
class ExampleActivity: AppCompatActivity {
  @Inject
  lateinit var composeNavigationFactories: @JvmSuppressWildcards Set<ComposeNavigationFactory>

  @Composable
  fun JetpackNavigationHiltApp() {
    val navController = rememberNavController()

    NavHost(navController, startDestination = "feature1") {
      composeNavigationFactories.forEach { factory ->
        factory.create(this, navController)
      }
    }
  }
}

As you can see, ExampleActivity does not need to know how to construct the composable functions, since this task is delegated to the ComposeNavigationFactory. Feature1 is created by Feature1ComposeNavigationFactory, which is located in the same module.

This diagram will help you better understand the idea:

Moving on

To make my solution even more useful, I created a library that simplifies some aspects and reduces the amount of boilerplate code. Hilt Compose Navigation Factory contains the ComposeNavigationFactory interface as well as the Dagger 2 compiler, which takes Google’s approach to the hiltViewModel () function. The new approach looks like this:

// Defined within the feature module
@HiltComposeNavigationFactory
internal class Feature1ComposeNavigationFactory @Inject constructor() : ComposeNavigationFactory {
  override fun create(builder: NavGraphBuilder, navController: NavHostController) {
    builder.composable(
      route = "feature1",
      content = {
        Feature1(
          navController = navController
        )
      }
    )
  }
}

HiltComposeNavigationFactory acts in a similar way to the Dagger-Hilt HiltViewModel annotation. It generates the Dagger 2 module, which we created manually in the previous example, thereby reducing the amount of boilerplate. An example of a set of factories used to construct a NavHost:

// Potentially defined within the app module
@Composable
fun JetpackNavigationHiltApp() {
  val navController = rememberNavController()
  val context = LocalContext.current
  
  // The start destination would still need to be known at this point.
  NavHost(navController, startDestination = "feature1") {
    hiltNavGraphNavigationFactories(context).addNavigation(this, navController)
  }
}

Note that for the Composable, we no longer need the scope of the Activity, since the hiltNavGraphNavigationFactories function can access the ComposeNavigationFactory collection through the context. Schematic diagram of the new approach using the library:

Conclusion

With the new pattern, we can scale the NavHost by placing the responsibility for defining the routes to the functions themselves. This is far from the only option available. There are other libraries that handle the navigation problem differently with Jetpack Compose Navigation, for example Compose Router… But if you want to stick with Google’s approach to navigation, then in my opinion this is a great way to apply the pattern in a scalable manner.

I would be glad to suggestions for improvement libraries, feel free to send questions and even pull requests!

Similar Posts

Leave a Reply

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