Last Updated: 2021-11-10

¿Por qué utilizar bases de datos locales?

Como se ha mencionado en tutoriales anteriores, una de las mejores opciones para almacenar información localmente en cachés es optar por una base de datos local. Hacer esto permite que la información en memoria sea almacenada con esquemas relacionales complejos y permite, a su vez, que el manejo de esta información esté a cargo de una tecnología sólida para bases de datos como lo es SQLite.

Adicionalmente, utilizar Room en una aplicación Android es la opción que más se ajusta al resto de la arquitectura de Jetpack, por lo que será considerablemente mucho más sencillo utilizar los mismos esquemas de información para todas las partes de la aplicación que lo requieren.

¿Qué construirá?

En este tutorial se trabajará sobre una aplicación desarrollada para un tutorial anterior. Dicha aplicación permite visualizar la información de un API REST que contiene información acerca de coleccionistas de álbumes musicales y los comentarios que realizan sobre ellos.

Al final de este tutorial usted tendrá:

¿Qué aprenderá?

Al desarrollar este tutorial aprenderá:

¿Qué necesita?

El proyecto que se ha desarrollado en tutoriales anteriores no incluye soporte para poder efectuar varios de los cambios que se realizarán en este tutorial. Por este motivo, el primer paso a seguir consiste en incluir nuevas dependencias en la configuración de Gradle de

Room es una parte de las librerías de Jetpack, la cual requiere ser explícitamente instalada, pues no viene como parte de las librerías que ya se han agregado al archivo build.gradle. Para incluir el soporte a Room, debe seguir estos pasos:

  1. Agregar la variable roomVersion en el archivo build.gradle a nivel de proyecto. Para esto, pegue la siguiente línea de código en la sección buildscript:
buildscript {
    ext.kotlin_version = "1.3.72"
    ext.nav_version = "2.3.1"
...
  1. Abra el archivo build.gradle del módulo :app en el editor de Android Studio.
  2. Diríjase al apartado llamado dependencies. Agregue las siguientes líneas de código:
dependencies {
    // Room components
    implementation "androidx.room:room-ktx:$rootProject.roomVersion"
    kapt "androidx.room:room-compiler:$rootProject.roomVersion"
    androidTestImplementation "androidx.room:room-testing:$rootProject.roomVersion"
}
  1. Agregar el plugin kapt-kotlin, con las siguientes líneas de código en una de las primeras líneas del mismo archivo build.gradle:
apply plugin: 'kotlin-kapt'
  1. Sincronice el proyecto con los nuevos cambios de Gradle con el botón Gradle Sync.

Luego de que termine el proceso de sincronización, revise si el resultado es adecuado o hubo algún error indicado en la parte inferior del IDE. Es posible que existan errores relacionados con su versión de Gradle o la versión de alguna de las librerías. Si este es el caso, Android Studio le ayudará a actualizarlas a las versiones adecuadas por medio de sugerencias del Lint (la herramienta de análisis estático de código de Android Studio).

Hasta el momento, en los tutoriales anteriores, en su aplicación se han definido los modelos de las entidades como simples data classes. Ahora, estos modelos pasarán a ser los esquemas que utilizará para definir las tablas de su base de datos local.

Es muy importante que tenga en cuenta que en otros escenarios puede suceder que la información que quiere guardar en memoria local con Room sea distinta a la información que muestra en sus vistas. No siempre vale la pena almacenar toda la información en caché. Sin embargo, en el caso de este tutorial, sí se implementará un esquema de Room para cada una de las entidades, puesto que toda la información de la aplicación se conecta entre sí.

Modificar la clase Collector

La primera clase que se modificará es la que modela la tabla de coleccionistas. Es necesario agregar la anotación Entity a la clase, para que pueda ser utilizada como esquema de datos, indicando como parámetro el nombre de la tabla. El encabezado de la clase debe verse como a continuación:

@Entity(tableName = "collectors_table")
data class Collector (

Además de esta anotación, es necesario incluir la anotación PrimaryKey para indicar que el atributo collectorId representa el identificador único de cada fila de la tabla. Así, la clase en su totalidad se ve de la siguiente forma:

@Entity(tableName = "collectors_table")
data class Collector (
   @PrimaryKey val collectorId: Int,
   val name:String,
   val telephone:String,
   val email:String
)

Modificar la clase Album

La siguiente clase a modificar es Album. En este caso, las anotaciones a incluir son las mismas de la entidad Collectors, con la diferencia del nombre de la tabla que representa la entidad. Modifique el código con las mismas consideraciones mencionadas anteriormente, de forma que su código se vea como el siguiente:

@Entity(tableName = "albums_table")
data class Album (
       @PrimaryKey val albumId:Int,
       val name:String,
       val cover:String,
       val releaseDate:String,
       val description:String,
       val genre:String,
       val recordLabel:String
)

Modificar la clase Comment

Finalmente, debe modificar la clase Comment. Esta clase no contiene una llave primaria, por lo cual es innecesario considerar dicha anotación. Incluya entonces la anotación Entity en el encabezado de la clase para definir la tabla que representa esta entidad.

@Serializable
@Entity(tableName = "comments_table")
data class Comment  (
   val description:String,
   val rating:String,
   val albumId:Int,
   @PrimaryKey(autoGenerate = true)
   val commentId:Int = 0
)

Cada entidad incluida con Room requiere de un Data Access Object (DAO), el cual se encargue de realizar las consultas necesarias para cada entidad en la base de datos local. Para declarar los DAOs, cree un nuevo directorio en el directorio del código fuente con el nombre database. Dentro de este, cree otro directorio con el nombre dao. Ahora en este subdirectorio, cree una interfaz para cada una de las entidades, haciendo clic derecho en el directorio y seleccionando la opción New > Kotlin File/Class > Interface con los nombres AlbumsDao, CollectorsDao, CommentsDao.

A continuación, se explican los pasos a seguir para completar cada uno de los DAOs.

Crear CollectorsDao

Al abrir el archivo podrá ver una estructura vacía de tipo interface. Lo primero que debe hacer es agregar la anotación Dao en el encabezado de su interfaz. De esta forma, el encabezado de la clase debe verse de la siguiente forma:

@Dao
interface CollectorsDao {
}

Luego de esto, incluya dentro de la interfaz los métodos pertinentes para obtener la información de los coleccionistas, agregar uno nuevo, y un método para eliminarlos de la base de datos local. Los métodos mencionados deben ser anotados con otras anotaciones de Room que permiten ejecutar consultas SQLite para fines específicos. El siguiente código corresponde a la declaración de la función abstracta que permite obtener los coleccionistas:

@Query("SELECT * FROM collectors_table")
fun getCollectors():List<Collector>

Note que se utiliza la anotación Query, y se pasa como parámetro una cadena de texto con la sentencia SQL a ejecutar para obtener la información de la tabla (cuyo nombre usted especificó como parte de las anotaciones de la entidad Collector). Este método retorna una lista con la entidad Collector.

En cuanto al método para agregar un coleccionista, la declaración debe hacerse de la siguiente forma:

@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insert(collector: Collector)

Note que en el caso de esta operación no es necesario declarar una consulta para poder insertar el dato. En su lugar, se utiliza la anotación Insert, indicando la estrategia de resolución de conflictos. Este método no tiene retorno y es declarado como una función de suspensión.

Finalmente, el código para eliminar todos los coleccionistas corresponde al siguiente:

@Query("DELETE FROM collectors_table")
suspend fun deleteAll()

Una vez más, esta operación se logra por medio de la anotación Query, indicando por medio de una cadena de texto la sentencia SQL a ejecutar. Este método tampoco tiene retorno y se declara como función de suspensión.

Crear AlbumsDao

En el caso de esta entidad, el DAO para manipular la información puede funcionar de la misma forma que el del apartado anterior. Para todo DAO, debe incluir la anotación Dao para la interfaz, así que parta de allí para crear el AlbumsDao.

Para esta entidad, los métodos declarados en la interfaz pueden ser idénticos a los del CollectorsDao, dado que se requiere realizar las mismas operaciones: Obtener todos los álbumes, crear un álbum y eliminar todos los álbumes.

Si lo desea, puede copiar el código de la interfaz CollectorsDao en esta interfaz y refactorizar las referencias, de forma que se utilice la entidad y la tabla adecuada.

Finalmente, su interfaz AlbumsDao debe contener el siguiente código:

@Dao
interface AlbumsDao {
   @Query("SELECT * FROM albums_table")
   fun getAlbums():List<Album>

   @Insert(onConflict = OnConflictStrategy.IGNORE)
   suspend fun insert(album: Album)

   @Query("DELETE FROM albums_table")
   suspend fun deleteAll():Int
}

Crear CommentsDao

Finalmente, para crear el DAO de la entidad correspondiente a los comentarios de un álbum, hay que tener presente que cada comentario está vinculado a un álbum, y no tiene una llave primaria inherente. Esto causará que las consultas SQL que debe ejecutar sean distintas y utilicen un filtro Where.

Anote la interfaz con la anotación Dao, y luego agregue el código para la función que permite obtener los comentarios de un álbum indicado por parámetro. Este debe corresponder al siguiente:

@Query("SELECT * FROM comments_table WHERE albumId = :albumId ORDER BY rating DESC")
fun getComments(albumId:Int):List<Comment>

Note que hay una diferencia en el valor de la consulta indicada en la anotación Query, ya que se incluyó un filtro Where albumId = :albumId. Esta sintaxis permite incorporar los parámetros del método abstracto a la consulta.

Ahora, agregue el código para agregar un comentario. Este método funciona igual que en los casos anteriores.

@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insert(comment: Comment)

Luego de esto, agregue el siguiente código para eliminar los comentarios de un álbum:

@Query("DELETE FROM comments_table")
suspend fun clear():Void

@Query("DELETE FROM comments_table WHERE albumId = :albumId")
suspend fun deleteAll(albumId: Int):Int

Allí podrá ver una nueva función donde, una vez más, se utiliza la anotación Query, utilizando el albumId como parámetro para filtrar los comentarios que se deben eliminar.

Luego de definir los DAOs, es necesario unificar todas las entidades y consultas que se van a utilizar dentro de la aplicación en una única base de datos. Para esto, se hará uso de un Singleton y se invocará a nivel de aplicación.

Crear la clase de tipo database

Dentro de su directorio database, cree una clase llamada VinylRoomDatabase. Esta clase debe ser de tipo abstracta y heredar de RoomDatabase. El encabezado de esta clase debe verse de la siguiente forma:

@Database(entities = [Album::class, Collector::class, Comment::class], version = 1, exportSchema = false)
abstract class VinylRoomDatabase : RoomDatabase() {
}

Note que en la anotación Database, se indica por parámetro un arreglo de DAOs, donde se incluyen todos los DAOs creados en el paso anterior. Además de esto, se indica por parámetro a la base de datos el número de la versión (el cual sirve para migrar de esquemas en caso de ser necesario), y un booleano indicando si se exporta el esquema.

Ahora dentro de esta clase abstracta, defina una propiedad para cada uno de los DAOs de la siguiente forma:

abstract fun albumsDao(): AlbumsDao
abstract fun collectorsDao(): CollectorsDao
abstract fun commentsDao(): CommentsDao

Además de esto, defina un método getInstance, el cual va a retornar un objeto de tipo VinylsDatabase. El cuerpo de este método será muy similar al que desarrolló en tutoriales anteriores para entregar un Singleton del NetworkServiceAdapter.

Finalmente, su clase debe verse de la siguiente forma:

@Database(entities = [Album::class, Collector::class, Comment::class], version = 1, exportSchema = false)
abstract class VinylRoomDatabase : RoomDatabase() {

   abstract fun albumsDao(): AlbumsDao
   abstract fun collectorsDao(): CollectorsDao
   abstract fun commentsDao(): CommentsDao
  
   companion object {
       // Singleton prevents multiple instances of database opening at the
       // same time.
       @Volatile
       private var INSTANCE: VinylRoomDatabase? = null

       fun getDatabase(context: Context): VinylRoomDatabase {
           // if the INSTANCE is not null, then return it,
           // if it is, then create the database
           return INSTANCE ?: synchronized(this) {
               val instance = Room.databaseBuilder(
                   context.applicationContext,
                   VinylRoomDatabase::class.java,
                   "vinyls_database"
               ).build()
               INSTANCE = instance
               // return instance
               instance
           }
       }
   }
}

Instanciar la base de datos a nivel de aplicación

Para instanciar la base de datos a nivel de aplicación es necesario que modifique la clase dentro del archivo llamado VinylApplication que se encuentra en el directorio raíz del código fuente. Esta clase debe heredar de Application, y su contenido es únicamente un atributo de la instancia de la base de datos, como se muestra a continuación:

class VinylsApplication: Application()  {
   val database by lazy { VinylRoomDatabase.getDatabase(this) }
}

Ahora, en todos los fragmentos, cuando se inicializen los ViewModels de cada entidad desde las factories, indique que la aplicación se representa por medio de esta nueva clase, de la siguiente forma:

viewModel = ViewModelProvider(this, AlbumViewModel.Factory(activity.application
)).get(AlbumViewModel::class.java)

Ahora que ya definió todas las partes de su base de datos y creó una única instancia, usted puede utilizar la base de datos local de Room para almacenar la información que definió y comunicarla con las vistas por medio del ViewModel. Recuerde que la responsabilidad de consultar las fuentes de datos fue delegada al repositorio.

Por este motivo, abra el archivo CollectorsRepository y modifique el código del método refreshData para consultar en primera instancia al caché, y en caso de no encontrarlo, consultar por el estado de red para hacer la petición por medio del NetworkServiceAdapter, o retornar una lista vacía en caso de no contar con conexión. Con lo anterior podrá reproducir la estrategia de caché utilizada en tutoriales anteriores.

El código del CollectorsRepository debe verse de la siguiente forma:

class CollectorsRepository (val application: Application, private val collectorsDao: CollectorsDao){
    suspend fun refreshData(): List<Collector>{
        var cached = collectorsDao.getCollectors()
        return if(cached.isNullOrEmpty()){
            val cm = application.baseContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
            if( cm.activeNetworkInfo?.type != ConnectivityManager.TYPE_WIFI && cm.activeNetworkInfo?.type != ConnectivityManager.TYPE_MOBILE){
                emptyList()
            } else NetworkServiceAdapter.getInstance(application).getCollectors()
        } else cached
   }
}

Usted puede replicar este comportamiento en los otros dos repositorios copiando el código y haciendo una refactorización de los parámetros utilizados.

Ya que en todos los repositorios se pasa un nuevo parámetro para el Dao respectivo de cada entidad, es necesario que modifique el código de los tres ViewModels. El cambio que debe hacer es instanciar el Dao. Sin embargo, como todos los Dao fueron declarados como interfaces, es necesario acceder a ellos desde la instancia de la base de datos, y asegurarse de que el código relacionado a esta última se haya autogenerado adecuadamente.

Para llevar a cabo el cambio de los ViewModels, abra el archivo CollectorViewModel.kt, y agregue el parámetro del repository de la siguiente forma:

private val collectorsRepository = CollectorsRepository(application, VinylRoomDatabase.getDatabase(application.applicationContext).collectorsDao())

Nuevamente, replique este cambio en el código de los archivos AlbumViewModel.kt y CommentViewModel.kt con el respectivo Dao.

Una vez haya terminado de implementar estos cambios, pruebe nuevamente la aplicación. No podrá ver cambios significativos en la interfaz gráfica, pero podrá notar que la funcionalidad no fue afectada y que, cuando la información es persistida localmente, su aplicación muestra la información de forma más confiable y rápida.

¡Felicidades!

Al finalizar este tutorial pudo familiarizarse con los conceptos básicos para utilizar Room, comprendiendo su utilidad y la forma en que se integran con su arquitectura Jetpack + MVVM. Así mismo, pudo comprender cómo implementar una base de datos local en un proyecto de Android en Kotlin.

Ahora podrá aplicar estos patrones de diseño a sus propios proyectos de Android, de forma que estos cuenten con un almacenamiento local sólido y sencillo de manejar.

Créditos

Versión 1.2 - Noviembre 10, 2021

Juan Sebastián Espitia Acero

Autor

Norma Rocio Héndez Puerto

Revisora

Mario Linares Vásquez

Revisor