Jetpack Datastore es una solución de almacenamiento de datos que permite almacenar pares clave-valor (de forma similar a SharedPreferences
) u objetos escritos en búferes de protocolo (también con objetos serializables, hablaremos de ello) apoyándose en el uso de Corrutinas y Flow de Kotlin para hacerlo de manera asíncrona, coherente y transaccional.
Para qué y por qué Datastore
Datastore
ofrece dos implementaciones diferentes, Preferences y Proto:
- Preferences. Almacena y accede a datos mediante claves sin requerir un esquema predefinido, por lo que no asegura la seguridad de tipo.
- Proto. Almacena los datos como instancias de un tipo personalizado de datos mediante la definición de un esquema con búferes de protocolo, proporcionando seguridad de tipo.
Debemos tener claro que Datastore
no sirve para conjuntos de datos grandes o complejos en los que sean necesarios actualizaciones parciales o integridad referencial, para ello ya existe Room
.
En la siguiente tabla comparativa podemos apreciar las diferencias entre cada implementación con respecto a SharedPreferences
:
Funcionalidad | SharedPreferences | Datastore Preferences | Datastore Proto |
API asíncrona | Sí (sólo para leer cambio de valores a través de Listener) | Sí (vía Flow) | Sí (vía Flow) |
API síncrona | Sí (pero no es seguro llamar en hilo de UI) | No | No |
Es seguro llamarla en hilo de UI | No | Sí (trabajo se mueve a Dispatchars.IO) | Sí (trabajo se mueve a Dispatchars.IO) |
Puede indicar errores | No | Sí | Sí |
Seguro contra excepciones en tiempo de ejecución | No | Sí | Sí |
API transaccional con garantías de coherencia sólida | No | Sí | Sí |
Maneja migraciones de datos | No | Sí (desde SharedPreferences) | Sí (desde SharedPreferences) |
Seguridad de tipos | No | No | Sí (con búferes de protocolo) |
Quizás por su familiaridad, la API síncrona de SharedPreferences
parece segura llamando al hilo de UI, pero en realidad realiza operaciones de E/S de disco, existiendo la posibilidad de bloquearlo e incluso generar ANRs. Otro problema es que los errores de parseo se lanzan como excepciones en tiempo de ejecución, lo que dificultan su identificación. Datastore
por el contrario guarda las preferencias y realiza todas sus operaciones en Dispatchers.IO por defecto.
Almacena pares clave-valor con Datastore Preferences
Esta implementación usa las clases Datastore
y Preferences
para conservar pares clave-valor simples en el disco. Necesitamos en primer lugar configurar las dependencias necesarias en nuestro archivo Gradle
dependencies{ implementation "androidx.datastore:datastore:$version" implementation "androidx.datastore:datastore-preferences:$version" }
Vamos a crear nuestro primer Preference Datastore. Para ello, usaremos el delegado PreferenceDataStore
para crear una instancia de Datastore
indicando el nombre de nuestras preferencias.
val Context.dataStore: DataStoreby preferencesDataStore(filename)
Para leer una preferencia de dicho DataStore
, al no usar un esquema predefinido, debemos usar la función correspondiente a cada tipo de valor almacenable para definir su clave. Por ejemplo, si el valor es un entero, tendremos que usar la función intPreferencesKey()
. Expondremos el valor almacenado mediante un Flow
con la propiedad DataStore.data
.
fun getInteger(key: String, defaultValue: Int): Flow{ return context.dataStore.data.map{ preferences -> preferences[intPreferencesKey(key)] ?: defaultValue } }
Los tipos que podemos usar además de Int
son Boolean
, Double
, Float
, Long
, String
y Set
, cada uno con su función auxiliar correspondiente.
Para escribir dicho valor entero, tendremos que hacer uso de la función edit()
que realiza la actualización de forma transaccional, cuyo parámetro transform
es un bloque de código donde actualizar los valores según se necesite de una transacción.
suspend fun putInteger(key: String, value: Int) { context.dataStore.data.edit{ preferences -> preferences[intPreferencesKey(key)] = value } }
Migración desde SharedPreference hacia Datastore Preferences
Lo más probable es que partamos de un escenario en el que ya tenemos nuestras preferencias guardadas y queramos migrar a esta nueva solución. Datastore
nos lo pone bastante fácil.
Imaginemos que tenemos una implementación de SharedPreferences donde el nombre del archivo de guardado es SHARED_FILE_NAME
val sharedPreferences = context.getSharedPreferences(SHARED_FILE_NAME, Context.MODE_PRIVATE)
Lo único que tendríamos que pasar a nuestro Datastore
es un SharedPreferenceMigration
indicando cómo realizar el proceso.
val Context.dataStore: DataStoreby preferencesDataStore( name = DATASTORE_FILE_NAME, migrations = listOf(SharedPreferencesMigration(context, SHARED_FILE_NAME)), )
De esta manera, Datastore
realizará automáticamente la migración de manera segura, antes de que ocurra cualquier acceso a datos. Así pues, Datastore.data
no devolverá valores de preferencias o no será posible actualizar datos con Datastore.updateData()
hasta bien acabadas las migraciones.
Por esto, es deseable gestionar la emisión de excepciones por si se produce algún error a través del bloque catch
:
fun getInteger(key: String, defaultValue: Int): Flow{ return context.dataStore.data .catch{ exception -> if (exception is IOException){ emit(emptyPreferences()) } else { throw exception } } .map{ preferences -> preferences[intPreferencesKey(key)] ?: defaultValue } }
Si se diera el caso de ocurrir un error de lectura de la preferencia, haciendo uso de la función auxiliar emptyPreferences()
emitiremos unas preferencias vacías.
¿Protoqué? Persistencia de datos estructurados con Datastore
La implementación de Datastore Proto
se fundamenta en la definición de un esquema con el serializar y persistir datos fuertemente tipados, usando Protocol Buffers, conocido también como Protobuf.
Presentamos Protobuf
Este formato pretende ser un mecanismo extensible, independiente del lenguaje de programación y la plataforma dónde se use para serializar datos estructurados siendo compatible con versiones anteriores y posteriores del propio formato.
Profobuf es el formato más utilizado por Google para diferentes cometidos: comunicación entre servidores, almacenamiento de datos en disco, registros, etc. Algunas ventajas frente a otras formas de serialización son las siguientes:
- Almacenamiento de datos compactos
- Parseo rápido
- Disponible en diferentes lenguajes de programación
- Optimizado a través de las clases generadas automáticamente.
Actualmente, el lenguaje de declaración va por la versión proto3
, veamos como declarar un Team
de una lista de Developer
usando las características básicas del protocolo (no olvidaros de los ;
de cierre de línea).
// Indicamos la versión del protocolo syntax = "proto3"; // Paquete dónde se generaran las clases Java option java_package = "es.alt10.android.poc.datastore"; option java_multiple_files = true; // Podemos importar tipos predefidos o de otros archivo .proto import 'google/protobuf/timestamp.proto'; // Definimos el mensaje Developer message Developer { // Un campo es un par clave-valor único, con un tipo específico string name = 1; // Los valores de los campos de un mensaje van del 1 en adelante int64 id = 2; bool teleworking = 3; // Podemos definir enumerados enum ExperienceType{ // El valor en un enumerado empieza en 0 en vez de en 1 como en los mensajes JUNIOR = 0; MEDIUM = 1; SENIOR = 2; } // Mensajes internos message Experience { int32 years = 1; ExperienceType type = 2; } // Un mensaje puede ser el tipo de un campo Experience experience = 4; enum DeviceType { MOBILE = 0; TABLET = 1; LAPTOP = 2; PC = 3; } message Device { int32 id = 1; DeviceType type = 2; } // Un campo puede ser // * singular (cero o un valor) // * repeated (puede tener de 0 a múltiples valores)` repeated Device device = 5; // Uso de tipo importado para un campo google.protobuf.Timestamp last_updated = 6; // Recomiendo definir el campo en singular, ya que las funciones generadas de acceso serán // getTechnologyList(), getTechnology(int index) y getTechnologyCount() entre otras repeated string technology = 7; } message Team{ repeated Developer developer = 1; }
Usemos Protobuf en Android
En primer lugar, configuramos las dependencias necesarias para usar Datastore Proto
, así como aplicar el plugin de Protobuf
y la tarea de generación de clases en el archivo build.gradle
del módulo app
:
plugins{ id "com.google.protobuf" version "protobuf-plugin-version" } dependencies{ //DataStore implementation "androidx.datastore:datastore:$datastore-version" //Proto DataStore implementation "com.google.protobuf:protobuf-javalite:$protobuf-java.version" implementation "androidx.datastore:datastore-core:$datastore-version" } protobuf{ protoc { artifact = "com.google.protobuf:protoc:$protobuf-java.version" } generateProtoTasks { all().each { task -> task.builtins { java { option 'lite' } } } } }
Una vez configurado y sincronizado, podemos hacer uso del archivo developers_team.proto
alojándolo en el directorio correspondiente app/src/main/proto
y volviendo a compilar el proyecto para que genere los archivos .java
correspondientes a la definición, generados en app/build/generated/source/proto
Ya tenemos el esquema definido y las clases generadas, tendremos que seguir los dos pasos básicos para generar un Proto Datastore
:
- Hay que declarar una clase que implemente la interfaz
Serializer
, dondeT
es el mensaje principal definido en nuestro archivo.proto
(en nuestro casoTeam
). Con ella podremos decirle a Datastore cómo leer y escribir el tipo de datos de nuestro fichero de preferencias.import androidx.datastore.core.Serializer // Implementamos la interfaz Serializer de Datastore object TeamSerializer : Serializer
{ // Se sobreescribe la propiedad defaultValue override val defaultValue: Team get() = Team.getDefaultInstance() // La función de lectura @Suppress("BlockingMethodInNonBlockingContext") override suspend fun readFrom(input: InputStream): Team { try { return Team.parseFrom(input) } catch (exception: Exception) { Log.e(TeamSerializer::javaClass.name, "Cannot read proto. " + exception.message) throw CorruptionException("Cannot read proto.", exception) } } // Así como la función de escritura @Suppress("BlockingMethodInNonBlockingContext") override suspend fun writeTo(t: Team, output: OutputStream) { try { t.writeTo(output) } catch (exception: IOException) { Log.e(TeamSerializer::javaClass.name, "Cannot write proto. " + exception.message) } } } - Usar el delegado de propiedad
dataStore
para crear una instancia deDataStore
indicando el nombre del archivo donde persistir los datos en el parámerofilename
y en el parámetroserializer
la clase encargada de la serialización.val Context.teamProtoStore: DataStore
by dataStore( filename = TEAM_FILE_NAME, serializer = TeamSerializer, )
A partir de aquí, ya podemos leer los miembros de un equipo:
fun getTeam(): Flow= // data expone un Flow con el objeto almacenado context.teamProtoStore.data // Con catch manejamos las posibles excepciones de lectura .catch { exception -> if (exception is IOException) { emit(Team.getDefaultInstance()) } else { throw exception } }
Y añadir por supuesto nuevos miembros:
suspend fun addDeveloper(developer: Developer) { /* Dentro del bloque transform de la función * updateData podremos modificar * el equipo de manera transaccional */ context.teamProtoStore.updateData { team -> //Comprobamos que el desarrollador no se encuentra ya en el equipo if (!team.developerList.contains(developer)) { team.toBuilder().addDeveloper(developer).build() } else { team } } }
¿Vale cualquier forma de serializar con Datastore?
Es evidente que los ingenieros de Android y Google quieren que usemos Protobuf como protocolo para la serialización de los objetos a persistir en Datastore, pero si nos fijamos en el delegado dataStore
para instanciar un Datastore
de cualquier tipo T
, sólo necesitamos una clase que implemente la interfaz Serializer
para realizar dicha serialización.
Para probar, vamos a utilizar Kotlin.Serialization
, que es la biblioteca de serialización multiplataforma de Kotlin. Incluimos la dependencia necesaria:
// build.gradle buildscript { dependencies { classpath "orj.jetbrains.kotlin:kotlin-serialization:$kotlin-serialization-plugin-version" } } // app/build.gradle plugins { id 'kotlinx-serialization' } dependencies { "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinx-serialization-version" }
En este caso vamos a definir una clase Person
con nombre y cis que sea @Serializable
:
@Serializable data class Person( val name: String = DEFAULT_NAME, val city: City = City.SEVILLA, ){ @Serializable enum class City{ @SerialName("SEVILLA") SEVILLA, @SerialName("CADIZ") CADIZ, @SerialName("MALAGA") MALAGA, } }
Vamos a seguir los dos pasos básicos:
- Primero definir el
Serializer
que necesitamos para una lista de personas:object PeopleSerializer: Serializer
- > {
// Kotlinx.Serialization necesita definir
// el formato con el que parsear a Json
private val stringFormat: StringFormat = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
// Devolviendo una lista vacía como valor por defecto
override val defaultValue: List
get() = emptyList() //Sobreescribimos la función de lectura que //decodifica a List @Suppress("BlockingMethodInNonBlockingContext") override suspend fun readFrom(input: InputStream): List { return try { stringFormat.decodeFromString(input.readBytes().decodeToString()) } catch (exception: SerializationException) { Log.e(PeopleSerializer::javaClass.name, "Cannot read p.", exception) defaultValue } } // Y también la de escritura, // teniendo en cuenta no olvidarnos los corchetes del formato JSON // para englobar la lista de Person @Suppress("BlockingMethodInNonBlockingContext") override suspend fun writeTo(t: List , output: OutputStream) { output.write("[${t.joinToString { stringFormat.encodeToString(it) }}]".encodeToByteArray()) } } - Crear la instancia de
DataStore
a través del delegado- >
dataStore
:val Context.peopleDataStore: DataStore
- > by dataStore(
fileName = PEOPLE_FILE_NAME,
serializer = PeopleSerializer,
)
Una vez hecho esto, ya podemos definir nuestras funciones de lectura y escritura de nuestra preferencia de personas:
override fun getPeople() = context.personDataStore.data override suspend fun addPerson(newPerson: Person) { context.personDataStore.updateData { people -> val foundIndex = people.indexOfFirst { it.name == newPerson.name } if (foundIndex == INT_NEGATIVE_ONE) { people + newPerson } else { val mutablePeople = people.toMutableList() mutablePeople
Podríamos haber usado cualquier librería de serialización, como Moshi
, Gson
, Jackson
, etc. Basta con seguir los dos pasos básicos: definir una clase que implemente Serializer
y pasárselo al delegado para instanciar un DataStore
. La única diferencia es la tecnología de serialización, aunque la recomendada obviamente es Protobuf.
Conclusiones
Desde este pasado Google I/O ’22, la biblioteca Jetpack Datastore en su versión 1.0 está liberada y plenamente funcional para ser usada en proyectos de producción.
Como hemos visto, provee de una API sencilla y robusta, apoyada en ventajas que provee el lenguaje Kotlin como Corrutinas
, Flow
y delegados
para sustituir a SharePreferences
de manera natural y moderna.
Aprovechando la ocasión, Google ha querido introducir en Datastore su protocolo de serialización multiplataforma Protobuf que promete ocupar poco espacio de almacenamiento, rápidas serializaciones y definir datos fuertemente tipados. El único handicap es conocer la sintaxis de mensajes y campos clave-valor con la que se definen los archivos .proto
(argumentan que merece la pena).
Pero sobre todo, han estandarizado mediante la interfaz Serializer
la manera de declarar serializadores que trabajen con datos almacenados como preferencias en Datastore, permitiendo usar no sólo Profobuf, sino cualquier biblioteca para este fin.
Javier Rodríguez,
Android Tech Leader
Referencias