wandres.dev
DATASTORE · preferencias y proto

Qué guardar dónde: el árbol de decisión del almacenamiento

La última lección del nivel sube un piso de abstracción y pone en su sitio las cuatro opciones que compiten por cada dato de la aplicación. Establece los ejes que deciden de verdad (forma del dato, volumen, patrón de consulta, sensibilidad y duración esperada), separa las preferencias de la base de datos por la existencia o ausencia de consultas, sitúa los ficheros y la caché con su distinción entre directorios respaldados y descartables, explica qué protege realmente el Keystore y qué no protege el cifrado a nivel de fichero, e integra la copia de seguridad automática como la variable que casi nadie considera y que rompe sesiones enteras al restaurar en otro dispositivo.

⏱ 20 min

Al terminar un nivel dedicado a un mecanismo concreto existe un riesgo profesional bien documentado, que consiste en salir de él con una herramienta nueva y buscarle problemas que resolver. DataStore es excelente y es la respuesta correcta a una franja estrecha de necesidades; fuera de esa franja es una elección mediocre o directamente peligrosa. Esta lección cierra el nivel poniéndolo en su sitio, junto a las otras tres opciones que compiten por cada dato que tu aplicación decide conservar, y construyendo el criterio que permite asignar cada dato a su lugar sin discutirlo dos veces. La conclusión que importa no es una lista de tecnologías sino un método: las preguntas que hay que hacerle a un dato antes de decidir dónde vive, formuladas en un orden que hace que la respuesta se imponga sola.

🎯 Al terminar esta lección sabrás
  • Aplicar los cinco ejes que deciden el almacén: forma, volumen, patrón de consulta, sensibilidad y duración.
  • Separar preferencias de base de datos por la existencia de consultas, y no por el tamaño aparente.
  • Situar ficheros y caché con la distinción entre directorios persistentes y descartables.
  • Entender qué protege el Keystore, qué protege el cifrado del dispositivo y qué no protege ninguno de los dos.

Los cinco ejes y el árbol que producen

La conversación sobre dónde guardar algo suele empezar por la tecnología y esa es la razón por la que nunca termina. Empezar por el dato la resuelve en un minuto. Hay cinco preguntas, y la primera que dé una respuesta contundente decide.

La forma. Un valor suelto con nombre propio, un objeto con estructura, una colección de entidades relacionadas o una secuencia de bytes sin interpretación. Cada una tiene un almacén natural y forzar la correspondencia produce dolor inmediato.

El volumen. No en abstracto sino en su crecimiento: un dato que ocupa lo mismo con diez usos que con diez mil es un candidato a preferencia; uno que crece con la actividad no lo es nunca.

El patrón de consulta. La pregunta decisiva de todo el nivel es si alguna vez necesitarás buscar, filtrar, ordenar, contar o unir. Si la respuesta es sí, la discusión terminó y hace falta una base de datos.

La sensibilidad. Qué le ocurre al usuario si otra aplicación, otra persona con el dispositivo en la mano o un análisis del sistema de ficheros lee ese dato.

La duración. Recuperable en cualquier momento desde el servidor, o irremplazable si se pierde. Esa distinción decide además si el dato pertenece a un directorio que el sistema puede vaciar.

El orden de las preguntas no es arbitrario y conviene respetarlo. La del patrón de consulta va primero porque es la única que descarta opciones de forma absoluta: ninguna cantidad de ingenio convierte un almacén sin índices en algo consultable. La de sensibilidad va después porque impone requisitos que se suman a cualquier elección posterior en lugar de sustituirla. Y la del volumen va al final, contra el instinto de casi todo el mundo, porque es la que más engaña: el tamaño de hoy no dice nada sobre el tamaño dentro de dos años, mientras que la forma del dato no cambia nunca.

flowchart TD
A[Un dato nuevo] --> B{Hace falta consultar filtrar u ordenar}
B -->|si| C[Base de datos con Room]
B -->|no| D{Es un bloque opaco o multimedia}
D -->|si| E{Se puede volver a descargar}
E -->|si| F[Directorio de cache]
E -->|no| G[Directorio de ficheros]
D -->|no| H{Compromete al usuario si se filtra}
H -->|si| I[Keystore o cifrado con clave del Keystore]
H -->|no| J{Tiene estructura e invariantes}
J -->|si| K[Proto DataStore]
J -->|no| L[Preferences DataStore]
style C fill:#89b4fa,color:#11111b
style L fill:#a6e3a1,color:#11111b
style I fill:#f38ba8,color:#11111b

