Architecture des applications : Couche de données – DataStore – Développeurs Android

Project: /architecture/_project.yaml Book: /architecture/_book.yaml keywords: datastore, architecture, api:JetpackDataStore description: Explore this app architecture guide on data layer libraries to learn about Preferences DataStore and Proto DataStore, Setup, and more. hide_page_heading: true

DataStore   Fait partie d'Android Jetpack.

Essayer avec Kotlin Multiplatform
Kotlin Multiplatform permet de partager la couche de données avec d'autres plates-formes. Découvrez comment configurer et utiliser DataStore dans KMP

Jetpack DataStore est une solution de stockage de données qui vous permet de stocker des paires clé-valeur ou des objets typés avec des tampons de protocole. DataStore utilise les coroutines Kotlin et Flow pour stocker les données de manière asynchrone, cohérente et transactionnelle.

Si vous utilisez SharedPreferences pour stocker des données, nous vous recommandons d'effectuer une migration vers DataStore.

API DataStore

L'interface DataStore fournit l'API suivante :

  1. Flux pouvant être utilisé pour lire des données à partir de DataStore

    val data: Flow<T>
    
  2. Fonction permettant de mettre à jour les données dans DataStore

    suspend updateData(transform: suspend (t) -> T)
    

Configurations DataStore

Si vous souhaitez stocker des données et y accéder à l'aide de clés, utilisez l'implémentation Preferences DataStore, qui ne nécessite pas de schéma prédéfini et ne fournit pas de sécurité de type. Il dispose d'une API semblable à SharedPreferences, mais ne présente pas les inconvénients associés aux préférences partagées.

DataStore vous permet de conserver des classes personnalisées. Pour ce faire, vous devez définir un schéma pour les données et fournir un Serializer pour les convertir dans un format persistant. Vous pouvez choisir d'utiliser Protocol Buffers, JSON ou toute autre stratégie de sérialisation.

Configuration

Pour utiliser Jetpack DataStore dans votre application, ajoutez les éléments suivants à votre fichier Gradle en fonction de l'implémentation que vous souhaitez utiliser :

Preferences DataStore

Ajoutez les lignes suivantes à la partie des dépendances de votre fichier Gradle :

Groovy

    dependencies {
        // Preferences DataStore (SharedPreferences like APIs)
        implementation "androidx.datastore:datastore-preferences:1.1.7"

        // Alternatively - without an Android dependency.
        implementation "androidx.datastore:datastore-preferences-core:1.1.7"
    }
    

Kotlin

    dependencies {
        // Preferences DataStore (SharedPreferences like APIs)
        implementation("androidx.datastore:datastore-preferences:1.1.7")

        // Alternatively - without an Android dependency.
        implementation("androidx.datastore:datastore-preferences-core:1.1.7")
    }
    

Pour ajouter la prise en charge facultative de RxJava, ajoutez les dépendances suivantes :

Groovy

    dependencies {
        // optional - RxJava2 support
        implementation "androidx.datastore:datastore-preferences-rxjava2:1.1.7"

        // optional - RxJava3 support
        implementation "androidx.datastore:datastore-preferences-rxjava3:1.1.7"
    }
    

Kotlin

    dependencies {
        // optional - RxJava2 support
        implementation("androidx.datastore:datastore-preferences-rxjava2:1.1.7")

        // optional - RxJava3 support
        implementation("androidx.datastore:datastore-preferences-rxjava3:1.1.7")
    }
    

DataStore

Ajoutez les lignes suivantes à la partie des dépendances de votre fichier Gradle :

Groovy

    dependencies {
        // Typed DataStore for custom data objects (for example, using Proto or JSON).
        implementation "androidx.datastore:datastore:1.1.7"

        // Alternatively - without an Android dependency.
        implementation "androidx.datastore:datastore-core:1.1.7"
    }
    

