Saltar al contenido
K
BúsquedaPaso a paso28 min Avanzado

El índice de búsqueda

Al terminar: Traer una sola vez los nombres de todos los Pokémon y guardarlos como índice local, con un candado y un test que cuenta las llamadas.

Al terminar esta lección, el repositorio de la Pokédex guardará en memoria el índice de nombres de todos los Pokémon (1351, con su número), lo pedirá a PokeAPI una sola vez aunque se lo pidan muchas veces seguidas, y habrá un test que lo vigila. Todo en commonMain y commonTest, comprobado en la JVM.

Y hay un problema antes de empezar, que es el que da forma a toda la lección: PokeAPI no tiene buscador. No existe ?q=pika. La única forma de buscar es traerse la lista entera de nombres y filtrar en el dispositivo.

Es la primera de dos lecciones. Aquí se construye y se prueba el índice; en la siguiente, El ViewModel de la búsqueda, se le pone encima lo que filtra mientras se teclea.

No se instala nada: kotlinx-coroutines y su librería de tests ya están en shared desde Corrutinas en commonMain.

El concepto · PokeAPI no tiene buscador

/pokemon/{nombre} existe, pero solo acierta con el nombre exacto: pikachu sí, pika no. En la documentación no hay ni un parámetro de búsqueda ni uno de prefijo.

Así que el plan es traerse el índice completo una vez: la lista de todos los nombres, cada uno con su número. Pidiendo https://pokeapi.co/api/v2/pokemon?limit=2000 salen 1351 nombres en 93.324 bytes, unos 93 KB. Es una descarga que cabe de sobra en memoria y que solo se hace una vez por sesión.

El concepto · Dos lectores a la vez: el candado

Traerse el catálogo tiene su trampa. El repositorio es uno solo para toda la app (single en Koin, como viste en Koin: un solo grafo en commonMain) y cualquiera puede pedirle el índice. Si dos lo piden a la vez cuando aún no está, cada uno lo descargaría por su cuenta: dos descargas idénticas.

Se evita con un candado. En corrutinas se llama Mutex (de mutual exclusion, «exclusión mutua»): mientras una corrutina lo tiene cogido, las demás esperan en la puerta, y cuando lo suelta, pasa la siguiente. Coger el candado se llama lock (suspende a la corrutina hasta que el candado es suyo) y soltarlo, unlock. Casi nunca se escriben a mano: candado.withLock { … } hace las dos cosas, y suelta el candado siempre, aunque lo de dentro falle.

No reentrante quiere decir que si desde dentro del withLock llamaras a otra función que también coge el candado, esa segunda espera al propio dueño y te quedarías bloqueado para siempre. En la app no pasa, porque dentro solo habrá una llamada a la API.

Un ejemplo con datos ajenos a la Pokédex, una biblioteca. Tres lectores llegan a la vez y el catálogo aún no está en la mesa. Es un ejemplo para probar fuera de tu proyecto, por ejemplo en el Kotlin Playground (que suele traer kotlinx-coroutines), o simplemente para leer: la salida de abajo es la que da de verdad.

Una cosa nueva en el ejemplo: joinAll(). Sobre una lista de corrutinas lanzadas, espera a que terminen todas (como join() espera a una sola) antes de seguir a la línea siguiente.

ejemplo mínimo: un candado para traer el catálogo una sola vez (con comentarios)
import kotlinx.coroutines.delay
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
 
class Biblioteca {
    var viajes = 0                                    // cuántas veces se fue al mostrador
        private set
    private val candado = Mutex()                     // el candado: una corrutina dentro, las demás esperan
    private var catalogo: List<String>? = null        // null = aún no lo tenemos
 
    private suspend fun traerCatalogo(): List<String> {
        viajes++
        delay(100)                                    // el viaje tarda: aquí otras corrutinas pueden colarse
        return listOf("Dune", "Emma", "Ulises")
    }
 
