Vamos a partir de nuestra Agenda de Contactos con navegación y vamos a añadir a añadir como fuente de datos una BD local de SQLite a la que accederemos usando el ORM room proporcionado por Jetpack Compose.
Solución
Si te surge alguna duda o tienes dificultades para completar este caso de estudio. Puedes descargar la solución de este caso de estudio del siguiente enlace: propuesta de solución
Para usar room en nuestra aplicación debemos añadir dependencias que se describen en los apuntes de room del módulo.
Crearemos el paquete .data.room dentro del cual definiremos las clases que nos permitirán acceder a la BD.
Dentro del paquete .data.room definiremos la entidad Contacto que será la clase que represente sus datos en la BD.
@Entity(tableName = "contactos") data class ContactoEntity( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Int, @ColumnInfo(name = "nombre") val nombre: String, @ColumnInfo(name = "apellidos") val apellidos: String, @ColumnInfo(name = "telefono") val telefono: String, @ColumnInfo(name = "email") val email: String, @ColumnInfo(name = "foto", typeAffinity = ColumnInfo.BLOB) val foto: String?, @ColumnInfo(name = "categorias") val categorias: String )
FÃjate en el código anterior que la imagen del contacto, la gradaremos en la BD como un tipo BLOB que es un array de bytes. Sin embargo, en la entidad ContactoEntity la imagen la tenemos como un String que será una cadena en base64 tal y como la usamos en nuestro modelo. Para ello debemos definir un conversor de tipos que nos permita convertir de un tipo a otro. Es por eso que crearemos la clase RoomConverters dentro del paquete .data.room.
class RoomConverters {
@TypeConverter
fun toBlob(value: ByteArray?): String? = Base64.encodeToString(value, Base64.DEFAULT)
@TypeConverter
fun fromBlob(value: String?): ByteArray? = Base64.decode(value, Base64.DEFAULT)
}
Vamos a definir el DAO sobre la entidad ContactoEntity. Para ello crearemos la interfaz ContactoDao dentro del paquete .data.room. En el, definiremos los métodos que nos permitirán realizar las operaciones CRUD sobre dicha entidad.
FÃjate que todos los métodos son suspend. De esta manera estamos indicando que son métodos que se ejecutarán en un hilo secundario (Dispatchers.IO) y que por tanto no bloquearán el hilo principal.
@Dao
interface ContactoDao {
@Insert
suspend fun insert(contacto : ContactoEntity)
@Delete
suspend fun delete(contacto : ContactoEntity)
@Update(onConflict = OnConflictStrategy.ABORT)
suspend fun update(contacto : ContactoEntity)
@Query("SELECT COUNT(*) FROM contactos")
suspend fun count(): Int
@Query("SELECT * FROM contactos")
suspend fun get(): List<ContactoEntity>
@Query("SELECT * FROM contactos WHERE id = :id")
suspend fun get(id: Int): ContactoEntity
}
Definimos la clase abstracta AgendaDb que extiende de RoomDatabase y que nos permitirá acceder a la BD. En ella definiremos:
@Database que nos permitirá indicar el nombre de la BD, la versión y las entidades que contiene.@TypeConverters que nos permitirá indicar los conversores de tipos que usaremos.getDatabase() que nos devolverá una instancia de la BD.contactoDao() que nos devolverá el DAO sobre la entidad ContactoEntity.@Database(
entities = [ContactoEntity::class],
exportSchema = false,
version = 1
)
@TypeConverters(RoomConverters::class)
abstract class AgendaDb : RoomDatabase() {
abstract fun contactoDao(): ContactoDao
companion object {
fun getDatabase(context: Context) = Room.databaseBuilder(
context,
AgendaDb::class.java, "agenda"
)
.allowMainThreadQueries()
.fallbackToDestructiveMigration()
.build()
}
}
En el fichero AppModule.kt dentro del paquete .di definiremos como inyectar la BD provideAgendaDatabase y el DAO provideContactoDao en nuestra aplicación.
FÃjate que la anotación @ApplicationContext inyecta el contexto de la aplicación. De esta manera podrá crear el fichero de la BD en el sistema de ficheros accesible por el contexto de la misma.
@Module @InstallIn(SingletonComponent::class) class AppModule { @Provides @Singleton fun provideAgendaDatabase( @ApplicationContext context: Context ) : AgendaDb = AgendaDb.getDatabase(context) @Provides @Singleton fun provideContactoDao( db: AgendaDb ) : ContactoDao = db.contactoDao() // En el proveedor del repositorio sustituimos DaoMock por el Dao @Provides @Singleton fun provideContactoRepository( contactoDao: ContactoDao ) : ContactoRepository = ContactoRepository(contactoDao) }
Necesitamos mapear las entidades de la BD a nuestro modelo y viceversa.
Lo normal es que sea inmediato pero no tiene que ser asÃ. Por ejemplo, si te fijas las categorias dentro de nuestro ContactoEntity es de tipo string por lo cual la forma de guardarlas será seguramente el nombre de las mismas separador por comas "Amigos,Trabajo,Familia" , pero en la clase Contacto de nuestro modelo era un array de tipo enumerado EnumSet<Categorias>.
Es por esto que en RepositoryConverters.kt dentro del paquete .data definiremos los métodos que nos permitirán convertir de un tipo a otro como hicimos a al principio con las clases de Mock y el modelo.
fun EnumSet<Contacto.Categorias>.toCategoriaEntity() =
joinToString(separator = ",") { it.name }
fun Contacto.toContactoEntity() = ContactoEntity(
id = id,
nombre = nombre,
apellidos = apellidos,
foto = foto,
email = correo,
telefono = telefono,
categorias = categorias.toCategoriaEntity()
)
fun String.toEnumSetCategorias(): EnumSet<Contacto.Categorias> {
val categorias = EnumSet.noneOf(Contacto.Categorias::class.java)
val textos = this.split(",")
textos.forEach { categoria ->
if (!categoria.isNullOrEmpty())
categorias.add(Contacto.Categorias.valueOf(categoria))
}
return categorias
}
fun ContactoEntity.toContacto() = Contacto(
id = id,
nombre = nombre,
apellidos = apellidos,
foto = foto,
correo = email,
telefono = telefono,
categorias = categorias.toEnumSetCategorias()
)
Vamos a reescribir el código del repositorio ContactoRepository dentro del paquete .data . En el, reescribiremos los métodos para usar ContactoDao.kt en lugar de ContactosMock.kt.
Nota
En la gran mayorÃa de ejemplos de Internet este paso lo hacen combinando el Facade Pattern con el Repository Pattern manteniendo asà ambos repositorios (Repository en el fondo es una concreción de Facade). Sin embargo, en nuestro ejemplo no lo vamos a hacer asÃ, ya que supondrÃa un mayor nivel de complejidad en la inyección de dependencias, teniendo que definir nuestras propias anotaciones para saber que concreción de la abstracción del repositorio vamos a inyectar en el ViewModel.
Básicamente la implementación será igual a la que tenÃamos con el DaoMock pero usando el Dao de Room....
class ContactoRepository @Inject constructor(
private val dao: ContactoDao
) {
suspend fun get(): List<Contacto> = withContext(Dispatchers.IO) {
dao.get().map { it.toContacto() }.toList()
}
suspend fun get(id: Int): Contacto = withContext(Dispatchers.IO) {
val dato = dao.get(id)
dato!!.toContacto()
}
suspend fun insert(contacto: Contacto) = withContext(Dispatchers.IO) {
dao.insert(contacto.toContactoEntity())
}
suspend fun update(contacto: Contacto) = withContext(Dispatchers.IO) {
dao.update(contacto.toContactoEntity())
}
suspend fun delete(id: Int) = withContext(Dispatchers.IO) {
dao.delete(dao.get(id))
}
}
Vamos a cargar los datos de Mock en la BD. Para ello, en el fichero AgendaApplication.kt dentro del paquete com.pmdm.agenda invalidaremos un método onCreate() del ciclo de vida de la aplicación y que se ejecutará al iniciar la misma. En él, comprobaremos si la BD está vacÃa y en caso afirmativo cargaremos los datos de Mock en la BD.
@HiltAndroidApp
class AgendaApplication : Application() {
@Inject
lateinit var daoMock: ContactoDaoMock
@Inject
lateinit var daoEntity: ContactoDao
override fun onCreate() {
super.onCreate()
runBlocking {
if (daoEntity.count() == 0)
daoMock.get().forEach { daoEntity.insert(it.toContacto().toContactoEntity()) }
}
}
}
En la primera ejecución creará la BD si no existe y cargará todos los datos. Recuerda que la DB se encuentra en la ruta de nuestro dispositivo /data/data/com.pmdm.agenda/databases y por tanto si borramos su contenido o la vaciamos del todo se volverá a cargar.