Saltar al contenido
K
Ajustes con DataStorePaso a paso26 min Intermedio

Ajustes en el núcleo

Al terminar: Instalar DataStore y crear el almacén de preferencias en commonMain, con una ruta por plataforma.

Los favoritos ya se guardan en una tabla de Room. Las preferencias no necesitan una tabla: son dos valores sueltos, el tema de la app y el orden de los favoritos, y para eso hay otra herramienta, DataStore. Al terminar esta lección el módulo shared tendrá DataStore instalado y un almacén de preferencias listo, con su fichero en una carpeta distinta en cada plataforma y registrado en Koin. No guarda todavía ningún ajuste: el modelo, el repositorio y los tests llegan en la lección siguiente, y las pantallas después.

Una preferencia es algo que el usuario elige una vez y la app recuerda siempre, como la posición del asiento de un coche: tú la fijas y el coche la recuerda aunque lo apagues. Son valores sueltos, sin filas ni consultas; por eso no van en una tabla.

DataStore ya lo usaste en Android. Aquí es lo propio de Kotlin Multiplatform: otra librería (la variante sin Android), una ruta de fichero distinta por plataforma y una regla (una sola instancia por fichero) que, si se rompe, hace fallar la lectura de los ajustes.

El concepto · un almacén, una puerta

Antes de tocar el proyecto, DataStore en pequeño. Este primer ejemplo es solo para leer: necesita la librería DataStore, así que no se ejecuta en el Playground; lo que ves debajo es la salida real. Es un almacén de preferencias: un fichero en el disco y una sola puerta para leerlo y escribirlo. Las lecturas llegan como un flujo (Flow): no es una foto del valor, es como un termómetro que vas mirando, y cada vez que algo cambia ves el valor nuevo. Las escrituras se hacen con edit, una función suspend.

Un ejemplo ajeno al proyecto, una app de recetas que recuerda las unidades y las raciones. Léelo con los comentarios; la salida real va debajo:

ejemplo mínimo: preferencias de una app de recetas (con comentarios)
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import okio.Path.Companion.toPath
 
// Las claves: una etiqueta con nombre y con tipo.
val UNIDADES = stringPreferencesKey("unidades")   // guarda un texto
val RACIONES = intPreferencesKey("raciones")      // guarda un número entero
 
fun main() = runBlocking {
    // Una carpeta temporal nueva y vacía, y dentro el nombre del fichero de preferencias.
    val ruta = kotlin.io.path.createTempDirectory("recetas").resolve("recetas.preferences_pb").toString()
    // El almacén: un fichero en disco y una sola puerta para leerlo y escribirlo.
    val almacen = PreferenceDataStoreFactory.createWithPath(produceFile = { ruta.toPath() })
 
    // Leer: `data` es un flujo. `first()` coge la primera emisión y para: sirve
    // para pedir el valor UNA vez, aquí para ver cómo estaba antes de guardar nada.
    val antes = almacen.data.first()
    println("Antes: unidades=${antes[UNIDADES]}, raciones=${antes[RACIONES]}")
 
    // Observar: cada vez que algo cambie, llega una emisión nueva.
    val observador = launch {
        almacen.data
            .map { prefs -> "${prefs[UNIDADES] ?: "métrico"} · ${prefs[RACIONES] ?: 2} raciones" }
            .take(2)                                        // las dos primeras y para
            .collect { println("La pantalla ve: $it") }
    }
 
    // Escribir: `edit` es una función suspend; dentro, `it` son las preferencias
    // y `it[CLAVE] = valor` guarda un valor bajo esa clave (`prefs[CLAVE]` lo lee).
    almacen.edit {
        it[UNIDADES] = "imperial"
        it[RACIONES] = 4
    }
    observador.join()
}
Antes: unidades=null, raciones=null
La pantalla ve: métrico · 2 raciones
La pantalla ve: imperial · 4 raciones

Tres cosas que ver. Sin nada guardado, cada clave da null: por eso el código pone un valor por defecto con ?: (el «si no hay nada, usa esto»). Se puede pedir el valor una vez con first(), pero una pantalla no pregunta: observa. Recibe el valor de partida y, tras el edit, recibe el nuevo sin volver a preguntar. Y el fichero acaba en .preferences_pb: _pb viene de protocol buffers, un formato binario de Google. Nunca lo abres a mano.

Paso 1 · Instala DataStore en shared

