Cómo conservar datos con Room

1. Antes de comenzar

La mayoría de las apps de calidad de producción tienen datos que deben guardarse. Por ejemplo, una app podría almacenar una lista de reproducción de canciones, elementos de una lista de tareas pendientes, registros de gastos e ingresos, un catálogo de constelaciones o un historial de datos personales. En la mayoría de estos casos, se usa una base de datos para almacenar esos datos persistentes.

Room es una biblioteca de persistencias que forma parte de Android Jetpack. Es una capa de abstracción que se ubica sobre una base de datos SQLite. SQLite usa un lenguaje especializado (SQL) para realizar operaciones de bases de datos. En lugar de usar SQLite directamente, Room simplifica las tareas de configuración de la base de datos, así como las interacciones con la app. Room también proporciona verificaciones en tiempo de compilación de las instrucciones de SQLite.

Una capa de abstracción es un conjunto de funciones que ocultan la implementación o la complejidad subyacente. Proporciona una interfaz para un conjunto existente de funciones, como SQLite en este caso.

En la siguiente imagen, se puede apreciar el modo en que Room, como fuente de datos, se adapta a la arquitectura general recomendada en este curso. Room es una fuente de datos.

La capa de datos contiene repositorios y fuentes de datos

Requisitos previos

  • Saber compilar una interfaz de usuario (IU) básica de una app para Android con Jetpack Compose
  • Saber usar elementos componibles como Text, Icon, IconButton y LazyColumn
  • Saber usar el elemento componible NavHost para definir rutas y pantallas en tu app
  • Saber navegar entre pantallas con un NavHostController
  • Conocer el componente de la arquitectura de Android ViewModel y saber usar ViewModelProvider.Factory para crear una instancia de ViewModels
  • Conocer los conceptos básicos de simultaneidad
  • Saber usar corrutinas para tareas de larga duración
  • Conocimientos básicos sobre las bases de datos SQLite y el lenguaje SQL

Qué aprenderás

  • Cómo crear la base de datos SQLite y cómo interactuar con ella mediante la biblioteca Room
  • Cómo crear una entidad, un objeto de acceso a datos (DAO) y clases de bases de datos
  • Cómo usar un DAO para asignar funciones de Kotlin a consultas en SQL

Qué compilarás

  • Compilarás una app de Inventory que guarde elementos de inventario en la base de datos SQLite.

Requisitos

  • Código de inicio de la app de Inventory
  • Una computadora con Android Studio
  • Un dispositivo o un emulador con nivel de API 26 o posterior

2. Descripción general de la app

En este codelab, trabajarás con un código de partida de la app de Inventory y le agregarás la capa de la base de datos con la biblioteca de Room. La versión final de la app mostrará una lista de elementos de la base de datos de inventario. El usuario tendrá opciones para agregar un elemento nuevo, actualizar uno existente y borrarlo de la base de datos de inventario. En este codelab, guardarás los datos del elemento en la base de datos de Room. Completarás el resto de la funcionalidad de la app en el siguiente codelab.

Pantalla de teléfono con elementos del inventario

Pantalla Add Item que aparece en la pantalla del teléfono

Pantalla Add Item con detalles del elemento completados

3. Descripción general de la app de inicio

Descarga el código de partida para este codelab

Para comenzar, descarga el código de partida:

Descargar ZIP

Como alternativa, puedes clonar el repositorio de GitHub para el código:

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-inventory-app.git
$ cd basic-android-kotlin-compose-training-inventory-app
$ git checkout starter

Puedes explorar el código en el repositorio de GitHub de Inventory app.

Descripción general del código de partida

  1. Abre el proyecto con el código de partida en Android Studio.
  2. Ejecuta la app en un dispositivo Android o en un emulador. Asegúrate de que el emulador o dispositivo conectado ejecute un nivel de API 26 o uno superior. El Inspector de bases de datos funciona en emuladores y dispositivos que ejecutan el nivel de API 26 o uno posterior.
  1. Observa que la app no muestra datos de inventario.
  2. Presiona el botón de acción flotante (BAF), que te permite agregar elementos nuevos a la base de datos.

La app navega a una pantalla nueva en la que puedes ingresar los detalles del elemento nuevo.

Inventario vacío de la pantalla del teléfono

Pantalla Add Item que aparece en la pantalla del teléfono

Problemas con el código de partida

  1. En la pantalla Add Item, ingresa los detalles de un elemento, como el nombre, el precio y la cantidad.
  2. Presiona Guardar. La pantalla Add Item no se cierra, pero puedes navegar hacia atrás con la tecla de volver. La función de guardar no está implementada, por lo que no se guardan los detalles del elemento.

Ten en cuenta que la app está incompleta y no se implementa la funcionalidad del botón Save.

Pantalla Add Item con detalles del elemento completados

En este codelab, agregarás el código que usa Room para guardar los detalles del inventario en la base de datos SQLite. Usas la biblioteca de persistencias Room para interactuar con la base de datos SQLite.

Explicación del código

El código de partida que descargaste tiene diseños de pantalla prediseñados. En esta ruta de aprendizaje, te enfocarás en implementar la lógica de la base de datos. La siguiente sección es una breve explicación de algunos de los archivos para comenzar.

ui/home/HomeScreen.kt

