El ViewModel de la búsqueda
Al terminar: Filtrar el índice con debounce y flatMapLatest, publicar el estado y probarlo con tiempo virtual.
Al terminar esta lección, escribir «char» devolverá Charmander, Charmeleon y Charizard en ese orden, sin lanzar una petición por cada letra y sin que una respuesta lenta pise a otra más reciente. Todo en commonMain, probado con tiempo virtual.
Es la segunda de dos lecciones: ya tienes el índice de nombres, y hoy se le pone encima el ViewModel que lo filtra mientras se teclea. Las rutas son otra vez las de la vista Project.
El concepto · Esperar distinto según lo que se escribe
Mientras alguien teclea no hay que buscar por cada letra. Eso ya lo resolviste en Marcador con debounce: espera a que pase un rato sin valores nuevos y solo entonces deja pasar el último.
La primera novedad es que la espera no tiene por qué ser la misma para todos los textos. Si el usuario borra el campo entero, no hay nada que esperar: la pantalla debe volver a su estado de reposo al instante. debounce admite una lambda en vez de un tiempo fijo: recibe cada valor y devuelve cuánto esperar para ese valor concreto.
El ejemplo, con un buscador de recetas y el reloj virtual de runTest, que deja ver el instante de cada salida (currentTime, en milisegundos) sin esperar de verdad. Es un ejemplo para probar fuera de tu proyecto, por ejemplo en el Kotlin Playground (que suele traer la librería de tests, como viste en Tests de corrutinas: tiempo virtual), o simplemente para leer: la salida de abajo es la que da de verdad.
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.currentTime
import kotlinx.coroutines.test.runTest
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) // debounce y currentTime aún no son estables
fun main() = runTest { // reloj virtual: currentTime en ms, sin esperar de verdad
val campo = flow { // lo que va escribiendo alguien en un buscador de recetas
emit("so")
delay(100)
emit("sop")
delay(100)
emit("sopa")
delay(500) // se para a pensar
emit("") // borra el campo de golpe
delay(100)
emit("pan")
delay(500)
}
campo
.debounce { texto -> // la lambda recibe cada texto y decide cuánto esperar
if (texto.isBlank()) 0.milliseconds // vacío o solo espacios: sin espera
else 300.milliseconds // con texto: 300 ms de calma
}
.collect { println("ms ${currentTime}: busca [$it]") }
}ms 500: busca [sopa]
ms 700: busca []
ms 1100: busca [pan]«so» y «sop» no salen: cada una va seguida de otro texto antes de que pasen 300 ms. «sopa» llega a los 200 ms y sale a los 500, tras 300 de calma. El campo vacío sale en el mismo milisegundo en que llega (700), porque para él la espera es 0. Y «pan» llega a los 800 y sale a los 1100.
La segunda novedad llega en el ViewModel: el texto que se ve en el campo no puede esperar esos 300 ms, así que se publica aparte de los resultados.
Los ficheros de hoy
Paso 1 · El estado, con dos preguntas ya resueltas
El archivo. SearchUiState.kt, en feature/search (créalo como PokemonRef.kt en la lección anterior: clic derecho sobre shared → New → File, con la ruta completa desde src/). Es todo lo que la pantalla necesita saber: qué se ha escrito, qué se ha encontrado, si se está buscando y si hubo error.
package com.sebastiannarvaez.pokedex.feature.search
import com.sebastiannarvaez.pokedex.core.UiError
import com.sebastiannarvaez.pokedex.domain.PokemonRef
data class SearchUiState(
val consulta: String = "",
val resultados: List<PokemonRef> = emptyList(),
val buscando: Boolean = false,
val error: UiError? = null,
) {
/** Sin escribir nada todavia: la pantalla sugiere, no dice «sin resultados». */
val enReposo: Boolean get() = consulta.isBlank()
val sinResultados: Boolean get() = !enReposo && !buscando && error == null && resultados.isEmpty()
}enReposo y sinResultados son cálculos, no campos. Y son cálculos compartidos a propósito: si Android y iOS decidieran cada una por su cuenta cuándo enseñar «no hay nada», una de las dos acabaría enseñándolo con el campo vacío. (UiError ya existe desde Errores que se pueden contar.)
Paso 2 · La cadena, en cinco pasos, y Koin
El archivo. PokemonSearchViewModel.kt, en la misma carpeta. Guarda lo que se escribe y lo convierte en resultados. Es el buscador que montaste en Marcador (flatMapLatest y debounce), con las dos novedades de arriba: la espera depende del texto y el texto se publica aparte.
Un flujo se transforma pasándolo por operadores, uno detrás de otro, y cada uno recibe lo que sale del anterior. A esa fila de operadores la llamaremos la cadena. La de hoy tiene cinco pasos, cada uno con su trabajo: esperar a que se deje de teclear (debounce), quitar los espacios de los extremos (map { it.trim() }), ignorar lo repetido (distinctUntilChanged), cancelar la búsqueda vieja cuando llega una nueva (flatMapLatest) y dejarlo todo en un estado que la pantalla lea (stateIn).
Empieza por los import (Android Studio también los propone con Option+Enter sobre cada nombre en rojo):
package com.sebastiannarvaez.pokedex.feature.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sebastiannarvaez.pokedex.core.aAppError
import com.sebastiannarvaez.pokedex.core.aUiError
import com.sebastiannarvaez.pokedex.data.PokemonRepository
import com.sebastiannarvaez.pokedex.domain.PokemonRef
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateInY la primera mitad de la clase. Cuatro cosas antes de leerla:
Y un operador que el comentario del código nombra sin usarlo, para compararlo: flatMapMerge. Es el hermano de flatMapLatest que no cancela: lanza cada búsqueda nueva sin tocar las anteriores, y todas emiten a la vez.
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
class PokemonSearchViewModel internal constructor(
private val repository: PokemonRepository,
) : ViewModel() {
private val consulta = MutableStateFlow("")
fun escribir(texto: String) {
consulta.value = texto
}
fun limpiar() {
consulta.value = ""
}
/**
* La cadena clasica de una busqueda, en cuatro operadores.
*
* - `debounce` espera a que el usuario deje de teclear. Sin el, escribir
* «pikachu» lanza siete busquedas y las seis primeras sobran.
* - `distinctUntilChanged` ignora lo que, sin los espacios de los extremos,
* es igual a lo anterior: «pika» y «pika » son la misma busqueda.
* - `flatMapLatest` **cancela la busqueda anterior** al llegar una nueva.
* Con `flatMapMerge`, una respuesta lenta podria pisar a una posterior y
* la pantalla acabaria mostrando resultados de una consulta vieja.
* - `stateIn` lo convierte en un `StateFlow`, que siempre tiene un valor
* actual. Este es privado: lo lee el `combine` de abajo.
*/
private val resultados: StateFlow<SearchUiState> = consulta
.debounce { if (it.isBlank()) 0.milliseconds else ESPERA }
.map { it.trim() }
.distinctUntilChanged()
.flatMapLatest { texto -> buscar(texto) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SearchUiState())
/**
* El texto se publica al instante; los resultados van a su ritmo.
* Este `stateIn` es el publico: el `uiState` que lee la pantalla.
*/
val uiState: StateFlow<SearchUiState> = combine(consulta, resultados) { texto, estado ->
estado.copy(consulta = texto, buscando = estado.buscando || texto.trim() != estado.consulta.trim())
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SearchUiState())Cuatro cosas merecen su explicación:
- Cinco pasos, no cuatro. El comentario del código cuenta cuatro operadores y junta dos tareas en el punto de
distinctUntilChanged. Lo que hace cada uno:map { it.trim() }transforma cada texto que pasa y le quita los espacios del principio y del final («pika » se queda en «pika»); va antes dedistinctUntilChanged, que solo deja pasar un texto si es distinto del anterior. Juntos hacen que «pika» y «pika » cuenten como una sola búsqueda. debounce { if (it.isBlank()) 0.milliseconds else ESPERA }es el ejemplo de la sopa: con el campo vacío, 0 ms (borrar se nota al instante); con texto, 300 ms (ESPERA, que se define al final de la clase).- Hay dos
stateIny cada uno tiene su trabajo. La cadena es un flujo «frío»: no hace nada hasta que alguien lo recoge, y no tiene un valor guardado. El primerstateInla convierte en elStateFlowprivadoresultados, que arranca con unSearchUiState()vacío: así elcombinetiene desde el primer instante unos resultados con los que juntar el texto. El segundo convierte elcombineen eluiStatepúblico, el que lee la pantalla y que siempre tiene un valor actual. Los dos solo trabajan mientras alguien observauiState. buscando = estado.buscando || texto.trim() != estado.consulta.trim()dice que se está buscando en dos casos: cuando los resultados mismos lo anuncian (estado.buscando), o cuando el texto que hay en el campo todavía no coincide con el texto al que pertenecen los resultados, que es justo el hueco de los 300 ms de espera. Así el indicador de carga aparece desde la primera letra, no 300 ms después.
Esa última fórmula con valores. texto es lo que hay en el campo en ese instante, y estado lo último que ha salido de la cadena:
| Momento | texto | estado.consulta | estado.buscando | buscando |
|---|---|---|---|---|
| Escribes «pi» (aún no se ha buscado nada) | «pi» | «» | false | true, porque «pi» no es «» |
| Pasan 300 ms y la cadena anuncia que empieza | «pi» | «pi» | true | true, lo anuncia el propio estado |
| Llegan los resultados | «pi» | «pi» | false | false: coinciden y ya no se busca |
| Escribes una letra más: «pik» | «pik» | «pi» | false | true, porque «pik» no es «pi» |
Esa última frase es la que decide entre flatMapLatest y flatMapMerge. Con flatMapMerge las búsquedas conviven y, si una tarda más que otra, su respuesta puede llegar la última y pisar a la buena: la pantalla acabaría enseñando resultados de la consulta vieja. Con flatMapLatest la vieja se cancela y ese orden es imposible. No es un problema de velocidad: subir el debounce solo lo haría menos probable, no imposible.
Aún no compila: falta buscar, la segunda mitad. Añádela debajo, dentro de la clase (el import de PokemonRef ya está en la lista de arriba). El orden del filtro es lo único no obvio: contains encuentra diez Pokémon con «char» dentro, pero no todos empiezan por ahí. Ordenados por número a secas, Chimchar (390) saldría delante de Charjabug (737) y Charcadet (935), que sí empiezan por «char». Por eso se ordena con un comparador que pone delante lo que empieza por lo escrito y desempata por número de Pokédex. Para Kotlin, al ordenar, false va antes que true (como 0 antes que 1); compareByDescending invierte ese orden, y así los true, los que empiezan por lo escrito, quedan delante.
private fun buscar(texto: String) = flow {
if (texto.isBlank()) {
emit(SearchUiState(consulta = texto))
return@flow
}
emit(SearchUiState(consulta = texto, buscando = true))
try {
val indice = repository.searchIndex()
val normalizado = texto.lowercase()
val encontrados = indice
.filter { it.name.contains(normalizado) }
// Lo que empieza por lo escrito, primero: buscar «char» debe
// dar Charmander antes que Chimchar.
.sortedWith(compareByDescending<PokemonRef> {
it.name.startsWith(normalizado)
}.thenBy { it.id })
.take(MAXIMO_RESULTADOS)
emit(SearchUiState(consulta = texto, resultados = encontrados))
} catch (e: Throwable) {
emit(SearchUiState(consulta = texto, error = e.aAppError().aUiError()))
}
}
private companion object {
val ESPERA = 300.milliseconds
/** Nadie recorre mil resultados: mas alla de esto, que afine. */
const val MAXIMO_RESULTADOS = 50
}
}Dos detalles del filtro. texto.lowercase() pasa lo escrito a minúsculas: los nombres del índice vienen todos en minúsculas, así que «Char» y «char» buscan lo mismo. Y el comentario del código habla de Charmander y Chimchar, que también cumplen; el caso que de verdad hace falta el comparador es el de Charjabug y Charcadet frente a Chimchar.
Dos constantes, una con const y otra sin él: const val solo vale para números, textos y poco más, valores que el compilador conoce al compilar. Una duración como 300.milliseconds es un objeto que se construye al ejecutar, así que ESPERA no puede ser const.
Con el campo vacío no se busca nada (ni siquiera se pide el índice), y el take(50) es la otra decisión: nadie recorre mil resultados. Sobre el índice real, «char» da:
charmander, charmeleon, charizard, charjabug, charcadet,
charizard-mega-x, charizard-mega-y, charizard-gmax, chimchar, pecharuntLos ocho que empiezan por «char» van delante, por número de Pokédex (las formas especiales, como las megaevoluciones, tienen números de 10 000 en adelante: por eso van detrás de Charcadet); al final, los dos que solo lo contienen. Chimchar tiene un número menor (390) que Charjabug (737) y Charcadet (935), y aun así va detrás: eso es el comparador. Es lo que espera quien teclea.
Un último paso: Koin. El grafo de Koin: un solo grafo en commonMain tiene que saber construir el ViewModel. Abre Modules.kt (Cmd+Shift+O). Primero el import, junto a los demás:
import com.sebastiannarvaez.pokedex.feature.search.PokemonSearchViewModelY la línea del ViewModel, dentro de pokedexModule, junto a los otros viewModelOf:
viewModelOf(::PokemonSearchViewModel)Compila: ./gradlew :shared:compileKotlinJvm debe terminar en BUILD SUCCESSFUL.
Paso 3 · Probarlo sin esperar 300 ms de verdad
runTest da tiempo virtual: advanceUntilIdle() salta el debounce sin que el test tarde (Tests de corrutinas: tiempo virtual). Pero recoger un flujo infinito dentro de un test tiene su truco: lo natural, un launch a secas, no funciona. Antes de ver el truco, puedes provocar el fallo tú.
Primero, un import en la cabecera de PokemonSearchTest.kt (junto a los demás): launch, que usa el test temporal y usarán también los tests de después.
import kotlinx.coroutines.launchAhora, provócalo. Añade este test temporal dentro de la clase PokemonSearchTest, debajo del primero, y este import junto a los demás:
import kotlin.time.Duration.Companion.seconds // TEMPORAL: bórralo cuando hayas visto el error
@Test
fun temporalLaunchSuelto() = runTest(timeout = 3.seconds) {
val repo = PokemonRepository(FakePokeApi(total = 30), TestDispatchers(StandardTestDispatcher(testScheduler)))
val vm = PokemonSearchViewModel(repo)
launch { vm.uiState.collect { } }
}runTest tiene un parámetro timeout: cuánto espera antes de rendirse. Por defecto es un minuto; con timeout = 3.seconds no tienes que esperarlo. Ejecuta solo ese test:
./gradlew :shared:jvmTest --tests "*PokemonSearchTest.temporalLaunchSuelto"Falla, y es lo que buscabas. Pero en la Terminal no verás el mensaje largo: Gradle solo escribe esto (con unas líneas en medio):
PokemonSearchTest[jvm] > temporalLaunchSuelto[jvm] FAILED
kotlinx.coroutines.test.UncompletedCoroutinesError at TestBuilders.kt:353
1 test completed, 1 failed
...
> There were failing tests. See the report at: file:///…/shared/build/reports/tests/jvmTest/index.html
BUILD FAILEDEl mensaje entero está en el informe de tests que enlaza la línea «See the report at»: copia esa dirección file:///…/index.html en el navegador, entra en PokemonSearchTest y en el test que falla lo verás así:
Bórralo ya: quita el test temporalLaunchSuelto y el import de seconds. Si lo dejas, la clase tendría un test que falla siempre.
Ahora los tests de verdad. Primero dos import más en la cabecera (junto a los demás): el dispatcher que arranca al instante y assertTrue, que da el test por fallido si lo que le pasas no es true.
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlin.test.assertTrueY estos tres tests dentro de la clase PokemonSearchTest, debajo del primero (antes de la última llave de la clase). En el primero aparece resultados.all { … }: es true si todos los elementos de la lista cumplen la condición, o sea, que ningún resultado se ha colado sin contener lo buscado.
@Test
fun buscarFiltraPorLoQueContieneElNombre() = runTest {
val repo = PokemonRepository(FakePokeApi(total = 30), TestDispatchers(StandardTestDispatcher(testScheduler)))
val vm = PokemonSearchViewModel(repo)
val estados = mutableListOf<SearchUiState>()
// `backgroundScope` y no una corrutina suelta: runTest lo cancela al
// acabar el test. Con `launch` a pelo, el test no termina nunca porque
// el flujo es infinito.
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
vm.uiState.collect { estados.add(it) }
}
vm.escribir("pokemon-1")
testScheduler.advanceUntilIdle()
val ultimo = estados.last()
// pokemon-1, pokemon-10..pokemon-19: once nombres contienen «pokemon-1»
assertTrue(ultimo.resultados.isNotEmpty(), "no encontro nada")
assertTrue(ultimo.resultados.all { it.name.contains("pokemon-1") })
}
@Test
fun loQueEmpiezaPorLoEscritoVaPrimero() = runTest {
val repo = PokemonRepository(FakePokeApi(total = 30), TestDispatchers(StandardTestDispatcher(testScheduler)))
val vm = PokemonSearchViewModel(repo)
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { vm.uiState.collect { } }
vm.escribir("pokemon-2")
testScheduler.advanceUntilIdle()
val primero = vm.uiState.value.resultados.first()
assertTrue(primero.name.startsWith("pokemon-2"), "el primero fue ${primero.name}")
}
@Test
fun conElCampoVacioNoSeBuscaNada() = runTest {
val api = FakePokeApi(total = 30)
val repo = PokemonRepository(api, TestDispatchers(StandardTestDispatcher(testScheduler)))
val vm = PokemonSearchViewModel(repo)
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { vm.uiState.collect { } }
vm.escribir(" ")
testScheduler.advanceUntilIdle()
assertTrue(vm.uiState.value.enReposo)
assertEquals(0, api.llamadasAlListado, "no deberia haber pedido el indice")
}Dos detalles de estos tests que parecen caprichos y no lo son:
- Varios planificadores, un solo reloj. El repositorio lleva
StandardTestDispatcher(testScheduler): no ejecuta nada por su cuenta, solo cuando el test deja avanzar el reloj, y por eso la espera deldebouncey la petición ocurren cuando tú dices, conadvanceUntilIdle(). El que recoge el flujo llevaUnconfinedTestDispatcher(testScheduler): arranca al instante, así que no se pierde ninguna emisión. Los dos comparten el mismotestScheduler, o sea el mismo reloj virtual. Hay además un tercero: elStandardTestDispatcher()quesetMainpuso en la cabecera de la clase, paraviewModelScope.runTestlo detecta y usa también su reloj, así que tampoco corre por su cuenta: avanza conadvanceUntilIdle()como los demás. - Dos formas de leer el resultado. El primer test guarda cada estado en la lista
estadosy mira el último (estados.last()), lo que enseña queuiStateemite una serie de estados. Los otros dos leenvm.uiState.value, el valor actual delStateFlow, que es más corto cuando solo importa el final. Aun así, elcollect { }vacío de esos dos hace falta: conWhileSubscribed, eluiStateno trabaja si nadie lo observa.
Una advertencia sobre loQueEmpiezaPorLoEscritoVaPrimero: no distingue si el comparador está o no. Los nombres del doble son pokemon-1, pokemon-2… y todos los que contienen «pokemon-2» (el 2 y del 20 al 29) también empiezan por «pokemon-2», así que con o sin comparador el primero cumple. Lo que sí fija es que buscar encuentra algo y que el primer resultado empieza por lo escrito. Para ver el orden «empieza por» de verdad está la lista de «char» de arriba, y lo verás en las pantallas de las dos lecciones siguientes.
Ejecuta los cuatro:
./gradlew :shared:jvmTest --tests "*PokemonSearchTest*"Punto de control
./gradlew :shared:jvmTest --tests "*PokemonSearchTest*"termina enBUILD SUCCESSFULcon los cuatro tests dePokemonSearchTest../gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Testtermina enBUILD SUCCESSFULcon 41 tests en la JVM, 40 en Android host y 42 en iOS sin contar los de la carpetaejercicios: 3 más que al terminar la lección anterior (38 / 37 / 39) y 4 más que al terminar el detalle. Si hiciste ejercicios en módulos anteriores, tu cifra será mayor.- Con el campo en blanco,
llamadasAlListadosigue en 0: no se pide el índice hasta que hay algo que buscar. - Con «pokemon-2» el test comprueba que el primer resultado empieza por lo escrito; el orden de Charjabug y Charcadet frente a Chimchar lo comprobarás con «char» en las pantallas.
PokemonSearchViewModellleva@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)y está registrado enModules.kt.- El test temporal
temporalLaunchSueltoya no está enPokemonSearchTest.kt.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.
flatMapMergelanza cada búsqueda nueva sin cancelar las anteriores. ¿Qué pasaría si la cadena de búsqueda lo usara en vez deflatMapLatest?2.El
uiStatefinal combinaconsultaconresultadosen vez de exponer solo el flujo condebounce. ¿Qué se consigue?3.En el test, recoger
vm.uiStatecon unlaunchsuelto dentro derunTesttermina enUncompletedCoroutinesError. ¿Cuál es la forma correcta y por qué?4.La cadena usa
debounce { if (it.isBlank()) 0.milliseconds else 300.milliseconds }. El usuario borra el campo de golpe. ¿Cuándo sale el texto vacío?5.Buscar «char» pone Charjabug (737) y Charcadet (935) delante de Chimchar (390), que tiene un número de Pokédex menor. ¿Qué lo consigue?