Utiliser Preferences DataStore

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 stocke 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 Preferences DataStore
  • Migrer de SharedPreferences vers Preferences 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.

Dans cet atelier de programmation, vous allez apprendre à utiliser Preferences DataStore en effectuant les tâches suivantes :

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

Nous vous recommandons de suivre également l'atelier de programmation Proto 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 sur Room. Pour plus d'informations sur Flow, consultez l'atelier de programmation Coroutines avancées avec Kotlin Flow et LiveData.

Au cours de cette étape, vous serez amené à télécharger le code pour l'intégralité de l'atelier de programmation, puis à exécuter 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 preferences_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 version 3.6 ou ultérieure.
  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 :

16eb4ceb800bf131.png

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

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

  • Activer/désactiver l'affichage de la liste des 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 d'ordre de tri et d'affichage des tâches exécutées, 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 d'affichage des tâches exécutées, 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 d'affichage des tâches exécutées 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 sur 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 d'affichage des tâches exécutées et d'ordre de tri 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.

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

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

PreferencesDataStore

ProtoDataStore

API Async

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

✅ (via Flow)

✅ (via Flow)

API synchrone

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

Appel sécurisé via un thread UI

❌*

✅ (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

❌**

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

Gestion de la migration des données

✅ (depuis SharedPreferences)

✅ (depuis SharedPreferences)

Sécurité du type

✅ avec tampons de protocole

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

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

L'API Preferences DataStore est semblable à SharedPreferences, à quelques différences notables :

  • Elle gère les mises à jour de données de manière transactionnelle.
  • Elle présente un flux représentant l'état actuel des données.
  • Elle ne contient pas de méthodes de conservation des données (apply(), commit()).
  • Elle ne renvoie pas de références modifiables à son état interne.
  • Elle présente une API semblable à Map et MutableMap à l'aide de clés typées.

Voyons comment l'ajouter au projet et effectuer la migration de SharedPreferences vers DataStore.

Ajouter des dépendances

Mettez à jour le fichier build.gradle pour ajouter la dépendance Preferences DataStore suivante :

implementation "androidx.datastore:datastore-preferences:1.0.0-alpha06"

Bien que les indicateurs d'affichage des tâches exécutées et d'ordre de tri 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 et à les stocker dans UserPreferencesRepository à l'aide de DataStore. Pour le moment, l'indicateur d'affichage des tâches exécutées est conservé dans la mémoire, dans TasksViewModel.

Commençons par créer une classe de données UserPreferences dans UserPreferencesRepository. Pour l'instant, elle ne doit comporter qu'un seul champ : showCompleted. Nous ajouterons l'ordre de tri plus tard.

data class UserPreferences(val showCompleted: Boolean)

Créer le DataStore

Créons un champ privé DataStore<Preferences> dans UserPreferencesRepository, en utilisant la méthode context.createDataStoreFactory(). Le paramètre obligatoire est le nom du Preferences DataStore.

private val dataStore: DataStore<Preferences> =
        context.createDataStore(name = "user")

Lire des données de Preferences DataStore

Preferences DataStore présente les données stockées dans Flow<Preferences>, qui sera émis chaque fois qu'une préférence aura été modifiée. Nous ne voulons pas présenter l'intégralité de l'objet Preferences, mais plutôt l'objet UserPreferences. Pour ce faire, nous devons mapper Flow<Preferences>, obtenir la valeur booléenne qui nous intéresse, en fonction d'une clé, et construire un objet UserPreferences.

La première chose à faire consiste à définir la clé show completed, c'est-à-dire une valeur booleanPreferencesKey que nous pouvons déclarer comme membre dans un objet PreferencesKeys privé.

private object PreferencesKeys {
  val SHOW_COMPLETED = booleanPreferencesKey("show_completed")
}

Affichons userPreferencesFlow: Flow<UserPreferences>, construit à partir de dataStore.data: Flow<Preferences>, puis mappons-le pour récupérer la préférence appropriée :

val userPreferencesFlow: Flow<UserPreferences> = dataStore.data
    .map { preferences ->
        // Get our show completed value, defaulting to false if not set:
        val showCompleted = preferences[PreferencesKeys.SHOW_COMPLETED]?: false
        UserPreferences(showCompleted)
    }

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

Des IOExceptions 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 ce problème, nous pouvons utiliser l'opérateur Flow catch() avant map() et émettre emptyPreferences() si l'exception renvoyée était de type IOException. Si un autre type d'exception a été renvoyé, il est préférable de le renvoyer.

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) {
            emit(emptyPreferences())
        } else {
            throw exception
        }
    }.map { preferences ->
        // Get our show completed value, defaulting to false if not set:
        val showCompleted = preferences[PreferencesKeys.SHOW_COMPLETED]?: false
        UserPreferences(showCompleted)
    }

Écrire des données dans Preferences DataStore

Pour écrire des données, DataStore propose une fonction DataStore.edit(transform: suspend (MutablePreferences) -> Unit) de suspension, qui accepte un bloc transform permettant de mettre à jour l'état de manière transactionnelle dans DataStore.

L'élément MutablePreferences transmis au bloc de transformation sera mis à jour avec toutes les modifications précédemment exécutées. Toutes les modifications apportées à MutablePreferences dans le bloc transform seront appliquées au disque une fois l'opération transform terminée et avant la fin de l'opération edit. Si vous définissez une valeur dans MutablePreferences, aucune des autres préférences ne change.

Remarque : N'essayez pas de modifier MutablePreferences en dehors du bloc de transformation.

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

suspend fun updateShowCompleted(showCompleted: Boolean) {
    dataStore.edit { preferences ->
        preferences[PreferencesKeys.SHOW_COMPLETED] = showCompleted
    }
}

