El puente de paginación para iOS
Al terminar: Envolver PagingDataPresenter en un StateFlow que SwiftUI pueda consumir sin saltos.
En Android, la paginación se pinta casi sola: una librería de Paging para Compose (paging-compose, la que instalarás en La lista en Android) trae un adaptador que convierte el flujo de páginas en algo que Compose entiende, y son dos líneas. Aquí solo hace falta saber que ese adaptador existe: es lo que verás en Android. En iOS no hay nada de eso.
Al terminar esta lección existirá la pieza que falta: un objeto de Kotlin que vive en iosMain, que habla con Paging y publica un StateFlow de un estado plano —una lista y cuatro banderas— que SwiftUI sabe pintar sin saber que Paging existe. En la lección siguiente, Probar el puente en iOS, lo probarás en el simulador.
Es la lección más difícil del módulo, y la que mejor enseña qué significa «compartir la lógica y escribir la interfaz dos veces»: el precio de la interfaz nativa se paga aquí, una vez, en unas noventa líneas. Es todo Kotlin (commonMain e iosMain) y no se instala nada nuevo: Paging y la anotación de @NativeCoroutinesState ya están en tu proyecto.
Las rutas de esta lección van desde la raíz del proyecto, en la vista Project del panel de Android Studio (la que muestra las carpetas tal como están en el disco; en Marcador usabas la vista Android).
Paso 1 · Qué existe ya, y qué falta
Esa última frase es la clave: LazyPagingItems, lo que usa Android, es una implementación de PagingDataPresenter. La clase base está en commonMain de paging-common, así que también compila para iOS. Lo que no existe es una implementación para SwiftUI. Así que la escribes, heredando de la misma clase que usa Compose. La clase base es abstracta: no se puede usar tal cual y deja un método sin rellenar, que cada hija tiene que escribir.
Los ficheros que vas a crear en esta lección, con la vista Project. Dos viven en shared/src/iosMain (solo existen para iOS) y uno en commonMain. Además harás un ejercicio en la carpeta ejercicios, que no forma parte de la app.
Los estados de una carga
Antes de escribir nada, una pieza de Paging que el puente lee a cada paso: el estado de una carga. Paging no solo reparte datos; también cuenta qué está haciendo con ellos. Esa cuenta es un LoadState, y solo tiene tres valores:
Loading: está cargando.NotLoading: no está cargando (terminó, o aún no empezó). Trae una marca,endOfPaginationReached, que dice si ya no queda nada más que pedir.Error: la carga falló. Lleva dentro la excepción.
Paging no guarda un solo estado, sino tres juntos (un CombinedLoadStates), porque en una lista pasan tres cosas distintas a la vez:
refresh: la primera carga, o un refresco que empieza de cero.append: pedir la página siguiente, la de abajo.prepend: pedir la anterior, la de arriba. Aquí nunca ocurre, porque la lista solo avanza (suprevKeyesnull, como viste en Paginación con Paging 3).
Un ejemplo mínimo, con datos ajenos al proyecto, para ver los tres valores:
// Una función que traduce un LoadState a una frase. `when` elige una rama
// según el tipo; `is` pregunta «¿es de este tipo?».
fun describir(estado: LoadState): String = when (estado) {
is LoadState.Loading -> "cargando"
// `estado.endOfPaginationReached` solo existe en NotLoading: dentro de esta rama Kotlin ya sabe que lo es.
is LoadState.NotLoading -> if (estado.endOfPaginationReached) "no hay más páginas" else "en reposo"
is LoadState.Error -> "falló: ${estado.error.message}" // `error` es la excepción que llevaba dentro
}
println(describir(LoadState.Loading))
println(describir(LoadState.NotLoading(endOfPaginationReached = false)))
println(describir(LoadState.NotLoading(endOfPaginationReached = true)))
println(describir(LoadState.Error(IOException("sin red"))))Salida real:
cargando
en reposo
no hay más páginas
falló: sin redEl puente de esta lección hace lo mismo, pero en vez de frases rellena un PagedListState. Esta es la traducción, y es todo lo que hace la función publicar() del paso 3:
| Lo que dice Paging | Lo que pinta PagedListState |
|---|---|
refresh es Loading | cargando = true (la primera carga, o el tirón para refrescar) |
append es Loading | cargandoMas = true (se pide la página siguiente) |
refresh es Error | error con el mensaje: la pantalla queda en blanco con su aviso |
append es Error | errorAlAmpliar: la lista cargada se queda y el aviso va al pie |
cualquier NotLoading | todas las banderas apagadas |
Por eso hay dos errores y no uno: vienen de dos estados distintos de Paging, y para el usuario son dos situaciones distintas.
El presentador de Paging, pieza a pieza
Para escribir el puente hace falta saber cómo se usa un PagingDataPresenter. Son cuatro ideas, y el siguiente ejemplo las trae todas, con otro tema: un contador que dice cuántos libros hay cargados.
class ContadorDeLibros(
paginas: Flow<PagingData<String>>, // un flujo de páginas: cada PagingData es «lo cargado»
hilo: CoroutineDispatcher, // el «dispatcher»: decide en qué hilo corre cada corrutina
) {
private val scope = CoroutineScope(SupervisorJob() + hilo)
private val _cuantos = MutableStateFlow(0)
val cuantos: StateFlow<Int> = _cuantos.asStateFlow()
// 1. Se hereda de PagingDataPresenter y se rellena el único método abstracto:
// Paging lo llama cada vez que la lista cambia (llegan datos, se borran, se recarga).
// 2. `mainContext`: el dispatcher con el que Paging procesa esos eventos. Si no se
// pasa, usa Dispatchers.Main, el que corre en el hilo principal de la plataforma.
private val presentador = object : PagingDataPresenter<String>(mainContext = hilo) {
override suspend fun presentPagingDataEvent(event: PagingDataEvent<String>) {
// 3. `snapshot()` es una foto de lo cargado ahora; `.items` es su lista.
_cuantos.value = snapshot().items.size
}
}
init {
// 4. `collectFrom` le entrega al presentador cada PagingData nuevo del flujo.
scope.launch { paginas.collect { presentador.collectFrom(it) } }
}
fun cerrar() = scope.cancel() // cancela las corrutinas del scope
}
// Una sola página ya hecha, con tres libros:
val paginas = flowOf(PagingData.from(listOf("Libro 1", "Libro 2", "Libro 3")))
// Dentro de un test con `runTest`; `UnconfinedTestDispatcher` es un dispatcher de prueba (se explica justo debajo, en el ejercicio):
val contador = ContadorDeLibros(paginas, UnconfinedTestDispatcher(testScheduler))
println("libros leídos: ${contador.cuantos.value}")Salida real:
libros leídos: 3PagingData.from(lista) fabrica un PagingData ya hecho a partir de una lista, sin red ni PagingSource: es la forma corta de probar un presentador.
Reto · Ejercicio: un lector de páginas
Este ejercicio va en la carpeta ejercicios de shared, como el de Tipos que cruzan a Swift: crea una subcarpeta nueva dentro de ejercicios, llamada lector, tanto en shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/ejercicios/ (el código) como en shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/ejercicios/ (el test). Va en commonMain y no en iosMain porque PagingDataPresenter es común, y así corre en los tres destinos.
En LectorDePaginas.kt, en el paquete com.sebastiannarvaez.pokedex.ejercicios.lector, escribe una clase LectorDePaginas que:
- recibe un flujo de páginas de textos (
FlowdePagingDatadeString) y el dispatcher con el que debe trabajar; - por dentro, usa un presentador de Paging con ese mismo dispatcher;
- publica en
textos(unStateFlowde lista de textos) la foto entera de lo cargado, cada vez que Paging avise; - tiene
cerrar(), que cancela sus corrutinas.
En LectorDePaginasTest.kt dale una única página ya hecha con «Ana», «Luis» y «Marta», y comprueba que textos vale exactamente esa lista. Para controlar el dispatcher en el test, usa UnconfinedTestDispatcher(testScheduler) dentro de runTest, y escribe @OptIn(ExperimentalCoroutinesApi::class) encima de la clase del test. Tres piezas nuevas, en dos líneas:
UnconfinedTestDispatcheres un dispatcher de mentira para tests: ejecuta cada paso de una corrutina en cuanto puede, sin esperar turno.testScheduleres el reloj virtual querunTestpone a tu disposición, y se le pasa para que el dispatcher y el test compartan el mismo.@OptIn(ExperimentalCoroutinesApi::class)es el permiso que Kotlin pide para usar una pieza que aún se considera experimental: sin él, el compilador avisa de que la API puede cambiar.
Ejecuta ./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test.
Resultado esperado:
BUILD SUCCESSFULSi quieres contar tests, esta lección suma uno en cada destino (los números del módulo están en el punto de control del final). El compilador de iOS avisa de StateFlow property is exposed to ObjC: es el aviso de El ViewModel compartido, y aquí no hace falta arreglarlo.
Ver solución
package com.sebastiannarvaez.pokedex.ejercicios.lector
import androidx.paging.PagingData
import androidx.paging.PagingDataEvent
import androidx.paging.PagingDataPresenter
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
// Convierte un flujo de páginas en algo que cualquiera sabe leer: una lista.
class LectorDePaginas(
paginas: Flow<PagingData<String>>,
hilo: CoroutineDispatcher, // el dispatcher con el que trabaja todo
) {
private val scope = CoroutineScope(SupervisorJob() + hilo)
private val _textos = MutableStateFlow<List<String>>(emptyList())
val textos: StateFlow<List<String>> = _textos.asStateFlow()
// El presentador de Paging, con el mismo dispatcher: si no, usaría Dispatchers.Main.
private val presentador = object : PagingDataPresenter<String>(mainContext = hilo) {
override suspend fun presentPagingDataEvent(event: PagingDataEvent<String>) {
_textos.value = snapshot().items // cada cambio: se relee la foto entera
}
}
init {
scope.launch { paginas.collect { presentador.collectFrom(it) } }
}
fun cerrar() = scope.cancel()
}package com.sebastiannarvaez.pokedex.ejercicios.lector
import androidx.paging.PagingData
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
@OptIn(ExperimentalCoroutinesApi::class)
class LectorDePaginasTest {
@Test
fun loQueLlegaEnPaginasSeLeeComoLista() = runTest {
// Una sola página ya hecha, con tres nombres.
val paginas = flowOf(PagingData.from(listOf("Ana", "Luis", "Marta")))
val lector = LectorDePaginas(paginas, UnconfinedTestDispatcher(testScheduler))
assertEquals(listOf("Ana", "Luis", "Marta"), lector.textos.value)
lector.cerrar()
}
}Si en la clase quitas mainContext = hilo del presentador, el test falla sin decir por qué: el mensaje solo cuenta que la lista llegó vacía (expected:<[Ana, Luis, Marta]> but was:<[]> en la JVM; Expected <[Ana, Luis, Marta]>, actual <[]>. en iOS). Es el mismo problema que vas a ver, a propósito, en la lección siguiente.
Paso 2 · El estado plano
El archivo. PagedListState.kt es lo único que SwiftUI va a leer: una lista de Pokémon y unas banderas que dicen en qué momento está (cargando, cargando más, con error). Va en commonMain porque no tiene nada de iOS.
Créalo. Clic derecho sobre la carpeta shared → New → File y escribe la ruta desde la carpeta del módulo (el método de El módulo compartido y la prueba de humo). La carpeta list ya existe: la creó Paginación con Paging 3.
src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/list/PagedListState.kt(Plan B, en la Terminal de Android Studio: touch con la misma ruta.)
El concepto. En Android no hace falta nada de esto, porque Compose entiende el flujo de páginas. Para SwiftUI hay que traducirlo a algo que se pinte directamente: la tabla de arriba. Fíjate en los dos errores: si falla la carga inicial, la pantalla se queda en blanco con su aviso; si falla al pedir la página siguiente, la lista que ya estaba cargada se queda donde está y el aviso va al pie. Ahí está la diferencia entre una lista que se usa y una que no.
Escribe, empezando por la línea package:
package com.sebastiannarvaez.pokedex.feature.list
import com.sebastiannarvaez.pokedex.core.UiError // el error ya traducido de la lección anterior
import com.sebastiannarvaez.pokedex.domain.Pokemon // lo que se pinta en cada tarjeta
/**
* La lista paginada, en un solo objeto que una interfaz pueda pintar.
*/
data class PagedListState(
val items: List<Pokemon> = emptyList(), // lo cargado hasta ahora
/** La primera carga, o un tirón para refrescar. */
val cargando: Boolean = false,
/** Se está pidiendo la página siguiente. */
val cargandoMas: Boolean = false,
/** El error de la carga inicial. Deja la pantalla en blanco con su aviso. */
val error: UiError? = null, // `?` = puede ser null: null significa «sin error»
/** El error al pedir la página siguiente. La lista sigue ahí. */
val errorAlAmpliar: UiError? = null,
) {
// Una propiedad calculada: ni cargando, ni con error, ni un solo elemento.
val vacia: Boolean get() = items.isEmpty() && !cargando && error == null
}Paso 3 · El puente
El archivo. PokemonListPresenter.kt es el enchufe del cuadro eléctrico: hereda de PagingDataPresenter, escucha lo que Paging cuenta y lo convierte en un PagedListState que publica en un StateFlow. Solo existe en iOS, así que va en iosMain.
Créalo. Clic derecho sobre shared → New → File → src/iosMain/kotlin/com/sebastiannarvaez/pokedex/feature/list/PokemonListPresenter.kt (el menú crea la carpeta feature/list dentro de iosMain, que todavía no existe; o mkdir -p y touch en la Terminal).
El concepto. Es el mismo esquema del ejemplo de los libros y del ejercicio, con tres añadidos: lee los tres estados de carga (publicar()), expone loadAround, retry y refresh para que SwiftUI mande órdenes, y tiene un StateFlow anotado para Swift. Tres piezas de sintaxis merecen una línea:
internal constructorsignifica que solo el código de este módulo puede crear unPokemonListPresenter: Swift lo recibe ya hecho porListIos.kt(paso 4) y nunca lo construye.acceso[index]sobre un presentador es avisar a Paging de que se mira esa posición; así se piden más páginas. Por esoloadAroundsolo «lee» el elemento y no hace nada con él.as?es un cast seguro. Un cast convierte un valor a otro tipo:asa secas lo hace a la fuerza y lanza una excepción si el valor no es de ese tipo;as?devuelvenullen vez de fallar.
val cosas: List<Any> = listOf("texto", 42) // una lista que mezcla tipos
for (cosa in cosas) {
val numero = cosa as? Int // Int si es un entero; null si no lo es
println("$cosa -> $numero")
}
try {
val a = cosas[0] as Int // «texto» no es un Int: lanza ClassCastException
println(a)
} catch (e: ClassCastException) {
println("as a secas: falla con ClassCastException")
}Salida real:
texto -> null
42 -> 42
as a secas: falla con ClassCastExceptionCon eso, (refresh as? LoadState.Error)?.error se lee así: «si refresh es un error, su excepción; si no, null». Escribe el fichero entero:
package com.sebastiannarvaez.pokedex.feature.list
import androidx.paging.LoadState // Loading / Error / NotLoading
import androidx.paging.PagingDataEvent // lo que Paging cuenta que ha cambiado
import androidx.paging.PagingDataPresenter // la clase de la que se hereda
import com.rickclephas.kmp.nativecoroutines.NativeCoroutinesState // hace `state` visible desde Swift
import com.sebastiannarvaez.pokedex.core.AppDispatchers // los hilos, como dependencia
import com.sebastiannarvaez.pokedex.core.aAppError // de la lección anterior: excepción → AppError
import com.sebastiannarvaez.pokedex.core.aUiError // AppError → texto para pintar
import com.sebastiannarvaez.pokedex.domain.Pokemon
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class PokemonListPresenter internal constructor(
private val viewModel: PokemonListViewModel, // de donde salen las páginas
// Los hilos se inyectan en vez de usar `Dispatchers.Main` a pelo, para
// poder sustituirlos en un test.
private val dispatchers: AppDispatchers,
) {
// El alcance de todas las corrutinas del presentador. Hay que cancelarlo (`close`).
private val scope = CoroutineScope(SupervisorJob() + dispatchers.main)
// Empieza en «cargando»: la primera página todavía no ha llegado.
private val _state = MutableStateFlow(PagedListState(cargando = true))
@NativeCoroutinesState // Swift lo ve como un valor + una secuencia
val state: StateFlow<PagedListState> = _state.asStateFlow()
// Un objeto anónimo que hereda de PagingDataPresenter, la clase base que
// también usa Compose; solo hay que decir qué hacer con cada evento.
// `mainContext`: el hilo en el que Paging procesa sus eventos. Se le pasa el
// mismo hilo inyectado que usa el `scope`: en la app es el principal y en un
// test es el de prueba.
private val presenter = object : PagingDataPresenter<Pokemon>(mainContext = dispatchers.main) {
override suspend fun presentPagingDataEvent(event: PagingDataEvent<Pokemon>) {
// Cualquier evento (inserción, borrado, recarga) se resuelve igual: se
// vuelve a leer la instantánea entera. Para unos cientos de elementos es
// más barato que llevar las diferencias a mano, y no hay forma de equivocarse.
publicar()
}
}
init {
scope.launch {
// Cada `PagingData` nuevo que emite el ViewModel se le entrega al presentador.
viewModel.pokemon.collect { presenter.collectFrom(it) }
}
scope.launch {
// Y cada cambio de estado de carga (cargando, error…) también se publica.
presenter.loadStateFlow.collect { publicar() }
}
}
/** Avisa de que la interfaz está mirando esa posición, para pedir más. */
fun loadAround(index: Int) {
if (index in 0 until presenter.size) presenter[index] // acceder al elemento ES avisar a Paging
}
fun retry() = presenter.retry() // reintenta lo que falló (el botón «Reintentar»)
fun refresh() = presenter.refresh() // vuelve a cargar desde el principio
/** Hay que llamarlo al desaparecer la pantalla, o las corrutinas siguen. */
fun close() = scope.cancel()
// Convierte lo que Paging sabe en el estado plano de arriba.
private fun publicar() {
// `CombinedLoadStates`: el estado de carga de refresh, prepend y append juntos
// (`null` hasta que Paging informa por primera vez, de ahí los `?.`).
val estados = presenter.loadStateFlow.value
val refresh = estados?.refresh // el estado de la carga inicial / refresco
val append = estados?.append // el estado de «pedir la página siguiente»
val items = presenter.snapshot().items // la foto de lo cargado ahora
_state.value = PagedListState(
items = items,
cargando = refresh is LoadState.Loading,
cargandoMas = append is LoadState.Loading,
// `as?` saca el error solo si de verdad lo es; después se traduce con lo del paso anterior.
error = (refresh as? LoadState.Error)?.error?.aAppError()?.aUiError(),
errorAlAmpliar = (append as? LoadState.Error)?.error?.aAppError()?.aUiError(),
)
}
}Tres decisiones que conviene mirar despacio:
- Releer la instantánea entera en cada evento. Podrías aplicar las diferencias —inserciones, borrados— una a una, y sería más eficiente. Con unos cientos de elementos sale más barato así, y sobre todo no hay forma de equivocarse. Cuando la lista se mida en decenas de miles, ya se cambiará.
- Swift no llama nunca a Paging. Lee
statey avisa de por dónde va mirando conloadAround. La API que cruza la frontera son cinco miembros —state(con sus dos caras, el valor y la secuencia),loadAround,retry,refreshyclose—, y ninguno mencionaPagingData. close()existe porque el scope es de este objeto. Si la pantalla desaparece y nadie lo llama, las dos corrutinas delinitsiguen vivas.
Paso 4 · La puerta, para que Swift no vea el grafo
El archivo. ListIos.kt es una función suelta que Swift llama para obtener el presentador ya montado: la «puerta» a todo lo anterior. Así Swift no conoce Koin ni el ViewModel; solo pide un presentador.
Créalo. Clic derecho sobre shared → New → File → src/iosMain/kotlin/com/sebastiannarvaez/pokedex/feature/list/ListIos.kt (la carpeta ya existe desde el paso anterior).
El concepto. Es el patrón de El mismo ViewModel en iOS: el ViewModel vive en un almacén (ViewModelStoreOwner) que controla Swift, y de ahí depende cuándo se destruye. Una función suelta (de nivel superior) en un fichero la ve Swift como ListIosKt.pokemonListPresenter(owner:). En Las funciones que cruzan a Swift viste la regla de no exponer funciones de nivel superior; esta es la misma salvedad que HomeIosKt: una sola función de entrada por pantalla y sin lógica.
package com.sebastiannarvaez.pokedex.feature.list
import androidx.lifecycle.ViewModelProvider // lo que crea un ViewModel dentro de un almacén
import androidx.lifecycle.ViewModelStoreOwner // el dueño del almacén: lo da Swift
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.sebastiannarvaez.pokedex.core.AppDispatchers
import org.koin.mp.KoinPlatform // da acceso al Koin ya arrancado
/**
* Lo único que Swift necesita saber de la lista.
*/
fun pokemonListPresenter(owner: ViewModelStoreOwner): PokemonListPresenter {
val koin = KoinPlatform.getKoin() // el grafo de dependencias (Koin), ya arrancado
val viewModel = ViewModelProvider.create(
owner = owner, // el ViewModel queda guardado en el almacén de Swift
// «Si no existe aún, créalo con Koin»: la fábrica solo se usa la primera vez.
factory = viewModelFactory { initializer { koin.get<PokemonListViewModel>() } },
)[PokemonListViewModel::class] // y se pide por su tipo
// El presentador se monta sobre ese ViewModel y los hilos de la app.
return PokemonListPresenter(viewModel, koin.get<AppDispatchers>())
}Punto de control
./gradlew :shared:linkDebugFrameworkIosSimulatorArm64termina enBUILD SUCCESSFUL. Puede avisar deFlow type is exposed to ObjC, como en Un Flow hasta Swift.- En el fichero de cabecera que genera,
grep -rn "pokemonListPresenter(owner:" shared/build/bin --include=Shared.hencuentra una línea conswift_name("pokemonListPresenter(owner:)")(se lanza desde la raíz del proyecto, la carpeta que contieneshared;grepbusca un texto dentro de ficheros,-rentra en las carpetas y-npone el número de línea). PokemonListPresenterpasamainContext = dispatchers.mainaPagingDataPresenter.stateestá anotado con@NativeCoroutinesState.PagedListStatedistingueerrordeerrorAlAmpliar../gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Testtermina enBUILD SUCCESSFULcon 34 / 33 / 33 tests (JVM / Android host / iOS) con tus ejercicios, y 27 / 26 / 26 sin ellos. Los ejercicios suman tests (los de las lecciones anteriores yLectorDePaginasTest, con 1 en cada destino, según los que hayas hecho). La JVM tiene uno más que Android porqueModulesTest, el de Koin, solo corre en la JVM.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.¿Qué es exactamente lo que falta en iOS y hay que escribir a mano?
2.
presentPagingDataEventignora el evento que recibe y vuelve a leer la instantánea entera. ¿Es un descuido?3.¿Por qué
PagedListStatetieneerroryerrorAlAmpliar, en vez de un solo campo de error?4.El usuario llega al final de la lista y la petición de la página siguiente falla. ¿Qué estado de Paging lo cuenta y qué campo de
PagedListStatese rellena?5.En
(refresh as? LoadState.Error)?.error, ¿qué vale la expresión cuandorefreshesLoading?