DEV Community

Nicholas Fragiskatos
Nicholas Fragiskatos

Posted on • Originally published at blog.nicholasfragiskatos.dev on

Saving Simple Data in Android - SharedPreferences and DataStore APIs

When it comes to data persistence and storing structured data on an Android device, the first solution that comes to mind might involve using a database, like SQLite, paired with an ORM like Room. However, if the goal is to store some configuration flags, user preferences, settings, or some other relatively small handful of simple data, then reaching for a database might be unnecessary.

Fortunately, the Android framework provides a few different storage solution APIs just for this use case: SharedPreferences , Preferences DataStore, and Proto DataStore. In this article, we will look at what they are, how they differ, and how to work with them.

I created an example app to demonstrate the different solutions. I will provide small code snippets where needed, but the full sample project can be found here on GitHub.

Project Setup

The project was created using Android Studio's "Empty Views Activity" template. Then I created three new Activities, one for each storage implementation:

  • SharedPreferencesActivity

  • PrefsDataStoreActivity

  • ProtoDataStoreActivity

Each activity contains the same form inputs and a button that when clicked will persist three string values and an integer value. The difference between each activity is which API is used.

View of the three screens in the sample app, each dealing with one of the API storage solutions

Saved Data File Locations

Each API creates its own unique file in app-specific storage to save all the data. Being in app-specific storage means the data will persist until the user either clears the app data, or uninstalls the app.

Data stored using the SharedPreferences API is located in

/data/data/<APP_NAME>/shared_prefs/<SHARED_PREF_FILE>
Enter fullscreen mode Exit fullscreen mode

Data stored using the DataStore API (both Preference and Proto) is located in

/data/data/<APP_NAME>/files/datastore/<DATASTORE_FILE>
Enter fullscreen mode Exit fullscreen mode

To view these files outside of the app, we can use the Device Explorer tool in Android Studio.

View -> Tool Windows -> Device Explorer

SharedPreferences API

The SharedPreferences API is the original solution, and also the simplest. It stores a collection of key-value pairs where the values are simple data types (String, Float, Int, Long, Boolean) and are stored in an XML file. For example:

/data/data/com.nicholasfragiskatos.preferancestorageexample/shared_prefs/my_prefs.xml
Enter fullscreen mode Exit fullscreen mode

The shared preference file is created using a file name, so any number of preference files can exist at a time for the app as long as a unique name is used. We can have one file for the whole application, one for an Activity, or some combination depending on the business logic and data organization strategies.

Creating and Retrieving a Shared Preference File

Creating or retrieving a shared preference file requires using Context.getSharedPreferences(String name, int mode). Typically this will be through an Activity since an Activity is a Context.

class SharedPreferencesActivity : AppCompatActivity() {

    // ....

    // References My_Shared_Prefs.xml
    val mySharedPrefs = getSharedPreferences("My_Shared_Prefs", Context.MODE_PRIVATE)

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

💡
There are four different modes: MODE_PRIVATE, MODE_WORLD_READABLE, MODE_WORLD_WRITEABLE, and MODE_MULTI_PROCESS. However, since this is an old API, the only mode that is not deprecated is MODE_PRIVATE.

The Activity class has a wrapper function named getPreferences(int mode). Although, all that does is invoke getSharedPreferences(...) using the class name of the current Activity as the name of the file. For example:

class SharedPreferencesActivity : AppCompatActivity() {

    // ....

