Cómo trabajar con Preferences Datastore

1. Introducción

¿Qué es DataStore?

DataStore es una solución nueva y mejorada de almacenamiento de datos que apunta a reemplazar SharedPreferences. Basada en corrutinas de Kotlin y Flow, proporciona dos implementaciones diferentes: Proto DataStore, que almacena objetos escritos (con el respaldo de búferes de protocolo) y Preferences DataStore, que almacena pares clave-valor. Los datos se almacenan de forma asíncrona, coherente y transaccional, por lo que resuelve algunos de los inconvenientes de SharedPreferences.

Qué aprenderás

  • Qué es DataStore y por qué deberías usarlo
  • Cómo agregar DataStore a tu proyecto
  • Las diferencias entre Preferences y Proto DataStore y las ventajas de cada uno
  • Cómo usar Preferences DataStore
  • Cómo migrar de SharedPreferences a Preferences DataStore

Qué compilarás

En este codelab, comenzarás con una app de ejemplo que muestra una lista de tareas que se pueden filtrar por estado completado y se pueden ordenar por prioridad y fecha límite.

fcb2ffa4e6b77f33.gif

La marca booleana para el filtro Mostrar tareas completadas se guarda en la memoria. El orden se conserva en el disco con un objeto SharedPreferences.

En este codelab, completarás las siguientes tareas para aprender a usar Preferences DataStore:

  • Conservar el filtro de estado completo en DataStore
  • Migrar el orden de los elementos de SharedPreferences a DataStore

También te recomendamos trabajar con el codelab de Proto DataStore a fin de comprender mejor la diferencia entre ambos.

Requisitos

Si deseas obtener una introducción a los componentes de la arquitectura, consulta el codelab sobre Room con una View. Para obtener una introducción a los flujos, consulta el codelab sobre corrutinas avanzadas con LiveData y flujo de Kotlin.

2. Cómo prepararte

En este paso, descargarás el código de todo el codelab y, luego, ejecutarás una app simple de ejemplo.

A fin de que comiences lo antes posible, preparamos un proyecto inicial sobre el cual puedes compilar.

Si tienes Git instalado, simplemente puedes ejecutar el comando que se indica abajo. Para comprobarlo, escribe git --version en la terminal o línea de comandos, y verifica que se ejecute correctamente.

 git clone https://github.com/googlecodelabs/android-datastore

El estado inicial se encuentra en la rama master. El código de la solución se encuentra en la rama preferences_datastore.

Si no tienes Git, puedes hacer clic en el siguiente botón a fin de descargar todo el código de este codelab:

Descarga el código fuente

  1. Descomprime el código y, luego, abre el proyecto en Android Studio Artic Fox.
  2. Ejecuta la configuración de ejecución app en un dispositivo o emulador.

b3c0dfdb92dfed77.png

La app se ejecuta y muestra la lista de tareas:

d3972939a2de88ba.png

3. Descripción general del proyecto

La app te permite ver una lista de tareas. Cada una tiene las siguientes propiedades: nombre, estado completado, prioridad y fecha límite.

A fin de simplificar el código con el que necesitamos trabajar, la app te permite realizar solo dos acciones:

  • Activar o desactivar la visibilidad de las tareas completadas (de forma predeterminada, estas tareas están ocultas)
  • Ordenar las tareas por prioridad, por fecha límite o por fecha límite y prioridad

La app sigue la arquitectura recomendada en la Guía de arquitectura de apps. En cada paquete, encontrarás lo siguiente:

data

  • La clase modelo Task
  • La clase TasksRepository, responsable de proporcionar las tareas (para simplificar, muestra datos codificados y los expone mediante un elemento Flow a fin de representar una situación más realista)
  • La clase UserPreferencesRepository, que contiene el elemento SortOrder definido como enum (el orden actual se guarda en SharedPreferences como String, según el nombre del valor enum, y expone métodos síncronos para guardar y obtener el orden)

ui

  • Clases relacionadas con la visualización de un elemento Activity con un elemento RecyclerView
  • La clase TasksViewModel, encargada de la lógica de la IU

