Travailler avec Proto DataStore

1. Introduction

Qu'est-ce que DataStore ?

DataStore est une nouvelle solution de stockage de données améliorée, destinée à remplacer SharedPreferences. Basé sur les coroutines Kotlin et Kotlin Flow, DataStore propose deux implémentations différentes : Proto DataStore, qui vous permet de stocker des objets typés (grâce à des tampons de protocole) et Preferences DataStore, qui stocke des paires clé/valeur. Les données sont stockées de manière asynchrone, cohérente et transactionnelle, permettant ainsi de pallier certains des inconvénients de SharedPreferences.

Points abordés

  • En quoi consiste DataStore et pourquoi l'utiliser
  • Ajouter DataStore à votre projet
  • Différences entre Preferences DataStore et Proto DataStore, et leurs avantages respectifs
  • Utiliser Proto DataStore
  • Migrer de SharedPreferences vers Proto DataStore

Objectif de cet atelier

Dans cet atelier de programmation, vous allez commencer avec un exemple d'application affichant une liste de tâches pouvant être filtrées par état d'avancement et triées par priorité et par date d'échéance.

fcb2ffa4e6b77f33.gif

L'indicateur booléen du filtre Show completed tasks (Afficher les tâches effectuées) est enregistré en mémoire. L'ordre de tri est conservé sur le disque à l'aide d'un objet SharedPreferences.

Étant donné qu'il existe deux implémentations différentes pour DataStore, Preferences DataStore et Proto DataStore, vous apprendrez à utiliser Proto DataStore en effectuant les tâches suivantes dans chaque implémentation :

  • Conserver le filtre d'état d'avancement dans DataStore
  • Migrer l'ordre de tri de SharedPreferences vers DataStore

Nous vous recommandons de suivre également l'atelier de programmation Preferences DataStore afin de mieux comprendre la différence entre les deux.

Prérequis

Pour une présentation des composants d'architecture, consultez notre atelier de programmation dans Room. Pour plus d'informations sur Flow, consultez l'atelier de programmation Coroutines avancées avec Kotlin Flow et LiveData.

2. Configuration

Au cours de cette étape, vous téléchargerez l'intégralité du code de cet atelier de programmation, puis exécuterez un exemple d'application simple.

Pour vous aider à démarrer le plus rapidement possible, nous avons préparé un projet de démarrage.

Si git est installé, vous pouvez simplement exécuter la commande ci-dessous. Pour vérifier si git est installé, saisissez git --version dans le terminal ou la ligne de commande, et vérifiez qu'il fonctionne correctement.

 git clone https://github.com/googlecodelabs/android-datastore

L'état initial se trouve dans la branche master. Le code de la solution se trouve dans la branche proto_datastore.

Si vous n'avez pas git, vous pouvez cliquer sur le bouton suivant pour télécharger tout le code de cet atelier de programmation :

Télécharger le code source

  1. Décompressez le code, puis ouvrez le projet dans Android Studio Arctic Fox.
  2. Exécutez la configuration d'exécution de l'application sur un appareil ou un émulateur.

b3c0dfdb92dfed77.png

L'application s'exécute et affiche la liste des tâches :

d3972939a2de88ba.png

3. Présentation du projet

L'application vous permet d'afficher une liste de tâches. Chaque tâche possède les propriétés suivantes : nom, état d'exécution, priorité et date d'échéance.

Pour simplifier le code que nous devons utiliser, l'application vous permet d'effectuer ces deux actions uniquement :

  • Activer/désactiver l'option Show completed tasks (Afficher les tâches exécutées). Les tâches sont masquées par défaut.
  • Trier les tâches par ordre de priorité, par date d'échéance, ou par date d'échéance et ordre de priorité

L'application suit l'architecture recommandée dans le Guide de l'architecture des applications. Voici le contenu de chaque package :

data

  • La classe de modèle Task.
  • La classe TasksRepository, chargée de fournir les tâches. Pour souci de simplicité, cette classe renvoie des données codées en dur et les affiche via un Flow pour représenter un scénario plus réaliste.
  • La classe UserPreferencesRepository : contient SortOrder, défini en tant que enum. L'ordre de tri actuel est enregistré dans SharedPreferences en tant que String, selon le nom de la valeur d'énumération. Elle propose des méthodes synchrones pour enregistrer et obtenir l'ordre de tri.

