La lista sin red en Android
Al terminar: Enseñar lo guardado cuando no hay conexión, con su aviso y su reintento, y comprobarlo en modo avión.
Al terminar esta lección, la lista de Pokémon se verá aunque no haya red, y cuando la red falle te lo dirá con una franja arriba: «Sin conexión, mostrando lo guardado», con un botón «Reintentar». La lista no se vacía ni se tapa con una pantalla de error: quien usa la app sigue viendo lo que ya tenía.
Importa porque, desde El repositorio con dos fuentes, la app ya funciona sin red, pero en silencio: si una persona tira para refrescar y no pasa nada, no sabe si la lista está al día o si algo falló. Temas: los dos estados de carga que trae una lista con mediador, una regla pequeña que decide cuándo avisar (con su test), la franja en Compose y, de paso, el estado de scroll de la rejilla un nivel más arriba.
Tu proyecto llega de Los tests del repositorio con 65 tests en la JVM, 37 en Android host y 39 en iOS.
Dos estados de carga, no uno
Hasta ahora la lista tenía una sola fuente: la red. Cuando fallaba, fallaba «la lista». Desde El repositorio con dos fuentes hay dos fuentes trabajando a la vez, y cada una tiene su propio estado de carga:
- La fuente (
source): la base de datos. Es la que la pantalla lee. Casi nunca falla. - El mediador (
mediator): la red, que va llenando la base. Es la que falla cuando no hay conexión.
loadState trae los dos, y aquí los usamos para decidir cuándo avisar. Cada fuente tiene su propio trío de estados, un LoadStates (en plural): refresh (la carga inicial o el tirón para refrescar), append (pedir la página siguiente) y prepend (cargar hacia arriba, que esta lista no hace). CombinedLoadStates guarda tres cosas:
- El resumen,
refresh,appendyprependsueltos: lo que usabas hasta ahora, mezcla de las dos fuentes. source: elLoadStatesde la base.mediator: elLoadStatesde la red, onullsi la lista no tiene mediador.
Para avisar «lo que ves está guardado porque la red falló» hay que mirar el del mediador por separado: el resumen no distingue de dónde viene el fallo. Eso sí, cuando el mediador falla, el resumen refresh también se pone en Error: por eso, con la base vacía, la pantalla cae en su rama de error de siempre (la verás en el Paso 3).
La regla, con un ejemplo ajeno
Toda la decisión cabe en una frase: se avisa si hay filas y el mediador ha fallado (al empezar o al ampliar). Fíjate en el ejemplo, con la panadería: la misma regla de Marcador, ahora con dos fuentes. Cada línea lleva su comentario; pégalo en tu Playground de Kotlin si quieres ejecutarlo:
// Los tres estados posibles de una fuente.
sealed interface Estado {
data object Reposo : Estado // no pasa nada
data object Cargando : Estado // trabajando
data class Fallo(val motivo: String) : Estado // algo salió mal
}
// Lo que se sabe de cada fuente. El camión puede no existir (null).
data class Fuentes(
val estante: Estado,
val camionAlEmpezar: Estado?,
val camionAlAmpliar: Estado?,
)
// Un caso para probar: un nombre, lo que se sabe y si hay pan en el estante.
data class Caso(val nombre: String, val fuentes: Fuentes, val hayPan: Boolean)
// Regla: se avisa solo si hay pan en el estante Y el camión ha fallado.
// Sin camión (null), `is` da false y no hay aviso.
fun avisarDeCamion(f: Fuentes, hayPan: Boolean): Boolean =
hayPan && (f.camionAlEmpezar is Estado.Fallo || f.camionAlAmpliar is Estado.Fallo)
fun main() {
val fallo = Estado.Fallo("el camión no llegó")
val casos = listOf(
Caso("todo bien, con pan", Fuentes(Estado.Reposo, Estado.Reposo, Estado.Reposo), hayPan = true),
Caso("camión caído, con pan", Fuentes(Estado.Reposo, fallo, Estado.Reposo), hayPan = true),
Caso("camión caído, estante vacío", Fuentes(Estado.Reposo, fallo, Estado.Reposo), hayPan = false),
Caso("sin proveedor, con pan", Fuentes(Estado.Reposo, null, null), hayPan = true),
)
for (caso in casos) {
println("${caso.nombre} -> avisar = ${avisarDeCamion(caso.fuentes, caso.hayPan)}")
}
}Al ejecutarlo sale:
todo bien, con pan -> avisar = false
camión caído, con pan -> avisar = true
camión caído, estante vacío -> avisar = false
sin proveedor, con pan -> avisar = falseEl tercer caso es el importante: con el estante vacío no es un aviso, es el error de siempre, y le toca a la pantalla de error con su botón de reintentar. El cuarto es una lista sin mediador (la de antes, solo red): tampoco avisa.
Paso 1 · La regla, en común
Qué. Un fichero nuevo en shared, con la regla escrita una sola vez. Va en commonMain porque la usarán las dos apps: la de Android en esta lección y la de iOS en la siguiente. Así las dos avisan exactamente en los mismos casos.
Crea el primero: clic derecho sobre la carpeta shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/list → New → Kotlin Class/File → nombre SinConexion (elige File). Es una función suelta, sin clase: se escribe como una extensión de CombinedLoadStates, es decir, una función que se llama como si el propio objeto la tuviera.
package com.sebastiannarvaez.pokedex.feature.list
import androidx.paging.CombinedLoadStates
import androidx.paging.LoadState
/**
* ¿La lista esta ensenando lo guardado porque la red ha fallado?
*
* Con un mediador hay dos estados de carga: el de la **fuente** (la base, que
* casi nunca falla) y el del **mediador** (la red). Si falla el mediador y hay
* filas, la lista se sigue viendo y solo hace falta avisar. Si no hay filas,
* no es un aviso: es la pantalla de error de siempre, con su reintento.
*
* La regla vive aqui, en comun, para que las dos apps avisen exactamente en
* los mismos casos.
*/
fun CombinedLoadStates.mostrandoLoGuardado(hayFilas: Boolean): Boolean =
hayFilas && (mediator?.refresh is LoadState.Error || mediator?.append is LoadState.Error)Dentro de la extensión, mediator es el del propio objeto sobre el que se llama (this.mediator; Kotlin deja escribir mediator a secas). Lleva el signo de pregunta porque es null en una lista que no tiene mediador (la de antes de este módulo): null is LoadState.Error da false, y sin mediador no hay aviso. Es el cuarto caso del ejemplo de la panadería.
Ahora su test, en commonTest. Estados de carga escritos a mano, sin pantalla ni red: tres tests que cubren los cuatro casos del ejemplo (el último junta dos).
Para crearlo, ve a shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/feature. Mira si dentro está la carpeta list: en El repositorio con dos fuentes borraste el test que vivía ahí y, si Android Studio quitó la carpeta al quedarse vacía, créala: clic derecho sobre feature → New → Directory → list. Después, clic derecho sobre list → New → Kotlin Class/File → nombre SinConexionTest → elige File.
package com.sebastiannarvaez.pokedex.feature.list
import androidx.paging.CombinedLoadStates
import androidx.paging.LoadState
import androidx.paging.LoadStates
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/** La regla del aviso, sin pantalla: solo estados de carga escritos a mano. */
class SinConexionTest {
private val bien = LoadStates(
refresh = LoadState.NotLoading(false),
prepend = LoadState.NotLoading(true),
append = LoadState.NotLoading(false),
)
private fun estados(mediador: LoadStates?) = CombinedLoadStates(
refresh = mediador?.refresh ?: bien.refresh,
prepend = bien.prepend,
append = mediador?.append ?: bien.append,
source = bien,
mediator = mediador,
)
private val fallo = LoadState.Error(Exception("sin red"))
@Test
fun conFilasYLaRedCaidaSeAvisa() {
assertTrue(estados(bien.copy(refresh = fallo)).mostrandoLoGuardado(hayFilas = true))
assertTrue(estados(bien.copy(append = fallo)).mostrandoLoGuardado(hayFilas = true))
}
@Test
fun sinFilasNoEsUnAvisoEsElError() {
// Sin nada guardado no hay nada que «seguir ensenando»: le toca a la
// pantalla de error de siempre.
assertFalse(estados(bien.copy(refresh = fallo)).mostrandoLoGuardado(hayFilas = false))
}
@Test
fun conLaRedBienNoSeAvisa() {
assertFalse(estados(bien).mostrandoLoGuardado(hayFilas = true))
// Y sin mediador (la lista de antes, solo red), tampoco.
assertFalse(estados(null).mostrandoLoGuardado(hayFilas = true))
}
}Dos cosas del ayudante estados que merecen una explicación:
LoadStates(...)es el trío de una fuente (refresh,prepend,append) yCombinedLoadStates(...)es lo que daloadStateen la pantalla: el resumen mássourceymediator. El ayudante rellena el resumen con lo que diría Paging: el del mediador si lo hay (mediador?.refresh ?: bien.refresh: «el del mediador y, si esnull, el debien») y el debienen lo demás.NotLoadinglleva un booleano: ¿se ha llegado al final por ese lado? Enprependvaletrue(hacia arriba no hay nada: la lista solo avanza) y enrefreshyappendvalefalse(puede haber más). No cambia nada en este test, pero así quedan estados creíbles.
Ejecútalo desde la Terminal de Android Studio, en la carpeta del proyecto:
./gradlew :shared:jvmTestDebe terminar en BUILD SUCCESSFUL con 68 tests en la JVM: tres más que al terminar Los tests del repositorio (65), los de SinConexionTest (si añadiste ejercicios con tests, tu cifra será mayor).
Esos tres están en commonTest, así que también corren en iOS y en Android host. Comprueba también esas dos:
./gradlew :shared:iosSimulatorArm64Test
./gradlew :shared:testAndroidHostTestDeben dar 42 en iOS (39 más los tres) y 40 en Android host (37 más los tres).
Paso 2 · La franja en la pantalla
Qué. Se modifica PokemonListScreen.kt, en androidApp. Cinco cambios: el estado de scroll de la rejilla sube un nivel (estadoRejilla), Rejilla recibe ese estado y un Boolean nuevo (sinConexion), se envuelve en una Column con la franja arriba, y se escribe la franja (AvisoSinConexion). Van en este orden: los import, el estado de scroll, la llamada a Rejilla, la función Rejilla y la franja.
Abre el fichero (androidApp → src → main → kotlin → … → android → list → PokemonListScreen.kt). Primero, los import nuevos, junto a los que ya hay. AnimatedVisibility, Column, fillMaxWidth y FontWeight ya estaban importados (los usan la barra superior y la búsqueda), así que no se repiten. Si después de pegar algo sale en rojo, pon el cursor encima y pulsa Option+Enter: el IDE ofrece el import que falta.
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.Surface
import androidx.compose.material3.TextButton
import com.sebastiannarvaez.pokedex.feature.list.mostrandoLoGuardadoSegundo, el estado de scroll de la rejilla sube un nivel (elevar el estado, como en State hoisting). LazyGridState recuerda por dónde va la rejilla; hasta ahora LazyVerticalGrid se lo fabricaba dentro de Rejilla, y desde hoy se crea en PokemonListContent, que siempre está en pantalla, y se le pasa. Es una precaución (si Rejilla saliera un instante del when, se llevaría su scroll): hoy no verás ninguna diferencia.
Dentro de PokemonListContent, debajo de la línea val comportamiento = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(…), añade:
// El estado de scroll vive aqui, que siempre esta, y no dentro de Rejilla,
// que solo aparece cuando ya hay paginas.
val estadoRejilla = rememberLazyGridState()Tercero, en la rama else del when, Rejilla pasa a recibir los dos datos nuevos: ese estado de scroll y la bandera, calculada con la regla común.
// antes
else -> Rejilla(pokemon, alPulsar, idsFavoritos, alMarcar)
// después
else -> Rejilla(
pokemon = pokemon,
alPulsar = alPulsar,
idsFavoritos = idsFavoritos,
alMarcar = alMarcar,
estado = estadoRejilla,
// La regla es común: la misma que usa iOS.
sinConexion = pokemon.loadState.mostrandoLoGuardado(hayFilas = pokemon.itemCount > 0),
)Cuarto, sustituye la función Rejilla entera por esta. Cambia por dentro porque la franja y la rejilla van una encima de la otra en una Column (una columna: sus hijos se apilan de arriba abajo). La franja ocupa lo que necesita y la rejilla, el resto: eso es Modifier.weight(1f), que dentro de una Column reparte la altura que sobra (ya lo viste en una Row, donde reparte la anchura). El contenido de cada tarjeta y el pie de carga no cambian; el resto del fichero tampoco. El parámetro estado: LazyGridState es el que acabas de subir, y state = estado se lo da a LazyVerticalGrid. Y la variable local del when final, que antes se llamaba estado, pasa a llamarse pie (el pie de la rejilla) para no tapar al parámetro nuevo.
@Composable
private fun Rejilla(
pokemon: LazyPagingItems<Pokemon>,
alPulsar: (Int) -> Unit,
idsFavoritos: Set<Int>,
alMarcar: (Pokemon) -> Unit,
estado: LazyGridState,
sinConexion: Boolean,
) {
Column(modifier = Modifier.fillMaxSize()) {
// El aviso va encima de la rejilla y fuera de ella. Dentro, como primer
// elemento, aparece por arriba de lo que se esta mirando y la rejilla
// conserva la posicion: queda fuera de la pantalla y no se ve.
AnimatedVisibility(visible = sinConexion) {
AvisoSinConexion(
reintentar = { pokemon.retry() },
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp),
)
}
LazyVerticalGrid(
state = estado,
columns = GridCells.Adaptive(minSize = 164.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().weight(1f),
) {
items(
count = pokemon.itemCount,
// `itemKey` y no una lambda propia. Escrita a mano, la tentacion es
// `pokemon.peek(i)?.id ?: i`, y ahi esta la trampa: el identificador
// 25 y el indice 25 son la misma clave. Con claves repetidas la
// rejilla deja huecos en blanco, y parece que faltan datos cuando lo
// que falta es una clave unica. `itemKey` resuelve el caso nulo sin
// invadir el espacio de los identificadores.
key = pokemon.itemKey { it.id },
contentType = pokemon.itemContentType { "pokemon" },
) { indice ->
pokemon[indice]?.let { p ->
PokemonCard(
pokemon = p,
esFavorito = p.id in idsFavoritos,
alPulsar = { alPulsar(p.id) },
alMarcar = { alMarcar(p) },
)
}
}
// El pie: cargando mas, o el error de ampliar con su reintento.
when (val pie = pokemon.loadState.append) {
is LoadState.Loading -> item(span = { GridItemSpan(maxLineSpan) }) {
Centrado(alto = 72.dp) { CircularProgressIndicator(modifier = Modifier.size(28.dp)) }
}
is LoadState.Error -> item(span = { GridItemSpan(maxLineSpan) }) {
val error = pie.error.aAppError().aUiError()
EstadoDeError(
titulo = error.titulo,
detalle = error.detalle,
reintentar = if (error.sePuedeReintentar) ({ pokemon.retry() }) else null,
)
}
else -> Unit
}
}
}
}AnimatedVisibility ya lo usan la barra superior y la búsqueda de esta pantalla: la franja aparece y desaparece con una animación suave en vez de saltar. Y pokemon.retry() es el reintento que ya usaba el pie de error: pide otra vez lo que falló. Qué es lo que falló (la carga inicial o la página siguiente) decide qué verás al pulsarlo; lo comprobarás en el paso 3.
Quinto, la franja. Es una caja de color con un icono, el texto y un botón. Se pone al final del fichero, junto a Centrado y EstadoDeError:
/** Una franja tonal, como un banner de Material 3: dice lo que pasa y ofrece reintentar. */
@Composable
private fun AvisoSinConexion(reintentar: () -> Unit, modifier: Modifier = Modifier) {
Surface(
modifier = modifier,
color = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
shape = MaterialTheme.shapes.medium,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 4.dp, bottom = 4.dp),
) {
Icon(Icons.Default.Warning, contentDescription = null)
Text(
"Sin conexión, mostrando lo guardado",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
// El color del texto de la franja, no el primario del tema.
TextButton(
onClick = reintentar,
colors = ButtonDefaults.textButtonColors(contentColor = LocalContentColor.current),
) { Text("Reintentar", fontWeight = FontWeight.Bold) }
}
}
}Los nombres nuevos, uno a uno:
secondaryContaineryonSecondaryContainerson un par de colores del tema pensados para ir juntos: el fondo y lo que se escribe encima.MaterialTheme.shapes.mediumes la forma de esquina mediana del tema, la que redondea la franja.LocalContentColor.currentes «el color que toca ahora para el texto y los iconos»: dentro deSurfaceesonSecondaryContainer.ButtonDefaults.textButtonColors(contentColor = …)cambia el color del botón de texto; sin esa línea el botón usaría el color primario del tema, que sobre el fondo tonal se lee mal.contentDescription = nullen el icono es correcto aquí: el texto de al lado ya dice lo mismo, y un lector de pantalla no tiene que decirlo dos veces.
Paso 3 · Comprobarlo en modo avión
Primera apertura, con la base vacía. Para ver cómo se llena la base hay que empezar sin datos.
- Vacía la app si ya la abriste antes: en el emulador, Settings → Apps → See all apps → la Pokédex → Storage & cache → Clear storage (el nombre de los menús varía un poco entre versiones de Android), o, desde la Terminal de Android Studio,
adb shell pm clear com.sebastiannarvaez.pokedex. - Ejecútala con red (configuración androidApp, como en Ejecutar en Android), con Logcat abierto (View → Tool Windows → Logcat) y filtrado por
Pokedex. - Cuenta las peticiones sin bajar ni un poco: unas 84 líneas
REQUEST. Cuatro son de listado, conoffset=0,offset=20,offset=40yoffset=60; las demás son la ficha de cada Pokémon de esas páginas.
La app descarga cuatro páginas (80 Pokémon) aunque en pantalla se vean solo unos pocos. La razón: si no le dices lo contrario, Paging pide en la primera lectura el triple del tamaño de página (3 × 20 = 60 filas; es el initialLoadSize que dejaste por defecto en Paginación con Paging 3). Mientras esa lectura de 60 llegue al final de la tabla (con 60 filas o menos), Paging le pide otra página al mediador, hasta que hay 80 y la lectura ya no llega al final. Los otros 20 aparecen al bajar. Así queda lista una copia para probar sin red.
Si no vacías la base, no verás esas peticiones: con una copia de menos de 12 horas la app ni intenta la red al abrir, y con una más vieja verás las del refresco. Las dos cosas son lo esperado.
Con la base llena y Logcat a mano, empieza la prueba sin red.
- Apaga la red del emulador. Arrastra con el ratón dos veces desde el borde de arriba de la pantalla del emulador para abrir los ajustes rápidos y pulsa Airplane mode (modo avión), o actívalo en Settings → Network & internet. Si el emulador conserva la red, desactiva también Wi-Fi y Mobile data; y si nada la corta, el test del Paso 1 ya demuestra la regla sin emulador.
- Cierra la app y ábrela otra vez. En Android Studio, pulsa el cuadrado rojo Stop y después Run (el triángulo verde); el modo avión del emulador no cambia. La lista aparece al instante y no hay franja. No es un error: la copia tiene menos de 12 horas y, por la regla de caducidad de El repositorio con dos fuentes, la app ni intentó la red. No ha fallado nada, así que no hay nada que avisar.
- Tira hacia abajo para refrescar. Ahora sí se intenta la red, y falla. La franja «Sin conexión, mostrando lo guardado» tarda unos segundos: el cliente intenta la petición tres veces antes de rendirse (Reintentos con Ktor) y mientras tanto se ve el círculo de refrescar. Cuando sale la franja, la lista no se vacía.
- Baja hasta el final de lo guardado (el número 80). El mediador intenta pedir más (otra vez tarda unos segundos), falla, y a la franja se suma el pie de siempre con «Reintentar». La lista sigue entera; las tarjetas que no habías visto salen sin ilustración, porque Coil no las tenía en su caché.
- Pulsa «Reintentar» (el de la franja o el del pie: con el refresco en error, lanza solo el refresco y descarta la página siguiente pendiente):
- Todavía sin red: falla otra vez, la franja sigue y el pie desaparece.
- Con la red de vuelta (repite el punto 1 para quitar el modo avión): el mediador borra la base y la rellena desde el principio (listados 0, 20, 40 y 60), la franja desaparece y la rejilla no vuelve arriba del todo: en el emulador se queda hacia los números 17 a 20 (el sitio exacto puede variar).
En Logcat ves los intentos sin red: este par de líneas (la petición y su failed) sale tres veces seguidas:
D Pokedex : REQUEST: https://pokeapi.co/api/v2/pokemon?limit=20&offset=0
D Pokedex : REQUEST https://pokeapi.co/api/v2/pokemon?limit=20&offset=0 failed with exception: java.net.UnknownHostException: Unable to resolve host "pokeapi.co": No address associated with hostnamePunto de control
./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Testtermina enBUILD SUCCESSFULcon 68 tests en la JVM, 40 en Android host y 42 en iOS: tres más en cada una que al terminar Los tests del repositorio (65 / 37 / 39), los deSinConexionTest(si añadiste ejercicios con tests, tus cifras serán mayores).PokemonListScreen.ktcreaestadoRejillaenPokemonListContenty se lo pasa aRejilla; la rejilla sigue conservando su sitio al cambiar de pestaña y al volver de una ficha.- Con la red, la lista carga; en modo avión y con la app recién abierta se ve entera y sin franja.
- Tirar para refrescar sin red muestra la franja «Sin conexión, mostrando lo guardado» y no vacía la lista; al bajar hasta el final sale además el pie de error con «Reintentar».
- «Reintentar» con la red de vuelta quita la franja y recarga desde el principio, sin llevarte arriba del todo.
- Con la base vacía y sin red sale la pantalla de error de siempre, no la franja.
Comprueba lo que entendiste
7 preguntas · necesitas 5 para completar la lección
1.Además del resumen (
refresh,append),loadStateguarda un estado de carga por cada fuente de una lista con mediador. ¿Cuáles son?2.¿Cuándo debe aparecer la franja «Sin conexión, mostrando lo guardado»?
3.Abres la app en modo avión con una copia de hace una hora. La lista se ve y no hay franja. ¿Por qué?
4.¿Por qué la regla
mostrandoLoGuardadoestá encommonMainy no dentro de la pantalla de Android?5.La franja va en una
Columnencima de la rejilla y no como primer elemento de la rejilla. ¿Por qué?6.Sin red y sin nada guardado, ¿qué ve la persona?
7.Sin red y con copia reciente, tiras para refrescar y aparece la franja. Bajas hasta el final de lo guardado. ¿Qué ves?