Saltar al contenido
K
Favoritos con RoomPaso a paso33 min Intermedio

Favoritos en el núcleo

Al terminar: Guardar y observar favoritos con un Flow que la lista pueda combinar sin recargarse entera.

La base de datos ya está montada y probada, pero nadie la usa. Al terminar esta lección tendrás un repositorio que marca y desmarca favoritos y un ViewModel que los publica por dos flujos distintos hacia la pantalla: la decisión que da forma a todo el módulo. Quitar un favorito con un aviso para deshacerlo viene en la lección siguiente, Quitar con deshacer.

La decisión de los dos flujos merece contarse entera, porque es la diferencia entre una lista que se actualiza sin que lo notes y una que parpadea y te tira el scroll cada vez que tocas un corazón. Sigue sin haber pantallas: todo lo de aquí se comprueba con tests.

No se instala ninguna librería: Room, Turbine y las corrutinas ya están. Sí hay un ajuste del compilador nuevo, el del paso 1. Tu proyecto llega con la base y los cuatro tests del DAO de La base en cada plataforma: 45 tests en la JVM, 40 en Android host y 42 en iOS.

shared
build.gradle.ktsse modifica (paso 1)
src/commonMain/kotlin/.../domain/FavoritePokemon.ktse crea (paso 2)
src/commonMain/kotlin/.../data/FavoritesRepository.ktse crea (pasos 3 y 4)
src/commonMain/kotlin/.../feature/favorites/FavoritesViewModel.ktse crea (paso 5)
src/commonMain/kotlin/.../di/Modules.ktse modifica (paso 5)
src/jvmTest/kotlin/.../feature/favorites/FavoritesTest.ktse crea (paso 6)
Lo que se toca hoy en el módulo shared (rutas desde su carpeta).

Paso 1 · El aviso que arrastrabas

El ajuste. Desde que existe el expect object de Room, cada compilación repite un aviso ('expect'/'actual' classes … are in Beta). Se silencia con una opción del compilador: una bandera que se le pasa a Kotlin al compilar, solo para este módulo. Abre shared/build.gradle.kts y, dentro del bloque kotlin { … }, justo debajo de la llave de apertura (y por encima de la línea jvm() con su comentario), añade:

shared/build.gradle.kts
    // Room genera un `expect object` para construir la base de datos, y las
    // clases expect/actual siguen en Beta. El aviso lo pide el propio
    // compilador: sin la bandera, cada compilacion escupe la misma linea.
    compilerOptions {
        freeCompilerArgs.add("-Xexpect-actual-classes")
    }

Sync Now, y comprueba: ./gradlew :shared:compileKotlinJvm --rerun-tasks ya no debe imprimir esa línea. (--rerun-tasks obliga a Gradle a repetir el trabajo aunque nada haya cambiado; sin él daría por bueno el resultado anterior y no imprimiría nada, ni con aviso ni sin él. Sin la bandera, el aviso sale dos veces: una por el expect object y otra por su actual generado.)

Paso 2 · El dominio, con lo justo para pintar una fila

El archivo. FavoritePokemon.kt es un favorito en el idioma de la app, sin nada de Room dentro. Créalo en src/commonMain/kotlin/com/sebastiannarvaez/pokedex/domain/ y escríbelo; es corto a propósito:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/domain/FavoritePokemon.kt
package com.sebastiannarvaez.pokedex.domain
 
/**
 * Un favorito, ya en lenguaje de la app.
 *
 * Tiene lo justo para pintar una fila **sin red**: numero, nombre y la
 * ilustracion, que se construye del numero.
 */
