Jetpack Datastore, o el adiós a SharedPreferences

Jetpack DataStore ALTEN
, , ,

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:

FuncionalidadSharedPreferencesDatastore PreferencesDatastore Proto
API asíncronaSí (sólo para leer cambio de valores a través de Listener)Sí (vía Flow)Sí (vía Flow)
API síncronaSí (pero no es seguro llamar en hilo de UI)NoNo
Es seguro llamarla en hilo de UINoSí (trabajo se mueve a Dispatchars.IO)Sí (trabajo se mueve a Dispatchars.IO)
Puede indicar erroresNo
Seguro contra excepciones en tiempo de ejecuciónNo
API transaccional con garantías de coherencia sólidaNo
Maneja migraciones de datosNoSí (desde SharedPreferences)Sí (desde SharedPreferences)
Seguridad de tiposNoNoSí (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: DataStore by 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: DataStore by 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:

  1. Hay que declarar una clase que implemente la interfaz Serializer, donde T es el mensaje principal definido en nuestro archivo .proto (en nuestro caso Team). 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)
            }
        }
    }
  2. Usar el delegado de propiedad dataStore para crear una instancia de DataStore indicando el nombre del archivo donde persistir los datos en el parámero filename y en el parámetro serializer 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 ci�s 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:

  1. 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())
        }
        
    }
  2. 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