    // References SharedPreferencesActivity.xml
    val mySharedPrefs = getPreferences(Context.MODE_PRIVATE)

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

💡
If the preference file does not already exist, then it will only be created when a value is written. Just calling getSharedPreferences(...) will not create the file.

Writing Data

Writing to the file requires invoking SharedPreferences.edit() to obtain an Editor that helps facilitate the file IO. Then, similar to how we work with a Bundle, we use putInt(...), putString(...), etc., functions provided by the Editor to modify the preferences. Each function requires a key and value parameters, where the key is a String. Lastly, to actually write the changes to disk, we either invoke commit(), or apply().

val demographicsPrefs = getSharedPreferences(DEMOGRAPHICS_FILE_KEY, Context.MODE_PRIVATE)

demographicsPrefs.edit().apply {  
    putString(FIRST_NAME_PREF_KEY, "Leslie")  
    putString(LAST_NAME_PREF_KEY, "Knope")  
    apply()  
}
Enter fullscreen mode Exit fullscreen mode

Example XML file:

<?xml version='1.0' encoding='utf-8' standalone='yes' ?>  
<map>  
    <string name="com.nicholasfragiskatos.preferancestorageexample.FIRST_NAME_PREF_KEY">Leslie</string>  
    <string name="com.nicholasfragiskatos.preferancestorageexample.LAST_NAME_PREF_KEY">Knope</string>  
</map>
Enter fullscreen mode Exit fullscreen mode

apply() vs commit()

commit() writes the data synchronously, and it returns a boolean on whether or not the new values were successfully written.

apply() commits its changes first to the in-memory SharedPreferences immediately, and also starts an asynchronous commit to the disk. However, it does not return any value to signal success or failure.

Unless you need the return value, it's advised to just use apply().

Reading Data

SharedPreferences provides getInt(...), getString(...), etc., functions to read data from the preference file. These functions require the same key parameter used to write the value and a default value parameter in case the value doesn't exist.

val demographicsPrefs = getSharedPreferences(DEMOGRAPHICS_FILE_KEY, Context.MODE_PRIVATE)

val firstName = demographicsPrefs.getString(FIRST_NAME_PREF_KEY, "")  
val lastName = demographicsPrefs.getString(LAST_NAME_PREF_KEY, "")
Enter fullscreen mode Exit fullscreen mode

DataStore

DataStore is the new API that is meant to replace SharedPreferences. Again, the values are simple data types (String, Float, Int, Long, Boolean). However, unlike SharedPreferences, DataStore uses Kotlin coroutines and Flows to store data asynchronously and guarantee data consistency across multi-process access.

There are two flavors of DataStore provided to us, Preferences DataStore and Proto DataStore. While both are an improvement over regular SharedPreferences, each requires a bit more complexity, with Proto DataStore being the most complex by including Protocol Buffers, as we'll see later.

💡
Creating more than one DataStore object that references the same file in the same process will break functionality and result in an IllegalStateException error when reading/writing data.

Viewing DataStore Files

The DataStore implementation used will determine what type of file is created on the device. If the preferences approach is used, then a .preferences_pb file will be created. If the proto approach is used then a .proto file will be created. For example:

/data/data/com.nicholasfragiskatos.preferancestorageexample/files/datastore/MyPrefsDataStore.preferences_pb

/data/data/com.nicholasfragiskatos.preferancestorageexample/files/datastore/MyProtoSchema.proto
Enter fullscreen mode Exit fullscreen mode

Unfortunately, both file types need to be decoded first before they can be viewed outside of the app. Google provides a command line utility called protoc for Mac and Linux. protoc can be used as follows.

protoc --decode_raw < MyProtoSchema.proto
Enter fullscreen mode Exit fullscreen mode

Preferences DataStore

To get started with Preference DataStore implementation, we first need to add a dependency to the Gradle file.

implementation "androidx.datastore:datastore-preferences:1.0.0"
Enter fullscreen mode Exit fullscreen mode

Similar to SharedPreferences, we need to create a handle to the file we want to read/write from. This time though it's a DataStore object that will manage the transactions. To obtain it we use the preferencesDataStore(...) property delegate on an extension property of Context.

// make sure to use androidx.datastore.preferences.core.Preferences
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "MyPrefsDataStore")
Enter fullscreen mode Exit fullscreen mode

Defining Keys for Values

Like SharedPreferences, we access DataStore preferences with a key, but instead of a simple String key, we need to create a Preferences.Key<T> for each value. This allows the key-value pair to be typed. These keys are created using stringPreferencesKey(...), intPreferencesKey(...), etc.

val FIRST_NAME_PREF_KEY = stringPreferencesKey("${BuildConfig.APPLICATION_ID}.FIRST_NAME_PREF_KEY")  
val LAST_NAME_PREF_KEY = stringPreferencesKey("${BuildConfig.APPLICATION_ID}.LAST_NAME_PREF_KEY")  
val FAVORITE_COLOR_PREF_KEY = stringPreferencesKey("${BuildConfig.APPLICATION_ID}.FAVORITE_COLOR_PREF_KEY")  
val FAVORITE_ICE_CREAM_PREF_KEY = intPreferencesKey("${BuildConfig.APPLICATION_ID}.FAVORITE_ICE_CREAM_PREF_KEY")
Enter fullscreen mode Exit fullscreen mode

Writing Data

Writing to the file requires invoking the DataStore.edit(...) extension function that takes a suspending lambda function parameter, suspend (MutablePreferences) -> Unit.

The MutablePreferences object implements the index get/set operator functions, so we can read/write values with our defined preference keys using the same syntax as we would for a Map.

val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "MyPrefsDataStore")

