Recently AndroidX Datastore 1.0 was released. The moment has come, it’s time to migrate away from SharedPreferences.
As for me, the key benefit is the built-in support of Kotlin Flow and Coroutines.
Previously we had to write our own wrapper under SharedPreferences.OnSharedPreferenceChangeListener, emit changes into ConflatedBroadcastChannel and then convert it into Flow.
//Listen app theme mode (dark, light) | |
private val selectedThemeChannel: ConflatedBroadcastChannel<String> by lazy { | |
ConflatedBroadcastChannel<String>().also { channel -> | |
channel.trySend(selectedTheme) | |
} | |
} | |
private val changeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> | |
when (key) { | |
PREF_DARK_MODE_ENABLED -> selectedThemeChannel.trySend(selectedTheme) | |
} | |
} | |
val selectedThemeFlow: Flow<String> | |
get() = selectedThemeChannel.asFlow() |
Firstly, this approach produces a lot of intermediate variables. Secondly, ConflatedBroadcastChannel deprecated and should be replaced with StateFlow. In this case, DataStore comes out as the winner.
The migrated to DataStore version looks clean and simple.
//initialization with extension | |
private val dataStore: DataStore<Preferences> = context.dataStore | |
val selectedThemeFlow = dataStore.data | |
.map { it[stringPreferencesKey(name = "pref_dark_mode")] } |
Read/write single value using SharedPreferences
Let’s imagine, we need to store the current App theme as String. For this purpose, we need to initialize SharedPreferences, then make two functions to read and write these values.
enum class Theme(val storageKey: String) { | |
LIGHT("light"), | |
DARK("dark"), | |
SYSTEM("system") | |
} | |
private const val PREF_DARK_MODE = "pref_dark_mode" | |
private val prefs: SharedPreferences = context.getSharedPreferences("PREFERENCES_NAME", Context.MODE_PRIVATE) | |
fun getTheme(): String = prefs.getString(PREF_DARK_MODE, SYSTEM.storageKey) | |
?: SYSTEM.storageKey | |
fun updateTheme(value: String) { | |
prefs.edit { | |
putString(PREF_DARK_MODE, value) | |
} | |
} |
Well, two functions it is not so bad, but we can try to merge this into single Property.
To make it fully clear and supportable we can use the Kotlin delegation feature. As we want to read and write simultaneously, ReadWritePropertywill suit our goal.
class StringPreference( | |
private val preferences: SharedPreferences, | |
private val name: String, | |
private val defaultValue: String | |
) : ReadWriteProperty<Any, String?> { | |
@WorkerThread | |
override fun getValue(thisRef: Any, property: KProperty<*>) = | |
preferences.getString(name, defaultValue) ?: defaultValue | |
override fun setValue(thisRef: Any, property: KProperty<*>, value: String?) { | |
preferences.edit { | |
putString(name, value) | |
} | |
} | |
} |
Please take a look at the final compact version, where all logic is encapsulated inside the delegate.
enum class Theme(val storageKey: String) { | |
LIGHT("light"), | |
DARK("dark"), | |
SYSTEM("system") | |
} | |
private val prefs: SharedPreferences = | |
context.getSharedPreferences("PREFERENCES_NAME", Context.MODE_PRIVATE) | |
var theme by StringPreference( | |
preferences = prefs, | |
name = "pref_dark_mode", | |
defaultValue = SYSTEM.storageKey | |
) |
DataStore Preferences and missing API
During migration, it turned out that the library doesn’t provide an API for reading a single property.
DataStore proposes to use Flow for all cases. Developers are now required to read/write the value in the scope of a suspend function.
val EXAMPLE_COUNTER = intPreferencesKey("example_counter") | |
val exampleCounterFlow: Flow<Int> = context.dataStore.data | |
.map { preferences -> | |
// No type safety. | |
preferences[EXAMPLE_COUNTER] ?: 0 | |
} |
What was easy in SharedPreferences like getString(key, defaultValue)became impossible in DataStore. I just wanna read a single value 😅
Let’s make a small crutch
As DataStore returns only Flow<T> we have to work with that. We want to read a single value from Flow. Coroutines library already contains necessary extensions and Flow<T>.first() perfect for our purpose.
The terminal operator that returns the first element emitted by the flow and then cancels flow’s collection. Throws NoSuchElementException if the flow was empty.
And as first() is suspend function, we can use the runBlocking{} block
fun <T> DataStore<Preferences>.get( | |
key: Preferences.Key<T>, | |
defaultValue: T | |
): T = runBlocking { | |
data.first()[key] ?: defaultValue | |
} | |
fun <T> DataStore<Preferences>.set( | |
key: Preferences.Key<T>, | |
value: T? | |
) = runBlocking<Unit> { | |
edit { | |
if (value == null) { | |
it.remove(key) | |
} else { | |
it[key] = value | |
} | |
} | |
} |
Job Offers
Now let’s wrap this functionality into old delegates, replace SharedPreferences with DataStore and change the key.
class PreferenceDataStore<T>( | |
private val dataStore: DataStore<Preferences>, | |
private val key: Preferences.Key<T>, | |
private val defaultValue: T | |
) : ReadWriteProperty<Any, T> { | |
@WorkerThread | |
override fun getValue(thisRef: Any, property: KProperty<*>) = | |
dataStore.get(key = key, defaultValue = defaultValue) | |
override fun setValue(thisRef: Any, property: KProperty<*>, value: T) { | |
dataStore.set(key = key, value = value) | |
} | |
} |
Note: DataStore has special wrapper under Key, in this case we can use generics. For SharedPreferences delegates it is necessary to create separate delegation class for each privitive type as we need to use typed functions getString()/putString(), getInt()/putInt() and etc.
The final result with datastore delegates will look like this:
enum class Theme(val storageKey: String) { | |
LIGHT("light"), | |
DARK("dark"), | |
SYSTEM("system") | |
} | |
// initialization DataStore with extension | |
private val dataStore: DataStore<Preferences> = context.dataStore | |
var selectedTheme by PreferenceDataStore<String>( | |
dataStore = dataStore, | |
key = stringPreferencesKey(name = "pref_dark_mode"), | |
defaultValue = SYSTEM.storageKey | |
) |
Please don’t forget to start the project:
Link to delegates source code: