Universidad del Quindío
Programa de Ingeniería de Sistemas y Computación
Título: Persistencia de Datos en Android
Docente: Carlos Andrés Florez V.

Persistencia de Datos en Android

Introducción

En clases anteriores, estudiamos DataStore, una solución moderna para la persistencia de datos en Android. Pero ¿qué pasa si necesitamos almacenar datos más complejos, como objetos o listas de objetos? Aquí es donde entra en juego Room, una biblioteca de persistencia que proporciona una capa de abstracción sobre SQLite para permitir un acceso más robusto a la base de datos.

SQLite

SQLite es una base de datos relacional ligera que viene integrada con Android. Permite almacenar datos estructurados en tablas y realizar consultas SQL para manipular esos datos. Sin embargo, trabajar directamente con SQLite puede ser tedioso y propenso a errores, especialmente cuando se trata de manejar esquemas de bases de datos y migraciones.

Internamente, SQLite utiliza un archivo de base de datos para almacenar los datos de la aplicación. Este archivo se encuentra en el sistema de archivos del dispositivo y es gestionado por el sistema operativo Android. Las aplicaciones pueden acceder a este archivo utilizando la API de SQLite proporcionada por Android.

Cabe resaltar que una aplicación móvil no tiene la responsabilidad de administrar datos complejos ya que esta responsabilidad recae en el backend. Sin embargo, existen casos en los que es necesario almacenar datos localmente, como cuando se desea ofrecer funcionalidad offline o mejorar el rendimiento al reducir las llamadas a la red.

Para más información sobre SQLite en Android, puede consultar la documentación oficial: SQLite Documentation.

Room

Room es una biblioteca de persistencia que simplifica el acceso a la base de datos SQLite en Android. Proporciona una capa de abstracción que permite definir entidades, DAOs (Data Access Objects) y bases de datos de manera más sencilla y segura. Gracias a Room, se simplifica la gestión de esquemas de tablas, consultas SQL y migraciones de bases de datos, ya que Room se encarga de generar el código necesario para interactuar con SQLite.

La arquitectura de Room se basa en tres componentes principales:

  1. Entidades: Son clases de datos que representan tablas en la base de datos. Cada entidad se anota con @Entity y define las columnas de la tabla mediante propiedades de la clase.
  2. DAOs (Data Access Objects): Son interfaces que definen los métodos para acceder a la base de datos. Los DAOs se anotan con @Dao y contienen métodos para insertar, actualizar, eliminar y consultar datos.
  3. Base de Datos: Es una clase abstracta que extiende RoomDatabase y define la base de datos. Esta clase se anota con @Database y especifica las entidades y la versión de la base de datos.

Se recomienda utilizar Room para la persistencia de datos en Android debido a su facilidad de uso, seguridad y eficiencia. Room proporciona una forma más estructurada y segura de interactuar con SQLite, lo que reduce la probabilidad de errores y mejora la calidad del código.

La documentación oficial de Room se puede encontrar en el siguiente enlace: Room Persistence Library.

Arquitectura de Room

A continuación, se muestra un diagrama que ilustra la arquitectura de Room:

Arquitectura de Room

La arquitectura de Room, como se mencionó previamente, se basa en la separación de responsabilidades entre las entidades, los DAOs y la base de datos. Por encima de esta arquitectura, se recomienda integrar Room con un patrón de repositorio y utilizar Hilt para la inyección de dependencias en los ViewModels donde se necesite acceder a los datos.


Ejemplo Práctico

A continuación, se presenta un ejemplo práctico de cómo utilizar Room para almacenar y recuperar datos en una aplicación Android.

💡 Nota: Este es un ejemplo autocontenido para aprender Room (la entidad UserEntity con id, name y age es solo demostrativa, no reemplaza al modelo User del dominio). En su proyecto puede aplicar este mismo patrón, por ejemplo, para guardar los reportes localmente y ofrecer funcionalidad offline.

1. Agregar dependencias

Primero, debemos agregar las dependencias de Room. En el archivo libs.versions.toml, agregamos las siguientes líneas:

[versions]
room = "2.8.4"

[libraries]
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }

Luego, en el archivo build.gradle.kts del módulo de la aplicación, agregamos las dependencias:

dependencies {
     implementation(libs.room.runtime)
     implementation(libs.room.ktx)
     ksp(libs.room.compiler)
}

2. Definir la Entidad

Creamos una clase de datos que represente una tabla en la base de datos. Por ejemplo, una entidad User con campos id, name y age. Esta clase se anota con @Entity y define las columnas de la tabla mediante propiedades de la clase.

Dado que las entidades suelen estar en un paquete separado, creamos un nuevo paquete llamado data.local.entity y dentro de este paquete creamos el archivo UserEntity.kt con el siguiente contenido:

package com.example.demoapp.data.local.entity

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "users")
data class UserEntity(
     @PrimaryKey val id: Int,
     val name: String,
     val age: Int
     // Otros campos que se deseen agregar ...
)

3. Crear el DAO (Data Access Object)

Definimos una interfaz DAO que contenga las funciones para acceder a la base de datos. Estas funciones son suspend para permitir su uso con corrutinas ya que las operaciones de base de datos pueden ser lentas y no deben ejecutarse en el hilo principal.

Los DAOs suelen estar en un paquete separado, por lo que creamos un nuevo paquete llamado data.local.dao y dentro de este paquete creamos el archivo UserDao.kt con el siguiente contenido:

package com.example.demoapp.data.local.dao

import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import com.example.demoapp.data.local.entity.UserEntity

@Dao
interface UserDao {
     @Insert
     suspend fun insert(user: UserEntity)

     @Query("SELECT * FROM users")
     suspend fun getAll(): List<UserEntity>
}

4. Definir la Base de Datos

Creamos una clase abstracta que extienda RoomDatabase y defina la base de datos. A medida que agreguemos más entidades, las incluiremos en el array entities, así como los DAOs correspondientes.

La base de datos suele estar en un paquete separado, por lo que creamos un nuevo paquete llamado data.local y dentro de este paquete creamos el archivo AppDatabase.kt con el siguiente contenido:

package com.example.demoapp.data.local

import androidx.room.Database
import androidx.room.RoomDatabase
import com.example.demoapp.data.local.dao.UserDao
import com.example.demoapp.data.local.entity.UserEntity

@Database(entities = [UserEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
     abstract fun userDao(): UserDao
}

5. Usar Room en la Aplicación

En nuestra aplicación, inicializamos la base de datos utilizando Room.

val db = Room.databaseBuilder(
     applicationContext,
     AppDatabase::class.java, "app-database"
).build()

6. Usar el DAO

Finalmente, podemos usar el DAO para insertar y recuperar datos.

val userDao = db.userDao()
val newUser = UserEntity(id = 1, name = "John Doe", age = 30)
userDao.insert(newUser)
val users = userDao.getAll()

El código anterior muestra cómo insertar un nuevo usuario y recuperar todos los usuarios almacenados en la base de datos. Tenga en cuenta que estas operaciones deben realizarse en un hilo de fondo, por lo que es recomendable utilizar corrutinas o algún otro mecanismo de concurrencia.


Integrar Room con una arquitectura más completa

Para una mejor arquitectura, es recomendable integrar Room con un patrón de repositorio y utilizar Hilt para la inyección de dependencias. Se recomienda seguir estos pasos adicionales:

1. Crear el Repositorio

Definimos una clase de repositorio que utilice el DAO para acceder a los datos. Como esta implementación usa la base de datos local, la ubicamos en el subpaquete data/repository/local (recuerde la organización por fuente de datos: memory, local, remote).

package com.example.demoapp.data.repository.local

// Se nombra UserLocalRepository para no confundirla con la interfaz UserRepository del dominio
@Singleton
class UserLocalRepository @Inject constructor(
    private val userDao: UserDao // Se inyecta el DAO aquí
) {

     // Función para insertar un usuario, es una operación suspend
     suspend fun insert(user: UserEntity) {
          userDao.insert(user)
     }

     // Función para obtener todos los usuarios, es una operación suspend
     suspend fun getAll(): List<UserEntity> {
          return userDao.getAll()
     }
}

Si ya contamos con una capa de modelos de dominio, es recomendable mapear entre las entidades de Room y los modelos de dominio dentro del repositorio para mantener una separación clara entre las capas de datos y dominio.

Además, este repositorio puede ser la implementación de una interfaz que defina las operaciones disponibles, de esta manera, podemos cambiar la fuente de datos en el futuro sin afectar las capas superiores.

⚠️ Importante: Tenga en cuenta que si el repositorio realiza operaciones suspendidas, las funciones que lo llamen también deben ser suspendidas o ejecutarse en un contexto adecuado (como una corrutina).

2. Configurar Hilt para la Inyección de Dependencias

Creamos un módulo de Hilt para proporcionar la instancia de la base de datos y el DAO. Esto asegura que las dependencias se gestionen correctamente, por lo tanto, no se debe llamar a Room.databaseBuilder directamente en la aplicación.

package com.example.demoapp.di

import android.content.Context
import androidx.room.Room
import com.example.demoapp.data.local.AppDatabase
import com.example.demoapp.data.local.dao.UserDao
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import jakarta.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {

     @Provides
     @Singleton
     fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
          // Proporciona la instancia de la base de datos
          return Room.databaseBuilder(
               context,
               AppDatabase::class.java, "app-database"
          ).build()
     }

     @Provides
     fun provideUserDao(database: AppDatabase): UserDao {
          // Se inicializa el DAO a partir de la base de datos
          return database.userDao()
     }
}

