Simple Preferences & Proto DataStore Demo App

vtsen

Vincent Tsen

Posted on June 17, 2023

Simple Preferences & Proto DataStore Demo App

Beginner's friendly step-by-step guide to learn how to use Preferences and Proto DataStore, Room Database is not covered.

abc.com

There are a few ways you can store data locally on your Android devices:

  • SharedPreferences (replaced by Preferences DataStore)

  • Preferences DataStore

  • Proto DataStore

  • Room Database

SharedPreferences is the old way, which has been replaced by Preferences DataStore due to its shortcomings (e.g. not safe to be called from the UI thread).

In this article, I'm going to share how to store data using both Preferences DataStore and Proto DataStore. Room Database will be covered later in the next article.

Prefer which way to store data?

Preferences DataStore stores data in a simple key-value format. It is usually for a small and simple dataset. If you have a more complex data format, use Proto DataStore.

Proto DataStore stores data in custom data type format, which is defined using protocol buffers. This is usually for a medium size dataset and it doesn't support partial updates. So for a very large dataset, it is better to use the Room Database.

Room Database is similar to Proto DataStore which can store data in custom data type in a relational database (SQLite) format. Since it supports partial updates, so there won't be any problem storing a very large and complex Database.

Preferences DataStore

Here is a simple app example to store 2 setting values in preferences DataStore. One is Boolean format and another is Int format.

1. Add Preferences DataStore Library

dependencies {
    implementation("androidx.datastore:datastore-preferences:1.0.0")
}
Enter fullscreen mode Exit fullscreen mode

2. Create a Preferences DataStore

It can be created anywhere and usually, you can do it at the file level of your main activity.

val Context.prefsDataStore by preferencesDataStore(name = "settings")
Enter fullscreen mode Exit fullscreen mode

You can also create more than one DataStore but make sure you give it a unique name.

val Context.prefsDataStore1 by preferencesDataStore(name = "settings1")
val Context.prefsDataStore2 by preferencesDataStore(name = "settings2")
val Context.prefsDataStore3 by preferencesDataStore(name = "settings3")
Enter fullscreen mode Exit fullscreen mode

3. Access Preferences DataStore in Composable

To access the DataStore in Composable, you can pass it in from your activity as a parameter or simply use the LocalContext CompositionLocal which I prefer.

@Composable
fun YourComposable() {
    val dataStore = LocalContext.current.prefsDataStore
}
Enter fullscreen mode Exit fullscreen mode

4. Setup the Preferences Key and Name

Before we can use Preferences DataStore(read from it and write to it), you need to set up the preferences key and name.

This creates booleanPreferencesKey() which allows you to read from / write to the DataStore.

 private val booleanOptionName = "Boolean Option"
 private val booleanOptionKey = booleanPreferencesKey(booleanOptionName)
Enter fullscreen mode Exit fullscreen mode

Here is the list of supported preferences key data types:

  • intPreferencesKey

  • doublePreferencesKey

  • stringPreferencesKey

  • booleanPreferencesKey

  • floatPreferencesKey

  • longPreferencesKey

  • stringSetPreferencesKey

If these data types do not meet your needs, Proto DataStore is probably what you need.

5. Write to a Preferences DataStore

This is what I have in my view model. To write to a preferences DataStore, you use the DataStore<Preferences>.edit() suspend API. Since it is a suspend function, you need to launch it under a coroutine.

