Seguridad de tipos en Kotlin DSL y Navigation Compose

Esta página contiene prácticas recomendadas para proporcionar seguridad de tipos de entorno de ejecución a la DSL de Kotlin de Navigation y a Navigation Compose. En resumen, debes asignar cada pantalla de tu app o gráfico de navegación a un archivo de Navigation por módulo. Cada uno de los archivos resultantes debe contener toda la información relacionada con Navigation para el destino correspondiente. En estos archivos de navegación, también son el sitio donde los modificadores de visibilidad de Kotlin proporcionan seguridad de tipos de entorno de ejecución.

  • Las funciones de tipo seguro se exponen de forma pública al resto de la base de código.
  • Los conceptos específicos de navegación para una pantalla o un gráfico de navegación específicos se ubican juntos y se mantienen privados en el mismo archivo para que sean inaccesibles para el resto de la base de código.

Cómo dividir el gráfico de navegación

Deberías dividir el gráfico de navegación por pantalla. En esencia, es el mismo enfoque que cuando divides las pantallas en diferentes funciones de componibilidad. Cada pantalla debe tener una función de extensión NavGraphBuilder.

Esta función de extensión es el puente entre una función de componibilidad sin estado en el nivel de la pantalla y una lógica específica de Navigation. Esta capa también puede definir de dónde proviene el estado y cómo se manejan los eventos.

Este es un elemento ConversationScreen típico que puede ser internal en su propio módulo a fin de que otros módulos no puedan acceder a él:

// ConversationScreen.kt

@Composable
internal fun ConversationScreen(
  uiState: ConversationUiState,
  onPinConversation: () -> Unit,
  onNavigateToParticipantList: (conversationId: String) -> Unit
) { ... }

La siguiente función de extensión NavGraphBuilder agrega el componible ConversationScreen como destino de ese NavGraph. También conecta la pantalla con un ViewModel que proporciona el estado de la IU de la pantalla y maneja la lógica empresarial relacionada con ella. Los eventos de navegación que el ViewModel no puede controlar se exponen al llamador.

// ConversationNavigation.kt

private const val conversationIdArg = "conversationId"

// Adds conversation screen to `this` NavGraphBuilder
fun NavGraphBuilder.conversationScreen(
  // Navigation events are exposed to the caller to be handled at a higher level
  onNavigateToParticipantList: (conversationId: String) -> Unit
) {
  composable("conversation/{$conversationIdArg}") {
    // The ViewModel as a screen level state holder produces the screen
    // UI state and handles business logic for the ConversationScreen
    val viewModel: ConversationViewModel = hiltViewModel()
    val uiState = viewModel.uiState.collectAsStateWithLifecycle()
    ConversationScreen(
      uiState,
      ::viewModel.pinConversation,
      onNavigateToParticipantList
    )
  }
}

El archivo ConversationNavigation.kt separa el código de la biblioteca de Navigation del destino en sí. También proporciona un encapsulamiento de conceptos de Navigation, como rutas o IDs de argumentos, que se mantienen privados porque nunca deben filtrarse fuera de este archivo. Los eventos de navegación que no se pueden manejar en esta capa deben exponerse al llamador para que se controlen en el nivel correcto. Encontrarás un ejemplo de ese evento con onNavigateToParticipantList en el fragmento de código anterior.

Navegación de tipo seguro

El DSL de Kotlin de Navigation en el que se basa Navigation Compose por el momento no ofrece seguridad de tipos de tiempo de compilación del tipo que proporciona Safe Args a gráficos de navegación integrados en archivos de recursos XML de navegación. Safe Args genera código que contiene clases y métodos de tipo seguro para los destinos y las acciones de Navigation. Sin embargo, puedes estructurar tu código de Navigation de modo que tenga seguridad de tipos en el entorno de ejecución. De esta manera, puedes evitar fallas y asegurarte de lo siguiente:

  • Los argumentos que proporcionas cuando navegas a un destino o gráfico de navegación son del tipo correcto y todos los argumentos necesarios están presentes.
  • Los argumentos que recuperas de SavedStateHandle son del tipo correcto.

Cada destino también debe exponer una función de extensión NavController para permitir que otros destinos naveguen de manera segura hacia él.

// ConversationNavigation.kt