TasksViewModel contiene todos los elementos necesarios para compilar los datos que se deben mostrar en la IU: la lista de tareas y las marcas showCompleted y sortOrder, dentro de un objeto TasksUiModel. Cada vez que cambia uno de estos valores, debemos reconstruir un nuevo elemento TasksUiModel. Para ello, combinamos 3 elementos:

  • Un elemento Flow<List<Task>> que se recupera de TasksRepository
  • Un elemento MutableStateFlow<Boolean> que contiene la última marca showCompleted que solo se conserva en la memoria
  • Un elemento MutableStateFlow<SortOrder> que contiene el valor sortOrder más reciente

Para asegurarnos de estar actualizando la IU de forma correcta, solo cuando se inicia el elemento Activity, exponemos un LiveData<TasksUiModel>.

Tenemos algunos problemas con nuestro código:

  • Bloqueamos el subproceso de IU en la E/S del disco cuando se inicializa UserPreferencesRepository.sortOrder. Esto puede ocasionar bloqueos de IU.
  • La marca showCompleted solo se conserva en la memoria, por lo que se restablecerá cada vez que el usuario abra la app. Al igual que sortOrder, debería conservarse incluso después de cerrar la app.
  • Actualmente, usamos SharedPreferences para conservar datos, pero conservamos un elemento MutableStateFlow en la memoria, que se modifica manualmente a fin de recibir notificaciones sobre los cambios. El elemento se rompe con facilidad si el valor se modifica en otro lugar de la aplicación.
  • En UserPreferencesRepository, exponemos dos métodos para actualizar el orden: enableSortByDeadline() y enableSortByPriority(). Ambos métodos dependen del valor actual del orden de clasificación, pero, si se llama a uno antes de que el otro termine, tendremos un valor final incorrecto. Además, estos métodos pueden provocar bloqueos de IU e incumplimientos del modo estricto a medida que se los llama en el subproceso de IU.

Veamos cómo usar DataStore para resolver estos problemas.

4. DataStore: conceptos básicos

Es posible que necesites almacenar conjuntos de datos pequeños o simples con frecuencia. Para ello, en el pasado, quizás habrías usado SharedPreferences, aunque esta API también tiene algunas desventajas. La biblioteca de Jetpack DataStore tiene como objetivo abordar esos problemas mediante una API simple, segura y asíncrona para almacenar datos. Brinda 2 implementaciones diferentes:

  • Preferences DataStore
  • Proto DataStore

Función

SharedPreferences

Preferences DataStore

Proto DataStore

API asíncrona

✅ (solo para leer los valores modificados, mediante un objeto de escucha)

✅ (mediante Flow y RxJava 2 y 3 Flowable)

✅ (mediante Flow y RxJava 2 y 3 Flowable)

API síncrona

✅ (pero no es seguro llamarla en el subproceso de IU)

Es seguro llamarla en el subproceso de IU

❌1

✅ (el trabajo se mueve a Dispatchers.IO debajo de la superficie)

✅ (el trabajo se mueve a Dispatchers.IO debajo de la superficie)

Puede indicar errores

Seguro contra excepciones de tiempo de ejecución

❌2

Tiene una API transaccional con garantías de coherencia sólida

Controla la migración de datos

Seguridad de tipos

✅ con búferes de protocolo

1 SharedPreferences tiene una API síncrona que puede parecer segura para realizar llamadas en el subproceso de IU, pero que en realidad realiza operaciones de E/S en el disco. Además, apply() bloquea el subproceso de IU en fsync(). Las llamadas de fsync() pendientes se activan cada vez que se inicia o se detiene un servicio, y cada vez que se inicia o se detiene una actividad en tu aplicación. El subproceso de IU está bloqueado en las llamadas pendientes fsync() programadas por apply(), que suelen convertirse en una fuente de ANR.

2 SharedPreferences genera errores de análisis como excepciones de tiempo de ejecución.

Preferences DataStore vs. Proto DataStore