edit() peut renvoyer une erreur IOException si une erreur s'est produite lors de la lecture ou de l'écriture sur le disque. Si une autre erreur se produit dans le bloc de transformation, elle sera renvoyée par edit().

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

L'ordre de tri est enregistré dans SharedPreferences. Transférons-le vers DataStore. Pour ce faire, commençons par mettre à jour UserPreferences afin de stocker également l'ordre de tri :

data class UserPreferences(
    val showCompleted: Boolean,
    val sortOrder: SortOrder
)

Migrer à partir de SharedPreferences

Pour pouvoir effectuer la migration vers DataStore, nous devons mettre à jour le compilateur de DataStore afin qu'il transmette SharedPreferencesMigration à la liste des migrations. La migration de SharedPreferences vers DataStore pourra ainsi se faire automatiquement. Les migrations sont exécutées avant tout accès aux données dans DataStore. Cela signifie que la migration doit avoir réussi pour que DataStore.data puisse émettre des valeurs et que DataStore.edit() puisse mettre à jour les données.

Remarque : Les clés ne sont transférées qu'une seule fois à partir de SharedPreferences. Vous devez donc cesser d'utiliser les anciennes préférences SharedPreferences une fois la migration du code vers DataStore terminée.

private val dataStore: DataStore<Preferences> =
    context.createDataStore(
        name = USER_PREFERENCES_NAME,
        migrations = listOf(SharedPreferencesMigration(context, USER_PREFERENCES_NAME))
    )

private object PreferencesKeys {
    ...
    // Note: this has the the same name that we used with SharedPreferences.
    val SORT_ORDER = stringPreferencesKey("sort_order")
}

Toutes les clés seront transférées vers DataStore et supprimées des préférences SharedPreferences de l'utilisateur. Depuis Preferences, nous pouvons désormais obtenir et mettre à jour SortOrder en fonction de la clé SORT_ORDER.

Lire l'ordre de tri à partir de DataStore

Mettons à présent à jour userPreferencesFlow pour récupérer également l'ordre de tri dans la transformation map() :

val userPreferencesFlow: Flow<UserPreferences> = dataStore.data
    .catch { exception ->
        if (exception is IOException) {
            emit(emptyPreferences())
        } else {
            throw exception
        }
    }.map { preferences ->
        // Get the sort order from preferences and convert it to a [SortOrder] object
        val sortOrder =
            SortOrder.valueOf(
                preferences[PreferencesKeys.SORT_ORDER] ?: SortOrder.NONE.name)

        // Get our show completed value, defaulting to false if not set:
        val showCompleted = preferences[PreferencesKeys.SHOW_COMPLETED] ?: false
        UserPreferences(showCompleted, sortOrder)
    }

Enregistrer l'ordre de tri dans DataStore

Actuellement, UserPreferencesRepository ne propose qu'une méthode synchrone pour définir l'indicateur d'ordre de tri et présente un problème de simultanéité. Nous avons deux méthodes pour mettre à jour l'ordre de tri : enableSortByDeadline() et enableSortByPriority(). Ces deux méthodes reposent sur la valeur actuelle de l'ordre de tri. Toutefois, si l'une est appelée avant que l'autre ne se termine, la valeur finale est incorrecte.

Puisque DataStore garantit que les mises à jour de données sont effectuées de manière transactionnelle, nous ne rencontrerons plus ce problème. Apportons les modifications suivantes :

  • Mettons à jour enableSortByDeadline() et enableSortByPriority() pour que ces éléments soient des fonctions suspend utilisant dataStore.edit().
  • Dans le bloc de transformation de edit(), récupérons currentOrder à partir du paramètre "Preferences" au lieu de le récupérer depuis le champ _sortOrderFlow.
  • Au lieu d'appeler updateSortOrder(newSortOrder), nous pouvons mettre à jour directement l'ordre de tri dans les préférences.

Voici à quoi ressemble l'implémentation.

suspend fun enableSortByDeadline(enable: Boolean) {
    // edit handles data transactionally, ensuring that if the sort is updated at the same
    // time from another thread, we won't have conflicts
    dataStore.edit { preferences ->
        // Get the current SortOrder as an enum
        val currentOrder = SortOrder.valueOf(
            preferences[PreferencesKeys.SORT_ORDER] ?: SortOrder.NONE.name
        )

        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[PreferencesKeys.SORT_ORDER] = newSortOrder.name
    }
}

suspend fun enableSortByPriority(enable: Boolean) {
    // edit handles data transactionally, ensuring that if the sort is updated at the same
    // time from another thread, we won't have conflicts
    dataStore.edit { preferences ->
        // Get the current SortOrder as an enum
        val currentOrder = SortOrder.valueOf(
            preferences[PreferencesKeys.SORT_ORDER] ?: SortOrder.NONE.name
        )

        val newSortOrder =
            if (enable) {
                if (currentOrder == SortOrder.BY_DEADLINE) {
                    SortOrder.BY_DEADLINE_AND_PRIORITY
                } else {
                    SortOrder.BY_PRIORITY
                }
            } else {
                if (currentOrder == SortOrder.BY_DEADLINE_AND_PRIORITY) {
                    SortOrder.BY_DEADLINE
                } else {
                    SortOrder.NONE
                }
            }
        preferences[PreferencesKeys.SORT_ORDER] = newSortOrder.name
    }
}

Maintenant que UserPreferencesRepository stocke à la fois les indicateurs d'affichage des tâches exécutées et d'ordre de tri 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

L'application devrait maintenant se compiler correctement. Exécutons-la pour voir si les indicateurs d'affichage des tâches exécutées et d'ordre de tri sont correctement enregistrés.

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

Maintenant que vous êtes passé à Preferences 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.