Scalable Jetpack Compose Navigation
For one of my recent personal projects, I decided to make Jetpack Compose a first-class citizen. For me, this meant that my app would use a single activity, and all the navigation would be performed using Compose.
When I originally started investigating the feasibility of this, Jetpack Compose Navigation did not exist, and there was no way to inject a View Model into a Composable without an Android Activity, Fragment or View to facilitate the injection.
Thankfully, roughly six months prior to this article’s publication, ‘Jetpack Compose Navigation’ and Compose support for Dagger Hilt was introduced to the community. In this article, I share my journey, explore what I believe is a scalability problem within the documentation provided by Google, and present a possible solution.
What is Jetpack Compose navigation?
Jetpack Compose navigation is the Compose equivalent of Jetpack navigation. It allows developers to define a navigation graph that uses URIs to navigate between destinations. The graph can be used to navigate between screens without needing to know the internal details of the screen but just the URI.
Google’s approach to Jetpack Compose navigation
At the time of publication of this article Google defines the structure of Compose navigation within their documentation as follows:
The above code snippet can be described as having the following characteristics:
- A NavHost needs to be defined within the Composable that is the host for navigation.
- Within this NavHost each route is defined using the composable function.
- The composable function takes a string parameter that defines the route, and any arguments that appear within the string. It is also able to modify the navigation behaviour using familiar concepts such as SingleTop, and to manipulate the navigation stack when navigating (i.e. popTo).
Consequences of Google’s Compose navigation approach
As you may have noticed from the above example, the NavHost scope needs to define every route. While it is possible to use nested navigation to decentralise the routing, the Composables still need to be instantiated within the scope of the NavHost.
To me, this seems like a difficult way to maintain an implementation. The NavHost definition needs to know how to instantiate every Composable and as a result, the NavHost could potentially become a god-class.
This diagram helps to explain Google’s approach:
If we have hundreds of Composable routes, how do we scale this? As Google has not yet provided guidance, I came up with my own approach.
Scaling Jetpack Compose Navigation with Dagger Hilt
Considering applications are becoming very modular, does it make sense for a single NavHost (depending on how you nest your graphs) to be responsible for creating all the Composables?
A solution that I devised recently is to delegate the responsibility of creating the routes within a NavHost using factories. These factories can be defined within a feature module, therefore keeping the responsibility of defining the composable route within the same module.
While this solution risks adding a lot of extra complexity and code, by utilising Dagger Hilt we can massively reduce this boilerplate and any associated headaches.
To those that are not aware, Dagger Hilt no longer requires developers to create Dagger components explicitly: instead, developers use the InstallIn annotation to specify the scope of a module (Singleton, ActivityScoped, FragmentScoped, ViewModelScoped, etc). I recently learned that Dagger Hilt discovers Hilt modules from Gradle library modules without any code-explicit referencing (what a mouthful!). This means that the navigation factory we want to create can be bound within a Gradle module at Singleton scope and accessed anywhere.
The other key change (which also happens to be the ‘a-ha’ moment for me that kicked off my investigation) was when Dagger Hilt introduced a Kotlin extension function called hiltViewModel(). This function allows a Composable to fetch a given View Model without needing an Activity, Fragment or View to host the binding. It does this by fetching the activity within which the Composable resides along with a tiny amount of reflection (as at the time of writing this article).
Bringing all this together
As mentioned, I devised this approach for use in a personal project. I have found it to be extremely useful for development, and an added benefit has been that all my feature modules can use the Kotlin internal keyword. This is thanks to the creation of the Composable residing within the factory class that is defined within the Composable’s feature module.
My Github project demonstrates this approach. Here are some snippets of the project to help provide a clearer picture:
As you can see, the ExampleActivity does not need to know how to construct the Composables but rather this is delegated to the ComposeNavigationFactory set. The responsibility of creating the Feature1 lies with the Feature1ComposeNavigationFactory, which lives in the same module.
Here is a diagram to help explain the new approach:
In case this approach is of interest, I have gone a step further and created a library that simplifies certain aspects and reduces boilerplate code.
The Hilt Compose Navigation Factory library contains the ComposeNavigationFactory interface as well as a Dagger 2 compiler that mirrors Google’s approach with regards to the hiltViewModel() function.
The new approach would look like this:
The HiltComposeNavigationFactory acts identically to the Dagger Hilt HiltViewModel annotation. It generates the Dagger 2 module we manually constructed in the previous example, reducing the amount of boilerplate code required.
An example of a set of factories being used to construct a NavHost:
Notice that we no longer need the Activity scope for the Composable as the hiltNavGraphNavigationFactories function is able to access the ComposeNavigationFactory set via the context.
This diagram helps explain the new approach using the library:
By using this new pattern, you can scale the NavHost by making it the responsibility of the features themselves to define their own route definition. This is by no means the only option available to you. There are other libraries out there that solve Jetpack Compose navigation differently, such as Compose Router. However, if you are looking to stick closely to Google’s suggested approach to navigation, I believe this is a great way to apply the pattern in a scalable way.
The library that I have published is quite new, and I am open to suggestions regarding improvements, so please feel free to get in touch, raise an issue or even a PR!
If you would like to know how I wrote the Dagger Hilt annotation processor for this library, please let me know in the comments.