DataStore / Jetpack

DataStore: Basics

In Early September 2020 Google released the first Alpha of a new Jetpack library named DataStore. DataStore is a replacement for SharedPreferences. While there are some similarities to SharedPreferences, DataStore offers far greater flexibility. In this series of posts we’ll take a detailed look at this new library.

NOTE: There is an update to this series of articles that covers how to use newer releases of DataStore.

DataStore comes in two separate flavours: Preferences and Proto. The Preferences flavour stores data as simple key / value pairs, and is pretty similar to SharedPreferences in the respect. However, Protobuf is the foundation for the Proto library which allows us to use more complex data structures.

At the time of writing, the official documentation and codelab mainly focus upon the Preferences flavour. Although there is a simple example of the Proto flavour, there are some omissions which mean that we can’t just lift the example code and use it in our projects. Moreover, the strongly-typed nature of the Proto flavour make it far more interesting. Also, the API for the Proto flavour makes it far more flexible. So this series will focus upon the Proto flavour.

Update: Since writing this article a DataStore Proto codelab has been published which configures the build similarly to how I’ve done it here. However, we’ll be building upon this foundation in future articles in this series.

Code Generation

I mentioned some omissions in the official documentation, so let’s qualify that. The Proto documentation shows the proto schema which represents the data structure that will be persisted using Protobuf. However, we must perform code generation to create the JVM components representing this. Specifically this is the Settings class that is used as the generic type for both SettingsSerializer and DataStore in the Kotlin example.

While this may initially feel like a major omission, it is quite understandable if we consider that there are multiple tools available for this. Different projects may have different requirements, so we can chose the tool which best meets those requirements. We are not tied to any specific code generation tool which is a sign of a well designed library.

Google Protobuf Tools

To get things working, I chose the official Google code gen tools. These consist of three components: A Gradle plugin, the Protobuf compiler, and the Protobuf runtime library. Specifically I chose to go with the Java lite option because it has a smaller runtime library making it more suitable for mobile.

First we need to add the Gradle plugin to our top level build.gradle:

buildscript {
    repositories {
        google()
        jcenter()
        mavenCentral()
    }
    dependencies {
        classpath "com.android.tools.build:gradle:4.2.0-alpha09"
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.4.0"
        classpath "com.google.dagger:hilt-android-gradle-plugin:2.28.3-alpha"
        classpath "com.google.protobuf:protobuf-gradle-plugin:0.8.13"

        // NOTE: Do not place your application dependencies here; they belong
        // in the individual module build.gradle files
    }
}
.
.
.

Next we need to update the app build.gradle file:

apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'com.google.protobuf'
apply plugin: 'kotlin-kapt'
apply plugin: 'dagger.hilt.android.plugin'

android {
    .
    .
    .
}

dependencies {
    .
    .
    .
    implementation "com.google.protobuf:protobuf-javalite:4.0.0-rc-2"
    .
    .
    .
}
.
.
.
protobuf {
    protoc {
        artifact = ""com.google.protobuf:protoc:4.0.0-rc-2"
    }
    generateProtoTasks {
        all().each { task ->
            task.builtins {
                java {
                    option "lite"
                }
            }
        }
    }
}

First we apply the Protobuf gradle plugin. Then we add a dependency for the java-lite runtime library. Finally the protobuf config block is the configuration for the Gradle plugin. It specifies which version of the protac compiler to use (I had issues if the compiler version and runtime library version did not match). The generateProtoTasks block adds a command line flag to the protac invocation which specifies that we should use lite mode.

It took me a fair bit of trial and error (and searching StackOverflow!) to get a working configuration. But hopefully this will help those trying DataStore Proto for the first time.

SimpleData

Now that we have the build tools in place, let’s start with a simple example. First we’ll create the proto schema for the data that we want to persist:

syntax = "proto3";

option java_package = "com.stylingandroid.datastore.data";
option java_multiple_files = true;

message SimpleData {
    string text = 1;
}

This is about as simple as we can go. Don’t worry we’ll explore more complexity later in the series. The first line specifies that we’ll use version 3 of the protobuf syntax. Next we define the package name under which the java classes will be generated.

The next line forces each java class to be generated in a separate file. The protobuf compiler generates three separate classes, and it will cause build issues if these get generated inside a single file.

Finally we have our data structure named SimpleData which contains a single string field.

The generated Java class source code is actually about 280 lines. So the generated code is far more than a simple POJO.

We must write a Serializer persist this data. This is a wrapper around some of the persistence methods that are in the generated class:

object SimpleDataSerializer : Serializer {

    override fun readFrom(input: InputStream): SimpleData {
        return try {
            SimpleData.parseFrom(input)
        } catch (exception: InvalidProtocolBufferException) {
            throw CorruptionException("Cannot read proto", exception)
        }
    }

    override fun writeTo(t: SimpleData, output: OutputStream) {
        t.writeTo(output)
    }
}

This might appear to be unnecessary boilerplate which could easily have been built in to DataStore. However, it is actually an important abstraction point. The value of this will become apparent later in the series.

The DataStore

Now that we have the data itself and the ability to persist it, we can create the DataStore itself. I chose to use Hilt in the sample app, so we have a Dagger Module which is capable of injecting the DataStore wherever it may be required.