Preferencias y base de datos: la frontera es la consulta

El error más común de todo el terreno es elegir por tamaño. Se piensa que las preferencias son para lo pequeño y la base de datos para lo grande, y con ese criterio se acaba guardando una lista de veinte favoritos serializada como cadena dentro de una preferencia, porque veinte parece poco. Es una elección equivocada y el motivo no tiene nada que ver con los veinte elementos.

DataStore lee y escribe el fichero entero. No existe la lectura parcial, no existe el índice, no existe la modificación de un elemento sin reescribir el conjunto. Cada consulta a esa lista de favoritos exige deserializar los veinte, y cada alta o baja exige reescribir los veinte. Con veinte no se nota; con doscientos se nota; y lo que garantiza el problema no es el número actual sino que el número dependa del uso, porque eso significa que existe un usuario para el que será grande.

Además, en cuanto alguien pida buscar entre los favoritos, o mostrarlos ordenados por fecha, o contar cuántos hay de una categoría, habrá que escribir a mano en Kotlin lo que una base de datos hace con una cláusula. Y ese código a mano se ejecutará sobre la colección completa cargada en memoria.

⚙️

DataStore

Estado de configuración. Un conjunto acotado de valores que el usuario o la aplicación fijan, se leen enteros y no se consultan por criterio.

🗃️

Room

Datos del dominio. Colecciones de entidades con relaciones, que crecen con el uso, que se consultan, se ordenan y se paginan.

Existe un tercer síntoma que delata la elección equivocada mucho antes de que aparezcan los problemas de rendimiento, y es la aparición de estructuras serializadas dentro de un valor de texto. En cuanto alguien guarda una cadena separada por comas, o un JSON embutido en una preferencia, ha construido una base de datos rudimentaria dentro de un almacén que no sabe que la contiene. Todo lo que venga después (la validación, la migración de formato, la reparación de un valor mal escrito) tendrá que implementarse a mano sobre un texto opaco que ninguna herramienta sabe inspeccionar.

// senal inequivoca de que el dato esta en el sitio equivocado
prefs[stringPreferencesKey("favoritos")] = ids.joinToString(",")

Hay un caso intermedio legítimo que conviene reconocer para no aplicar la regla con rigidez: un conjunto pequeño y acotado por diseño, como los identificadores de las tres pestañas que el usuario ha ocultado, cabe perfectamente en una preferencia. La diferencia con los favoritos no es el tamaño sino que existe un límite estructural que no depende del comportamiento del usuario.

💡
La prueba que resuelve la duda en diez segundos

Formula en voz alta la operación más compleja que harás con ese dato dentro de un año. Si en esa frase aparecen las palabras buscar, filtrar, ordenar, los últimos, cuántos o por página, ya has elegido base de datos aunque hoy tengas tres elementos.

Ficheros, caché y lo que el sistema puede borrar

Para bytes sin interpretación (imágenes, documentos, modelos descargados, ficheros que el usuario importó) el destino natural es el sistema de ficheros del almacenamiento interno de la aplicación, y la decisión relevante no es dónde ponerlos sino en cuál de los dos directorios.

El directorio de ficheros contiene lo que la aplicación considera suyo y necesita conservar. El sistema no lo toca. El directorio de caché contiene lo que se puede volver a obtener, y el sistema lo vacía sin preguntar cuando el almacenamiento escasea, además de que el usuario puede borrarlo desde los ajustes con un botón. Colocar en la caché algo irremplazable es una avería futura garantizada, y colocar en el directorio de ficheros todo lo descargable es la razón por la que ciertas aplicaciones ocupan varios gigabytes que nadie sabe recuperar.

Conviene añadir que la caché no se autogestiona por el hecho de estar en ese directorio. El sistema la vacía cuando el almacenamiento aprieta, pero eso ocurre tarde y de golpe; una caché sana necesita además su propia política de expulsión por tamaño o por antigüedad, que la aplicación aplica por su cuenta. Delegar la limpieza exclusivamente en el sistema produce aplicaciones que aparecen en la lista de las que más ocupan hasta el día en que el terminal se queda sin espacio.

