paint-brush
Jetpack DataStore in Android Explainedby@timofeykrestyanov
2,258 reads
2,258 reads

Jetpack DataStore in Android Explained

by Timofey KrestyanovMay 24th, 2022
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

Jetpack DataStore is a data storage solution that allows you to store key/value pairs or protocol buffers to store typed objects. All operations on DataStore are safe and performant. It is built on Kotlin and Flow coroutines, so all transactions are asynchronous. The DataStore exposes stored data in the Preferences object. The preferences will be stored in a file in the "datastore/" subdirectory in the application context's files directory and is generated using preferencesDataStoreFile. Add a catch block to handle errors.

Companies Mentioned

Mention Thumbnail
Mention Thumbnail

Coin Mentioned

Mention Thumbnail
featured image - Jetpack DataStore in Android Explained
Timofey Krestyanov HackerNoon profile picture

What is DataStore?

Jetpack DataStore is a data storage solution that allows you to store key/value pairs or protocol buffers to store typed objects. DataStore is built on Kotlin and Flow coroutines, so all transactions are asynchronous, data storage and retrieval operations are safe and performant. It will replace SharedPreferences.


Jetpack DataStore is available in two types:

  • Preferences DataStore - stores and retrieves data using keys.

    Does not provide type safety

  • Proto DataStore - stores data as instances of a custom data type.

    Provides type safety with Protobuffers.


For most applications, Preferences DataStore is enough. Person objects are usually stored in the room database.

Preferences DataStore

Implementation Preferences DataStore

Add the dependencies in the build.gradle file for your app or module:

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

Create a Preferences DataStore

Creates a property delegate for a single process DataStore. This should only be called once in a file (at the top level), and all usages of the DataStore should use a reference the same Instance. The receiver type for the property delegate must be an instance of Context.

val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
    name = "settings",
    corruptionHandler = null,
    produceMigrations = { context ->
        emptyList()
    },
    scope = CoroutineScope(Dispatchers.IO + Job())
)


  • name - The name of the preferences. The preferences will be stored in a file in the "datastore/" subdirectory in the application context's files directory and is generated using preferencesDataStoreFile.
  • corruptionHandler - The corruptionHandler is invoked if DataStore encounters a Exception.
  • produceMigrations - produce the migrations. The ApplicationContext is passed in to these callbacks as a parameter. DataMigrations are run before any access to data can occur.
  • scope - The scope in which IO operations and transform functions will execute.


Creating keys

Let's create objects containing the keys that we will use

companion object {
        val IS_DARK_MODE = booleanPreferencesKey("dark_mode")
        ...
        ...
    }

Read from a Proto DataStore

To access data use DataStore.data. Add a catch block to handle errors.

The DataStore exposes stored data in the Preferences object.


enum class UiMode {
    LIGHT, DARK
}

val uiModeFlow: Flow<UiMode> = context.dataStore.data
        .catch {
            if (it is IOException) {
                it.printStackTrace()
                emit(emptyPreferences())
            } else {
                throw it
            }
        }
        .map { preference ->
            // No type safety.
            when (preference[IS_DARK_MODE] ?: false) {
                true -> UiMode.DARK
                false -> UiMode.LIGHT
            }
        }

Write to a Preferences DataStore

Preferences DataStore provides an edit() function that transactionally updates the data in a DataStore. DataStore.edit() is a suspend and extension function on DataStore. All operations are safe and serialized. The coroutine will completes when the data has been persisted durably to disk

suspend fun setUiMode(uiMode: UiMode) {
        context.dataStore.edit { preferences ->
            preferences[IS_DARK_MODE] = when (uiMode) {
                UiMode.LIGHT -> false
                UiMode.DARK -> true
            }
        }
    }

Protocol Buffers

Implementation Proto DataStore

Add the dependencies in the build.gradle file for your app or module:

plugins {
    ...
    id "com.google.protobuf" version "0.8.18"
}
...
dependencies {
	  ...
    //DataStore
    implementation "androidx.datastore:datastore:1.0.0"
    // You need to depend on the lite runtime library, not protobuf-java
    implementation 'com.google.protobuf:protobuf-javalite:3.20.1'
}

protobuf {
    protoc {
        artifact = 'com.google.protobuf:protoc:3.20.1'
    }

    generateProtoTasks {
        all().each { task ->
            task.builtins {
                java {
                    option 'lite'
                }
            }
        }
    }
}

Check out the Protobuf Plugin for Gradle notes

Sync the project to generate a java class for the DataStore according to the scheme from proto.

Define a schema

The Proto DataStore implementation uses DataStore and protocol buffers to persist typed objects to disk. To learn more about defining a proto schema, see the protobuf language guide.

To describe the settings scheme, you need to create the proto file in the app/src/main/proto/ folder, a file with the *.proto extension

This schema defines the type for the objects that are to be persisted in Proto DataStore

syntax = "proto3";

option java_package = "com.example.protodatastore";
option java_multiple_files = true;

message Person {
  int32 id = 1;
  string first_name = 2;
  string last_name= 3;
}


  • syntax - specifies that you’re using proto3 syntax
  • java_package - specifies where you want the compiler to put generated files.
  • java_multiple_files - set to true means the code generator will create a separate file for each top-level message.
  • message - structure detection keyword
  • Markers 1, 2, 3 identify the unique tag that the field uses in binary encoding.

Useful plugin for proto files -  Protocol Buffer Editor.

Creating a Serializer

The serializer will take a stream of bytes and create a Person instance as a stream of bytes.

parse.

This Serializer object must be created as a singleton

object PersonSerializer : Serializer<Person> {
    override suspend fun readFrom(input: InputStream): Person {
        try {
            return Person.parseFrom(input)
        } catch (exception: InvalidProtocolBufferException) {
            throw CorruptionException("CorruptionException proto", exception)
        }
    }

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

    override val defaultValue: Person = Person.getDefaultInstance()
}


  • defaultValue - returns a default value if nothing is found
  • writeTo - how to convert for storage format
  • readFrom - reverse how to convert storage format

Create a Proto DataStore

Create a Proto DataStore instance by using createDataStore()

private val Context.dataStore: DataStore<Person> = context.createDataStore(
    fileName = "*.proto" ,
    serializer = PersonSerializer ,
    …
)

Use the fileName of the file where you will save the data.

Use the serializer we created earlier.

Reading

Get the data object: Flow<T>, which will return us the actual instance of the model stored in the DataStore:

val personFlow: Flow<Person> = dataStore.data

Do not layer a cache on top of this API: it will be be impossible to guarantee consistency. Instead, use data.first() to access a single snapshot.

Writing

Use the DataStore.updateData() suspend method to transactionally update data in an atomic read-modify-write operation. All operations are serialized, and the transformation itself is a coroutine.

suspend fun updateIsRegistered(firstName: String) {
    dataStore.updateData { personSettings ->
        person.toBuilder().setFirstName(firstName).build()
    }
}
  • toBuilder()- gets the Builder version of our person
  • .setFirstName(firstName) - sets the new value
  • .build() - update by converting to PersonPreferences

Conclusion

DataStore is a replacement for SharedPreferences that fixes most of the shortcomings with the synchronous API, the lack of an error signaling mechanism, the lack of a transactional API, and others. DataStore gives us a very simple and convenient tool. If you need partial updates, referential integrity, or big data support, consider using the Room API instead of DataStore. The DataStore is used for small, simple data sets such as application settings and state. It does not support partial updates: if any field changes, the entire object will be serialized and saved to disk.