Lesson 09 / 14
Key–Value Storage
A key–value store keeps small preferences under a single key; reads and writes are asynchronous, and the value arrives as a flow. The lesson measures the origin of a read of a preference that was never written and how that origin changes when the user writes it.
Published Updated
In this lesson
- Explains that preference data fits a key–value store rather than a structured table
- Distinguishes asynchronous reading and writing, which appear as a flow
- Measures the origin of a read of a preference that was never written as default
- Shows that the origin is disk when the user writes a value and when an empty string is written
Contents
In the previous lesson a migration carried the rows of a table to the new schema. Not every piece of data is structured enough to enter a table: preferences such as theme, language, and whether notifications are on are each a single value written under a single key. In this lesson we look at that small data, and the moment we care about most is the one when the user has not yet written anything. The question of the lesson is the same as in the earlier lessons: where does the answer to this read come from?
// Origin.kt — classifies which tier a read's answer came from
class Tier(val origin: String, private val entries: Map<String, String>) {
init {
require(origin in listOf("memory", "disk", "network")) { "invalid tier: $origin" }
}
fun lookup(key: String): String? = entries[key]
}
class Read(val label: String, val key: String, chain: List<Tier>, fallback: String) {
private val hit: Tier? = chain.firstOrNull { it.lookup(key) != null }
val value: String = hit?.lookup(key) ?: fallback
val origin: String = hit?.origin ?: "default"
fun show() {
println("$label: key=$key | value=$value -> $origin")
}
}
fun countOrigins(reads: List<Read>) {
for (name in listOf("memory", "disk", "network", "default")) {
println("$name: ${reads.count { it.origin == name }}")
}
}
Preference Data
A key–value store binds values to string keys instead of to a table. There is no schema; a key either exists or it does not. A preference is a small setting written into this store. The preference store of the platform has two properties. The first is that reading and writing are asynchronous: both are suspending functions, and they do not block the main thread. The second is that a read arrives as a flow rather than as a one-time value: every time the preference changes, a new value comes out of the flow.
// Platform code: ThemePreferences
class ThemePreferences(private val dataStore: DataStore<Preferences>) {
private val themeKey = stringPreferencesKey("settings.theme")
val theme: Flow<String> = dataStore.data.map { it[themeKey] ?: "light" }
suspend fun setTheme(value: String) {
dataStore.edit { it[themeKey] = value }
}
}
The theme flow produces the value "light" if the key was not written. This line is the subject of the lesson itself: the preference store gives no value for an unwritten key, and the code that reads it picks a fallback with ?:. This snippet is platform code, and here we make no claim about output, duration, or file path. The model builds the rule of the platform; the model is not the platform itself.
In the model, the preference store is a disk tier. A write is a function that returns a new map without changing the old one.
// Preferences.kt — a write returns a new map without changing the old one
fun write(store: Map<String, String>, key: String, value: String): Map<String, String> =
store + (key to value)
The settings screen reads three preferences. Each has its own fallback value: light for theme, English for language, yes for notification.
// Settings.kt — the three preferences the settings screen reads
fun readSettings(disk: Tier): List<Read> {
val chain = listOf(disk)
return listOf(
Read("theme", "settings.theme", chain, "light"),
Read("language", "settings.language", chain, "en"),
Read("notification", "notification.enabled", chain, "yes"),
)
}
A Preference That Was Never Written
The user opened the application for the first time. In the preference store there is only the name of the profile; none of the settings keys were written.
// Main.kt — a preference that was never written is read from the fallback value
fun main() {
val store = write(emptyMap(), "profile.name", "Ayse")
val reads = readSettings(Tier("disk", store))
reads.forEach { it.show() }
countOrigins(reads)
}
theme: key=settings.theme | value=light -> default language: key=settings.language | value=en -> default notification: key=notification.enabled | value=yes -> default memory: 0 disk: 0 network: 0 default: 3
All three of the three reads fell to the default. The disk tier gave zero matches because none of the settings keys were written. This lesson’s origin is default: the preference store is a tier that does not answer unless something is written.
A distinction is needed here. In the Android Platform and Tools course (M31/K02), the case in which the platform could not find the requested source and gave another one was classified as a substitute. The default in this lesson is not that substitute: there the platform picks another value, and the chosen value could differ from the requested one; here no tier holds the key, and the code that reads brings the fallback value itself.
DL1. The model assumes that the preference store holds only written keys. The deletion of a key, a preference being written with a different type, and the corruption of the store file are absent from the model.
When the User Writes
The user sets the theme to dark. The write returns a new map without changing the old one, and the theme key is now on disk.
// Main.kt — when the user writes the theme, the same read comes from disk
fun main() {
val store = write(write(emptyMap(), "profile.name", "Ayse"), "settings.theme", "dark")
val reads = readSettings(Tier("disk", store))
reads.forEach { it.show() }
countOrigins(reads)
}
theme: key=settings.theme | value=dark -> disk language: key=settings.language | value=en -> default notification: key=notification.enabled | value=yes -> default memory: 0 disk: 1 network: 0 default: 2
The theme is read from disk, and its value is dark. Language and notification did not change; they are still default. The reading code stayed the same; only the content of the store changed.
The Bounding Measurement
What happens if the written value is the same as the fallback value? Or if an empty string is written? We try three cases at the same time. The theme is written as light, the same value as its fallback. The language is written as an empty string. The notification is not written.
// Main.kt — a value equal to the fallback and an empty string are also written preferences
fun main() {
val written = write(write(emptyMap(), "settings.theme", "light"), "settings.language", "")
val reads = readSettings(Tier("disk", written))
reads.forEach { it.show() }
countOrigins(reads)
}
theme: key=settings.theme | value=light -> disk language: key=settings.language | value= -> disk notification: key=notification.enabled | value=yes -> default memory: 0 disk: 2 network: 0 default: 1
The theme is still light, and this time it is from disk. The fallback value and the written value were the same; the origin changed because the key is now written. The value of the language is an empty string, and its origin is disk as well. An empty string is an answer, not a default: Tier.lookup does not count an empty string as null; it says that the tier holds the key. The read did not fall to the fallback value en.
The benefit of this measurement is that it makes the distinction between “the preference is empty” and “the preference was never written” visible. If the user left the language empty on purpose, the fallback value must not override that decision. What the rule does not say is whether an empty string is meaningful for the application; that is told not by the lesson but by the code that uses that preference.
DL2. The model stores a preference as a string and counts an empty string as a valid value. The definition of a preference as a number, a boolean, or a string, and the need for a different key object depending on the type, are absent from the model.
Summary
- This lesson’s origin: default. No tier holds a preference that was never written, and the code that reads it supplies the fallback value.
- A key–value store keeps small preferences under a single key; on the platform, a read is a flow and a write is a suspending function.
- In an empty store all three of the three reads are default: disk 0, default 3.
- When the user writes a preference, the same read becomes disk: disk 1, default 2. If a value equal to the fallback is written, the value stays the same and the origin becomes disk.
- An empty string is a written preference; its read comes from disk and does not fall to the fallback value.
Next Step
In this lesson, the disk tier was a small store with single keys. Sometimes an application stores a file such as an image or a document, and a file can sit in a directory that belongs only to the application or in a shared place that other applications also see. The next lesson, File and Media Storage, measures the origin from which a file in the application’s own directory is read and why the file of another application does not enter the chain.
To keep your progress and take notes, Log in
My notes
Log in to take notes.