Android Room con un componente View: Kotlin

La colección componentes de la arquitectura de Android brinda orientación sobre la arquitectura de las apps, con bibliotecas para tareas comunes, como la administración del ciclo de vida y la persistencia de datos. Los componentes de la arquitectura te ayudan a estructurar la app de una manera sólida, que se puede probar y mantener con menos código estándar.

Las bibliotecas de componentes de la arquitectura son parte de Android Jetpack.

Esta es la versión del codelab para Kotlin. Puedes encontrar la versión para el lenguaje de programación Java aquí.

Si, a medida que avanzas con este codelab, encuentras algún problema (errores de código, errores gramaticales, texto poco claro, etc.), infórmalo mediante el vínculo Informar un error que se encuentra en la esquina inferior izquierda del codelab.

Requisitos previos

Debes estar familiarizado con Kotlin, con los conceptos de diseño orientados a objetos y con los aspectos básicos de desarrollo de Android, en particular:

También es útil familiarizarse con los patrones arquitectónicos de software que separan datos de la interfaz de usuario, como Model-View-Presenter (MVP) o Model-View-Controller (MVC). En este codelab, se implementará la arquitectura que se define en la Guía de arquitectura de apps de la documentación para desarrolladores de Android.

Este codelab está enfocado en componentes de la arquitectura de Android. El código y los conceptos no relevantes se proporcionan para que simplemente los copies y pegues.

Actividades

Aprenderás a diseñar y construir una app con los componentes de la arquitectura Room, ViewModel y LiveData. Tu app hará lo siguiente:

  • Implementar la arquitectura recomendada con los componentes de la arquitectura de Android
  • Funcionar con una base de datos para obtener y guardar datos, y prepropagar la base de datos con palabras de muestra
  • Mostrar todas las palabras de un elemento RecyclerView en la clase MainActivity
  • Abrir una segunda actividad cuando el usuario presiona el botón "+" (cuando se ingresa una palabra, esa palabra se agrega a la base de datos y se muestra en la lista RecyclerView)

La app es simple, pero lo suficientemente compleja para que puedas usarla como plantilla. Aquí tienes una vista previa:

Requisitos

  • Android Studio 4.0 o posterior y conocimiento sobre cómo usarlo (asegúrate de que Android Studio esté actualizado, así como el SDK y Gradle)
  • Un dispositivo o emulador de Android

Este codelab proporciona todo el código necesario para compilar la app completa.

Aquí encontrarás un pequeño diagrama introductorio sobre los componentes de la arquitectura y su funcionamiento en conjunto. Ten en cuenta que este codelab se enfoca en un subconjunto de los componentes; en particular, LiveData, ViewModel y Room. Cada componente se explica en detalle a medida que lo usas en tu app.

8e4b761713e3a76b.png

LiveData: Una clase contenedora de datos observable. Siempre conserva o almacena en caché la última versión de los datos, y notifica a los observadores cuando los datos han cambiado. LiveData está optimizado para los ciclos de vida. Los componentes de IU solo observan los datos relevantes y no detienen ni reanudan la observación. LiveData se ocupa automáticamente de todo esto, ya que está al tanto de los cambios de estado del ciclo de vida relevantes mientras lleva a cabo la observación.

ViewModel: Actúa como un centro de comunicación entre el repositorio (datos) y la IU. La IU ya no tiene que preocuparse por el origen de los datos. Las instancias de ViewModel sobreviven a la recreación por actividad o fragmento.

Repositorio: Es una clase que creas que se usa principalmente para administrar varias fuentes de datos.

Entidad: Es una clase anotada que describe una tabla de base de datos cuando se trabaja con Room.

Base de datos de Room: Simplifica el trabajo de la base de datos y sirve como punto de acceso a la base de datos SQLite subyacente (oculta SQLiteOpenHelper)). La base de datos de Room usa el DAO para enviar consultas a la base de datos SQLite.

Base de datos SQLite: Es el almacenamiento del dispositivo. La biblioteca de persistencias de Room crea y mantiene esta base de datos por ti.

DAO: Es el objeto de acceso a datos. Es un mapeo de búsquedas de SQL a las funciones Si usas un DAO, tú llamas a los métodos y Room se encarga del resto.

Descripción general de la arquitectura de RoomWordSample

En el siguiente diagrama, se muestra cómo deberían interactuar todas las partes de la app. Cada uno de los cuadros adjuntos (a excepción de la base de datos SQLite) representa una clase que crearás.

a70aca8d4b737712.png

  1. Abre Android Studio y haz clic en Start a new Android Studio project.
  2. En la ventana Create New Project, selecciona Empty Activity y haz clic en Next.
  3. En la pantalla siguiente, asigna un nombre a la app RoomWordSample y haz clic en Finish.

9b6cbaec81794071.png

A continuación, deberás agregar las bibliotecas de componentes a tus archivos de Gradle.

  1. En Android Studio, haz clic en la pestaña Projects y expande la carpeta de secuencias de comandos de Gradle.

Abre build.gradle (Módulo: app).

  1. A fin de aplicar el complemento procesador de anotaciones kapt de Kotlin, agrégalo después de la sección de complementos definida en la parte superior de tu archivo build.gradle (Módulo: app).
apply plugin: 'kotlin-kapt'
  1. Agrega el bloque packagingOptions dentro del bloque android para excluir el módulo de funciones atómicas del paquete y evitar advertencias.
  2. Agrega también la versión 1.8 de jvmTarget al bloque android, ya que algunas de las API que usarás la requerirán.
android {
    // other configuration (buildTypes, defaultConfig, etc.)

    packagingOptions {
        exclude 'META-INF/atomicfu.kotlin_module'
    }

    kotlinOptions {
        jvmTarget = "1.8"
    }

}
  1. Reemplaza el bloque dependencies por:
dependencies {
    implementation "androidx.appcompat:appcompat:$rootProject.appCompatVersion"
    implementation "androidx.activity:activity-ktx:$rootProject.activityVersion"

    // Dependencies for working with Architecture components
    // You'll probably have to update the version numbers in build.gradle (Project)

    // Room components
    implementation "androidx.room:room-ktx:$rootProject.roomVersion"
    kapt "androidx.room:room-compiler:$rootProject.roomVersion"
    androidTestImplementation "androidx.room:room-testing:$rootProject.roomVersion"

    // Lifecycle components
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$rootProject.lifecycleVersion"
    implementation "androidx.lifecycle:lifecycle-livedata-ktx:$rootProject.lifecycleVersion"
    implementation "androidx.lifecycle:lifecycle-common-java8:$rootProject.lifecycleVersion"

    // Kotlin components
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
    api "org.jetbrains.kotlinx:kotlinx-coroutines-core:$rootProject.coroutines"
    api "org.jetbrains.kotlinx:kotlinx-coroutines-android:$rootProject.coroutines"

    // UI
    implementation "androidx.constraintlayout:constraintlayout:$rootProject.constraintLayoutVersion"
    implementation "com.google.android.material:material:$rootProject.materialVersion"

    // Testing
    testImplementation "junit:junit:$rootProject.junitVersion"
    androidTestImplementation "androidx.arch.core:core-testing:$rootProject.coreTestingVersion"
    androidTestImplementation ("androidx.test.espresso:espresso-core:$rootProject.espressoVersion", {
        exclude group: 'com.android.support', module: 'support-annotations'
    })
    androidTestImplementation "androidx.test.ext:junit:$rootProject.androidxJunitVersion"
}

En este momento, Gradle puede solicitar versiones faltantes o indefinidas, lo que debe corregirse en el próximo paso.

  1. En tu archivo build.gradle (Project: RoomWordsSample), agrega los números de versión al final del archivo, como se indica en el código que aparece a continuación.
ext {
    activityVersion = '1.1.0'
    appCompatVersion = '1.2.0'
    constraintLayoutVersion = '2.0.2'
    coreTestingVersion = '2.1.0'
    coroutines = '1.3.9'
    lifecycleVersion = '2.2.0'
    materialVersion = '1.2.1'
    roomVersion = '2.2.5'
    // testing
    junitVersion = '4.13.1'
    espressoVersion = '3.1.0'
    androidxJunitVersion = '1.1.2'
}

Los datos de esta app son palabras y necesitarás una tabla sencilla para contener esos valores:

3821ac1a6cb01278.png

Room te permite crear tablas a través de una entidad. Hagámoslo ahora.

  1. Crea un nuevo archivo de clase de Kotlin llamado Word que contenga la clase de datos Word. Esta clase describirá la entidad (que representará la tabla SQLite) para tus palabras. Cada propiedad de la clase representará una columna en la tabla. Finalmente, Room usará estas propiedades para construir la tabla y crear instancias de objetos a partir de filas en la base de datos.

Este es el código:

data class Word(val word: String)

A fin de que la clase Word sea significativa para una base de datos de Room, debes crear una asociación entre la clase y la base de datos mediante las anotaciones de Kotlin. Utiliza anotaciones específicas para identificar cómo se relaciona cada parte de esta clase con una entrada de la base de datos. Room usa esta información adicional para generar código.

Si escribes las anotaciones por tu cuenta (en lugar de pegarlas), Android Studio importará las clases de anotación automáticamente.

  1. Actualiza tu clase Word con anotaciones como se muestra en este código:
@Entity(tableName = "word_table")
class Word(@PrimaryKey @ColumnInfo(name = "word") val word: String)

Veamos qué hacen estas anotaciones:

  • @Entity(tableName = "word_table"): Cada clase @Entity representa una tabla SQLite. Anota tu declaración de clase para indicar que es una entidad. Puedes especificar el nombre de la tabla si deseas que sea diferente al nombre de la clase. De esta forma, la tabla se llamará "word_table".
  • @PrimaryKey: Cada entidad necesita una clave primaria. Para simplificar las cosas, cada palabra actúa como su propia clave primaria.
  • @ColumnInfo(name = "word"): Especifica el nombre de la columna en la tabla si deseas que sea diferente del nombre de la variable de miembro. De esta forma, la columna se llamará "word".
  • Cada propiedad que se almacena en la base de datos debe tener visibilidad pública, que es el valor predeterminado de Kotlin.

Puedes encontrar una lista completa de las anotaciones en la referencia del resumen del paquete de Room.

¿Qué es el DAO?

En el DAO (objeto de acceso a los datos), se especifican las búsquedas de SQL y se las asocia con llamadas de método. El compilador revisa el SQL y genera consultas a partir de anotaciones convenientes para consultas comunes, como @Insert. Room usa el DAO con el objetivo de crear una API limpia para tu código.

El DAO debe ser una interfaz o una clase abstracta.

Todas las consultas deben ejecutarse en un subproceso separado de forma predeterminada.

Room tiene compatibilidad con corrutinas de Kotlin. Esto permite que tus consultas se anoten con el modificador suspend y, luego, se las llame desde una corrutina o desde otra función de suspensión.

Cómo implementar el DAO

Escribamos un DAO que proporcione búsquedas para lo siguiente:

  • Ordenar todas las palabras alfabéticamente
  • Insertar una palabra
  • Borrar todas las palabras
  1. Crea un archivo de clase de Kotlin nuevo llamado WordDao.
  2. Copia y pega el siguiente código en WordDao y corrige las importaciones según sea necesario para compilarse.
@Dao
interface WordDao {

    @Query("SELECT * FROM word_table ORDER BY word ASC")
    fun getAlphabetizedWords(): List<Word>

    @Insert(onConflict = OnConflictStrategy.IGNORE)
    suspend fun insert(word: Word)

    @Query("DELETE FROM word_table")
    suspend fun deleteAll()
}

Veamos cómo hacerlo paso a paso.

  • WordDao es una interfaz. Los DAO deben ser interfaces o clases abstractas.
  • La anotación @Dao lo identifica como una clase de DAO para Room.
  • suspend fun insert(word: Word): Declara una función de suspensión para insertar una palabra.
  • La anotación @Insert es una anotación de método DAO especial para la que no necesitas proporcionar un SQL. (También hay anotaciones @Delete y @Update para borrar y actualizar filas, pero no las utilizarás en esta app).
  • onConflict = OnConflictStrategy.IGNORE: La estrategia onConflict seleccionada ignorará una palabra nueva si es exactamente la misma que una que ya esté en la lista. Para obtener más información sobre las estrategias de conflicto disponibles, consulta la documentación.
  • suspend fun deleteAll(): Declara una función suspendida para borrar todas las palabras.
  • No hay ninguna anotación conveniente para borrar varias entidades, por lo que se anota con el @Query genérico.
  • @Query("DELETE FROM word_table"): @Query requiere que proporciones una consulta de SQL como un parámetro de string a la anotación, lo que permite realizar consultas de lectura complejas y otras operaciones.
  • fun getAlphabetizedWords(): List<Word>: Es el método para obtener todas las palabras y mostrar una List de Words.
  • @Query("SELECT * FROM word_table ORDER BY word ASC"): Es una búsqueda que muestra una lista de palabras ordenadas de forma ascendente.

Por lo general, cuando cambian los datos, realizas alguna acción, como mostrar los datos actualizados en la IU. Esto significa que debes observar los datos para reaccionar cuando cambien.

Para observar los cambios en los datos, usarás un flujo de kotlinx-coroutines. Usa un valor de muestra de tipo Flow en la descripción del método y Room generará todo el código necesario para actualizar Flow cuando se actualice la base de datos.

En WordDao, cambia la firma del método getAlphabetizedWords() para que el List<Word> que se muestra esté unida a Flow.

   @Query("SELECT * FROM word_table ORDER BY word ASC")
   fun getAlphabetizedWords(): Flow<List<Word>>

Más adelante, en este laboratorio de código, transformaremos el flujo en LiveData en el ViewModel. Aprenderemos más acerca de estos componentes cuando empecemos a implementarlos.

¿Qué es una base de datos Room**?**

  • Room es una capa de base de datos sobre una base de datos SQLite.
  • Room se ocupa de las tareas rutinarias de las que solías encargarte con SQLiteOpenHelper.
  • Room usa el DAO para enviar consultas a su base de datos.
  • De manera predeterminada, para evitar un rendimiento deficiente en la IU, Room no permite enviar consultas en el subproceso principal. Cuando las consultas de Room muestran Flow, las consultas se ejecutan automáticamente de manera asíncrona en un subproceso, en segundo plano.
  • Room proporciona comprobaciones de tiempo de compilación de las sentencias de SQLite.

Cómo implementar la base de datos de Room

La clase de tu base de datos de Room debe ser abstracta y extender RoomDatabase. Por lo general, solo necesitas una instancia de una base de datos de Room para toda la app.

Creemos una ahora.

  1. Crea un archivo de clase de Kotlin llamado WordRoomDatabase y agrégale este código:
// Annotates class to be a Room Database with a table (entity) of the Word class
@Database(entities = arrayOf(Word::class), version = 1, exportSchema = false)
public abstract class WordRoomDatabase : RoomDatabase() {

   abstract fun wordDao(): WordDao

   companion object {
        // Singleton prevents multiple instances of database opening at the
        // same time.
        @Volatile
        private var INSTANCE: WordRoomDatabase? = null

        fun getDatabase(context: Context): WordRoomDatabase {
            // 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,
                        WordRoomDatabase::class.java,
                        "word_database"
                    ).build()
                INSTANCE = instance
                // return instance
                instance
            }
        }
   }
}

Veamos el código paso a paso:

  • La clase de la base de datos de Room debe ser abstract y extender RoomDatabase..
  • Puedes anotar la clase para que sea una base de datos Room con @Database y usar los parámetros de anotación para declarar las entidades que pertenecen a la base de datos y establecer el número de versión. Cada entidad corresponde a una tabla que se creará en la base de datos. Las migraciones de bases de datos están fuera del alcance de este codelab, por lo que se configuró exportSchema como falso para evitar una advertencia de compilación. En una app real, te recomendamos establecer un directorio para que Room exporte el esquema a fin de que puedas comprobar el esquema actual en tu sistema de control de versión.
  • La base de datos expone el DAO con un método "get" abstracto para cada @Dao.
  • Definimos el singleton WordRoomDatabase, para evitar que se abran varias instancias de la base de datos al mismo tiempo.
  • getDatabase muestra el singleton. Se creará la base de datos la primera vez que se acceda a ella, usando el compilador de bases de datos de Room para crear un objeto RoomDatabase en el contexto de la aplicación a partir de la clase WordRoomDatabase y se le asignará el nombre "word_database".

¿Qué es un repositorio?

Una clase de repositorio abstrae el acceso a múltiples fuentes de datos. El repositorio no forma parte de las bibliotecas de componentes de la arquitectura, pero es una práctica recomendada para la separación del código y su arquitectura. Una clase de repositorio proporciona una API limpia para el acceso de datos al resto de la aplicación.

cdfae5b9b10da57f.png

¿Por qué usar un repositorio?

Un repositorio administra las consultas y te permite usar varios backends. En el ejemplo más común, el repositorio implementa la lógica para decidir si debe recuperar datos de una red o usar resultados almacenados en caché de una base de datos local.

Cómo implementar el repositorio

Crea un archivo de clase de Kotlin llamado WordRepository y pega el siguiente código:

// Declares the DAO as a private property in the constructor. Pass in the DAO
// instead of the whole database, because you only need access to the DAO
class WordRepository(private val wordDao: WordDao) {

    // Room executes all queries on a separate thread.
    // Observed Flow will notify the observer when the data has changed.
    val allWords: Flow<List<Word>> = wordDao.getAlphabetizedWords()

    // By default Room runs suspend queries off the main thread, therefore, we don't need to
    // implement anything else to ensure we're not doing long running database work
    // off the main thread.
    @Suppress("RedundantSuspendModifier")
    @WorkerThread
    suspend fun insert(word: Word) {
        wordDao.insert(word)
    }
}

Las ideas principales son las siguientes:

  • El DAO se pasa al constructor del repositorio, en lugar de la base de datos completa. Esto se debe a que solo necesita acceso al DAO, ya que contiene todos los métodos de lectura y escritura de la base de datos. No es necesario exponer toda la base de datos en el repositorio.
  • La lista de palabras es una propiedad pública. Se inicializa al obtener la lista de palabras Flow de Room. Esto es posible debido a la forma en la que definimos el método getAlphabetizedWords para que muestre Flow en el paso "Observar cambios en la base de datos". Room ejecuta todas las consultas en un subproceso separado.
  • El modificador suspend le indica al compilador que debe llamarse desde una corrutina o desde otra función de suspensión.
  • Room ejecuta las consultas suspendidas fuera del subproceso principal.

¿Qué es un ViewModel?

La función de ViewModel es proporcionar datos a la IU y sobrevivir a los cambios de configuración. Un ViewModel actúa como un centro de comunicación entre el repositorio y la IU. También puedes usar un ViewModel para compartir datos entre fragmentos. ViewModel forma parte de la biblioteca de ciclo de vida.

72848dfccfe5777b.png

Consulta ViewModel Overview o la entrada de blog "ViewModels: A Simple Example" para ver una guía introductoria sobre este tema.

¿Por qué deberías usar un ViewModel?

Un ViewModel contiene los datos de la IU de tu app de una manera optimizada para los ciclos de vida que sobrevive a los cambios de configuración. La separación entre los datos de IU de tu app y las clases Activity y Fragment te permite seguir mejor el principio de responsabilidad única: las actividades y los fragmentos son responsables de dibujar datos en la pantalla, mientras que tu ViewModel puede encargarse de contener y procesar todos los datos necesarios para la IU.

LiveData y ViewModel

LiveData es una clase contenedora de datos observable. Puedes recibir notificaciones cada vez que cambien los datos. A diferencia del flujo, LiveData está optimizada para los ciclos de vida, lo que significa que respetará el ciclo de vida de otros componentes, como Activity y Fragment. LiveData detiene o reanuda la observación automáticamente según el ciclo de vida del componente que escucha los cambios, lo que la convierte en el componente perfecto para trabajar con datos modificables que la IU usará o mostrará.

ViewModel convertirá los datos del repositorio de flujo en LiveData, y le mostrará a la IU la lista de palabras como LiveData. De esta forma, nos aseguraremos de que nuestra IU se actualice automáticamente cada vez que se modifiquen los datos en la base de datos.

viewModelScope

En Kotlin, todas las corrutinas se ejecutan dentro de un CoroutineScope. Un alcance controla las corrutinas desde el principio con su trabajo. Cuando cancelas el trabajo de un alcance, se cancelan todas las corrutinas que se iniciaron en ese alcance.

La biblioteca lifecycle-viewmodel-ktx de AndroidX agrega un viewModelScope como una función de extensión de la clase ViewModel, lo que te permite trabajar con alcances.

Si quieres obtener más información sobre cómo trabajar con corrutinas en el ViewModel, consulta el paso 5 del codelab Cómo usar corrutinas de Kotlin en tu app de Android o la entrada de blog "Easy Coroutines in Android: viewModelScope".

Cómo implementar ViewModel

Crea un archivo de clase de Kotlin para WordViewModel y agrégale este código:

class WordViewModel(private val repository: WordRepository) : ViewModel() {

    // Using LiveData and caching what allWords returns has several benefits:
    // - We can put an observer on the data (instead of polling for changes) and only update the
    //   the UI when the data actually changes.
    // - Repository is completely separated from the UI through the ViewModel.
    val allWords: LiveData<List<Word>> = repository.allWords.asLiveData()

    /**
     * Launching a new coroutine to insert the data in a non-blocking way
     */
    fun insert(word: Word) = viewModelScope.launch {
        repository.insert(word)
    }
}

class WordViewModelFactory(private val repository: WordRepository) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(WordViewModel::class.java)) {
            @Suppress("UNCHECKED_CAST")
            return WordViewModel(repository) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}

Desglosemos este código. Esto es lo que hiciste:

  • Creaste una clase llamada WordViewModel que obtiene el WordRepository como parámetro y extiende ViewModel. El repositorio es la única dependencia que ViewModel necesita. Si se hubieran necesitado otras clases, también se habrían aprobado en el constructor.
  • Agregaste una variable de miembro LiveData pública para almacenar en caché la lista de palabras.
  • Inicializaste el LiveData con el flujo allWords desde el repositorio y, luego, llamaste a asLiveData(). para convertir el flujo en LiveData.
  • Creaste un método insert() de wrapper que llama al método insert() del repositorio. De esta manera, la implementación de insert() se encapsula desde la IU, lanzamos una corrutina nueva y llamamos a la inserción del repositorio, que es una función suspendida. Como se mencionó antes, ViewModels tiene un alcance de corrutinas basado en los ciclos de vida llamado viewModelScope, que usarás aquí.
  • Creaste el ViewModel mediante la implementación de un ViewModelProvider.Factory que obtiene las dependencias necesarias para crear WordViewModel como parámetro: el WordRepository.

Cuando usas viewModels y ViewModelProvider.Factory, el framework se encarga del ciclo de vida del ViewModel. Sobrevivirá a los cambios de configuración y siempre obtendrás la instancia correcta de la clase WordViewModel, incluso si se recrea la actividad.

A continuación, debes agregar el diseño XML para la lista y los elementos.

En este codelab, se asume que estás familiarizado con la creación de diseños en XML. Por ello, solo te proporcionamos el código.

Configura el AppTheme como superior a Theme.MaterialComponents.Light.DarkActionBar para crear tu material de temas de la aplicación. Agrega un estilo para los elementos de lista en values/styles.xml:

<resources>

    <!-- Base application theme. -->
    <style name="AppTheme" parent="Theme.MaterialComponents.Light.DarkActionBar">
        <!-- Customize your theme here. -->
        <item name="colorPrimary">@color/colorPrimary</item>
        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
        <item name="colorAccent">@color/colorAccent</item>
    </style>

    <!-- The default font for RecyclerView items is too small.
    The margin is a simple delimiter between the words. -->
    <style name="word_title">
        <item name="android:layout_marginBottom">8dp</item>
        <item name="android:paddingLeft">8dp</item>
        <item name="android:background">@android:color/holo_orange_light</item>
        <item name="android:textAppearance">@android:style/TextAppearance.Large</item>
    </style>
</resources>

Crea un archivo de recursos de dimensión nuevo:

  1. Haz clic en el módulo de la app en la ventana Project.
  2. Selecciona File > New > Android Resource File.
  3. En los calificadores disponibles, selecciona Dimension.
  4. Asígnale el nombre: dimens

aa5895240838057.png

Agrega estos recursos de dimensión a values/dimens.xml:

<dimen name="big_padding">16dp</dimen>

Agrega un diseño layout/recyclerview_item.xml:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <TextView
        android:id="@+id/textView"
        style="@style/word_title"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@android:color/holo_orange_light" />
</LinearLayout>

En layout/activity_main.xml, reemplaza TextView por RecyclerView y agrega un botón de acción flotante (BAF). Ahora, tu diseño debería verse así:

<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerview"
        android:layout_width="0dp"
        android:layout_height="0dp"
        tools:listitem="@layout/recyclerview_item"
        android:padding="@dimen/big_padding"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/fab"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="16dp"
        android:contentDescription="@string/add_word"/>

</androidx.constraintlayout.widget.ConstraintLayout>

La apariencia de tu BAF debería corresponderse con la acción disponible, por lo que debes reemplazar el ícono por el símbolo "+".

En primer lugar, debes agregar un elemento vectorial nuevo:

  1. Selecciona File > New > Vector Asset.
  2. Haz clic en el ícono de robot de Android en el campo Clip Art:. 8d935457de8e7a46.png
  3. Busca la opción "add" para agregar y selecciona el elemento "+". Haz clic en OK. 758befc99c8cc794.png
  4. En la ventana Asset Studio, haz clic en Next. 672248bada3cfb25.png
  5. Confirma la ruta de acceso del ícono como main > drawable y haz clic en Finish para agregar el elemento. ef118084f96c6176.png
  6. Aún en layout/activity_main.xml, actualiza el BAF para incluir el nuevo elemento de diseño:
<com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/fab"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="16dp"
        android:contentDescription="@string/add_word"
        android:src="@drawable/ic_add_black_24dp"/>

Los datos se mostrarán en RecyclerView, que es un poco más agradable que solo arrojarlos en un TextView. En este codelab, se asume que sabes cómo funcionan RecyclerView, RecyclerView.ViewHolder y ListAdapter.

Deberás crear lo siguiente:

  • La clase WordListAdapter, que extiende ListAdapter
  • Una parte anidada de la clase DiffUtil.ItemCallback de WordListAdapter.
  • El ViewHolder que mostrará cada palabra de nuestra lista

Este es el código:

class WordListAdapter : ListAdapter<Word, WordViewHolder>(WordsComparator()) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WordViewHolder {
        return WordViewHolder.create(parent)
    }

    override fun onBindViewHolder(holder: WordViewHolder, position: Int) {
        val current = getItem(position)
        holder.bind(current.word)
    }

    class WordViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        private val wordItemView: TextView = itemView.findViewById(R.id.textView)

        fun bind(text: String?) {
            wordItemView.text = text
        }

        companion object {
            fun create(parent: ViewGroup): WordViewHolder {
                val view: View = LayoutInflater.from(parent.context)
                    .inflate(R.layout.recyclerview_item, parent, false)
                return WordViewHolder(view)
            }
        }
    }

    class WordsComparator : DiffUtil.ItemCallback<Word>() {
        override fun areItemsTheSame(oldItem: Word, newItem: Word): Boolean {
            return oldItem === newItem
        }

        override fun areContentsTheSame(oldItem: Word, newItem: Word): Boolean {
            return oldItem.word == newItem.word
        }
    }
}

Esto es lo que hiciste:

  • La clase WordViewHolder, que nos permite vincular un texto a un TextView. Esta muestra una función create() estática que controla el aumento del diseño.
  • WordsComparator define cómo calcular si dos palabras son iguales o si los contenidos son los mismos.
  • WordListAdapter crea el WordViewHolder en onCreateViewHolder y lo vincula en onBindViewHolder.

Agrega RecyclerView al método onCreate() de MainActivity.

En el método onCreate(), después de setContentView:

   val recyclerView = findViewById<RecyclerView>(R.id.recyclerview)
   val adapter = WordListAdapter()
   recyclerView.adapter = adapter
   recyclerView.layoutManager = LinearLayoutManager(this)

Ejecuta tu app para asegurarte de que todo funcione correctamente. No hay elementos porque aún no has agregado los datos.

79cb875d4296afce.png

Te recomendamos que tengas solo una instancia de la base de datos y del repositorio en la app. Una forma fácil de lograrlo es crearlos como miembros de la clase Application. De esta manera, en lugar de compilarse cada vez que sea necesario, simplemente se los recuperará de la aplicación.

Crea una clase nueva llamada WordsApplication que extienda Application. Este es el código:

class WordsApplication : Application() {
    // Using by lazy so the database and the repository are only created when they're needed
    // rather than when the application starts
    val database by lazy { WordRoomDatabase.getDatabase(this) }
    val repository by lazy { WordRepository(database.wordDao()) }
}

A continuación, te mostramos lo que hiciste:

  • Creaste una instancia de base de datos.
  • Creaste una instancia de repositorio en función del DAO de la base de datos.
  • Dado que estos objetos deberían crearse únicamente cuando se los necesita por primera vez y no al iniciar la app, usaste la delegación de propiedades de Kotlin: by lazy.

Ahora que creaste la clase Application, actualiza el archivo AndroidManifest y configura WordsApplication como application android:name.

La etiqueta de aplicación debería verse así:

<application
        android:name=".WordsApplication"
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
...

Por el momento, no hay datos en la base de datos. Agregarás datos de dos maneras: algunos cuando se cree la base de datos, y un Activity para agregar palabras.

Crearás una RoomDatabase.Callback y anula onCreate() para borrar todo el contenido y volver a propagar la base de datos cada vez que se crea la app. Como no puedes realizar operaciones de la base de datos de Room en el subproceso de IU, onCreate() inicia una corrutina en el despachador de IO.

Para iniciar una corrutina, necesitas un CoroutineScope. Actualiza el método getDatabase de la clase WordRoomDatabase a fin de obtener también un alcance de corrutina como parámetro:

fun getDatabase(
       context: Context,
       scope: CoroutineScope
  ): WordRoomDatabase {
...
}

La propagación de la base de datos no está relacionada con el ciclo de vida de una IU, por lo tanto, no recomendamos usar un CoroutineScope como viewModelScope. Se relaciona con el ciclo de vida de la app. Deberás actualizar WordsApplication para que contenga un applicationScope y, luego, lo pasaremos al WordRoomDatabase.getDatabase.

class WordsApplication : Application() {
    // No need to cancel this scope as it'll be torn down with the process
    val applicationScope = CoroutineScope(SupervisorJob())

    // Using by lazy so the database and the repository are only created when they're needed
    // rather than when the application starts
    val database by lazy { WordRoomDatabase.getDatabase(this, applicationScope) }
    val repository by lazy { WordRepository(database.wordDao()) }
}

En el elemento WordRoomDatabase, creaste una implementación personalizada de RoomDatabase.Callback(), que también obtiene un objeto CoroutineScope como parámetro del constructor. Luego, anulaste el método onOpen para propagar la base de datos.

Este es el código para crear la devolución de llamada dentro de la clase WordRoomDatabase:

private class WordDatabaseCallback(
    private val scope: CoroutineScope
) : RoomDatabase.Callback() {

    override fun onCreate(db: SupportSQLiteDatabase) {
        super.onCreate(db)
        INSTANCE?.let { database ->
            scope.launch {
                populateDatabase(database.wordDao())
            }
        }
    }

    suspend fun populateDatabase(wordDao: WordDao) {
        // Delete all content here.
        wordDao.deleteAll()

        // Add sample words.
        var word = Word("Hello")
        wordDao.insert(word)
        word = Word("World!")
        wordDao.insert(word)

        // TODO: Add your own words!
    }
}

Por último, agrega la devolución de llamada a la secuencia de compilación de la base de datos justo antes de llamar a .build() en el Room.databaseBuilder():

.addCallback(WordDatabaseCallback(scope))

Así se verá el código final:

@Database(entities = arrayOf(Word::class), version = 1, exportSchema = false)
abstract class WordRoomDatabase : RoomDatabase() {

   abstract fun wordDao(): WordDao