ui

  • Classes associées à l'affichage d'une Activity avec un RecyclerView
  • La classe TasksViewModel est responsable de la logique de l'interface utilisateur

TasksViewModel contient tous les éléments nécessaires à la création des données à afficher dans l'interface utilisateur : liste des tâches, indicateurs showCompleted et sortOrder, le tout encapsulé dans un objet TasksUiModel. Chaque fois que l'une de ces valeurs change, il nous faut créer un nouveau TasksUiModel. Pour cela, nous combinons trois éléments :

  • Un élément Flow<List<Task>>, récupéré à partir de TasksRepository
  • Un élément MutableStateFlow<Boolean>, contenant le dernier indicateur showCompleted, qui n'est conservé que dans la mémoire.
  • Un élément MutableStateFlow<SortOrder>, contenant la dernière valeur sortOrder.

Pour nous assurer que nous effectuons correctement la mise à jour de l'interface utilisateur, uniquement lorsque l'activité est lancée, nous affichons un LiveData<TasksUiModel>.

Nous rencontrons quelques problèmes avec notre code :

  • Nous bloquons le thread UI sur les opérations d'E/S du disque lors de l'initialisation de UserPreferencesRepository.sortOrder. Cela peut entraîner des à-coups dans l'interface utilisateur.
  • L'indicateur showCompleted n'est conservé que dans la mémoire. Cela signifie qu'il est réinitialisé chaque fois que l'utilisateur ouvre l'application. À l'instar de SortOrder, il faut conserver cet élément pour qu'il survive à la fermeture de l'application.
  • Nous utilisons actuellement SharedPreferences pour conserver des données, mais nous gardons un MutableStateFlow dans la mémoire, que nous modifions manuellement afin d'être avertis des changements. Cela peut rapidement poser problème si la valeur est modifiée ailleurs dans l'application.
  • Dans UserPreferencesRepository, nous présentons deux méthodes permettant de mettre à jour l'ordre de tri : enableSortByDeadline() et enableSortByPriority(). Ces deux méthodes reposent sur la valeur actuelle de l'ordre de tri, mais si l'une est appelée avant la fin de l'autre, nous obtenons une valeur finale incorrecte. En particulier, ces méthodes peuvent générer des à-coups dans l'interface utilisateur ou des violations du mode strict, car elles sont appelées dans le thread UI.

Bien que les indicateurs showCompleted et sortOrder soient des préférences utilisateur, ils sont actuellement représentés comme deux objets distincts. L'un de nos objectifs consiste donc à regrouper ces deux indicateurs en une même classe UserPreferences.

Découvrons comment utiliser DataStore pour résoudre ces problèmes.

4. DataStore : principes de base

Vous aurez peut-être souvent besoin de stocker des ensembles de données simples ou petits. Vous avez peut-être déjà utilisé SharedPreferences dans le passé, mais cette API comporte également un certain nombre d'inconvénients. La bibliothèque Jetpack DataStore vise à résoudre ces problèmes en créant une API simple, plus sécurisée et asynchrone pour le stockage des données. Elle propose deux implémentations différentes :

  • Preferences DataStore
  • Proto DataStore

Fonctionnalité

SharedPreferences

Preferences DataStore

Proto DataStore

API Async

