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.
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
- Este es un codelab de nivel intermedio, y deberías tener conocimientos básicos sobre cómo se compilan las apps para Android (consulta a continuación los recursos de aprendizaje para principiantes).
- Experiencia con Kotlin, lo que incluye lambdas, corrutinas y flujos. Para aprender cómo escribir en Kotlin en apps para Android, consulta la unidad 1 del curso Aspectos básicos de Android en Kotlin.
- Conocimientos básicos sobre las bibliotecas Hilt (inserción de dependencias) y Room (almacenamiento de base de datos).
- Algo de experiencia con Jetpack Compose. Las unidades 1 a 3 del curso Aspectos básicos de Android en Compose son un excelente recurso para aprender sobre Compose.
- Opcional: Lee las guías sobre la descripción general de la arquitectura y la capa de datos.
- Opcional: Completa el codelab de Room.
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.
Figura 2: Captura de la pantalla de lista de tareas. | Figura 3: Captura de la pantalla de detalles de tareas. |
2. Prepárate
- Descarga el código:
https://github.com/android/architecture-samples/archive/refs/heads/data-codelab-start.zip
- 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
- 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
.
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.
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.
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.
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 |
| 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 |
| Interno | Una tarea almacenada en una base de datos local |
|
| Interno | Una tarea que un servidor de red recuperó |
|
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
:
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.
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 dedata/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
.
- Abre
ToDoDatabase.kt
y cambiaBlankEntity
aLocalTask
. - Quita
BlankEntity
y todas las sentenciasimport
redundantes. - 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 alDatabaseModule
:
@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:
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 abreTaskDaoTest.kt
. En su interior, crea una clase vacía con el nombreTaskDaoTest
.
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ó |
- 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)
}
- Para ejecutar la prueba, haz clic en Play junto a la prueba en el margen.
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.
Figura 12: Captura de pantalla con una prueba fallida.
- Quita la sentencia
assertEquals
existente. - 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])
- Vuelve a ejecutar la prueba. Verás la prueba aprobada dentro de la ventana de resultados de prueba.
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 endata/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 dedescription
. - El campo
isCompleted
se representa como una enum destatus
, que tiene dos valores posibles:ACTIVE
yCOMPLETE
. - 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 nombreTaskNetworkDataSource
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.
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
- Abre
DefaultTaskRepository.kt
en el paquetedata
y, luego, crea una clase con el nombreDefaultTaskRepository
, que tomeTaskDao
yTaskNetworkDataSource
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.
- Agrega un método con el nombre
observeAll
, que muestra un flujo de modelosTask
que utilizan unFlow
.
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
.
- 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
.
- Utiliza la función
toExternal
recientemente creada dentro deobserveAll
:
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
.
- Agrega un método con el nombre
create
, que tometitle
ydescription
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.
- Agrega un método para crear un ID de tarea.
// This method might be computationally expensive
private fun createTaskId() : String {
return UUID.randomUUID().toString()
}
- 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.
- En primer lugar, agrega un
CoroutineDispatcher
como una dependencia aDefaultTaskRepository
. Utiliza el calificador de@DefaultDispatcher
ya creado (definido endi/CoroutinesModule.kt
) para indicarle a Hilt que inserte esta dependencia conDispatchers.Default
. Se recomienda el despachador deDefault
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,
)
- Ahora, coloca la llamada a
UUID.randomUUID().toString()
dentro de un bloquewithContext
.
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
- 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
.
- Agrega la siguiente función de extensión al final de
LocalTask
. Esta es la función de asignación inversa deLocalTask.toExternal
, que creaste anteriormente.
fun Task.toLocal() = LocalTask(
id = id,
title = title,
description = description,
isCompleted = isCompleted,
)
- Utilízala dentro de
create
para insertar la tarea en la fuente de datos local y, luego, muestra eltaskId
.
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 laTask
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 |
| Cargar datos de una base de datos local | Figura 15: Diagrama en el que se muestra el flujo de datos de una fuente de datos local al repositorio de tareas. |
Guardar |
| 1. Escribe los datos en la base de datos local. 2. Copia todos los datos a la red y sobrescribe toda la información. | 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 |
| 1. Carga los datos de red. 2. Cópialos en la base de datos local y sobrescribe toda la información. | 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.
- En primer lugar, crea funciones de asignación de
LocalTask
aNetworkTask
y viceversa dentro deNetworkTask.kt
. También puedes colocar las funciones dentro deLocalTask.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.
- Agrega el método
refresh
al final deDefaultTaskRepository
.
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.
- Agrega el método
saveTasksToNetwork
al final deDefaultTaskRepository
.
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.
- Ahora, actualiza los métodos existentes. Se actualizarán las tareas
create
ycomplete
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.
- 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.
- Usa
scope.launch
para unir el código dentro desaveTasksToNetwork
.
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.
- En la ventana del explorador de proyectos, expande la carpeta
(test)
y, luego, expande la carpetasource.local
y abreFakeTaskDao.kt.
.
Figura 18: Captura de pantalla en la que se muestra FakeTaskDao.kt
en la estructura de carpeta del proyecto.
- 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.
- 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á: |
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 |
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 |
Luego | La tarea se crea tanto en la fuente de datos local como en la fuente de datos de red |
- 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 |
Luego | También se actualizan los datos locales y los datos de red |
- 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 |
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.
- 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 () { /* ... */ }
- 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).
- 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 {
...
}
}
}
- 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
- Abre
AddEditTaskViewModel
y agregaDefaultTaskRepository
como parámetro de constructor, al igual que en el paso anterior.
class AddEditTaskViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val taskRepository: DefaultTaskRepository,
)
- 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
- Llegó el tan esperado momento de ejecutar tu app. Verás una pantalla con el mensaje You have no tasks! (Ya no tienes tareas).
Figura 19: Captura de la pantalla de tareas de la app cuando no hay tareas.
- Presiona los tres puntos en la esquina superior derecha y, luego, selecciona Refresh.
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.
Figura 21: Captura de la pantalla de tareas de la app con dos tareas.
- 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.
Figura 22: Captura de la pantalla para agregar tareas de la app.
- Presiona el botón de verificación en la esquina inferior derecha para guardar la tarea.
Figura 23: Captura de la pantalla de tareas de la app después de agregar una tarea.
- Selecciona la casilla de verificación al lado de la tarea para marcarla como 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.