Si bien Preferences y Proto DataStore permiten el almacenamiento de datos, lo hacen de diferente manera:

  • Preferences DataStore, al igual que SharedPreferences, accede a los datos según las claves, sin definir un esquema por adelantado.
  • Proto DataStore define el esquema mediante búferes de protocolo. El uso de protobufs permite conservar los datos de tipado fuerte. Son más rápidos, más pequeños, más simples y menos ambiguos que XML y otros formatos de datos similares. Si bien Proto DataStore requiere que aprendas un mecanismo de serialización nuevo, creemos que la ventaja del tipado fuerte que proporciona Proto DataStore vale la pena.

Room vs. DataStore

Si necesitas actualizaciones parciales, integridad referencial o conjuntos de datos grandes o complejos, debes considerar usar Room en lugar de DataStore. DataStore es ideal para conjuntos de datos pequeños y simples, y no admite actualizaciones parciales ni integridad referencial.

5. Descripción general de Preferences DataStore

La API de DataStore Preferences es similar a SharedPreferences con varias diferencias notables:

  • Administra actualizaciones de datos de manera transaccional.
  • Expone un flujo que representa el estado actual de los datos.
  • No tiene métodos persistentes de datos (apply(), commit()).
  • No muestra referencias mutables a su estado interno.
  • Expone una API similar a Map y MutableMap con claves de tipo.

Veamos cómo agregarlo al proyecto y migrar SharedPreferences a DataStore.

Cómo agregar dependencias

Actualiza el archivo build.gradle para agregar la siguiente dependencia de Preferences DataStore:

implementation "androidx.datastore:datastore-preferences:1.0.0"

6. Datos persistentes en Preferences DataStore

Aunque las marcas showCompleted y sortOrder son preferencias del usuario, en la actualidad, se representan como dos objetos diferentes. Uno de nuestros objetivos es unificar estas dos marcas en una clase UserPreferences y almacenarlas en UserPreferencesRepository mediante DataStore. Actualmente, la marca showCompleted se conserva en la memoria, en TasksViewModel.

Comencemos creando una clase de datos UserPreferences en UserPreferencesRepository. Por ahora, solo debería tener un campo: showCompleted. Agregaremos el orden más adelante.

data class UserPreferences(val showCompleted: Boolean)

Cómo crear DataStore

Para crear una instancia de DataStore, usamos el delegado preferencesDataStore con Context como receptor. Para mayor simplicidad, en este codelab, lo haremos en TasksActivity:

private const val USER_PREFERENCES_NAME = "user_preferences"

private val Context.dataStore by preferencesDataStore(
    name = USER_PREFERENCES_NAME
)

El elemento delegado preferencesDataStore garantiza que tengamos una sola instancia de DataStore con ese nombre en nuestra aplicación. En este momento, UserPreferencesRepository se implementa como un singleton, porque contiene sortOrderFlow y evita tenerlo vinculado al ciclo de vida de TasksActivity. Debido a que UserPreferenceRepository trabajará con los datos de DataStore y no creará ni conservará objetos nuevos, podemos quitar la implementación del singleton:

  • Quita el companion object.
  • Haz que constructor sea público.

El objeto UserPreferencesRepository debería obtener una instancia de DataStore como parámetro constructor. Por ahora, podemos dejar Context como parámetro, ya que SharedPreferences lo necesita, pero lo quitaremos más tarde.

class UserPreferencesRepository(
    private val userPreferencesStore: DataStore<UserPreferences>,
    context: Context
) { ... }

Actualicemos la construcción de UserPreferencesRepository en TasksActivity y pasemos dataStore:

viewModel = ViewModelProvider(
    this,
    TasksViewModelFactory(
        TasksRepository,
        UserPreferencesRepository(dataStore, this)
    )
).get(TasksViewModel::class.java)

Cómo leer los datos de Preferences StoreStore

Preferences DataStore expone los datos almacenados en un Flow<Preferences> que se emitirán cada vez que se haya modificado una preferencia. No queremos exponer todo el objeto Preferences, sino el objeto UserPreferences. Para hacerlo, tendremos que asignar el Flow<Preferences>, obtener el valor booleano que nos interesa, según una clave y construir un objeto UserPreferences.

