Android Room con un componente View: Kotlin

El propósito de los componentes de la arquitectura es brindar 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 que se puede 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 Informa 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:

Además, es útil familiarizarse con los patrones arquitectónicos de software que separan datos de la interfaz de usuario, como MVP o MVC. En este codelab, se implementará la arquitectura que se define en la Guía de arquitectura de apps.

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.

Si no estás familiarizado con Kotlin, aquí te proporcionamos una versión de este codelab en el lenguaje de programación Java.

Actividades

En este codelab, aprenderás a diseñar y construir una app con los componentes de arquitectura Room, ViewModel y LiveData. También crearás una app que haga lo siguiente:

  • Implementa nuestra arquitectura recomendada con los componentes de la arquitectura de Android.
  • Funciona con una base de datos para obtener y guardar los datos, y prepropaga la base de datos con algunas palabras.
  • Muestra todas las palabras de una RecyclerView en MainActivity.
  • Abrir una segunda actividad cuando el usuario presiona el botón "+" Cuando el usuario ingresa una palabra, la agrega a la base de datos y a la lista.

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

Requisitos

  • Android Studio 3.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.

Hay muchos pasos para usar los componentes de la arquitectura e implementar la arquitectura recomendada. Lo más importante es crear un modelo mental de lo que sucede, entender cómo las piezas se relacionan entre sí y cómo fluyen los datos. A medida que trabajes en este codelab, intenta comenzar a desarrollar la comprensión interna del código y no simplemente copiarlo y pegarlo.

Para presentar la terminología, esta es una breve introducción a 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 con más detalle a medida que lo usas.

Puedes ver una forma básica de la arquitectura en este diagrama:

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

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.

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.

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

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.

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.

Descripción general de la arquitectura de RoomWordSample

El diagrama siguiente muestra 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.

  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.

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. Para aplicar el complemento procesador de anotaciones kapt de Kotlin, agrégalo después de los otros complementos definidos 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.
android {
    // other configuration (buildTypes, defaultConfig, etc.)

    packagingOptions {
        exclude 'META-INF/atomicfu.kotlin_module'
    }
}
  1. Agrega el siguiente código al final del bloque dependencies.
// Room components
implementation "androidx.room:room-runtime:$rootProject.roomVersion"
kapt "androidx.room:room-compiler:$rootProject.roomVersion"
androidTestImplementation "androidx.room:room-testing:$rootProject.roomVersion"

// Lifecycle components
implementation "androidx.lifecycle:lifecycle-extensions:$rootProject.archLifecycleVersion"
kapt "androidx.lifecycle:lifecycle-compiler:$rootProject.archLifecycleVersion"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$rootProject.archLifecycleVersion"

// 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"

// Material design
implementation "com.google.android.material:material:$rootProject.materialVersion"

// Testing
testImplementation 'junit:junit:4.12'
androidTestImplementation "androidx.arch.core:core-testing:$rootProject.coreTestingVersion"
  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 {
    roomVersion = '2.2.5'
    archLifecycleVersion = '2.2.0'
    coreTestingVersion = '2.1.0'
    materialVersion = '1.1.0'
    coroutines = '1.3.4'
}

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

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 representa 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 anotarla. Las anotaciones identifican cómo cada parte de esta clase se relaciona con una entrada en la base de datos. Room usa esta información 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
    Todas las entidades necesitan 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 consultas 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, lo que permite que tus búsquedas 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 deben observar los datos para poder reaccionar cuando cambien.

Según cómo se almacenen los datos, esto puede ser complicado. La observación de cambios en los datos de varios componentes de la app puede crear rutas de dependencia explícitas y rígidas entre los componentes. Esto dificulta la prueba y la depuración, entre otras cosas.

LiveData, una clase de biblioteca de ciclo de vida para la observación de datos, resuelve este problema. Usa un valor de muestra de tipo LiveData en la descripción del método y Room generará todo el código necesario para actualizar LiveData 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 LiveData.

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

Más adelante en este codelab, harás un seguimiento de los cambios en los datos mediante un Observer en MainActivity.

¿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 búsquedas de Room muestran LiveData, las consultas se ejecutan automáticamente de forma 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 {
            val tempInstance = INSTANCE
            if (tempInstance != null) {
                return tempInstance
            }
            synchronized(this) {
                val instance = Room.databaseBuilder(
                        context.applicationContext,
                        WordRoomDatabase::class.java, 
                        "word_database"
                    ).build()
                INSTANCE = instance
                return 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 la base de datos están fuera del alcance de este codelab, por lo que configuraremos exportSchema como falso para evitar una advertencia de compilación. En una app real, deberías pensar en 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 un singleton para evitar que se abran varias instancias de la base de datos al mismo tiempo, WordRoomDatabase,.
  • getDatabase muestra el singleton. Se creará la base de datos la primera vez que acceda a ella, mediante 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.

¿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 LiveData will notify the observer when the data has changed.
    val allWords: LiveData<List<Word>> = wordDao.getAlphabetizedWords()
 
    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 LiveData de Room. Esto es posible debido a la forma en la que definimos el método getAlphabetizedWords para que muestre LiveData en el paso "Clase LiveData". Room ejecuta todas las consultas en un subproceso separado. Luego, LiveData observado notificará al observador en el subproceso principal cuando los datos hayan cambiado.
  • El modificador suspend le indica al compilador que debe llamarse desde una corrutina o desde otra función de suspensión.

¿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.

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

¿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.

En ViewModel, usa LiveData para datos modificables que la IU usará o mostrará. El uso de LiveData ofrece varios beneficios:

  • Puedes colocar un observador de los datos (en lugar de sondear los cambios) y solo actualizar la
    la IU cuando los datos realmente cambien.
  • El repositorio y la IU están completamente separados por ViewModel.
  • No hay llamadas a la base de datos desde el ViewModel (todo esto se maneja en el repositorio), lo que hace que el código sea más fácil de probar.

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 Corrutinas simples en Android: viewModelScope.

Cómo implementar ViewModel

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

class WordViewModel(application: Application) : AndroidViewModel(application) {

    private val repository: WordRepository
    // Using LiveData and caching what getAlphabetizedWords 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>>

    init {
        val wordsDao = WordRoomDatabase.getDatabase(application).wordDao()
        repository = WordRepository(wordsDao)
        allWords = repository.allWords
    }

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

Lo que hicimos hasta aquí:

  • Creamos una clase llamada WordViewModel que obtiene el Application como parámetro y extiende AndroidViewModel.
  • Se agregó una variable de miembro privado para mantener una referencia al repositorio.
  • Agregamos una variable de miembro LiveData pública para almacenar en caché la lista de palabras.
  • Se creó un bloque init que obtiene una referencia a WordDao desde WordRoomDatabase.
  • En el bloque init, se construye el WordRepository en función de la WordRoomDatabase.
  • En el bloque init, inicializaste el LiveData de allWords con el repositorio.
  • Creamos 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, No queremos insertar inserción para bloquear el subproceso principal, por lo que estamos lanzando una corrutina nueva y llamando a la inserción del repositorio, que es una función de suspensión. Como se mencionó antes, ViewModels tiene un alcance de corrutinas basado en los ciclos de vida, llamado viewModelScope, que usamos aquí.

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_width">match_parent</item>
        <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>

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 debemos reemplazar el ícono por el símbolo &'

En primer lugar, debemos 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:.
  3. Busca &addt; y selecciona el elemento '+' Haz clic en OK.
  4. Luego, haz clic en Siguiente.
  5. Confirma la ruta de acceso del ícono como main > drawable y haz clic en Finish para agregar el elemento.
  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 supone que sabes cómo funcionan RecyclerView, RecyclerView.LayoutManager, RecyclerView.ViewHolder y RecyclerView.Adapter.

Ten en cuenta que la variable words del adaptador almacena en caché los datos. En la próxima tarea, agregará el código que actualiza los datos automáticamente.

Crea un archivo de clase de Kotlin para WordListAdapter que extienda RecyclerView.Adapter. Este es el código:

class WordListAdapter internal constructor(
        context: Context
) : RecyclerView.Adapter<WordListAdapter.WordViewHolder>() {

    private val inflater: LayoutInflater = LayoutInflater.from(context)
    private var words = emptyList<Word>() // Cached copy of words

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

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WordViewHolder {
        val itemView = inflater.inflate(R.layout.recyclerview_item, parent, false)
        return WordViewHolder(itemView)
    }

    override fun onBindViewHolder(holder: WordViewHolder, position: Int) {
        val current = words[position]
        holder.wordItemView.text = current.word
    }

    internal fun setWords(words: List<Word>) {
        this.words = words
        notifyDataSetChanged()
    }

    override fun getItemCount() = words.size
}

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(this)
   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.

No hay datos en la base de datos. Agregarás datos de dos maneras: algunos cuando se abra la base de datos, y un Activity para agregar palabras.

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

Para iniciar una corrutina, necesitamos 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 {
...
}

Actualiza el inicializador de recuperación de la base de datos en el bloque init de WordViewModel para pasar también el permiso:

val wordsDao = WordRoomDatabase.getDatabase(application, viewModelScope).wordDao()

En el WordRoomDatabase, creamos una implementación personalizada de RoomDatabase.Callback(), que también obtiene un objeto CoroutineScope como parámetro del constructor. Luego, anulamos 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 onOpen(db: SupportSQLiteDatabase) {
        super.onOpen(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 onOpen(db: SupportSQLiteDatabase) {
           super.onOpen(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>

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

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

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. Establece el nombre del archivo: dimens.

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

<dimen name="small_padding">8dp</dimen>
<dimen name="big_padding">16dp</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 una variable de miembro para ViewModel:

private lateinit var wordViewModel: WordViewModel

Usa ViewModelProvider para asociar tu ViewModel con tu Activity.

Cuando comience la Activity, el ViewModelProviders creará la ViewModel. Cuando se destruye la actividad, por ejemplo, a través de un cambio de configuración, el elemento ViewModel persiste. Cuando se vuelve a crear la actividad, el objeto ViewModelProviders muestra el ViewModel existente. Para obtener más información, consulta ViewModel.

En onCreate(), debajo del bloque de código RecyclerView, obtén una ViewModel de la ViewModelProvider:

wordViewModel = ViewModelProvider(this).get(WordViewModel::class.java)

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.setWords(it) }
})

Queremos que, cuando se presiona el BAF, se abra la NewWordActivity y, una vez que regresamos a la MainActivity, insertar la nueva palabra en la base de datos o mostrar un Toast. Para lograrlo, comencemos 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 const val newWordActivityRequestCode = 1
   private lateinit var wordViewModel: WordViewModel

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

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

       wordViewModel = ViewModelProvider(this).get(WordViewModel::class.java)
       wordViewModel.allWords.observe(this, Observer { words ->
           // Update the cached copy of the words in the adapter.
           words?.let { adapter.setWords(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, 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()
       }
   }
}

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:

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 LiveData de la base de datos y recibe una notificación cuando cambian.
  • 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. En el MainActivity, hay un Observer que observa las palabras LiveData de la base de datos y recibe una notificación cuando cambian.
  • 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.
  • 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 estamos 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í:

Download source code

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