Some time ago, Google introduced a new part of Android Jetpack, DataStore. It’s a library that is supposed to replace SharedPreferences. 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.
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.
As it was mentioned earlier, we have two types of DataStore available:
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")
}
}
createDataStore()
and pass the name of the DataStore.preferencesKey<T>(name)
. Type T is the desired type of value stored under this key. edit()
function from the DataStore object. Inside the lambda we have access to MutablePreferences, so we can change the value under the specified key. The edit()
function is a suspend function, so it needs to be called from CoroutinesContext.Flow<Preferences>
under dataStore.data
. Using the map{}
operator, we can get Flow<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.
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:
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'
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
})
)
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