Jetpack DataStore in Modern Android Development
As an Android developer, it would be vital to store certain options persistently throughout the lifetime of your Android app. Initially, SharedPreferences is the typical way used to store key-value pairs in local storage. However, there are some controversial issues in practice, such as dealing with uncaught exceptions and blocking the UI thread when making calls. To address these matters effectively, Jetpack DataStore has been introduced recently by Google as an advanced and enhanced data storage solution for replacing SharedPreferences, as it is thread-safe and non-blocking. This essay aims to discuss some main concepts in using Jetpack DataStore as a new feature in modern Android development.
Introduction and Overview
Basically, Android Jetpack is a set of libraries, tools, and guidance for modern Android development. Currently, there are four categories for using Jetpack, which includes: Architecture, UI, Behavior, and Foundation. Jetpack has seen incredible adoption and momentum. There have been done a number of works and improvements in Android Jetpack during recent years by Google. Therefore, these advanced libraries and features will make it easier to implement high-quality Android apps.
Jetpack is a suite of libraries to help developers follow best practices, reduce boilerplate code, and write code that works consistently across Android versions and devices so that developers can focus on the code they care about.
Besides, SharedPreferences is the typical way used to store key-value pairs in local storage. Probably, you have utilized SharedPreferences in your Android applications. It is also likely that you have been facing some problematic issues with SharedPreferences that are difficult to reproduce and trace your crashes in your analytics because of some different reasons, such as dealing with uncaught exceptions, blocking the UI thread when making calls, or having inconsistent persisted data throughout your application.
Jetpack DataStore is an advanced and enhanced data storage solution for replacing SharedPreferences, as it is thread-safe and non-blocking. In general, DataStore is a Jetpack library that provides a safe and consistent method to store small amounts of data, like preferences or Android app state. DataStore is built based on Kotlin coroutines and Flow to store data asynchronously and consistently. Eventually, it offers two different implementations: Proto DataStore, which stores typed objects backed by protocol buffers to define a schema, and Preferences DataStore, which stores key-value pairs. This means Proto DataStore stores data as instances of a custom data type in a safety way. To store and access in using Preferences DataStore, you will not need a predefined schema, and it does not support type safety as well.
In coroutines, a flow is a type that can emit multiple values sequentially, as opposed to suspend functions that return only a single value. For example, you can use a flow to receive live updates from a database.
Differences between DataStore and SharedPreferences
Initially, to you use data storage APIs, most probably, you will require to get notified asynchronously while data have been modified. SharedPreferences does provide some async support, but just only for getting updates on changed values via its listener. However, this callback is still invoked on the main thread. In the same way, if you want to offload your file-saving work to background, you could use SharedPreferences.apply(). The important point is that this will block the UI thread on fsync. So, this can lead to jank and ANRs. This situation could happen any time a service starts or stops or an activity pauses or stops.
Android renders UI by generating a frame from your app and displaying it on the screen. If your app suffers from slow UI rendering, then the system is forced to skip frames. When this happens, the user perceives a recurring flicker on their screen, which is referred to as jank.
In contrast, DataStore supports a fully asynchronous method to retrieve and save data by using the power of Kotlin Coroutines and Flow. In the meantime, this can reduce the risk of blocking your UI thread.
When the UI thread of an Android app is blocked for too long, an “Application Not Responding” (ANR) error is triggered. If the app is in the foreground, the system displays a dialog to the user, as shown in figure 1. The ANR dialog gives the user the opportunity to force quit the app.
In fact, SharedPreferences has a synchronous API, which can appear safe to call on the UI thread. However, these commit functions for modifying data have heavy disk I/O operations. Also, you might deal with ANRs and jank.
To address this issue efficiently, DataStore does not provide ready-to-use synchronous support. DataStore saves the preferences in a file and carry out all data operations in the background behind the scenes. However, it is achievable to combine DataStore and synchronous work by helping from Coroutine Builders.
SharedPreferences can throw parsing errors as runtime exceptions. Thus, this can lead to your app vulnerable to crashes. For instance, the ClassCastException is a typical exception thrown by the API when the incorrect data type is requested. In comparison, DataStore offers an effective way to catch any exception when reading or writing data based on Flow’s error-signaling mechanism.
As it is mentioned, utilizing key-value pairs for storing and retrieving persisted data does not support type safety. However, by using Proto DataStore, you can be able to predefine a schema for your data model to get the advantage of full type safety protection as you need.
Obviously, SharedPreferences does not guarantee data consistency in a multi-threaded situation; therefore, you cannot rely on your data modifications is being reflected in various conditions. This can have some drawbacks, particularly when the entire point of API is based on persisted data. On the other hand, DataStorage’s fully transactional API offers a considerable guarantee for consistency, as the data is updated in an atomic read, modify, write operation.
Finally, SharedPreferences does not support a built-in migration mechanism, like remapping of your values from your previous storage to the new one. As a result, this can rise the chances of runtime exceptions because of data type mismatch. DataStore; on the other hand, offers an effective way of easily migrating data, as well as the provided implementation for SharedPreferences to DataStore migration process.
Note: This migration only supports the basic SharedPreferences types: boolean, float, int, long, string and string set. If the result of getAll contains other types, they will be ignored.
Using Preferences DataStore and Proto DataStore
Generally, the Preferences DataStore implementation uses the DataStore
and Preferences
classes to persist key-value pairs to disk without defining the schema and structure. After adding dependencies for Preferences DataStore, you should create an instance of DataStore<Preferences>.
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
In fact, to make DataStore as a Singleton pattern easier, you should call it once at the top level of your Kotlin file, and access it in the rest of your Android app as well. In addition, as it is mentioned, Preferences DataStore does not utilize a pre-defined schema, you have to define the related key type function for every value that you require to store in the DataStore<Preferences> instance. As an example for reading from a Preferences DataStore:
val SAMPLE_COUNTER = intPreferencesKey("sample_counter")
val sampleCounterFlow: Flow<Int> = context.dataStore.data
.map { preferences ->
preferences[SAMPLE_COUNTER] ?: 0
}
In contrast, Proto DataStore uses protocol buffers for readability. Basically, protocol buffers provide an extensible mechanism for serializing structured data in a forward-compatible and backward-compatible way. It is similar to JSON, however, it could be smaller and faster, and it could also generates native language bindings. So, you have to define your schema in a proto file in the directory. This schema describes the type for the objects, which you persist in your Proto DataStore.
Then, you will need two steps involved in creating a Proto DataStore to save your typed objects. First. you must create a class that implements Serializer<T>, where T is the type defined in the proto file. This means serializer class points out how DataStore should read and write your data type. The Serializer defines also the default value to be returned if there is no data on disk. Second, you must use the property delegate created by to create an instance of DataStore<T>. For example:
object UserPreferencesSerializer : Serializer<UserPreferences> {
override val defaultValue: UserPreferences = UserPreferences.getDefaultInstance()
override suspend fun readFrom(input: InputStream): UserPreferences {
try {
return UserPreferences.parseFrom(input)
} catch (exception: InvalidProtocolBufferException) {
throw CorruptionException("Cannot read proto.", exception)
}
}
override suspend fun writeTo(t: UserPreferences, output: OutputStream) = t.writeTo(output)
}
In conclusion
Jetpack DataStore is as an advanced and enhanced data storage solution for replacing SharedPreferences, as it is thread-safe and non-blocking. This article considered some main concepts in using Jetpack DataStore based on Google resources and documents. However, the final question is that why not just use Room to store my data? if you require to provide large and complex datasets with partial updates or referential integrity among data tables, you should use Room.