Cómo compilar una capa de datos

1. Antes de comenzar

En este codelab, aprenderás sobre la capa de datos y cómo se adapta a tu arquitectura de apps general.

La capa de datos como la capa inferior, ubicada por debajo de las capas del dominio y la IU.

Figura 1: Diagrama en el que se muestra cómo las capas del dominio y la IU dependen de la capa de datos.

Compilarás la capa de datos para una app de administración de tareas. Crearás fuentes de datos para una base de datos local y un servicio de red, además de un repositorio que expone, actualiza y sincroniza los datos.

Requisitos previos

Qué aprenderás

En este codelab, aprenderás a hacer lo siguiente:

  • Crear repositorios, fuentes de datos y modelos de datos para la administración de datos efectiva y escalable.
  • Exponer los datos a otras capas de arquitectura.
  • Controlar actualizaciones de datos asíncronas y tareas complejas o de ejecución prolongada.
  • Sincronizar datos entre varias fuentes de datos.
  • Crear pruebas para verificar el comportamiento de tus repositorios y fuentes de datos.

Qué compilarás

Compilarás una app de administración de tareas que te permite agregar tareas y marcarlas como completadas.

No tendrás que escribir la app desde cero, sino que trabajarás con una app que ya tiene una capa de la IU. La capa de la IU en esta app incluye pantallas y contenedores de estado en el nivel de la pantalla implementados con ViewModels.

Durante el codelab, agregarás la capa de datos y, luego, la conectarás a la capa de la IU existente, lo que hará que la app sea completamente funcional.

La pantalla de lista de tareas.

La pantalla de detalles de tareas.

Figura 2: Captura de la pantalla de lista de tareas.

Figura 3: Captura de la pantalla de detalles de tareas.

2. Prepárate

  1. Descarga el código:

https://github.com/android/architecture-samples/archive/refs/heads/data-codelab-start.zip

  1. Como alternativa, puedes clonar el repositorio de GitHub para el código:
git clone https://github.com/android/architecture-samples.git
git checkout data-codelab-start
  1. Abre Android Studio y carga el proyecto architecture-samples.

Estructura de carpetas

  • Abre el explorador de proyectos en la vista de Android.

Encontrarás varias carpetas en el interior de la carpeta java/com.example.android.architecture.blueprints.todoapp.

Ventana del explorador de proyectos de Android Studio en la vista de Android.

Figura 4: Captura de pantalla con la ventana del explorador de proyectos de Android Studio en la vista de Android.

  • <root> incluye clases en el nivel de la app, como clases para la navegación, para la actividad principal y para la aplicación.
  • addedittask incluye la función de IU que permite a los usuarios agregar y editar tareas.
  • data incluye la capa de datos. Trabajarás principalmente en esta carpeta.
  • di incluye los módulos de Hilt para la inserción de dependencias.
  • tasks incluye la función de IU que permite a los usuarios ver y actualizar listas de tareas.
  • util incluye clases de utilidad.

También hay dos carpetas de prueba, que podrás distinguir por el texto entre paréntesis al final del nombre de carpeta.

  • androidTest sigue la misma estructura que <root>, pero incluye pruebas de instrumentación.
  • test sigue la misma estructura que <root>, pero incluye pruebas locales.

Cómo ejecutar el proyecto

  • Haz clic en el ícono de reproducción verde en la barra de herramientas de arriba.

El botón de configuración de ejecución, dispositivo de destino y ejecución de Android Studio.

Figura 5: Captura de pantalla en la que se muestra el botón de ejecutar configuración, dispositivo de destino y ejecución de Android Studio.

Deberías ver la pantalla de lista de tareas con un ícono giratorio de carga que nunca desaparece.

La app en su estado de inicio con un ícono giratorio de carga que no desaparece.

Figura 6: Captura de pantalla de la app con un ícono giratorio de carga que no desaparece.

Al final de este codelab, se mostrará una lista de tareas en esta pantalla.

Para ver el código final en el codelab, consulta la rama de data-codelab-final.

git checkout data-codelab-final

Recuerda que primero debes almacenar los cambios de manera segura.

3. Obtén más información sobre la capa de datos

En este codelab, compilarás la capa de datos para la app.

La capa de datos, como su nombre lo indica, es una capa de arquitectura que controla los datos de tu aplicación. También incluye la lógica empresarial, que son las reglas empresariales del mundo real que determinan cómo se deben crear, almacenar y modificar los datos de una aplicación. Esta separación de funciones permite que la capa de datos sea reutilizable, gracias a lo cual es posible presentarla en varias pantallas, además de compartir información entre distintas partes de la app y reproducir la lógica empresarial fuera de la IU para la prueba de unidades.

Los tipos de componentes fundamentales que conforman la capa de datos son los modelos de datos, las fuentes de datos y los repositorios.

Los tipos de componentes en la capa de datos, incluidas las dependencias entre modelos de datos, fuentes de datos y repositorios.

Figura 7: Diagrama en el que se muestran los tipos de componentes en la capa de datos, incluidas las dependencias entre los modelos de datos, las fuentes de datos y los repositorios.

Modelos de datos

Por lo general, los datos de la aplicación se representan como modelos de datos. Son representaciones de los datos en la memoria.

Como esta es una app de administración de tareas, necesitas un modelo de datos para una tarea. La clase Task es la siguiente:

data class Task(
    val id: String
    val title: String = "",
    val description: String = "",
    val isCompleted: Boolean = false,
) { ... }

Un aspecto fundamental de este modelo es que es inmutable. Otras capas no pueden cambiar las propiedades de una tarea, sino que deben utilizar una capa de datos si desean realizar cambios en una tarea.

Modelos de datos internos y externos

Task es un ejemplo de un modelo de datos externo. La capa de datos lo expone externamente y otras capas pueden acceder a él. Más adelante, aprenderás a definir modelos de datos internos que solo se utilizan dentro de una capa de datos.

Vale la pena definir un modelo de datos para cada representación de un modelo de negocio. En esta app, hay tres modelos de datos.

Nombre del modelo

¿Interno o externo respecto de la capa de datos?

Representa

Fuente de datos asociada

Task

Externo

Una tarea que se puede utilizar en cualquier lugar de la app y solo se almacena en la memoria o cuando se guarda el estado de la aplicación

N/A

LocalTask

Interno

Una tarea almacenada en una base de datos local

TaskDao

NetworkTask

Interno

Una tarea que un servidor de red recuperó

NetworkTaskDataSource

Fuentes de datos

Una fuente de datos es una clase responsable de leer los datos y escribirlos en una fuente única, como una base de datos o un servicio de red.

En esta app, hay dos fuentes de datos:

  • TaskDao es una fuente de datos local que lee una base de datos y escribe en ella.
  • NetworkTaskDataSource es una fuente de datos de red que lee un servidor de red y escribe en él.

Repositorios

Un repositorio debería administrar un solo modelo de datos. En esta app, crearás un repositorio que administre modelos de Task. El repositorio:

  • Expone una lista de modelos de Task.
  • Proporciona métodos para crear y actualizar un modelo de Task.
  • Ejecuta la lógica empresarial, como la creación de un ID único para cada tarea.
  • Combina modelos de datos internos de fuentes de datos en modelos de Task o los asigna a estos modelos.
  • Sincroniza fuentes de datos.

Prepárate para codificar

  • Cambia a la vista de Android y expande el paquete com.example.android.architecture.blueprints.todoapp.data:

Ventana del explorador de proyectos en la que se muestran carpetas y archivos.

Figura 8: Ventana del explorador de proyectos en la que se muestran carpetas y archivos.

Ya se creó la clase Task para que se pueda compilar el resto de la app. A partir de ahora, debes crear la mayoría de las clases de capas de datos desde cero agregando las implementaciones a los archivos .kt vacíos proporcionados.

4. Cómo almacenar datos localmente

En este paso, crearás una fuente de datos y un modelo de datos para una base de datos de Room que almacena tareas a nivel local en el dispositivo.

La relación entre repositorio, modelo, fuente de datos y base de datos de la tarea.

Figura 9: Diagrama en el que se muestra la relación entre repositorio, modelo, fuente de datos y base de datos de la tarea.

Cómo crear un modelo de datos

Para almacenar datos en una base de datos de Room, debes crear una entidad de base de datos.

  • Abre el archivo LocalTask.kt dentro de data/source/local y, luego, agrégale el siguiente código:
@Entity(
    tableName = "task"
)
data class LocalTask(
    @PrimaryKey val id: String,
    var title: String,
    var description: String,
    var isCompleted: Boolean,
)

La clase LocalTask representa los datos almacenados en una tabla con el nombre task en la base de datos de Room. Tiene acoplamiento alto con Room y no se debe utilizar para otras fuentes de datos como DataStore.

El prefijo Local en el nombre de clase se utiliza para indicar que estos datos están almacenados localmente. También se usa para distinguir esta clase del modelo de datos Task, que está expuesto a las otras capas en la app. Dicho de otro modo, LocalTask es interno respecto de la capa de datos, mientras que Task es externo respecto de la capa de datos.

Cómo crear una fuente de datos

Ahora que tienes un modelo de datos, debes crear una fuente de datos para que cree, lea, actualice y borre (CRUD, por sus siglas en inglés) los modelos de LocalTask. Como estás usando Room, puedes utilizar un objeto de acceso a datos (la anotación @Dao) como fuente de datos local.

  • Crea una nueva interfaz de Kotlin en el archivo con el nombre TaskDao.kt.
@Dao
interface TaskDao {

    @Query("SELECT * FROM task")
    fun observeAll(): Flow<List<LocalTask>>

    @Upsert
    suspend fun upsert(task: LocalTask)

    @Upsert
    suspend fun upsertAll(tasks: List<LocalTask>)

    @Query("UPDATE task SET isCompleted = :completed WHERE id = :taskId")
    suspend fun updateCompleted(taskId: String, completed: Boolean)

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

Los métodos para leer los datos tienen el prefijo observe. Son funciones sin suspensión que muestran un Flow. Cada vez que los datos subyacentes cambian, se emite un nuevo elemento al flujo. Esta función útil de la biblioteca de Room (y muchas otras bibliotecas de almacenamiento de datos) te permiten detectar cambios en los datos en lugar de tener que sondear la base de datos en búsqueda de nuevos datos.

Los métodos para escribir datos son funciones de suspensión porque realizan operaciones de E/S.

Cómo actualizar el esquema de la base de datos

Lo próximo que debes hacer es actualizar la base de datos para que almacene los modelos de LocalTask.

  1. Abre ToDoDatabase.kt y cambia BlankEntity a LocalTask.
  2. Quita BlankEntity y todas las sentencias import redundantes.
  3. Agrega un método para que se muestre el DAO con el nombre taskDao.

La clase actualizada debería verse de la siguiente manera:

@Database(entities = [LocalTask::class], version = 1, exportSchema = false)
abstract class ToDoDatabase : RoomDatabase() {

    abstract fun taskDao(): TaskDao
}

Cómo actualizar la configuración de Hilt

Este proyecto utiliza Hilt para la inserción de dependencias. Hilt necesita saber cómo crear TaskDao para que se pueda insertar en las clases que lo usan.

  • Abre di/DataModules.kt y agrega el siguiente método al DatabaseModule:
    @Provides
    fun provideTaskDao(database: ToDoDatabase) : TaskDao = database.taskDao()

Ahora tienes todos los elementos necesarios para leer tareas y escribirlas en una base de datos local.

5. Cómo probar la fuente de datos local

En el último paso, escribiste mucho código, pero ¿cómo sabes que funciona correctamente? Es fácil equivocarse con todas esas consultas en SQL en TaskDao. Crea pruebas para verificar que TaskDao se comporte como corresponde.

Las pruebas no forman parte de la app, por lo que debes ubicarlas en otra carpeta. Hay dos carpetas de prueba, que podrás distinguir por el texto entre paréntesis al final del nombre de los paquetes:

Las carpetas test y androidTest en el explorador de proyectos.

Figura 10: Captura de pantalla con las carpetas test y androidTest en el explorador de proyectos.

  • androidTest incluye pruebas que se ejecutan en un dispositivo o emulador de Android. Se conocen como pruebas de instrumentación.
  • test incluye pruebas que se ejecutan en tu máquina anfitrión, también conocidas como pruebas locales.

TaskDao requiere una base de datos de Room (que solo se puede crear en un dispositivo Android). Por lo tanto, para probarla, debes crear una prueba de instrumentación.

Cómo crear la clase de prueba

  • Expande la carpeta androidTest y abre TaskDaoTest.kt. En su interior, crea una clase vacía con el nombre TaskDaoTest.
class TaskDaoTest {

}

Cómo agregar una base de datos de prueba

  • Agrega ToDoDatabase e inicialízala antes de cada prueba.
    private lateinit var database: ToDoDatabase

    @Before
    fun initDb() {
        database = Room.inMemoryDatabaseBuilder(
            getApplicationContext(),
            ToDoDatabase::class.java
        ).allowMainThreadQueries().build()
    }

De esta manera, se creará una base de datos en la memoria antes de cada prueba. Una base de datos en la memoria es mucho más rápida que una base de datos en el disco. Esto la convierte en una buena opción para las pruebas automatizadas en las que los datos no necesitan persistir por más tiempo que las pruebas.

Cómo agregar una prueba

Agrega una prueba que verifique que se puede insertar una LocalTask y que la misma LocalTask se puede leer con TaskDao.

Todas las pruebas de este codelab siguen la estructura qué, cuándo, luego:

Qué

Una base de datos vacía

Cuándo

Se inserta una tarea y comienzas a observar el flujo de tareas

Luego

El primer elemento en el flujo de tareas coincide con la tarea que se insertó

  1. Para comenzar, crea una prueba fallida. Esto permite verificar que la prueba se esté ejecutando realmente y que se sometan a prueba los objetos correctos y sus dependencias.
@Test
fun insertTaskAndGetTasks() = runTest {

    val task = LocalTask(
        title = "title",
        description = "description",
        id = "id",
        isCompleted = false,
    )
    database.taskDao().upsert(task)

    val tasks = database.taskDao().observeAll().first()

    assertEquals(0, tasks.size)
}
  1. Para ejecutar la prueba, haz clic en Play junto a la prueba en el margen.

El ícono Play de la prueba en el margen del editor de código.

Figura 11: Captura de pantalla con el botón Play de la prueba en el margen del editor de código.

En la ventana de resultados de prueba, verás cómo falla la prueba con el mensaje expected:<0> but was:<1>. Este es el resultado esperado porque el número de tareas en la base de datos es uno, no cero.

Una prueba fallida.

Figura 12: Captura de pantalla con una prueba fallida.

  1. Quita la sentencia assertEquals existente.
  2. Agrega código para probar que la fuente de datos proporcione solo una tarea y que sea la misma tarea que se insertó.

El orden de los parámetros para assertEquals siempre debe ser el valor esperado seguido del valor real**.**

assertEquals(1, tasks.size)
assertEquals(task, tasks[0])
  1. Vuelve a ejecutar la prueba. Verás la prueba aprobada dentro de la ventana de resultados de prueba.

Una prueba aprobada.

Figura 13: Captura de pantalla en la que se muestra una prueba aprobada.

6. Cómo crear una fuente de datos de red

Es muy útil que las tareas se puedan almacenar de manera local en el dispositivo, pero ¿y si también quieres guardar y cargar esas tareas en un servicio de red? Quizás tu app para Android sea solo una de las maneras en las que los usuarios pueden agregar tareas a su lista de cosas para hacer. Las tareas también se podrían administrar a través de una aplicación para sitio web o de escritorio. O quizás solo quieras ofrecer una opción de copia de seguridad de los datos en línea para que los usuarios puedan restaurar los datos de la app incluso si cambian de dispositivo.

En estas situaciones, por lo general, tienes un servicio basado en la red que todos los clientes, incluida tu app para Android, pueden usar para cargar y guardar datos.

En este siguiente paso, crearás una fuente de datos para comunicarte con este servicio de red. Para los fines de este codelab, este es un servicio simulado que no está conectado a un servicio de red activo, pero te dará una idea sobre cómo podría implementarse en una app real.

Información sobre el servicio de red

En este ejemplo, la API de red es muy simple. Solo realiza dos operaciones:

  • Guarda todas las tareas y sobrescribe todos los datos escritos anteriormente.
  • Carga todas las tareas, lo que proporciona una lista de todas las tareas que están guardadas actualmente en el servicio de red.

Cómo modelar los datos de red

Cuando se obtienen datos de una API de red, es común que los datos se representen de una manera diferente a como son a nivel local. La representación de red de una tarea podría tener campos adicionales o podría utilizar diferentes tipos o nombres de campos para representar los mismos valores.

Para tener en cuenta estas diferencias, crea un modelo de datos específico para la red.

  • Abre el archivo NetworkTask.kt que se encuentra en data/source/network y, luego, agrega el siguiente código para representar los campos:
data class NetworkTask(
    val id: String,
    val title: String,
    val shortDescription: String,
    val priority: Int? = null,
    val status: TaskStatus = TaskStatus.ACTIVE
) {
    enum class TaskStatus {
        ACTIVE,
        COMPLETE
    }
}

Estas son algunas de las diferencias entre LocalTask y NetworkTask:

  • La descripción de la tarea tiene el nombre shortDescription en lugar de description.
  • El campo isCompleted se representa como una enum de status, que tiene dos valores posibles: ACTIVE y COMPLETE.
  • Incluye un campo priority adicional, que es un número entero.

Cómo crear la fuente de datos de red

  • Abre TaskNetworkDataSource.kt y, luego, crea una clase con el nombre TaskNetworkDataSource que tenga el siguiente contenido:
class TaskNetworkDataSource @Inject constructor() {

    // A mutex is used to ensure that reads and writes are thread-safe.
    private val accessMutex = Mutex()
    private var tasks = listOf(
        NetworkTask(
            id = "PISA",
            title = "Build tower in Pisa",
            shortDescription = "Ground looks good, no foundation work required."
        ),
        NetworkTask(
            id = "TACOMA",
            title = "Finish bridge in Tacoma",
            shortDescription = "Found awesome girders at half the cost!"
        )
    )

    suspend fun loadTasks(): List<NetworkTask> = accessMutex.withLock {
        delay(SERVICE_LATENCY_IN_MILLIS)
        return tasks
    }

    suspend fun saveTasks(newTasks: List<NetworkTask>) = accessMutex.withLock {
        delay(SERVICE_LATENCY_IN_MILLIS)
        tasks = newTasks
    }
}

private const val SERVICE_LATENCY_IN_MILLIS = 2000L

Este objeto simula la interacción con el servidor, incluido un retraso simulado de dos segundos cada vez que se llama a loadTasks o saveTasks. Esto podría representar la latencia de respuesta del servidor o la red.

También incluye algunos datos de prueba que puedes usar más adelante para verificar que las tareas se puedan cargar correctamente desde la red.

Si tu API de servidor real utiliza HTTP, te recomendamos usar una biblioteca como Ktor o Retrofit para compilar tu fuente de datos de red.

7. Cómo crear el repositorio de tareas

Las piezas comienzan a encajar.

Las dependencias de DefaultTaskRepository.

Figura 14: Diagrama en el que se muestran las dependencias de DefaultTaskRepository.

Tenemos dos fuentes de datos: una para datos locales (TaskDao) y otra para datos de red (TaskNetworkDataSource). Cada una de ellas lee y escribe, y tiene su propia representación de una tarea (LocalTask y NetworkTask, respectivamente).

Ahora es momento de crear un repositorio que utilice estas fuentes de datos y proporcione una API para que otras capas de arquitectura puedan acceder a los datos de esta tarea.

Cómo exponer los datos

  1. Abre DefaultTaskRepository.kt en el paquete data y, luego, crea una clase con el nombre DefaultTaskRepository, que tome TaskDao y TaskNetworkDataSource como dependencias.
class DefaultTaskRepository @Inject constructor(
    private val localDataSource: TaskDao,
    private val networkDataSource: TaskNetworkDataSource,
) {

}

Los datos se deben exponer utilizando flujos. De esta manera, los emisores de llamadas recibirán una notificación cuando esos datos cambien con el transcurso del tiempo.

  1. Agrega un método con el nombre observeAll, que muestra un flujo de modelos Task que utilizan un Flow.
fun observeAll() : Flow<List<Task>> {
    // TODO add code to retrieve Tasks
}

Los repositorios deben exponer datos de una sola fuente de confianza. Es decir, los datos deben venir de una sola fuente de datos. Puede ser una caché en la memoria; un servidor remoto; o, en este caso, la base de datos local.

Para acceder a las tareas en la base de datos local, puedes usar TaskDao.observeAll, que convenientemente muestra un flujo. Sin embargo, se trata de un flujo de modelos LocalTask, en el que LocalTask es un modelo interno que no se debería exponer a otras capas de arquitectura.

Debes transformar LocalTask en una Task. Este es un modelo externo que forma parte de la API de capa de datos.

Cómo asignar modelos internos a modelos externos

Para realizar esta conversión, debes asignar los campos de LocalTask a los campos en Task.

  1. Crea funciones de extensión para este fin en LocalTask.
// Convert a LocalTask to a Task
fun LocalTask.toExternal() = Task(
    id = id,
    title = title,
    description = description,
    isCompleted = isCompleted,
)

// Convenience function which converts a list of LocalTasks to a list of Tasks
fun List<LocalTask>.toExternal() = map(LocalTask::toExternal) // Equivalent to map { it.toExternal() }

Ahora, cada vez que necesites transformar LocalTask en una Task, solo tendrás que llamar a toExternal.