Qué instalas y por qué ahora. datastore-preferences-core: DataStore de preferencias sin la parte de Android (la que trae Context y el delegado by preferencesDataStore, que solo existe en Android y no se puede usar desde commonMain). Hace falta ya porque el paso 2 crea el almacén.

(SharedPreferences es la forma antigua que tiene Android de guardar preferencias sueltas; «transaccional» quiere decir «todo o nada»: lo que escribes en un bloque se guarda entero o no se guarda.)

DataStore tiene dos variantes: Preferences (pares clave-valor sueltos, la del ejemplo de arriba) y otra con Protocol Buffers (un objeto con esquema). En multiplataforma solo está la primera, que es la que hace falta aquí.

gradle
libs.versions.tomlse modifica: [versions] y [libraries]
shared
build.gradle.ktsse modifica: commonMain.dependencies
Dos ficheros de Gradle: el catálogo y el módulo compartido.

1 · La versión, en [versions]. Abre gradle/libs.versions.toml y añade al final de [versions] (la cabecera ya existe; no la repitas):

gradle/libs.versions.toml: [versions], línea nueva
datastore = "1.2.1"   # una sola versión para todos los artefactos de DataStore

2 · La librería, en [libraries]. Al final de esa sección:

gradle/libs.versions.toml: [libraries], línea nueva
androidx-datastore-preferences-core = { module = "androidx.datastore:datastore-preferences-core", version.ref = "datastore" }

3 · La dependencia, en el módulo. Abre shared/build.gradle.kts y, dentro de commonMain.dependencies { … }, debajo de api(libs.koin.core), añade:

shared/build.gradle.kts: commonMain, línea nueva
            // `api` y no `implementation`: `createSettingsStore` (paso 2) es pública
            // y su firma nombra `DataStore<Preferences>`, así que esos tipos tienen
            // que viajar con el módulo. Cuándo usar cada una se explica en
            // «La primera dependencia».
            api(libs.androidx.datastore.preferences.core)

Es la misma elección de api que hiciste con Room en Persistencia con Room 3; la explicación completa está en La primera dependencia.

4 · Sync Now. Pulsa Sync Now en la barra amarilla. Sin rojo (si sale en rojo, mira la caja «Si Sync sale en rojo» de Los ficheros de Gradle de tu proyecto). Dos errores típicos:

In version catalog libs, version reference 'datastor' doesn't exist

es una errata en version.ref (tiene que coincidir letra por letra con la clave de [versions]), y

Could not find androidx.datastore:datastore-preferences-cor:1.2.1.

es una errata en module = "…".

5 · Comprueba. En la Terminal de Android Studio, desde la carpeta de tu proyecto:

./gradlew :shared:dependencies --configuration jvmCompileClasspath | grep -E "datastore|okio"

La primera parte pide a Gradle la lista de librerías con las que se compila el código de la JVM (jvmCompileClasspath). Lo que va tras la barra | es grep, un filtro que se queda solo con las líneas que contienen «datastore» u «okio» (-E permite escribir las dos palabras separadas por |).

Salen las líneas de datastore-preferences-core, datastore-core, datastore-core-okio (el puente entre los dos) y también okio. Okio es una librería de ficheros y rutas que DataStore usa por dentro y trae consigo (la usarás en el paso 2 con ruta.toPath()): no hay que declararla aparte.

Paso 2 · El almacén y su ruta por plataforma

El archivo. SettingsStore.kt crea el DataStore a partir de una ruta. Es código común, así que va en commonMain: 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/data/local/SettingsStore.kt
shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/data/local/SettingsStore.kt
package com.sebastiannarvaez.pokedex.data.local
 
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import okio.Path.Companion.toPath
 
// El nombre del fichero es el mismo en las tres plataformas; lo que cambia es
// la carpeta donde vive. `internal`: solo lo usa este módulo. `const val`: una
// constante cuyo valor se fija al compilar y nunca cambia.
internal const val SETTINGS_FILE_NAME = "pokedex.preferences_pb"
 
// Crea el almacén de preferencias sobre el fichero que está en `ruta`.
// Regla: UNA sola instancia por fichero (se registrará como `single` en Koin;
// la razón, en la caja de abajo).
fun createSettingsStore(ruta: String): DataStore<Preferences> =
    PreferenceDataStoreFactory.createWithPath(
        produceFile = { ruta.toPath() },  // `String` → `Path` (el tipo de ruta de Okio)
    )