data class FavoritePokemon(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 se guarda en la tabla: se calcula. Una URL almacenada envejece el día que cambie el servidor de imágenes, y entonces habría que migrar filas para arreglar algo que era una cuenta de una línea.

Paso 3 · El repositorio, y el reloj por parámetro

El archivo. FavoritesRepository.kt, en data. Traduce las filas de la base a objetos del dominio y es el único que habla con el DAO. El concepto: necesita saber la hora al marcar un favorito (para ordenar por reciente), y en vez de leer el reloj por dentro se lo pasan por parámetro: así un test puede fijar la hora y comprobar el orden sin esperar de verdad.

Créalo: clic derecho sobre la carpeta shared → New → File y la ruta src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/FavoritesRepository.kt. Cabecera y constructor (los import completos están aquí; si falta alguno, Android Studio lo propone con Option+Enter sobre el nombre en rojo):

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/FavoritesRepository.kt
package com.sebastiannarvaez.pokedex.data
 
import com.sebastiannarvaez.pokedex.core.AppDispatchers
import com.sebastiannarvaez.pokedex.data.local.FavoriteDao
import com.sebastiannarvaez.pokedex.data.local.FavoriteEntity
import com.sebastiannarvaez.pokedex.domain.FavoritePokemon
import kotlin.time.Clock
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
 
/**
 * Los favoritos, traducidos de fila a dominio.
 *
 * El reloj se inyecta en vez de llamar a `Clock.System` por dentro: asi un test
 * puede fijar la hora y comprobar el orden sin dormir.
 */
internal class FavoritesRepository(
    private val dao: FavoriteDao,
    private val dispatchers: AppDispatchers,
    // El reloj por defecto es el real; un test pasa el suyo.
    private val ahora: () -> Long = { Clock.System.now().toEpochMilliseconds() },
) {
}

Las funciones de los pasos siguientes van dentro de las llaves de la clase. Clock.System.now() es el instante actual, y toEpochMilliseconds() lo convierte en el número que guarda la columna addedAt: los milisegundos desde 1970 que viste en Persistencia con Room 3.

internal class significa que solo el módulo shared puede usar FavoritesRepository (las apps lo reciben ya montado dentro de un ViewModel). Es la misma decisión que con PokemonRepository, y de ella sale el internal constructor del ViewModel del paso 5.

Marcar y desmarcar es la misma función, porque en la pantalla es el mismo gesto. Dentro de la clase:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/FavoritesRepository.kt
    suspend fun toggle(id: Int, name: String): Boolean = withContext(dispatchers.io) {
        // Un bloque `{ … }` vale lo que valga su última línea, como el `if` de
        // «Condiciones: if y when»: aqui, `false` en la primera rama y `true` en la segunda.
        if (dao.isFavorite(id)) {
            dao.remove(id)
            false
        } else {
            dao.add(FavoriteEntity(pokemonId = id, name = name, addedAt = ahora()))
            true
        }
    }

El valor de withContext { … } es el de su última expresión, y esa es el if: si el favorito existía, se borra y el bloque vale false; si no, se añade y vale true. Por eso la función no necesita ningún return. Devuelve el estado nuevo, no el anterior: quien llama quiere saber cómo queda el corazón, y así no tiene que negar nada por su cuenta.

Paso 4 · Dos flujos, y aquí está la lección

El repositorio expone dos maneras de observar, una para cada pantalla. Añádelas a la clase:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/FavoritesRepository.kt
    fun observeFavorites(): Flow<List<FavoritePokemon>> =
        dao.observeAll().map { filas -> filas.map { FavoritePokemon(it.pokemonId, it.name) } }
 
    /**
     * Solo los identificadores, como conjunto.
     *
     * Es lo que la lista necesita para pintar el corazon, y va aparte a
     * proposito: combinarlo dentro del `PagingData` obligaria a reemitir la
     * pagina entera cada vez que se marca uno, con su parpadeo y su salto de
     * scroll. Un `Set` responde «¿esta este numero?» sin recorrerse entero.
     */
    fun observeFavoriteIds(): Flow<Set<Int>> = dao.observeIds().map { it.toSet() }

La tentación es evidente y hay que resistirla. Si cada Pokemon llevara dentro un esFavorito, la rejilla no tendría que consultar nada: pintaría lo que le llega. Para conseguirlo habría que combinar los favoritos dentro del PagingData, con un map sobre cada elemento.

La rejilla de Android hará esa consulta con p.id in idsFavoritos al pintar cada celda, en Favoritos en Android (in pregunta si un número está dentro del conjunto). Falta el último gesto del repositorio, vaciar todo (lo llamará el botón «Quitar todos»). Al final de la clase:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/FavoritesRepository.kt
    suspend fun clear() = withContext(dispatchers.io) { dao.clear() }

Paso 5 · El ViewModel publica los dos

El archivo. FavoritesViewModel.kt, en feature/favorites. Lleva dos cosas: el estado de la pantalla (FavoritesUiState) y el ViewModel que lo publica. Créalo: clic derecho sobre la carpeta shared → New → File y la ruta (si la carpeta favorites no existe, el diálogo la crea):

src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/favorites/FavoritesViewModel.kt

Primero la cabecera, con el paquete y todos los import (ViewModel y viewModelScope de androidx.lifecycle, los operadores de kotlinx.coroutines.flow y launch):

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/favorites/FavoritesViewModel.kt
package com.sebastiannarvaez.pokedex.feature.favorites
 
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sebastiannarvaez.pokedex.data.FavoritesRepository
import com.sebastiannarvaez.pokedex.domain.FavoritePokemon
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch

Debajo, el estado, que es un dato:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/favorites/FavoritesViewModel.kt
data class FavoritesUiState(
    val favoritos: List<FavoritePokemon> = emptyList(),
    val cargando: Boolean = true,
) {
    val vacia: Boolean get() = !cargando && favoritos.isEmpty()
}

vacia no es lo mismo que «la lista está vacía». Mientras cargando sea cierto todavía no se sabe, y enseñar «Todavía no hay favoritos» a alguien que sí los tiene, durante el instante que tarda la primera consulta, es de los parpadeos que más molestan. Y debajo del estado, el ViewModel, con sus dos flujos. Es internal constructor por lo mismo que en El ViewModel de la búsqueda: FavoritesRepository es internal.

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/favorites/FavoritesViewModel.kt
class FavoritesViewModel internal constructor(
    private val repository: FavoritesRepository,
) : ViewModel() {
 
    val uiState: StateFlow<FavoritesUiState> = repository.observeFavorites()
        .map { FavoritesUiState(favoritos = it, cargando = false) }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), FavoritesUiState())
 
    /** Los identificadores, para que la lista pinte el corazon sin recargarse. */
    val favoriteIds: StateFlow<Set<Int>> = repository.observeFavoriteIds()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptySet())
 
    fun toggle(id: Int, name: String) {
        viewModelScope.launch { repository.toggle(id, name) }
    }
 
    fun vaciar() {
        viewModelScope.launch { repository.clear() }
    }
}