   private class WordDatabaseCallback(
       private val scope: CoroutineScope
   ) : RoomDatabase.Callback() {

       override fun onCreate(db: SupportSQLiteDatabase) {
           super.onCreate(db)
           INSTANCE?.let { database ->
               scope.launch {
                   var wordDao = database.wordDao()

                   // Delete all content here.
                   wordDao.deleteAll()

                   // Add sample words.
                   var word = Word("Hello")
                   wordDao.insert(word)
                   word = Word("World!")
                   wordDao.insert(word)

                   // TODO: Add your own words!
                   word = Word("TODO!")
                   wordDao.insert(word)
               }
           }
       }
   }

   companion object {
       @Volatile
       private var INSTANCE: WordRoomDatabase? = null

       fun getDatabase(
           context: Context,
           scope: CoroutineScope
       ): WordRoomDatabase {
            // 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,
                        WordRoomDatabase::class.java,
                        "word_database"
                )
                 .addCallback(WordDatabaseCallback(scope))
                 .build()
                INSTANCE = instance
                // return instance
                instance
        }
     }
   }
}

Agrega estos recursos de string a values/strings.xml:

<string name="hint_word">Word...</string>
<string name="button_save">Save</string>
<string name="empty_not_saved">Word not saved because it is empty.</string>
<string name="add_word">Add word</string>

Agrega este recurso de color a value/colors.xml:

<color name="buttonLabel">#FFFFFF</color>

Agrega un recurso de dimensión min_height a values/dimens.xml:

<dimen name="min_height">48dp</dimen>

Crea un Activity de Android vacío nuevo con la plantilla Empty Activity:

  1. Selecciona File > New > Activity > Empty Activity.
  2. Ingresa NewWordActivity en el nombre de la actividad.
  3. Verifica que se haya agregado la nueva actividad al manifiesto de Android.
<activity android:name=".NewWordActivity"></activity>

Actualiza el archivo activity_new_word.xml de la carpeta de diseño con el siguiente código:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <EditText
        android:id="@+id/edit_word"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:minHeight="@dimen/min_height"
        android:fontFamily="sans-serif-light"
        android:hint="@string/hint_word"
        android:inputType="textAutoComplete"
        android:layout_margin="@dimen/big_padding"
        android:textSize="18sp" />

    <Button
        android:id="@+id/button_save"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@color/colorPrimary"
        android:text="@string/button_save"
        android:layout_margin="@dimen/big_padding"
        android:textColor="@color/buttonLabel" />

</LinearLayout>

Actualiza el código de la actividad:

class NewWordActivity : AppCompatActivity() {

    private lateinit var editWordView: EditText

    public override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_new_word)
        editWordView = findViewById(R.id.edit_word)

        val button = findViewById<Button>(R.id.button_save)
        button.setOnClickListener {
            val replyIntent = Intent()
            if (TextUtils.isEmpty(editWordView.text)) {
                setResult(Activity.RESULT_CANCELED, replyIntent)
            } else {
                val word = editWordView.text.toString()
                replyIntent.putExtra(EXTRA_REPLY, word)
                setResult(Activity.RESULT_OK, replyIntent)
            }
            finish()
        }
    }

    companion object {
        const val EXTRA_REPLY = "com.example.android.wordlistsql.REPLY"
    }
}

El paso final es conectar la IU a la base de datos. Para ello, guarda las palabras nuevas que el usuario ingrese y muestra el contenido actual de la base de datos de palabra en el RecyclerView.

Para mostrar el contenido actual de la base de datos, agrega un observador que observe LiveData en el ViewModel.

Cuando cambian los datos, se invoca la devolución de llamada onChanged(), que llama al método setWords() del adaptador para actualizar los datos almacenados en caché del adaptador y actualizar la lista que se muestra.

En MainActivity, crea el ViewModel:

private val wordViewModel: WordViewModel by viewModels {
    WordViewModelFactory((application as WordsApplication).repository)
}

Para crear el ViewModel, usaste el delegado viewModels, que pasa una instancia de nuestro WordViewModelFactory. Se construye a partir de un repositorio recuperado de WordsApplication.

También en onCreate(), agrega un observador para la propiedad LiveData de allWords desde el WordViewModel.

El método onChanged() (el método predeterminado para Lambda) se activa cuando se modifican los datos observados y la actividad está en primer plano:

wordViewModel.allWords.observe(this, Observer { words ->
            // Update the cached copy of the words in the adapter.
            words?.let { adapter.submitList(it) }
})

Debes hacer que, cuando se presiona el BAF, se abra la NewWordActivity y, una vez que regresas a la MainActivity, insertar la nueva palabra en la base de datos o mostrar un Toast.

Para lograrlo, comienza por definir un código de solicitud:

private val newWordActivityRequestCode = 1

En MainActivity, agrega el código onActivityResult() para la NewWordActivity.

Si la actividad muestra RESULT_OK, inserta la palabra que se mostró en la base de datos mediante una llamada al método insert() de WordViewModel:

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)

    if (requestCode == newWordActivityRequestCode && resultCode == Activity.RESULT_OK) {
        data?.getStringExtra(NewWordActivity.EXTRA_REPLY)?.let {
            val word = Word(it)
            wordViewModel.insert(word)
        }
    } else {
        Toast.makeText(
            applicationContext,
            R.string.empty_not_saved,
            Toast.LENGTH_LONG).show()
    }
}

En MainActivity,, inicia NewWordActivity cuando el usuario presione el BAF. En la onCreate MainActivity, busca el BAF y agrega un onClickListener con este código:

val fab = findViewById<FloatingActionButton>(R.id.fab)
fab.setOnClickListener {
  val intent = Intent(this@MainActivity, NewWordActivity::class.java)
  startActivityForResult(intent, newWordActivityRequestCode)
}

El código finalizado debería verse así:

class MainActivity : AppCompatActivity() {

    private val newWordActivityRequestCode = 1
    private val wordViewModel: WordViewModel by viewModels {
        WordViewModelFactory((application as WordsApplication).repository)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val recyclerView = findViewById<RecyclerView>(R.id.recyclerview)
        val adapter = WordListAdapter()
        recyclerView.adapter = adapter
        recyclerView.layoutManager = LinearLayoutManager(this)

        // Add an observer on the LiveData returned by getAlphabetizedWords.
        // The onChanged() method fires when the observed data changes and the activity is
        // in the foreground.
        wordViewModel.allWords.observe(owner = this) { words ->
            // Update the cached copy of the words in the adapter.
            words.let { adapter.submitList(it) }
        }

        val fab = findViewById<FloatingActionButton>(R.id.fab)
        fab.setOnClickListener {
            val intent = Intent(this@MainActivity, NewWordActivity::class.java)
            startActivityForResult(intent, newWordActivityRequestCode)
        }
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, intentData: Intent?) {
        super.onActivityResult(requestCode, resultCode, intentData)

        if (requestCode == newWordActivityRequestCode && resultCode == Activity.RESULT_OK) {
            intentData?.getStringExtra(NewWordActivity.EXTRA_REPLY)?.let { reply ->
                val word = Word(reply)
                wordViewModel.insert(word)
            }
        } else {
            Toast.makeText(
                applicationContext,
                R.string.empty_not_saved,
                Toast.LENGTH_LONG
            ).show()
        }
    }
}

Ahora ejecuta tu app. Cuando agregues una palabra a la base de datos en NewWordActivity, la IU se actualizará de forma automática.

Ahora que tienes una app funcional, repasemos lo que compilaste. A continuación, se muestra la estructura de la app una vez más:

a70aca8d4b737712.png

Los componentes de la aplicación son los siguientes:

  • MainActivity: Muestra palabras en una lista mediante un RecyclerView y WordListAdapter. En MainActivity, hay un Observer que observa las palabras de la base de datos y recibe una notificación cuando se modifican.
  • NewWordActivity: agrega una palabra nueva a la lista.
  • WordViewModel: Proporciona métodos para acceder a la capa de datos, y muestra LiveData para que MainActivity pueda configurar la relación del observador.*
  • LiveData<List<Word>>: Permite que las actualizaciones sean automáticas para los componentes de la IU. Puedes llamar a flow.toLiveData() y convertir Flow en LiveData.
  • Repository: administra una o más fuentes de datos. El Repository muestra los métodos para que ViewModel interactúe con el proveedor de datos subyacente. En esta aplicación, ese backend es una base de datos de Room.
  • Room: es un wrapper e implementa una base de datos SQLite. Room hace por ti mucho del trabajo que solías hacer.
  • DAO: Asigna llamadas de método a consultas de la base de datos para que, cuando el repositorio llame a un método como getAlphabetizedWords(), Room pueda ejecutar SELECT * FROM word_table ORDER BY word ASC**.**
  • DAO puede mostrar consultas suspend para solicitudes únicas y consultas Flow, cuando quieres recibir notificaciones de los cambios en la base de datos.
  • Word: es la clase de entidad que contiene una sola palabra.
  • Views y Activities (y Fragments) solo interactúan con los datos mediante ViewModel. Por lo tanto, es irrelevante de dónde provengan los datos.

Flujo de datos para las actualizaciones automáticas de la IU (IU reactiva)

La actualización automática es posible porque estás usando LiveData. En el MainActivity, hay un Observer que observa las palabras LiveData de la base de datos y recibe una notificación cuando cambian. Cuando hay un cambio, el método onChange() del observador se ejecuta y se actualiza mWords en el WordListAdapter.

Los datos pueden observarse porque son LiveData y lo que se observa es la LiveData<List<Word>> que muestra la propiedad WordViewModel allWords.

El WordViewModel oculta toda la información del backend de la capa de IU. Proporciona métodos para acceder a la capa de datos y muestra LiveData para que MainActivity pueda configurar la relación del observador. Views y Activities (y Fragments) solo interactúan con los datos mediante ViewModel. Por lo tanto, es irrelevante de dónde provengan los datos.

En este caso, los datos provienen de un Repository. El ViewModel no necesita saber con qué interactúa el repositorio. Solo necesita saber cómo interactuar con el Repository, que se realiza con los métodos que muestra el Repository.

El repositorio administra una o más fuentes de datos. En la app WordListSample, ese backend es una base de datos de Room. Room es un wrapper que implementa una base de datos SQLite. Room hace por ti mucho del trabajo que solías hacer. Por ejemplo, Room hace todo lo que solías hacer con una clase SQLiteOpenHelper.

El DAO asigna llamadas de método a las consultas de la base de datos para que, cuando el repositorio llame a un método como getAllWords(), Room pueda ejecutar SELECT * FROM word_table ORDER BY word ASC

.

Como el resultado que muestra la consulta es LiveData observado, cada vez que se modifican los datos de Room, se ejecuta el método onChanged() de la interfaz Observer y se actualiza la IU.

[Opcional] Descarga el código de la solución

Si aún no lo hiciste, puedes ver el código de la solución para el codelab. Puedes mirar el repositorio de GitHub o descargar el código aquí:

Descarga el código fuente

Descomprime el archivo zip descargado. Esto descomprimirá una carpeta raíz, android-room-with-a-view-kotlin, que contiene la app completa.