@Module
@InstallIn(ApplicationComponent::class)
object DataStoreModule {

    @Provides
    fun providesDataStore(@ApplicationContext context: Context): DataStore =
        context.createDataStore(
            fileName = "DataStoreTest.pb",
            serializer = SimpleDataSerializer
        )
}

The providesDataStore() method is responsible for creating a DataStore instance. This specifies a filename in app storage to which the data will be persisted, and the Serializer to use to persist the data. In this case it’s the Serializer that we just created.

ViewModel

The ViewModel where we’ll use this looks like this:

) : ViewModel() {

    val text = dataStore.data
        .map { it.text }
        .asLiveData(viewModelScope.coroutineContext)

    val mutableText = TwoWayBinder(text) { newValue ->
        viewModelScope.launch {
            dataStore.updateData { simpleData ->
                simpleData.toBuilder()
                    .setText(newValue)
                    .build()
            }
        }
    }
}

There’s quite a bit going on here, so let’s break it down. A DataStore<SimpleData> instance is injected. The text file is a LiveData<String> which represents the value of the single String field within SimpleData. The map function maps the SimpleData object to its text field. This is then converted from a Flow to LiveData.

The mutableText field is a wrapper around this the text LiveData, converting it to a MutableLiveData instance using TwoWayBinder:

class TwoWayBinder(
    private val liveData: LiveData,
    private val updateListener: (T) -> Unit = {}
) : MutableLiveData() {

    override fun onActive() {
        super.onActive()
        liveData.observeForever(::setValue)
    }

    override fun setValue(newValue: T) {
        val changed = value != newValue
        super.setValue(newValue)
        if (changed) {
            updateListener(newValue)
        }
    }

    override fun onInactive() {
        liveData.removeObserver(::setValue)
        super.onInactive()
    }
}

This take a LiveData object and a lambda as arguments. It observes liveData when it has active observers. Whenever the value of liveData is updated, it checks whether the value has actually change. If it has, it invokes the lambda.

Going back to mutableText in the ViewModel, TwoWayBinder wraps text and, whenever its value changes, the lambda is invoked. The change detection is important here. The value of mutableText can change for one of two reasons: Either the value of text changes because the DataStore value has changed, or a consumer changes the value of mutableData.

In the first case, the change is being emitted from DataStore because the value has changed. In the second case, the lambda will update the value in the DataStore which will get persisted, and will cause DataStore to emit a new value. Without the value changed check in TwoWayBinding this will cause an infinite loop.

I should mention that the updateData() method of DataStore is a suspend function hence we must wrap it in a launch block. DataStore will automatically perform the work on an IO thread so we don’t need to explicitly do that here.

Also, there is no synchronous method for obtaining the current value from DataStore

The Layout

Let’s take a look at the layout now that the business logic is in place. I have opted to use DataBinding here because it play extremely nicely with LiveData. I opted to use two way data binding here, and that is reflected in the naming of the TwoWayBinder class that we saw earlier.




    
        
    

    

        

        

        

    

Here we have an EditText and a TextView.

Let’s start with the TextView as it’s is simpler. It is bound to the text field in the ViewModel. Whenever the value of that changes, then the text of the TextView will be updated. A change in the value store in the DataStore will trigger an update to the TextView text.

The EditText has a two-way binding on mutableText – the @= prefix indicates two-way binding. This behaves identically to the TextView, but will also update the value of mutableText whenever the user chances the contents of the field. So editing this will trigger an update to the persisted value in the DataStore.

The net result of all this is that if we alter the contents of the EditText, these changes get propagated through to DataStore which persists the changes, and then triggers an update on the Flow. This updates the text in the TextView:

Synchronous Operation

One important thing to remember about DataStore is that it does not provide a synchronous mechanism for reading persisted values. The only option is to subscribe to a Flow. There might be a temptation to wrap a call in runBlocking {} and return the first value emitted on the Flow. However, I would try and avoid this. It will perform potentially expensive operations on the UI thread. A far better approach is to subscribe to the Flow for the lifecycle of the component within which it will be accessed. Then that component can access the last emitted value to get the current state.

For example, in a ViewModel we can do something like this:

private val liveData = dataStore.data.asLiveData(viewModelScope.coroutineContext)
    
private lateinit var current: SimpleData

private fun observer(simpleData: SimpleData) {
    current = simpleData
}
    
init {
    liveData.observeForever(::observer)
}

override fun onCleared() {
    liveData.removeObserver(::observer)
    super.onCleared()
}

This will work for the vast majority of case – we can simple use current to obtain the latest state. The only potential issue is the race condition if we attempt to access current before an initial value has been emitted on the Flow. We can use ::current.isInitialized to check for that, and observe liveData until it emits a value.

This provides what appears to be synchronous access to the data, without accessing the DataStore synchronously.

Conclusion

We have a basic example working. However there’s an awful lot more that we can build on top of this foundation. In the next article we’ll consider alternatives to using the Google Protobuf codegen.

The source code for this article is available here.

© 2020 – 2021, Mark Allison. All rights reserved.

Copyright © 2020 Styling Android. All Rights Reserved.
Information about how to reuse or republish this work may be available at http://blog.stylingandroid.com/license-information.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.