✅ (uniquement pour la lecture de valeurs modifiées, via l'écouteur)

✅ (via Flow, et RxJava 2 et 3 Flowable)

✅ (via Flow, et RxJava 2 et 3 Flowable)

API synchrone

✅ (mais appel non sécurisé via un thread UI)

Appel sécurisé via un thread UI

❌(1)

✅ (le travail est déplacé vers Dispatchers.IO en arrière-plan).

✅ (le travail est déplacé vers Dispatchers.IO en arrière-plan).

Peut signaler des erreurs

Protection contre les exceptions d'exécution

❌(2)

Comporte une API transactionnelle dotée d'une garantie de cohérence forte

Gestion de la migration des données

Sécurité du type

✅ avec tampons de protocole

(1) SharedPreferences dispose d'une API synchrone dont l'appel sur le thread UI peut sembler sûr, mais qui effectue en réalité des opérations d'E/S sur le disque. De plus, apply() bloque le thread UI sur fsync(). Les appels fsync() en attente sont déclenchés chaque fois qu'un service démarre ou s'arrête, et chaque fois qu'une activité démarre ou s'arrête quelque part dans votre application. Le thread UI est bloqué pour les appels fsync() en attente planifiés par apply(), et deviennent souvent une source d'ANR.

(2) SharedPreferences génère des erreurs d'analyse sous la forme d'exceptions d'exécution.

Preferences DataStore et Proto DataStore

Bien que Preferences DataStore et Proto DataStore permettent tous deux d'enregistrer des données, ils ne procèdent pas de la même manière :

  • Comme SharedPreferences, Preference DataStore accède aux données en fonction de clés, sans définir de schéma au préalable.
  • Proto DataStore définit le schéma à l'aide de tampons de protocole. L'utilisation de tampons de protocole permet de conserver des données très typées. Ils sont plus rapides, plus petits, plus simples et moins ambigus que le format XML et autres formats de données similaires. Bien que Proto DataStore nécessite d'apprendre un nouveau mécanisme de sérialisation, nous pensons que l'avantage offert par Proto DataStore quant aux données très typées en vaut la peine.

Room et DataStore

Si vous avez besoin d'effectuer des mises à jour partielles, d'assurer l'intégrité des référentiels ou de disposer d'ensembles de données volumineux ou complexes, nous vous recommandons d'utiliser Room au lieu de DataStore. DataStore est idéal pour les ensembles de données simples ou petits, et n'accepte pas les mises à jour partielles ni l'intégrité des référentiels.

5. Proto DataStore : présentation

L'un des inconvénients de SharedPreferences et de Preferences DataStore est qu'il n'existe aucun moyen de définir un schéma ni de garantir l'accessibilité aux clés avec le type approprié. Proto DataStore résout ce problème en utilisant des tampons de protocole pour définir le schéma. L'utilisation de tampons de protocole permet à DataStore de savoir quels types sont stockés et de les fournir simplement, ce qui évite d'avoir à utiliser des clés.

Voyons maintenant ce que sont les tampons de protocole, comment ajouter Proto DataStore et des tampons de protocole au projet, comment utiliser les tampons de protocole avec Proto DataStore et comment migrer SharedPreferences vers DataStore.

Ajouter des dépendances

Pour travailler avec Proto DataStore et faire en sorte qu'un tampon de protocole génère du code pour notre schéma, nous allons apporter plusieurs modifications au fichier build.gradle :

  • Ajouter le plug-in des tampons de protocole
  • Ajouter les dépendances des tampons de protocole et de Proto DataStore
  • Configurer les tampons de protocole
plugins {
    ...
    id "com.google.protobuf" version "0.8.17"
}

dependencies {
    implementation  "androidx.datastore:datastore-core:1.0.0"
    implementation  "com.google.protobuf:protobuf-javalite:3.18.0"
    ...
}

protobuf {
    protoc {
        artifact = "com.google.protobuf:protoc:3.14.0"
    }

    // Generates the java Protobuf-lite code for the Protobufs in this project. See
    // https://github.com/google/protobuf-gradle-plugin#customizing-protobuf-compilation
    // for more information.
    generateProtoTasks {
        all().each { task ->
            task.builtins {
                java {
                    option 'lite'
                }
            }
        }
    }
}

6. Définir et utiliser des objets tampons de protocoles

Les tampons de protocole sont un mécanisme de sérialisation des données structurées. Vous définissez la manière dont vous souhaitez que vos données soient structurées une seule fois, puis le compilateur génère du code source pour écrire et lire facilement les données structurées.

Créer le fichier .proto

Vous définissez votre schéma dans un fichier .proto. Dans notre atelier de programmation, nous avons deux préférences utilisateur, show_completed et sort_order, qui sont actuellement représentés comme deux objets différents. L'un de nos objectifs consiste donc à regrouper ces deux indicateurs en une même classe UserPreferences stockée dans DataStore. Au lieu de définir cette classe dans Kotlin, nous allons la définir dans un schéma de tampons de protocole.

Consultez le Guide du langage proto pour obtenir des informations détaillées sur la syntaxe. Dans cet atelier de programmation, nous n'aborderons que les types dont nous aurons besoin.

Créez un fichier nommé user_prefs.proto dans le répertoire app/src/main/proto. Si vous ne voyez pas cette structure de dossiers, passez en vue Projet. Dans les tampons de protocole, chaque structure est définie à l'aide d'un mot clé message, et chaque membre de la structure est défini dans le message en fonction du type et du nom. Chaque structure se voit attribuer un ordre basé sur 1. Définissons maintenant un message UserPreferences qui, pour l'instant, comporte simplement une valeur booléenne appelée show_completed.

syntax = "proto3";

option java_package = "com.codelab.android.datastore";
option java_multiple_files = true;

message UserPreferences {
  // filter for showing / hiding completed tasks
  bool show_completed = 1;
}

Créer le sérialiseur

Pour indiquer à DataStore comment lire et écrire le type de données défini dans le fichier .proto, nous devons mettre en place un sérialisateur. Le sérialiseur définit également la valeur par défaut à afficher en l'absence d'informations sur le disque. Créez un fichier nommé UserPreferencesSerializer dans le package data :

object UserPreferencesSerializer : Serializer<UserPreferences> {
    override val defaultValue: UserPreferences = UserPreferences.getDefaultInstance()
    override suspend fun readFrom(input: InputStream): UserPreferences {
        try {
            return UserPreferences.parseFrom(input)
        } catch (exception: InvalidProtocolBufferException) {
            throw CorruptionException("Cannot read proto.", exception)
        }
    }

    override suspend fun writeTo(t: UserPreferences, output: OutputStream) = t.writeTo(output)
}

7. Conserver des données dans Proto DataStore

Créer le DataStore

L'indicateur showCompleted est conservé en mémoire, dans TasksViewModel, mais devrait être stocké dans UserPreferencesRepository, dans une instance DataStore.

Pour créer une instance DataStore, nous utilisons le délégué dataStore, avec le Context comme destinataire. Ce délégué comporte deux paramètres obligatoires :

  • Le nom du fichier sur lequel DataStore agira
  • Le sérialiseur pour le type utilisé avec DataStore (dans notre cas : UserPreferencesSerializer)

Dans cet atelier de programmation, nous allons réaliser cette opération dans TasksActivity, par souci de simplicité :

private const val USER_PREFERENCES_NAME = "user_preferences"
private const val DATA_STORE_FILE_NAME = "user_prefs.pb"
private const val SORT_ORDER_KEY = "sort_order"

private val Context.userPreferencesStore: DataStore<UserPreferences> by dataStore(
    fileName = DATA_STORE_FILE_NAME,
    serializer = UserPreferencesSerializer
)

Le délégué dataStore permet de s'assurer que nous n'avons qu'une seule instance de DataStore portant ce nom dans notre application. Actuellement, UserPreferencesRepository est implémenté comme Singleton, car il contient sortOrderFlow et évite qu'il ne soit lié au cycle de vie de TasksActivity. Comme UserPreferenceRepository n'utilisera que les données de DataStore, et ne créera pas d'objets ni n'en contiendra de nouveaux, nous pouvons dès à présent supprimer l'implémentation du Singleton :

  • Supprimez companion object.
  • Rendez constructor public.

UserPreferencesRepository devrait recevoir une instance DataStore comme paramètre de constructeur. Pour le moment, nous pouvons laisser Context comme paramètre, car SharedPreferences en a besoin. Toutefois, nous le supprimerons plus tard.

class UserPreferencesRepository(
    private val userPreferencesStore: DataStore<UserPreferences>,
    context: Context
) { ... }

Mettons à jour la construction de UserPreferencesRepository dans TasksActivity et transmettons dataStore :

viewModel = ViewModelProvider(
    this,
    TasksViewModelFactory(
        TasksRepository,
        UserPreferencesRepository(dataStore, this)
    )
).get(TasksViewModel::class.java)

Lire des données depuis Proto DataStore

Proto DataStore expose les données stockées dans un Flow<UserPreferences>. Nous allons maintenant créer une valeur publique userPreferencesFlow: Flow<UserPreferences> qui se voit attribuer dataStore.data :

val userPreferencesFlow: Flow<UserPreferences> = dataStore.data

Gérer les exceptions lors de la lecture des données

Des exceptions IOException sont générées lorsqu'une erreur se produit au cours de la lecture des données à partir d'un fichier par DataStore. Pour résoudre ces problèmes, utilisez la transformation Flow catch et consignez l'erreur :

private val TAG: String = "UserPreferencesRepo"

val userPreferencesFlow: Flow<UserPreferences> = dataStore.data
    .catch { exception ->
        // dataStore.data throws an IOException when an error is encountered when reading data
        if (exception is IOException) {
            Log.e(TAG, "Error reading sort order preferences.", exception)
            emit(UserPreferences.getDefaultInstance())
        } else {
            throw exception
        }
    }

Écrire des données dans Proto DataStore

Pour écrire des données, DataStore propose une fonction DataStore.updateData() de suspension, où nous obtenons l'état actuel des UserPreferences en tant que paramètre. Pour le mettre à jour, nous devons transformer l'objet des préférences en compilateur, définir la nouvelle valeur, puis définir les nouvelles préférences.

updateData() met à jour les données de manière transactionnelle en une opération atomique de lecture-écriture-modification. La coroutine se termine une fois que les données sont conservées sur le disque.

Créons à présent une fonction de suspension permettant de mettre à jour la propriété showCompleted de UserPreferences, appelée updateShowCompleted(), qui appelle dataStore.updateData() et définit la nouvelle valeur :

suspend fun updateShowCompleted(completed: Boolean) {
    dataStore.updateData { preferences ->
        preferences.toBuilder().setShowCompleted(completed).build()
    }
}

À ce stade, l'application doit compiler, mais la fonctionnalité que nous venons de créer dans UserPreferencesRepository n'est pas utilisée.

8. SharedPreferences vers Proto DataStore

Définir les données à enregistrer dans le fichier .proto

L'ordre de tri est enregistré dans SharedPreferences. Transférons-le vers DataStore. Pour ce faire, commençons par mettre à jour les UserPreferences dans le fichier .proto pour stocker également l'ordre de tri. L'indicateur SortOrder étant un enum, nous devons le définir dans nos UserPreference. Les enums sont définis dans des tampons de protocoles, de la même façon qu'avec Kotlin.

Pour les énumérations, la valeur par défaut est la première valeur répertoriée dans la définition de type de l'énumération. Toutefois, lors de la migration à partir de SharedPreferences, nous devons savoir si la valeur que nous avons obtenue est celle par défaut ou bien celle définie précédemment dans SharedPreferences. Pour ce faire, nous définissons une nouvelle valeur dans notre énumération SortOrder, UNSPECIFIED, et la répertorions en premier pour qu'elle puisse devenir la valeur par défaut.

Le fichier user_prefs.proto doit se présenter comme suit :

syntax = "proto3";

option java_package = "com.codelab.android.datastore";
option java_multiple_files = true;

message UserPreferences {
  // filter for showing / hiding completed tasks
  bool show_completed = 1;

  // defines tasks sorting order: no order, by deadline, by priority, by deadline and priority
  enum SortOrder {
    UNSPECIFIED = 0;
    NONE = 1;
    BY_DEADLINE = 2;
    BY_PRIORITY = 3;
    BY_DEADLINE_AND_PRIORITY = 4;
  }

  // user selected tasks sorting order
  SortOrder sort_order = 2;
}

Nettoyez et recréez votre projet pour vous assurer qu'un nouvel objet UserPreferences a bien été généré et qu'il contient le nouveau champ.

Maintenant que SortOrder est défini dans le fichier .proto, nous pouvons supprimer la déclaration dans UserPreferencesRepository. Supprimez ce code :

enum class SortOrder {
    NONE,
    BY_DEADLINE,
    BY_PRIORITY,
    BY_DEADLINE_AND_PRIORITY
}

Assurez-vous que la bonne importation SortOrder est utilisée partout :

import com.codelab.android.datastore.UserPreferences.SortOrder

Dans la fonction TasksViewModel.filterSortTasks(), nous faisons différentes actions en fonction du type de SortOrder. Maintenant que nous avons également ajouté l'option UNSPECIFIED, nous devons ajouter un autre cas pour l'instruction when(sortOrder). Étant donné que nous ne souhaitons pas gérer d'autres options au-delà de celles que nous gérons déjà actuellement, nous pouvons simplement générer une UnsupportedOperationException dans d'autres cas.

Notre fonction filterSortTasks() ressemble maintenant à ceci :

private fun filterSortTasks(
    tasks: List<Task>,
    showCompleted: Boolean,
    sortOrder: SortOrder
): List<Task> {
    // filter the tasks
    val filteredTasks = if (showCompleted) {
        tasks
    } else {
        tasks.filter { !it.completed }
    }
    // sort the tasks
    return when (sortOrder) {
        SortOrder.UNSPECIFIED -> filteredTasks
        SortOrder.NONE -> filteredTasks
        SortOrder.BY_DEADLINE -> filteredTasks.sortedByDescending { it.deadline }
        SortOrder.BY_PRIORITY -> filteredTasks.sortedBy { it.priority }
        SortOrder.BY_DEADLINE_AND_PRIORITY -> filteredTasks.sortedWith(
            compareByDescending<Task> { it.deadline }.thenBy { it.priority }
        )
        // We shouldn't get any other values
        else -> throw UnsupportedOperationException("$sortOrder not supported")
    }
}

Migrer à partir de SharedPreferences

Pour faciliter la migration, DataStore définit la classe SharedPreferencesMigration. La méthode by dataStore qui crée DataStore (utilisée dans TasksActivity) affiche également un paramètre produceMigrations. Dans ce bloc, nous créons la liste des DataMigration qui doivent s'exécuter pour cette instance DataStore. Dans notre cas, nous n'avons qu'une seule migration : SharedPreferencesMigration.

Lors de l'implémentation de SharedPreferencesMigration, le bloc migrate nous donne deux paramètres :

  • SharedPreferencesView, qui nous permet de récupérer les données de SharedPreferences
  • Les données actuelles UserPreferences

Nous devrons renvoyer un objet UserPreferences.

Lors de l'intégration du bloc migrate, nous devrons procéder comme suit :

  1. Vérifier la valeur de sortOrder dans UserPreferences.
  2. Si la valeur est SortOrder.UNSPECIFIED, cela signifie que nous devons récupérer la valeur à partir de SharedPreferences. Si l'élément SortOrder est manquant, nous pouvons utiliser la valeur SortOrder.NONE par défaut.
  3. Une fois l'ordre de tri obtenu, nous devons convertir l'objet UserPreferences en compilateur, définir l'ordre de tri, puis recréer l'objet en appelant build(). Aucun autre champ ne sera affecté par cette modification.
  4. Si la valeur sortOrder de UserPreferences n'est pas SortOrder.UNSPECIFIED, nous pouvons simplement renvoyer les données actuelles figurant dans migrate, car la migration a déjà été effectuée.
private val Context.userPreferencesStore: DataStore<UserPreferences> by dataStore(
    fileName = DATA_STORE_FILE_NAME,
    serializer = UserPreferencesSerializer,
    produceMigrations = { context ->
        listOf(
            SharedPreferencesMigration(
                context,
                USER_PREFERENCES_NAME
            ) { sharedPrefs: SharedPreferencesView, currentData: UserPreferences ->
                // Define the mapping from SharedPreferences to UserPreferences
                if (currentData.sortOrder == SortOrder.UNSPECIFIED) {
                    currentData.toBuilder().setSortOrder(
                        SortOrder.valueOf(
                            sharedPrefs.getString(SORT_ORDER_KEY, SortOrder.NONE.name)!!
                        )
                    ).build()
                } else {
                    currentData
                }
            }
        )
    }
)

Maintenant que nous avons défini la logique de migration, nous devons indiquer à DataStore qu'il doit l'utiliser. Pour ce faire, mettez à jour le compilateur DataStore et attribuez au paramètre migrations une nouvelle liste contenant une instance de notre SharedPreferencesMigration :

private val dataStore: DataStore<UserPreferences> = context.createDataStore(
    fileName = "user_prefs.pb",
    serializer = UserPreferencesSerializer,
    migrations = listOf(sharedPrefsMigration)
)

Enregistrer l'ordre de tri dans DataStore

Pour mettre à jour l'ordre de tri lorsque enableSortByDeadline() et enableSortByPriority() sont appelés, procédez comme suit :

  • Appelez leurs fonctionnalités respectives dans le lambda de dataStore.updateData().
  • updateData() étant une fonction de suspension, il faut également faire de enableSortByDeadline() et enableSortByPriority() des fonctions de suspension.
  • Utilisez les UserPreferences actuelles reçues à partir de updateData() pour créer le nouvel ordre de tri.
  • Mettez à jour les UserPreferences en les convertissant en compilateur, en définissant le nouvel ordre de tri, puis en créant à nouveau les préférences.

Voici à quoi ressemble l'implémentation de enableSortByDeadline(). Vous pourrez effectuer vous-même les modifications de enableSortByPriority().

suspend fun enableSortByDeadline(enable: Boolean) {
    // updateData handles data transactionally, ensuring that if the sort is updated at the same
    // time from another thread, we won't have conflicts
    dataStore.updateData { preferences ->
        val currentOrder = preferences.sortOrder
        val newSortOrder =
            if (enable) {
                if (currentOrder == SortOrder.BY_PRIORITY) {
                    SortOrder.BY_DEADLINE_AND_PRIORITY
                } else {
                    SortOrder.BY_DEADLINE
                }
            } else {
                if (currentOrder == SortOrder.BY_DEADLINE_AND_PRIORITY) {
                    SortOrder.BY_PRIORITY
                } else {
                    SortOrder.NONE
                }
            }
        preferences.toBuilder().setSortOrder(newSortOrder).build()
    }
}

Vous pouvez à présent supprimer le paramètre de constructeur context ainsi que tous les usages de SharedPreferences.

9. Mettre à jour TasksViewModel pour utiliser UserPreferencesRepository

Maintenant que UserPreferencesRepository stocke à la fois les indicateurs show_completed et sort_order dans DataStore, et qu'il affiche Flow<UserPreferences>, mettons à jour le champ TasksViewModel pour les utiliser.

Supprimons showCompletedFlow et sortOrderFlow, et à la place créons une valeur appelée userPreferencesFlow qui est initialisée avec userPreferencesRepository.userPreferencesFlow :

private val userPreferencesFlow = userPreferencesRepository.userPreferencesFlow

Lors de la création de tasksUiModelFlow, remplaçons showCompletedFlow et sortOrderFlow par userPreferencesFlow. Remplaçons les paramètres en conséquence.

Lors de l'appel de filterSortTasks, transférons showCompleted et sortOrder de userPreferences. Le code doit se présenter comme suit :

private val tasksUiModelFlow = combine(
        repository.tasks,
        userPreferencesFlow
    ) { tasks: List<Task>, userPreferences: UserPreferences ->
        return@combine TasksUiModel(
            tasks = filterSortTasks(
                tasks,
                userPreferences.showCompleted,
                userPreferences.sortOrder
            ),
            showCompleted = userPreferences.showCompleted,
            sortOrder = userPreferences.sortOrder
        )
    }

La fonction showCompletedTasks() doit à présent être mise à jour pour appeler userPreferencesRepository.updateShowCompleted(). Comme il s'agit d'une fonction de suspension, créons une coroutine dans viewModelScope :

fun showCompletedTasks(show: Boolean) {
    viewModelScope.launch {
        userPreferencesRepository.updateShowCompleted(show)
    }
}

Les fonctions userPreferencesRepository, enableSortByDeadline() et enableSortByPriority() sont maintenant des fonctions de suspension. Elles doivent donc aussi être appelées dans une nouvelle coroutine, lancée dans viewModelScope :

fun enableSortByDeadline(enable: Boolean) {
    viewModelScope.launch {
       userPreferencesRepository.enableSortByDeadline(enable)
    }
}

fun enableSortByPriority(enable: Boolean) {
    viewModelScope.launch {
        userPreferencesRepository.enableSortByPriority(enable)
    }
}

Nettoyer UserPreferencesRepository

Supprimez les champs et les méthodes dont vous n'avez plus besoin. Vous devriez pouvoir supprimer les éléments suivants :

  • _sortOrderFlow
  • sortOrderFlow
  • updateSortOrder()
  • private val sortOrder: SortOrder
  • private val sharedPreferences

L'application devrait maintenant se compiler correctement. Exécutons-la pour voir si les indicateurs show_completed et sort_order sont correctement enregistrés.

Consultez la branche proto_datastore du dépôt de l'atelier de programmation pour comparer vos modifications.

10. Conclusion

Maintenant que vous êtes passé à Proto DataStore, récapitulons ce que nous avons appris :

  • SharedPreferences présente divers inconvénients : une API synchrone qui semble pouvoir être appelée en toute sécurité sur le thread UI, l'absence de mécanisme de signalement d'erreur, l'absence d'API transactionnelle, etc.
  • DataStore remplace SharedPreferences et corrige la plupart des défauts de l'API.
  • DataStore dispose d'une API totalement asynchrone basée sur des coroutines Kotlin et Kotlin Flow, gère la migration et la corruption des données, et garantit la cohérence des données.