Storing preferences with the DataStore in Android
Type-safe permanent storage for Android apps with a couple of tricks
Android is gradually abandoning shared preferences because of downsides like non-type safety loading, not being adapted for multiprocess apps, and lack of loading outside the main thread. The datastore storage tries to address every issue, which makes it a much more robust solution.
One of the hurdles shown inside of the Android documentation is the use of the .proto files, but we can go around it with a JSON file and a definition of the serializer.
Photo by Steve Johnson on Unsplash
Set up
Firstly, the dependency for the datastore needs to be added. For the current version, go here. The JSON serializer is required to decode and decode underlying JSON files.
// inside of the app's build.gradle
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.serialization'
}
...
dependencies {
implementation "androidx.datastore:datastore:1.0.0"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1"
}
Data Structure
Secondly, the structure of our preferences needs to be made. The datastore stores the data in any predefined data structure. Most commonly and familiarly, the data can be stored in JSON files.
In the context of kotlin language, the app needs to know how to decode and encode the JSON data. For this, the kotlin plugins offer us the serialization plugin with JSON implementation.
@Serializable
data class AppSettings(
val onboarded: Boolean,
val nickName: String,
val age: Int
) {
companion object {
val default = AppSettings(false, "", -1)
}
}
A couple of things to note. The most important is the @Seriazible
annotation, which tells the compiler to use a JSON converter. Otherwise, the data class contains primitive data.
If more advanced data structures are presented into the data class, than you have to create serializables on your own for every custom data type.
Another note is to define the default value, which is used at the first initialization of the local memory.
Decoding and encoding the data store
Following the data structure definition comes the definition of the serializer, which is used to load in save the current data for permanent storage.
Decoding of the stored file uses the bytes coming from InputStream. Those are converted to a string and passed to the deserializer defined by our data class annotated with @Serializable
.
Encoding is the same process, but vice versa.
Usage of the suspend functions makes it preventable from blocking the main thread because the IO thread can be used instead of the main. This prevents blockage of the main thread.
Remember, the whole process of loading and saving is customizable. If you have some special requirement for data storage or a special case to handle before storage, like encryption or something else, here is the place to do it.
// serializer with our defined data class AppSettings
object SettingsSerializer : Serializer<AppSettings> {
// default value of the settings for initialization
override val defaultValue: AppSettings = AppSettings.default
// reading the inputstream of the stored file
override suspend fun readFrom(input: InputStream): AppSettings {
try {
return Json.decodeFromString(
deserializer = AppSettings.serializer(),
string = input.readBytes().decodeToString()
)
} catch (exception: SerializationException) {
throw CorruptionException("Error occured during decoding the storage", exception)
}
}
// writting the the output stream with actual datatype
override suspend fun writeTo(
t: AppSettings,
output: OutputStream
) = output.write(
Json.encodeToString(serializer = AppSettings.serializer(), value = t).toByteArray()
)
}
// context property delegate with the datastore
val Context.settingsDataStore: DataStore<AppSettings> by dataStore(
fileName = "settings.json",
serializer = SettingsSerializer
)
The DataStore adds the property delegate, where we can add our file name and define a specific serializer.
Reading and storing new data
The storage could be called synchronously with the runBlocking{}
, but it is not the best practice to do so. The data should be called within reasonable CoroutineScope
and Dispatcher
to avoid interference with the main thread or at least minimize it.
With Hilt, we can inject the datastore inside the custom wrapper or anywhere else where we need the datastore.
Feel free to use any kind of dependency injection / manual injection, with which you are familiar.
@Module
@InstallIn(SingletonComponent::class)
object DataStoreDI {
@Provides
@Singleton
fun provideAppSettingsDataStore(@ApplicationContext context: Context): DataStore<AppSettings> =
context.settingsDataStore
}
Converting the flow
to StateFlow
with MutableStateFlow
field can make the settings accessible across the whole app at any time in the most current state.
// constructor injection with hilt
class AppCache @Inject constructor(private val dataStore: DataStore<AppSettings>){
private val _cacheState = MutableStateFlow<AppSettings>(AppSettings.default)
var state = _cacheState.asStateFlow()
// loading in the data
suspend fun loadInCache() {
try {
dataStore.data.collect {
_cacheState.value = it
}
} catch(error: SerializationException) {
// handle the excetion
}
}
// storing the new data
suspend fun storeOnboarding(isOnboarded: Boolean) {
dataStore.updateData { actualSettings: AppSettings ->
actualSettings.copy(onboarded = isOnboarded)
}
}
suspend fun storeNickName(nickName: String) {
dataStore.updateData { actualSettings: AppSettings ->
actualSettings.copy(nickName = nickName))
}
}
suspend fun storeAge(age: Int) {
dataStore.updateData { actualSettings: AppSettings ->
actualSettings.copy(age = age)
}
}
}
Somewhere in your logic during the initialization of the app ViewModel
or CoroutineScope
, the load in of the data needs to be called. If you make the AppSettings
declared in StateFlow
, the changes can be tracked at any point in your app, and you can make your UI nicely reactive in any way.
// for example:
@HiltViewModel
class IntroViewModel @Inject constructor(private val appCache: AppCache<AppSettings>): ViewModel() {
...
init {
viewModelScope.launch {
appCache.state.collect{ appSettings ->
// listening to changes in the settings
// convert it to other state and create representation in the UI
}
}
viewModelScope.launch {
// initial loading in the data
appCache.loadInCache()
}
}
...
}
Going further
To make the process more interactive, the state of the memory is declared with the sealed class. Some cheaper phones can take their time to load in something, or something goes wrong during the loading. For these cases, the sealed classes can be used to extend the state handling of the DataStore.
sealed class AppCacheState {
// show loading UI
object Loading: AppCacheState()
// we have settings and we can do something
class Loaded(val settings: AppSettings): AppCacheState()
// error occured during the loading ...
object Error: AppCacheState()
}
The states are implemented inside of the AppCache to represent the current state. Loading is as default. If the settings are loaded in, then it turns to the current settings state or error state. In dependence on that, the UI can be modified to represent the current state and be more interactive.
class AppCache @Inject constructor(private val dataStore: DataStore<AppSettings>){
// loading as default
private val _cacheState = MutableStateFlow<AppCacheState>(AppCacheState.Loading)
override var state = _cacheState.asStateFlow()
// loading in the data
suspend fun loadInCache() {
try {
dataStore.data.collect {
_cacheState.value = AppCacheState.Loaded(it)
}
} catch(error: SerializationException) {
_cacheState.value = AppCacheState.Error
}
}
}
By doing this, the ViewModel
can more appropriately relate to the states of the app settings and respond to them.
@HiltViewModel
class IntroViewModel @Inject constructor(private val appCache: AppCache<AppCacheState>): ViewModel() {
init {
viewModelScope.launch {
appCache.state.collect{
when(it){
AppCacheState.Error -> {
// react to error
}
is AppCacheState.Loaded -> {
// react to loaded settings
}
AppCacheState.Loading -> {
// show loading UI
}
}
}
}
viewModelScope.launch {
appCache.loadInCache()
}
}
fun storeUserOnboarding() {
viewModelScope.launch(context = Dispatchers.IO) {
// save user data
appCache.store...
}
}
}
Thanks for reading the article, and follow for more articles like this!