wandres.dev
DATASTORE · preferencias y proto

Preferences DataStore: claves tipadas, flujo y escritura transaccional

La primera de las dos caras de DataStore conserva el modelo mental de las preferencias sueltas y sustituye por completo su maquinaria. Esta lección construye la API desde sus invariantes: la instancia única por fichero y por proceso y por qué violarla lanza una excepción deliberada, las claves tipadas que eliminan la ambigüedad de las cadenas, la lectura como `Flow` que emite el valor actual y cada cambio posterior, la escritura como función suspendida y transaccional cuyo bloque puede leer el estado previo, la serialización del acceso que convierte la concurrencia en un problema resuelto, y el manejo obligatorio de errores de lectura mediante la captura de la excepción de entrada y salida que la API sí se molesta en exponer.

⏱ 20 min

La forma más rápida de entender Preferences DataStore es mirarlo como lo que realmente es: la misma idea de un mapa de claves y valores, reimplementada por gente que ya sabía todo lo que la implementación anterior había hecho mal. No hay conceptos nuevos que aprender sobre qué se guarda, solo una revisión completa de cómo se guarda y de qué se le cuenta al programador mientras ocurre. El cambio de superficie parece pequeño, apenas un par de tipos y una firma con suspensión, pero cada elemento de esa superficie corresponde a una garantía concreta: el valor que lees está actualizado siempre, la escritura que lanzas ha terminado cuando la llamada devuelve, la lectura y la modificación son una sola operación indivisible, y si el disco falla te vas a enterar. Ninguna de esas cuatro frases se podía decir antes.

🎯 Al terminar esta lección sabrás
  • Crear y compartir correctamente una instancia de DataStore, entendiendo por qué debe existir una sola por fichero.
  • Definir claves tipadas y leer con Flow, aplicando el valor por defecto en el punto adecuado.
  • Escribir con edit comprendiendo su naturaleza suspendida, transaccional y de lectura y modificación conjunta.
  • Manejar los errores de lectura sin enmascararlos, distinguiendo la corrupción del fallo transitorio.

Una sola instancia, y el motivo por el que lo exige

La primera regla no es estilística sino de corrección. Un DataStore mantiene en memoria el estado actual del fichero y un mecanismo de exclusión que serializa todas las operaciones sobre él. Si existieran dos instancias apuntando al mismo fichero dentro del mismo proceso, cada una tendría su propia copia y su propio cerrojo, y ninguna sabría de las escrituras de la otra: exactamente el problema que hacía inservible el modo multiproceso de las preferencias clásicas. Por eso la biblioteca detecta la duplicación y lanza una excepción en lugar de dejar que el defecto viva silenciosamente.

La forma idiomática es un delegado de propiedad sobre el contexto, declarado una única vez en el ámbito del fichero.

val Context.ajustes: DataStore<Preferences> by preferencesDataStore(name = "ajustes")

Ese delegado construye la instancia de forma perezosa la primera vez que se accede y la conserva mientras viva el proceso. En una aplicación con inyección de dependencias la alternativa es proveerla como singleton en el grafo, que es preferible porque hace explícita la unicidad en lugar de confiarla a la disciplina de no volver a llamar al delegado. Lo que nunca debe hacerse es construirla dentro de una función, de un ViewModel o de un repositorio instanciado varias veces.

⚠️
El fichero no es el que crees

El nombre que se pasa no es una ruta sino un identificador; la biblioteca lo resuelve a un fichero con extensión propia dentro del directorio privado. Eso importa a la hora de migrar y a la hora de borrar datos: un fichero de preferencias clásico con el mismo nombre y un DataStore con ese nombre son artefactos distintos que coexisten sin enterarse el uno del otro.

Claves tipadas y lectura como flujo

Las claves dejan de ser cadenas sueltas y pasan a ser objetos con tipo asociado, construidos por funciones específicas para cada tipo admitido: enteros, largos, booleanos, flotantes, dobles, cadenas y conjuntos de cadenas. El tipo viaja con la clave, de modo que el compilador impide leer como booleano lo que se escribió como entero. Es una mejora modesta en apariencia y decisiva en la práctica, porque elimina una familia entera de fallos que antes solo se manifestaban en ejecución.

private object Claves {
    val TEMA = stringPreferencesKey("tema")
    val NOTIFICA = booleanPreferencesKey("notificaciones")
    val TAMANO = intPreferencesKey("tamano_fuente")
}

La cadena que se pasa al construir la clave es la que acaba escrita en el fichero, así que cambiarla equivale a perder el valor guardado. Conviene fijarla una vez y no volver a tocarla nunca, aunque el nombre de la constante que la contiene evolucione con el dominio.

Agrupar las claves en un objeto propio no es adorno: es lo que impide que la misma cadena literal aparezca escrita de dos maneras distintas en dos módulos, que es el segundo fallo clásico del modelo anterior.