class PrefsDataStoreScreenViewModel(
    private val dataStore: DataStore<Preferences>
) : ViewModel() {
    /*...*/
    private val booleanOptionName = "Boolean Option"
    private val booleanOptionKey = booleanPreferencesKey(booleanOptionName)

    fun saveBooleanOptionValue(value: Boolean) {
        viewModelScope.launch {
            dataStore.edit { preferences ->
                preferences[booleanOptionKey] = value
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The preferences here is the MutablePreferences very much behave the same as MutableMap in Kotlin which you can assign key-value pairs.

6. Read from a Preferences DataStore

To read, we use DataStore<Preferences>.data flow API which exposes Flow<Preferences>

Typically, I don't want to expose the flow directly in ViewModel. I prefer to expose the StateFlow instead because I don't want the flow to keep emitting whenever there is a new collector.

class PrefsDataStoreScreenViewModel(
    private val dataStore: DataStore<Preferences>
) : ViewModel() {

    private val booleanOptionName = "Boolean Option"
    private val booleanOptionKey = booleanPreferencesKey(booleanOptionName)

    val booleanOptionState = dataStore.data.map { preferences ->
            preferences[booleanOptionKey] ?: false
        }.stateIn(
            scope = viewModelScope,
            started = SharingStarted.Eagerly,
            initialValue = false
        )
    /*...*/
}
Enter fullscreen mode Exit fullscreen mode

It looks complex, but let me break it out.

This converts Flow<Preferences> to Flow<Boolean> using Flow<T>.map() API. When the value is null, it returns false since we don't want the nullable Boolean (e.g. Flow<Boolean?>)

val booleanFlow = dataStore.data.map { preferences ->
    preferences[booleanOptionKey] ?: false
}
Enter fullscreen mode Exit fullscreen mode

Then, we convert Flow<Boolean> to StateFlow<Boolean> using the Flow<T>.stateIn() API.

val booleanStateFlow = booleanFlow.stateIn(
    scope = viewModelScope,
    started = SharingStarted.Eagerly,
    initialValue = false
)
Enter fullscreen mode Exit fullscreen mode

To collect the StateFlow in composable function, you can use collectAsStateWithLifecycle().

@Composable
fun PrefsDataStoreScreen(doneCallback: ()->Unit) {
    /*...*/
    val booleanOptionValue by 
        viewModel.booleanOptionState.collectAsStateWithLifecycle()
    /*...*/
}
Enter fullscreen mode Exit fullscreen mode

If you have trouble understanding this, you may want to refer to this article.

For the rest, please refer to the source code. The code also uses a custom ViewModelFactory. If you're not familiar with it, you can read this article.

Now, let's move on to Proto DataStore...

Proto DataStore

I implemented the bookmarked articles for my RSS feed reader app using Proto DataStore. So, I'm going to use it as an example here, which is to store a list of bookmarked articles.

1. Add DataStore & protobuf-javalite Libraries

dependencies {
    implementation("androidx.datastore:datastore:1.0.0")
    implementation("com.google.protobuf:protobuf-javalite:3.21.12")
}
Enter fullscreen mode Exit fullscreen mode

Setting up DataStore is straightforward, but not the protocol buffers library and plugin. The official documentation has missed this information, so I have to figure it out by myself.

2. Add protobuf Plugins

plugins {
    /*...*/
    id ("com.google.protobuf") version("0.9.0")
}
Enter fullscreen mode Exit fullscreen mode

3. Add Java Protobuf-lite code Generation

Add the following code at the end of your app-level build.gradle/kts file.

Groovy

plugins {
    /*...*/
    id "com.google.protobuf" version '0.9.0'
}

android {
    /*...*/
}

dependencies {
    /*...*/
}

protobuf {
    protoc {
        artifact = "com.google.protobuf:protoc:3.19.4"
    }

    generateProtoTasks {
        all().each { task ->
            task.builtins {
                java {
                    option 'lite'
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Kotlin (KTS)

plugins {
    /*...*/
    id ("com.google.protobuf") version("0.9.0")
}

android {
    /*...*/
}

dependencies {
    /*...*/
}

protobuf {
    protoc {
        artifact = "com.google.protobuf:protoc:3.19.4"
    }

    generateProtoTasks {
        all().forEach { task ->
            task.builtins {
                create("java") {
                    option("lite")
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

I added both Groovy and Kotlin versions here because there are not exactly 1-to-1 matches. I have issues converting from Groovy to Kotlin.

  • all().each is Groovy needs to be changed to all().forEach in Kotlin/KTS

  • Java's option in Groovy is not recognized by Kotlin/KTS.

4. Define a ProtoBuf Scheme

The data structure format that you want to store is defined in this ProtoBuf scheme file (*.proto) and you need to create this proto file in app/src/main/proto/ directory in your app module.

In this example, I would like to store the list of bookmarked article IDs in string format and here is the ProtoPreferences.proto file.

syntax = "proto3";

option java_package = "vtsen.hashnode.dev.datastoredemoapp";
option java_multiple_files = true;

message ProtoPreferences {
  //first field 
  map<string, bool> bookmarked_article_ids = 1;
  //second field
  map<string, bool> read_article_ids = 2;
}
Enter fullscreen mode Exit fullscreen mode
  • syntax="proto3" indicates the definition follows proto3 version rules

  • java_package refers to the applicationId in your build.gradle.kts file.

  • java_multiple_files = true configure each message has it's own Java class file.

  • message ProtoPreferences is a message type that has 2 fields in the above example.

  • map<string, bool> is the map data structure where the key is the article Id in string format and the value is a boolean that indicates whether the article is bookmarked.

Tip: If you name your proto file name the same as the message name (e.g. ProtoPreferences), make sure you use the same capitalization. For example, protopreferences.proto won't work. It must be ProtoPreferences.proto or some other name.

Compile the code, make sure you get the successful build and the ProtoPreferences Java class should be generated.

5. Implement DataStore Serializer Interface

Implement the Serializer interface singleton object for generated ProtoPreferences Java class (ProtoBuf scheme) that you created in step 4 above.

object ProtoPreferencesSerializer : Serializer<ProtoPreferences> {
    override val defaultValue: ProtoPreferences
        = ProtoPreferences.getDefaultInstance()

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

    override suspend fun writeTo(
        t: ProtoPreferences, 
        output: OutputStream) = t.writeTo(output)
}
Enter fullscreen mode Exit fullscreen mode

This is pretty standard code to read from and write to the serializer, so I'm not going to explain it here.

6. Create a Proto DataStore

Similar to Preferences DataStore, instead of using by preferenceDataStore delegate, you use by dataStore

val Context.protoDataStore: DataStore<ProtoPreferences> by dataStore(
    fileName = "ProtoPreferences.pb",
    serializer = ProtoPreferencesSerializer
)
Enter fullscreen mode Exit fullscreen mode

In this example, it is created in the main activity. Depending on your need, you can also create it in the repository class if it is only referenced there. You can see the example here.

7. Write to a Proto DataStore

DataStore<T>.updateData() suspend function is used to update the Proto DataStore data. It needs a builder to build a MessageTypeas you can see below.

class ProtoDataStoreScreenViewModel(
    private val dataStore: DataStore<ProtoPreferences>
) : ViewModel() {
    /*...*/    
    fun saveBookmarkedArticle(articleId: String, bookmarked: Boolean) {
        viewModelScope.launch {
            dataStore.updateData { protoPreferences ->
                protoPreferences.toBuilder()
                    .putBookmarkedArticleIds(articleId, bookmarked)
                    .build()
            }
        }
    }
    /*...*/
}
Enter fullscreen mode Exit fullscreen mode
  • putBookmarkedArticleIds() API is auto-generated based on what you define in the ProtoBuf scheme. For other generated APIs, refer to the generated ProtoPreferences.java file.

8. Read from a Proto DataStore

Reading is very similar to Preferences DataStore.

DataStore<ProtoPreferences>.data flow API exposes Flow<ProtoPreferences>. Then, it is converted to Flow<Map<String, Boolean>> using flow mapping. Finally, it is converted to StateFlow<Map<String, Boolean>> using stateIn flow operator.

class ProtoDataStoreScreenViewModel(
    private val dataStore: DataStore<ProtoPreferences>
) : ViewModel() {
    /*...*/
    val bookmarkedArticlesState = dataStore.data.map { protoPreferences ->
            protoPreferences.bookmarkedArticleIdsMap
        }.stateIn(
            scope = viewModelScope,
            started = SharingStarted.Eagerly,
            initialValue = null)
}
Enter fullscreen mode Exit fullscreen mode

Here is the usage which you are probably already familiar with.

@Composable
fun ProtoDataStoreScreen(doneCallback: ()->Unit) {

    val viewModel: ProtoDataStoreScreenViewModel = viewModel(
        factory = ViewModelFactory(LocalContext.current.protoDataStore)
    )

    val articles by viewModel.bookmarkedArticlesState
        .collectAsStateWithLifecycle()

    /*...*/
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Preferences DataStore is relatively easy to implement with comprehensive documentation available. However, setting up Proto DataStore can be a bit challenging due to the lack of proper documentation.

To address this, I have taken the initiative to document the process here, providing a valuable reference for anyone encountering similar difficulties.

Source Code

GitHub Repository: Demo_DataStore


Originally published at https://vtsen.hashnode.dev.

💖 💪 🙅 🚩
vtsen
Vincent Tsen

Posted on June 17, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related