El repositorio y el ViewModel de los ajustes
Al terminar: Leer y guardar las preferencias desde un repositorio, exponerlas al ViewModel y probarlas con cinco tests.
Con el almacén instalado y su fichero ya en cada plataforma, falta lo que guarda y lee de verdad. Al terminar esta lección el módulo shared guardará las dos preferencias (el tema de la app y el orden de los favoritos), las expondrá como un flujo y tendrá cinco tests nuevos. Las pantallas llegan en las lecciones siguientes.
Esta lección añade una idea más: que un fallo de lectura se oiga en vez de esconderse.
Paso 1 · El modelo: Tema y Settings
El archivo. Settings.kt guarda lo que la app recuerda del usuario. Créalo en shared: clic derecho sobre la carpeta shared → New → File y escribe la ruta desde la carpeta del módulo (como en Favoritos en el núcleo):
src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/settings/Settings.kt(Si el menú no te lo ofrece, en la Terminal, desde la carpeta de tu proyecto: primero mkdir -p shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/settings, que crea la carpeta, y después touch shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/settings/Settings.kt, que crea el fichero vacío.) En el panel (vista Project) lo verás en shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/settings/.
El concepto. El tema tiene tres estados, no dos: claro, oscuro y «lo que diga el sistema». Por eso es un enum y no un Boolean de «modo oscuro»: con un booleano el tercer estado acaba siendo un segundo booleano que nadie recuerda por qué existe.
package com.sebastiannarvaez.pokedex.feature.settings
// Los tres temas posibles.
enum class Tema { SISTEMA, CLARO, OSCURO }
// Todas las preferencias de la app juntas, cada una con su valor por defecto.
data class Settings(
val tema: Tema = Tema.SISTEMA, // no decidir por el usuario: sigue al sistema
val favoritosPorNumero: Boolean = false, // false = lo más reciente primero
)El valor por defecto es SISTEMA a propósito: elegir claro u oscuro por alguien que no lo ha pedido es una decisión, y la menos invasiva es no decidir.
Paso 2 · El repositorio: leer observando, escribir con edit
El archivo. SettingsRepository.kt es la única clase que toca el almacén. Créalo en shared con la ruta
src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/SettingsRepository.ktEl concepto. DataStore no es SharedPreferences con otro nombre: no hay una lectura síncrona que te devuelva el valor al instante. Se lee siempre con un Flow: cada cambio llega como una emisión, y la pantalla observa su configuración en vez de consultarla (se puede pedir el valor una sola vez con first(), como en el ejemplo de la lección anterior, pero una pantalla necesita enterarse de los cambios). Y escribir es una operación suspend.
Falta una pieza que el repositorio necesita y que ninguna lección anterior ha enseñado: el operador catch de un Flow. Se parece al try/catch que ya conoces (Excepciones: try, catch y require), pero vive dentro de la cadena del flujo: atrapa los errores que ocurren antes de él y puede emitir un valor de reemplazo con emit. Lo que va después (un map, el collect) no lo protege.
Un ejemplo ajeno, un sensor de temperatura que da una lectura y luego falla. El catch solo atrapa el error de lectura (IOException, el error de leer o escribir ficheros o dispositivos); cualquier otro lo vuelve a lanzar:
import java.io.IOException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.runBlocking
// Un sensor de temperatura que da una lectura y luego falla con el error que le pasemos.
fun sensor(fallo: Exception): Flow<Int> = flow {
emit(20) // primera lectura
throw fallo // y se rompe
}
// Solo se atrapa el error de lectura (IOException); cualquier otro se vuelve a lanzar.
fun Flow<Int>.conRepuesto(): Flow<Int> = catch { e ->
if (e !is IOException) throw e
println("aviso: ${e.message}")
emit(-1) // valor de reemplazo
}
fun main() = runBlocking {
sensor(IOException("sensor desconectado")).conRepuesto().collect { println("lectura: $it") }
try {
sensor(IllegalStateException("error de programación")).conRepuesto().collect { println("lectura: $it") }
} catch (e: IllegalStateException) {
println("llega el error: ${e.message}")
}
}lectura: 20
aviso: sensor desconectado
lectura: -1
lectura: 20
llega el error: error de programación(fun Flow<Int>.conRepuesto() es una función de extensión: añade conRepuesto() a todos los Flow<Int>, como viste en Extensiones.) El primer sensor falla con un IOException: el catch lo atrapa, avisa y emite -1. El segundo falla con otro error: el catch lo deja pasar y el error llega a quien recoge el flujo. Eso es un catch estrecho, y es justo el que usa el repositorio.
Dos piezas más. emptyPreferences() son unas preferencias sin nada guardado: al leerlas, cada clave da null y manda el valor por defecto. Y una transacción es todo o nada: edit escribe todo lo del bloque junto o no escribe nada, así que si algo falla a mitad no queda nada a medias en el fichero.
Una última pieza antes del repositorio: lo que se guarda del tema es su nombre, no su posición. Un ejemplo ajeno lo enseña: unas tallas que alguien reordena en la versión siguiente de la app.
// Hoy la app tiene este orden de tallas...
enum class TallaAntes { PEQUENA, MEDIANA, GRANDE }
// ...y en la versión siguiente alguien las reordena.
enum class TallaDespues { GRANDE, MEDIANA, PEQUENA }
fun main() {
val elegida = TallaAntes.GRANDE
val guardadoPosicion = elegida.ordinal // 2: su posición en la lista
val guardadoNombre = elegida.name // "GRANDE": su nombre
println("Guardado: posición $guardadoPosicion, nombre $guardadoNombre")
// Al abrir la versión nueva, se lee lo guardado:
println("Leído por posición: ${TallaDespues.entries[guardadoPosicion]}")
println("Leído por nombre: ${TallaDespues.valueOf(guardadoNombre)}")
// Un nombre que ya no existe lanza una excepción; runCatching la convierte en null.
println("Nombre borrado: ${runCatching { TallaDespues.valueOf("ENORME") }.getOrNull() ?: TallaDespues.MEDIANA}")
}Guardado: posición 2, nombre GRANDE
Leído por posición: PEQUENA
Leído por nombre: GRANDE
Nombre borrado: MEDIANAname es el nombre de la constante como texto, ordinal su posición, entries la lista de todas y valueOf la operación inversa de name: de un texto saca la constante, o lanza una excepción si no existe. Quien guardó la talla «grande» por su posición (2) vería «pequeña» tras el reordenamiento sin haber tocado nada; por su nombre, sigue siendo «grande». Lo guardado tiene que poder leerse dentro de un año, y el orden de unas constantes en un fichero de Kotlin no es un contrato que nadie vaya a respetar. Con valueOf y runCatching ya en la mano, el repositorio de abajo se lee de arriba abajo.
package com.sebastiannarvaez.pokedex.data
import androidx.datastore.core.DataStore
import androidx.datastore.core.IOException
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.stringPreferencesKey
import com.sebastiannarvaez.pokedex.core.Log
import com.sebastiannarvaez.pokedex.feature.settings.Settings
import com.sebastiannarvaez.pokedex.feature.settings.Tema
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
// Lee y escribe las preferencias. `internal`: las pantallas lo usan a través
// del ViewModel, no directamente.
internal class SettingsRepository(private val store: DataStore<Preferences>) {
// Las etiquetas con las que cada valor se guarda dentro del fichero.
// Cada clave lleva su tipo: leer `tema` como Boolean no compila.
// `companion object`: miembros de la clase, no de cada instancia, y aquí
// `private`: solo esta clase los ve (más en «object, companion y vararg»).
private companion object {
val CLAVE_TEMA = stringPreferencesKey("tema")
val CLAVE_ORDEN = booleanPreferencesKey("favoritos_por_numero")
}
// Lectura: un Flow que emite las preferencias cada vez que cambian.
val settings: Flow<Settings> = store.data
.catch { causa ->
// Solo se atrapa el error de DISCO (`IOException`: fichero ilegible
// o corrupto). Cualquier otro es un fallo de programación y se
// vuelve a lanzar para que se oiga.
if (causa !is IOException) throw causa
// Si leer el fichero falla, se avisa en el registro y se sigue con
// las preferencias vacías (= los valores por defecto).
Log.w("no se pudieron leer las preferencias", causa)
emit(emptyPreferences())
}
.map { prefs ->
Settings(
// Lo guardado es un texto. `?.let` lo intenta convertir en `Tema` solo si hay texto;
// si no hay texto o no corresponde a ningún Tema, `?:` pone SISTEMA.
tema = prefs[CLAVE_TEMA]?.let { runCatching { Tema.valueOf(it) }.getOrNull() } ?: Tema.SISTEMA,
favoritosPorNumero = prefs[CLAVE_ORDEN] ?: false, // sin valor guardado: false
)
}
// Escritura: `edit` abre una transacción; al salir del bloque se escribe todo.
suspend fun cambiarTema(tema: Tema) {
// Dentro de `edit`, `it` son las preferencias editables: `it[CLAVE] = valor` escribe.
store.edit { it[CLAVE_TEMA] = tema.name } // se guarda el NOMBRE del Tema
}
suspend fun cambiarOrden(porNumero: Boolean) {
store.edit { it[CLAVE_ORDEN] = porNumero }
}
}IOException aquí es el que trae DataStore (androidx.datastore.core.IOException) y no el de Java (java.io.IOException): el de Java no existe en iOS, y este código es común a las tres plataformas, así que DataStore ofrece el suyo con el mismo nombre (en Android y en la JVM es el de Java por debajo).
Para leer la línea del tema: prefs[CLAVE_TEMA] da el texto guardado o null; ?.let { … } significa «si no es null, haz esto con ese texto»; Tema.valueOf(it) lo convierte en un Tema; getOrNull() da null si eso falló; y el último ?: pone SISTEMA en cualquiera de los dos casos de null.
Dos defensas, las dos sobre datos que ya están en un disco que no controlas. El catch es para el fichero que no se puede leer: se registra y la app abre con los valores por defecto. Es estrecho a propósito: solo atrapa IOException. Un catch ancho convertiría cualquier error de programación en «valores por defecto» silenciosos, y cuanto más estrecho es, más se oye lo que falla (el paso 5 lo provoca). El runCatching { Tema.valueOf(it) } es para un nombre que ya no existe (si un día borras una constante del enumerado, quien la tuviera guardada vuelve a SISTEMA en vez de reventar).
Paso 3 · El ViewModel y el grafo de Koin
El archivo. SettingsViewModel.kt expone el flujo como StateFlow y traduce cada gesto en una escritura. Créalo en shared con la ruta
src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/settings/SettingsViewModel.ktpackage com.sebastiannarvaez.pokedex.feature.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sebastiannarvaez.pokedex.data.SettingsRepository
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
// `internal constructor`: solo código de `shared` puede construirlo, porque su
// parámetro (`SettingsRepository`) también es `internal`. Las apps no lo
// construyen: se lo piden a Koin.
class SettingsViewModel internal constructor(
private val repository: SettingsRepository,
) : ViewModel() {
// El flujo del repositorio como StateFlow: siempre tiene un valor actual.
// `stateIn` lo convierte; `WhileSubscribed(5_000)` lo mantiene vivo mientras
// alguien lo mire y 5 s más (sobrevive a girar la pantalla); `Settings()` es
// el valor mientras DataStore lee el fichero ([StateFlow y SharedFlow] lo explica).
val settings: StateFlow<Settings> = repository.settings
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), Settings())
// Cada gesto de la pantalla es una escritura (suspend) lanzada desde aquí.
fun cambiarTema(tema: Tema) {
viewModelScope.launch { repository.cambiarTema(tema) }
}
fun cambiarOrden(porNumero: Boolean) {
viewModelScope.launch { repository.cambiarOrden(porNumero) }
}
}stateIn, SharingStarted.WhileSubscribed y el valor inicial tienen su lección: StateFlow y SharedFlow. El companion object está en object, companion y vararg. internal significa «visible solo dentro de este módulo»: aquí, shared.
Al compilar para iOS saldrá un aviso más, StateFlow property is exposed to ObjC, sobre settings. (ObjC es Objective-C, el lenguaje antiguo de Apple por el que Kotlin habla con Swift.) Es el mismo aviso de los demás ViewModel, que ya viste en El ViewModel compartido: es normal y no hay que hacer nada.
Ahora el grafo. Abre shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.kt. Dos import nuevos, junto a los que ya tiene:
import com.sebastiannarvaez.pokedex.data.SettingsRepository
import com.sebastiannarvaez.pokedex.feature.settings.SettingsViewModelEl repositorio, en la lista de single, después de los repositorios que ya hay (el de favoritos y el de la lista, single { FavoritesRepository(get(), get()) } y single { PokemonListRepository(get(), get()) }). get() es el almacén que registró el módulo de plataforma:
single { SettingsRepository(get()) }Y el ViewModel, debajo de viewModelOf(::FavoritesViewModel):
viewModelOf(::SettingsViewModel)El test del grafo avisa. Si ahora lanzas ./gradlew :shared:jvmTest, el test del grafo de Koin: un solo grafo en commonMain falla:
MissingKoinDefinitionException: Missing definition for
'[field:'store' - type:'androidx.datastore.core.DataStore']' in definition
'[Singleton: 'com.sebastiannarvaez.pokedex.data.SettingsRepository']'.verify solo mira pokedexModule, y el almacén lo registra platformModule. Se arregla igual que el HttpClientEngine que ya está en ese test: declarándolo como tipo que existe aunque este módulo no lo construya. Abre shared/src/jvmTest/kotlin/com/sebastiannarvaez/pokedex/di/ModulesTest.kt, añade el import y sustituye la llamada a verify:
import androidx.datastore.core.DataStore pokedexModule.verify(
extraTypes = listOf(
HttpClientEngine::class,
// El almacén lo registra `platformModule` (uno por plataforma),
// y `verify` solo mira `pokedexModule`.
DataStore::class,
),
)Paso 4 · Los tests
El archivo. SettingsTest.kt, en la JVM (como el resto de tests que tocan ficheros). Clic derecho sobre shared → New → File:
src/jvmTest/kotlin/com/sebastiannarvaez/pokedex/feature/settings/SettingsTest.ktCinco tests. Fíjate en que cada uno escribe y espera la emisión con awaitItem(), que es como se comprueba un flujo. El quinto es el de la regla de la instancia única de la lección anterior: dos almacenes sobre el mismo fichero.
package com.sebastiannarvaez.pokedex.feature.settings
import app.cash.turbine.test
import com.sebastiannarvaez.pokedex.data.SettingsRepository
import com.sebastiannarvaez.pokedex.data.local.createSettingsStore
import kotlin.io.path.createTempDirectory
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertTrue
import kotlinx.coroutines.test.runTest
class SettingsTest {
// Un repositorio nuevo sobre un fichero temporal nuevo: cada test parte de cero.
private fun repo(): SettingsRepository {
val ruta = createTempDirectory("prefs").resolve("pokedex.preferences_pb").toString()
return SettingsRepository(createSettingsStore(ruta))
}
@Test
fun sinNadaGuardadoMandaElSistema() = runTest {
repo().settings.test {
val inicial = awaitItem() // la primera emisión del flujo
// El último argumento de cada aserción es el mensaje que sale si falla.
assertEquals(Tema.SISTEMA, inicial.tema, "sin nada guardado, el tema es el del sistema")
assertFalse(inicial.favoritosPorNumero, "sin nada guardado, lo más reciente va primero")
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun elCambioLlegaComoEmision() = runTest {
val repo = repo()
repo.settings.test {
assertEquals(Tema.SISTEMA, awaitItem().tema) // 1.ª emisión: el valor por defecto
repo.cambiarTema(Tema.OSCURO) // se escribe...
assertEquals(Tema.OSCURO, awaitItem().tema, "tras escribir llega una emisión nueva")
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun loGuardadoSeLeeDesdeElMismoAlmacen() = runTest {
val ruta = createTempDirectory("prefs").resolve("pokedex.preferences_pb").toString()
val almacen = createSettingsStore(ruta) // UN almacén...
SettingsRepository(almacen).cambiarOrden(porNumero = true)
// ...y un repositorio distinto sobre ese mismo almacén: ve lo guardado.
SettingsRepository(almacen).settings.test {
assertTrue(awaitItem().favoritosPorNumero, "otro repositorio sobre el mismo almacén ve lo guardado")
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun elTemaGuardadoSeRecuperaPorSuNombre() = runTest {
val repo = repo()
repo.cambiarTema(Tema.CLARO)
repo.settings.test {
assertEquals(Tema.CLARO, awaitItem().tema, "el tema guardado se recupera por su nombre")
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun dosAlmacenesSobreElMismoFicheroSeOyen() = runTest {
val ruta = createTempDirectory("prefs").resolve("pokedex.preferences_pb").toString()
val primero = createSettingsStore(ruta)
SettingsRepository(primero).cambiarOrden(porNumero = true)
val segundo = createSettingsStore(ruta) // un SEGUNDO almacén sobre el mismo fichero
SettingsRepository(segundo).settings.test {
// DataStore protesta con una `IllegalStateException` al leer del segundo
// almacén. Como el `catch` del repositorio es estrecho, el error llega.
val error = awaitError()
assertIs<IllegalStateException>(error)
}
}
}Lánzalos desde la Terminal de Android Studio: ./gradlew :shared:jvmTest. Pasan los cinco de SettingsTest y el de Koin sigue en verde. Para ver la cifra, abre el informe como en la lección anterior, open shared/build/reports/tests/jvmTest/index.html: arriba pone 73 tests en la JVM (eran 68). Si en lugar del terminal lanzas SettingsTest con el triángulo verde junto a la clase, el panel de tests de Android Studio muestra también cuántos han pasado.
Paso 5 · Opcional: provócalo, ensancha el catch
La regla de la lección anterior («una sola instancia por fichero») no es un capricho, y el quinto test es lo que la guarda. Para ver por qué el catch es estrecho, ensánchalo. Antes, guarda una copia de SettingsRepository.kt fuera del proyecto: abre el fichero, selecciona todo (en Mac, Cmd+A), copia (Cmd+C) y pégalo en un documento de texto del Escritorio (TextEdit, por ejemplo). No la dejes en la misma carpeta con otro nombre: el compilador vería dos clases SettingsRepository y fallaría con Redeclaration. Después, en settings, borra la línea if (causa !is IOException) throw causa y lanza solo ese test:
./gradlew :shared:jvmTest --tests "*SettingsTest*" --rerun -i--tests filtra por nombre, --rerun obliga a Gradle a ejecutar las pruebas aunque no haya cambiado nada desde la última vez, e -i (info) muestra todo lo que el test escribe en la consola. En esa salida aparecen las dos cosas a la vez: el registro del repositorio y el fallo.
Warn: (Pokedex) no se pudieron leer las preferencias
java.lang.IllegalStateException: There are multiple DataStores active for the same file: …
…
TurbineAssertionError: Expected error but found Item(Settings(tema=SISTEMA, favoritosPorNumero=false))La librería protestó con IllegalStateException, pero el catch ancho se tragó la excepción, la escribió en el registro y emitió valores por defecto: en la pantalla, «los ajustes no se guardan» sin ningún error que seguir. Con el catch estrecho el error llega y el test lo ve. Vuelve a escribir la línea (o pega encima tu copia del Escritorio) y borra el documento. Después vuelve a lanzar los tests (./gradlew :shared:jvmTest) para comprobar que todo vuelve a pasar en 73.
- Por eso el almacén se registra como
singley nunca se crea dentro de una pantalla. - Cuanto más estrecho es un
catch, más se oyen los errores de programación.
Punto de control
./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Testtermina enBUILD SUCCESSFUL, y los informes de tests dan 73 en la JVM, 40 en Android host y 43 en iOS. Los cinco nuevos, los deSettingsTest, son de la JVM y suben la cifra de 68 a 73; si añadiste ejercicios con tests, la tuya será mayor.- El
catchdeSettingsRepositorysolo atrapaIOException. SettingsRepositoryySettingsViewModelexisten, yModules.ktregistra el repositorio comosingley el ViewModel conviewModelOf.- Ninguna pantalla llama a
createSettingsStore: el almacén siempre llega por inyección. Tematiene tres valores y el valor por defecto esSISTEMA.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.Lees desde un segundo
DataStoresobre el mismo fichero. ¿Por qué el repositorio deja que laIllegalStateExceptionllegue en vez de atraparla?2.¿Por qué el tema es un enumerado de tres valores y no un booleano de «modo oscuro»?
3.¿Por qué se guarda el nombre del enumerado y no su posición?
4.En un
Flow, elcatchatrapa los errores que ocurren…5.Los tests de
SettingsTestescriben y luego esperan conawaitItem(). ¿Qué comprueban así?