Kotlin

    dependencies {
        // Typed DataStore for custom data objects (for example, using Proto or JSON).
        implementation("androidx.datastore:datastore:1.1.7")

        // Alternatively - without an Android dependency.
        implementation("androidx.datastore:datastore-core:1.1.7")
    }
    

Ajoutez les dépendances facultatives suivantes pour la prise en charge de RxJava :

Groovy

    dependencies {
        // optional - RxJava2 support
        implementation "androidx.datastore:datastore-rxjava2:1.1.7"

        // optional - RxJava3 support
        implementation "androidx.datastore:datastore-rxjava3:1.1.7"
    }
    

Kotlin

    dependencies {
        // optional - RxJava2 support
        implementation("androidx.datastore:datastore-rxjava2:1.1.7")

        // optional - RxJava3 support
        implementation("androidx.datastore:datastore-rxjava3:1.1.7")
    }
    

Pour sérialiser du contenu, ajoutez des dépendances pour la sérialisation Protocol Buffers ou JSON.

Sérialisation JSON

Pour utiliser la sérialisation JSON, ajoutez les lignes suivantes à votre fichier Gradle :

Groovy

    plugins {
        id("org.jetbrains.kotlin.plugin.serialization") version "2.2.20"
    }

    dependencies {
        implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0"
    }
    

Kotlin

    plugins {
        id("org.jetbrains.kotlin.plugin.serialization") version "2.2.20"
    }

    dependencies {
        implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
    }
    

Sérialisation Protobuf

Pour utiliser la sérialisation Protobuf, ajoutez les lignes suivantes à votre fichier Gradle :

Groovy

    plugins {
        id("com.google.protobuf") version "0.9.5"
    }
    dependencies {
        implementation "com.google.protobuf:protobuf-kotlin-lite:4.32.1"

    }

    protobuf {
        protoc {
            artifact = "com.google.protobuf:protoc:4.32.1"
        }
        generateProtoTasks {
            all().forEach { task ->
                task.builtins {
                    create("java") {
                        option("lite")
                    }
                    create("kotlin")
                }
            }
        }
    }
    

Kotlin

    plugins {
        id("com.google.protobuf") version "0.9.5"
    }
    dependencies {
        implementation("com.google.protobuf:protobuf-kotlin-lite:4.32.1")
    }

    protobuf {
        protoc {
            artifact = "com.google.protobuf:protoc:4.32.1"
        }
        generateProtoTasks {
            all().forEach { task ->
                task.builtins {
                    create("java") {
                        option("lite")
                    }
                    create("kotlin")
                }
            }
        }
    }
    

Utiliser correctement DataStore

Pour utiliser correctement DataStore, gardez toujours à l'esprit les règles suivantes :

  1. Ne créez jamais plus d'une instance de DataStore pour un fichier donné dans le même processus. En effet, les fonctionnalités de DataStore pourraient ne plus marcher. Si plusieurs instances sont actives pour un fichier donné au cours du même processus, DataStore générera une IllegalStateException lors de la lecture ou de la mise à jour de données.

  2. Le type générique de DataStore<T> doit être immuable. La mutation d'un type utilisé dans DataStore annule la cohérence fournie par DataStore et crée des bugs potentiellement graves et difficiles à détecter. Nous vous recommandons d'utiliser des tampons de protocole, qui permettent d'assurer l'immuabilité, une API claire et une sérialisation efficace.

  3. N'utilisez jamais SingleProcessDataStore et MultiProcessDataStore pour le même fichier. Si vous avez l'intention d'accéder à DataStore depuis plusieurs processus, vous devez utiliser MultiProcessDataStore.

Définition des données

Preferences DataStore

Définissez une clé qui sera utilisée pour conserver les données sur le disque.

val EXAMPLE_COUNTER = intPreferencesKey("example_counter")

JSON DataStore

Pour le datastore JSON, ajoutez une annotation @Serialization aux données que vous souhaitez rendre persistantes.