La regla es directa: si perderlo obliga a volver a pedirlo a la red y eso es aceptable, es caché. Si perderlo destruye trabajo del usuario, es fichero. Y en ambos casos, lo que se guarda en disco suele necesitar una entrada correspondiente en la base de datos con su ruta y sus metadatos, porque el sistema de ficheros no sabe responder preguntas.

// descargable de nuevo: el sistema puede vaciarlo
val miniatura = File(context.cacheDir, "thumb_$id.webp")

// documento que el usuario importo: no se puede perder
val documento = File(context.filesDir, "docs/$id.pdf")

Queda un tercer destino que no pertenece a la aplicación y que conviene distinguir con claridad: el almacenamiento compartido, donde viven las fotos, los vídeos y los documentos que el usuario espera encontrar desde cualquier aplicación. Ahí no se escribe con rutas sino a través del proveedor de contenidos del sistema, y la propiedad del fichero deja de ser tuya: sobrevive a la desinstalación y otras aplicaciones pueden verlo. La regla de asignación es la intención del usuario, no la comodidad del programador. Si el usuario piensa que ese fichero es suyo, va al almacenamiento compartido; si es un artefacto interno de tu aplicación, va a tu directorio privado y desaparece cuando se desinstala, que es exactamente lo que él espera que ocurra.

Hay además una consideración de coste que se ignora hasta que llega una reseña quejándose del espacio ocupado. El sistema muestra al usuario un desglose entre datos de la aplicación y caché, y le ofrece un botón para vaciar la segunda. Una aplicación que guarda sus descargables en el directorio de datos convierte ese botón en inútil y obliga al usuario a elegir entre desinstalar o quedarse sin espacio. Colocar bien la frontera es, literalmente, la diferencia entre un problema que el usuario puede resolver solo y uno que solo puede resolver borrándote.

Secretos: qué protege el Keystore y qué no

El último ramal del árbol es el de los datos cuya filtración perjudica al usuario, y aquí conviene desmontar dos creencias antes de dar cualquier recomendación.

La primera es que el almacenamiento interno de una aplicación ya es privado y en un dispositivo no manipulado ninguna otra aplicación puede leerlo. Eso es cierto y es la base de todo lo demás: el modelo de aislamiento de la plataforma, sostenido por identificadores de usuario distintos por aplicación, es la protección principal y no un complemento. La segunda es que el cifrado del dispositivo protege los datos en reposo cuando el terminal está apagado o bloqueado antes del primer desbloqueo, y no protege absolutamente nada frente a código que se ejecuta con tus permisos mientras el dispositivo está en uso.

De ahí se sigue qué es y qué no es el Keystore. Es un almacén de claves criptográficas respaldado por hardware seguro, cuya propiedad esencial es que la clave nunca sale de él: se pide una operación de cifrado o descifrado y se recibe el resultado, pero el material de la clave es inextraíble incluso para un atacante con acceso completo al sistema de ficheros. Admite además condicionar su uso a la autenticación del usuario, de modo que una clave solo funcione tras una huella o un patrón.

Lo que no es: un sitio donde guardar datos. En el Keystore van claves, no contenidos. El patrón correcto para un dato sensible de tamaño apreciable es cifrarlo con una clave que vive en el Keystore y guardar el resultado cifrado en tu almacén habitual, sea un fichero o una base de datos.