Entonces, lo primero que debemos hacer es definir la clave show_completed: se trata de un valor booleanPreferencesKey que podemos declarar como un miembro en un objeto PreferencesKeys privado.

private object PreferencesKeys {
  val SHOW_COMPLETED = booleanPreferencesKey("show_completed")
}

Expondremos un userPreferencesFlow: Flow<UserPreferences>, construido sobre dataStore.data: Flow<Preferences>, que luego se asigna para recuperar la preferencia correcta:

val userPreferencesFlow: Flow<UserPreferences> = dataStore.data
    .map { preferences ->
        // Get our show completed value, defaulting to false if not set:
        val showCompleted = preferences[PreferencesKeys.SHOW_COMPLETED]?: false
        UserPreferences(showCompleted)
    }

Cómo administrar excepciones mientras se leen los datos

A medida que DataStore lee datos de un archivo, se generan IOExceptions cuando se produce un error durante la lectura de los datos. Para controlarlos, podemos usar el operador de flujo catch() antes de map() y emitir emptyPreferences() en caso de que la excepción arrojada sea un IOException. Si se arrojó otro tipo de excepción, es preferible que vuelva se vuelva a arrojar.

val userPreferencesFlow: Flow<UserPreferences> = dataStore.data
    .catch { exception ->
        // dataStore.data throws an IOException when an error is encountered when reading data
        if (exception is IOException) {
            emit(emptyPreferences())
        } else {
            throw exception
        }
    }.map { preferences ->
        // Get our show completed value, defaulting to false if not set:
        val showCompleted = preferences[PreferencesKeys.SHOW_COMPLETED]?: false
        UserPreferences(showCompleted)
    }

Cómo escribir datos en Preferences DataStore

Para escribir datos, DataStore ofrece una función de suspensión DataStore.edit(transform: suspend (MutablePreferences) -> Unit), que acepta un bloque transform que nos permite actualizar el estado de forma transaccional en DataStore.

El MutablePreferences pasado al bloque de transformación estará actualizado con las ediciones ejecutadas anteriormente. Todos los cambios que realices en MutablePreferences, en el bloque transform se aplicarán al disco una vez que se complete transform y antes de que se complete edit. Si estableces un valor en MutablePreferences, no se modificarán las demás preferencias.

Nota: No intentes modificar el MutablePreferences fuera del bloque de transformación.

Creemos una función de suspensión que nos permita actualizar la propiedad showCompleted de UserPreferences, llamada updateShowCompleted(), que llame a dataStore.edit() y establezca el valor nuevo:

suspend fun updateShowCompleted(showCompleted: Boolean) {
    dataStore.edit { preferences ->
        preferences[PreferencesKeys.SHOW_COMPLETED] = showCompleted
    }
}

edit() puede arrojar una excepción IOException si se encontró un error durante la lectura o escritura en el disco. Si se produce otro error en el bloque de transformación, edit() lo arrojará.

En este punto, la app debería compilarse, pero la funcionalidad que acabamos de crear en UserPreferencesRepository no se usa.

7. SharedPreferences a Preferences DataStore

El orden se guarda en SharedPreferences. Trasladémoslo a DataStore. Para comenzar, actualicemos UserPreferences a fin de almacenar el orden de clasificación:

data class UserPreferences(
    val showCompleted: Boolean,
    val sortOrder: SortOrder
)

Migración desde SharedPreferences

A fin de poder migrar a DataStore, debemos actualizar el compilador de DataStore para pasar un elemento SharedPreferencesMigration a la lista de migraciones. DataStore podrá migrar automáticamente de SharedPreferences a DataStore. Las migraciones se ejecutan antes de que se pueda acceder a los datos en DataStore. Esto significa que la migración debe realizarse correctamente antes de que DataStore.data emita cualquier valor y que DataStore.edit() pueda actualizar los datos.