@Serializable
data class Settings(
    val exampleCounter: Int
)

Définissez une classe qui implémente Serializer<T>, où T est le type de la classe à laquelle vous avez ajouté l'annotation plus tôt. Assurez-vous d'inclure une valeur par défaut à utiliser pour le sérialiseur si aucun fichier n'a encore été créé.

object SettingsSerializer : Serializer<Settings> {

    override val defaultValue: Settings = Settings(exampleCounter = 0)

    override suspend fun readFrom(input: InputStream): Settings =
        try {
            Json.decodeFromString<Settings>(
                input.readBytes().decodeToString()
            )
        } catch (serialization: SerializationException) {
            throw CorruptionException("Unable to read Settings", serialization)
        }

    override suspend fun writeTo(t: Settings, output: OutputStream) {
        output.write(
            Json.encodeToString(t)
                .encodeToByteArray()
        )
    }
}

Proto DataStore

L'implémentation Proto DataStore utilise DataStore et des tampons de protocole pour conserver des objets typés sur le disque.

Proto DataStore nécessite un schéma prédéfini dans un fichier .proto du répertoire app/src/main/proto/. Ce schéma définit le type des objets que vous conservez dans votre Proto DataStore. Pour en savoir plus sur la définition d'un schéma .proto, consultez le Guide du langage des tampons de protocole.

Ajoutez un fichier nommé settings.proto dans le dossier src/main/proto :

syntax = "proto3";

option java_package = "com.example.datastore.snippets.proto";
option java_multiple_files = true;

message Settings {
  int32 example_counter = 1;
}

Définissez une classe qui implémente Serializer<T>, où T est le type défini dans le fichier .proto. Cette classe de sérialiseur définit la manière dont DataStore lit et écrit votre type de données. Assurez-vous d'inclure une valeur par défaut à utiliser pour le sérialiseur si aucun fichier n'a encore été créé.

object SettingsSerializer : Serializer<Settings> {
    override val defaultValue: Settings = Settings.getDefaultInstance()

    override suspend fun readFrom(input: InputStream): Settings {
        try {
            return Settings.parseFrom(input)
        } catch (exception: InvalidProtocolBufferException) {
            throw CorruptionException("Cannot read proto.", exception)
        }
    }

    override suspend fun writeTo(t: Settings, output: OutputStream) {
        return t.writeTo(output)
    }
}

Créer un DataStore

Vous devez spécifier un nom pour le fichier utilisé pour conserver les données.

Preferences DataStore

L'implémentation Preferences DataStore utilise les classes DataStore et Preferences pour conserver des paires clé-valeur sur le disque. Utilisez le délégué de propriété créé par preferencesDataStore pour créer une instance de DataStore<Preferences>. Appelez-le une fois au niveau supérieur de votre fichier Kotlin. Accédez à DataStore via cette propriété dans le reste de votre application. Il est ainsi plus facile de conserver votre DataStore en tant que singleton. Si vous utilisez RxJava, vous pouvez également utiliser RxPreferenceDataStoreBuilder. Le paramètre name obligatoire est le nom du Preferences DataStore.

// At the top level of your kotlin file:
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")

JSON DataStore

Utilisez le délégué de propriété créé par dataStore pour créer une instance de DataStore<T>, où T est la classe de données sérialisables. Appelez-le une fois au niveau supérieur de votre fichier Kotlin et accédez-y via ce délégué dans le reste de votre application. Le paramètre fileName indique à DataStore le fichier à utiliser pour stocker les données, et le paramètre serializer indique à DataStore le nom de la classe de sérialiseur définie à l'étape 1.

val Context.dataStore: DataStore<Settings> by dataStore(
    fileName = "settings.json",
    serializer = SettingsSerializer,
)

Proto DataStore

