Preferences DataStore

1. Before you begin

In previous codelabs, you learned how to save data in a SQLite database using Room, a database abstraction layer. This codelab introduces Jetpack DataStore. Built on Kotlin coroutines and Flow, DataStore provides two different implementations: Proto DataStore, which stores typed objects, and Preferences DataStore, which stores key-value pairs.

In this hands-on codelab you will learn how to use the Preferences DataStore. The Proto DataStore is beyond the scope of this codelab.

Prerequisites

  • You are familiar with the Android architecture components ViewModel, LiveData, and Flow, and know how to use ViewModelProvider.Factory to instantiate the ViewModel.
  • You are familiar with concurrency fundamentals.
  • You know how to use coroutines for long-running tasks.

What you'll learn

  • What DataStore is, and why and when you should use it.
  • How to add the Preference DataStore to your app.

What you need

  • Starter code for the Words app (this is the same as the Words app solution code from a previous codelab).
  • A computer with Android Studio installed.

Download the starter code for this codelab

In this codelab you will extend the features of the Words app from previous solution code. The starter code may contain code that is also familiar to you from previous codelabs.

To get the code for this codelab from GitHub and open it in Android Studio, do the following.

  1. Start Android Studio.
  2. On the Welcome to Android Studio window, click Check out project from version control.
  3. Choose Git.

b89a22e2d8cf3b4e.png

  1. In the Clone Repository dialog, paste the provided code URL into the URL box.
  2. Click the Test button, wait, and make sure there is a green popup bubble that says Connection successful.
  3. Optionally, change the Directory to something different than the suggested default.

e4fb01c402e47bb3.png

  1. Click Clone. Android Studio starts fetching your code.
  2. In the Checkout from Version Control popup, click Yes.

1902d34f29119530.png

  1. Wait for Android Studio to open.
  2. Select the correct module for your codelab starter or solution code.

2371589274bce21c.png

  1. Click the Run button 11c34fc5e516fb1c.png to build and run your code.

2. Starter app overview

The Words app consists of two screens: The first screen shows letters the user can select from; a second screen displays a list of words beginning with the selected letters.

This app has a menu option for the user to toggle between list and grid layouts for the letters.

  1. Download the starter code, open it in Android Studio and run the app. The letters are displayed in a linear layout.
  2. Tap on the menu option in the right top corner. The layout switches to Grid layout.
  3. Exit and relaunch the app. You can do this using the Stop ‘app' 1c2b7a60ebd9a46e.pngand Run ‘app' 3b4c2b852ca05ab9.pngoptions in Android Studio. Note that when the app is relaunched the letters are displayed in a linear layout, and not in a Grid.

Note that the user selection is not retained. This codelab shows you how to fix this issue.

What you'll build

  • In this codelab, you learn how to use Preferences DataStore to persist the layout setting in the DataStore.

3. Introduction to Preferences DataStore

Preferences DataStore is ideal for small, simple datasets, such as storing login details, the dark mode setting, font size, and so on. The DataStore is not suitable for complex datasets, such as an online grocery store inventory list, or a student database. If you need to store large or complex datasets, consider using Room instead of DataStore.

Using the Jetpack DataStore library you can create a simple, safe, and asynchronous API for storing data. It provides two different implementations: Preferences DataStore and

Proto DataStore. While both Preferences and Proto DataStore allow saving data, they do it in different ways:

  • Preferences DataStore accesses and stores data based on keys, without defining a schema (database model) upfront.
  • Proto DataStore defines the schema using Protocol buffers. Using Protocol buffers, or Protobufs, lets you persist strongly typed data. Protobufs are faster, smaller, simpler, and less ambiguous than XML and other similar data formats.

Room versus Datastore: when to use

If your application needs to store large/complex data in a structured format such as SQL, consider using Room. However, if you only need to store simple or small amounts of data that can be saved in key-value pairs, then DataStore is an ideal choice.

Proto versus Preferences DataStore: when to use

Proto DataStore is type safe and efficient but requires configuration and setup. If your app data is simple enough that it can be saved in key-value pairs, then Preferences DataStore is a better choice since it is much easier to set up.

Add Preferences DataStore as a dependency

The first step in integrating a DataStore with your app is to add it as a dependency.

  1. In build.gradle(Module: Words.app), add the following dependency.
implementation "androidx.datastore:datastore-preferences:1.0.0"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.3.1"

4. Create a Preferences DataStore

  1. Add a package called data and create a Kotlin class named SettingsDataStore inside it.
  2. Add a constructor parameter to the SettingsDataStore class of the type Context.
class SettingsDataStore(context: Context) {}
  1. Outside the SettingsDataStore class, declare a private const val called LAYOUT_PREFERENCES_NAME, and assign the string value layout_preferences to it. This is the name of the Preferences Datastore you will instantiate in the next step.