class PrefsDataStoreActivity : AppCompatActivity() {

    // ...

    private suspend fun savePreferences() {
        val firstName = "Ron"
        val lastName = "Swanson"
        val favoriteColor = "Blue"  
        val iceCreamId = 2131231085 // some id for radio button

        applicationContext.dataStore.edit { settings: MutablePreferences ->  
            settings[FIRST_NAME_PREF_KEY] = firstName  
            settings[LAST_NAME_PREF_KEY] = lastName  
            settings[FAVORITE_COLOR_PREF_KEY] = favoriteColor  
            settings[FAVORITE_ICE_CREAM_PREF_KEY] = iceCreamId  
        }
    }

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

MyPrefsDataStore.preferences_pb content:

protoc --decode_raw < MyPrefsDataStore.preferences_pb

1 {
  1: "com.nicholasfragiskatos.preferancestorageexample.FIRST_NAME_PREF_KEY"
  2 {
    5: "Ron"
  }
}
1 {
  1: "com.nicholasfragiskatos.preferancestorageexample.LAST_NAME_PREF_KEY"
  2 {
    5: "Swanson"
  }
}
1 {
  1: "com.nicholasfragiskatos.preferancestorageexample.FAVORITE_COLOR_PREF_KEY"
  2 {
    5: "Brown"
  }
}
1 {
  1: "com.nicholasfragiskatos.preferancestorageexample.FAVORITE_ICE_CREAM_PREF_KEY"
  2 {
    3: 2131231085
  }
}
Enter fullscreen mode Exit fullscreen mode

Reading Data

Reading from the file is done by collecting on the DataStore.data property, which is a Flow.

val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "MyPrefsDataStore")

class PrefsDataStoreActivity : AppCompatActivity() {
    private suspend fun initFromPreferences() {  
        applicationContext.dataStore.data.collect { settings: Preferences ->  

            val firstName = settings[FIRST_NAME_PREF_KEY] ?: ""  
            val lastName = settings[LAST_NAME_PREF_KEY] ?: ""  
            val favoriteColor = settings[FAVORITE_COLOR_PREF_KEY] ?: ""  
            val favoriteIceCreamId = settings[FAVORITE_ICE_CREAM_PREF_KEY] ?: R.id.rbChocolate  
        }  
    }
}
Enter fullscreen mode Exit fullscreen mode

Proto DataStore

Using the Proto DataStore implementation requires significantly more setup than the previous two methods. However, despite the overhead, this approach does ensure type safety, and read and writes are defined by updating a custom, generated class object with named properties instead of relying on keys.

To begin, we need the following changes in the Gradle file:

  • Add Protobuf plugin

  • Add Proto DataStore, and ProtoBuf dependencies

  • Create a custom Protobuf configuration

plugins {  
    // ...
    id "com.google.protobuf" version "0.9.1"  
}

// ...

dependencies {  
    // ...

    implementation "androidx.datastore:datastore:1.0.0"  
    implementation "com.google.protobuf:protobuf-javalite:3.25.0"  

}

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

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

Next, we need to create a schema in the app/src/main/proto directory of the project. It's a simple text file with the extension .proto. Here's a basic example for the sample application, but the Protocol Buffer Documentation provides more details.

syntax = "proto3";  

option java_package = "com.nicholasfragiskatos.preferancestorageexample";  
option java_multiple_files = true;  

message MySettings {  
    string first_name = 1;  
    string last_name = 2;  
    string favorite_color = 3;  
    int32 favorite_ice_cream_flavor = 4;  
}
Enter fullscreen mode Exit fullscreen mode

With the schema defined, a MySettings class will be generated for us. The generated class implementation is extensive, but one convenient thing to point out is that it has a corresponding property for each property defined in the schema. For example, the schema defines a first_name property, so MySettings will have a firstName property.

The next part of the setup is to create a custom Serializer that tells DataStore how to read and write our custom data type (MySettings) as defined by the schema. This is largely boilerplate code.

object MySettingsSerializer : Serializer<MySettings> {  

