Saltar al contenido
K
Detalle y navegaciónPaso a paso30 min Intermedio

El ViewModel del detalle

Al terminar: Poner el estado de la ficha en un ViewModel compartido, registrarlo en Koin con parámetro y probarlo.

Al terminar esta lección, la ficha de un Pokémon tendrá quien la cargue y la guarde: un ViewModel en commonMain que pide el detalle al repositorio, lo publica en un estado, sabe reintentar cuando falla la red, y está probado con cinco tests. Las dos pantallas, la de Android y la de iOS, solo tendrán que mirar ese estado.

Hay una pieza nueva de verdad: este ViewModel necesita un dato que no sale del grafo de Koin, el número del Pokémon que tocó el usuario. Lo verás resuelto con parametersOf, y verás qué no comprueba el test que protege el grafo. Al final, un test que se rompe a propósito para entender por qué los ViewModel necesitan su propio hilo principal en una prueba.

El concepto · Un dato que el grafo no sabe

Hasta ahora, todo lo que un ViewModel necesitaba lo tenía Koin: el repositorio, la configuración, los dispatchers. Koin sabe cómo fabricar cada uno (cada receta del grafo) y lo entrega cuando alguien lo pide. Pero el ViewModel de la ficha necesita además qué Pokémon abrir, y eso no está en ningún sitio del grafo: lo decide el usuario al tocar una tarjeta, y cambia cada vez.

En Koin, el dato extra viaja en un paquete que se llama parametersOf. Quien pide el objeto lo rellena, y la receta del grafo lo recoge al principio de su lambda. Un ejemplo con datos ajenos a la Pokédex, una biblioteca:

ejemplo mínimo: un parámetro que no sale del grafo (con comentarios)
import org.koin.core.parameter.parametersOf
import org.koin.dsl.koinApplication
import org.koin.dsl.module
 
class Catalogo { fun titulo(id: Int) = "Libro n.º $id" }                // esto sí lo sabe el grafo
class Ficha(private val catalogo: Catalogo, private val id: Int) {      // id lo pone quien la pide
    fun resumen() = catalogo.titulo(id)
}
 
fun ejemplo() {
    val biblioteca = module {
        single { Catalogo() }                            // una receta sin parámetros
        factory { (id: Int) -> Ficha(get(), id) }        // (id: Int) -> recoge lo que llegue en parametersOf
    }
 
    val koin = koinApplication { modules(biblioteca) }.koin   // arranca un grafo suelto con esa receta y se queda con su `koin`, el objeto que sirve las peticiones
    val ficha7: Ficha = koin.get { parametersOf(7) }          // `get` = «dame una»; el tipo sale de la variable (Ficha); y las llaves llevan el paquete con el 7
    val ficha9: Ficha = koin.get { parametersOf(9) }
    println(ficha7.resumen())
    println(ficha9.resumen())
    println("mismo catálogo: " + (koin.get<Catalogo>() === koin.get<Catalogo>()))   // sin parámetros: se indica el tipo entre <>; === compara si son el mismo objeto
}

Este ejemplo es solo para leer: necesita Koin y no corre en el Kotlin Playground (no hace falta que lo ejecutes). Esta es la salida de ejemplo():

Libro n.º 7
Libro n.º 9
mismo catálogo: true

Tres cosas que ver. Una, (id: Int) -> es la forma de decir «a esta receta le llega un número»: lo que el que pide metió en parametersOf(7). Dos, get() sigue buscando en el grafo lo que sí sabe, el Catalogo, y las dos fichas comparten el mismo, porque es un single. Tres, factory fabrica una Ficha nueva en cada petición, porque cada una lleva su número. Es la @Assisted de Hilt escrita en una línea: el grafo pone el Catalogo, y quien pide pone el número.

Paso 1 · El estado y el ViewModel

shared
src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/detail/PokemonDetailUiState.ktse crea
src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/detail/PokemonDetailViewModel.ktse crea
Dos ficheros nuevos, en un paquete nuevo, dentro del módulo compartido.

El archivo. PokemonDetailUiState.kt es todo lo que la ficha necesita para pintarse, en un objeto, como el estado de la lista. Créalo con la ruta src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/detail/PokemonDetailUiState.kt (clic derecho sobre la carpeta shared → New → File):

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/detail/PokemonDetailUiState.kt
package com.sebastiannarvaez.pokedex.feature.detail
 