Nota: Las claves solo se migran de SharedPreferences una vez, por lo que debes dejar de usar SharedPreferences una vez que el código se migró a DataStore.

Primero, actualicemos la creación de DataStore en TasksActivity:

private const val USER_PREFERENCES_NAME = "user_preferences"

private val Context.dataStore by preferencesDataStore(
    name = USER_PREFERENCES_NAME,
    produceMigrations = { context ->
        // Since we're migrating from SharedPreferences, add a migration based on the
        // SharedPreferences name
        listOf(SharedPreferencesMigration(context, USER_PREFERENCES_NAME))
    }
)

Luego, agrega sort_order a nuestro PreferencesKeys:

private object PreferencesKeys {
    ...
    // Note: this has the the same name that we used with SharedPreferences.
    val SORT_ORDER = stringPreferencesKey("sort_order")
}

Todas las claves se migrarán a nuestro DataStore y se borrarán de las preferencias del usuario de SharedPreferences. Ahora, desde Preferences podremos obtener y actualizar el elemento SortOrder en función de la claveSORT_ORDER.

Cómo leer el orden en DataStore

Actualicemos el userPreferencesFlow para recuperar también el orden en la transformación map():

val userPreferencesFlow: Flow<UserPreferences> = dataStore.data
    .catch { exception ->
        if (exception is IOException) {
            emit(emptyPreferences())
        } else {
            throw exception
        }
    }.map { preferences ->
        // Get the sort order from preferences and convert it to a [SortOrder] object
        val sortOrder =
            SortOrder.valueOf(
                preferences[PreferencesKeys.SORT_ORDER] ?: SortOrder.NONE.name)

        // Get our show completed value, defaulting to false if not set:
        val showCompleted = preferences[PreferencesKeys.SHOW_COMPLETED] ?: false
        UserPreferences(showCompleted, sortOrder)
    }

Cómo guardar el orden en DataStore

Actualmente, UserPreferencesRepository solo expone una forma síncrona de configurar la marca del orden y tiene un problema de simultaneidad. Extendemos dos métodos para actualizar el orden de clasificación: enableSortByDeadline() y enableSortByPriority() ambos métodos usan el valor actual del orden, pero, si se llama a uno antes de que el otro termine, obtendremos un valor final incorrecto.

Debido a que DataStore garantiza que las actualizaciones de datos se realicen de forma transaccional, ya no tendremos ese problema. Realizaremos los siguientes cambios:

  • Actualiza enableSortByDeadline() y enableSortByPriority() para que sean funciones de suspend que usen dataStore.edit().
  • En el bloque de transformación de edit(), obtendremos la currentOrder del parámetro Preferencias, en lugar de recuperarla desde el campo _sortOrderFlow.
  • En lugar de llamar a updateSortOrder(newSortOrder), podemos actualizar directamente el orden en las preferencias.

A continuación, se muestra la implementación.

suspend fun enableSortByDeadline(enable: Boolean) {
    // edit handles data transactionally, ensuring that if the sort is updated at the same
    // time from another thread, we won't have conflicts
    dataStore.edit { preferences ->
        // Get the current SortOrder as an enum
        val currentOrder = SortOrder.valueOf(
            preferences[PreferencesKeys.SORT_ORDER] ?: SortOrder.NONE.name
        )

        val newSortOrder =
            if (enable) {
                if (currentOrder == SortOrder.BY_PRIORITY) {
                    SortOrder.BY_DEADLINE_AND_PRIORITY
                } else {
                    SortOrder.BY_DEADLINE
                }
            } else {
                if (currentOrder == SortOrder.BY_DEADLINE_AND_PRIORITY) {
                    SortOrder.BY_PRIORITY
                } else {
                    SortOrder.NONE
                }
            }
        preferences[PreferencesKeys.SORT_ORDER] = newSortOrder.name
    }
}