Cuando se ejecute la aplicación, Hilt se encargará de crear y proporcionar las instancias necesarias de AppDatabase y UserDao donde se requieran.

3. Crear los Casos de Uso

Siguiendo la arquitectura que venimos usando, el ViewModel no accede al repositorio directamente, sino a través de casos de uso. Cree los casos de uso en el paquete domain/usecase/user. Como las operaciones del repositorio son suspend, los casos de uso también lo serán.

package com.example.demoapp.domain.usecase.user

import com.example.demoapp.data.local.entity.UserEntity
import com.example.demoapp.data.repository.local.UserLocalRepository
import jakarta.inject.Inject

class InsertUserUseCase @Inject constructor(
    private val userLocalRepository: UserLocalRepository
) {
    suspend operator fun invoke(user: UserEntity) {
        userLocalRepository.insert(user)
    }
}
package com.example.demoapp.domain.usecase.user

import com.example.demoapp.data.local.entity.UserEntity
import com.example.demoapp.data.repository.local.UserLocalRepository
import jakarta.inject.Inject

class GetUsersUseCase @Inject constructor(
    private val userLocalRepository: UserLocalRepository
) {
    suspend operator fun invoke(): List<UserEntity> = userLocalRepository.getAll()
}

4. Usar los Casos de Uso en el ViewModel

Finalmente, inyectamos los casos de uso en nuestro ViewModel para acceder a los datos. Supongamos que tenemos un ViewModel llamado UserViewModel:

// Importaciones necesarias ...

@HiltViewModel
class UserViewModel @Inject constructor(
     private val insertUserUseCase: InsertUserUseCase,
     private val getUsersUseCase: GetUsersUseCase
) : ViewModel() {

     // Patrón de StateFlow que venimos usando en el curso para exponer estado a la UI
     private val _users = MutableStateFlow<List<UserEntity>>(emptyList())
     val users: StateFlow<List<UserEntity>> = _users.asStateFlow()

     fun insert(user: UserEntity) {
          // Se utiliza una corrutina para insertar el usuario ya que es una operación suspend
          viewModelScope.launch {
               insertUserUseCase(user)
               loadUsers() // Refresca la lista después de insertar
          }
     }

     fun loadUsers() {
          // Se utiliza una corrutina para obtener los usuarios ya que es una operación suspend
          viewModelScope.launch {
               _users.value = getUsersUseCase()
          }
     }
}

En la pantalla, los usuarios se observan con collectAsState(), igual que en el resto de las guías.

En el ejemplo anterior, ambas funciones usan UserEntity directamente. Sin embargo, en una arquitectura más limpia, es recomendable utilizar modelos de dominio separados para evitar acoplar la capa de presentación con la capa de datos.

Esta arquitectura busca que el DAO esté encapsulado dentro del repositorio, que los casos de uso encapsulen cada acción de negocio, y que el ViewModel interactúe únicamente con los casos de uso para obtener los datos. Esto mejora la separación de responsabilidades y facilita las pruebas unitarias.


Conclusión

Room es una herramienta poderosa para la persistencia de datos en Android, que facilita el manejo de bases de datos SQLite. Al utilizar Room, podemos definir entidades, DAOs y bases de datos de manera más sencilla y segura, lo que mejora la calidad del código y reduce la probabilidad de errores. Aunque la persistencia local no siempre es necesaria en aplicaciones móviles, existen casos donde es fundamental para ofrecer una mejor experiencia al usuario.