// la clave se genera una vez y jamas sale del hardware seguro
val spec = KeyGenParameterSpec.Builder("clave_sesion",
        KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
    .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
    .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
    .setUserAuthenticationRequired(true)     // exige huella o patron para usarla
    .build()

El parámetro de autenticación obligatoria es el que aporta la protección más fuerte y también el que más consecuencias tiene sobre el diseño, porque convierte cada lectura del secreto en una interacción con el usuario. Activarlo para un testigo que se consulta en cada petición de red produce una aplicación insufrible; activarlo para el desbloqueo de un almacén de credenciales es exactamente lo correcto. La decisión pertenece al producto y no a la capa de datos, y conviene tomarla explícitamente en lugar de heredarla de un ejemplo copiado.

Conviene también situar el papel de las bibliotecas de preferencias cifradas que muchos proyectos adoptaron por costumbre. Resuelven el caso concreto de cifrar valores pequeños con una clave del Keystore sin escribir el código a mano, y para eso sirven; lo que no hacen es convertir en seguro un diseño que guardaba lo que no debía guardar. Antes de envolver un secreto en una capa de cifrado merece la pena agotar la pregunta anterior, que es si ese secreto tiene que estar en el dispositivo.

Y hay una advertencia final que ahorra incidentes reales: si el dato es un secreto que pertenece al servidor, la mejor decisión suele ser no guardarlo. Un testigo de acceso de vida corta que se renueva al arrancar es estructuralmente más seguro que cualquier esquema de cifrado local, porque un dato que no está no se puede robar.

⚠️
La copia de seguridad automática es un canal de salida que casi nadie mira

Por omisión el sistema respalda en la nube el contenido de tus preferencias, tu base de datos y tus ficheros, y lo restaura en un dispositivo distinto. Eso significa que un secreto cifrado con una clave del Keystore viaja al terminal nuevo mientras la clave no lo hace, produciendo un dato indescifrable que la aplicación interpretará como corrupción. También significa que una sesión puede restaurarse en un dispositivo que no es el original. Excluir explícitamente de la copia los ficheros con material cifrado y los testigos de sesión no es una optimización: es parte de la decisión de dónde vive cada dato.

El almacén no es una elección técnica sino la declaración de qué significa cada dato

Conviene cerrar el nivel con la observación que ordena todo lo anterior y que se pierde si uno se queda en la comparación entre bibliotecas. Cada vez que decidimos dónde guardar algo estamos respondiendo, sin darnos cuenta, a cuatro preguntas que no son técnicas en absoluto: quién produjo este dato, quién puede volver a producirlo, cuánto vale para el usuario si se pierde y a quién perjudica si se filtra. Las respuestas a esas cuatro preguntas determinan el almacén de manera prácticamente unívoca, y por eso la discusión sobre tecnologías se vuelve corta en cuanto se han contestado y se vuelve interminable cuando se saltan. Un equipo que debate durante una hora si los favoritos van en preferencias o en base de datos no tiene un problema de arquitectura de almacenamiento: tiene sin decidir si los favoritos son configuración del usuario o datos del dominio, y esa es una pregunta sobre el producto que ninguna biblioteca puede responder. La segunda mitad del argumento es la que separa a los sistemas que envejecen bien de los que se degradan, y tiene que ver con lo que ocurre después de la decisión. Un dato colocado en el almacén equivocado no produce un fallo inmediato; produce una presión constante y silenciosa sobre todo lo que se construya encima. La lista que vive en una preferencia empieza a necesitar orden y alguien lo implementa en memoria. Después necesita búsqueda y alguien recorre la colección completa. Después necesita paginación y ahí el edificio se detiene, porque paginar exige exactamente lo que se renunció a tener al elegir el almacén. En ese punto el coste de la migración ya no es el que habría sido al principio, sino el de reescribir todas las capas que se apoyaron en la decisión original. De ahí la conclusión práctica que merece la pena llevarse del nivel entero, y que vale mucho más que cualquier detalle de API: el almacenamiento es la decisión más barata de tomar bien al principio y la más cara de corregir después, porque es la única que queda congelada en dispositivos que no controlas. Piensa en el dato, no en la biblioteca; pregúntate qué es antes de preguntarte dónde va; y cuando dudes entre dos opciones, elige la que te dejará más caminos abiertos dentro de dos años, que casi siempre es la que hoy parece un poco excesiva.

⚔️ Audita el almacenamiento entero de tu aplicación
  1. Inventaría todo lo que tu aplicación persiste hoy y clasifica cada elemento respondiendo a los cinco ejes, sin mirar todavía dónde está guardado.
  2. Compara tu clasificación con la realidad y localiza al menos un dato colocado en el almacén equivocado. Estima el coste de moverlo hoy y dentro de dos años.
  3. Busca colecciones que crezcan con el uso guardadas fuera de la base de datos y mide el tiempo de lectura con diez, cien y mil elementos.
  4. Revisa el directorio de caché y el de ficheros y comprueba que nada irremplazable está en el primero ni nada descargable ocupa el segundo.
  5. Enumera los datos sensibles, verifica cuáles viajan en la copia de seguridad automática y decide para cada uno si debe excluirse, cifrarse con clave del Keystore o directamente no guardarse.