suspend fun enableSortByPriority(enable: Boolean) {
    // edit handles data transactionally, ensuring that if the sort is updated at the same
    // time from another thread, we won't have conflicts
    dataStore.edit { preferences ->
        // Get the current SortOrder as an enum
        val currentOrder = SortOrder.valueOf(
            preferences[PreferencesKeys.SORT_ORDER] ?: SortOrder.NONE.name
        )

        val newSortOrder =
            if (enable) {
                if (currentOrder == SortOrder.BY_DEADLINE) {
                    SortOrder.BY_DEADLINE_AND_PRIORITY
                } else {
                    SortOrder.BY_PRIORITY
                }
            } else {
                if (currentOrder == SortOrder.BY_DEADLINE_AND_PRIORITY) {
                    SortOrder.BY_DEADLINE
                } else {
                    SortOrder.NONE
                }
            }
        preferences[PreferencesKeys.SORT_ORDER] = newSortOrder.name
    }
}

Ahora, puedes quitar el parámetro de constructor context y todos los usos de SharedPreferences.

8. Cómo actualizar TasksViewModel para usar UserPreferencesRepository

Ahora que UserPreferencesRepository almacena las marcas show_completed y sort_order en DataStore y expone una Flow<UserPreferences>, actualicemos TasksViewModel para usarlas.

Quita showCompletedFlow y sortOrderFlow y, en su lugar, crea un valor llamado userPreferencesFlow que se inicialice con userPreferencesRepository.userPreferencesFlow:

private val userPreferencesFlow = userPreferencesRepository.userPreferencesFlow

En la creación de tasksUiModelFlow, reemplaza showCompletedFlow y sortOrderFlow con userPreferencesFlow. Reemplaza los parámetros según corresponda.

Cuando llamas a filterSortTasks, pasa showCompleted y sortOrder de las userPreferences. Tu código debería verse de la siguiente manera:

private val tasksUiModelFlow = combine(
        repository.tasks,
        userPreferencesFlow
    ) { tasks: List<Task>, userPreferences: UserPreferences ->
        return@combine TasksUiModel(
            tasks = filterSortTasks(
                tasks,
                userPreferences.showCompleted,
                userPreferences.sortOrder
            ),
            showCompleted = userPreferences.showCompleted,
            sortOrder = userPreferences.sortOrder
        )
    }

Se debe actualizar la función showCompletedTasks() para llamar a userPreferencesRepository.updateShowCompleted(). Como esta es una función de suspensión, crea una corrutina nueva en viewModelScope:

fun showCompletedTasks(show: Boolean) {
    viewModelScope.launch {
        userPreferencesRepository.updateShowCompleted(show)
    }
}

Las funciones de userPreferencesRepository, enableSortByDeadline() y enableSortByPriority() ahora son funciones de suspensión, de modo que también deberían llamarse en una corrutina nueva, iniciada en viewModelScope:

fun enableSortByDeadline(enable: Boolean) {
    viewModelScope.launch {
       userPreferencesRepository.enableSortByDeadline(enable)
    }
}

fun enableSortByPriority(enable: Boolean) {
    viewModelScope.launch {
        userPreferencesRepository.enableSortByPriority(enable)
    }
}

Limpieza de UserPreferencesRepository

Quitemos los campos y métodos que ya no son necesarios. Deberías poder borrar los siguientes elementos:

  • _sortOrderFlow
  • sortOrderFlow
  • updateSortOrder()
  • private val sortOrder: SortOrder

Ahora, nuestra app debería compilarse correctamente. Ejecutémosla a fin de ver si las marcas show_completed y sort_order se guardaron correctamente.

Revisa la rama preferences_datastore del repositorio de codelab para comparar tus cambios.

9. Conclusión

Ahora que migraste a Preferences StoreStore, veamos qué aprendimos:

  • SharedPreferences incluye una serie de desventajas: una API sincrónica que puede parecer segura para realizar llamadas en el subproceso de IU, ningún mecanismo para señalar errores y falta de API de transacciones, entre otros.
  • DataStore es un reemplazo para SharedPreferences que aborda la mayoría de las deficiencias de la API.
  • DataStore tiene una API completamente asíncrona que usa corrutinas de Kotlin y Flow y que administra la migración de datos, garantiza su coherencia y resuelve su corrupción.