One way or the other, any modern Android application stores some user or config data locally on the device. In the past, developers relied on the SharedPreferences API to store simple data in key-value pairs.
Where SharedPreferences API fails to shine is in its synchronous API for read and write operations. Since Android frowns at performing non-UI work on the main thread, this is not safe to use.
In this article, you will learn how to use the DataStore API with generic persistent storage. This approach will let us create a storage class where we can specify any data type we wish to save as a key-value pair to the device.
We’ll cover the following topics:
getAll
operationinsert
operationget
operationclearAll
operationmodel
class and in-memory data sourceIn this demo, we will create a sample application to fetch the application’s configurations from an in-memory source and save them on the device using DataStore.
There are a few prerequisites before we can get started:
Let’s start by creating an empty Android Studio project.
Copy and paste the following dependencies into your app-level build.gradle
file.
implementation "androidx.datastore:datastore-preferences:1.0.0" implementation "io.insert-koin:koin-android:3.1.4" implementation 'com.google.code.gson:gson:2.8.7'
Alongside the dependency for DataStore are the extra koin
and gson
dependencies, which are for dependency injection and serialization/deserialization, respectively.
After inserting these dependencies, Android Studio will prompt you to sync the project. This typically takes a few seconds.
Create a Kotlin interface file, like so.
interface Storage<T> { fun insert(data: T): Flow<Int> fun insert(data: List<T>): Flow<Int> fun get(where: (T) -> Boolean): Flow<T> fun getAll(): Flow<List<T>> fun clearAll(): Flow<Int }
We use a storage interface to define the actions for the persistent data storage. In other words, it is a contract the persistent storage will fulfill. Any data type we intend to associate with the interface should be able to carry out all four operations in the interface we created.
PersistentStorage
is the concrete implementation of Storage
interface we defined in the previous step.
class PersistentStorage<T> constructor( private val gson: Gson, private val type: Type, private val dataStore: DataStore<Preferences>, private val preferenceKey: Preferences.Key<String> ) : Storage<T>
You will observe by now that we are taking advantage of generics in Storage
and PersistentStorage
. This is done to achieve type safety. If your code is relying on generic persistent storage to store data, only one data type will be associated with a particular instance of Storage
.
There are also a number of object dependencies required:
gson
: As previously mentioned, this will be used for serialization/deserializationtype
: Our implementation gives the user the flexibility to save more than one piece of data of the same type — and with great power comes great responsibility. Writing and reading a list with GSON will result in corrupted or lost data because Java doesn’t yet provide a way to represent generic types, and GSON cannot recognize which type to use for its conversion at runtime, so we use a type token to effectively convert our objects to a JSON string and vice versa without any complicationsDataStore
DataStore
: This will provide APIs for writing to and reading from the preferencesgetAll
operation... fun getAll(): Flow<List> { return dataStore.data.map { preferences -> val jsonString = preferences[preferenceKey] ?: EMPTY_JSON_STRING val elements = gson.fromJson<List>(jsonString, typeToken) elements } } ...
DataStore.data
returns a flow of preferences with Flow<Preferences>
, which can be transformed into a Flow<List<T>>
using the map
operator. Inside of the map block, we first attempt to retrieve the JSON string with the preference key.
In the event that the value is null
, we assign EMPTY_JSON_STRING
to jsonString
. EMPTY_JSON_STRING
is actually a constant variable, defined like so:
private const val EMPTY_JSON_STRING = "[]"
GSON will conveniently recognize this as a valid JSON string, which represents an empty list of the specified type. This approach is more logical, rather than throwing some exception that could potentially cause a crash in the app. I am sure we don’t want that happening in our apps 🙂
insert
operationfun insert(data: List<T>): Flow<Int> { return flow { val cachedDataClone = getAll().first().toMutableList() cachedDataClone.addAll(data) dataStore.edit { val jsonString = gson.toJson(cachedDataClone, type) it[preferenceKey] = jsonString emit(OPERATION_SUCCESS) } } }
To write data to DataStore, we call edit on Datastore
. Within the transform block, we edit the MutablePreferences
, as shown in the code block above.
To avoid overwriting the old data with the new, we create a list that contains both old data and new data before modifying MutablePreferences
with the newly created list.
n.b., I opted to use method overloading to insert a single or a list of data over a vararg parameter because varargs in Kotlin require extra memory when copying the list of data to an array.
get
operationfun get(where: (T) -> Boolean): Flow { return getAll().map { cachedData -> cachedData.first(where) } }
In this operation, we want to get a single piece of data from the store that matches the predicate where
. This predicate is to be implemented on the client side.
clearAll
operationfun clearAll(): Flow<Int> { return flow { dataStore.edit { it.remove(preferenceKey) emit(OPERATION_SUCCESS) } } }
As the name implies, we want to wipe the data that is associated with the preference
key. emit(OPERATION_SUCCESS)
is our way of notifying the client of a successful operation.
At this point, we have done justice to the generic storage APIs. Up next, we’ll set up the model class and an in-memory data source.
model
class and in-memory data sourceCreate a Config
data class, like so:
data class Config(val type: String, val enable: Boolean)
To keep things simple, this data class only captures a config type and its corresponding toggle value. Depending on your use case, your config class can describe many more actions.
class DataSource { private val _configs = listOf( Config("in_app_billing", true), Config("log_in_required", false), ) fun getConfigs(): Flow<List<Config>> { return flow { delay(500) // mock network delay emit(_configs) } } }
For lack of an actual server to connect to, we have our configs stored in-memory and retrieved when needed. We have also included a delay to mock an actual network call.
While this article is focused on creating a minimalistic demo Android app, it is okay to adopt some modern practices. We will implement the code for fetching configs via a ViewModel
and provide dependencies to objects where necessary using koin.
Koin is a powerful Kotlin dependency injection framework. It has simple APIs and is relatively easy to set up.
ViewModel
classclass MainViewModel( private val dataSource: DataSource, private val configStorage: Storage<Config> ) : ViewModel() { init { loadConfigs() } private fun loadConfigs() = viewModelScope.launch { dataSource .getConfigs() .flatMapConcat(configStorage::insert) .collect() } }
Here, we fetch the configs from a data source and save them to our DataStore preferences.
The intention is to be able to retrieve those configs locally without having to make additional network calls to the server. The most obvious place to initiate this request would be at app launch.
Define your koin modules like so:
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "com.enyason.androiddatastoreexample.shared.preferences") val dataAccessModule = module { single<Storage<Config>> { PersistentStorage( gson = get(), type = object : TypeToken<List<Config>>() {}.type, preferenceKey = stringPreferencesKey("config"), dataStore = androidContext().dataStore ) } single { Gson() } viewModel { MainViewModel( dataSource = DataSource(), configStorage = get() ) } }
We have now delegated the heavy lifting to Koin. We no longer have to worry how the objects are being created — Koin handles all of that for us.
The single
definition tells Koin to create only one instance of the specified type throughout the lifecycle of the application. The viewModel
definition tells Koin to create only an object type that extends the Android ViewModel
class.
We need to initialize Koin to prepare our dependencies before our app requests them. Create an Application
class, like so:
class App : Application() { override fun onCreate() { super.onCreate() startKoin { androidContext(this@App) modules(dataAccessModule) } } }
We have finally wired up all the pieces together, and our project should now work as expected. Kindly check out this GitHub repo for the complete project setup.
Storage<Config>
is sure to retrieve only the data of Config
typePreferenceHelper
classes, which are intended to manage app preferences, usually result in monolith classes, which is a bad software engineering practice. With the generic approach discussed in this article, you can achieve more with less codePersistentStorage<T>
Implementing generic persistent storage is an elegant way of managing data with Android DataStore. The gains as I have discussed above outweigh the traditional approach on Android with SharedPreference. I hope you liked this tutorial 😊
LogRocket is an Android monitoring solution that helps you reproduce issues instantly, prioritize bugs, and understand performance in your Android apps.
LogRocket also helps you increase conversion rates and product usage by showing you exactly how users are interacting with your app. LogRocket's product analytics features surface the reasons why users don't complete a particular flow or don't adopt a new feature.
Start proactively monitoring your Android apps — try LogRocket for free.
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowSOLID principles help us keep code flexible. In this article, we’ll examine all of those principles and their implementation using JavaScript.
JavaScript’s Date API has many limitations. Explore alternative libraries like Moment.js, date-fns, and the new Temporal API.
Explore use cases for using npm vs. npx such as long-term dependency management or temporary tasks and running packages on the fly.
Validating and auditing AI-generated code reduces code errors and ensures that code is compliant.