stateIn con WhileSubscribed(5_000) es el mismo patrón de El ViewModel de la búsqueda. Los dos gestos toggle y vaciar lanzan una corrutina en viewModelScope y delegan en el repositorio; vaciar() es lo que llama el botón «Quitar todos» de las pantallas.

Koin lo registra. En Modules.kt, dos import (FavoritesRepository y FavoritesViewModel) y dos líneas: el repositorio junto a los demás single, y el ViewModel junto a los demás viewModelOf. El repositorio pasa solo dos get(), el DAO y los dispatchers: el tercer parámetro, el reloj, tiene valor por defecto y Koin no lo toca.

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.kt
    single { FavoritesRepository(get(), get()) }
shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.kt
    viewModelOf(::FavoritesViewModel)

Paso 6 · Los tests, que no necesitan dobles

El archivo. FavoritesTest.kt. Créalo: clic derecho sobre la carpeta shared → New → File y la ruta src/jvmTest/kotlin/com/sebastiannarvaez/pokedex/feature/favorites/FavoritesTest.kt. No hay un FakeFavoriteDao: la base va en memoria y es la de verdad (con el getDatabaseBuilder() de La base en cada plataforma), así que el test prueba también el SQL.

Dos piezas que no has visto en un repositorio con reloj:

  • TestDispatchers es un doble de AppDispatchers que ya existe en tu carpeta de tests (dobles, de Probar el puente en iOS): reparte el mismo dispatcher de pruebas a los tres de AppDispatchers (main, io y default).
  • testScheduler es el reloj virtual que runTest da a cada test. El dispatcher de pruebas tiene que usar ese mismo reloj (StandardTestDispatcher(scheduler)); si cada uno tuviera el suyo, el test esperaría de verdad. Por eso el ayudante repo(…) recibe el TestCoroutineScheduler (el tipo de ese reloj; se escribe con su ruta completa para no añadir otro import) y cada test se lo pasa.

Escríbelo entero. Cuatro tests, y cada uno cuenta una cosa:

  • marcarYDesmarcarDevuelveElEstadoNuevo: toggle devuelve el estado nuevo.
  • losIdentificadoresLleganComoConjunto: el segundo flujo emite un Set cada vez que cambia la tabla.
  • elRelojInyectadoOrdenaLaLista: el reloj por parámetro paga. Se marcan tres en un orden y salen en otro, sin esperar nada. Con el reloj dentro del repositorio, el test tendría que esperar de verdad entre marcado y marcado (delay no sirve: en runTest es tiempo virtual y el reloj real no se mueve), y seguiría siendo frágil.
  • elFavoritoSirveSinRed: deja escrita la promesa del paso 2, nombre e ilustración sin pedir nada.
shared/src/jvmTest/kotlin/com/sebastiannarvaez/pokedex/feature/favorites/FavoritesTest.kt
package com.sebastiannarvaez.pokedex.feature.favorites
 
import app.cash.turbine.test
import com.sebastiannarvaez.pokedex.data.FavoritesRepository
import com.sebastiannarvaez.pokedex.data.local.createDatabase
import com.sebastiannarvaez.pokedex.data.local.getDatabaseBuilder
import com.sebastiannarvaez.pokedex.dobles.TestDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
 