Dos piezas que no son obvias. createWithPath crea el almacén, pero no le das la ruta: le das una función que la devuelve (produceFile), y DataStore la llama la primera vez que necesita el fichero. Y toPath() existe porque Okio usa su propio tipo de ruta, Path: convierte el texto "/…/pokedex.preferences_pb" en ese tipo.

La regla de la instancia única la pone Google, y rompe los ajustes si se ignora:

Cada plataforma aporta solo su carpeta, en el fichero donde ya está la ruta de la base de datos (lo creaste en La base en cada plataforma). En Android, abre shared/src/androidMain/kotlin/com/sebastiannarvaez/pokedex/data/local/DatabaseBuilder.android.kt y añade esta función al final (el import android.content.Context ya está):

shared/src/androidMain/kotlin/com/sebastiannarvaez/pokedex/data/local/DatabaseBuilder.android.kt
// El fichero de preferencias, en la carpeta privada de la app (`filesDir`).
fun settingsPath(context: Context): String =
    context.filesDir.resolve(SETTINGS_FILE_NAME).absolutePath

filesDir es la carpeta privada que Android da a cada app (nadie más la ve y se borra al desinstalar); resolve le añade el nombre del fichero y absolutePath da la ruta entera como texto.

En iOS, abre shared/src/iosMain/kotlin/com/sebastiannarvaez/pokedex/data/local/DatabaseBuilder.ios.kt y añade al final esta función (los import ya están, porque getDatabaseBuilder usa los mismos). Es la misma consulta a Apple que ya hiciste con la base de datos en La base en cada plataforma; aquí, línea a línea:

shared/src/iosMain/kotlin/com/sebastiannarvaez/pokedex/data/local/DatabaseBuilder.ios.kt
// Llamar a funciones de Apple desde Kotlin exige este permiso explícito (ver debajo).
@OptIn(ExperimentalForeignApi::class)
// El fichero de preferencias, en la misma carpeta Documents que la base.
fun settingsPath(): String {
    // `NSFileManager` es el gestor de ficheros de Apple; `defaultManager` es el que usa todo el mundo.
    val documentos: NSURL = NSFileManager.defaultManager.URLForDirectory(
        directory = NSDocumentDirectory,   // «dame la carpeta Documents»...
        inDomain = NSUserDomainMask,       // ...la de este usuario (la de la app, dentro de su sandbox)
        appropriateForURL = null,          // sin ruta de referencia
        create = false,                    // existe siempre: no hay que crearla
        error = null,                      // (parámetro de Apple, no la función de abajo) sin objeto de error
    ) ?: error("no se pudo resolver el directorio de documentos")   // si la llamada devolvió null, se para con este mensaje
    // `.path` es la ruta como texto (puede ser null: `requireNotNull` para si lo es).
    return requireNotNull(documentos.path) + "/" + SETTINGS_FILE_NAME
}

Tres piezas de ese bloque, en palabras sencillas. NSURL es el tipo de Apple para una dirección de fichero o carpeta. @OptIn(ExperimentalForeignApi::class) es el permiso: Kotlin llama a las funciones de Apple mediante un puente llamado cinterop, aún marcado como experimental, y esa línea dice «lo sé y lo acepto». Y error("…") es una función de Kotlin que detiene el programa con ese mensaje; el ?: solo la ejecuta si la consulta a Apple devolvió null. (Que el parámetro de Apple también se llame error es casualidad: son dos cosas distintas.)

Y en la JVM (donde solo corren los tests), shared/src/jvmMain/kotlin/com/sebastiannarvaez/pokedex/data/local/DatabaseBuilder.jvm.kt, también al final:

shared/src/jvmMain/kotlin/com/sebastiannarvaez/pokedex/data/local/DatabaseBuilder.jvm.kt
// En una carpeta temporal nueva: la JVM solo existe para los tests.
fun settingsPath(): String =
    java.nio.file.Files.createTempDirectory("pokedex").resolve(SETTINGS_FILE_NAME).toString()

createTempDirectory("pokedex") crea una carpeta nueva y vacía en la carpeta temporal del ordenador, con un nombre que empieza por pokedex; resolve le añade el nombre del fichero, como en Android.

