Vincent Tsen
Posted on June 17, 2023
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")
}
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")
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")
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
}
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)
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
}
}
}
}
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
)
/*...*/
}
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
}
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
)
To collect the StateFlow in composable function, you can use collectAsStateWithLifecycle()
.
@Composable
fun PrefsDataStoreScreen(doneCallback: ()->Unit) {
/*...*/
val booleanOptionValue by
viewModel.booleanOptionState.collectAsStateWithLifecycle()
/*...*/
}
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")
}
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")
}
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'
}
}
}
}
}
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")
}
}
}
}
}
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;
}
syntax="proto3"
indicates the definition follows proto3 version rulesjava_package
refers to theapplicationId
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)
}
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
)
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 MessageType
as 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()
}
}
}
/*...*/
}
-
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)
}
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()
/*...*/
}
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.
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
September 16, 2023