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.
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>
Data stored using the DataStore API (both Preference and Proto) is located in
/data/data/<APP_NAME>/files/datastore/<DATASTORE_FILE>
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
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)
// ...
}
💡
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)
// ...
}
💡
If the preference file does not already exist, then it will only be created when a value is written. Just callinggetSharedPreferences(...)
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()
}
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>
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, "")
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 oneDataStore
object that references the same file in the same process will break functionality and result in anIllegalStateException
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
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
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"
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")
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")
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
}
}
// ...
}
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
}
}
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
}
}
}
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'
}
}
}
}
}
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;
}
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)
}
}
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)
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()
}
}
}
MyProtoSchema.proto content:
protoc --decode_raw < MyProtoSchema.proto
1: "Ben"
2: "Wyatt"
3: "Green"
4: 2131231084
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)
}
}
// ...
}
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)