Ahora Koin, la herramienta que entrega a cada pieza lo que necesita. En cada uno de los tres ficheros Modules.<plataforma>.kt (en shared/src/androidMain/kotlin/com/sebastiannarvaez/pokedex/di/, shared/src/iosMain/…/di/ y shared/src/jvmMain/…/di/) el módulo de plataforma registra el almacén como single. Sustituye cada fichero entero. Android:

shared/src/androidMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.android.kt
package com.sebastiannarvaez.pokedex.di
 
import com.sebastiannarvaez.pokedex.data.local.createSettingsStore
import com.sebastiannarvaez.pokedex.data.local.getDatabaseBuilder
import com.sebastiannarvaez.pokedex.data.local.settingsPath
import org.koin.android.ext.koin.androidContext
import org.koin.core.module.Module
import org.koin.dsl.module
 
actual val platformModule: Module = module {
    single { getDatabaseBuilder(androidContext()) }
    // `single`: UNA instancia del almacén para toda la app (la razón, en la caja del paso 2).
    single { createSettingsStore(settingsPath(androidContext())) }
}

iOS y JVM son iguales entre sí (sin Context). Primero iOS:

shared/src/iosMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.ios.kt
package com.sebastiannarvaez.pokedex.di
 
import com.sebastiannarvaez.pokedex.data.local.createSettingsStore
import com.sebastiannarvaez.pokedex.data.local.getDatabaseBuilder
import com.sebastiannarvaez.pokedex.data.local.settingsPath
import org.koin.core.module.Module
import org.koin.dsl.module
 
actual val platformModule: Module = module {
    single { getDatabaseBuilder() }
    // `single`: UNA instancia del almacén para toda la app (la razón, en la caja del paso 2).
    single { createSettingsStore(settingsPath()) }
}

Y la JVM, con exactamente el mismo contenido:

shared/src/jvmMain/kotlin/com/sebastiannarvaez/pokedex/di/Modules.jvm.kt
package com.sebastiannarvaez.pokedex.di
 
import com.sebastiannarvaez.pokedex.data.local.createSettingsStore
import com.sebastiannarvaez.pokedex.data.local.getDatabaseBuilder
import com.sebastiannarvaez.pokedex.data.local.settingsPath
import org.koin.core.module.Module
import org.koin.dsl.module
 
actual val platformModule: Module = module {
    single { getDatabaseBuilder() }
    // `single`: UNA instancia del almacén para toda la app (la razón, en la caja del paso 2).
    single { createSettingsStore(settingsPath()) }
}

Paso 3 · Comprueba que sigue todo en pie

Este paso no añade código: confirma que lo que has hecho compila en las tres plataformas y que el grafo de Koin sigue cuadrando. En la Terminal de Android Studio, desde la carpeta de tu proyecto:

./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test

Debe terminar en BUILD SUCCESSFUL. Gradle no dice cuántos tests ha corrido; la cifra se lee en el informe de cada plataforma, como en Bucles y tu primer test (open es la orden de macOS que abre un fichero con su programa, aquí el navegador). Arriba de cada página, el recuadro «tests» da la cifra:

open shared/build/reports/tests/jvmTest/index.html
open shared/build/reports/tests/testAndroidHostTest/index.html
open shared/build/reports/tests/iosSimulatorArm64Test/index.html

Salen 68 en la JVM, 40 en Android host y 43 en iOS: los mismos que traías, porque esta lección no añade tests.

Punto de control

  • ./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test termina en BUILD SUCCESSFUL, y los informes dan 68 tests en la JVM, 40 en Android host y 43 en iOS (los de antes; si añadiste ejercicios con tests, la tuya será mayor).
  • libs.versions.toml tiene datastore y androidx-datastore-preferences-core, y shared/build.gradle.kts los usa con api.
  • createSettingsStore existe en commonMain, y settingsPath en androidMain, iosMain y jvmMain.
  • Los tres Modules.<plataforma>.kt registran createSettingsStore(settingsPath(…)) como single.

Comprueba lo que entendiste

4 preguntas · necesitas 3 para completar la lección

  1. 1.Creas un segundo DataStore sobre el mismo fichero que ya usa otro, dentro de la misma app. ¿Qué pasa al leer o escribir con él?

  2. 2.¿Qué cambia al usar DataStore en lugar de SharedPreferences?

  3. 3.¿Qué artefacto de DataStore se usa en commonMain, y qué pasa con Okio?

  4. 4.En el módulo común, createSettingsStore recibe la ruta como parámetro. ¿Por qué?

Faltan 4 de 4