This post focuses on a specific application of Kotlin delegates mechanism for data read and write operations using SharedPreferences.
What’s the problem?
SharedPreferences
is an ideal solution for long-term storage of small pieces of data (such as user settings in an application or a cache of individual service messages). However, the read/write operations in SharedPreferences are quite verbose.
Consider, for example, a simple example — setting the display mode (light/dark theme):
private val sharedPrefs = context.getSharedPreferences("prefs_file_name", Context.MODE_PRIVATE)
companion object {
private const val KEY_DARK_MODE = "dark_mode"
}
fun getIsDarkMode(): Boolean {
return sharedPrefs.getBoolean(KEY_DARK_MODE, false)
}
fun saveIsDarkMode(darkMode: Boolean) {
sharedPrefs.edit().putBoolean(KEY_DARK_MODE, darkMode).apply()
}
Thanks to Kotlin, it is also possible to use properties instead of methods, but the problem with boilerplate code still remains:
var isDarkMode: Boolean
get() = sharedPrefs.getBoolean(KEY_DARK_MODE, false)
set(darkMode) {
sharedPrefs.edit().putBoolean(KEY_DARK_MODE, darkMode).apply()
}
I would like to be able to read and set settings values with a single command — and here is a solution to such a problem.
1. Primitives
private class SharedPrefsPrimitiveDelegate<T>(private val sp: SharedPreferences, private val key: String, private val fallback: T): ReadWriteProperty<Any, T> {
override operator fun getValue(thisRef: Any, property: KProperty<*>): T {
return (sp.all[key] as? T) ?: fallback
}
override fun setValue(thisRef: Any, property: KProperty<*>, value: T) {
val editor = sp.edit()
when(value) {
is Boolean -> editor.putBoolean(key, value)
is Int -> editor.putInt(key, value)
is Long -> editor.putLong(key, value)
is Float -> editor.putFloat(key, value)
is String -> editor.putString(key, value)
}
editor.apply()
}
}
fun SharedPreferences.preference(key: String, fallback: Int = 0) = SharedPrefsPrimitiveDelegate(this, key, fallback)
fun SharedPreferences.preference(key: String, fallback: Long = 0L) = SharedPrefsPrimitiveDelegate(this, key, fallback)
fun SharedPreferences.preference(key: String, fallback: Float = 0.0f) = SharedPrefsPrimitiveDelegate(this, key, fallback)
fun SharedPreferences.preference(key: String, fallback: Boolean = false) = SharedPrefsPrimitiveDelegate(this, key, fallback)
fun SharedPreferences.preference(key: String, fallback: String = "") = SharedPrefsPrimitiveDelegate(this, key, fallback)
Then the example with the customization for the dark theme gets the form:
var isDarkMode by sharedPrefs.preference(KEY_DARK_MODE)
2. Complex objects
Storing records of primitive data types has built-in support in SharedPreferences. However, sometimes it makes sense to write and then read a more complex object. In this case, some serialization/deserialization mechanism similar to Parcelable is required, but converting the object not to/from a binary format, but to/from one of the types supported by SharedPreferences. Obviously, the most appropriate option is to convert to a string.
For this purpose, let’s define some simple interfaces for the conversion:
interface StringParser<T> {
fun parse(input: String): T?
}
interface StringConverter<T> : StringParser<T> {
fun stringify(input: T): String
}
interface StringConvertible {
fun stringify(): String
}
And let’s immediately prepare a default implementation for their communication:
abstract class SimpleStringConverter<T : StringConvertible> : StringConverter<T> {
override fun stringify(input: T): String = input.stringify()
}
Then the delegate will be of the form:
class SharedPrefsComplexDelegate<T : StringConvertible>(
private val sp: SharedPreferences,
private val key: String,
private val converter: StringConverter<T>,
) : ReadWriteProperty<Any, T?> {
constructor(sp: SharedPreferences, key: String, parser: (String) -> T?) :
this(sp, key, object : StringConverter<T> {
override fun parse(input: String): T? = parser(input)
override fun stringify(input: T): String = input.toString()
})
override operator fun getValue(thisRef: Any, property: KProperty<*>): T? {
return try {
sp.getString(key, null)?.let { converter.parse(it) }
} catch (t: Throwable) {
null
}
}
override fun setValue(thisRef: Any, property: KProperty<*>, value: T?) {
val editor = sp.edit()
editor.putString(key, value?.stringify())
editor.apply()
}
}
fun <T : StringConvertible> SharedPreferences.preference(key: String, parser: StringParser<T>) =
SharedPrefsComplexDelegate<T>(this, key, parser::parse)
And here is an example of its use (for example, when we want to save user avatar data):
data class Attach(val id: Long, val width: Int, val height: Int, val text: String) : StringConvertible {
override fun stringify(): String {
return "Attach|${id}|${width}|${height}|${text}"
}
}
class AttachConverter : SimpleStringConverter<Attach>() {
override fun parse(input: String): Attach? {
val items = input.split("|")
return try {
Attach(items[1].toLong(), items[2].toInt(), items[3].toInt(), items[4])
} catch (t: Throwable) {
null
}
}
}
var mainAttach by sp.preference("main_avatar", AttachConverter())
3. Collections (sets, lists, etc.)
In the sections above, we used a custom delegate to handle individual settings of primitive types (numbers, strings, logical flags) or complex objects. However, Android SharedPreferences allow you to store sets of strings as well. Obviously, the values of a set can be not only abstract strings (for example, the draft text of a message entered by the user in the input field of a messenger), but also string representations of other primitive types or even entire complex objects. Then with the application of a particular parser, we can use a wrapper over the Set handling methods to get sets of the desired type:
class SharedPrefsSetDelegates<T>(
private val sp: SharedPreferences,
private val key: String,
private val converter: StringConverter<T>
) : ReadWriteProperty<Any, Set<T>> {
constructor(sp: SharedPreferences, key: String, parser: (String) -> T?) :
this(sp, key, object : StringConverter<T> {
override fun parse(input: String): T? = parser(input)
override fun stringify(input: T): String = input.toString()
})
override operator fun getValue(thisRef: Any, property: KProperty<*>): Set<T> {
return try {
val list = sp.getStringSet(key, emptySet())?.mapNotNull { converter.parse(it) }
list?.toSet<T>() ?: emptySet()
} catch (t: Throwable) {
emptySet()
}
}
override fun setValue(thisRef: Any, property: KProperty<*>, value: Set<T>) {
val editor = sp.edit()
val set = value.map { converter.stringify(it) }.toSet()
editor.putStringSet(key, set)
editor.apply()
}
}
And, as usual, a set of auxiliary functions:
fun SharedPreferences.intSetPreference(key: String) =
SharedPrefsSetDelegates(this, key, String::toIntOrNull)
fun SharedPreferences.longSetPreference(key: String) =
SharedPrefsSetDelegates(this, key, String::toLongOrNull)
fun SharedPreferences.floatSetPreference(key: String) =
SharedPrefsSetDelegates(this, key, String::toFloatOrNull)
fun SharedPreferences.boolSetPreference(key: String) =
SharedPrefsSetDelegates(this, key, String::toBooleanStrictOrNull)
fun SharedPreferences.stringSetPreference(key: String) =
SharedPrefsSetDelegates(this, key) { it }
fun <T> SharedPreferences.listPreference(key: String, converter: StringConverter<T>) =
SharedPrefsSetDelegates(this, key, converter)
fun <T: StringConvertible> SharedPreferences.listPreference(key: String, converter: SimpleStringConverter<T>) =
SharedPrefsSetDelegates(this, key, converter)
4. DataStore
In 2021, Google introduced a new data storage mechanism designed to replace SharedPreferences — DataStore
.
DataStore also requires quite a bit of template code, and it makes sense to wrap it in a delegate. In general, creating such a delegate will not differ too much from the ones shown above in the article, and so its text will not be given here.
Top comments (0)