    // Sin candado: las tres ven null a la vez y van las tres al mostrador.
    suspend fun sinCandado(): List<String> =
        catalogo ?: traerCatalogo().also { catalogo = it }
 
    // Con candado: la primera entra; las otras esperan en la puerta y, al pasar,
    // vuelven a mirar (el segundo `catalogo ?:`) y lo encuentran ya traído.
    suspend fun conCandado(): List<String> =
        catalogo ?: candado.withLock {
            catalogo ?: traerCatalogo().also { catalogo = it }
        }
}
 
fun main() = runBlocking {
    val a = Biblioteca()
    List(3) { launch { a.sinCandado() } }.joinAll()   // tres lectores a la vez
    println("sin candado: ${a.viajes} viajes")
 
    val b = Biblioteca()
    List(3) { launch { b.conCandado() } }.joinAll()
    println("con candado: ${b.viajes} viaje")
 
    b.conCandado()                                    // un lector más tarde: ni candado ni viaje
    println("un lector más: ${b.viajes} viaje")
}
sin candado: 3 viajes
con candado: 1 viaje
un lector más: 1 viaje

Lo que importa está en conCandado. El primer catalogo ?: mira sin coger el candado: si el catálogo ya está (el lector que llega más tarde), se devuelve sin esperar a nadie. Si no está, se coge el candado, y dentro se mira otra vez: mientras esperabas en la puerta, quien iba delante pudo haberlo traído. Por eso el catalogo ?: aparece dos veces. Es lo que hace searchIndex en la Pokédex, con otros nombres.

(?: es «si es null, usa esto» (Nulabilidad) y also guarda el valor sin interrumpir la cadena (Funciones de alcance).)

Los ficheros de hoy

shared
src/commonMain/kotlin/com/sebastiannarvaez/pokedex/domain/PokemonRef.ktse crea (paso 1)
src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/PokemonRepository.ktse modifica: el índice y un Mutex (paso 2)
src/commonTest/kotlin/com/sebastiannarvaez/pokedex/dobles/Dobles.ktse modifica (paso 3)
src/commonTest/kotlin/com/sebastiannarvaez/pokedex/feature/search/PokemonSearchTest.ktse crea (paso 3)
Lo que se toca en el módulo compartido hoy.

Paso 1 · El modelo mínimo: PokemonRef

El archivo. PokemonRef.kt es la ficha mínima de un Pokémon para buscarlo: un número y un nombre, nada más. Es como la fila de un índice de libro: te dice dónde está, no cuenta el libro. Créalo (clic derecho sobre la carpeta shared → New → File, y la ruta completa desde src/, como en El módulo compartido y la prueba de humo):

src/commonMain/kotlin/com/sebastiannarvaez/pokedex/domain/PokemonRef.kt

No es un Pokemon porque no tiene tipos, y fingir que los tiene obligaría a pedir el detalle de los mil trescientos solo para poder escribir en un campo de texto. Tampoco es el PokemonRefDto que ya tienes en data: ese es lo que manda PokeAPI en el listado (un nombre y una dirección en texto); PokemonRef, en domain, es lo que usa la app, con el número ya sacado. Escríbelo:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/domain/PokemonRef.kt
package com.sebastiannarvaez.pokedex.domain
 
/**
 * Lo minimo para buscar: un numero y un nombre.
 *
 * No es un `Pokemon` porque no tiene tipos, y fingir que los tiene obligaria a
 * pedir el detalle de los mil trescientos solo para poder escribir en un campo
 * de texto. La ilustracion si sale gratis: se construye del identificador.
 */
data class PokemonRef(val id: Int, val name: String) {
    val artworkUrl: String
        get() = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/$id.png"
}

artworkUrl no es un campo que se guarde: es un cálculo (get() = …) que arma la dirección de la ilustración oficial cada vez que se lee, a partir del id. Las ilustraciones las sirve el propio proyecto PokeAPI desde un servidor de archivos. Las pantallas de las lecciones siguientes la usarán para dibujar cada resultado.

Paso 2 · El repositorio guarda el índice, pedido una sola vez

El archivo. Abre PokemonRepository.kt (Navigate → File…, Cmd+Shift+O). Ya existe; hoy gana dos campos, una función y una constante.

Primero, tres import nuevos arriba del fichero (junto a los que ya hay):

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/PokemonRepository.kt
import com.sebastiannarvaez.pokedex.domain.PokemonRef
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

Lo que sigue va dentro de la clase PokemonRepository, en tres sitios. Primero, los dos campos, justo debajo de su llave de apertura:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/PokemonRepository.kt
    private val candado = Mutex()
    private var indice: List<PokemonRef>? = null

Segundo, la función searchIndex, debajo de page (o donde prefieras dentro de la clase):

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/PokemonRepository.kt
    /**
     * El indice de nombres, pedido una sola vez y guardado en memoria.
     *
     * **PokeAPI no tiene busqueda.** No hay `?q=pika`: `/pokemon/{nombre}` solo
     * acierta con el nombre exacto. La unica forma es traerse la lista entera
     * de nombres —son unos mil trescientos, apenas 100 KB— y filtrar aqui.
     *
     * El `Mutex` evita que dos llamadas simultaneas pidan el indice dos veces:
     * el repositorio es uno para toda la app.
     */
    suspend fun searchIndex(): List<PokemonRef> = withContext(dispatchers.io) {
        indice ?: candado.withLock {
            indice ?: api.page(limit = INDICE_COMPLETO, offset = 0).results
                .mapNotNull { ref -> ref.id?.let { PokemonRef(id = it, name = ref.name) } }
                .also { indice = it }
        }
    }

El comentario de arriba dice «apenas 100 KB» porque redondea; la cifra medida es la de antes, unos 93 KB.

Y tercero, la constante, en un companion object nuevo al final de la clase, antes de su última llave:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/PokemonRepository.kt
    private companion object {
        /** PokeAPI declara 1351 hoy; se pide de sobra y se acabo. */
        const val INDICE_COMPLETO = 2000
    }

Es el conCandado de la biblioteca: el indice ?: de fuera mira sin candado, y el de dentro vuelve a mirar por si otra llamada lo rellenó mientras esperaba.

Una línea de la cadena merece su explicación: .mapNotNull { ref -> ref.id?.let { PokemonRef(id = it, name = ref.name) } }. De cada elemento que manda PokeAPI se saca el número (ref.id, que puede ser null si la dirección no lo trae) y, solo si existe, se construye el PokemonRef; los demás se descartan.

Paso 3 · Un test que cuenta las llamadas

El archivo. PokemonSearchTest.kt, en commonTest. Para contar cuántas veces se pide el índice, el doble de la API necesita un contador. Ese doble es FakePokeApi, y vive en Dobles.kt (carpeta commonTest, paquete dobles), el fichero que creó Probar el puente en iOS. Ábrelo (Cmd+Shift+O, escribe Dobles) y, dentro de la clase FakePokeApi, justo debajo del contador llamadasAlDetalle que ya tiene, añade otro igual:

shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/dobles/Dobles.kt
    var llamadasAlListado = 0
        private set

Y en esa misma clase, la función page (la que simula el listado) ya existe y empieza por override suspend fun page(limit: Int, offset: Int): PokemonPageDto {. No la toques: solo añade esta línea como primera línea de su cuerpo, justo debajo de esa llave, para que sume uno cada vez que se la llama:

shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/dobles/Dobles.kt
        llamadasAlListado++

Crea PokemonSearchTest.kt (clic derecho sobre shared → New → File, ruta src/commonTest/kotlin/com/sebastiannarvaez/pokedex/feature/search/PokemonSearchTest.kt) y escribe la cabecera con el primer test (los otros tres llegan en la lección siguiente).

Este primer test no crea ningún ViewModel y no necesita setMain; lo dejamos ya puesto porque lo necesitarán los tests de la lección siguiente, que sí lo crean, y así no hay que volver a tocar la cabecera.

shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/feature/search/PokemonSearchTest.kt
package com.sebastiannarvaez.pokedex.feature.search
 
import com.sebastiannarvaez.pokedex.data.PokemonRepository
import com.sebastiannarvaez.pokedex.dobles.FakePokeApi
import com.sebastiannarvaez.pokedex.dobles.TestDispatchers
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
 
@OptIn(ExperimentalCoroutinesApi::class)
class PokemonSearchTest {
 
    @BeforeTest
    fun instalarElHiloPrincipal() = Dispatchers.setMain(StandardTestDispatcher())
 
    @AfterTest
    fun devolverElHiloPrincipal() = Dispatchers.resetMain()
 
    @Test
    fun elIndiceSePideUnaSolaVezAunqueSeBusqueVariasVeces() = runTest {
        val api = FakePokeApi(total = 30)
        val repo = PokemonRepository(api, TestDispatchers(StandardTestDispatcher(testScheduler)))
 
        repo.searchIndex()
        repo.searchIndex()
        repo.searchIndex()
 
        // Una sola peticion: el indice se guarda en memoria. Si esto falla,
        // cada letra escrita se trae mil trescientos nombres otra vez.
        assertEquals(1, api.llamadasAlListado)
    }
}

TestDispatchers(StandardTestDispatcher(testScheduler)) hace que los tres dispatchers del repositorio (el withContext(dispatchers.io) de searchIndex entre ellos) usen el reloj virtual de runTest, en vez de hilos de verdad.

Ese test vigila que el índice se guarde: tres llamadas una detrás de otra, una sola descarga. Si alguien quita el .also { indice = it }, o los dos indice ?:, cada búsqueda volvería a traerse los 1351 nombres y el test lo cantaría. (Quitar solo uno de los dos indice ?: no lo notaría: el otro sigue cubriendo las llamadas seguidas.) El candado no lo prueba: haría falta un doble lento y tres llamadas a la vez, como en el ejemplo de la biblioteca. Ejecútalo:

./gradlew :shared:jvmTest --tests "*PokemonSearchTest*"

Debe pasar (1 test).

Punto de control

  • ./gradlew :shared:jvmTest --tests "*PokemonSearchTest*" termina en BUILD SUCCESSFUL con 1 test.
  • ./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test termina en BUILD SUCCESSFUL con 38 tests en la JVM, 37 en Android host y 39 en iOS sin contar los de la carpeta ejercicios: uno más en cada una que al terminar el detalle (37 / 36 / 38), el de hoy. Si hiciste ejercicios en módulos anteriores, tu cifra será mayor.
  • Tres llamadas seguidas a searchIndex() dejan llamadasAlListado en 1.
  • PokemonRepository tiene searchIndex(), y PokemonRef.kt está en la carpeta domain.

En la siguiente lección, El ViewModel de la búsqueda, el índice se filtra: un ViewModel espera a que dejes de teclear, busca en él y publica el resultado, y ahí llegan los otros tres tests de esta clase.

Comprueba lo que entendiste

4 preguntas · necesitas 3 para completar la lección

  1. 1.¿Por qué la búsqueda se hace sobre un índice descargado y no pidiéndosela a PokeAPI?

  2. 2.En searchIndex(), indice ?: aparece dos veces, una fuera del Mutex y otra dentro. ¿Por qué la de dentro?

  3. 3.El test elIndiceSePideUnaSolaVezAunqueSeBusqueVariasVeces llama tres veces seguidas a searchIndex() y espera una sola descarga. ¿Qué fallo detecta y cuál no?

  4. 4.Dentro de un candado.withLock { … } se llama a otra función que hace withLock con el mismo Mutex. ¿Qué ocurre?

Faltan 4 de 4