  1. Utiliza la función toExternal recientemente creada dentro de observeAll:
fun observeAll(): Flow<List<Task>> {
    return localDataSource.observeAll().map { tasks ->
        tasks.toExternal()
    }
}

Cada vez que los datos de las tareas cambian en la base de datos local, se emite al flujo una nueva lista de modelos LocalTask. Luego, cada LocalTask se asigna a una Task.

¡Perfecto! Ahora, las otras capas pueden usar observeAll para obtener todos los modelos Task de tu base de datos local y recibir una notificación cada vez que cambien esos modelos Task.

Cómo actualizar datos

Una app de lista de cosas para hacer no es muy útil si no puedes crear ni actualizar tareas. Ahora puedes agregar métodos para lograrlo.

Los métodos para crear, actualizar o borrar datos son operaciones únicas y se deben implementar usando funciones suspend.

  1. Agrega un método con el nombre create, que tome title y description como parámetros y muestre el ID de la tarea recién creada.
suspend fun create(title: String, description: String): String {
}

Ten en cuenta que la API de capa de datos prohíbe que otras capas creen una Task si solo proporcionan un método create que acepta parámetros individuales, y no una Task. Este enfoque encapsula lo siguiente:

  • La lógica empresarial para crear un ID de tarea único.
  • Dónde se almacena la tarea después de la creación inicial.
  1. Agrega un método para crear un ID de tarea.
// This method might be computationally expensive
private fun createTaskId() : String {
    return UUID.randomUUID().toString()
}
  1. Crea un ID de tarea utilizando el método createTaskId recién agregado.
suspend fun create(title: String, description: String): String {
    val taskId = createTaskId()
}

No bloquees el subproceso principal

¡Pero espera! ¿Qué sucede si la creación del ID de tarea es costosa a nivel informático? Quizás utiliza criptografía para crear una clave de hash para el ID, lo que tarda varios segundos. Esto podría provocar que la IU se atasque si se la llama en el subproceso principal.

La capa de datos tiene la responsabilidad de garantizar que las tareas complejas o de ejecución prolongada no bloqueen el subproceso principal.

Para corregir este problema, especifica un despachador de corrutinas que se utilizará para ejecutar estas instrucciones.

  1. En primer lugar, agrega un CoroutineDispatcher como una dependencia a DefaultTaskRepository. Utiliza el calificador de @DefaultDispatcher ya creado (definido en di/CoroutinesModule.kt) para indicarle a Hilt que inserte esta dependencia con Dispatchers.Default. Se recomienda el despachador de Default porque está optimizado para los trabajos que hacen uso intensivo de la CPU. Obtén más información sobre los despachadores de corrutinas aquí.
class DefaultTaskRepository @Inject constructor(
   private val localDataSource: TaskDao,
   private val networkDataSource: TaskNetworkDataSource,
   @DefaultDispatcher private val dispatcher: CoroutineDispatcher,
)
  1. Ahora, coloca la llamada a UUID.randomUUID().toString() dentro de un bloque withContext.
val taskId = withContext(dispatcher) {
    createTaskId()
}

Obtén más información sobre los subprocesos en la capa de datos.

Cómo crear y almacenar la tarea

  1. Ahora que tienes un ID de tarea, utilízalo junto con los parámetros proporcionados para crear una Task nueva.
suspend fun create(title: String, description: String): String {
    val taskId = withContext(dispatcher) {
        createTaskId()
    }
    val task = Task(
        title = title,
        description = description,
        id = taskId,
    )
}

Antes de insertar la tarea en una fuente de datos local, debes asignarla a una LocalTask.

  1. Agrega la siguiente función de extensión al final de LocalTask. Esta es la función de asignación inversa de LocalTask.toExternal, que creaste anteriormente.
fun Task.toLocal() = LocalTask(
    id = id,
    title = title,
    description = description,
    isCompleted = isCompleted,
)
  1. Utilízala dentro de create para insertar la tarea en la fuente de datos local y, luego, muestra el taskId.
suspend fun create(title: String, description: String): Task {
    ...
    localDataSource.upsert(task.toLocal())
    return taskId
}

Cómo completar la tarea

  • Crea un método adicional, complete, que marca la Task como completa.
suspend fun complete(taskId: String) {
    localDataSource.updateCompleted(taskId, true)
}

Ahora conoces algunos métodos útiles para crear y completar tareas.

Cómo sincronizar datos

En esta app, la fuente de datos de red se utiliza como una copia de seguridad en línea que se actualiza cada vez que se escriben datos a nivel local. Los datos se cargan desde la red cada vez que el usuario solicita una actualización.

En el siguiente diagrama se resume el comportamiento para cada tipo de operación.

Tipo de operación

Métodos del repositorio

Pasos

Movimiento de datos

Cargar

observeAll

Cargar datos de una base de datos local

El flujo de datos de una fuente de datos local al repositorio de tareas.Figura 15: Diagrama en el que se muestra el flujo de datos de una fuente de datos local al repositorio de tareas.

Guardar

createcomplete

1. Escribe los datos en la base de datos local. 2. Copia todos los datos a la red y sobrescribe toda la información.

Los datos se trasladan del repositorio de tareas a la fuente de datos local y, luego, a la fuente de datos de red.Figura 16: Diagrama en el que se muestra el flujo de datos del repositorio de tareas a la fuente de datos local y, luego, a la fuente de datos de red.

Actualizar

refresh

1. Carga los datos de red. 2. Cópialos en la base de datos local y sobrescribe toda la información.

Los datos se trasladan de la fuente de datos de red a la fuente de datos local y, luego, al repositorio de tareas.Figura 17: Diagrama en el que se muestra el flujo de datos de la fuente de datos de red a la fuente de datos local y, luego, al repositorio de tareas.

Cómo guardar y actualizar los datos de red

Tu repositorio ya carga tareas de la fuente de datos local. A fin de completar el algoritmo de sincronización, debes crear métodos para guardar y actualizar datos desde la fuente de datos de red.

  1. En primer lugar, crea funciones de asignación de LocalTask a NetworkTask y viceversa dentro de NetworkTask.kt. También puedes colocar las funciones dentro de LocalTask.kt.
fun NetworkTask.toLocal() = LocalTask(
    id = id,
    title = title,
    description = shortDescription,
    isCompleted = (status == NetworkTask.TaskStatus.COMPLETE),
)

fun List<NetworkTask>.toLocal() = map(NetworkTask::toLocal)

fun LocalTask.toNetwork() = NetworkTask(
    id = id,
    title = title,
    shortDescription = description,
    status = if (isCompleted) { NetworkTask.TaskStatus.COMPLETE } else { NetworkTask.TaskStatus.ACTIVE }
)

fun List<LocalTask>.toNetwork() = map(LocalTask::toNetwork)

Aquí puedes ver la ventaja de tener modelos individuales para cada fuente de datos: la asignación de un tipo de datos a otro se encapsula en funciones individuales.

  1. Agrega el método refresh al final de DefaultTaskRepository.
suspend fun refresh() {
    val networkTasks = networkDataSource.loadTasks()
    localDataSource.deleteAll()
    val localTasks = withContext(dispatcher) {
        networkTasks.toLocal()
    }
    localDataSource.upsertAll(networkTasks.toLocal())
}

De esta manera, se reemplazan todas las tareas locales con las tareas de la red. withContext se utiliza para la operación toLocal masiva, ya que hay una cantidad desconocida de tareas y cada operación de asignación puede ser costosa a nivel informático.

  1. Agrega el método saveTasksToNetwork al final de DefaultTaskRepository.
private suspend fun saveTasksToNetwork() {
    val localTasks = localDataSource.observeAll().first()
    val networkTasks = withContext(dispatcher) {
        localTasks.toNetwork()
    }
    networkDataSource.saveTasks(networkTasks)
}

De esta manera, se reemplazan todas las tareas de la red con las tareas de la fuente de datos local.

  1. Ahora, actualiza los métodos existentes. Se actualizarán las tareas create y complete para que los datos locales se guarden en la red cuando esta cambie.
    suspend fun create(title: String, description: String): String {
        ...
        saveTasksToNetwork()
        return taskId
    }

     suspend fun complete(taskId: String) {
        localDataSource.updateCompleted(taskId, true)
        saveTasksToNetwork()
    }

No hagas que el emisor de la llamada espere

Si ejecutaras este código, verías que saveTasksToNetwork se bloquea. Esto quiere decir que los emisores de las llamadas a create y complete deben esperar hasta que los datos se guarden en la red antes de poder estar seguros de que la operación ha finalizado. En la fuente de datos de red de la simulación esto sucede en solo dos segundos; pero, en una app real, puede tardar mucho más (o incluso nunca realizarse si no hay conexión de red).

Esta operación es innecesariamente restrictiva, y es posible que genere una experiencia del usuario deficiente (ningún usuario quiere esperar para poder crear una tarea, sobre todo si está ocupado).

Una mejor opción es usar un alcance de corrutina para guardar los datos en la red. Esto permite que la operación se complete en segundo plano sin que el emisor de la llamada tenga que esperar el resultado.

  1. Agrega un alcance de corrutina como parámetro a DefaultTaskRepository.
class DefaultTaskRepository @Inject constructor(
    // ...other parameters...
    @ApplicationScope private val scope: CoroutineScope,
)

El calificador de Hilt @ApplicationScope (definido en di/CoroutinesModule.kt) se utiliza para insertar un alcance que sigue el ciclo de vida de la app.

  1. Usa scope.launch para unir el código dentro de saveTasksToNetwork.
    private fun saveTasksToNetwork() {
        scope.launch {
            val localTasks = localDataSource.observeAll().first()
            val networkTasks = withContext(dispatcher) {
                localTasks.toNetwork()
            }
            networkDataSource.saveTasks(networkTasks)
        }
    }

Ahora, saveTasksToNetwork se muestra inmediatamente y las tareas se guardan en la red en segundo plano.

8. Cómo probar el repositorio de tareas

¡Vaya! Agregaste muchas funcionalidades a tu capa de datos. Ahora, para verificar que todas funcionen, debes crear pruebas de unidades para DefaultTaskRepository.

Debes crear una instancia de la función que deseas probar (DefaultTaskRepository) con dependencias de prueba para las fuentes de datos locales y de red. En primer lugar, debes crear esas dependencias.

  1. En la ventana del explorador de proyectos, expande la carpeta (test) y, luego, expande la carpeta source.local y abre FakeTaskDao.kt..

El archivo FakeTaskDao.kt en la estructura de carpetas del proyecto.

Figura 18: Captura de pantalla en la que se muestra FakeTaskDao.kt en la estructura de carpeta del proyecto.

  1. Agrega el contenido que se encuentra a continuación:
class FakeTaskDao(initialTasks: List<LocalTask>) : TaskDao {

    private val _tasks = initialTasks.toMutableList()
    private val tasksStream = MutableStateFlow(_tasks.toList())

    override fun observeAll(): Flow<List<LocalTask>> = tasksStream

    override suspend fun upsert(task: LocalTask) {
        _tasks.removeIf { it.id == task.id }
        _tasks.add(task)
        tasksStream.emit(_tasks)
    }

    override suspend fun upsertAll(tasks: List<LocalTask>) {
        val newTaskIds = tasks.map { it.id }
        _tasks.removeIf { newTaskIds.contains(it.id) }
        _tasks.addAll(tasks)
    }

    override suspend fun updateCompleted(taskId: String, completed: Boolean) {
        _tasks.firstOrNull { it.id == taskId }?.let { it.isCompleted = completed }
        tasksStream.emit(_tasks)
    }

    override suspend fun deleteAll() {
        _tasks.clear()
        tasksStream.emit(_tasks)
    }
}

En una app real, también debes crear una dependencia falsa para reemplazar TaskNetworkDataSource (haciendo que los objetos falsos y reales implementen una interfaz común), pero para este codelab la utilizarás directamente.

  1. Dentro de DefaultTaskRepositoryTest, agrega lo siguiente.

Una regla que define que el despachador principal se utilice en todas las pruebas.

Algunos datos de prueba.

Las dependencias de prueba para las fuentes de datos locales y de red.

El elemento que se probará: DefaultTaskRepository.

class DefaultTaskRepositoryTest {

    private var testDispatcher = UnconfinedTestDispatcher()
    private var testScope = TestScope(testDispatcher)

    private val localTasks = listOf(
        LocalTask(id = "1", title = "title1", description = "description1", isCompleted = false),
        LocalTask(id = "2", title = "title2", description = "description2", isCompleted = true),
    )

    private val localDataSource = FakeTaskDao(localTasks)
    private val networkDataSource = TaskNetworkDataSource()
    private val taskRepository = DefaultTaskRepository(
        localDataSource = localDataSource,
        networkDataSource = networkDataSource,
        dispatcher = testDispatcher,
        scope = testScope
    )
}

¡Perfecto! Ahora puedes comenzar a escribir pruebas de unidades. Hay tres áreas principales que debes probar: lecturas, escrituras y sincronización de datos.

Cómo probar los datos expuestos

Puedes probar que el repositorio esté exponiendo los datos correctamente de la siguiente manera. La prueba sigue la estructura qué, cuándo, luego. Por ejemplo:

Qué

La fuente de datos local tiene algunas tareas existentes

Cuándo

El flujo de tareas se obtiene del repositorio mediante observeAll

Luego

El primer elemento en el flujo de tareas coincide con la representación externa de las tareas en la fuente de datos local

  • Crea una prueba con el nombre observeAll_exposesLocalData que incluya lo siguiente:
@Test
fun observeAll_exposesLocalData() = runTest {
    val tasks = taskRepository.observeAll().first()
    assertEquals(localTasks.toExternal(), tasks)
}

Usa la función first para obtener el primer elemento del flujo de tareas.

Cómo probar las actualizaciones de datos

A continuación, escribe una prueba que verifique que una tarea se crea y se guarda en la fuente de datos de red.

Qué

Una base de datos vacía

Cuándo

Una tarea se crea mediante una llamada a create

Luego

La tarea se crea tanto en la fuente de datos local como en la fuente de datos de red

  1. Crea una prueba con el nombre onTaskCreation_localAndNetworkAreUpdated.
@Test
    fun onTaskCreation_localAndNetworkAreUpdated() = testScope.runTest {
        val newTaskId = taskRepository.create(
            localTasks[0].title,
            localTasks[0].description
        )

        val localTasks = localDataSource.observeAll().first()
        assertEquals(true, localTasks.map { it.id }.contains(newTaskId))

        val networkTasks = networkDataSource.loadTasks()
        assertEquals(true, networkTasks.map { it.id }.contains(newTaskId))
    }

A continuación, verifica que, cuando se complete una tarea, se escriba correctamente en la fuente de datos local y se guarde en la fuente de datos de red.

Qué

La fuente de datos local incluye una tarea

Cuándo

La tarea se completa llamando a complete

Luego

También se actualizan los datos locales y los datos de red

  1. Crea una prueba con el nombre onTaskCompletion_localAndNetworkAreUpdated.
    @Test
    fun onTaskCompletion_localAndNetworkAreUpdated() = testScope.runTest {
        taskRepository.complete("1")

        val localTasks = localDataSource.observeAll().first()
        val isLocalTaskComplete = localTasks.firstOrNull { it.id == "1" } ?.isCompleted
        assertEquals(true, isLocalTaskComplete)

        val networkTasks = networkDataSource.loadTasks()
        val isNetworkTaskComplete =
            networkTasks.firstOrNull { it.id == "1"} ?.status == NetworkTask.TaskStatus.COMPLETE
        assertEquals(true, isNetworkTaskComplete)
    }

Cómo probar la actualización de datos

Por último, prueba que la operación de actualización se complete correctamente.

Qué

La fuente de datos de red incluye los datos

Cuándo

Se llama a refresh

Luego

Los datos locales son iguales a los datos de red

  • Crea una prueba con el nombre onRefresh_localIsEqualToNetwork.
@Test
    fun onRefresh_localIsEqualToNetwork() = runTest {
        val networkTasks = listOf(
            NetworkTask(id = "3", title = "title3", shortDescription = "desc3"),
            NetworkTask(id = "4", title = "title4", shortDescription = "desc4"),
        )
        networkDataSource.saveTasks(networkTasks)

        taskRepository.refresh()

        assertEquals(networkTasks.toLocal(), localDataSource.observeAll().first())
    }

Eso es todo. Ejecuta las pruebas y todas deberían aprobarse.

9. Cómo actualizar la capa de IU

Ahora que sabes que la capa de datos funciona, es momento de conectarla a la capa de IU.

Cómo actualizar el modelo de vista de la pantalla de lista de tareas

Comienza con TasksViewModel. Este es el modelo de vistas para mostrar la primera pantalla en la app: la lista de todas las tareas activas actualmente.

  1. Abre esta clase y agrega DefaultTaskRepository como parámetro de constructor.
@HiltViewModel
class TasksViewModel @Inject constructor(
    private val savedStateHandle: SavedStateHandle,
    private val taskRepository: DefaultTaskRepository,
) : ViewModel () { /* ... */ }
  1. Usa el repositorio para inicializar la variable tasksStream.
private val tasksStream = taskRepository.observeAll()

Tu modelo de vistas ahora tiene acceso a todas las tareas que proporciona el repositorio y recibirá una nueva lista de tareas cada vez que cambien los datos (aun cuando solo cambie una línea de código).

  1. A continuación, solo tienes que conectar las acciones del usuario a sus métodos correspondientes en el repositorio. Busca el método complete y actualízalo a:
fun complete(task: Task, completed: Boolean) {
    viewModelScope.launch {
        if (completed) {
            taskRepository.complete(task.id)
            showSnackbarMessage(R.string.task_marked_complete)
        } else {
            ...
       }
    }
}
  1. Haz lo mismo con refresh.
    fun refresh() {
        _isLoading.value = true
        viewModelScope.launch {
            taskRepository.refresh()
            _isLoading.value = false
        }
    }

Cómo actualizar el modelo de vista de la pantalla para agregar tareas

  1. Abre AddEditTaskViewModel y agrega DefaultTaskRepository como parámetro de constructor, al igual que en el paso anterior.
class AddEditTaskViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    private val taskRepository: DefaultTaskRepository,
)
  1. Actualiza el método create de la siguiente forma:
    private fun createNewTask() = viewModelScope.launch {
        taskRepository.create(uiState.value.title, uiState.value.description)
        _uiState.update {
            it.copy(isTaskSaved = true)
        }
    }

Ejecuta la app

  1. Llegó el tan esperado momento de ejecutar tu app. Verás una pantalla con el mensaje You have no tasks! (Ya no tienes tareas).

Pantalla de tareas de la app cuando no hay tareas.

Figura 19: Captura de la pantalla de tareas de la app cuando no hay tareas.

  1. Presiona los tres puntos en la esquina superior derecha y, luego, selecciona Refresh.

La pantalla de tareas de la app con el menú de acciones.

Figura 20: Captura de la pantalla de tareas de la app con el menú de acciones.

Verás un ícono giratorio de carga durante dos segundos, seguido de las tareas de prueba que agregaste anteriormente.

La pantalla de tareas de la app con dos tareas.

Figura 21: Captura de la pantalla de tareas de la app con dos tareas.

  1. Ahora, presiona el signo más en la esquina inferior derecha para agregar una tarea nueva. Completa los campos de título y descripción.

La pantalla para agregar tareas de la app.

Figura 22: Captura de la pantalla para agregar tareas de la app.

  1. Presiona el botón de verificación en la esquina inferior derecha para guardar la tarea.

La pantalla de tareas de la app después de agregar una tarea.

Figura 23: Captura de la pantalla de tareas de la app después de agregar una tarea.

  1. Selecciona la casilla de verificación al lado de la tarea para marcarla como completada.

La pantalla de tareas de la app en la que se muestra una tarea completada.

Figura 24: Captura de la pantalla de tareas de la app en la que se muestra una tarea completada.

10. ¡Felicitaciones!

Creaste correctamente una capa de datos para una app.

La capa de datos es una parte fundamental de la arquitectura de tu aplicación. Es una base sobre la que se pueden crear otras capas, de manera que crearla correctamente permite que tu app crezca a la par de las necesidades de tus usuarios y tu empresa.

Qué aprendiste

  • La función de la capa de datos en la arquitectura de la app para Android.
  • Cómo crear modelos y fuentes de datos.
  • La función de los repositorios y cómo exponen los datos y proporcionan métodos únicos para actualizar los datos.
  • Cuándo cambiar el despachador de corrutina y por qué es importante hacerlo.
  • Sincronizar datos con varias fuentes de datos.
  • Cómo crear pruebas de unidades y pruebas de instrumentación para clases de capas de datos.

Un nuevo desafío

Si quieres desafiarte aún más, implementa las siguientes funciones:

  • Reactiva una tarea después de que se haya marcado como completada.
  • Presiona el título y la descripción de una tarea para editarlos.

No hay instrucciones. Todo depende de ti. Si no logras avanzar, echa un vistazo a la app completamente funcional en la rama de main.

git checkout main

Próximos pasos

Para obtener más información sobre la capa de datos, echa un vistazo a la documentación oficial y a la guía para las apps que priorizan el uso sin conexión. También puedes aprender más sobre otras capas de arquitectura, como la capa de la IU y la capa de dominio.

Si deseas ver una muestra más compleja y realista, echa un vistazo a la app de Now in Android.