import com.sebastiannarvaez.pokedex.core.UiError
import com.sebastiannarvaez.pokedex.domain.PokemonDetail
 
/**
 * Todo lo que la ficha necesita, en un objeto.
 *
 * Una bandera y dos datos en vez de una jerarquia sellada: cruza a Swift, y
 * alli una sellada pierde la exhaustividad.
 */
data class PokemonDetailUiState(
    val cargando: Boolean = true,
    val detalle: PokemonDetail? = null,
    val error: UiError? = null,
)

Es la misma regla de El ViewModel compartido: data class para el estado que cruza a Swift.

El ViewModel. Junto al anterior, PokemonDetailViewModel.kt:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/detail/PokemonDetailViewModel.kt
package com.sebastiannarvaez.pokedex.feature.detail
 
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 kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
 
class PokemonDetailViewModel internal constructor(
    private val repository: PokemonRepository,   // viene del grafo de Koin
    private val pokemonId: Int,                  // este NO: lo trae la navegación
) : ViewModel() {
 
    private val _uiState = MutableStateFlow(PokemonDetailUiState())          // el estado, que solo cambia aquí dentro
    val uiState: StateFlow<PokemonDetailUiState> = _uiState.asStateFlow()    // y la copia de solo lectura que ven las pantallas
 
    init {
        cargar()   // la ficha empieza a cargarse al crear el ViewModel
    }
 
    fun reintentar() = cargar()   // el botón de reintentar vuelve a pedirla
 
    private fun cargar() {
        _uiState.value = PokemonDetailUiState(cargando = true)   // vuelve a «cargando» (y borra el error anterior)
        viewModelScope.launch {
            _uiState.value = try {
                PokemonDetailUiState(cargando = false, detalle = repository.detail(pokemonId))
            } catch (e: Throwable) {
                // aAppError relanza la cancelación, así que este catch no se traga
                // el cierre de la pantalla: solo atrapa fallos de verdad.
                PokemonDetailUiState(cargando = false, error = e.aAppError().aUiError())
            }
        }
    }
}

Ese catch (e: Throwable) suele ser mala idea porque se traga la CancellationException; aquí no, porque aAppError() relanza la cancelación antes de traducir nada, tal y como se escribió en Errores que se pueden contar.

Paso 2 · Decirle a Koin que este ViewModel lleva un parámetro

El archivo. Modules.kt ya existe, en src/commonMain/kotlin/com/sebastiannarvaez/pokedex/di/. Ábrelo (Navigate → File…, Cmd+Shift+O). Un import arriba junto a los de Koin y otro para el ViewModel:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.kt
import com.sebastiannarvaez.pokedex.feature.detail.PokemonDetailViewModel
import org.koin.core.module.dsl.viewModel

Y debajo de los dos viewModelOf, el registro:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.kt
    // Con parametro: el identificador no lo sabe el grafo, lo trae la
    // navegacion. `viewModel { }` y no `viewModelOf`, que solo sirve cuando
    // todas las dependencias salen del grafo.
    viewModel { (id: Int) -> PokemonDetailViewModel(get(), id) }

(id: Int) -> recoge el parámetro con el que se pida el ViewModel, igual que en el ejemplo de la biblioteca: quien lo pide escribe parametersOf(25), que empaqueta el 25 para entregárselo a esa lambda (lo verás en Navegación en Android con Navigation 3 y en Navegación en iOS con NavigationStack). Y get() busca en el grafo el PokemonRepository.

Qué hace verify() con esta línea. El test del grafo de Koin: un solo grafo en commonMain (ModulesTest) sigue pasando sin que le añadas nada. Pero conviene saber por qué, y lo que no protege: verify() revisa el constructor de las piezas del grafo que lo tienen público, y salta los internal, como ya viste en Paginación con Paging 3. El de este ViewModel es internal constructor, así que verify() no lo mira. Si le añadieras un parámetro que el grafo no sabe construir, el test seguiría en verde y el fallo aparecería al pedir el ViewModel, es decir, al abrir la ficha. Esta pieza la protege el compilador y la prueba de la pantalla, no verify().

Comprueba que todo el código común compila:

./gradlew :shared:compileKotlinJvm

Debe terminar en BUILD SUCCESSFUL. Si queda algo en rojo, lo más probable es un import que falta en Modules.kt: Option+Enter sobre el nombre en rojo lo añade.

