Universidad del Quindío
Programa de Ingeniería de Sistemas y Computación
Título: Data Store en Jetpack Compose
Docente: Carlos Andrés Florez V.
En el desarrollo de aplicaciones móviles con Jetpack Compose, es fundamental contar con mecanismos eficientes para almacenar y gestionar datos de manera persistente. Data Store es una solución moderna proporcionada por Android que permite almacenar datos de forma segura y eficiente, reemplazando a SharedPreferences. En esta guía, exploraremos cómo utilizar Data Store en una aplicación Jetpack Compose para almacenar y recuperar datos de usuario.
Data Store ofrece dos tipos principales de almacenamiento: Preferences Data Store y Proto Data Store. Preferences Data Store es ideal para almacenar pares clave-valor simples, mientras que Proto Data Store utiliza Protocol Buffers para almacenar datos estructurados. En nuestro caso, nos centraremos en Preferences Data Store debido a su simplicidad y facilidad de uso.
La implementación de Data Store en Jetpack Compose nos permitirá mantener la persistencia de datos asegurando que la información relevante se conserve incluso después de cerrar la aplicación. Esto es especialmente útil para almacenar configuraciones de usuario, datos de sesión y preferencias.
Originalmente, Android proporcionaba SharedPreferences como la principal solución para el almacenamiento de datos clave-valor. Sin embargo, SharedPreferences tiene varios problemas, por lo que Google introdujo Data Store como una alternativa más moderna y eficiente.
Antes de sumergirnos en la implementación de Data Store, es importante entender las diferencias clave entre SharedPreferences y Data Store.
| Característica | SharedPreferences | Data Store |
|---|---|---|
| Modelo de almacenamiento | Basado en XML | Basado en archivos binarios |
| Tipo de datos | Clave-valor simples | Clave-valor (Preferences) o datos estructurados (Proto) |
| Seguridad | No cifrado por defecto | Soporta cifrado y es más seguro |
| Rendimiento | Menor rendimiento en operaciones concurrentes | Mejor rendimiento en operaciones concurrentes |
| API | Síncrona y propensa a errores | Asíncrona y basada en corrutinas |
| Manejo de errores | Propenso a errores de concurrencia | Manejo de errores más robusto |
Claramente, Data Store ofrece varias ventajas sobre SharedPreferences, especialmente en términos de seguridad, rendimiento y manejo de errores. Por estas razones, se recomienda utilizar Data Store para nuevas aplicaciones y migrar las existentes cuando sea posible.
La documentación oficial de Data Store se puede encontrar en el siguiente enlace: Data Store.
Para utilizar Data Store en nuestro proyecto, primero debe agregar las dependencias necesarias en el archivo libs.versions.toml. Añada la siguiente línea para incluir la biblioteca de Data Store:
[versions]
dataStore = "1.2.0"
[libraries]
data-store = { module = "androidx.datastore:datastore-preferences", version.ref="dataStore" }
Luego, en el archivo build.gradle.kts del módulo de la aplicación, agregue la dependencia:
dependencies {
implementation(libs.data.store)
}
Sincronice el proyecto para descargar las nuevas dependencias.
El siguiente paso es crear un Singleton para manejar la instancia de Data Store. Cree un archivo llamado SessionDataStore.kt para gestionar el almacenamiento de datos de sesión del usuario. Este archivo debe crearlo en el paquete data/datastore.
package com.example.demoapp.data.datastore
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.example.demoapp.data.model.UserSession
import com.example.demoapp.domain.model.UserRole
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "session")
@Singleton
class SessionDataStore @Inject constructor(
@param:ApplicationContext private val context: Context
) {
// Claves para las preferencias
private object Keys {
val USER_ID = stringPreferencesKey("user_id")
val ROLE = stringPreferencesKey("role")
}
// Flujo para observar los datos de la sesión
val sessionFlow: Flow<UserSession?> = context.dataStore.data.map { prefs ->
// Intenta obtener los datos de la sesión desde Data Store
val userId = prefs[Keys.USER_ID]
val roleStr = prefs[Keys.ROLE]
// Si alguno de los datos es nulo, retorna null
if (userId.isNullOrBlank() || roleStr.isNullOrBlank()) {
null
} else {
// Retorna un objeto UserSession con los datos obtenidos si existen
UserSession(
userId = userId,
role = UserRole.valueOf(roleStr)
)
}
}
suspend fun saveSession(userId: String, role: UserRole) {
// Guarda los datos de la sesión en Data Store (clave-valor)
context.dataStore.edit { prefs ->
prefs[Keys.USER_ID] = userId
prefs[Keys.ROLE] = role.name
}
}
suspend fun clearSession() {
context.dataStore.edit { it.clear() }
}
}
Cree un archivo llamado UserSession.kt en el paquete data/model para definir el modelo de datos que se almacenará en Data Store.
package com.example.demoapp.data.model
import com.example.demoapp.domain.model.UserRole
data class UserSession(
val userId: String,
val role: UserRole
)
Esta clase representa la sesión del usuario, incluyendo su ID y rol. Es como un DTO (Data Transfer Object) que facilita el almacenamiento y recuperación de datos desde Data Store.
Cree un archivo llamado SessionViewModel.kt en el paquete core/navigation para gestionar la lógica de la sesión del usuario utilizando Data Store.
package com.example.demoapp.core.navigation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.demoapp.data.model.UserSession
import com.example.demoapp.data.datastore.SessionDataStore
import com.example.demoapp.domain.model.UserRole
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
// Define los posibles estados de la sesión
sealed interface SessionState {
data object Loading : SessionState
data object NotAuthenticated : SessionState
data class Authenticated(val session: UserSession) : SessionState
}
@HiltViewModel
class SessionViewModel @Inject constructor(
private val sessionDataStore: SessionDataStore
) : ViewModel() {
// Flujo que representa el estado de la sesión
val sessionState: StateFlow<SessionState> = sessionDataStore.sessionFlow
.map { session -> // Mapea la sesión a un estado de sesión
if (session != null) {
SessionState.Authenticated(session)
} else {
SessionState.NotAuthenticated
}
}
.stateIn( // Convierte el flujo en un StateFlow para que pueda ser observado
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = SessionState.Loading
)
fun login(userId: String, role: UserRole) {
// Guarda la sesión del usuario en Data Store. Se utiliza viewModelScope para lanzar la corrutina.
viewModelScope.launch {
sessionDataStore.saveSession(userId, role)
}
}
fun logout() {
// Limpia la sesión del usuario en Data Store. Se utiliza viewModelScope para lanzar la corrutina.
viewModelScope.launch {
sessionDataStore.clearSession()
}
}
}
Ahora, vamos a modificar la navegación para que la pantalla de inicio dependa del estado de la sesión gestionada por Data Store.
La idea es hacer algo así:
Vaya a AppNavigation.kt y actualice el código de la siguiente manera:
@Composable
fun AppNavigation(
sessionViewModel: SessionViewModel = hiltViewModel()
) {
// Observa el estado de la sesión desde el ViewModel
val sessionState by sessionViewModel.sessionState.collectAsState()
Surface(modifier = Modifier.fillMaxSize()) {
when (val state = sessionState) {
is SessionState.Loading -> {
// Se muestra un indicador de carga mientras se determina el estado de la sesión
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
is SessionState.NotAuthenticated -> AuthNavigation() // Sin sesión, muestra la navegación de autenticación
is SessionState.Authenticated -> MainNavigation(
session = state.session,
onLogout = sessionViewModel::logout
)
}
}
}
Simplificamos la navegación para que dependa del estado de la sesión. Si el usuario no está autenticado, se muestra la navegación de autenticación; si está autenticado, se muestra la navegación principal con la sesión del usuario.
Debe agregar el siguiente composable para la navegación de autenticación en el mismo archivo AppNavigation.kt:
@Composable
private fun AuthNavigation() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = MainRoutes.Home
) {
composable<MainRoutes.Home> {
// Tal como antes, se pasan los callbacks de navegación
HomeScreen(
onNavigateToLogin = {
navController.navigate(MainRoutes.Login)
},
onNavigateToRegister = {
navController.navigate(MainRoutes.Register)
}
)
}
composable<MainRoutes.Login> {
// Sin callback de navegación ya que el cambio de sesión hace que AppNavigation cambie a MainNavigation
LoginScreen(
onNavigateToRegister = {
navController.navigate(MainRoutes.Register)
}
)
}
composable<MainRoutes.Register> {
RegisterScreen(
onNavigateToBack = {
navController.popBackStack()
}
)
}
// Otras pantallas que no requieren autenticación pueden ir aquí
}
}
⚠️ Importante: En este ejemplo solo se usan algunas pantallas, en su proyecto puede tener más pantallas que debe agregar aquí.
Así mismo, debe agregar el siguiente composable para la navegación una vez autenticado en el mismo archivo AppNavigation.kt:
@Composable
private fun MainNavigation(
session: UserSession,
onLogout: () -> Unit
) {
val navController = rememberNavController()
// Determina la pantalla de inicio según el rol del usuario
val startDestination: Any = when (session.role) {
UserRole.ADMIN -> MainRoutes.HomeAdmin
UserRole.USER -> MainRoutes.HomeUser
}
NavHost(
navController = navController,
startDestination = startDestination
) {
composable<MainRoutes.HomeUser> {
// Se pasa el callback de logout para cerrar sesión, úselo dentro de UserScreen
UserScreen(
onLogout = onLogout
)
}
composable<MainRoutes.HomeAdmin> {
// Si no tiene AdminScreen debe crearlo, se pasa el callback de logout para cerrar sesión, úselo dentro de AdminScreen
AdminScreen(
onLogout = onLogout
)
}
}
}
De esta manera, la navegación de la aplicación se adapta dinámicamente según el estado de la sesión del usuario almacenada en Data Store. Tenga en cuenta que tanto UserScreen como AdminScreen tienen su propio NavHost para gestionar la navegación interna.
Ejecute la aplicación en un emulador o dispositivo físico. Navegue a la pantalla de inicio de sesión, ingrese las credenciales y verifique que la sesión se almacene correctamente en Data Store. Luego, cierre y vuelva a abrir la aplicación para asegurarse de que la sesión persista y el usuario permanezca autenticado.
Para acceder al usuario almacenado en Data Store desde cualquier parte de la aplicación, simplemente inyecte SessionDataStore utilizando Hilt directamente en el ViewModel donde lo necesite. Luego, puede observar el flujo sessionFlow para obtener los datos de la sesión del usuario.
Por ejemplo, en un ViewModel del perfil de usuario:
package com.example.demoapp.features.user.profile
@HiltViewModel
class ProfileViewModel @Inject constructor(
private val repository: UserRepository,
private val sessionDataStore: SessionDataStore // Inyección de SessionDataStore
) : ViewModel() {
// Estado para el usuario
private val _user = MutableStateFlow<User?>(null)
val user: StateFlow<User?> = _user.asStateFlow()
init {
// Cargar el usuario actual al inicializar el ViewModel
loadCurrentUser()
}
private fun loadCurrentUser() {
// Usar viewModelScope para lanzar una corrutina
viewModelScope.launch {
// Obtener userId de la sesión
val session = sessionDataStore.sessionFlow.filterNotNull().first()
// Buscar el usuario en el repositorio usando el userId
val foundUser = repository.findById(session.userId)
// Actualizar el estado del usuario si se encuentra
foundUser?.let { user ->
_user.value = user
// Aquí puede cargar los datos del usuario en la UI
}
}
}
// Resto del ViewModel...
}
Ejecute nuevamente la aplicación y navegue a la pantalla de perfil. Verifique que los datos del usuario se carguen correctamente utilizando el userId almacenado en Data Store.
Cree todas las pantallas necesarias para el rol Admin acorde a los requerimientos de su proyecto. Asegúrese de que estas pantallas solo sean accesibles para usuarios con el rol Admin. Si ya ha creado estas pantallas, revise que la navegación funcione correctamente según el rol del usuario almacenado en Data Store.
Cree todas las pantallas necesarias para el rol User acorde a los requerimientos de su proyecto. Asegúrese de que estas pantallas solo sean accesibles para usuarios con el rol User. Si ya ha creado estas pantallas, revise que la navegación funcione correctamente según el rol del usuario almacenado en Data Store.
Piense en otros datos que podrían ser útiles almacenar en Data Store (por ejemplo, preferencias de usuario, configuraciones de la aplicación, etc.) e implemente su almacenamiento y recuperación utilizando Data Store.