La lectura es un flujo. La propiedad data emite el contenido completo cada vez que cambia, empezando por el valor actual en el momento de la suscripción. De ese contenido se extrae cada clave, aplicando el valor por defecto en el punto de extracción, que es donde debe estar porque es donde se conoce el significado del dato.

val tema: Flow<Tema> = context.ajustes.data
    .map { prefs -> Tema.desde(prefs[Claves.TEMA] ?: Tema.SISTEMA.nombre) }
    .distinctUntilChanged()

Repara en dos decisiones del ejemplo. La primera es que el valor por defecto se aplica al leer, nunca escribiéndolo en el fichero al arrancar; escribir los valores por defecto es un antipatrón que impide distinguir entre no configurado y configurado con ese valor, y que además hace imposible cambiar el valor por defecto en una versión futura. La segunda es distinctUntilChanged, necesario porque cualquier escritura sobre cualquier clave provoca una emisión del contenido entero, y sin ese filtro una pantalla que observa el tema se recompone cada vez que alguien toca un ajuste que no le concierne.

🔑

La clave lleva el tipo

stringPreferencesKey y sus hermanas producen claves que solo aceptan un tipo. El error migra de la ejecución a la compilación.

🌊

La lectura es continua

No se consulta un valor: se observa. Quien lo consume recibe el estado inicial y todos los cambios sin registrar ni desregistrar nada.

Escribir es una transacción, no una asignación

La escritura se hace con edit, una función suspendida que recibe un bloque con una copia mutable del contenido actual y que devuelve cuando el resultado está persistido en disco. Tres propiedades convergen en esa firma y conviene enunciarlas por separado.

Es suspendida, de modo que el trabajo de entrada y salida ocurre fuera del hilo principal por construcción y no por disciplina. No hay ninguna versión que bloquee, lo cual es una decisión de diseño deliberada: la API se niega a ofrecer el atajo que causó el problema anterior.

Es transaccional. El bloque completo se aplica o no se aplica; no existe el estado intermedio donde una clave se escribió y otra no. Eso resuelve el problema de los valores que solo tienen sentido juntos sin ninguna ceremonia adicional.

Y es de lectura y modificación conjuntas. Dentro del bloque se puede consultar el valor previo y calcular el nuevo a partir de él, con la garantía de que ninguna otra escritura se cuela entre la lectura y la escritura porque el acceso está serializado por un mecanismo de exclusión interno. El contador que se incrementa, el caso canónico donde el modelo anterior perdía actualizaciones bajo concurrencia, aquí es correcto sin esfuerzo.

suspend fun registrarApertura() {
    context.ajustes.edit { prefs ->
        val previo = prefs[Claves.APERTURAS] ?: 0
        prefs[Claves.APERTURAS] = previo + 1          // atomico frente a otras escrituras
        prefs[Claves.ULTIMA] = System.currentTimeMillis()
    }
}
sequenceDiagram
participant UI as Interfaz
participant DS as DataStore
participant D as Disco
UI->>DS: edit con bloque de transformacion
DS->>DS: toma el cerrojo y lee el estado actual
DS->>DS: aplica el bloque sobre una copia mutable
DS->>D: escribe en fichero temporal y renombra
D-->>DS: escritura confirmada
DS-->>UI: la funcion suspendida retorna
DS-->>UI: el flujo data emite el nuevo contenido

La escritura en disco se hace sobre un fichero temporal que después se renombra, lo cual convierte la operación en atómica a ojos del sistema de ficheros: o existe el contenido viejo completo o existe el nuevo completo, nunca una mezcla. Esa es la diferencia entre perder una preferencia y corromper el fichero entero cuando el proceso muere a mitad de la escritura.

Conviene señalar un matiz de coste que la simplicidad de la API oculta. Como cada edit reescribe el fichero completo y espera a que la escritura se confirme, encadenar veinte llamadas consecutivas produce veinte escrituras a disco. El patrón correcto cuando varios valores cambian a la vez es agruparlos en un único bloque, no porque quede más elegante sino porque es la diferencia entre una operación y veinte. Y para el caso de un control que emite muchos cambios seguidos, como un deslizador, lo razonable es filtrar en origen con un retardo antes de persistir, dejando que la interfaz refleje el estado transitorio desde su propio estado local.

Queda una consecuencia que sorprende a quien viene del modelo anterior: como edit es suspendida y solo retorna cuando el dato está en disco, escribir dentro de un manejador de pulsación exige un ámbito de corrutina. Lo correcto es que la escritura viva en el repositorio y se lance desde el ámbito del ViewModel, nunca desde uno atado a la composición, porque el usuario puede abandonar la pantalla justo después de tocar el interruptor.

💡
Una escritura que sobrevive a la pantalla