Este archivo es la pantalla principal o la primera pantalla de la app, que contiene los elementos componibles para mostrar la lista de inventario. Tiene un BAF + para agregar elementos nuevos a la lista. Mostrarás los elementos de la lista más adelante en la ruta de aprendizaje.

Pantalla de teléfono con elementos del inventario

ui/item/ItemEntryScreen.kt

Esta pantalla es similar a ItemEditScreen.kt. Ambos tienen campos de texto para los detalles del elemento. Esta pantalla se muestra cuando se presiona el BAF en la pantalla principal. ItemEntryViewModel.kt es el ViewModel correspondiente de esta pantalla.

Pantalla Add Item con detalles del elemento completados

ui/navigation/InventoryNavGraph.kt

Este archivo es el gráfico de navegación de toda la aplicación.

4. Componentes principales de Room

Kotlin ofrece una manera fácil de trabajar con datos a través de clases de datos. Si bien es fácil trabajar con datos en la memoria mediante clases de datos, cuando se trata de datos persistentes, debes convertirlos en un formato compatible con el almacenamiento de bases de datos. De este modo, necesitas tablas para almacenar los datos y consultas para acceder a ellos y modificarlos.

Los siguientes tres componentes de Room facilitan estos flujos de trabajo.

  • Las entidades de Room representan tablas de la base de datos de tu app. Se usan para actualizar los datos almacenados en filas de las tablas y crear filas nuevas para insertarlas.
  • Los DAO de Room proporcionan métodos que tu app usa para recuperar, actualizar, insertar y borrar datos en la base de datos.
  • La clase de Database de Room es la clase de base de datos que proporciona a tu app instancias de los DAO asociados con esa base de datos.

Más adelante en este codelab, implementarás estos componentes y aprenderás más sobre ellos. En el siguiente diagrama, se muestra cómo los componentes de Room funcionan en conjunto para interactuar con la base de datos.

a3288e8f37250031.png

Agrega dependencias de Room

En esta tarea, agregarás las bibliotecas de componentes de Room necesarias a tus archivos Gradle.

  1. Abre el archivo de Gradle de nivel de módulo build.gradle.kts (Module: InventoryApp.app).
  2. En el bloque dependencies, agrega las dependencias para la biblioteca Room que se muestra en el siguiente código.
//Room
implementation("androidx.room:room-runtime:${rootProject.extra["room_version"]}")
ksp("androidx.room:room-compiler:${rootProject.extra["room_version"]}")
implementation("androidx.room:room-ktx:${rootProject.extra["room_version"]}")

KSP es una API simple y potente para analizar anotaciones de Kotlin.

5. Crea un elemento Entity

Una clase Entity define una tabla, y cada instancia de esta clase representa una fila en la tabla de la base de datos. Asimismo, tiene asignaciones para indicarle a Room cómo pretende presentar la información en la base de datos e interactuar con ella. En tu app, la entidad conserva información sobre los elementos del inventario, como el nombre, el precio y la cantidad disponible.

8c9f1659ee82ca43.png

La anotación @Entity marca una clase como una clase Entity de base de datos. Para cada clase Entity, la app crea una tabla de base de datos que contenga los elementos. Cada campo de Entity se representa como una columna en la base de datos, a menos que se indique lo contrario (consulta la documentación sobre Entity para obtener más información). Cada instancia de entidad que se almacena en la base de datos debe tener una clave primaria. La clave primaria se usa para identificar de manera única cada registro o entrada en las tablas de tu base de datos. Una vez que la app asigna una clave primaria, no se puede modificar. Representa el objeto de la entidad, siempre que exista en la base de datos.

En esta tarea, crearás una clase Entity y definirás campos para almacenar la siguiente información de inventario para cada elemento: Int para almacenar la clave primaria, String para almacenar el nombre del elemento, double para almacenar el precio del elemento y Int para almacenar la cantidad en stock.

  1. Abre el código de partida en Android Studio.
  2. Abre el paquete data en el paquete base com.example.inventory.
  3. Dentro del paquete data, abre la clase de Kotlin Item, que representa una entidad de base de datos en tu app.
// No need to copy over, this is part of the starter code
class Item(
    val id: Int,
    val name: String,
    val price: Double,
    val quantity: Int
)

Clases de datos

Las clases de datos se usan principalmente para conservar datos en Kotlin. Se definen con la palabra clave data. Los objetos de clase de datos de Kotlin tienen algunos beneficios adicionales. Por ejemplo, el compilador genera automáticamente utilidades para comparar, imprimir y copiar elementos como toString(), copy() y equals().

Ejemplo:

// Example data class with 2 properties.
data class User(val firstName: String, val lastName: String){
}

Para garantizar la coherencia y el comportamiento significativo del código generado, las clases de datos deben cumplir con los siguientes requisitos:

  • El constructor principal debe tener al menos un parámetro.
  • Todos los parámetros del constructor principal deben ser val o var.
  • Las clases de datos no pueden ser abstract, open ni sealed.

Para obtener más información sobre las clases de datos, consulta la documentación correspondiente.

  1. Prefija la definición de la clase Item con la palabra clave data para convertirla en una clase de datos.
data class Item(
    val id: Int,
    val name: String,
    val price: Double,
    val quantity: Int
)
  1. Sobre la declaración de clase Item, anota la clase de datos con @Entity. Usa el argumento tableName para establecer items como el nombre de la tabla de SQLite.
import androidx.room.Entity

@Entity(tableName = "items")
data class Item(
   ...
)
  1. Anota la propiedad id con @PrimaryKey para que id sea la clave primaria. Una clave primaria es un ID para identificar de manera única cada registro o entrada en la tabla Item.
import androidx.room.PrimaryKey

@Entity(tableName = "items")
data class Item(
    @PrimaryKey
    val id: Int,
    ...
)
  1. Asigna a id un valor predeterminado de 0, que es necesario para que id genere automáticamente valores de id.
  2. Agrega el parámetro autoGenerate a la anotación @PrimaryKey para especificar si la columna de clave primaria se debe generar de forma automática. Si autoGenerate está configurado como true, Room generará automáticamente un valor único para la columna de clave primaria cuando se inserte una nueva instancia de entidad en la base de datos. Esto garantiza que cada instancia de la entidad tenga un identificador único, sin tener que asignar valores manualmente a la columna de clave primaria.
data class Item(
    @PrimaryKey(autoGenerate = true)
    val id: Int = 0,
    // ...
)

¡Muy bien! Ahora que creaste una clase Entity, puedes crear un objeto de acceso a datos (DAO) para acceder a la base de datos.

6. Crea el elemento DAO

El objeto de acceso a datos (DAO) es un patrón que puedes usar para separar la capa de persistencia del resto de la aplicación proporcionando una interfaz abstracta. Este aislamiento sigue el principio de responsabilidad única, que viste en los codelabs anteriores.

La funcionalidad del DAO es ocultar todas las complejidades relacionadas con la realización de operaciones de la base de datos en la capa de persistencia, aparte del resto de la aplicación. Esto te permite cambiar la capa de datos independientemente del código que usa los datos.

8b91b8bbd7256a63.png

En esta tarea, definirás un DAO para Room. Los DAO son los componentes principales de Room que son responsables de definir la interfaz que accede a la base de datos.

El DAO que creas es una interfaz personalizada que proporciona métodos convenientes para consultar/recuperar, insertar, borrar y actualizar la base de datos. Room genera una implementación de esta clase en el tiempo de compilación.

La biblioteca de Room proporciona anotaciones de conveniencia, como @Insert, @Delete y @Update, para definir métodos que realizan inserciones, actualizaciones y eliminaciones simples sin necesidad de escribir una instrucción de SQL.

Si necesitas definir operaciones más complejas para la inserción, actualización o eliminación, o si necesitas consultar los datos en la base de datos, usa una anotación @Query.

Como beneficio adicional, a medida que escribes tus consultas en Android Studio, el compilador comprueba si las consultas de SQL tienen errores de sintaxis.

En el caso de la app de Inventory, debes poder hacer lo siguiente:

  • Insertar o agregar un elemento nuevo
  • Actualizar un elemento existente para actualizar el nombre, el precio y la cantidad
  • Obtener un elemento específico según su clave primaria, id
  • Obtener todos los elementos para que puedas mostrarlos
  • Borrar una entrada de la base de datos

59aaa051e6a22e79.png

Completa los siguientes pasos para implementar el elemento DAO en tu app:

  1. En el paquete data, crea la interfaz de Kotlin ItemDao.kt.

Campo de nombre completado como elemento DAO

  1. Anota la interfaz ItemDao con @Dao.
import androidx.room.Dao

@Dao
interface ItemDao {
}
  1. Dentro del cuerpo de la interfaz, agrega una anotación @Insert.
  2. Debajo de @Insert, agrega una función insert() que tome una instancia del item de la clase Entity como su argumento.
  3. Marca la función con la palabra clave suspend para permitir que se ejecute en un subproceso separado.

Las operaciones de la base de datos pueden demorar mucho tiempo en ejecutarse, por lo que deben hacerlo en un subproceso independiente. Room no permite el acceso a la base de datos en el subproceso principal.

import androidx.room.Insert

@Insert
suspend fun insert(item: Item)

Cuando se insertan elementos en la base de datos, se pueden generar conflictos. Por ejemplo, varios lugares en el código intentan actualizar la entidad con valores diferentes, en conflicto, como la misma clave primaria. Una entidad es una fila en DB. En la app de Inventory, solo insertamos la entidad desde un lugar que es la pantalla Agregar elemento, por lo que no esperamos que haya ningún conflicto y podemos establecer la estrategia de conflicto como Ignorar.

  1. Agrega un argumento onConflict y asígnale un valor de OnConflictStrategy.IGNORE.

El argumento onConflict le indica a Room qué hacer en caso de conflicto. La estrategia OnConflictStrategy.IGNORE ignora un elemento nuevo.

Para obtener más información sobre las estrategias de conflicto disponibles, consulta la documentación de OnConflictStrategy.

import androidx.room.OnConflictStrategy

@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insert(item: Item)

Ahora, Room genera todo el código necesario para insertar item en la base de datos. Cuando llamas a cualquiera de las funciones DAO que están marcadas con anotaciones de Room, Room ejecuta la consulta en SQL correspondiente en la base de datos. Por ejemplo, cuando llamas al método anterior, insert() desde tu código Kotlin, Room ejecuta una consulta en SQL para insertar la entidad en la base de datos.

  1. Agrega una función nueva con la anotación @Update que tome un Item como parámetro.

La entidad que se actualiza tiene la misma clave primaria que la que se pasa. Puedes actualizar algunas o todas las demás propiedades de la entidad.

  1. Al igual que con el método insert(), marca esta función con la palabra clave suspend.
import androidx.room.Update

@Update
suspend fun update(item: Item)

Agrega otra función con la anotación @Delete para borrar elementos y convertirla en una función de suspensión.

import androidx.room.Delete

@Delete
suspend fun delete(item: Item)

No hay ninguna anotación de conveniencia para la funcionalidad restante, por lo que debes usar la anotación @Query y proporcionar consultas de SQLite.

  1. Escribe una consulta de SQLite para recuperar un elemento específico de la tabla de elementos según el id especificado. El siguiente código proporciona una consulta de muestra que selecciona todas las columnas de items, donde id coincide con un valor específico y id es un identificador único.

Ejemplo:

// Example, no need to copy over
SELECT * from items WHERE id = 1
  1. Agrega una anotación @Query.
  2. Usa la consulta de SQLite del paso anterior como un parámetro de cadena a la anotación @Query.
  3. Agrega un parámetro String a @Query, que es una consulta de SQLite para recuperar un elemento de la tabla correspondiente.

La consulta ahora indica que se seleccionen todas las columnas de items, donde id coincide con el argumento :id. Observa que :id usa la notación de dos puntos en la consulta para hacer referencia a argumentos en la función.

@Query("SELECT * from items WHERE id = :id")
  1. Después de la anotación @Query, agrega una función getItem() que tome un argumento Int y muestre un Flow<Item>.
import androidx.room.Query
import kotlinx.coroutines.flow.Flow

@Query("SELECT * from items WHERE id = :id")
fun getItem(id: Int): Flow<Item>

Se recomienda usar Flow en la capa de persistencia. Con Flow como el tipo de datos que se muestra, recibirás una notificación cada vez que cambien los datos de la base de datos. Room mantiene este Flow actualizado por ti, lo que significa que solo necesitas obtener los datos de forma explícita una vez. Esta configuración es útil para actualizar la lista de inventario, que implementarás en el siguiente codelab. Debido al tipo de datos que se muestra para Flow, Room también ejecuta la búsqueda en el subproceso en segundo plano. No necesitas convertirla de manera explícita en una función suspend ni llamar dentro del alcance de la corrutina.

  1. Agrega una @Query con una función getAllItems().
  2. Haz que la consulta de SQLite muestre todas las columnas de la tabla item, ordenadas de forma ascendente.
  3. Haz que getAllItems() muestre una lista de entidades Item como Flow. Room mantiene este Flow actualizado por ti, lo que significa que solo necesitas obtener los datos de forma explícita una vez.
@Query("SELECT * from items ORDER BY name ASC")
fun getAllItems(): Flow<List<Item>>

ItemDao completado:

import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow

@Dao
interface ItemDao {
    @Insert(onConflict = OnConflictStrategy.IGNORE)
    suspend fun insert(item: Item)

    @Update
    suspend fun update(item: Item)

    @Delete
    suspend fun delete(item: Item)

    @Query("SELECT * from items WHERE id = :id")
    fun getItem(id: Int): Flow<Item>

    @Query("SELECT * from items ORDER BY name ASC")
    fun getAllItems(): Flow<List<Item>>
}
  1. Si bien no notarás ningún cambio visible, compila la app para asegurarte de que no tenga errores.

7. Crea una instancia de base de datos

En esta tarea, crearás una RoomDatabase que use tu Entity y tu DAO a partir de las tareas anteriores. La clase de base de datos define la lista de entidades y DAO.

La clase Database proporciona a tu app instancias de los DAO que definas. A su vez, la app puede usar los DAO para recuperar datos de la base de datos como instancias de objetos de entidad de datos asociados. La app también puede usar las entidades de datos definidas para actualizar filas de las tablas correspondientes o crear filas nuevas para su inserción.

Debes crear una clase abstracta RoomDatabase y anotarla con @Database. Esta clase tiene un método que muestra la instancia existente de RoomDatabase si la base de datos no existe.

Este es el proceso general para obtener la instancia RoomDatabase:

  • Crea una clase public abstract que extienda RoomDatabase. La nueva clase abstracta que defines actúa como un contenedor de la base de datos. La clase que defines es abstracta porque Room crea la implementación por ti.
  • Anota la clase con @Database. En los argumentos, enumera las entidades para la base de datos y establece el número de versión.
  • Define una propiedad o un método abstracto que muestre una instancia de ItemDao, y Room genera la implementación por ti.
  • Solo necesitas una instancia de RoomDatabase para toda la app, así que haz que RoomDatabase sea un singleton.
  • Usa el Room.databaseBuilder de Room para crear tu base de datos (item_database), solo si no existe. De lo contrario, muestra la base de datos existente.

Crea la base de datos

  1. En el paquete data, crea una clase de Kotlin InventoryDatabase.kt.
  2. En el archivo InventoryDatabase.kt, haz que la clase InventoryDatabase sea una clase abstract que extienda RoomDatabase.
  3. Anota la clase con @Database. Ignora el error de parámetros faltantes, ya que lo corregirás en el siguiente paso.
import androidx.room.Database
import androidx.room.RoomDatabase

@Database
abstract class InventoryDatabase : RoomDatabase() {}

La anotación @Database requiere varios argumentos para que Room pueda compilar la base de datos.

  1. Especifica el Item como la única clase con la lista de entities.
  2. Establece version como 1. Cada vez que cambies el esquema de la tabla de la base de datos, debes aumentar el número de versión.
  3. Establece exportSchema como false para que no se conserven las copias de seguridad del historial de versiones de esquemas.
@Database(entities = [Item::class], version = 1, exportSchema = false)
  1. Dentro del cuerpo de la clase, declara una función abstracta que muestre el ItemDao de modo que la base de datos sepa sobre el DAO.
abstract fun itemDao(): ItemDao
  1. Debajo de la función abstracta, define un companion object, que permite el acceso a los métodos para crear u obtener la base de datos y usa el nombre de clase como calificador.
 companion object {}
  1. Dentro del objeto companion, declara una variable anulable privada Instance para la base de datos y, luego, inicialízala en null.

La variable Instance conserva una referencia a la base de datos, cuando se crea una. Esto ayuda a mantener una sola instancia de la base de datos abierta en un momento determinado, que es un recurso costoso para crear y mantener.

  1. Anota Instance con @Volatile.

El valor de una variable volátil nunca se almacena en caché, y todas las lecturas y escrituras son desde y hacia la memoria principal. Estas funciones ayudan a garantizar que el valor de Instance esté siempre actualizado y sea el mismo para todos los subprocesos de ejecución. Eso significa que los cambios realizados por un subproceso en Instance son visibles de inmediato para todos los demás subprocesos.

@Volatile
private var Instance: InventoryDatabase? = null
  1. Debajo de Instance, mientras estás dentro del objeto companion, define un método getDatabase() con un parámetro Context que necesite el compilador de bases de datos.
  2. Muestra un tipo InventoryDatabase. Aparecerá un mensaje de error porque getDatabase() aún no muestra nada.
import android.content.Context

fun getDatabase(context: Context): InventoryDatabase {}

Es posible que varios subprocesos soliciten una instancia de base de datos al mismo tiempo, lo que genera dos bases de datos en lugar de una. Este problema se conoce como condición de carrera. Unir el código para obtener la base de datos dentro de un bloque synchronized significa que solo un subproceso de ejecución a la vez puede ingresar este bloque de código, lo que garantiza que la base de datos solo se inicialice una vez. Usa el bloque synchronized{} para evitar la condición de carrera.

  1. Dentro de getDatabase(), muestra la variable Instance o, si Instance es nula, inicialízala dentro de un bloque synchronized{}. Para ello, usa el operador elvis (?:).
  2. Pasa this, el objeto complementario. Solucionarás el error en los pasos posteriores.
return Instance ?: synchronized(this) { }
  1. Dentro del bloque sincronizado, usa el compilador de bases de datos para obtener la base de datos. Ignora los errores, ya que los corregirás en los próximos pasos.
import androidx.room.Room

Room.databaseBuilder()
  1. Dentro del bloque synchronized, usa el compilador de bases de datos para obtener una base de datos. Pasa a Room.databaseBuilder() el contexto de la aplicación, la clase de la base de datos y un nombre para la base de datos, item_database.
Room.databaseBuilder(context, InventoryDatabase::class.java, "item_database")

Android Studio genera un error de discrepancia de tipos. Para quitar este error, debes agregar una build() en los pasos siguientes.

  1. Agrega la estrategia de migración necesaria al compilador. Usa . fallbackToDestructiveMigration().
.fallbackToDestructiveMigration()
  1. Para crear la instancia de base de datos, llama a .build(). Esta llamada quita los errores de Android Studio.
.build()
  1. Después de build(), agrega un bloque also y asigna Instance = it para mantener una referencia a la instancia de base de datos recién creada.
.also { Instance = it }
  1. Al final del bloque synchronized, muestra instance. El código final se ve como el siguiente:
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase

/**
* Database class with a singleton Instance object.
*/
@Database(entities = [Item::class], version = 1, exportSchema = false)
abstract class InventoryDatabase : RoomDatabase() {

    abstract fun itemDao(): ItemDao

    companion object {
        @Volatile
        private var Instance: InventoryDatabase? = null

        fun getDatabase(context: Context): InventoryDatabase {
            // if the Instance is not null, return it, otherwise create a new database instance.
            return Instance ?: synchronized(this) {
                Room.databaseBuilder(context, InventoryDatabase::class.java, "item_database")
                    .build()
                    .also { Instance = it }
            }
        }
    }
}
  1. Compila tu código para asegurarte de que no haya errores.

8. Implementa el repositorio

En esta tarea, implementarás la interfaz ItemsRepository y la clase OfflineItemsRepository para proporcionar entidades get, insert, delete y update de la base de datos.

  1. Abre el archivo ItemsRepository.kt bajo el paquete data.
  2. Agrega las siguientes funciones a la interfaz, que se asignan a la implementación de DAO.
import kotlinx.coroutines.flow.Flow

/**
* Repository that provides insert, update, delete, and retrieve of [Item] from a given data source.
*/
interface ItemsRepository {
    /**
     * Retrieve all the items from the the given data source.
     */
    fun getAllItemsStream(): Flow<List<Item>>

    /**
     * Retrieve an item from the given data source that matches with the [id].
     */
    fun getItemStream(id: Int): Flow<Item?>

    /**
     * Insert item in the data source
     */
    suspend fun insertItem(item: Item)

    /**
     * Delete item from the data source
     */
    suspend fun deleteItem(item: Item)

    /**
     * Update item in the data source
     */
    suspend fun updateItem(item: Item)
}
  1. Abre el archivo OfflineItemsRepository.kt bajo el paquete data.
  2. Pasa un parámetro de constructor del tipo ItemDao.
class OfflineItemsRepository(private val itemDao: ItemDao) : ItemsRepository
  1. En la clase OfflineItemsRepository, anula las funciones definidas en la interfaz ItemsRepository y llama a las funciones correspondientes desde el ItemDao.
import kotlinx.coroutines.flow.Flow

class OfflineItemsRepository(private val itemDao: ItemDao) : ItemsRepository {
    override fun getAllItemsStream(): Flow<List<Item>> = itemDao.getAllItems()

    override fun getItemStream(id: Int): Flow<Item?> = itemDao.getItem(id)

    override suspend fun insertItem(item: Item) = itemDao.insert(item)

    override suspend fun deleteItem(item: Item) = itemDao.delete(item)

    override suspend fun updateItem(item: Item) = itemDao.update(item)
}

Implementa la clase AppContainer

En esta tarea, crearás una instancia de la base de datos y pasarás la instancia de DAO a la clase OfflineItemsRepository.

  1. Abre el archivo AppContainer.kt bajo el paquete data.
  2. Pasa la instancia ItemDao() al constructor OfflineItemsRepository.
  3. Para crear una instancia de la instancia de base de datos, llama a getDatabase() en la clase InventoryDatabase que pasa el contexto y llama a .itemDao() para crear la instancia de Dao.
override val itemsRepository: ItemsRepository by lazy {
    OfflineItemsRepository(InventoryDatabase.getDatabase(context).itemDao())
}

Ahora tienes todos los componentes básicos para trabajar con tu Room. Este código se compila y se ejecuta, pero no hay forma de saber si realmente funciona. Por lo tanto, este es un buen momento para probar tu base de datos. Para completar la prueba, necesitas que ViewModel se comunique con la base de datos.

9. Agrega la función de guardar

Hasta ahora, creaste una base de datos, y las clases de IU formaban parte del código de partida. Para guardar los datos transitorios de la app y acceder a la base de datos, debes actualizar los ViewModels. Tus elementos ViewModel interactúan con la base de datos a través del DAO y proporcionan datos a la IU. Todas las operaciones de la base de datos deben ejecutarse fuera del subproceso de IU principal. Para ello, usa corrutinas y viewModelScope.

Explicación de la clase de estado de la IU

Abre el archivo ui/item/ItemEntryViewModel.kt. La clase de datos ItemUiState representa el estado de la IU de un elemento. La clase de datos ItemDetails representa un solo elemento.

El código de partida te proporciona tres funciones de extensión:

  • La función de extensión ItemDetails.toItem() convierte el objeto de estado de la IU de ItemUiState en el tipo de entidad Item.
  • La función de extensión Item.toItemUiState() convierte el objeto de entidad Item de Room en el tipo de estado de la IU de ItemUiState.
  • La función de extensión Item.toItemDetails() convierte el objeto de entidad de Room Item en ItemDetails.
// No need to copy, this is part of starter code
/**
* Represents Ui State for an Item.
*/
data class ItemUiState(
    val itemDetails: ItemDetails = ItemDetails(),
    val isEntryValid: Boolean = false
)

data class ItemDetails(
    val id: Int = 0,
    val name: String = "",
    val price: String = "",
    val quantity: String = "",
)

/**
* Extension function to convert [ItemDetails] to [Item]. If the value of [ItemDetails.price] is
* not a valid [Double], then the price will be set to 0.0. Similarly if the value of
* [ItemDetails.quantity] is not a valid [Int], then the quantity will be set to 0
*/
fun ItemDetails.toItem(): Item = Item(
    id = id,
    name = name,
    price = price.toDoubleOrNull() ?: 0.0,
    quantity = quantity.toIntOrNull() ?: 0
)

fun Item.formatedPrice(): String {
    return NumberFormat.getCurrencyInstance().format(price)
}

/**
* Extension function to convert [Item] to [ItemUiState]
*/
fun Item.toItemUiState(isEntryValid: Boolean = false): ItemUiState = ItemUiState(
    itemDetails = this.toItemDetails(),
    isEntryValid = isEntryValid
)

/**
* Extension function to convert [Item] to [ItemDetails]
*/
fun Item.toItemDetails(): ItemDetails = ItemDetails(
    id = id,
    name = name,
    price = price.toString(),
    quantity = quantity.toString()
)

Usa la clase anterior en los ViewModels para leer y actualizar la IU.

Actualiza ViewModel de InputEntry

En esta tarea, debes pasar el repositorio al archivo ItemEntryViewModel.kt. También guardas los detalles del elemento ingresados en la pantalla Add Item en la base de datos.

  1. Observa la función privada validateInput() en la clase ItemEntryViewModel.
// No need to copy over, this is part of starter code
private fun validateInput(uiState: ItemDetails = itemUiState.itemDetails): Boolean {
    return with(uiState) {
        name.isNotBlank() && price.isNotBlank() && quantity.isNotBlank()
    }
}

La función anterior verifica si name, price y quantity están vacíos. Usarás esta función para verificar la entrada del usuario antes de agregar o actualizar la entidad en la base de datos.

  1. Abre la clase ItemEntryViewModel y agrega un parámetro de constructor predeterminado private del tipo ItemsRepository.
import com.example.inventory.data.ItemsRepository

class ItemEntryViewModel(private val itemsRepository: ItemsRepository) : ViewModel() {
}
  1. Actualiza el initializer del ViewModel de entrada de elementos en ui/AppViewModelProvider.kt y pasa la instancia del repositorio como parámetro.
object AppViewModelProvider {
    val Factory = viewModelFactory {
        // Other Initializers
        // Initializer for ItemEntryViewModel
        initializer {
            ItemEntryViewModel(inventoryApplication().container.itemsRepository)
        }
        //...
    }
}
  1. Ve al archivo ItemEntryViewModel.kt y, al final de la clase ItemEntryViewModel, agrega una función de suspensión llamada saveItem() para insertar un elemento en la base de datos de Room. Esta función agrega los datos a la base de datos sin bloqueos.
suspend fun saveItem() {
}
  1. Dentro de la función, verifica si itemUiState es válido y conviértelo al tipo Item para que Room pueda comprender los datos.
  2. Llama a insertItem() en itemsRepository y pasa los datos. La IU llama a esta función para agregar detalles del elemento a la base de datos.
suspend fun saveItem() {
    if (validateInput()) {
        itemsRepository.insertItem(itemUiState.itemDetails.toItem())
    }
}

Agregaste todas las funciones necesarias para incluir entidades en la base de datos. En la siguiente tarea, actualizarás la IU para usar las funciones anteriores.

Explicación del elemento componible ItemEntryBody()

  1. En el archivo ui/item/ItemEntryScreen.kt, el elemento ItemEntryBody() componible se implementa de forma parcial como parte del código de partida. Observa el elemento ItemEntryBody() componible en la llamada a función ItemEntryScreen().
// No need to copy over, part of the starter code
ItemEntryBody(
    itemUiState = viewModel.itemUiState,
    onItemValueChange = viewModel::updateUiState,
    onSaveClick = { },
    modifier = Modifier
        .padding(innerPadding)
        .verticalScroll(rememberScrollState())
        .fillMaxWidth()
)
  1. Ten en cuenta que el estado de la IU y la lambda updateUiState se pasan como parámetros de función. Mira la definición de la función para ver cómo se actualiza el estado de la IU.
// No need to copy over, part of the starter code
@Composable
fun ItemEntryBody(
    itemUiState: ItemUiState,
    onItemValueChange: (ItemUiState) -> Unit,
    onSaveClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    Column(
        // ...
    ) {
        ItemInputForm(
             itemDetails = itemUiState.itemDetails,
             onValueChange = onItemValueChange,
             modifier = Modifier.fillMaxWidth()
         )
        Button(
             onClick = onSaveClick,
             enabled = itemUiState.isEntryValid,
             shape = MaterialTheme.shapes.small,
             modifier = Modifier.fillMaxWidth()
         ) {
             Text(text = stringResource(R.string.save_action))
         }
    }
}

Estás mostrando ItemInputForm y un botón Save en este elemento componible. En el elemento componible ItemInputForm(), se muestran tres campos de texto. El botón Save solo está habilitado si se ingresa texto en los campos de texto. El valor isEntryValid es "true" si el texto de todos los campos es válido (no está vacío).

Pantalla de teléfono con los detalles del elemento completados parcialmente y el botón Guardar inhabilitado

Pantalla de teléfono con los detalles del elemento completados y el botón Guardar habilitado

  1. Observa la implementación de la función de componibilidad ItemInputForm() y el parámetro de la función onValueChange. Estás actualizando el valor de itemDetails con el valor que ingresó el usuario en los campos de texto. Cuando el botón Save está habilitado, itemUiState.itemDetails tiene los valores que se deben guardar.
// No need to copy over, part of the starter code
@Composable
fun ItemEntryBody(
    //...
) {
    Column(
        // ...
    ) {
        ItemInputForm(
             itemDetails = itemUiState.itemDetails,
             //...
         )
        //...
    }
}
// No need to copy over, part of the starter code
@Composable
fun ItemInputForm(
    itemDetails: ItemDetails,
    modifier: Modifier = Modifier,
    onValueChange: (ItemUiState) -> Unit = {},
    enabled: Boolean = true
) {
    Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
        OutlinedTextField(
            value = itemUiState.name,
            onValueChange = { onValueChange(itemDetails.copy(name = it)) },
            //...
        )
        OutlinedTextField(
            value = itemUiState.price,
            onValueChange = { onValueChange(itemDetails.copy(price = it)) },
            //...
        )
        OutlinedTextField(
            value = itemUiState.quantity,
            onValueChange = { onValueChange(itemDetails.copy(quantity = it)) },
            //...
        )
    }
}

Agrega un objeto de escucha de clics al botón Save

Para vincular todo, agrega un controlador de clics al botón Save. Dentro del controlador de clics, inicias una corrutina y llamas a saveItem() para guardar los datos en la base de datos de Room.

  1. En ItemEntryScreen.kt, dentro de la función de componibilidad ItemEntryScreen, crea un val llamado coroutineScope con la función de componibilidad rememberCoroutineScope().
import androidx.compose.runtime.rememberCoroutineScope

val coroutineScope = rememberCoroutineScope()
  1. Actualiza la llamada a la función ItemEntryBody() y, luego, inicia una corrutina dentro de la lambda onSaveClick.
ItemEntryBody(
   // ...
    onSaveClick = {
        coroutineScope.launch {
        }
    },
    modifier = modifier.padding(innerPadding)
)
  1. Observa la implementación de la función saveItem() en el archivo ItemEntryViewModel.kt para verificar si itemUiState es válido a través de la conversión de itemUiState en el tipo Item y, luego, su inserción en la base de datos con itemsRepository.insertItem().
// No need to copy over, you have already implemented this as part of the Room implementation

suspend fun saveItem() {
    if (validateInput()) {
        itemsRepository.insertItem(itemUiState.itemDetails.toItem())
    }
}
  1. En ItemEntryScreen.kt, dentro de la función de componibilidad ItemEntryScreen, dentro de la corrutina, llama a viewModel.saveItem() para guardar el elemento en la base de datos.
ItemEntryBody(
    // ...
    onSaveClick = {
        coroutineScope.launch {
            viewModel.saveItem()
        }
    },
    //...
)

Observa que no usaste viewModelScope.launch() para saveItem() en el archivo ItemEntryViewModel.kt, pero es necesario para ItemEntryBody() cuando llamas a un método de repositorio. Solo puedes llamar a funciones de suspensión desde una corrutina o desde otra función de suspensión. La función viewModel.saveItem() es de suspensión.

  1. Compila y ejecuta tu app.
  2. Presiona el BAF +.
  3. En la pantalla Add Item, agrega los detalles del elemento y presiona Save. Observa que, si presionas el botón Save, no se cierra la pantalla Add Item.

Pantalla de teléfono con los detalles del elemento completados y el botón Guardar habilitado

  1. En la lambda onSaveClick, agrega una llamada a navigateBack() después de la llamada a viewModel.saveItem() para volver a la pantalla anterior. Tu función ItemEntryBody() se ve como el siguiente código:
ItemEntryBody(
    itemUiState = viewModel.itemUiState,
    onItemValueChange = viewModel::updateUiState,
    onSaveClick = {
        coroutineScope.launch {
            viewModel.saveItem()
            navigateBack()
        }
    },
    modifier = modifier.padding(innerPadding)
)
  1. Vuelve a ejecutar la app y realiza los mismos pasos para ingresar y guardar los datos. Observa que, esta vez, la app regresa a la pantalla Inventory.

Esta acción guarda los datos, pero no puede ver los datos de inventario en la app. En la próxima tarea, usarás el Inspector de bases de datos para ver los datos que guardaste.

Pantalla de la app con una lista de inventario vacía

10. Consulta el contenido de la base de datos con el Inspector de bases de datos

El Inspector de bases de datos te permite inspeccionar, consultar y modificar las bases de datos de tu app mientras se ejecuta. Esto es particularmente útil para depurar bases de datos. El Inspector de bases de datos funciona con SQLite simple y bibliotecas compiladas sobre SQLite, como Room. El Inspector de bases de datos funciona mejor en emuladores o dispositivos que ejecutan el nivel de API 26.

  1. Si aún no lo hiciste, ejecuta tu app en un emulador o un dispositivo conectado con nivel de API 26 o superior.
  2. En Android Studio, selecciona View > Tool Windows > App Inspection en la barra de menú.
  3. Elige la pestaña Database Inspector.
  4. En el panel Database Inspector, selecciona com.example.inventory en el menú desplegable si aún no está seleccionado. item_database de la app de Inventory aparece en el panel Databases.

76408bd5e93c3432.png

  1. Expande el nodo de item_database del panel Databases y selecciona Item para inspeccionarlo. Si el panel Databases está vacío, usa el emulador para agregar algunos elementos a la base de datos con la pantalla Add Item.
  2. Marca la casilla de verificación Live updates en el Inspector de bases de datos para actualizar automáticamente los datos que presenta mientras interactúas con tu app en ejecución en el emulador o dispositivo.

9e21d9f7eb426008.png

¡Felicitaciones! Creaste una app que puede conservar datos con Room. En el siguiente codelab, agregarás una lazyColumn a tu app para mostrar los elementos en la base de datos y agregar nuevas funciones a la app, como la capacidad de borrar y actualizar las entidades. ¡Nos vemos!

11. Obtén el código de la solución

El código de solución para este codelab se encuentra en el repositorio de GitHub. Para descargar el código del codelab terminado, usa los siguientes comandos de Git:

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-inventory-app.git
$ cd basic-android-kotlin-compose-training-inventory-app
$ git checkout room

También puedes descargar el repositorio como un archivo ZIP, descomprimirlo y abrirlo en Android Studio.

Descargar ZIP

Si deseas ver el código de la solución para este codelab, míralo en GitHub.

12. Resumen

  • Define tus tablas como clases de datos anotadas con @Entity. Define las propiedades anotadas con @ColumnInfo como columnas en las tablas.
  • Define un objeto de acceso a datos (DAO) como una interfaz anotada con @Dao. El DAO asigna funciones de Kotlin a consultas de bases de datos.
  • Usa anotaciones para definir las funciones @Insert, @Delete y @Update.
  • Usa la anotación @Query con una cadena de consulta de SQLite como parámetro para cualquier otra consulta.
  • Usa el Inspector de bases de datos para ver los datos guardados en la base de datos SQLite de Android.

13. Más información

Documentación para desarrolladores de Android

Entradas de blog

Videos

Otros artículos y documentación