Paso 3 · Los tests del ViewModel, y el que hay que romper

El fichero de tests. PokemonDetailTest.kt ya existe, con tres tests. Ahora se completa con dos más que comprueban el ViewModel (publica la ficha y deja de cargar; si falla la red, el estado trae un UiError con sePuedeReintentar) y con el andamio que necesitan. Son del mismo tipo que los de Probar la paginación, así que no se escriben a mano: abre el fichero, pulsa Copiar fichero (aquí debajo), selecciona todo (Cmd+A) y pega (Cmd+V). Sustituye el contenido: los tres tests de antes vienen dentro, junto a los dos nuevos. El gesto es el de Clases y objetos.

PokemonDetailTest.kt

Los dos tests nuevos son elViewModelPublicaLaFichaYLuegoDejaDeCargar y siFallaLaRedElEstadoTraeUnErrorConReintento: son los que crean el ViewModel. Lee el primero:

el mismo test, con comentarios
    @Test
    fun elViewModelPublicaLaFichaYLuegoDejaDeCargar() = runTest {
        // Se crea a mano, con el repositorio de prueba y el número 1. Al crearse,
        // su `init` lanza la carga, pero la corrutina aún no ha corrido.
        val vm = PokemonDetailViewModel(repo(scheduler = testScheduler), pokemonId = 1)
 
        assertTrue(vm.uiState.value.cargando)   // recién creado: sigue cargando
 
        testScheduler.advanceUntilIdle()        // «ejecuta ya todo lo pendiente»: la ficha llega
 
        val estado = vm.uiState.value
        assertFalse(estado.cargando)            // ya no carga (la aserción que más adelante verás fallar)
        assertEquals("pokemon-1", estado.detalle?.name)   // y trae la ficha
        assertEquals(null, estado.error)        // sin error
    }

El fichero que pegaste lleva el mismo código con menos comentarios. El segundo test es igual, pero con falla = true: espera al final y comprueba que no hay ficha y que el error se puede reintentar.

Ejecuta los tests de la ficha:

./gradlew :shared:jvmTest --tests '*PokemonDetailTest'

Debe terminar en BUILD SUCCESSFUL, con los cinco tests.

Provócalo: quita el hilo principal del test

Hay una pieza del fichero que conviene romper a propósito para entenderla: el andamio del principio de la clase.

shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/feature/detail/PokemonDetailTest.kt
    @BeforeTest
    fun instalarElHiloPrincipal() {
        Dispatchers.setMain(StandardTestDispatcher())
    }
 
    @AfterTest
    fun devolverElHiloPrincipal() {
        Dispatchers.resetMain()
    }

Comenta la línea Dispatchers.setMain(StandardTestDispatcher()) (con //) y vuelve a ejecutar la misma orden. Fallan dos de los cinco tests, los dos que crean el ViewModel, con:

java.lang.AssertionError: Expected value to be false.

La aserción que falla es assertFalse(estado.cargando).

Quita el // y comprueba que vuelve a pasar.

Punto de control

  • ./gradlew :shared:jvmTest --tests '*PokemonDetailTest' termina en BUILD SUCCESSFUL con los cinco tests.
  • ./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test termina en BUILD SUCCESSFUL con 32 tests en la JVM, 31 en Android host y 33 en iOS sin contar los de la carpeta ejercicios: 5 más que al terminar la lista. Si hiciste ejercicios en módulos anteriores, tu cifra será mayor.
  • Con el setMain comentado fallan dos de los cinco con Expected value to be false.; con él puesto, ninguno.
  • ModulesTest sigue en verde con la línea viewModel { (id: Int) -> … }.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.El test del ViewModel fallaba en la JVM con «Expected value to be false» al quitar Dispatchers.setMain. ¿Cuál era la causa?

  2. 2.En viewModel { (id: Int) -> PokemonDetailViewModel(get(), id) }, ¿de dónde sale el valor de id?

  3. 3.Añades un tercer parámetro al constructor de PokemonDetailViewModel que el grafo de Koin no sabe construir. ¿Qué hace ModulesTest, que usa verify()?

  4. 4.PokemonDetailUiState es una data class con cargando, detalle y error y no una jerarquía sellada (Cargando, Listo, Fallo). ¿Por qué?

  5. 5.El catch (e: Throwable) de cargar() suele ser mala idea. ¿Por qué aquí no se traga la cancelación?

Faltan 5 de 5