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.
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
- Android Studio Arctic Fox
- Conocer los siguientes componentes de la arquitectura: LiveData, ViewModel, Vinculación de vista y la arquitectura sugerida en la Guía de arquitectura de apps
- Conocer las corrutinas y el Flujo de Kotlin
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:
- Descomprime el código y, luego, abre el proyecto en Android Studio Artic Fox.
- Ejecuta la configuración de ejecución app en un dispositivo o emulador.
La app se ejecuta y muestra la lista de tareas:
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 elementoFlow
a fin de representar una situación más realista) - La clase
UserPreferencesRepository
, que contiene el elementoSortOrder
definido comoenum
(el orden actual se guarda en SharedPreferences comoString
, 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 elementoRecyclerView
- 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 deTasksRepository
- Un elemento
MutableStateFlow<Boolean>
que contiene la última marcashowCompleted
que solo se conserva en la memoria - Un elemento
MutableStateFlow<SortOrder>
que contiene el valorsortOrder
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 quesortOrder
, 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()
yenableSortByPriority()
. 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 | ✅ (mediante |
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 | ✅ (el trabajo se mueve a |
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
yMutableMap
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()
yenableSortByPriority()
para que sean funciones desuspend
que usendataStore.edit()
. - En el bloque de transformación de
edit()
, obtendremos lacurrentOrder
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.