Saltar al contenido
Todas las entradas

Navegación con seguridad de tipos en Jetpack Compose: rutas como datos, no como strings

Cómo reemplazar las rutas basadas en strings de Compose Navigation por objetos Kotlin serializables — argumentos con seguridad de tipos, grafos anidados y deep links que sobreviven a un refactor.

MFKAPPS 5 min de lectura

Durante los primeros años de Compose Navigation, cada ruta era un string. Construías "item/{itemId}", lo emparejabas en un NavHost, y volvías a sacar itemId de un NavBackStackEntry con getString("itemId") — esperando que la clave que escribiste al salir coincidiera con la que escribiste al entrar. El compilador no podía ayudarte. Un error de tipeo en cualquiera de los dos strings compilaba sin problema y fallaba en tiempo de ejecución, normalmente en una pantalla que no estabas mirando cuando hiciste el cambio. Las rutas con seguridad de tipos de Navigation Compose solucionan esto convirtiendo una ruta en un objeto Kotlin en lugar de un string, y después de migrar cada pantalla de una app en producción a este enfoque, la clase de bug de argumento mal tipeado desaparece por completo — el compilador lo detecta antes de que termine el build.

Rutas como una jerarquía sealed

La idea central es pequeña: define tus destinos como una sealed interface, anota cada uno con @Serializable, y deja que kotlinx.serialization se encargue de convertirlos en una ruta y de vuelta.

sealed interface Screen {
    @Serializable
    data object Home : Screen

    @Serializable
    data class ItemDetail(val itemId: Long) : Screen

    @Serializable
    data class EditItem(val itemId: Long, val fromDetail: Boolean = false) : Screen
}

Sin plantilla de string, sin nombre de clave que mantener sincronizado — los argumentos son las propiedades de la clase. Navegar es una llamada a función con tipos reales:

navController.navigate(Screen.ItemDetail(itemId = item.id))

Si itemId se renombrara o su tipo cambiara de Long a String en algún punto anterior, cada lugar donde se llama fallaría al compilar en lugar de fallar al parsear en tiempo de ejecución.

Conectando el NavHost

El lado del NavHost refleja esto con la sobrecarga composable<T>(), que toma el tipo como un parámetro reificado en lugar de un patrón de string:

NavHost(navController = navController, startDestination = Screen.Home) {
    composable<Screen.Home> {
        HomeScreen(onItemClick = { id -> navController.navigate(Screen.ItemDetail(id)) })
    }

    composable<Screen.ItemDetail> { backStackEntry ->
        val args: Screen.ItemDetail = backStackEntry.toRoute()
        ItemDetailScreen(itemId = args.itemId)
    }

    composable<Screen.EditItem> { backStackEntry ->
        val args: Screen.EditItem = backStackEntry.toRoute()
        EditItemScreen(itemId = args.itemId, cameFromDetail = args.fromDetail)
    }
}

toRoute() deserializa la entrada del back stack directamente al tipo de destino. No hay clave de Bundle que se pueda escribir mal ni comprobación manual de nulos sobre un argumento faltante — una propiedad requerida que no está presente simplemente no compila, y una opcional recibe su valor por defecto declarado, igual que cualquier otro argumento de función en Kotlin.

Los grafos anidados también mantienen la seguridad de tipos

El mismo patrón se extiende a los grafos de navegación anidados, que es donde las rutas basadas en strings solían volverse realmente dolorosas — un error de tipeo en el destino inicial de un grafo anidado falla en silencio y simplemente muestra una pantalla en blanco. Con rutas sealed, el grafo anidado es su propio tipo:

sealed interface OnboardingGraph : Screen {
    @Serializable data object Welcome : OnboardingGraph
    @Serializable data object Permissions : OnboardingGraph
    @Serializable data class Done(val skippedPermissions: Boolean) : OnboardingGraph
}

navigation<OnboardingGraph>(startDestination = OnboardingGraph.Welcome) {
    composable<OnboardingGraph.Welcome> { /* ... */ }
    composable<OnboardingGraph.Permissions> { /* ... */ }
    composable<OnboardingGraph.Done> { entry ->
        val args: OnboardingGraph.Done = entry.toRoute()
        // ...
    }
}

Como OnboardingGraph extiende Screen, el código en otras partes de la app que navega contra el grafo externo no necesita saber que el grafo anidado existe — simplemente llama a navigate(OnboardingGraph.Welcome) y el límite del grafo se resuelve de forma estructural, no por coincidencia de prefijo de string.

Los deep links se conectan a los mismos destinos tipados, así que una URI entrante igualmente se resuelve en un objeto real en lugar de una bolsa de strings para parsear a mano:

composable<Screen.ItemDetail>(
    deepLinks = listOf(navDeepLink<Screen.ItemDetail>(basePath = "myapp://item"))
) { backStackEntry ->
    val args: Screen.ItemDetail = backStackEntry.toRoute()
    ItemDetailScreen(itemId = args.itemId)
}

Tanto myapp://item/42 como un navigate(Screen.ItemDetail(42)) dentro de la app terminan en el mismo composable con el mismo argumento tipado — hay exactamente un lugar que define cómo es un destino ItemDetail, sin importar si se llegó a él desde un toque en una notificación, un widget o un botón dentro de la app.

Dos cosas que vale la pena saber antes de migrar

Los tipos personalizados en una ruta necesitan su propio NavType. Un argumento Long o String funciona de fábrica, pero una ruta que contiene un enum o una value class necesita una pequeña implementación de NavType<T> registrada en la llamada al composable, ya que el back stack en última instancia almacena la ruta como un string serializado y necesita saber cómo codificar y decodificar tu tipo dentro de él. Son pocas líneas, pero no son gratis — cuéntalo en el presupuesto de la primera pantalla que necesite un argumento no primitivo.

Migra primero las hojas, no la raíz. Convertir primero las pantallas más internas, antes que los grafos que las alojan, te permite tener rutas de string y rutas con seguridad de tipos funcionando en paralelo mientras avanzas, en lugar de una reescritura de una sola vez en la que nada compila hasta que cada ruta está terminada. Yo migré un flujo a la vez — onboarding, luego el detalle de un item, luego los ajustes — verificando la navegación manualmente después de cada uno, y nunca tuve más que un puñado de archivos en un estado inconsistente.

El resultado no es un código de navegación más rápido, es un código de navegación donde la clase de bug que solía sobrevivir a la revisión de código — un argumento renombrado que aún se parsea con el tipo equivocado — simplemente ya no existe. Para una app mantenida en solitario, sin un segundo revisor que detecte el error de tipeo, ese es el argumento que más importa.