Utilisez le délégué de propriété créé par dataStore pour créer une instance de DataStore<T>, où T est le type défini dans le fichier .proto. Appelez-le une fois au niveau supérieur de votre fichier Kotlin et accédez-y via ce délégué dans le reste de votre application. Le paramètre fileName indique à DataStore le fichier à utiliser pour stocker les données, et le paramètre serializer indique à DataStore le nom de la classe de sérialiseur définie à l'étape 1.

val Context.dataStore: DataStore<Settings> by dataStore(
    fileName = "settings.pb",
    serializer = SettingsSerializer,
)

Lire depuis DataStore

Vous devez spécifier un nom pour le fichier utilisé pour conserver les données.

Preferences DataStore

Étant donné que Preferences DataStore n'utilise pas de schéma prédéfini, vous devez utiliser la fonction de type de clé correspondante pour définir une clé pour chaque valeur à stocker dans l'instance DataStore<Preferences>. Par exemple, pour définir une clé pour une valeur int, utilisez intPreferencesKey(). Utilisez ensuite la propriété DataStore.data pour exposer la valeur stockée appropriée à l'aide d'un Flow.

fun counterFlow(): Flow<Int> = context.dataStore.data.map { preferences ->
    preferences[EXAMPLE_COUNTER] ?: 0
}

JSON DataStore

Utilisez DataStore.data pour exposer un Flow de la propriété appropriée à partir de votre objet stocké.

fun counterFlow(): Flow<Int> = context.dataStore.data.map { settings ->
    settings.exampleCounter
}

Proto DataStore

Utilisez DataStore.data pour exposer un Flow de la propriété appropriée à partir de votre objet stocké.

fun counterFlow(): Flow<Int> = context.dataStore.data.map { settings ->
    settings.exampleCounter
}

Écrire dans DataStore

DataStore fournit une fonction updateData() qui met à jour un objet stocké de manière transactionnelle. updateData vous donne l'état actuel des données comme instance de votre type de données, et met à jour les données de manière transactionnelle en une opération atomique de lecture-écriture-modification. Tout le code du bloc updateData est considéré comme une seule transaction.

Preferences DataStore

suspend fun incrementCounter() {
    context.dataStore.updateData {
        it.toMutablePreferences().also { preferences ->
            preferences[EXAMPLE_COUNTER] = (preferences[EXAMPLE_COUNTER] ?: 0) + 1
        }
    }
}

JSON DataStore

suspend fun incrementCounter() {
    context.dataStore.updateData { settings ->
        settings.copy(exampleCounter = settings.exampleCounter + 1)
    }
}

Proto DataStore

suspend fun incrementCounter() {
    context.dataStore.updateData { settings ->
        settings.copy { exampleCounter = exampleCounter + 1 }
    }
}

Exemple Compose

Vous pouvez regrouper ces fonctions dans une classe et l'utiliser dans une application Compose.

Preferences DataStore

Nous pouvons maintenant placer ces fonctions dans une classe appelée PreferencesDataStore et l'utiliser dans une application Compose.

val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val preferencesDataStore = remember(context) { PreferencesDataStore(context) }

// Display counter value.
val exampleCounter by preferencesDataStore.counterFlow()
    .collectAsState(initial = 0, coroutineScope.coroutineContext)
Text(
    text = "Counter $exampleCounter",
    fontSize = 25.sp
)

// Update the counter.
Button(
    onClick = {
        coroutineScope.launch { preferencesDataStore.incrementCounter() }
    }
) {
    Text("increment")
}

JSON DataStore

Nous pouvons maintenant placer ces fonctions dans une classe appelée JSONDataStore et l'utiliser dans une application Compose.

val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val jsonDataStore = remember(context) { JsonDataStore(context) }

// Display counter value.
val exampleCounter by jsonDataStore.counterFlow()
    .collectAsState(initial = 0, coroutineScope.coroutineContext)
Text(
    text = "Counter $exampleCounter",
    fontSize = 25.sp
)

// Update the counter.
Button(onClick = { coroutineScope.launch { jsonDataStore.incrementCounter() } }) {
    Text("increment")
}

