Navigation Compose meet Type Safety

Bringing Safe Args to Navigation Compose

Published in
8 min readMay 1, 2024

--

As of Navigation 2.8.0-alpha08, the Navigation Component has a full type safe system based on Kotlin Serialization for defining your navigation graph when using our Kotlin DSL, designed to work best with integrations like Navigation Compose.

Kotlin DSL? What’s that for?

The Navigation Component has three main components:

  • Host — the UI element in your layout that displays the current ‘destination’
  • Graph — the data structure that defines all of the possible destinations in your app
  • Controller — the central coordinator that manages navigating between destinations and saving the back stack of destinations

The Kotlin DSL is just one of the ways to build that navigation graph. Since the very first alpha of Navigation back in 2018, Navigation has always offered three ways to build the graph:

  • Manually constructing instances of NavGraph and adding destinations like Fragment Destinations to construct the graph (this is still the underlying base for everything else, but not something you should actively be doing yourself)
  • Inflating your graph from a navigation XML file, editing it by hand or via the Navigation Editor
  • Using the Kotlin DSL to construct your navigation graph directly in your Kotlin code

Navigation Compose was the first integration to really embrace the Kotlin DSL as the way to build your graph, purposefully moving to a more flexible system and away from static XML files.

Kotlin code, but at what cost?

However, the move from build time static XML files to generating your graph at runtime meant that the tools available to developers also changed significantly. The Navigation Component’s Safe Args Gradle Plugin, which generated type safe “Directions” classes you could use in your code to navigate between destinations, relied on reading the destinations and their arguments from those navigation XML files. That means without navigation XML files, there was no generated Safe Args code.

And while Navigation requires the correct types and arguments at runtime (telling you loudly (crashing) if you tried to pass a String to something expecting an Int or if you forgot a required argument), compile time safety was left as an exercise to the developer.

Compile time type safety then

Without the Safe Args Gradle plugin, what would you have left? The Kotlin DSL with Navigation Compose was based on the idea that each destination had a unique “route” — a RESTful path that uniquely identifies that destination.

For example, just like a website, you might have a "home" destination, a "products" destination, as well as pages that take arguments — the route for a particular product might be "products/{productId}" — it would include a placeholder for the unique ID of that product.

That meant:

  • Keeping track of these string routes
  • Managing their arguments and their types
  • Worst of all, doing string interpolation

Our own documentation and video content explored how to minimize this bookkeeping — isolating the strings alongside type safe extensions on top of our base Kotlin DSL. While this provided a strong barrier between the base Kotlin DSL and the API you expose across the rest of your code base and across different modules, it clearly wasn’t enough.

I’d like to personally thank the larger Android community for providing some fantastic solutions built on top of Navigation Compose designed to minimize or completely eliminate this manually written code including:

Rafael Costa’s Compose Destinations uses KSP to process annotations attached to composable functions to generate the entire navigation graph.

Kiwi.com’s navigation-compose-typed uses Kotlin Serialization to generate routes directly from @Serializable objects or data classes.

So many ways to generate code

When looking at what ‘Safe Args’ would look like in our Kotlin DSL, we explored a number of approaches, essentially looking at many of the technologies available to us to generate type safe code.

One approach we explored was a transliteration of what we did with the Safe Args Gradle Plugin — rather than a Gradle plugin that would read the source of truth (your navigation XML file), we’d use your existing Kotlin code as the source of truth.

That meant if you wrote a piece of your Kotlin DSL that looked like:

