Home Android & Kotlin Books Saving Data on Android

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.

The Main Screen.
The Main Screen.

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:

  1. NotePrefs.kt: This file contains a class that takes an instance of SharedPreferences. It has methods to read and write the preferences.
  2. 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:

  1. It’s not always safe to call SharedPreferences on the UI thread because it can cause jank by blocking the UI thread.
  2. There is no way for SharedPreferences to signal errors except for parsing errors as runtime exceptions.
  3. 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.
  4. SharedPreferences doesn’t provide type safety. If you try to store both Booleans and Integers 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:

  1. Preferences DataStore: Stores data as key-value pairs, similar to SharedPreferences. You use this to store and retrieve primitive data types.

  2. 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 for Preferences.

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 to CoroutineScope, which is tied to the Activity ’s lifecycle. CoroutineScope helps you specify which thread the task will run on and when it can be canceled.
  • launchWhenStarted() starts a coroutine in lifecycleScope when Activity is at least in the STARTED state.
  • saveNoteBackgroundColor() is invoked from inside lifecycleScope.

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, choose kotlinx.coroutines.flow.Flow.

The code above does a few important things:

  1. Accesses the data inside DataStore using the data attribute, which returns Flow of type Preferences.

  2. Catches any exceptions thrown into Flow. If the exception is an IOException, it returns empty preferences using emit(). Else, it throws the error to the caller.

  3. Reads the preference value using the BACKGROUND_COLOR key. If preferences don’t contain data with the given key, it will return null. In that case, you choose DEFAULT_COLOR. Finally, it creates an instance of UserPreferences and passes it into Flow.

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 from DataStore when it’s absolutely necessary because doing so potentially blocks the UI thread while DataStore 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.

Background Color Changes Based on the User’s Preferences.
Background Color Changes Based on the User’s Preferences.

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, choose androidx.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.

Note Preferences Persist Across App Launches.
Note Preferences Persist Across App Launches.

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 a DataStore instance.
  • DataStore instances can have a unique name, just like SharedPreferences.
  • DataStore data is exposed as Flow that you can collect when you want to read data.
  • You use migrations to migrate data from SharedPreferences to DataStore.

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.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.

Have feedback to share about the online reading experience? If you have feedback about the UI, UX, highlighting, or other features of our online readers, you can send them to the design team with the form below:

© 2021 Razeware LLC