Proto DataStore

Nous pouvons maintenant placer ces fonctions dans une classe appelée ProtoDataStore et l'utiliser dans une application Compose.

val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val protoDataStore = remember(context) { ProtoDataStore(context) }

// Display counter value.
val exampleCounter by protoDataStore.counterFlow()
    .collectAsState(initial = 0, coroutineScope.coroutineContext)
Text(
    text = "Counter $exampleCounter",
    fontSize = 25.sp
)

// Update the counter.
Button(onClick = { coroutineScope.launch { protoDataStore.incrementCounter() } }) {
    Text("increment")
}

Utiliser DataStore en code synchrone

L'un des principaux avantages de DataStore est l'API asynchrone, mais il n'est pas toujours possible de modifier le code environnant pour qu'il soit asynchrone. Cela peut être le cas si vous travaillez avec un codebase existant qui utilise des E/S de disque synchrones ou si vous avez une dépendance qui ne fournit pas d'API asynchrone.

Les coroutines Kotlin fournissent le constructeur de coroutines runBlocking() pour aider à combler l'écart entre le code synchrone et asynchrone. Vous pouvez utiliser runBlocking() pour lire des données de DataStore de manière synchrone. RxJava propose des méthodes de blocage sur Flowable. Le code suivant bloque le thread d'appel jusqu'à ce que DataStore renvoie des données :

Kotlin

val exampleData = runBlocking { context.dataStore.data.first() }

Java

Settings settings = dataStore.data().blockingFirst();

Effectuer des opérations d'E/S synchrones sur le thread UI peut entraîner des erreurs ANR ou une interface utilisateur non réactive. Vous pouvez limiter ces problèmes en préchargeant les données de DataStore de manière asynchrone :

Kotlin

override fun onCreate(savedInstanceState: Bundle?) {
    lifecycleScope.launch {
        context.dataStore.data.first()
        // You should also handle IOExceptions here.
    }
}

Java

dataStore.data().first().subscribe();

De cette façon, DataStore lit les données de manière asynchrone et les met en cache. Les lectures synchrones ultérieures utilisant runBlocking() peuvent être plus rapides ou éviter complètement une opération d'E/S sur le disque si la lecture initiale est terminée.

Utiliser DataStore dans un code multiprocessus

Vous pouvez configurer DataStore pour accéder aux mêmes données dans différents processus avec les mêmes propriétés de cohérence des données que dans un seul processus. En particulier, DataStore fournit les éléments suivants :

  • les lectures ne renvoient que les données qui ont été conservées sur le disque ;
  • la lecture après écriture est cohérente ;
  • les écritures sont sérialisées ;
  • les lectures ne sont jamais bloquées par les écritures.

Prenons l'exemple d'une application avec un service et une activité où le service s'exécute dans un processus distinct et met régulièrement à jour le DataStore.

Cet exemple utilise un datastore JSON, mais vous pouvez également utiliser un datastore de préférences ou proto.

@Serializable
data class Time(
    val lastUpdateMillis: Long
)

Un sérialiseur indique à DataStore comment lire et écrire votre type de données. Assurez-vous d'inclure une valeur par défaut à utiliser pour le sérialiseur si aucun fichier n'a encore été créé. Voici un exemple d'implémentation utilisant kotlinx.serialization :

object TimeSerializer : Serializer<Time> {

    override val defaultValue: Time = Time(lastUpdateMillis = 0L)

    override suspend fun readFrom(input: InputStream): Time =
        try {
            Json.decodeFromString<Time>(
                input.readBytes().decodeToString()
            )
        } catch (serialization: SerializationException) {
            throw CorruptionException("Unable to read Time", serialization)
        }

    override suspend fun writeTo(t: Time, output: OutputStream) {
        output.write(
            Json.encodeToString(t)
                .encodeToByteArray()
        )
    }
}