composable(
route = "profile/{userId}/{name}",
arguments = listOf(
navArgument("userId") {
type = NavType.Int,
nullable = false
},
navArgument("name") {
type = NavType.String,
nullable = true
}
)
) {

We would generate the ProfileDestination and ProfileArgs classes you’d need to navigate to the "profile" destination and extract those arguments out. After looking into this….this was way easier said than done. Information like the string "profile/{userId}/{name}" was technically possible to extract, but only as a Kotlin Compiler Plugin. And even then, while we could find the route String passed to the Kotlin DSL, it was difficult to resolve the String if it was anything other than a constant String. Given that we have a number of Kotlin Compiler Plugin experts on our larger team who know exactly how much maintenance is involved in a compiler plugin (hi Compose folks!) and that we’re currently transitioning between the K1 and K2 compilers, we chose not to develop this solution any further.

So if the Kotlin DSL code wasn’t a viable source of truth, what could be a viable source of truth? And what tools were available to read that information? It turns out the other two big options (KSP and Kotlin Serialization) are also Kotlin Compiler Plugins. But importantly: they’re ones that ship alongside every version of Kotlin, which is critical for getting out of the way of developers eager to use new versions of Kotlin as they come out.

Why Kotlin Serialization

One of the guiding principles we’ve followed in developing the Navigation Component is in trying to minimize how ‘infectious’ Navigation code is: e.g., how easy is it to swap out our library for another (no judgment!). If you have Navigation code and classes spread throughout your entire code base in every file, you’re never going to get rid of it.

That’s why our testing guide specifically recommends avoiding having any references to your NavController in your screen level composable methods and specifically why there isn’t a NavController composition local: a button deep in your hierarchy, as convenient as it may be, should not be tied to your particular choice of navigation library.

So when looking for a ‘source of truth’ for how to define each destination in our graph, having each of those definitions totally independent of Navigation’s classes was exactly the type of approach we were looking for.

This meant that if you wanted to define a new destination in your navigation graph, you could write the simplest code possible:

// Define a home destination that doesn't take any arguments
@Serializable
object Home

// Define a profile destination that takes an ID
@Serializable
data class Profile(val id: String)

You’ll note that these purposefully don’t need to implement any Navigation provided interface or even be defined in a module that has a Navigation dependency. Yet, they are enough to encapsulate a meaningful name of the destination (I think you might get some looks if you named it object Object1) and any parameters that are core to the identity of that destination. That looks like it could be a viable source of truth.

With Kotlin Serialization as a viable source for compile time safety, we proceeded to take every API that took a String route and add Kotlin Serialization based overloads.

Show me the code

So having defined your Home and Profile Serializable classes, your graph now looks like:

NavHost(navController, startDestination = Home) {
composable<Home> {
HomeScreen(onNavigateToProfile = { id ->
navController.navigate(Profile(id))
})
}
composable<Profile> { backStackEntry ->
val profile: Profile = backStackEntry.toRoute()
ProfileScreen(profile)
}
}

You should note one thing immediately: no strings! Specifically:

  • No route string when defining a composable destination — specifying the type is enough to generate the route for you as well as the arguments (no more navArgument either)
  • No route string when navigating to a new destination. You pass NavController the Serializable object associated with the destination you want to navigate to.
  • No route string when defining the start destination of a navigation graph.

For the Profile screen, we use the toRoute() extension method to recreate the Profile object from the NavBackStackEntry and its arguments. There’s a similar extension method on SavedStateHandle, making it just as easy to get the type safe arguments in your ViewModel as well without needing to reference specific argument keys.

This new approach applies to individual destinations, so you can incrementally migrate from your current approach to this new approach, one destination or one module at a time.

Route vs Route Patterns

One of my personal favorite features of this type safe approach is in making it very clear which APIs support a route pattern (e.g., "profile/{id}") and which support a filled in route (e.g., "profile/42"). For instance, the popBackStack() API actually supports both, but that wasn’t clear when its parameter was just a String. With the type safe APIs, it is much clearer:

// Pop up to the topmost instance of the Profile screen, inclusive
navController.popBackStack<Profile>(inclusive = true)

// Pop up to the exact instance of the Profile screen with ID 42
// also popping any other instances that are on top of it in the back stack
navController.popBackStack(Profile(42), inclusive = true)

So when you see an API that takes a reified class, you’ll know that it denotes any destination of that type, irrespective of its arguments. While one that takes an actual instance of that class is used to find a specific destination with exactly those matching arguments.

APIs like getBackStackEntry() or even the startDestination of your graph are examples where technically they’ve supported both for some time and you might not have even known it!

Custom types

If you’re really doing something custom beyond the primitive types (or their Array and now List equivalents), complicated types like Parcelable types can even be used as fields on your Serializable classes by writing your own custom NavType and passing it through when building your graph:

// The Search screen requires more complicated parameters
@Parcelize
data class SearchParameters(
val searchQuery: String,
val filters: List<String>
)

@Serializable
data class Search(
val parameters: SearchParameters
)

val SearchParametersType = object : NavType<SearchParameters>(
isNullableAllowed = false
) {
// See the custom NavType docs linked above for an
//example of how to implement this
}

// Now use this in your destination
composable<Search>(
typeMap = mapOf(typeOf<SearchParameters>() to SearchParametersType)
) { backStackEntry ->
val searchParameters = backStackEntry.toRoute<Search>().parameters
}

Note: this is supposed to be a speed bump: think long and hard whether an immutable, snapshot-in-time argument is really the source of truth for this data, or if this should really be an object you retrieve from a reactive source, such as a Flow exposed from a repository that would automatically refresh if your data changes.

Go forth with safety

The complete type safe API is available starting in Navigation 2.8.0-alpha08. Besides support for all of the Kotlin DSL builders we support (including both Navigation Compose that we talked about here and Navigation with Fragments), it also includes other APIs you might find interesting like the navDeepLink API that takes a Serializable class and a prefix that allows you to easily connect external links to the same type safe APIs.

If you find any issues or have feature requests for APIs we missed, please file an issue — while these APIs are still in alpha is the best time to request changes.

The code snippets in this blog have the following license:

// Copyright 2024 Google LLC. SPDX-License-Identifier: Apache-2.0

--

--

  翻译: