En el capítulo pasado, empezamos a conocer la persistencia de datos y su utilidad, el problema es que como ya comenté las shared preferences están destinadas a persistir pequeños datos de preferencia del usuario, por lo que si necesitamos guardar más información necesitamos una base de datos. Aunque hay disponible muchísimas opciones muy completas, en este curso trabajaremos siempre con Room, puesto que es la alternativa que nos propone google.

¿Qué es Room?

Room es un ORM (Object-Relational mapping) que nos permitirá trabajar de una forma más sencilla con bases de datos SQL.

La imagen anterior nos muestra el funcionamiento de dicha herramienta que, aunque parezca complicado al principio, es muy fácil de entender cuando nos pongamos a ello.

La idea es clara, tendremos una base de datos que le devolverá a nuestra app los Data Access Objects (DAO) estos son los encargados de persistir la información en la base de datos y de devolvernos las entities, que serán las encargadas de devolvernos la información que hemos ido almacenando.

Una vez entendamos un poco como funciona es el momento de ponerse manos a la obra. Lo primero que haremos será crear nuestro primer proyecto como siempre.

MisNotas

Así se llamará nuestra app y consistirá en una lista de tareas las cuales podemos marcar como hechas o no y tendremos la posibilidad de borrarlas. Al final del artículo lo tendréis disponible para descargar a través de GitHub (ya haré un artículo sobre git/gitflow y demás).

Lo primero que haremos será añadir todas las dependencias necesarias. Como ya he comentado anteriormente las dependencias son pequeñas llamadas que hace nuestro fichero Gradle para implementar funciones que por defecto nuestro proyecto no tiene. Por ejemplo, ahora necesitamos usar Room y aunque sea oficial de Google no viene por defecto en nuestro proyecto y es muy fácil de entender. Si vinieran todas las dependencias ya implementadas al abrir un proyecto, nuestra app pesaría muchísimo.

Así que vamos a ir al gradle del módulo app (por defecto tendremos un gradle por módulo y por aplicación, de eso ya hablaré en otros artículos). Para localizarlo, en tan sencillo como ir a Gradle Scripts si tenemos la vista «android».

curso android en kotlin

Y meteremos todas las dependencias que necesitamos. En este caso vamos a meter cuatro más, ya luego a medida que vayamos a usarlas las iré explicando.

implementation 'com.android.support:design:26.1.0'
implementation 'com.android.support:recyclerview-v7:26.1.0'
implementation 'org.jetbrains.anko:anko-common:0.9'
implementation "android.arch.persistence.room:runtime:1.0.0-rc1"
kapt "android.arch.persistence.room:compiler:1.0.0-rc1"

Las meteremos donde están las demás, dentro de dependencies {}. Sincronizamos el gradle.

Creando nuestra base de datos

Vamos a crear un directorio nuevo llamado database dentro de nuestro proyecto y ahí crearemos todo lo necesario siguiente el esquema que vimos antes. Necesitaremos crear 3 clases, empezaremos creando nuestra entidad que será el modelo a persistir en la base de datos.

TaskEntity

La aplicación va a ser una lista de tareas, así que el modelo se llamará TaskEntity y contendrá 3 valores, una ID para localizar el objeto, un nombre (el de la tarea a realizar) y un Booleano que será el que usemos para saber si la tarea está hecha o no. Así que creamos nuestra clase y la dejamos así.

@Entity(tableName = "task_entity")
data class TaskEntity (
        @PrimaryKey(autoGenerate = true)
        var id:Int = 0,
        var name:String = "",
        var isDone:Boolean = false
)

Aunque es bastante pequeñita quiero recalcar algunas cosillas:

  • La anotación @Entity la utilizamos para añadirle un nombre a nuestra entidad como tabla de la base de datos. Cada base de datos puede contener una o varias tablas y cada una persiste un modelo diferente.
  • La anotación @PrimaryKey (autoGenerate = true) está diciendo que la variable id es un valor que se autogenera al crear un objeto de esta clase y que no podrá repetirse. Es un valor único con el cual podremos localizar un objeto concreto.

TaskDao

TaskDao será la interfaz que contendrá las consultas a la base de datos. Aquí distinguiremos cuatro tipos de consultas.

  • @Query: Se hacen consultas directamente a la base de datos, usaremos SQL para hacerlas. En este ejemplo haremos dos muy sencillitas, pero se pueden hacer cosas impresionantes.
  • @Insert: Se usará para insertar entidades a la base de datos, a diferencia de las @Query no hay que hacer ningún tipo de consulta, sino pasar el objeto a insertar.
  • @Update: Actualizan una entidad ya insertada. Solo tendremos que pasar ese objeto modificado y ya se encarga de actualizarlo. ¿Cómo sabe que objeto hay que modificar? Pues por nuestro id (recordad que es la PrimaryKey).
  • @Delete: Como su propio nombre indica borra de la tabla un objeto que le pasemos.
@Dao
interface TaskDao {
    @Query("SELECT * FROM task_entity")
    fun getAllTasks(): MutableList<TaskEntity>
}

Por ahora solo meteremos una query, que lo que hará será seleccionar todas las TaskEntity que tengamos en la base de datos. Fijaros que es una interfaz en lugar de una clase, y que contiene la anotación @Dao.

TaskDatabase

Una vez tengamos nuestro Dao y nuestra entidad, vamos a crear la base de datos que los contendrá, en este caso se llamará TaskDatabase.

@Database(entities = arrayOf(TaskEntity::class), version = 1)
abstract class TasksDatabase : RoomDatabase() {
    abstract fun taskDao(): TaskDao
}

Lo primero que debemos fijarnos es en la anotación @Database, que especifica que la entidad será una lista de TaskEntity (entidad que ya hemos creado) y que la versión es 1. Las versiones se usan para la posible migración de datos al actualizar la App. Imaginemos que sacamos una segunda versión de la app y en vez de 3 parámetros almacenamos 4, no podemos cambiar nuestra entidad de golpe pues habría problemas. Para eso se usa la versión, junto a un fichero de migración que le dice al programa que deberá hacer para pasar de la versión 1 a la 2, 3 o la que sea.

También debemos fijarnos en que nuestra clase extienda de RoomDatabase() que es una clase que tenemos gracias a importar la dependencia de Room en nuestro gradle. Para finalizar tiene una sola función que hace referencia al Dao que hemos creado anteriormente, si tuviésemos más Dao’s pues habría que implementarlos ahí también.

Con esto ya tenemos nuestra base de datos preparada, ahora debemos instanciarla al inicio de la aplicación. Como hicimos en el capítulo anterior con las Shared Preferences crearemos una clase application para poder acceder a nuestra información en cualquier clase.

MisNotasApp

class MisNotasApp: Application() {

    companion object {
        lateinit var database: TasksDatabase
    }

    override fun onCreate() {
        super.onCreate()
        MisNotasApp.database =  Room.databaseBuilder(this, TasksDatabase::class.java, "tasks-db").build()
    }
}

Como ya he comentado, esta clase es básicamente igual a la del capítulo anterior por lo que no hay nada que explicar salvo que la instancia de database necesitará tres parámetros, el contexto (this), la clase de nuestra base de datos (TasksDatabase) y el nombre que le pondremos, en este caso la he llamado «trasks-db».

Recordad que para que esta clase se lance al abrir la app debemos ir al AndroidManifest.xml y añadir android:name=».MisNotasApp» dentro de la etiqueta <Application>

activity_main

Aunque tengamos varias clases por detrás, nuestra app solo tendrá un layout, una sola vista. La idea era crear algo sencillo y usable, por lo que me decanté por una barra superior donde añadir las tareas y luego un RecyclerView donde se muestren todas. El XML es muy sencillito, solo he usado algún atributo nuevo que comentaré.

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@android:color/background_light"
    tools:context="com.cursokotlin.misnotas.UI.MainActivity">

    <android.support.v7.widget.RecyclerView
        android:id="@+id/rvTask"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_below="@+id/rlAddTask"/>


    <RelativeLayout
        android:id="@+id/rlAddTask"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:elevation="10dp"
        android:layout_margin="10dp"
        android:background="@android:color/white">

        <EditText
            android:id="@+id/etTask"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:hint="añade una tarea"
            android:layout_alignParentLeft="true"
            android:layout_toLeftOf="@+id/btnAddTask"
            />

        <Button
            android:id="@+id/btnAddTask"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_alignParentRight="true"
            android:text="Añadir"/>

    </RelativeLayout>

</RelativeLayout>

Debajo del RecyclerView he metido la barra superior. La he puesto debajo porque en los XML de android, si imaginamos que va todo por capas, la parte inferior es la capa más visible. Por ejemplo si pusiéramos dos fotos de tamaño completo a la pantalla, se vería la que esté en la parte más abajo de nuestro archivo.

A parte de eso conocemos todos los atributos de la vista excepto android:elevation que lo que hace es dar una elevación a nuestro componente añadiéndole una sombra por debajo. El efecto es el siguiente.

curso android en kotlin

MainActivity

Ya tenemos nuestra vista preparada, es el momento de empezar a generar la lógica. Empezamos creando las variables necesarias.

lateinit var recyclerView: RecyclerView
lateinit var tasks: MutableList<TaskEntity>

Ahora nos vamos al OnCreate de nuestra actividad, lo primero que haremos será instanciar tasks como una arrayList y acto seguido llamaremos a la función getTasks() que vamos a crear. Esta función será la encargada de acceder a nuestro DAO para hacer la primera consulta, recuperar todas las entidades que el usuario tenga guardadas. Así que por ahora dejamos el OnCreate así.

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        tasks = ArrayList()
        getTasks()
}

Ahora antes de seguir tengo que hacer un poco de hincapié en los hilos. Los hilos son los que permiten la multitarea de un dispositivo, por ejemplo en un hilo puedes ir guardando información asíncronamente (por detrás) mientras el usuario sigue haciendo cosas. En Android, el hilo principal es el encargado de la parte visual de la aplicación, por lo que no nos deja acceder a la base de datos desde ahí, por ello crearemos un hilo secundario que de modo asíncrono hará la petición a la base de datos y recuperará la información que necesitemos.

Como este capítulo no va de hilos, no solo lo he resumido mucho sino que lo voy a hacer de la manera más sencilla (en un futuro haré un artículo dedicado a ello). Para este fin vamos a usar Anko, una librería muy completa para Kotlin que nos hará más sencillas muchas tareas del día a día. Anko ya lo implementamos al principio del capítulo en las dependencias.

fun getTasks() {
      doAsync {
          tasks = MisNotasApp.database.taskDao().getAllTasks()
          uiThread {
              setUpRecyclerView(tasks)
          }
      }
  }

Gracias a Anko la función queda muy sencilla. Todo lo que tengamos que hacer en el segundo hilo asíncrono, lo meteremos dentro de doAsync{}  y una vez haya acabado, usando uiThead{} podemos decir que haga algo en el hilo principal, el de la vista. Nosotros hemos asignado a tasks los valores que recuperamos de nuestra base de datos, y cuando los completa, llamamos al método setUpRecyclerView(tasks) que será el que configure nuestro RecyclerView.

Pero antes de mostraros el método anterior vamos a crear nuestro adapter, que a diferencia del primero que vimos hace unos capítulos, este tendrá diferentes eventos para capturar los clicks del usuario.

TasksAdapter

A diferencia de nuestro último adapter que tenía un constructor para pasar los parámetros, en este caso usaremos la propia clase para hacerlo. Se puede hacer de ambas formas pero así vais viendo formas diferentes que más se acomoden a vuestro modo de trabajo.

Antes de empezar a trabajar con el layout de la celda. Vamos a crear algo muy sencillito así que creamos un nuevo layout llamado item_task.xml.

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

    <CheckBox
        android:id="@+id/cbIsDone"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginRight="10dp"
        android:layout_marginEnd="10dp" />

    <TextView
        android:id="@+id/tvTask"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textStyle="bold"
        android:textSize="18sp"
        tools:text="Test"/>

</LinearLayout>

Nos quedará una celda muy sencilla. La idea es que cuando marquemos el Checkbox se actualice en la base de datos el objeto y si hacemos click en cualquier otra parte de la vista se borre de base de datos.

curso android en kotlin

Así que esta vez le pasaremos 3 parámetros, la lista de tareas que tenemos almacenadas en nuestra base de datos y funciones. Estas funciones nos permitirán recuperar el evento del click en cada una de las celdas, ya sea la vista completa o un componente concreto.

class TasksAdapter(
        val tasks: List<TaskEntity>,
        val checkTask: (TaskEntity) -> Unit,
        val deleteTask: (TaskEntity) -> Unit) : RecyclerView.Adapter<TasksAdapter.ViewHolder>() {

Ahora en el onBindViewHolder haríamos lo mismo de siempre pero añadiéndole el setOnClickListener a cada uno de los componentes que nos interese, en este caso yo se lo he puesto al checkbox y como quiero controlar el click en cualquier otra parte de la vista podemos acceder a itemView que nos devuelve la celda completa.

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
       val item = tasks[position]
       holder.bind(item, checkTask, deleteTask)
   }

Y ya completamos TaskAdapter con los dos métodos que nos faltan onCreateViewHolder getItemCount. Estos métodos los dejaremos por defecto así que no voy a explicar nada ya que lo he hecho en capítulos anteriores.

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
    val layoutInflater = LayoutInflater.from(parent.context)
    return ViewHolder(layoutInflater.inflate(R.layout.item_task, parent, false))
}

override fun getItemCount(): Int {
    return tasks.size
}

Para finalizar la clase ViewHolder que solo tendrá de novedad en la función bind, a través de .isChecked podemos iniciar la vista con el checkbox marcado o no, así que comprobaremos si está a true nuestra entidad y si es así pues lo marcamos.

Una vez configurada la celda, le añadimos .setOnClickListener a nuestro checkBox y al itemView que es el componente completo pasando el propio objeto.

class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
      val tvTask = view.findViewById<TextView>(R.id.tvTask)
      val cbIsDone = view.findViewById<CheckBox>(R.id.cbIsDone)

     fun bind(task: TaskEntity, checkTask: (TaskEntity) -> Unit, deleteTask: (TaskEntity) -> Unit) {
          tvTask.text = task.name
          cbIsDone.isChecked = task.isDone
          cbIsDone.setOnClickListener{checkTask(task)}
          itemView.setOnClickListener { deleteTask(task) }
      }
  }

Puedes ver la clase completa haciendo click aquí.

Volvemos al MainActivity

Con nuestro adapter completo es el paso de configurarlo desde MainActivity con nuestra función setUpRecyclerView() a la cual le pasaremos la lista de tareas que hemos recuperado.

fun setUpRecyclerView(tasks: List<TaskEntity>) {
        adapter = TasksAdapter(tasks, { updateTask(it) }, {deleteTask(it)})
        recyclerView = findViewById(R.id.rvTask)
        recyclerView.setHasFixedSize(true)
        recyclerView.layoutManager = LinearLayoutManager(this)
        recyclerView.adapter = adapter
    }

Debemos fijarnos que al instanciar el adapter le pasamos tres parámetros, la lista de tareas,  updateTask(it) deleteTask(it). Estos no son parámetros sino métodos que tendremos en el MainActivity, que serán llamados automáticamente cuando se ejecute el evento del click que configuramos en el adapter.

Antes de mostraron esos dos métodos, vamos a configurar el botón de añadir tareas, que lo que hará será crear un objeto Task, almacenarlo en base de datos y luego añadirlo a la lista que tiene el adapter. Lo primero que haremos será ir a nuestro DAO y añadir una nueva función de insertar.

@Insert
fun addTask(taskEntity : TaskEntity):Long

Simplemente recibirá un objeto TaskEntity y lo añadirá a la base de datos. Fijaros que devuelve un Long, eso es porque nos dará automáticamente la ID del item añadido.

Nos vamos a nuestro onCreate del MainAcitivity y añadimos lo siguiente.

