Créer une couche de données

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.

Couche de données en tant que couche inférieure sous les couches de domaine et d'UI

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

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.

Écran de la liste des tâches

Écran des détails d'une tâche

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

  1. Pour télécharger le code :

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

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

Fenêtre de l'explorateur de projets Android Studio dans la vue Android

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.

Configuration d'exécution d'Android Studio, appareil cible et bouton de lecture

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.

Application dans son état de départ avec une icône de chargement active

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.

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

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

Task

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

LocalTask

Interne

Tâche stockée dans une base de données locale.

TaskDao

NetworkTask

Interne

Tâche qui a été récupérée à partir d'un serveur réseau.

NetworkTaskDataSource

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 :

Fenêtre de l'explorateur de projets affichant des dossiers et des fichiers

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.

Relation entre le dépôt de tâches, le modèle, la source de données et la base de données

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 dans data/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.

  1. Ouvrez ToDoDatabase.kt et remplacez BlankEntity par LocalTask.
  2. Supprimez BlankEntity et toute instruction import redondante.
  3. 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 au DatabaseModule :
    @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 :

Dossiers "test" et "androidTest" dans l'explorateur de projets

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 ouvrez TaskDaoTest.kt. À l'intérieur, créez une classe vide nommée TaskDaoTest.
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

  1. 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)
}
  1. Pour exécuter le test, cliquez sur le bouton de lecture à côté du test dans la marge.

Bouton de lecture du test dans la marge de l'éditeur de code

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.

Test qui échoue

Figure 12. Capture d'écran illustrant un test qui échoue

  1. Supprimez l'instruction assertEquals existante.
  2. 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])
  1. Exécutez à nouveau le test. Vous devriez voir le test réussir dans la fenêtre des résultats.

Test réussi

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 sous data/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 de description.
  • Le champ isCompleted est représenté sous la forme d'une énumération status, qui a deux valeurs possibles : ACTIVE et COMPLETE.
  • 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ée TaskNetworkDataSource 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.

Dépendances de DefaultTaskRepository

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

  1. Ouvrez DefaultTaskRepository.kt dans le package data, puis créez une classe nommée DefaultTaskRepository, qui utilise TaskDao et TaskNetworkDataSource 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.

  1. Ajoutez une méthode nommée observeAll, qui renvoie un flux de modèles Task avec un Flow.
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.

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

  1. Utilisez la fonction toExternal que vous venez de créer dans observeAll :
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.

  1. Ajoutez une méthode nommée create, qui utilise des éléments title et description 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
  1. 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()
}
  1. 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.

  1. Commencez par ajouter un CoroutineDispatcher comme dépendance de DefaultTaskRepository. Utilisez le qualificatif @DefaultDispatcher déjà créé (et défini dans di/CoroutinesModule.kt) pour indiquer à Hilt d'injecter Dispatchers.Default dans cette dépendance. Le répartiteur Default 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,
)
  1. Maintenant, effectuez l'appel à UUID.randomUUID().toString() dans un bloc withContext.
val taskId = withContext(dispatcher) {
    createTaskId()
}

En savoir plus sur les threads dans la couche de données

Créer et stocker la tâche

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

  1. Ajoutez la fonction d'extension suivante à la fin de LocalTask. Il s'agit de la fonction de mappage inverse de LocalTask.toExternal, que vous avez créée précédemment.
fun Task.toLocal() = LocalTask(
    id = id,
    title = title,
    description = description,
    isCompleted = isCompleted,
)
  1. Utilisez-la dans create pour insérer la tâche dans la source de données locale, puis renvoyez le taskId.
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

observeAll

Charger les données à partir de la base de données locale

Flux de données depuis la source de données locale vers le dépôt de tâchesFigure 15. Diagramme illustrant le flux de données depuis la source de données locale vers le dépôt de tâches

Enregistrer

createcomplete

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

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éseauFigure 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

refresh

1. Charger les données réseau 2. Les copier dans la base de données locale en écrasant tout

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âchesFigure 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.

  1. Tout d'abord, créez des fonctions de mappage de LocalTask à NetworkTask et vice versa dans NetworkTask.kt. Sinon, vous pouvez également placer les fonctions dans 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)

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.

  1. Ajoutez la méthode refresh à la fin de DefaultTaskRepository.
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.

  1. Ajoutez la méthode saveTasksToNetwork à la fin de DefaultTaskRepository.
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.

  1. À présent, mettez à jour les méthodes existantes, qui actualisent les tâches create et complete 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.

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

  1. Encapsulez le code dans saveTasksToNetwork avec scope.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.

  1. Dans la fenêtre de l'explorateur de projets, développez le dossier (test), puis développez le dossier source.local et ouvrez FakeTaskDao.kt.

Fichier FakeTaskDao.kt dans la structure de dossiers du projet

Figure 18. Diagramme illustrant FakeTaskDao.kt dans la structure de dossiers du projet

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

  1. 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é : 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
    )
}

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'observeAll

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 create

Alors

La tâche est créée dans les sources de données locales et réseau

  1. 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 complete

Alors

Les données locales et les données réseau sont également mises à jour

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

refresh est appelé

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.

  1. 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 () { /* ... */ }
  1. 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 !

  1. 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 {
            ...
       }
    }
}
  1. 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

  1. Ouvrez AddEditTaskViewModel et ajoutez DefaultTaskRepository 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,
)
  1. 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

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

Écran des tâches de l'application lorsqu'il n'y a aucune tâche

Figure 19. Capture de l'écran des tâches de l'application lorsqu'il n'y a aucune tâche

  1. Appuyez sur les trois points en haut à droite, puis appuyez sur Refresh (Actualiser).

Écran des tâches de l'application avec affichage du menu d'actions

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.

Écran des tâches de l'application avec affichage de deux tâches

Figure 21. Capture de l'écran des tâches de l'application avec affichage de deux tâches

  1. Appuyez maintenant sur le signe Plus en bas à droite pour ajouter une tâche. Complétez les champs de titre et de description.

Écran d'ajout de tâches dans l'application

Figure 22. Capture de l'écran d'ajout de tâches dans l'application

  1. Appuyez sur la coche en bas à droite pour enregistrer la tâche.

Écran des tâches de l'application après l'ajout d'une tâche

Figure 23. Capture de l'écran des tâches de l'application après l'ajout d'une tâche

  1. Marquez la tâche comme terminée en cochant la case à côté de celle-ci.

Écran des tâches de l'application affichant une tâche terminée

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.