Si la operación debe completarse aunque el usuario salga de inmediato, el ámbito del ViewModel tampoco basta cuando la pantalla se cierra por completo. Para esos casos el patrón es un ámbito de aplicación con supervisión, reservado exclusivamente a escrituras cortas e idempotentes. No es una excusa para lanzar todo desde ahí: es el reconocimiento de que ciertas escrituras pertenecen a la sesión y no a la pantalla.

Los errores existen y hay que decidir qué hacer con ellos

Aquí es donde la nueva API se separa de forma más visible de la antigua. El flujo de lectura puede emitir un error, y si nadie lo trata, ese error se propaga hasta el recolector y derriba la corrutina que estaba observando. La biblioteca declara explícitamente el tipo que puede aparecer cuando falla la lectura del fichero, y el tratamiento mínimo aceptable es capturarlo y decidir.

val ajustes: Flow<Ajustes> = context.ajustes.data
    .catch { e ->
        if (e is IOException) emit(emptyPreferences())   // arrancamos con valores por defecto
        else throw e                                     // un fallo de programacion no se traga
    }
    .map { prefs -> Ajustes.desde(prefs) }

La distinción del ejemplo es la parte que más se copia mal. Un fallo de entrada y salida es una condición del entorno: el fichero no se pudo leer en este arranque concreto. Recuperarse emitiendo preferencias vacías es razonable porque el usuario prefiere una aplicación con los ajustes por defecto a una aplicación que no abre. Cualquier otra excepción indica un error del programa, y tragárselo convierte un defecto reproducible en un comportamiento errático imposible de diagnosticar. Escribir catch { emit(emptyPreferences()) } sin comprobar el tipo es el atajo que hay que evitar.

Hay un segundo nivel de tratamiento para la corrupción persistente. Si el fichero está dañado, cada arranque reproducirá el mismo fallo y el usuario quedará atrapado con sus ajustes inaccesibles para siempre. La biblioteca admite un manejador de corrupción que se invoca en ese caso y cuyo cometido es producir un contenido de reemplazo, tras lo cual el fichero se reescribe y el problema desaparece. Configurarlo es la diferencia entre un incidente y una avería permanente en el dispositivo de alguien.

val Context.ajustes: DataStore<Preferences> by preferencesDataStore(
    name = "ajustes",
    corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
)
Exponer el fallo no es pesimismo, es devolver el control a quien puede decidir

Merece la pena detenerse en lo que significa que esta API obligue a escribir catch donde la anterior no ofrecía ni el lugar donde ponerlo, porque la diferencia parece burocrática y es exactamente lo contrario. Persistir un dato es una operación que atraviesa la frontera entre el programa y el mundo, y el mundo incluye almacenamientos llenos, sistemas de ficheros con errores, procesos que mueren a mitad de una escritura y usuarios que restauran copias de seguridad entre dispositivos con configuraciones distintas. Ninguna API puede eliminar esas condiciones; lo único que puede decidir es si las menciona. Cuando las oculta, no está protegiendo al programador de la complejidad, está tomando por él una decisión que no le corresponde: la de qué hacer cuando el dato no está. Y esa decisión no es genérica en absoluto, porque depende por completo del significado del dato. Si lo que no se pudo leer es la preferencia de tema, arrancar con el tema del sistema es obviamente correcto y el usuario ni se entera. Si lo que no se pudo leer es el testigo que indica que el usuario aceptó las condiciones de uso, tratarlo como no aceptado significa volver a mostrarle una pantalla legal que ya había completado, lo cual es molesto pero seguro. Y si lo que no se pudo leer es el registro de que ya se le cobró una suscripción, cualquier valor por defecto que elijas está mal y lo correcto es no adivinar sino consultar al servidor. Tres datos que viven en el mismo fichero y tres respuestas incompatibles ante el mismo fallo: eso es precisamente lo que ninguna biblioteca puede decidir en tu lugar, y es la razón por la que la única postura defendible es hacer el fallo visible en el punto donde alguien conoce el significado. La incomodidad de tener que escribir el manejo cada vez no es un defecto de ergonomía que la biblioteca no supo pulir. Es la señal de que se te ha devuelto una decisión que siempre fue tuya y que durante quince años estuviste tomando sin saberlo, casi siempre de la peor manera posible, que es fingir que el fallo no ocurrió.

⚔️ Construye una capa de ajustes que no mienta
  1. Declara una única instancia mediante inyección de dependencias y demuestra con un test que crear una segunda sobre el mismo fichero falla.
  2. Modela tres ajustes con claves tipadas y expón un único flujo de estado de la pantalla, comprobando con registros que distinctUntilChanged reduce las emisiones.
  3. Escribe dos claves interdependientes dentro de un mismo edit y verifica que no existe estado intermedio matando el proceso durante la escritura.
  4. Implementa un contador con lectura y modificación conjuntas, lánzalo desde cien corrutinas simultáneas y comprueba que el resultado final es exacto.
  5. Corrompe deliberadamente el fichero, observa el fallo sin manejador, y después instala el manejador de corrupción y contrasta el comportamiento en el siguiente arranque.