5.
Jetpack DataStore
Written by Subhrajyoti Sen
DataStore is Google’s new library to persist data as key-value pairs or typed objects using protocol buffers. Using Kotlin coroutines and Flow as its foundation, it aims to replace SharedPreferences. Since it’s part of the Jetpack suite of libraries, it’s also known as Jetpack DataStore.
In this chapter, you’ll learn about:
- DataStore’s advantages over SharedPreferences.
- Types of DataStore implementations.
- Writing to Preferences DataStore.
- Reading from Preferences DataStore.
- Migrating existing SharedPreferences to DataStore.
It’s time to jump in!
Getting started
Open the starter project in Android Studio. Build and run to see the main screen of the app.
Using FloatingActionButton
, you can add a note. You can filter and sort the notes using the toolbar menu options. If you restart the app, you’ll notice that your sorting and filtering preferences persist.
Right now, you can’t change the background color. You’ll fix that later.
In the source code, there are two files you should focus on:
-
NotePrefs.kt: This file contains a class that takes an instance of
SharedPreferences
. It has methods to read and write the preferences. -
MainActivity.kt: This file includes a class that uses an instance of
NotePrefs
to update the preferences based on user interactions, then updates the UI accordingly.
You’ll start updating the app shortly. But first, take a moment to understand more about why to use DataStore and how it works.
Limitations of SharedPreferences
Before you can understand DataStore’s advantages, you need to know about the limitations of the SharedPreferences
API. Even though SharedPreferences
has been around since API level 1, it has drawbacks that have persisted over time:
- It’s not always safe to call
SharedPreferences
on the UI thread because it can cause jank by blocking the UI thread. - There is no way for
SharedPreferences
to signal errors except for parsing errors as runtime exceptions. -
SharedPreferences
has no support for data migration. If you want to change the type of a value, you have to write the entire logic manually. -
SharedPreferences
doesn’t provide type safety. If you try to store bothBoolean
s andInteger
s using the same key, the app will compile just fine.
Google introduced DataStore to address the above limitations.
Types of DataStore implementations
DataStore offers two implementations, which you can choose from depending upon your use case:
-
Preferences DataStore: Stores data as key-value pairs, similar to
SharedPreferences
. You use this to store and retrieve primitive data types. -
Proto DataStore: Uses protocol buffers to store custom data types. When using Proto DataStore, you need to define a schema for the custom data type.
In this chapter, you’ll focus on Preferences DataStore. However, here’s a quick overview of Proto DataStore.
SharedPreferences
uses XML to store data. As the amount of data increases, the file size increases dramatically and it’s more expensive for the CPU to read the file.
Protocol buffers are a new way to represent structured data that’s faster and than XML and has a smaller size. They’re helpful when the read-time of stored data affects the performance of your app.
To use them, you define your data schema using a .proto file. A language-dependent plugin then generates a class for you.
Given how vast a topic protocol buffers are, this chapter won’t cover them in depth. However, you can refer to the Where to go from here? section for resources to learn more about them.
Now, it’s time to build your first DataStore!
Creating your DataStore
To start working with Preferences DataStore, you need to add its dependency.
Open build.gradle and add the following dependency:
implementation "androidx.datastore:datastore-preferences:1.0.0"
Click Sync Now and wait for the dependency to sync.
Similar to other Jetpack libraries, the code to create DataStore
is very concise. You have to use the property delegate named preferencesDataStore
to get an instance of DataStore
.
Open MainActivity.kt and add the following code right before the class declaration:
private val Context.dataStore by preferencesDataStore(
name = NotePrefs.PREFS_NAME
)
In the code above, you create a property whose receiver type is Context
. You then delegate its value to preferencesDataStore()
. preferencesDataStore()
takes the name of DataStore
as a parameter. This is similar to how you create a SharedPreferences
instance using a name.
When you create a DataStore
instance, the library, in turn, creates a new directory named datastore in the files directory associated with your app.
Before you can start accessing DataStore
, you need access to its instance.
Accessing DataStore
Open NotePrefs.kt and change the constructor of NotePrefs
to the following:
class NotePrefs(
private val sharedPrefs: SharedPreferences,
private val dataStore: DataStore<Preferences>
)
Note: Choose
androidx.datastore.preferences.core.Preferences
to add the import forPreferences
.
The code above adds a constructor parameter of type DataStore<Preferences>
, which indicates that this is an instance of DataStore
.
Next, head over to MainActivity.kt and change the lazy assignment of notePrefs
to include the new constructor parameter:
private val notePrefs: NotePrefs by lazy {
NotePrefs(
applicationContext.getSharedPreferences(NotePrefs.PREFS_NAME, Context.MODE_PRIVATE),
dataStore
)
}
The code above passes the DataStore
instance created in MainActivity
as a parameter to NotePrefs
.
Now that NotePrefs
has access to DataStore
, you’ll add the code to write data to it.
Writing to DataStore
To read and write data to DataStore
, you need an instance of Preferences.Key<T>
, where T
is the type of data you want to read and write.
Creating a key
To interact with String
data, you need an instance of Preferences.Key<String>
. The DataStore library also contains functions like stringPreferencesKey()
and doublePreferencesKey()
that make it easy to create such keys.
Open NotePrefs.kt and code the following code inside companion object
:
private val BACKGROUND_COLOR = stringPreferencesKey("key_app_background_color")
The code above creates a key of type String
and names it key_app_background_color.
Writing a key-value pair
Now that you’ve created a key, you can use it to write a value corresponding to the key. Replace saveNoteBackgroundColor()
with the following:
suspend fun saveNoteBackgroundColor(noteBackgroundColor: String) {
dataStore.edit { preferences ->
preferences[BACKGROUND_COLOR] = noteBackgroundColor
}
}
Note: Here, you create a suspending function. If you need to brush up on suspending functions, or coroutines in general, refer to our book, Kotlin Coroutines by Tutorials https://www.raywenderlich.com/books/kotlin-coroutines-by-tutorials.
In the code above, you use edit()
to start editing DataStore
. edit()
takes in a lambda that provides access to the underlying preferences
. You then use the key, BACKGROUND_COLOR
, to store the new color.
Finally, you need to invoke saveNoteBackgroundColor()
in MainActivity
inside a coroutine.
Open MainActivity.kt and change the invocation of saveNoteBackgroundColor()
inside showNoteBackgroundColorDialog()
to the following:
lifecycleScope.launchWhenStarted {
notePrefs.saveNoteBackgroundColor(selectedRadioButton.text.toString())
}
A lot is happening in the code above:
-
lifecycleScope
is an extension property that gives you access toCoroutineScope
, which is tied to theActivity
’s lifecycle.CoroutineScope
helps you specify which thread the task will run on and when it can be canceled. -
launchWhenStarted()
starts a coroutine inlifecycleScope
whenActivity
is at least in theSTARTED
state. -
saveNoteBackgroundColor()
is invoked from insidelifecycleScope
.
The code above makes sure Datastore
updates asynchronously.
Also, remove the line below from showNoteBackgroundColorDialog()
.
changeNotesBackgroundColor(getCurrentBackgroundColorInt())
You don’t need this anymore because, using DataStore
, you’ll update your UI reactively whenever the user chances their preferences.
Now, you’ve set up the method to write the data. Your next goal is to write the mechanism to read this data.
Reading from DataStore
Unlike SharedPreferences
, DataStore
doesn’t provide APIs to read data synchronously. Instead, it uses Flow to give you a way to observe data inside DataStore
and handle errors correctly. Flow
is a Kotlin coroutines API that provides an asynchronous data stream that sequentially emits values.
Create a new file called UserPreferences.kt in com/raywenderlich/android/organizedsimplenotes and add the following lines to it:
data class UserPreferences(
val backgroundColor: AppBackgroundColor
)
In the code above, UserPreferences
acts as a container class that groups different user preferences. For now, it contains only the background color.
Initializing Flow
At this point, you need a way to convert the data you read from DataStore
into an instance of UserPreferences
.
Open NotePrefs.kt and add the following code at the top of the class, just below the class declaration:
// 1
val userPreferencesFlow: Flow<UserPreferences> = dataStore.data
// 2
.catch { exception ->
if (exception is IOException) {
emit(emptyPreferences())
} else {
throw exception
}
}.map { preferences ->
// 3
val backgroundColor = AppBackgroundColor.getColorByName(preferences[BACKGROUND_COLOR] ?: DEFAULT_COLOR)
UserPreferences(backgroundColor)
}
Note: When prompted to add the import for
Flow
, choosekotlinx.coroutines.flow.Flow
.
The code above does a few important things:
-
Accesses the data inside
DataStore
using thedata
attribute, which returnsFlow
of typePreferences
. -
Catches any exceptions thrown into
Flow
. If the exception is anIOException
, it returns empty preferences usingemit()
. Else, it throws the error to the caller. -
Reads the preference value using the
BACKGROUND_COLOR
key. Ifpreferences
don’t contain data with the given key, it will returnnull
. In that case, you chooseDEFAULT_COLOR
. Finally, it creates an instance ofUserPreferences
and passes it intoFlow
.
Collecting Flow
In the previous step, you accessed the data inside DataStore
, mapped it to UserPreferences
and then emitted it into Flow
.
Now, you need to collect Flow
in MainActivity
to read its values. Collecting a flow is equivalent to reading the values emitted from Flow
.
Open MainActivity.kt and replace the changeNotesBackgroundColor()
invocation inside onCreate()
with the following code:
try {
lifecycleScope.launchWhenStarted {
notePrefs.userPreferencesFlow.collect { userPreferences ->
changeNotesBackgroundColor(userPreferences.backgroundColor.intColor)
}
}
} catch (e: Exception) {
Log.e("MainActivity", e.localizedMessage)
}
Also, add the following imports:
import kotlinx.coroutines.flow.collect
import android.util.Log
In the code above, you call collect()
on Flow
and get access to the UserPreferences
instance. You then extract the backgroundColor
value and invoke changeNotesBackgroundColor()
. Finally, you wrap the entire collect()
call in a try-catch block since any exception throw by Flow
will be re-thrown by collect()
as well.
Next, you need to fetch the background color from DataStore
.
Making synchronous calls
When you migrate a project from SharedPreferences
to DataStore
, it isn’t always possible to replace all synchronous reads with asynchronous reads.
Open NotePrefs.kt and replace getAppBackgroundColor()
with the following code:
fun getAppBackgroundColor(): AppBackgroundColor =
runBlocking {
AppBackgroundColor.getColorByName(dataStore.data.first()[BACKGROUND_COLOR] ?: DEFAULT_COLOR)
}
In the code above, runBlocking()
blocks the current thread while the coroutine runs and effectively lets you make synchronous reads from DataStore
.
Note: You should only use
runBlocking()
to read fromDataStore
when it’s absolutely necessary because doing so potentially blocks the UI thread whileDataStore
reads the values.
You use dataStore.data.first()
to access the first item emitted by Flow
and then use BACKGROUND_COLOR
to access the background color from the preferences.
Build and run. Open the overflow menu in the toolbar and change the background color to orange. You’ll see that the background changes. Close and reopen the app. The background color will still be orange.
Congratulations, you’ve successfully used DataStore
to store and retrieve user preferences. Next, you’ll take a look at how to handle adding DataStore
to an app that already uses SharedPreferences
.
Migrating from SharedPreferences
If you’re working on a new app, you can use DataStore
right from the start. But, in most cases, you’ll be working on an existing app that uses SharedPreferences
. The users of this app will already have preferences that you want to persist when you use DataStore
.
To emulate the second scenario, use the starter project.
Migrating the read/write methods
NotePrefs
contains methods that persist preferences in SharedPreferences
. The first step in your migration is to rewrite these methods to use DataStore
.
Open NotePrefs.kt and add the following code to companion object
:
private val NOTE_SORT_ORDER = stringPreferencesKey("note_sort_preference")
private val NOTE_PRIORITY_SET = stringSetPreferencesKey("note_priority_set")
In the code above, you create keys for the note sort order and priority. This is similar to how you handled the background color.
Next, replace saveNoteSortOrder()
, getNoteSortOrder()
, saveNotePriorityFilters()
and getNotePriorityFilters()
with the following code:
suspend fun saveNoteSortOrder(noteSortOrder: NoteSortOrder) {
dataStore.edit { preferences ->
preferences[NOTE_SORT_ORDER] = noteSortOrder.name
}
}
fun getNoteSortOrder() = runBlocking {
NoteSortOrder.valueOf(dataStore.data.first()[NOTE_SORT_ORDER] ?: DEFAULT_SORT_ORDER)
}
suspend fun saveNotePriorityFilters(priorities: Set<String>) {
dataStore.edit { preferences ->
preferences[NOTE_PRIORITY_SET] = priorities
}
}
fun getNotePriorityFilters() = runBlocking {
dataStore.data.first()[NOTE_PRIORITY_SET] ?: setOf(DEFAULT_PRIORITY_FILTER)
}
The code above is similar to what you already wrote for the background color preference. In saveNoteSortOrder()
and saveNotePriorityFilters()
, you’re assigning provided value to specific key and store it into DataStore
. Using getNotePriorityFilters()
and getNoteSortOrder()
, you’re retrieving stored value, or a default value if the real one is null
.
Open MainActivity.kt and replace updateNoteSortOrder()
and updateNotePrioritiesFilter()
to make them use CoroutineScope
, as follows:
private fun updateNoteSortOrder(sortOrder: NoteSortOrder) {
noteAdapter.updateNotesFilters(order = sortOrder)
lifecycleScope.launchWhenStarted {
notePrefs.saveNoteSortOrder(sortOrder)
}
}
private fun updateNotePrioritiesFilter(priorities: Set<String>) {
noteAdapter.updateNotesFilters(priorities = priorities)
lifecycleScope.launchWhenStarted {
notePrefs.saveNotePriorityFilters(priorities)
}
}
updateNoteSortOrder()
applies provided sorOrderd
to the note list inside the adapter. Then, it uses saveNoteSortOrder()
to save the current order filter to DataStore
. updateNotePrioritiesFilter()
is doing a similar thing - updates noteAdapter
about filter priorities, and saves the priorities to DataStore
.
Now that you have methods to write values to DataStore
and to read them, you have one final step. You need to copy the user’s existing preferences values from SharedPreferences
to DataStore
.
Migrating the preferences
To migrate data from SharedPreferences
, you need to tell DataStore
the name of the SharedPreferences
instance that holds the data you want to copy. You use SharedPreferencesMigration
to achieve this.
To add a migration, change preferencesDataStore()
to contain the following paramter :
produceMigrations = { context ->
listOf(SharedPreferencesMigration(context, NotePrefs.PREFS_NAME))
}
Note: When adding the import for
SharedPreferencesMigration
, chooseandroidx.datastore.preferences.SharedPreferencesMigration
.
In the code above, produceMigrations
is the constructor argument, and takes a lambda of type (Context) -> List<DataMigration<Preferences>>
.
You create an instance of SharedPreferencesMigration
that takes a Context
instance and the name of the existing SharedPreferences
. You then make a list with the SharedPreferencesMigration
instance and return it from the lambda.
Similarly, you can migrate multiple SharedPreferences
to a single DataStore
.
Without building the app, run it once. Change the priority to Priority 1 and Priority 2 and the sort order to Filename Ascending from the toolbar. Remember your selections.
Now, build and run for the last time to verify that your sorting and priority choices from the previous session persisted. This proves that you correctly migrated the preferences from SharedPreferences
.
Key points
-
SharedPreferences
has limitations regarding blocking the UI thread, error handling and data migration. - Google introduced DataStore to address the limitations in the SharedPreferences API.
- There are two implementations of DataStore: Preferences and Proto.
- You use
preferencesDataStore()
to create or get access to aDataStore
instance. -
DataStore
instances can have a unique name, just likeSharedPreferences
. -
DataStore
data is exposed asFlow
that you can collect when you want to read data. - You use migrations to migrate data from
SharedPreferences
toDataStore
.
Where to go from here?
In this chapter, you learned about Preferences DataStore in detail.
To learn about protocol buffers in your favorite programming language, refer to Google’s protocol buffers documentation https://developers.google.com/protocol-buffers/docs/tutorials.
To learn about Proto DataStore, refer to the official Android documentation at https://developer.android.com/topic/libraries/architecture/datastore#proto-datastore.
To improve the app, try removing all usages of runBlocking {}
, then refactor MainActivity
so it reads the preferences asynchronously.
In the next chapter, you’ll start learning about Room and an architecture that integrates with it well.