La pila de pantallas en Android
Al terminar: Guardar la pila, abrir la ficha desde la lista, gestionar el atrás y ejecutar la navegación.
Al terminar esta lección, tocar una tarjeta de la lista abre la ficha del Pokémon, y el botón atrás —el del sistema, el gesto y el de la pantalla— vuelve a la lista con el scroll donde estaba. Cada ficha tendrá además su propio ViewModel, que se limpia solo al salir, y la pila (la ficha abierta) sobrevivirá a girar el móvil y a que el sistema cierre la app.
Todos los comandos se escriben en la Terminal de Android Studio, en ~/Pokedex-KMP. Para abrir un fichero que ya existe: Navigate → File… (Cmd+Shift+O) y su nombre.
Paso 1 · La pila, con un almacén de ViewModel por entrada
El archivo. PokedexApp.kt es la raíz de la navegación: crea la pila y dice qué pantalla dibuja cada clave. Créalo junto a NavKeys.kt (clic derecho sobre la carpeta androidApp → New → File), con la ruta:
src/main/kotlin/com/sebastiannarvaez/pokedex/android/navigation/PokedexApp.ktEl concepto nuevo de este paso es la línea de entryDecorators. Los decoradores de entrada ya los conoces (Navigation 3: la pila de pantallas): cada uno le da a la entrada una capacidad. Hay dos que importan aquí, y los dos van en la lista:
rememberSaveableStateHolderNavEntryDecorator(): lo que cada pantalla recuerda conrememberSaveable—como la posición de la rejilla—. Se pone el primero, como en Navegación en Marcador: es el orden con el que la lista conserva su scroll al volver.rememberViewModelStoreNavEntryDecorator(): un almacén deViewModelpor entrada. Sin él, todas las fichas comparten el de la Activity y abrir un Pokémon enseña el anterior (lo provocas al final).
Hay una razón para escribir la lista a mano y no fiarse del valor por defecto: la lista que pasas sustituye a la de por defecto. NavDisplay trae el decorador de rememberSaveable solo si no le pasas ninguna; en cuanto le pasas una, solo cuenta lo que hay en ella.
Escríbelo, con las dos pantallas y los dos decoradores:
package com.sebastiannarvaez.pokedex.android.navigation
import androidx.compose.runtime.Composable
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import com.sebastiannarvaez.pokedex.android.detail.PokemonDetailScreen
import com.sebastiannarvaez.pokedex.android.list.PokemonListScreen
@Composable
fun PokedexApp(inicio: NavKey = ListaKey) {
// La pila es una lista de claves que sobrevive a rotar y a que maten el proceso.
val pila = rememberNavBackStack(inicio)
NavDisplay(
backStack = pila,
// «Atrás» quita la última clave, pero solo si queda otra debajo.
onBack = { if (pila.size > 1) pila.removeAt(pila.lastIndex) },
entryDecorators = listOf(
// Primero el de rememberSaveable: la lista conserva el scroll al volver.
rememberSaveableStateHolderNavEntryDecorator(),
// Y el de ViewModel: cada ficha tiene el suyo y se limpia al salir.
rememberViewModelStoreNavEntryDecorator(),
),
entryProvider = entryProvider {
entry<ListaKey> {
// La lista no sabe adónde lleva un toque: avisa, y aquí se decide.
PokemonListScreen(
alPulsar = { pokemon -> pila.add(DetalleKey(pokemon.id)) },
)
}
entry<DetalleKey> { clave ->
// clave es la DetalleKey de arriba de la pila, con su pokemonId tipado.
PokemonDetailScreen(
pokemonId = clave.pokemonId,
alVolver = { if (pila.size > 1) pila.removeAt(pila.lastIndex) },
)
}
},
)
}PokemonListScreen todavía no acepta alPulsar: Android Studio lo marcará en rojo hasta el paso siguiente.
Lo que escribe cada parte, de arriba abajo:
rememberNavBackStack(inicio)crea la pila con una clave dentro, la lista, y la guarda para que sobreviva. Por eso las claves son@Serializabley por eso hace falta el plugin del paso 1 de la lección anterior.onBackes lo que pasa al pulsar atrás. Quita la última clave, pero soloif (pila.size > 1): con una sola clave, la lista, no hay nada debajo a lo que volver. En ese casoNavDisplayno atiende el atrás y lo atiende el sistema, que cierra la app.entry<ListaKey> { … }yentry<DetalleKey> { clave -> … }dicen qué pantalla dibuja cada clave. En la segunda,clavees laDetalleKeyde arriba de la pila, con supokemonIdya tipado.
Una cosa más: entry<ListaKey> { … } no se importa, porque es una función del bloque entryProvider { } y dentro de él está disponible sin más.
Paso 2 · Que la lista avise, y que la Activity deje de dibujar la lista
Tres ficheros que ya existen cambian. Ábrelos con Navigate → File….
MainActivity.kt deja de pintar la lista y pinta la pila. Sustituye el import de PokemonListScreen por el de PokedexApp y la llamada:
import com.sebastiannarvaez.pokedex.android.navigation.PokedexApp PokedexTheme {
PokedexApp()
}PokemonCard.kt y PokemonListScreen.kt cambian en varios sitios pequeños, así que se sustituyen enteros: ábrelos, pulsa Copiar fichero, selecciona todo (Cmd+A) y pega encima (Cmd+V). Son los mismos que copiaste en La lista en Android con un parámetro nuevo.
Lo que cambia, para que lo veas. La tarjeta gana un parámetro y se lo pasa a Card, que ya trae su propio onClick con resaltado y accesibilidad hechos:
fun PokemonCard(
pokemon: Pokemon,
alPulsar: () -> Unit, // nuevo: qué hacer al tocarla
modifier: Modifier = Modifier,
) {
// …
Card(
onClick = alPulsar, // nuevoY la pantalla deja pasar el aviso de arriba abajo, sin saber adónde lleva: tres funciones lo reciben y lo pasan a la siguiente, hasta la tarjeta (alPulsar: (Pokemon) -> Unit es el hilo conductor):
fun PokemonListScreen(
alPulsar: (Pokemon) -> Unit, // nuevo
modifier: Modifier = Modifier,
viewModel: PokemonListViewModel = koinViewModel(),
) {
// …
PokemonListContent(pokemon = pokemon, alPulsar = alPulsar, modifier = modifier)
}
private fun PokemonListContent(pokemon: LazyPagingItems<Pokemon>, alPulsar: (Pokemon) -> Unit, modifier: Modifier = Modifier) {
// …
else -> Rejilla(pokemon, alPulsar)
}
private fun Rejilla(pokemon: LazyPagingItems<Pokemon>, alPulsar: (Pokemon) -> Unit) {
// …
pokemon[indice]?.let { PokemonCard(it, alPulsar = { alPulsar(it) }) }
}La pantalla de lista no conoce la pila, no conoce DetalleKey y no importa nada de Navigation 3. Solo dice «han pulsado este Pokémon». Quien decide qué significa eso es PokedexApp.
Paso 3 · Ejecútala, y rómpela dos veces
Ejecútala. Con androidApp en el selector de configuración y tu emulador, como en Ejecutar en Android, pulsa Run. Baja por la lista, toca una tarjeta y vuelve con el gesto atrás: la lista debe estar donde la dejaste. Prueba también el botón de la flecha de la ficha y el botón atrás del sistema: los tres hacen lo mismo, quitar la ficha de la pila.
Y si desplazas la ficha hacia arriba, verás que el botón de volver y el número se van con el contenido y se meten bajo la barra de estado, tapados por ella. Es lo normal en una ficha que se desplaza, y el atrás del sistema (el gesto o el botón) sigue funcionando.
Qué sobrevive. Una regla que puedes comprobar tú mismo, con los dos decoradores puestos:
- La ficha abierta sobrevive siempre, al girar y a que el sistema mate el proceso.
- La lista vuelve a su sitio al volver de una ficha y al girar.
- Tras matar el proceso, la lista conserva su posición si estaba dentro de las primeras 60 tarjetas más o menos (lo que Paging vuelve a cargar de entrada); si estaba más abajo, vuelve a una zona cercana, no a la tarjeta exacta. El scroll no es una garantía infinita.
Para girar el emulador, pulsa el botón de rotar de la barra lateral (si no gira, activa el giro automático en los ajustes rápidos del móvil virtual). Para matar el proceso, es el caso de Rotación y muerte del proceso:
Con la ficha abierta, pulsa Home; en la Terminal de Android Studio escribe adb shell am kill com.sebastiannarvaez.pokedex y comprueba con adb shell pidof com.sebastiannarvaez.pokedex que ya no imprime nada (si imprime un número, el proceso sigue vivo: repite el kill). Reabre la app desde el icono del emulador, no desde Android Studio: abre directamente en la ficha, porque la pila se guardó.
Los dos decoradores no son opcionales. Sin el de rememberSaveable, la lista pierde su posición en cualquier vuelta, no solo al cerrar la app: al volver de una ficha aparece arriba del todo. La posición de la rejilla vive en un rememberSaveable, y sin el decorador que lo guarda por entrada, cada entrada olvida lo que recordaba al salir de la pantalla. En este estado de la app no hace falta guardar la posición de la rejilla a mano.
Provócalo 1: quita el plugin de serialización
Del plugin no avisa nadie al compilar, y por eso conviene verlo romperse. En androidApp/build.gradle.kts, borra la línea alias(libs.plugins.kotlinxSerialization), pulsa Sync Now y ejecuta la app. Compila sin una sola queja. Y al abrirse, se cierra sola, sin que toques nada.
En Logcat, con la consulta package:mine level:ERROR, aparece, recortado (el número de PID será otro):
FATAL EXCEPTION: main
Process: com.sebastiannarvaez.pokedex, PID: 9579
kotlinx.serialization.SerializationException: Serializer for class 'ListaKey' is not found.
Please ensure that class is marked as '@Serializable' and that the serialization compiler plugin is applied.
at kotlinx.serialization.internal.Platform_commonKt.serializerNotRegistered(Platform.common.kt:90)
at androidx.navigation3.runtime.serialization.NavKeySerializer.serialize(NavKeySerializer.android.kt:93)
at androidx.navigation3.runtime.serialization.NavBackStackSerializer.serialize(NavBackStackSerializer.kt:55)
…El mensaje lo dice: ListaKey está marcada como @Serializable, pero nadie ha generado su serializador, porque el plugin del compilador no está aplicado. Y se rompe al abrir y no al girar o al esperar un rato, porque rememberNavBackStack es un rememberSaveable, y Compose, nada más dibujar la lista por primera vez, intenta guardar la pila una primera vez. Para guardarla necesita el serializador de ListaKey, y no existe. Ni siquiera llega a pintarse la lista.
Devuelve la línea, pulsa Sync Now y comprueba que vuelve a abrir.
Provócalo 2: quita el decorador del ViewModel
En PokedexApp.kt, quita de entryDecorators la línea rememberViewModelStoreNavEntryDecorator() (y su import) y deja la otra. Ejecuta. Abre Bulbasaur, vuelve, desliza hasta Ivysaur y ábrelo.
Abres Ivysaur y la ficha dice Bulbasaur. No es un parpadeo: espera diez segundos y sigue diciendo Bulbasaur. Abre Venusaur y lo mismo: se queda con el primero.
La causa está en dónde vive el ViewModelStore. Sin el decorador, todas las entradas de la pila comparten el almacén de la Activity. koinViewModel { parametersOf(pokemonId) } pide un PokemonDetailViewModel a ese almacén, y ahí ya hay uno, el de Bulbasaur, guardado bajo la misma clave de tipo. Koin no llega a construir nada: el almacén devuelve el que tenía, con el identificador del primero y su uiState ya relleno, y el init que carga la ficha ya corrió y no vuelve a correr.
Por eso el fallo aparece entre pantallas y no dentro de una: dentro de una ficha todo es coherente. Lo que falla es la identidad, que se asume por tipo cuando debería ser por entrada de la pila. De aquí sale una regla que sirve para toda navegación con ViewModel: el ámbito de un ViewModel tiene que ser el mismo que el de la pantalla que lo usa. Si el ámbito es más ancho —la Activity—, verás datos de otra pantalla. Si es más estrecho —la composición—, perderás el estado al girar el móvil.
El arreglo no es pasar el identificador como clave manual ni limpiar el ViewModel a mano. Es devolver la línea del decorador, que le da a cada NavEntry su propio ViewModelStore y lo limpia cuando esa entrada sale de la pila. Devuélvela, con su import, y comprueba que Ivysaur vuelve a decir Ivysaur.
Punto de control
- Tocas una tarjeta y se abre la ficha; el botón de volver, el gesto atrás y el botón del sistema devuelven a la lista.
- Bajas varias tarjetas, abres una ficha y vuelves: la lista sigue en el mismo sitio, no arriba del todo.
- Abres un Pokémon, vuelves y abres otro: siempre es el que tocaste, nunca el anterior.
- Con la ficha sin desplazar, el botón de volver queda justo bajo la barra de estado, sin taparse, y al bajar hasta el final la última estadística no queda tapada por la barra del sistema.
- Un atrás más, desde la lista, cierra la app: la pila tiene una sola clave y no hay nada a lo que volver.
NavKeys.ktyPokedexApp.ktson los únicos ficheros que importan algo deandroidx.navigation3(compruébalo con Cmd+Shift+F, que busca texto en todo el proyecto, escribiendoimport androidx.navigation3;androidx.lifecycle.viewmodel.navigation3es otro paquete y también lo importaPokedexApp.kt).PokemonListScreenno importa nada de Navigation 3: solo recibe una lambda.- Los dos «Provócalo» quedan deshechos: el plugin y el decorador del
ViewModelestán puestos.
Comprueba lo que entendiste
6 preguntas · necesitas 5 para completar la lección
1.Quitas
rememberViewModelStoreNavEntryDecorator()deentryDecorators. Abres Bulbasaur, vuelves, abres Ivysaur. ¿Qué ves?2.Una pantalla de lista no debe saber a qué pantalla lleva un toque. En esta lección, ¿cómo se consigue?
3.¿Por qué
NavKeys.ktyPokedexApp.ktson los únicos ficheros que importan algo deandroidx.navigation3?4.Pasas
entryDecorators = listOf(rememberViewModelStoreNavEntryDecorator())aNavDisplayy nada más. ¿Qué le pasa a la lista al volver de una ficha?5.Quitas el plugin
kotlinxSerializationdeandroidApp/build.gradle.ktsy ejecutas. ¿Qué pasa?6.Estás en la lista, con una sola clave en la pila, y pulsas atrás. ¿Qué pasa?