Pour utiliser DataStore dans différents processus, vous devez construire l'objet DataStore à l'aide de MultiProcessDataStoreFactory pour le code de l'application et du service :

val dataStore = MultiProcessDataStoreFactory.create(
    serializer = TimeSerializer,
    produceFile = {
        File("${context.cacheDir.path}/time.pb")
    },
    corruptionHandler = null
)

Ajoutez le code suivant à votre AndroidManifiest.xml :

<service
    android:name=".TimestampUpdateService"
    android:process=":my_process_id" />

Le service appelle régulièrement updateLastUpdateTime(), qui écrit dans le datastore à l'aide de updateData.

suspend fun updateLastUpdateTime() {
    dataStore.updateData { time ->
        time.copy(lastUpdateMillis = System.currentTimeMillis())
    }
}

L'application lit la valeur écrite par le service à l'aide du flux de données :

fun timeFlow(): Flow<Long> = dataStore.data.map { time ->
    time.lastUpdateMillis
}

Nous pouvons maintenant regrouper toutes ces fonctions dans une classe appelée MultiProcessDataStore et l'utiliser dans une application.

Voici le code de service :

class TimestampUpdateService : Service() {
    val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
    val multiProcessDataStore by lazy { MultiProcessDataStore(applicationContext) }


    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        serviceScope.launch {
            while (true) {
                multiProcessDataStore.updateLastUpdateTime()
                delay(1000)
            }
        }
        return START_NOT_STICKY
    }

    override fun onDestroy() {
        super.onDestroy()
        serviceScope.cancel()
    }
}

Et le code de l'application :

val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val multiProcessDataStore = remember(context) { MultiProcessDataStore(context) }

// Display time written by other process.
val lastUpdateTime by multiProcessDataStore.timeFlow()
    .collectAsState(initial = 0, coroutineScope.coroutineContext)
Text(
    text = "Last updated: $lastUpdateTime",
    fontSize = 25.sp
)

DisposableEffect(context) {
    val serviceIntent = Intent(context, TimestampUpdateService::class.java)
    context.startService(serviceIntent)
    onDispose {
        context.stopService(serviceIntent)
    }
}

Vous pouvez utiliser l'injection de dépendances Hilt pour que votre instance DataStore soit unique pour chaque processus :

@Provides
@Singleton
fun provideDataStore(@ApplicationContext context: Context): DataStore<Settings> =
   MultiProcessDataStoreFactory.create(...)

Gérer la corruption de fichiers

Dans de rares cas, le fichier persistant sur disque de DataStore peut être corrompu. Par défaut, DataStore ne récupère pas automatiquement les données corrompues. Toute tentative de lecture à partir de celui-ci entraînera l'affichage d'un CorruptionException par le système.

DataStore propose une API de gestion de la corruption qui peut vous aider à récupérer les données de manière fluide dans un tel scénario et à éviter de générer l'exception. Lorsqu'il est configuré, le gestionnaire de corruption remplace le fichier corrompu par un nouveau fichier contenant une valeur par défaut prédéfinie.

Pour configurer ce gestionnaire, fournissez un corruptionHandler lors de la création de l'instance DataStore dans by dataStore() ou dans la méthode de fabrique DataStoreFactory :

val dataStore: DataStore<Settings> = DataStoreFactory.create(
   serializer = SettingsSerializer(),
   produceFile = {
       File("${context.cacheDir.path}/myapp.preferences_pb")
   },
   corruptionHandler = ReplaceFileCorruptionHandler { Settings(lastUpdate = 0) }
)

Envoyer des commentaires

Faites-nous part de vos commentaires et de vos idées via les ressources suivantes :

Outil de suivi des problèmes :
Signalez les problèmes pour que nous puissions corriger les bugs.

Ressources supplémentaires

Pour en savoir plus sur Jetpack DataStore, consultez les ressources supplémentaires suivantes :

Exemples

Blogs

Ateliers de programmation