private const val LAYOUT_PREFERENCES_NAME = "layout_preferences"
  1. Still outside the class, create a DataStore instance using the preferencesDataStore delegate. Since you are using Preferences Datastore, you need to pass Preferences as a datastore type. Also, set the datastore name to LAYOUT_PREFERENCES_NAME.

The completed code:

private const val LAYOUT_PREFERENCES_NAME = "layout_preferences"

// Create a DataStore instance using the preferencesDataStore delegate, with the Context as
// receiver.
private val Context.dataStore : DataStore<Preferences> by preferencesDataStore(
   name = LAYOUT_PREFERENCES_NAME
)

5. Implement SettingsDataStore Class

As discussed, Preferences DataStore stores data in key-value pairs. In this step, you define the required keys to store the layout setting, and define functions to write to and read from the Preferences DataStore.

Key type functions

Preferences DataStore does not use a predefined schema like Room, it uses corresponding key type functions to define a key for each value that you store in the DataStore<Preferences> instance. For example, to define a key for an int value, use intPreferencesKey(), and for a string value use stringPreferencesKey(). In general, these function names are prefixed with the type of data you want to store against the key.

Implement the following in the data\SettingsDataStore class:

  1. In order to implement the SettingsDataStore class, the first step is to create a key that stores a Boolean value that specifies whether the user setting is a linear layout. Create a private class property called IS_LINEAR_LAYOUT_MANAGER, and initialize it using booleanPreferencesKey() passing in the is_linear_layout_manager key name as the function parameter.
private val IS_LINEAR_LAYOUT_MANAGER = booleanPreferencesKey("is_linear_layout_manager")

Write to the Preferences DataStore

Now it's time to use your key and store the Boolean layout setting in the DataStore. Preferences DataStore provides an edit() suspend function that transactionally updates the data in DataStore. The function's transform parameter accepts a block of code where you can update the values as needed. All of the code in the transform block is treated as a single transaction. Under the hood the transaction work is moved to Dispacter.IO, so don't forget to make your function suspend when calling the edit() function.

  1. Create a suspend function called saveLayoutToPreferencesStore() that takes two parameters: the layout setting Boolean, and the Context.
suspend fun saveLayoutToPreferencesStore(isLinearLayoutManager: Boolean, context: Context) {

}
  1. Implement the above function, call dataStore.edit(), and pass in a block of code to store the new value.
suspend fun saveLayoutToPreferencesStore(isLinearLayoutManager: Boolean, context: Context) {
   context.dataStore.edit { preferences ->
       preferences[IS_LINEAR_LAYOUT_MANAGER] = isLinearLayoutManager
   }
}

Read from the Preferences DataStore

Preferences DataStore exposes the data stored in a Flow<Preferences> that emits every time a preference has changed. You don't want to expose the entire Preferences object, just the Boolean value. To do this, we map the Flow<Preferences>, and get the Boolean value you're interested in.

  1. Expose a preferenceFlow: Flow<UserPreferences>, constructed based on dataStore.data: Flow<Preferences>, map it to retrieve the Boolean preference. Since the Datastore is empty on the first run, return true by default.
val preferenceFlow: Flow<Boolean> = context.dataStore.data
   .map { preferences ->
       // On the first run of the app, we will use LinearLayoutManager by default
       preferences[IS_LINEAR_LAYOUT_MANAGER] ?: true
   }
  1. Add the following imports if they are not automatically imported.
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map

Exception handling

As DataStore reads and writes data from files, IOExceptions may occur when accessing the data. You handle these using the catch() operator to catch exceptions.

  1. SharedPreference DataStore throws an IOException when an error is encountered while reading data. In preferenceFlow declaration, before map(), use the catch() operator to catch the IOException and emit emptyPreferences(). To keep things simple, since we don't expect any other types of exceptions here, if a different type of exception is thrown, re-throw it.
val preferenceFlow: Flow<Boolean> = context.dataStore.data
   .catch {
       if (it is IOException) {
           it.printStackTrace()
           emit(emptyPreferences())
       } else {
           throw it
       }
   }
   .map { preferences ->
       // On the first run of the app, we will use LinearLayoutManager by default
       preferences[IS_LINEAR_LAYOUT_MANAGER] ?: true
   }

Your data\SettingsDataStore class is ready to use!

6. Use SettingsDataStore Class

In this next task you will use SettingsDataStore in your LetterListFragment class. You will attach an observer to the layout setting and update the UI accordingly.

Implement the following steps in LetterListFragment

  1. Declare a private class variable called SettingsDataStore of type SettingsDataStore. Make this variable lateinit since you will initialize it later.
private lateinit var SettingsDataStore: SettingsDataStore
  1. At the end of the onViewCreated() function, initialize the new variable and pass in the requireContext() to the SettingsDataStore constructor.
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
   ...
   // Initialize SettingsDataStore
   SettingsDataStore = SettingsDataStore(requireContext())
}

Read and observe the data

  1. In LetterListFragment, inside the onViewCreated() method, below the SettingsDataStore initialization, convert the preferenceFlow to Livedata using asLiveData(). Attach an observer and pass in the viewLifecycleOwner as the owner.
SettingsDataStore.preferenceFlow.asLiveData().observe(viewLifecycleOwner, { })
  1. Inside the observer, assign the new layout setting to the isLinearLayoutManager variable. Make a call to the chooseLayout() function to update the RecyclerView layout.
SettingsDataStore.preferenceFlow.asLiveData().observe(viewLifecycleOwner, { value ->
           isLinearLayoutManager = value
           chooseLayout()
   })

The completed onViewCreated() function should look similar to below:

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
   recyclerView = binding.recyclerView
   // Initialize SettingsDataStore
   SettingsDataStore = SettingsDataStore(requireContext())
   SettingsDataStore.preferenceFlow.asLiveData().observe(viewLifecycleOwner, { value ->
           isLinearLayoutManager = value
           chooseLayout()
   })
}

Write the layout setting to the DataStore

The final step is to write the layout setting into the Preferences DataStore when the user taps the menu option. Writing data to the preference datastore should be performed asynchronously inside a coroutine. To perform this inside a fragment, use the CoroutineScope called LifecycleScope.

LifecycleScope

Lifecycle-aware components such as fragments, provide first-class coroutine support for logical scopes in your app along with an interoperability layer with LiveData. A LifecycleScope is defined for each Lifecycle object. Any coroutine launched in this scope is canceled when the Lifecycle owner is destroyed.

  1. In LetterListFragment, inside onOptionsItemSelected() function, at the end of case R.id.action_switch_layout, launch the coroutine using the lifecycleScope. Inside the launch block, make a call to the saveLayoutToPreferencesStore() passing in the isLinearLayoutManager and the context.
override fun onOptionsItemSelected(item: MenuItem): Boolean {
   return when (item.itemId) {
       R.id.action_switch_layout -> {
           ...
           // Launch a coroutine and write the layout setting in the preference Datastore
           lifecycleScope.launch {             SettingsDataStore.saveLayoutToPreferencesStore(isLinearLayoutManager, requireContext())
           }
           ...

           return true
       }
  1. Run the app. Click on the menu option to change the layout of the app.

  1. Now test the persistence of the Preferences DataStore. Change the app layout to Grid layout. Exit and relaunch the app (you can do this using the Stop ‘app' 1c2b7a60ebd9a46e.pngand Run ‘app' 3b4c2b852ca05ab9.pngoptions in Android Studio).

cd2c31f27dfb5157.png

When the app is relaunched the letters are now displayed in a Grid layout, and not in Linear layout. Your app is successfully saving the layout setting selected by the user!

Note that although the letters are now displayed in a Grid layout, the menu icon is not updated correctly. Next, we'll look at how to fix this issue.

7. Fix the menu icon bug

The reason for the menu icon bug is, in onViewCreated() the RecyclerView layout is updated according to the layout setting, but not the menu icon. This issue can be resolved by redrawing the menu along with updating the RecyclerView layout.

Redrawing the options menu

Once a menu is created, it's not redrawn every frame since it would be redundant to redraw the same menu every frame. The invalidateOptionsMenu() function tells Android to redraw the options menu.

You can call this function when you change something in the Options menu, such as adding a menu item, deleting an item, or changing the menu text or icon. In this case, the menu icon was changed. Calling this method declares that the Options menu has changed, and so should be recreated. The onCreateOptionsMenu(android.view.Menu) method is called the next time it needs to be displayed.

  1. In LetterListFragment, inside onViewCreated(), at the end of the preferenceFlow observer, below the call to chooseLayout(). Redraw the menu by calling invalidateOptionsMenu() on the activity.
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
   ...
   SettingsDataStore.preferenceFlow.asLiveData().observe(viewLifecycleOwner, { value ->
           ...
           // Redraw the menu
           activity?.invalidateOptionsMenu()
   })
}
  1. Run the app again and change the layout.
  2. Exit and relaunch the app. Notice that the menu icon is now updated correctly.

ce3474dba2a9c1c8.png

Congratulations! You have successfully added Preferences DataStore to your app to save the user selection.

8. Solution code

The solution code for this codelab is in the project and module shown below.

9. Summary

  • DataStore has a fully asynchronous API using Kotlin coroutines and Flow, guaranteeing data consistency.
  • Jetpack DataStore is a data storage solution that lets you store key-value pairs or typed objects with protocol buffers.
  • DataStore provides two different implementations: Preferences DataStore and Proto DataStore.
  • Preferences DataStore does not use a predefined schema.
  • Preferences DataStore uses the corresponding key type function to define a key for each value that you need to store in the DataStore<Preferences> instance. For example, to define a key for an int value, use intPreferencesKey().
  • Preferences DataStore provides an edit() function that transactionally updates the data in a DataStore.

10. Learn more

Blog

Prefer Storing Data with Jetpack DataStore