Saltar al contenido
K
Jetpack y arquitecturaPaso a paso30 min de lectura · 10 min de máquina Básico

DataStore

Al terminar: Guardar preferencias con DataStore y compararlo con SharedPreferences.

Al terminar esta lección tendrás DataStore instalado y habrás guardado una preferencia con él en un ejemplo ajeno, una reserva de mesa que recuerda cuántos comensales eligiste aunque cierres la app. Marcador no cambia: la lección siguiente aplica todo esto a la duración del partido.

Importa porque casi toda app necesita recordar unos pocos ajustes sueltos (tema, idioma, una opción), y para eso una base de datos es demasiado. DataStore es la pieza de Jetpack para ese caso, y sustituye a SharedPreferences, la forma antigua de Android de guardar ajustes sueltos en un fichero, que tiene un fallo de diseño que acaba en pantallas congeladas.

Temas: DataStore Preferences frente a SharedPreferences, y leer y escribir una preferencia.

Por qué DataStore y no SharedPreferences

En la cita: asíncrona es que guardar y leer no te hace esperar con la app parada; consistente, que lo que lees es siempre lo último guardado; transaccional, que cada escritura se hace entera o no se hace. Y los protocol buffers son un formato de Google para describir datos con estructura: es la variante «Proto» de DataStore, que no usarás.

SharedPreferencesDataStore Preferences
LecturaSíncrona: la primera vez carga el fichero en el hilo principal (puede provocar un ANR)Un Flow: nunca bloquea el hilo
EscrituraSin garantía de que se guarde entera, y los fallos pasan en silencioUna función suspend, edit { }, entera o nada
ErroresSe pierdenLlegan como excepciones
CambiosHay que volver a leerQuien observa el Flow se entera

Sirve para pocos datos sueltos, con una clave cada uno. Para datos con estructura y consultas, Room.

Un valor por defecto es lo que devuelve la lectura mientras nadie ha guardado nada: en DataStore lo pones tú al leer.

ejemplo mínimo: leer y escribir una preferencia (con comentarios)
// A nivel de fichero, UNA sola vez: DataStore exige una sola instancia por fichero.
private val Context.ajustes: DataStore<Preferences> by preferencesDataStore(name = "ajustes")
 
val claveDuracion = intPreferencesKey("duracion_del_partido")   // la clave lleva su tipo (aquí, un número entero)
 
// Leer: `data` es un Flow<Preferences>; se elige la clave y, si no hay nada, el valor por defecto.
val duracion: Flow<Int> = contexto.ajustes.data.map { it[claveDuracion] ?: 90 }
 
// Escribir: `edit` es suspend y atómico.
suspend fun guardar(minutos: Int) = contexto.ajustes.edit { it[claveDuracion] = minutos }

Tres piezas de sintaxis:

  • val Context.ajustes es una propiedad de extensión: añade una propiedad a Context sin tocar la clase (Extensiones).
  • by preferencesDataStore(…) es una delegación de propiedad: crea el DataStore la primera vez y devuelve siempre la misma instancia (Delegación: by lazy). Por eso va una sola vez, a nivel de fichero (fuera de cualquier clase): así es única para toda la app, y dentro de una clase se crearía una por objeto.
  • ?: devuelve lo de la derecha si lo de la izquierda es null (Nulabilidad): sin nada guardado, it[claveDuracion] es null y sale 90.

El fichero acaba en la carpeta privada de la app (la zona del móvil donde Android guarda los datos de esa app y que ninguna otra puede leer), en files/datastore/ajustes.preferences_pb.

Prepara el proyecto · Instala DataStore (Preferences)

Objetivo: que :app tenga la librería DataStore Preferences, instalada a mano a través del catálogo, y que sus clases se reconozcan en el código.

Es la receta de Los ficheros de Gradle de tu proyecto, con una librería de versión propia como en JSON e instalar kotlinx.serialization.

Gradle Scripts
libs.versions.toml (Version Catalog "libs")se modifica: [versions] y [libraries]
build.gradle.kts (Module :app)se modifica: dependencies
Los dos ficheros de esta instalación, tal como los ves en la vista Android.

1 · Qué instalas y por qué ahora. androidx.datastore:datastore-preferences: la pieza de Jetpack que guarda pares clave-valor (la otra variante, Proto DataStore, guarda objetos con protocol buffers y no la usarás). Trae DataStore, Preferences, preferencesDataStore, intPreferencesKey y edit. Hace falta ahora porque el ejercicio de debajo y el paso siguiente guardan datos con ella.

2 · Catálogo. Abre Gradle Scripts → libs.versions.toml (Version Catalog "libs") y añade una línea al final de [versions] y otra al final de [libraries]. No hay nada que tocar en [plugins] ni en la raíz: es una librería normal.

catálogo: líneas nuevas (con comentarios)
[versions]
datastore = "1.2.1"   # su propio número de versión, con el nombre «datastore»
 
[libraries]
# Coordenadas androidx.datastore:datastore-preferences; la versión es la de «datastore».
# En Gradle será libs.androidx.datastore.preferences
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }

3 · Dependencia. Abre Gradle Scripts → build.gradle.kts (Module :app) y añade una línea en dependencies { … }, debajo de la última implementation:

build.gradle.kts (Module :app): línea nueva en dependencies (con comentarios)
    // implementation: la usa el código de la app (PreferenciasDataStore), no solo los tests
    implementation(libs.androidx.datastore.preferences)

4 · Sync Now. Pulsa Sync Now en la barra de arriba del editor. Debe terminar sin errores rojos; la primera vez descarga la librería y necesita internet.

5 · Comprueba. Sync no prueba que la librería esté en la app. En la Terminal de Android Studio (View → Tool Windows → Terminal) pide las librerías que usa la app al ejecutarse:

./gradlew :app:dependencies --configuration gratisDebugRuntimeClasspath

gratisDebugRuntimeClasspath es la lista de la variante gratisDebug de Gradle: flavors y variantes. Añade | grep datastore al final (la barra | pasa la salida al comando siguiente, y grep filtra las líneas que contienen esa palabra). Salen varias líneas; la primera es androidx.datastore:datastore-preferences:1.2.1.

Ejercicio · la reserva de mesa

Antes de tocar Marcador, practica DataStore con un ejemplo ajeno. El ejercicio usa una pieza de pantalla que no has visto, el chip:

FilterChip(selected, onClick, label) es un botón de Material 3 con aspecto de etiqueta que puede estar marcado. selected dice si lo está, onClick qué hacer al pulsarlo y label lo que muestra.

ejemplo mínimo: tres chips, uno marcado (con comentarios)
var elegido by remember { mutableIntStateOf(4) }          // lo que está marcado ahora
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
    listOf(2, 4, 6).forEach { numero ->                   // un chip por opción
        FilterChip(
            selected = elegido == numero,                 // marcado si es el elegido
            onClick = { elegido = numero },               // al pulsarlo, pasa a ser el elegido
            label = { Text("$numero") },                  // el texto del chip
        )
    }
}

Aquí el elegido vive en memoria; en el ejercicio tendrá que vivir en el DataStore.

Reto · Ejercicio: la reserva de mesa

En la carpeta ejercicios de tu proyecto (la que creaste en La rotación y la muerte del proceso), crea la subcarpeta mesa: clic derecho sobre com.sebastiannarvaez.marcador → New → Package → añade .ejercicios.mesa al final del nombre que aparece ya escrito. Después, clic derecho sobre esa carpeta → New → Kotlin Class/File → File → ReservaDeMesa. La primera línea debe ser package com.sebastiannarvaez.marcador.ejercicios.mesa.

Escribe un composable ReservaDeMesa y su vista previa, con un DataStore propio llamado reservas y una clave de número, comensales. La pantalla dice «Mesa para N personas» (con 2 si no hay nada guardado) y, debajo, una fila con tres chips, «2», «4» y «6», con el chip guardado marcado. Al pulsar uno, se guarda.

Lo que debes ver: el panel de vista previa no tiene disco, así que ejecuta la vista previa en el emulador (triángulo verde junto a @Preview → Run 'ReservaDeMesaPreview', como en Estado en Compose). Aparece «Mesa para 2 personas» con el chip «2» marcado. Pulsa «6»: «Mesa para 6 personas». Cierra la app desde recientes (deslízala hacia arriba) y vuelve a ejecutar la vista previa: sigue en 6, con su chip marcado. Al terminar, elige otra vez app en el selector de arriba.

Pistas: el nombre del DataStore no puede ser ajustes (ese fichero es de Marcador); dentro de un composable, el Context lo da LocalContext.current (como en la biblioteca de Room); y para guardar desde un botón hace falta un alcance de corrutinas (rememberCoroutineScope, de Más efectos: scope, derivedStateOf y SideEffect).

Ver solución
app/src/main/java/com/sebastiannarvaez/marcador/ejercicios/mesa/ReservaDeMesa.kt
package com.sebastiannarvaez.marcador.ejercicios.mesa
 
import android.content.Context
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
 
private val Context.reservas: DataStore<Preferences> by preferencesDataStore(name = "reservas")
 
private val claveComensales = intPreferencesKey("comensales")
 
@Composable
fun ReservaDeMesa(modifier: Modifier = Modifier) {
    val almacen = LocalContext.current.applicationContext.reservas
    val flujo = remember { almacen.data.map { it[claveComensales] ?: 2 } }
    val comensales by flujo.collectAsStateWithLifecycle(initialValue = 2)
    val alcance = rememberCoroutineScope()
 
    Column(modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
        Text("Mesa para $comensales personas")
        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            listOf(2, 4, 6).forEach { personas ->
                FilterChip(
                    selected = comensales == personas,
                    onClick = { alcance.launch { almacen.edit { it[claveComensales] = personas } } },
                    label = { Text("$personas") },
                )
            }
        }
    }
}
 
@Preview(showBackground = true)
@Composable
private fun ReservaDeMesaPreview() {
    MaterialTheme { ReservaDeMesa() }
}

Lo nuevo frente a lo de arriba: LocalContext.current.applicationContext da el Context dentro de un composable; remember { … } abre el Flow una sola vez, y collectAsStateWithLifecycle(initialValue = 2) lo convierte en estado con un valor mientras llega el primer dato (el mismo 2 por defecto). El edit va dentro de alcance.launch { … } porque es suspend.

Punto de control

  • En la Terminal de Android Studio, ./gradlew :app:assembleGratisDebug termina en BUILD SUCCESSFUL.
  • ./gradlew :app:dependencies --configuration gratisDebugRuntimeClasspath (con | grep datastore) lista androidx.datastore:datastore-preferences:1.2.1.
  • La reserva de mesa recuerda los comensales tras cerrar la app y volver a ejecutar la vista previa.
  • Marcador no ha cambiado.

La lección siguiente, DataStore en Marcador: la duración del partido, aplica esta preferencia al directo.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.¿Cuál es el fallo de diseño de SharedPreferences que DataStore corrige?

  2. 2.En el ejemplo de la lección, la lectura es contexto.ajustes.data.map { it[claveDuracion] ?: 90 }. ¿Qué devuelve si nadie ha guardado nunca la duración?

  3. 3.¿Por qué private val Context.ajustes … by preferencesDataStore(…) se declara una sola vez y a nivel de fichero, fuera de cualquier clase?

  4. 4.¿Qué garantiza que edit { it[claveDuracion] = minutos } sea «atómico»?

  5. 5.¿Para qué tipo de datos sirve DataStore Preferences y cuándo conviene Room?

Faltan 5 de 5