@OptIn(ExperimentalCoroutinesApi::class)
class FavoritesTest {
 
    private val db = createDatabase(getDatabaseBuilder())
    private var reloj = 1_000L
 
    // Se ejecuta después de cada test: cierra la base, como en el test del DAO.
    @AfterTest
    fun limpiar() = db.close()
 
    // El repositorio de cada test: la base de arriba, un dispatcher que comparte el
    // reloj virtual del test y, en la lambda final, el reloj de mentira. Kotlin deja
    // sacar la ultima lambda fuera de los parentesis: esto es lo mismo que
    // `FavoritesRepository(dao, dispatchers, ahora = { reloj })`, asi que `ahora()`
    // devuelve cada vez lo que valga `reloj` en ese momento.
    private fun repo(scheduler: kotlinx.coroutines.test.TestCoroutineScheduler) =
        FavoritesRepository(db.favoriteDao(), TestDispatchers(StandardTestDispatcher(scheduler))) { reloj }
 
    @Test
    fun marcarYDesmarcarDevuelveElEstadoNuevo() = runTest {
        val repo = repo(testScheduler)
 
        assertTrue(repo.toggle(25, "pikachu"), "marcar deberia devolver true")
        assertFalse(repo.toggle(25, "pikachu"), "desmarcar deberia devolver false")
    }
 
    @Test
    fun losIdentificadoresLleganComoConjunto() = runTest {
        val repo = repo(testScheduler)
 
        repo.observeFavoriteIds().test {
            assertEquals(emptySet(), awaitItem())
            repo.toggle(25, "pikachu")
            assertEquals(setOf(25), awaitItem())
            repo.toggle(1, "bulbasaur")
            assertEquals(setOf(25, 1), awaitItem())   // en un Set el orden no cuenta
            cancelAndIgnoreRemainingEvents()
        }
    }
 
    @Test
    fun elRelojInyectadoOrdenaLaLista() = runTest {
        val repo = repo(testScheduler)
 
        // El `;` separa dos ordenes en la misma linea: se fija la hora y se marca.
        reloj = 1_000; repo.toggle(1, "bulbasaur")
        reloj = 3_000; repo.toggle(25, "pikachu")
        reloj = 2_000; repo.toggle(4, "charmander")
 
        repo.observeFavorites().test {
            assertEquals(listOf("pikachu", "charmander", "bulbasaur"), awaitItem().map { it.name })
            cancelAndIgnoreRemainingEvents()
        }
    }
 
    @Test
    fun elFavoritoSirveSinRed() = runTest {
        val repo = repo(testScheduler)
        repo.toggle(25, "pikachu")
 
        repo.observeFavorites().test {
            // single() devuelve el unico elemento de la lista (y falla si hay 0 o varios).
            val favorito = awaitItem().single()
            // Nombre e ilustracion sin pedir nada: abrir favoritos en el metro
            // tiene que funcionar.
            assertEquals("pikachu", favorito.name)
            assertTrue(favorito.artworkUrl.endsWith("/25.png"))
            cancelAndIgnoreRemainingEvents()
        }
    }
}

Ejecútalos:

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

Los cuatro pasan. En total tu proyecto tiene ya 49 tests en la JVM (los 45 de antes y estos cuatro).

Punto de control

  • ./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test termina en BUILD SUCCESSFUL con 49 tests en la JVM, 40 en Android host y 42 en iOS. Los cuatro de FavoritesTest son de la JVM y suben la cifra de 45 a 49; si añadiste ejercicios con tests, la tuya será mayor.
  • ./gradlew :shared:compileKotlinJvm --rerun-tasks ya no imprime el aviso de 'expect'/'actual' classes ... are in Beta.
  • FavoritesRepository y FavoritesViewModel están en el grafo de Koin y el test de verify() de Koin: un solo grafo en commonMain sigue en verde.
  • observeFavoriteIds() devuelve un Set, no una lista: la pregunta «¿está este número?» de la rejilla se contesta en tiempo constante.
  • Las dos apps compilan y arrancan igual. Todavía no hay ningún corazón que tocar.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.¿Por qué los identificadores de favoritos viajan en un flujo aparte y no dentro del PagingData?

  2. 2.¿Por qué observeFavoriteIds() devuelve Set<Int> y no List<Int>?

  3. 3.¿Para qué entra el reloj como parámetro de FavoritesRepository con un valor por defecto?

  4. 4.En FavoritesUiState, ¿qué distingue vacia de «la lista está vacía»?

  5. 5.¿Qué silencia la bandera -Xexpect-actual-classes en freeCompilerArgs?

Faltan 5 de 5