1. Avant de commencer
Cet atelier de programmation porte sur la couche de données et sur la manière dont elle s'intègre dans votre architecture globale d'application.
Figure 1. Diagramme qui illustre la couche de données en tant que couche dont dépendent les couches de domaine et d'UI
Vous allez concevoir la couche de données d'une application de gestion des tâches. Vous créerez également des sources de données pour une base de données locale et un service réseau, ainsi qu'un dépôt qui expose, met à jour et synchronise les données.
Conditions préalables
- Comme il s'agit d'un atelier de programmation intermédiaire, vous devez comprendre la manière dont les applications Android sont créées (vous trouverez ci-dessous les ressources de formation pour débutants).
- Vous devez maîtriser le langage Kotlin, y compris les lambdas, les coroutines et les flux. Pour en savoir plus sur l'écriture en Kotlin dans les applications Android, consultez le module 1 du cours sur les principes de base d'Android en Kotlin.
- Vous devez connaître les bases des bibliothèques Hilt (injection de dépendances) et Room (stockage de base de données).
- Vous devez connaître Jetpack Compose. Les modules 1 à 3 du cours sur les principes de base d'Android dans Compose sont un excellent point de départ pour vous familiariser avec Compose.
- Facultatif : vous avez lu les guides sur la présentation de l'architecture et sur la couche de données.
- Facultatif : vous avez suivi l'atelier de programmation sur Room.
Points abordés
Dans cet atelier de programmation, vous allez découvrir comment effectuer les opérations suivantes :
- Créer des dépôts, des sources et des modèles de données pour une gestion des données efficace et évolutive
- Exposer les données à d'autres couches architecturales
- Gérer les mises à jour de données asynchrones et les tâches complexes ou de longue durée
- Synchroniser les données entre plusieurs sources de données
- Créer des tests qui vérifient le comportement de vos dépôts et sources de données
Objectifs de l'atelier
Vous allez créer une application de gestion des tâches qui permet d'ajouter des tâches et de les marquer comme terminées.
Vous n'aurez pas à écrire l'application à partir de zéro. À la place, vous travaillerez sur une application qui comprend déjà une couche d'UI. La couche d'UI de cette application comporte des écrans et des conteneurs d'état au niveau de l'écran qui ont été implémentés à l'aide de ViewModels.
Au cours de cet atelier de programmation, vous allez ajouter la couche de données, puis la connecter à la couche d'UI existante, ce qui permettra à l'application de devenir entièrement fonctionnelle.
Figure 2. Capture d'écran de la liste des tâches | Figure 3. Capture d'écran des détails d'une tâche |
2. Configuration
- Pour télécharger le code :
https://github.com/android/architecture-samples/archive/refs/heads/data-codelab-start.zip
- Vous pouvez également cloner le dépôt GitHub du code :
git clone https://github.com/android/architecture-samples.git git checkout data-codelab-start
- Ouvrez Android Studio et chargez le projet
architecture-samples
.
Structure des dossiers
- Ouvrez l'explorateur de projets dans la vue Android.
Plusieurs dossiers se trouvent sous java/com.example.android.architecture.blueprints.todoapp
.
Figure 4. Capture d'écran de la fenêtre de l'explorateur de projets Android Studio dans la vue Android
<root>
contient des classes au niveau de l'application, telles que la navigation, l'activité principale et la classe d'application.addedittask
contient la fonctionnalité d'UI qui permet aux utilisateurs d'ajouter et de modifier des tâches.data
contient la couche de données. Vous allez principalement utiliser ce dossier.di
contient des modules Hilt pour l'injection de dépendances.tasks
contient la fonctionnalité d'UI qui permet aux utilisateurs d'afficher et de mettre à jour les listes de tâches.util
contient les classes utilitaires.
Il existe également deux dossiers de test, qui sont indiqués par le texte entre parenthèses à la fin du nom de dossier.
androidTest
suit la même structure que<root>
, mais contient des tests instrumentés.test
suit la même structure que<root>
, mais contient des tests locaux.
Exécuter le projet
- Cliquez sur l'icône de lecture verte dans la barre d'outils supérieure.
Figure 5. Capture d'écran illustrant la configuration d'exécution d'Android Studio, l'appareil cible et le bouton de lecture
L'écran de la liste des tâches devrait s'afficher avec une icône de chargement qui ne disparaît jamais.
Figure 6. Capture d'écran de l'application dans son état de départ avec une icône de chargement active
À la fin de l'atelier de programmation, une liste de tâches figurera sur cet écran.
Pour afficher le code final de l'atelier de programmation, consultez la branche data-codelab-final
.
git checkout data-codelab-final
N'oubliez pas d'enregistrer d'abord vos modifications.
3. Se familiariser avec la couche de données
Dans cet atelier de programmation, vous allez créer la couche de données de l'application.
La couche de données est, comme son nom l'indique, une couche architecturale qui gère les données de votre application. Elle contient également la logique métier, c'est-à-dire les règles métier réelles qui déterminent la manière dont les données d'application doivent être créées, stockées et modifiées. Cette séparation des tâches permet de réutiliser la couche de données (elle peut ainsi être présentée sur plusieurs écrans), partager des informations entre différentes parties de l'application et reproduire la logique métier en dehors de l'UI pour les tests unitaires.
Les principaux types de composants qui constituent la couche de données sont les modèles de données, les sources de données et les dépôts.
Figure 7. Diagramme illustrant les types de composants de la couche de données, y compris les dépendances entre les modèles de données, les sources de données et les dépôts
Modèles de données
Les données d'application sont généralement représentées sous forme de modèles de données. Il s'agit de représentations en mémoire des données.
Comme il est ici question d'une application de gestion des tâches, vous avez besoin d'un modèle de données correspondant à une tâche. Voici la classe Task
:
data class Task(
val id: String
val title: String = "",
val description: String = "",
val isCompleted: Boolean = false,
) { ... }
Ce modèle est immuable, ce qui est une caractéristique essentielle. Les autres couches ne peuvent pas modifier les propriétés de la tâche. Elles doivent utiliser la couche de données pour pouvoir apporter des modifications à une tâche.
Modèles de données internes et externes
Task
est un exemple de modèle de données externe. Il est exposé à l'extérieur par la couche de données, et d'autres couches peuvent y accéder. Par la suite, vous définirez des modèles de données internes qui ne sont utilisés qu'à l'intérieur de la couche de données.
Il est recommandé de définir un modèle de données pour chaque représentation d'un modèle métier. Cette application comprend trois modèles de données.
Nom du modèle | Externe ou interne à la couche de données ? | Représente | Source de données associée |
| Externe | Tâche qui peut être utilisée partout dans l'application, uniquement stockée en mémoire ou lors de l'enregistrement de l'état de l'application. | N/A |
| Interne | Tâche stockée dans une base de données locale. |
|
| Interne | Tâche qui a été récupérée à partir d'un serveur réseau. |
|
Sources de données
Une source de données est une classe responsable de la lecture et de l'écriture de données dans une source unique telle qu'une base de données ou un service réseau.
Cette application comprend deux sources de données :
TaskDao
est une source de données locale qui lit et écrit des données dans une base de données.NetworkTaskDataSource
est une source de données réseau qui lit et écrit des données sur un serveur réseau.
Dépôts
Un dépôt doit gérer un seul modèle de données. Dans cette application, vous allez créer un dépôt qui gère les modèles Task
. Ce dépôt remplira les conditions suivantes :
- Il exposera une liste de modèles
Task
. - Il fournira des méthodes pour créer et mettre à jour un modèle
Task
. - Il exécutera la logique métier, telle que la création d'un ID unique pour chaque tâche.
- Il combinera ou mappera les modèles de données internes à partir de sources de données dans des modèles
Task
. - Il synchronisera les sources de données.
À vos claviers !
- Passez à la vue Android et développez le package
com.example.android.architecture.blueprints.todoapp.data
:
Figure 8. Fenêtre de l'explorateur de projets affichant des dossiers et des fichiers
La classe Task
a déjà été créée pour que le reste de l'application se compile. À partir de maintenant, vous allez créer entièrement la plupart des classes de couche de données en ajoutant les implémentations aux fichiers .kt
vides fournis.
4. Stocker les données localement
Au cours de cette étape, vous allez créer une source de données et un modèle de données pour une base de données Room qui stocke les tâches localement sur l'appareil.
Figure 9. Diagramme illustrant la relation entre le dépôt de tâches, le modèle, la source de données et la base de données
Créer un modèle de données
Pour stocker des données dans une base de données Room, vous devez créer une entité de base de données.
- Ouvrez le fichier
LocalTask.kt
dansdata/source/local
, puis ajoutez-y le code suivant :
@Entity(
tableName = "task"
)
data class LocalTask(
@PrimaryKey val id: String,
var title: String,
var description: String,
var isCompleted: Boolean,
)
La classe LocalTask
représente les données stockées dans une table nommée task
dans la base de données Room. Elle est étroitement liée à Room ne doit pas être utilisée pour d'autres sources de données telles que DataStore.
Le préfixe Local
dans le nom de la classe est utilisé pour indiquer que ces données sont stockées localement. Il sert également à distinguer cette classe du modèle de données Task
, qui est exposé aux autres couches de l'application. Autrement dit, LocalTask
est interne à la couche de données, et Task
est externe.
Créer une source de données
Maintenant que vous disposez d'un modèle de données, créez une source de données pour créer, lire, mettre à jour et supprimer des modèles LocalTask
(voir les opérations CRUD). Avec Room, vous pouvez utiliser un objet d'accès aux données (l'annotation @Dao
) comme source de données locale.
- Créez une interface Kotlin dans le fichier nommé
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()
}
Les méthodes de lecture des données ont le préfixe observe
. Il s'agit de fonctions non suspendues qui renvoient un Flow
. Chaque fois que les données sous-jacentes changent, un nouvel élément est émis dans le flux. Grâce à cette fonctionnalité utile de la bibliothèque Room (et de nombreuses autres bibliothèques de stockage de données), vous pouvez écouter les modifications de données au lieu d'interroger la base de données en quête de nouvelles données.
Les méthodes pour l'écriture de données sont des fonctions suspendues, car elles effectuent des opérations d'E/S.
Mettre à jour le schéma de base de données
L'étape suivante consiste à mettre à jour la base de données afin qu'elle stocke les modèles LocalTask
.
- Ouvrez
ToDoDatabase.kt
et remplacezBlankEntity
parLocalTask
. - Supprimez
BlankEntity
et toute instructionimport
redondante. - Ajoutez une méthode pour renvoyer le DAO nommé
taskDao
.
La classe mise à jour devrait se présenter comme suit :
@Database(entities = [LocalTask::class], version = 1, exportSchema = false)
abstract class ToDoDatabase : RoomDatabase() {
abstract fun taskDao(): TaskDao
}
Mettre à jour la configuration de Hilt
Ce projet utilise Hilt pour l'injection des dépendances. Hilt doit savoir comment créer TaskDao
pour pouvoir l'injecter dans les classes qui l'utilisent.
- Ouvrez
di/DataModules.kt
et ajoutez la méthode suivante auDatabaseModule
:
@Provides
fun provideTaskDao(database: ToDoDatabase) : TaskDao = database.taskDao()
Vous disposez maintenant de tous les éléments nécessaires pour lire et écrire des tâches dans une base de données locale.
5. Tester la source de données locale
Au cours de la dernière étape, vous avez écrit beaucoup de code, mais comment savez-vous qu'il fonctionne comme prévu ? Il est facile de se tromper avec toutes ces requêtes SQL dans TaskDao
. Créez des tests pour vérifier que TaskDao
se comporte comme il se doit.
Les tests ne font pas partie de l'application. Ils doivent donc être placés dans un dossier différent. Il existe deux dossiers de test, qui sont indiqués par le texte entre parenthèses à la fin des noms de package :
Figure 10. Capture d'écran illustrant les dossiers "test" et "androidTest" dans l'explorateur de projets
androidTest
contient les tests qui s'exécutent dans un émulateur ou sur un appareil Android. Il s'agit des tests instrumentés.test
contient les tests exécutés sur votre machine hôte, également appelés tests locaux.
TaskDao
nécessite une base de données Room (qui ne peut être créée que sur un appareil Android). Pour le tester, vous devrez donc créer un test instrumenté.
Créer la classe de test
- Développez le dossier
androidTest
, puis ouvrezTaskDaoTest.kt
. À l'intérieur, créez une classe vide nomméeTaskDaoTest
.
class TaskDaoTest {
}
Ajouter une base de données de test
- Ajoutez
ToDoDatabase
et initialisez-la avant chaque test.
private lateinit var database: ToDoDatabase
@Before
fun initDb() {
database = Room.inMemoryDatabaseBuilder(
getApplicationContext(),
ToDoDatabase::class.java
).allowMainThreadQueries().build()
}
Cela créera une base de données en mémoire avant chaque test. Elle est beaucoup plus rapide qu'une base de données sur disque. Elle convient donc particulièrement aux tests automatisés dans lesquels les données n'ont pas besoin de perdurer au-delà des tests.
Ajouter un test
Ajoutez un test qui vérifie qu'une tâche locale (LocalTask
) peut être insérée et que cette même LocalTask
peut être lue à l'aide de TaskDao
.
Les tests de cet atelier de programmation suivent tous une structure logique de type Avec, Quand, Alors :
Avec | Une base de données vide |
Quand | Une tâche est insérée et vous commencez à observer le flux de tâches |
Alors | Le premier élément du flux de tâches correspond à la tâche qui a été insérée |
- Commencez par créer un test qui échoue. Cette approche permet de vérifier que le test fonctionne et que les objets corrects et leurs dépendances sont testés.
@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)
}
- Pour exécuter le test, cliquez sur le bouton de lecture à côté du test dans la marge.
Figure 11. Capture d'écran illustrant le bouton de lecture du test dans la marge de l'éditeur de code
Vous devriez voir le test échouer dans la fenêtre des résultats, avec le message expected:<0> but was:<1>
. C'est normal, car il y a une tâche dans la base de données, et non zéro.
Figure 12. Capture d'écran illustrant un test qui échoue
- Supprimez l'instruction
assertEquals
existante. - Ajoutez du code pour faire un test afin de vérifier qu'une seule et même tâche est fournie par la source de données et qu'il s'agit de la tâche qui a été insérée.
L'ordre des paramètres pour assertEquals
doit toujours être la valeur attendue, puis la valeur réelle**.**
assertEquals(1, tasks.size)
assertEquals(task, tasks[0])
- Exécutez à nouveau le test. Vous devriez voir le test réussir dans la fenêtre des résultats.
Figure 13. Capture d'écran illustrant un test réussi
6. Créer une source de données réseau
Il est pratique que les tâches puissent être enregistrées localement sur l'appareil, mais que se passe-t-il si vous souhaitez également enregistrer et charger ces tâches sur un service réseau ? Peut-être votre application Android n'est-elle que l'un des moyens par lesquels les utilisateurs peuvent ajouter des éléments à leur liste de tâches. Peut-être que les tâches pourraient également être gérées via un site Web ou une application de bureau. Ou peut-être souhaitez-vous simplement fournir une sauvegarde en ligne afin que les utilisateurs puissent restaurer les données de l'application même s'ils changent d'appareil.
Ces scénarios impliquent généralement un service réseau que tous les clients, y compris votre application Android, peuvent utiliser pour charger et enregistrer des données.
Au cours de la prochaine étape, vous allez créer une source de données pour communiquer avec ce service réseau. Pour les besoins de cet atelier de programmation, il s'agit d'un service simulé qui ne se connecte pas à un service réseau en direct, mais vous donne une idée de la manière dont cela pourrait être implémenté dans une application réelle.
À propos du service réseau
Dans l'exemple, l'API réseau est très simple. Elle n'effectue que deux opérations :
- Elle enregistre toutes les tâches en écrasant toutes les données précédemment écrites.
- Elle charge toutes les tâches, ce qui fournit une liste de toutes les tâches actuellement enregistrées sur le service réseau.
Modéliser les données réseau
Lorsque vous obtenez des données à partir d'une API réseau, il est courant que ces données soient représentées différemment de la façon dont elles sont enregistrées localement. La représentation réseau d'une tâche peut contenir des champs supplémentaires ou utiliser différents types ou noms de champs pour représenter les mêmes valeurs.
Pour tenir compte de ces différences, créez un modèle de données spécifique au réseau.
- Ouvrez le fichier
NetworkTask.kt
sousdata/source/network
, puis ajoutez le code suivant pour représenter les champs :
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
}
}
Voici les différences entre LocalTask
et NetworkTask
:
- La description de la tâche est nommée
shortDescription
au lieu dedescription
. - Le champ
isCompleted
est représenté sous la forme d'une énumérationstatus
, qui a deux valeurs possibles :ACTIVE
etCOMPLETE
. - Elle contient un champ
priority
supplémentaire, qui est un entier.
Créer la source de données réseau
- Ouvez
TaskNetworkDataSource.kt
, puis créez une classe nomméeTaskNetworkDataSource
avec le contenu suivant :
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
Cet objet simule l'interaction avec le serveur, y compris un délai simulé de deux secondes chaque fois que loadTasks
ou saveTasks
est appelé. Cela est assimilable à la latence de réponse du réseau ou du serveur.
Il inclut également des données de test que vous utiliserez ultérieurement pour vérifier que les tâches peuvent être chargées à partir du réseau.
Si votre API de serveur réelle utilise HTTP, envisagez d'avoir recours à une bibliothèque comme Ktor ou Retrofit pour créer votre source de données réseau.
7. Créer le dépôt de tâches
Tout commence à prendre forme.
Figure 14. Diagramme illustrant les dépendances de DefaultTaskRepository
Nous avons deux sources de données : une pour les données locales (TaskDao
) et une pour les données réseau (TaskNetworkDataSource
). Chacune autorise les lectures et les écritures, et possède sa propre représentation d'une tâche (LocalTask
et NetworkTask
, respectivement).
Créons maintenant un dépôt qui utilise ces sources de données et fournit une API afin que d'autres couches architecturales puissent accéder aux données de cette tâche.
Exposer les données
- Ouvrez
DefaultTaskRepository.kt
dans le packagedata
, puis créez une classe nomméeDefaultTaskRepository
, qui utiliseTaskDao
etTaskNetworkDataSource
comme dépendances.
class DefaultTaskRepository @Inject constructor(
private val localDataSource: TaskDao,
private val networkDataSource: TaskNetworkDataSource,
) {
}
Les données doivent être exposées à l'aide de flux. Cela permet aux appelants d'être informés des modifications apportées au fil du temps à ces données.
- Ajoutez une méthode nommée
observeAll
, qui renvoie un flux de modèlesTask
avec unFlow
.
fun observeAll() : Flow<List<Task>> {
// TODO add code to retrieve Tasks
}
Les dépôts doivent exposer les données à partir d'une source unique de référence. Autrement dit, les données doivent provenir d'une seule source de données. Il peut s'agir d'un cache en mémoire, d'un serveur distant ou, dans ce cas, de la base de données locale.
Les tâches de la base de données locale sont accessibles à l'aide de TaskDao.observeAll
, qui renvoie facilement un flux. Toutefois, il s'agit d'un flux de modèles LocalTask
, dans lequel LocalTask
est un modèle interne qui ne doit pas être exposé à d'autres couches architecturales.
Vous devez convertir LocalTask
en Task
. Il s'agit d'un modèle externe qui fait partie de l'API de la couche de données.
Mapper les modèles internes aux modèles externes
Pour effectuer cette conversion, vous devez faire correspondre les champs de LocalTask
avec ceux de Task
.
- Créez des fonctions d'extension correspondantes dans
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() }
À présent, lorsque vous devez convertir LocalTask
en Task
, il vous suffit d'appeler toExternal
.
- Utilisez la fonction
toExternal
que vous venez de créer dansobserveAll
:
fun observeAll(): Flow<List<Task>> {
return localDataSource.observeAll().map { tasks ->
tasks.toExternal()
}
}
Chaque fois que les données des tâches changent dans la base de données locale, une nouvelle liste de modèles LocalTask
est émise dans le flux. Chaque élément LocalTask
est ensuite mappé à une Task
.
Parfait ! Désormais, d'autres couches peuvent utiliser observeAll
afin d'obtenir tous les modèles Task
de votre base de données locale et d'être averties chaque fois que ces modèles Task
changent.
Mettre à jour des données
Une application de gestion des tâches n'est pas d'une grande aide si elle ne vous permet pas de créer ni de mettre à jour des tâches. Vous allez maintenant ajouter des méthodes pour remédier à cela.
Les méthodes de création, de mise à jour ou de suppression de données sont des opérations ponctuelles et doivent être implémentées à l'aide de fonctions suspend
.
- Ajoutez une méthode nommée
create
, qui utilise des élémentstitle
etdescription
comme paramètres et qui renvoie l'ID de la tâche qui vient d'être créée.
suspend fun create(title: String, description: String): String {
}
Notez que l'API de la couche de données interdit à une Task
d'être créée par d'autres couches en fournissant uniquement une méthode create
qui accepte des paramètres individuels, pas une Task
. Cette approche englobe les éléments suivants :
- La logique métier pour créer un ID de tâche unique
- L'emplacement de stockage de la tâche après sa création initiale
- Ajoutez une méthode pour créer un ID de tâche.
// This method might be computationally expensive
private fun createTaskId() : String {
return UUID.randomUUID().toString()
}
- Créez un ID de tâche à l'aide de la méthode
createTaskId
que vous venez d'ajouter.
suspend fun create(title: String, description: String): String {
val taskId = createTaskId()
}
Ne pas bloquer le thread principal
Une question se pose. Que se passe-t-il si la création de l'ID de tâche est coûteuse en calcul ? Peut-être utilise-t-elle la cryptographie afin de créer une clé de hachage pour l'ID, ce qui prend plusieurs secondes. Cela pourrait entraîner des à-coups de l'UI en cas d'appel sur le thread principal.
La couche de données doit s'assurer que les tâches longues ou complexes ne bloquent pas le thread principal.
Pour résoudre ce problème, spécifiez un répartiteur de coroutine à utiliser pour exécuter ces instructions.
- Commencez par ajouter un
CoroutineDispatcher
comme dépendance deDefaultTaskRepository
. Utilisez le qualificatif@DefaultDispatcher
déjà créé (et défini dansdi/CoroutinesModule.kt
) pour indiquer à Hilt d'injecterDispatchers.Default
dans cette dépendance. Le répartiteurDefault
est spécifié, car il est optimisé pour les tâches qui nécessitent beaucoup de ressources de processeur. En savoir plus sur les répartiteurs de coroutines
class DefaultTaskRepository @Inject constructor(
private val localDataSource: TaskDao,
private val networkDataSource: TaskNetworkDataSource,
@DefaultDispatcher private val dispatcher: CoroutineDispatcher,
)
- Maintenant, effectuez l'appel à
UUID.randomUUID().toString()
dans un blocwithContext
.
val taskId = withContext(dispatcher) {
createTaskId()
}
En savoir plus sur les threads dans la couche de données
Créer et stocker la tâche
- Maintenant que vous avez un ID de tâche, utilisez-le avec les paramètres fournis pour créer une
Task
.
suspend fun create(title: String, description: String): String {
val taskId = withContext(dispatcher) {
createTaskId()
}
val task = Task(
title = title,
description = description,
id = taskId,
)
}
Avant d'insérer la tâche dans la source de données locale, vous devez la mapper à une LocalTask
.
- Ajoutez la fonction d'extension suivante à la fin de
LocalTask
. Il s'agit de la fonction de mappage inverse deLocalTask.toExternal
, que vous avez créée précédemment.
fun Task.toLocal() = LocalTask(
id = id,
title = title,
description = description,
isCompleted = isCompleted,
)
- Utilisez-la dans
create
pour insérer la tâche dans la source de données locale, puis renvoyez letaskId
.
suspend fun create(title: String, description: String): Task {
...
localDataSource.upsert(task.toLocal())
return taskId
}
Finaliser la tâche
- Créez une méthode supplémentaire,
complete
, qui marque la tâche (Task
) comme terminée.
suspend fun complete(taskId: String) {
localDataSource.updateCompleted(taskId, true)
}
Vous disposez maintenant de méthodes utiles pour créer et finaliser des tâches.
Synchroniser les données
Dans cette application, la source de données réseau fonctionne comme une sauvegarde en ligne qui est mise à jour chaque fois que des données sont écrites localement. Les données sont chargées à partir du réseau chaque fois que l'utilisateur demande une actualisation.
Les diagrammes suivants résument le comportement de chaque type d'opération.
Type d'opération | Méthodes du dépôt | Étapes | Transfert de données |
Charger |
| Charger les données à partir de la base de données locale | Figure 15. Diagramme illustrant le flux de données depuis la source de données locale vers le dépôt de tâches |
Enregistrer |
| 1. Écrire des données dans la base de données locale 2. Copier toutes les données sur le réseau en écrasant tout | Figure 16. Diagramme illustrant le flux de données depuis le dépôt de tâches vers la source de données locale, puis vers la source de données réseau |
Actualiser |
| 1. Charger les données réseau 2. Les copier dans la base de données locale en écrasant tout | Figure 17. Diagramme illustrant le flux de données depuis la source de données réseau vers la source de données locale, puis vers le dépôt de tâches |
Enregistrer et actualiser les données réseau
Votre dépôt charge déjà des tâches à partir de la source de données locale. Pour mener à bien l'algorithme de synchronisation, vous devez créer des méthodes afin d'enregistrer et d'actualiser les données à partir de la source de données réseau.
- Tout d'abord, créez des fonctions de mappage de
LocalTask
àNetworkTask
et vice versa dansNetworkTask.kt
. Sinon, vous pouvez également placer les fonctions dansLocalTask.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)
Vous pouvez voir ici l'avantage que présente l'utilisation de modèles distincts pour chaque source de données : le mappage d'un type de données à un autre est encapsulé dans des fonctions distinctes.
- Ajoutez la méthode
refresh
à la fin deDefaultTaskRepository
.
suspend fun refresh() {
val networkTasks = networkDataSource.loadTasks()
localDataSource.deleteAll()
val localTasks = withContext(dispatcher) {
networkTasks.toLocal()
}
localDataSource.upsertAll(networkTasks.toLocal())
}
Cela remplace toutes les tâches locales par celles du réseau. withContext
est utilisé pour l'opération toLocal
groupée, car il existe un nombre inconnu de tâches et chaque opération de mappage peut être coûteuse en calcul.
- Ajoutez la méthode
saveTasksToNetwork
à la fin deDefaultTaskRepository
.
private suspend fun saveTasksToNetwork() {
val localTasks = localDataSource.observeAll().first()
val networkTasks = withContext(dispatcher) {
localTasks.toNetwork()
}
networkDataSource.saveTasks(networkTasks)
}
Cela remplace toutes les tâches réseau par celles de la source de données locale.
- À présent, mettez à jour les méthodes existantes, qui actualisent les tâches
create
etcomplete
afin que les données locales soient enregistrées sur le réseau lorsqu'elles changent.
suspend fun create(title: String, description: String): String {
...
saveTasksToNetwork()
return taskId
}
suspend fun complete(taskId: String) {
localDataSource.updateCompleted(taskId, true)
saveTasksToNetwork()
}
Ne pas faire attendre l'appelant
Si vous exécutiez ce code, vous remarqueriez que saveTasksToNetwork
se bloque. Cela signifie que les appelants de create
et complete
doivent attendre que les données soient enregistrées sur le réseau avant de pouvoir être sûrs que l'opération est terminée. Dans la source de données réseau simulée, ce processus ne prend que deux secondes, mais dans une application réelle, cela peut être beaucoup plus long. Cela peut même ne jamais avoir lieu s'il n'y a pas de connexion réseau.
Ce système est inutilement restrictif et contribue à nuire à l'expérience utilisateur. Personne n'aime attendre pour créer une tâche, et encore moins quand on est bien occupé.
Il existe une meilleure solution : vous pouvez utiliser un champ d'application de coroutine différent afin d'enregistrer les données sur le réseau. Cette approche permet à l'opération de se terminer en arrière-plan sans que l'appelant n'ait à attendre le résultat.
- Ajoutez un champ d'application de coroutine en tant que paramètre à
DefaultTaskRepository
.
class DefaultTaskRepository @Inject constructor(
// ...other parameters...
@ApplicationScope private val scope: CoroutineScope,
)
Le qualificatif Hilt @ApplicationScope
(défini dans di/CoroutinesModule.kt
) permet d'injecter un champ d'application qui suit le cycle de vie de l'application.
- Encapsulez le code dans
saveTasksToNetwork
avecscope.launch
.
private fun saveTasksToNetwork() {
scope.launch {
val localTasks = localDataSource.observeAll().first()
val networkTasks = withContext(dispatcher) {
localTasks.toNetwork()
}
networkDataSource.saveTasks(networkTasks)
}
}
À présent, saveTasksToNetwork
fonctionne immédiatement, et les tâches sont enregistrées sur le réseau en arrière-plan.
8. Tester le dépôt de tâches
Beaucoup de fonctionnalités ont été ajoutées à votre couche de données. Vérifions désormais que tout fonctionne en créant des tests unitaires pour DefaultTaskRepository
.
Vous devez instancier le sujet testé (DefaultTaskRepository
) avec des dépendances de test pour les sources de données locales et réseau. Pour commencer, vous devez créer ces dépendances.
- Dans la fenêtre de l'explorateur de projets, développez le dossier
(test)
, puis développez le dossiersource.local
et ouvrezFakeTaskDao.kt.
Figure 18. Diagramme illustrant FakeTaskDao.kt
dans la structure de dossiers du projet
- Ajoutez-y ce qui suit :
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)
}
}
Dans une application réelle, vous devriez aussi créer une dépendance fictive pour remplacer TaskNetworkDataSource
(en faisant en sorte que les objets fictifs et les objets réels implémentent une UI). Toutefois, pour les besoins de cet atelier de programmation, vous l'utiliserez directement.
- Dans
DefaultTaskRepositoryTest
, ajoutez ce qui suit.
Une règle qui définit le répartiteur principal à utiliser dans tous les tests |
Quelques données de test |
Les dépendances de test pour les sources de données locales et réseau |
Le sujet testé : |
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
)
}
Parfait ! Vous pouvez maintenant commencer à écrire des tests unitaires. Vous devez tester trois aspects principaux : les lectures, les écritures et la synchronisation des données.
Tester les données exposées
Voici comment vérifier que le dépôt expose correctement les données. Le test suit une structure logique de type Avec, Quand, Alors : Par exemple :
Avec | La source de données locale qui contient des tâches |
Quand | Le flux de tâches est obtenu à partir du dépôt à l'aide d' |
Alors | Le premier élément du flux de tâches correspond à la représentation externe des tâches dans la source de données locale |
- Créez un test nommé
observeAll_exposesLocalData
avec le contenu suivant :
@Test
fun observeAll_exposesLocalData() = runTest {
val tasks = taskRepository.observeAll().first()
assertEquals(localTasks.toExternal(), tasks)
}
Utilisez la fonction first
pour obtenir le premier élément du flux de tâches.
Tester les mises à jour des données
Ensuite, écrivez un test qui vérifie qu'une tâche est créée et enregistrée dans la source de données réseau.
Avec | Une base de données vide |
Quand | Une tâche est créée en appelant |
Alors | La tâche est créée dans les sources de données locales et réseau |
- Créez un test nommé
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))
}
Ensuite, vérifiez que lorsqu'une tâche est terminée, elle est écrite correctement dans la source de données locale et enregistrée dans la source de données réseau.
Avec | La source de données locale qui contient une tâche |
Quand | Cette tâche est terminée en appelant |
Alors | Les données locales et les données réseau sont également mises à jour |
- Créez un test nommé
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)
}
Tester l'actualisation des données
Enfin, vérifiez que l'opération d'actualisation a réussi.
Avec | La source de données réseau qui contient des données |
Quand |
|
Alors | Les données locales sont les mêmes que les données réseau |
- Créez un test nommé
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())
}
Et voilà ! Exécutez les tests. Ils devraient tous réussir.
9. Mettre à jour la couche d'UI
Maintenant que vous savez que la couche de données fonctionne, connectez-la à la couche d'UI.
Mettre à jour le modèle de vue de l'écran de la liste des tâches
Commencez par TasksViewModel
. Il s'agit du modèle de vue qui permet d'afficher le premier écran de l'application, à savoir la liste de toutes les tâches actives.
- Ouvrez cette classe et ajoutez
DefaultTaskRepository
en tant que paramètre de constructeur.
@HiltViewModel
class TasksViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
private val taskRepository: DefaultTaskRepository,
) : ViewModel () { /* ... */ }
- Initialisez la variable
tasksStream
à l'aide du dépôt.
private val tasksStream = taskRepository.observeAll()
Votre modèle de vue a désormais accès à toutes les tâches fournies par le dépôt et recevra une nouvelle liste de tâches chaque fois que les données changent, en une seule ligne de code !
- Il ne reste plus qu'à connecter les actions de l'utilisateur à leurs méthodes correspondantes dans le dépôt. Recherchez la méthode
complete
et mettez-la à jour pour qu'elle se présente comme suit :
fun complete(task: Task, completed: Boolean) {
viewModelScope.launch {
if (completed) {
taskRepository.complete(task.id)
showSnackbarMessage(R.string.task_marked_complete)
} else {
...
}
}
}
- Procédez de la même manière avec
refresh
.
fun refresh() {
_isLoading.value = true
viewModelScope.launch {
taskRepository.refresh()
_isLoading.value = false
}
}
Mettre à jour le modèle de vue de l'écran d'ajout des tâches
- Ouvrez
AddEditTaskViewModel
et ajoutezDefaultTaskRepository
en tant que paramètre de constructeur, comme vous l'avez fait à l'étape précédente.
class AddEditTaskViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val taskRepository: DefaultTaskRepository,
)
- Mettez à jour la méthode
create
comme suit :
private fun createNewTask() = viewModelScope.launch {
taskRepository.create(uiState.value.title, uiState.value.description)
_uiState.update {
it.copy(isTaskSaved = true)
}
}
Exécuter l'application
- Le moment tant attendu est arrivé : vous pouvez désormais exécuter l'application. Un écran indiquant You have no tasks! (Aucune tâche en cours) devrait s'afficher
Figure 19. Capture de l'écran des tâches de l'application lorsqu'il n'y a aucune tâche
- Appuyez sur les trois points en haut à droite, puis appuyez sur Refresh (Actualiser).
Figure 20. Capture de l'écran des tâches de l'application avec affichage du menu d'actions
Une icône de chargement en cours devrait apparaître pendant deux secondes, puis les tâches de test que vous avez ajoutées précédemment devraient s'afficher.
Figure 21. Capture de l'écran des tâches de l'application avec affichage de deux tâches
- Appuyez maintenant sur le signe Plus en bas à droite pour ajouter une tâche. Complétez les champs de titre et de description.
Figure 22. Capture de l'écran d'ajout de tâches dans l'application
- Appuyez sur la coche en bas à droite pour enregistrer la tâche.
Figure 23. Capture de l'écran des tâches de l'application après l'ajout d'une tâche
- Marquez la tâche comme terminée en cochant la case à côté de celle-ci.
Figure 24. Capture de l'écran des tâches de l'application affichant une tâche terminée
10. Félicitations !
Vous avez créé une couche de données pour une application.
La couche de données fait partie intégrante de l'architecture de votre application. Elle est à la base des autres couches que vous pouvez créer. Il est donc essentiel de bien faire les choses pour permettre à votre application de s'adapter aux besoins de vos utilisateurs et de votre entreprise.
Ce que vous avez appris
- Rôle de la couche de données dans l'architecture des applications Android
- Comment créer des sources et modèles de données
- Le rôle des dépôts et la façon dont ils exposent les données et fournissent des méthodes ponctuelles pour mettre à jour les données
- Quand changer le répartiteur de coroutine et pourquoi il est important de le faire
- Synchronisation des données à l'aide de plusieurs sources de données
- Comment créer des tests unitaires et instrumentés pour les classes courantes de la couche de données
Défi supplémentaire
Si vous voulez relever un autre défi, implémentez les fonctionnalités suivantes :
- Réactivez une tâche une fois qu'elle a été marquée comme terminée.
- Modifiez le titre et la description d'une tâche en appuyant dessus.
Aucune instruction n'est fournie. C'est à vous de jouer ! Si vous êtes bloqué, examinez l'application entièrement fonctionnelle sur la branche main
.
git checkout main
Étapes suivantes
Pour en savoir plus sur la couche de données, consultez la documentation officielle et le guide des applications orientées hors connexion. Vous pouvez également vous familiariser avec les autres couches architecturales : la couche d'UI et la couche de domaine.
Pour un exemple réel plus complexe, consultez l'application Now in Android.