    override val defaultValue: MySettings = MySettings.getDefaultInstance()  
    override suspend fun readFrom(input: InputStream): MySettings {  
        try {  
            return MySettings.parseFrom(input)  
        } catch (exception: InvalidProtocolBufferException) {  
            throw CorruptionException("cannot read proto.", exception)  
        }  
    }  

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

Now, when we read from the file DataStore provides a MySettings object, and when we write to the file we give DataStore a MySettings object.

Lastly, similar to preferences DataStore, we need to create a DataStore object that will manage the transactions. This time we use the dataStore(...) property delegate on an extension property of Context.

val Context.myProtoDataStore: DataStore<MySettings> by dataStore("MyProtoSchema.proto", serializer = MySettingsSerializer)
Enter fullscreen mode Exit fullscreen mode

Writing Data

Writing to the file requires invoking the DataStore.updateData(...) function that takes a suspending lambda function parameter, suspend (MySettings) -> MySettings. We use the builder pattern to create and return an updated MySettings object to save.

val Context.myProtoDataStore: DataStore<MySettings> by dataStore("MyProtoSchema.proto", serializer = MySettingsSerializer)  

class ProtoDataStoreActivity : AppCompatActivity() {  

private lateinit var binding: ActivityProtoDataStoreBinding  

    // ...

    private suspend fun savePreferences() {  
        val firstName = "Ben"
        val lastName = "Wyatt"
        val favoriteColor = "Green"
        val iceCreamId = 2131231084 // some id for radio button

        applicationContext.myProtoDataStore.updateData { settings ->  
            settings.toBuilder()  
            .setFirstName(firstName)  
            .setLastName(lastName)  
            .setFavoriteColor(favoriteColor)  
            .setFavoriteIceCreamFlavor(iceCreamId)  
            .build()  
        }  
    }  
}
Enter fullscreen mode Exit fullscreen mode

MyProtoSchema.proto content:

protoc --decode_raw < MyProtoSchema.proto
1: "Ben"
2: "Wyatt"
3: "Green"
4: 2131231084
Enter fullscreen mode Exit fullscreen mode

Reading Data

Like Preferences DataStore, reading from the file is done by collecting on the DataStore.data property, which is a Flow.

val Context.myProtoDataStore: DataStore<MySettings> by dataStore("MyProtoSchema.proto", serializer = MySettingsSerializer)  

class ProtoDataStoreActivity : AppCompatActivity() {  

private lateinit var binding: ActivityProtoDataStoreBinding  

    // ...

    private suspend fun initFromPreferences() {  
        applicationContext.myProtoDataStore.data.collect { settings ->  
            binding.etFirstName.setText(settings.firstName)  
            binding.etLastName.setText(settings.lastName)  
            binding.etFavoriteColor.setText(settings.favoriteColor)  
            binding.rgIceCreamFlavor.check(settings.favoriteIceCreamFlavor)  
        }  
    }  

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

Conclusion

The Android framework provides the developer with SharedPreferences , Preferences DataStore, and Proto DataStore APIs to persist sets of structured data without having to rely on a database. All three APIs write and read simple values (String, Float, Int, Long, Boolean) to a file in app-specific storage.

SharedPreferences is the original solution and is built into the Context interface with the getSharedPreferences(...) function. We write and read values to an XML file using String keys. While simple and convenient, it does rely on managing unique keys for all values. Also, it could perform synchronous file I/O on the main UI thread if using commit(), and even if apply() is used instead of commit() then there is no mechanism for signaling success or failure.

The Preferences DataStore API improves upon SharedPreferences by taking advantage of Kotlin coroutines and Flows to store data asynchronously and guarantee data consistency across multi-processor access. While this solution also relies on keys to write data, this time they are not String values but instead Preferences.Key<T> objects that allow the key-value pair to be typed.

Lastly, we learned about Proto DataStore. Like Preferences DataStore, it provides the benefits of asynchronous writes using coroutines and Flows. Another major benefit is that instead of managing key-value pairs, we get a custom, schema-backed, generated class with regular named properties. Proto DataStore provides this custom object when reading from storage, and we provide the same object when writing back to storage. However, these benefits come at the cost of much greater complexity in setup and configuration.


Thank you for taking the time to read my article. I hope it was helpful.

If you noticed anything in the article that is incorrect or isn't clear, please let me know. I always appreciate the feedback.


Top comments (0)