Say Goodbye to SharedPreferences - Meet DataStore
This is the reason for the catchy title: Sooner or later, all of us will probably be forced to switch to DataStore.
There are some similarities between those two APIs, but DataStore offers more flexibility. It comes with two different types of storage. The first one is simple key-value pair storage, just like SharedPreferences. The second type, Proto DataStore, is more interesting and complex. It’s based on Google’s Protobuf library and allows us to create more complex data structures. The whole DataStore is currently in alpha. Before we dive into details, let’s take a look at the comparison between DataStore and SharedPreferences.
SharedPreferences and DataStore comparison
- DataStore is built on top of Kotlin Coroutines and Flow, so it provides a pretty good asynchronous API for modifying and reading data. On the other hand, SharedPreferences provides asynchronous access only via a listener on changing values.
- DataStore can be called on the UI thread without any problems. It changes the dispatcher to IO under the hood.
- DataStore is also safe from runtime exceptions during data parsing.
It is important to mention that DataStore is not a replacement for Room. It’s good only for small datasets and when there is no need for partial updates or referential integrity. If you need any of those, consider using Room.
Types of DataStore
As it was mentioned earlier, we have two types of DataStore available:
- Preferences DataStore, which is very similar to SharedPreferences. It stores key-value pairs and does not provide any type safety.
- Proto DataStore, which is more complex. It can store data in custom objects. Thanks to that, it provides type safety out of the box. It requires a bit more work during setup, because it’s required to create a schema using protocol buffers.
Preferences DataStore
First, let’s take a look at key-value storage. Setup is pretty easy. To start using this version of DataStore, simply add a dependency to your app’s build.gradle.
// Preferences DataStore
implementation "androidx.datastore:datastore-preferences:1.0.0-alpha02"
I’ve created a simple ProfilePreferences class to handle DataStore. Basically, only a simple Boolean in Preferences is stored. It determines if Profile information can be changed.
class ProfilePreferences(context: Context) {
private val dataStore: DataStore<Preferences> = context.createDataStore(
"profile",
migrations = listOf(SharedPreferencesMigration(context, "oldProfilePreferences"))
)
val editStateFlow: Flow<Boolean> = dataStore.data
.map{ it[EDIT_MODE_ENABLED_KEY] ?: true }
suspend fun toggleEditMode(enabled: Boolean) {
dataStore.edit {
it[EDIT_MODE_ENABLED_KEY] = enabled
}
}
companion object {
private val EDIT_MODE_ENABLED_KEY = preferencesKey<Boolean>("edit_mode_enabled")
}
}
- ProfilePreferences needs a Context to create a DataStore. With that provided, we can use the extension function
createDataStore()
and pass the name of the DataStore. - There is a possibility to add a migration from SharedPreferences. Just pass a SharedPreferenceMigration object with the name of the preferences to be migrated.
- DataStore does not use plain Strings as keys, they’re wrapped by Key a class. To get a specific key, you need to use
preferencesKey<T>(name)
. Type T is the desired type of value stored under this key. - To change the stored value, we can use an
edit()
function from the DataStore object. Inside the lambda we have access to MutablePreferences, so we can change the value under the specified key. Theedit()
function is a suspend function, so it needs to be called from CoroutinesContext. - To get stored preferences, we have access to
Flow<Preferences>
underdataStore.data
. Using themap{}
operator, we can getFlow<Boolean>
.
To change the value of EDIT_MODE, just use the toggleEditMode()
method from ProfilePreferences
in ViewModel.
fun toggleEditMode(enabled: Boolean) {
viewModelScope.launch {
profileRepository.toggleEditMode(enabled)
}
}
When that operation completes, a new value is shared using Flow. In our example, I converted that Flow into LiveData with the asLiveData()
extension and I observed that in Activity.
val editEnabled = profileRepository.editStateFlow.asLiveData()
viewModel.editEnabled.observe(this) {
with(binding) {
name.isEnabled = it
surname.isEnabled = it
}
}
Pretty easy to use, right? Now let’s jump into the Proto DataStore example.
Proto DataStore
As I mentioned before, Proto DataStore can store instances of custom data. To do this, you must define a schema of that data using Protocol Buffers. The example schema used in my project looks like below.
syntax = "proto3";
option java_package = "com.netguru.datastoresample";
option java_multiple_files = true;
message ProfileInfo {
string surname = 1;
string name = 2;
}
As you can see, despite the data structure, there are also additional options. Buffers use proto2
by default, so to aim for the latest syntax we must provide it explicitly. The two other options define where and how the generated Java classes should be created. The last and most important thing in this file is the message definition. It’s a schema for the compiler on how it should generate a new class.
When I was writing this article, the documentation of Proto DataStore was missing some details about the configuration part. Based on that documentation, someone can think that this is enough when it comes to preparations. Unfortunately this is not the case, and I found it a little difficult to set up everything correctly. The part about generating Java classes based on the defined schema was missing.
I chose Wire for code generation. There are also some official Google tools, but Wire can generate Kotlin classes. It was designed specifically for Android, so it’s better optimized and the generated code is much cleaner. The same class generated by Google tools had about 400 lines of code, while Wire generated only 130 lines. Classes generated by Wire are also parcelable, so we can simply pass them through a Bundle. When it comes to the configuration, we need a couple of things:
- Gradle plugin, which you should add in your root build.gradle.
classpath "com.squareup.wire:wire-gradle-plugin:3.3.0"
You also need to add this plugin in your app’s build.gradle.
apply plugin: 'com.squareup.wire'
- Next, you need to add the Protobuf runtime library and compiler.
implementation "com.squareup.wire:wire-runtime:3.3.0"
implementation "androidx.datastore:datastore-core:1.0.0-alpha02"
Last but not least, add the configuration block for the Gradle plugin.
wire {
kotlin {
android = true
}
}
If you already have your schema prepared, just rebuild the project and everything should be correctly generated.
Let’s go back to the schema example.
message ProfileInfo {
string surname = 1;
string name = 2;
}
Protobuffers support a couple of data types. First, they can store all scalars, so integers, doubles, etc. We can also use enums or even other messages as types. For more information, you can check the documentation.
The provided sample schema only contains 2 strings fields. The compiler will create a ProfileInfo class based on that message. Don’t confuse the numbers assigned to fields with default values. These numbers are tags added to those values by the generator.
When we already have a generated model class, we need to create a Serializer to persist this data.
object ProfileInfoSerializer : Serializer<ProfileInfo> {
override fun readFrom(input: InputStream): ProfileInfo {
try {
return ProfileInfo.ADAPTER.decode(input)
} catch (exception: InvalidProtocolBufferException) {
throw CorruptionException("Cannot read proto.", exception)
}
}
override fun writeTo(t: ProfileInfo, output: OutputStream) = ProfileInfo.ADAPTER.encode(output,t)
}
It may look boilerplate code, and in most cases probably will be. However, with that abstraction we can, for example, provide encryption to our data before saving it to the DataStore.
Finally, let’s take a look at the usage and creation of DataStore itself. It looks pretty similar to Preferences DataStore.
class ProfileStore(context: Context) {
private val dataStore: DataStore<ProfileInfo> = context.createDataStore(
fileName = "basket_item.pb",
serializer = ProfileInfoSerializer
)
val profileInfoFlow = dataStore.data
suspend fun changeName(name: String) {
dataStore.updateData {
it.copy(name = name)
}
}
suspend fun changeSurname(surname: String) {
dataStore.updateData {
it.copy(surname = surname)
}
}
}
To create a Proto DataStore, we need to provide a name of the file where data should be stored and our serializer that we created before.
If you want to migrate from SharedPreferences to Proto DataStore, you can. All you have to do is create a map function where you will add data from SharedPreferences into specific fields in the model class.
private val dataStore: DataStore<ProfileInfo> = context.createDataStore(
fileName = "profile_info.pb",
serializer = ProfileInfoSerializer,
migrations = listOf(SharedPreferencesMigration(context,"profile_preferences")
{ sharedPreferences: SharedPreferencesView, profileInfo: ProfileInfo ->
// Map preferences into profile info
})
)
Summary
DataStore has many benefits over SharedPreferences. One is support for coroutines and flow, which makes asynchronous reads and writes possible and safe to call from the UI thread. Then there’s support for error handling. In my opinion, this will be a great replacement for SharedPreferences. Migration options are making it even better, because it can be implemented in any project, not only in a new one. I encourage you to try it, even in a sample project, and become more familiar with this library.
Photo by Lucas van Oort on Unsplash