fun NavController.navigateToConversation(conversationId: String) {
    this.navigate("conversation/$conversationId")
}

Si deseas navegar a una pantalla de tu app con diferentes NavOptions, como popUpTo, savedState, restoreState o singleTop cuando navegas, pasa un parámetro opcional a la función de extensión NavController.

// HomeNavigation.kt

const val HomeRoute = "home"

fun NavController.navigateToHome(navOptions: NavOptions? = null) {
    this.navigate(HomeRoute, navOptions)
}

Wrapper de argumentos de tipo seguro

De forma opcional, puedes crear un wrapper de tipo seguro a fin de extraer los argumentos de un SavedStateHandle para tu ViewModel y de una NavBackStackEntry en el contenido de un destino para obtener los beneficios mencionados en la introducción de esta sección.

// ConversationNavigation.kt

private const val conversationIdArg = "conversationId"

internal class ConversationArgs(val conversationId: String) {
  constructor(savedStateHandle: SavedStateHandle) :
    this(checkNotNull(savedStateHandle[conversationIdArg]) as String)
}

// ConversationViewModel.kt

internal class ConversationViewModel(...,
  savedStateHandle: SavedStateHandle
) : ViewModel() {
  private val conversationArgs = ConversationArgs(savedStateHandle)
}

Cómo ensamblar el gráfico de navegación

Los gráficos de navegación usan las funciones de extensión de tipo seguro descritas más arriba para agregar destinos y navegar a ellos.

En el siguiente ejemplo, el destino de conversación junto con otros dos destinos, inicio y lista de participantes, se incluye en un nivel NavHost de la app de la siguiente manera:

// MyApp.kt

@Composable
fun MyApp(modifier: Modifier = Modifier) {
  val navController = rememberNavController()
  NavHost(
    navController = navController,
    startDestination = HomeRoute,
    modifier = modifier
  ) {

    homeScreen(
      onNavigateToConversation = { conversationId ->
        navController.navigateToConversation(conversationId)
      }
    )

    conversationScreen(
      onNavigateToParticipantList = { conversationId ->
        navController.navigateToParticipantList(conversationId)
      }
    }

    participantListScreen()
}

Seguridad de tipos en gráficos de navegación anidados

Debes elegir la visibilidad correcta para los módulos que proporcionan varias pantallas. Este es el mismo concepto que para cada método en las secciones anteriores. Sin embargo, puede que no tenga sentido exponer pantallas individuales a otros módulos. En ese caso, debes considerarlas parte de un flujo independiente más grande.

Este conjunto de pantallas autónomo se denomina gráfico de navegación anidado. Esto te permite incluir varias pantallas en un solo método de extensión NavGraphBuilder. Este método usa esos métodos de extensión NavController para vincular las pantallas dentro del mismo módulo.

En el siguiente ejemplo, el destino de conversación descrito en las secciones anteriores aparece en un gráfico de navegación anidado junto con otros dos destinos, lista de conversaciones y lista de participantes:

// ConversationGraphNavigation.kt

private val ConversationGraphRoutePattern = "conversation"

fun NavController.navigateToConversationGraph(navOptions: NavOptions? = null) {
  this.navigate(ConversationGraphRoutePattern, navOptions)
}

fun NavGraphBuilder.conversationGraph(navController: NavController) {
  navigation(
    startDestination = ConversationListRoutePattern,
    route = ConversationGraphRoutePattern
  ) {
    conversationListScreen(
      onNavigateToConversation = { conversationId ->
        navController.navigateToConversation(conversationId)
      }
    )
    conversationScreen(
      onNavigateToParticipantList = { conversationId ->
        navController.navigateToParticipantList(conversationId)
      }
    )
    partipantList()
}

Puedes usar varios gráficos de navegación anidados en un nivel NavHost de la app de la siguiente manera:

// MyApp.kt

@Composable
fun MyApp(modifier: Modifier = Modifier) {
  val navController = rememberNavController()
  NavHost(
    navController = navController,
    startDestination = HomeGraphRoutePattern
    modifier = modifier
  ) {
    homeGraph(
      navController,
      onNavigateToConversation = {
        navController.navigateToConversationGraph()
      }
    }
    conversationGraph(navController)
}