btnAddTask.setOnClickListener {
           addTask(TaskEntity(name = etTask.text.toString()))}

Simplemente le hemos asignado al evento del click un método llamado addTask() al cual le pasamos un objeto nuevo con el texto de la celda. Dicha función añadirá a base de datos la tarea, luego recuperemos dicha tarea y la añadiremos a la lista del adapter. Hay varias formas de hacer esto mejor, pero he ido poniendo varias formas en cada una de las peticiones a la base de datos para poder observarlas.

fun addTask(task:TaskEntity){
        doAsync {
            val id = MisNotasApp.database.taskDao().addTask(task)
            val recoveryTask = MisNotasApp.database.taskDao().getTaskById(id)
            uiThread {
                tasks.add(recoveryTask)
                adapter.notifyItemInserted(tasks.size)
                clearFocus()
                hideKeyboard()
            }
        }
    }

Así que lo que estamos haciendo es añadir la tarea y luego recuperamos dicho objeto a través de getTaskById pasándole la ID que nos devuelve addTask. Obviamente debemos añadir a nuestro DAO la función de recuperar el item.

@Query("SELECT * FROM task_entity where id like :arg0")
fun getTaskById(id: Long): TaskEntity

Cuando hemos acabado de realizar esto, en el hilo principal añadimos el objeto recuperado a la lista, le decimos al adapter que hemos añadido un objeto nuevo a través de adapter.notifyItemInserted (hay que pasarle pa posición, como es el último objeto añadido, podemos saber cual es recuperando el tamaño de la lista) y luego los métodos clearFocus() hideKeyboard() simplemente nos quitarán el texto del editText y bajarán el teclado.

fun clearFocus(){
   etTask.setText("")
}

fun Context.hideKeyboard() {
    val inputMethodManager = getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager
    inputMethodManager.hideSoftInputFromWindow(currentFocus.windowToken, 0)
}

Con esto ya podemos insertar tareas en nuestra app.

curso android en kotlin

Una vez tengamos tareas, pasamos a actualizarlas con updateTask() que será el encargado de cambiar en la base de datos el booleano que marca si la tarea está hecha o no, así que nos volvemos a nuestro DAO y añadimos un update.

@Update
fun updateTask(taskEntity: TaskEntity):Int

Y su respectivo método en la actividad principal es muy sencillo, simplemente recibe el objeto, nosotros cambiamos el booleano por su opuesto (si es true, lo ponemos a false) y se lo mandamos al DAO.

fun updateTask(task: TaskEntity) {
       doAsync {
           task.isDone = !task.isDone
           MisNotasApp.database.taskDao().updateTask(task)
       }
   }

Recordamos que con la exclamación delante de un booleano nos da el valor contrario.

Para finalizar nuestra app nos falta la opción de borrar tareas tocando cualquier parte de la celda menos el checkBox que ya hace una función de actualizar. Así que volvemos al DAO y hacemos una función @Delete.

@Delete
fun deleteTask(taskEntity: TaskEntity):Int

En el método deleteTask() debemos hacer algo más, para empezar, buscaremos en nuestra lista tasks la posición del item que vamos a borrar para tener una referencia. Para ello usaremos la función de las listas indexOf(item) que nos devolverá dicha posición y la almacenamos en una variable, luego borraremos el objeto de la base de datos y de nuestra lista y acabaremos en el hilo principal avisando al adapter que hemos removido un objeto, pasándole la posición.

fun deleteTask(task: TaskEntity){
       doAsync {
           val position = tasks.indexOf(task)
           MisNotasApp.database.taskDao().deleteTask(task)
           tasks.remove(task)
           uiThread {
               adapter.notifyItemRemoved(position)
           }
       }
   }

Puedes ver la clase completa haciendo click aquí.

curso android en kotlin

Este artículo ha sido un poquito más «intenso» pero ya empezamos a entrar en verdadera materia. Os recuerdo que podéis descargar el proyecto completo desde mi GitHub. Y que si tenéis alguna duda podéis dejarla en los comentarios.

Continúa con el curso: Capítulo 18 – Componentes personalizados [Primera parte]