Lesson 13 / 19
Simple Preference Storage
A measurement of how the order in which two screens write to the same key, the repetition of a launch counter, and a default value change the outcome when a small value is stored persistently.
Published Updated
In this lesson
- Identifies small, non-secret values as the ones that suit a preference store
- Measures that the order in which two screens write to the same key changes the result
- Measures that incrementing a launch counter changes the result when repeated
- Verifies that a default value can move the same pair of writes into a different sensitivity class
Contents
In the previous lesson, Reactive Streams, we saw how a value flows over time. When the stream ends, the value is gone. Small values that must survive the app being closed and reopened need another place: a theme choice, the last opened tab, whether a hint has already been shown. This topic covers that place; its name is persistence. The question of the course stays the same here: if this operation is repeated or reordered, does the result change? There are four answers, and their names are fixed throughout the course: robust, repeat-sensitive, order-sensitive, and doubly sensitive. The umbrella name is sensitivity, and the rule reads it not from a label but from the result the operation leaves in the state.
The measurement runs on the core file. Every lesson gives it unchanged.
// Replay.swift — classifies an operation's sensitivity to repetition and order
struct Trial<State: Equatable & CustomStringConvertible> {
let label: String
let once: State
let twice: State
let forward: State
let backward: State
var sensitivity: String {
switch (once != twice, forward != backward) {
case (false, false): return "robust"
case (true, false): return "repeat-sensitive"
case (false, true): return "order-sensitive"
case (true, true): return "doubly-sensitive"
}
}
func report() -> String {
print("\(label) | once=\(once) twice=\(twice) ab=\(forward) ba=\(backward) -> \(sensitivity)")
return sensitivity
}
}
func replay<State>(_ label: String, from start: State, _ a: (State) -> State, then b: (State) -> State) -> Trial<State> {
Trial(label: label, once: a(start), twice: a(a(start)), forward: b(a(start)), backward: a(b(start)))
}
func countSensitivities(_ results: [String]) {
for name in ["robust", "repeat-sensitive", "order-sensitive", "doubly-sensitive"] {
print("\(name): \(results.filter { $0 == name }.count)")
}
}
once is the operation applied to the starting state one time, twice is two times; ab is a first and then b, and ba is the reverse order. If once and twice differ, the operation is repeat-sensitive; if ab and ba differ, it is order-sensitive. The operations measured in this lesson are applied to a pure Swift model of a preference store. The model builds what the operation does to the state; it is not the store or the disk itself.
What the Preference Store Is For
The platform’s preference store keeps key-value pairs. The values are simple types such as strings, numbers, Booleans, dates, and arrays and dictionaries built from them. A UserDefaults.standard object is shared by the whole app: a value written by one screen is read immediately by another screen in the same process. This convenience is the real subject of the measurement, because two screens writing to a shared key can overwrite each other’s value without noticing.
The store is meant for small, non-secret values. Large data goes to the file system or a database; secret values such as passwords and tokens go to the keychain, which is the subject of the next lesson. A value written to the preference store sits there in readable form.
import Foundation
UserDefaults.standard.register(defaults: ["theme": "system", "launchCount": 0])
UserDefaults.standard.set("dark", forKey: "theme")
let count = UserDefaults.standard.integer(forKey: "launchCount")
UserDefaults.standard.set(count + 1, forKey: "launchCount")
UserDefaults.standard.removeObject(forKey: "theme")
This fragment is SOURCE; it calls a class of the platform, so it does not run here. Three things are read from it: set(_:forKey:) writes the value, integer(forKey:) returns 0 when the key does not exist, and removeObject(forKey:) deletes the written value. The model below carries these names unchanged.
The Model of the Preference Store
The model keeps two separate dictionaries: the values written explicitly and the registered default values. A read returns the written value first, and the default if there is none. The state is also compared and printed by this resolved value.
// PreferenceModel.swift — a pure Swift model of the preference store
struct PreferenceBox: Equatable, CustomStringConvertible {
private(set) var stored: [String: String] = [:]
private(set) var registered: [String: String] = [:]
var resolved: [String: String] {
registered.merging(stored) { _, explicit in explicit }
}
var description: String {
let pairs = resolved.keys.sorted().map { "\($0)=\(resolved[$0] ?? "-")" }
return pairs.isEmpty ? "-" : pairs.joined(separator: ",")
}
static func == (lhs: PreferenceBox, rhs: PreferenceBox) -> Bool {
lhs.resolved == rhs.resolved
}
mutating func register(defaults: [String: String]) {
registered.merge(defaults) { _, new in new }
}
mutating func set(_ value: String, forKey key: String) { stored[key] = value }
mutating func set(_ value: Int, forKey key: String) { stored[key] = String(value) }
mutating func removeObject(forKey key: String) { stored[key] = nil }
func string(forKey key: String) -> String? { resolved[key] }
func integer(forKey key: String) -> Int { Int(resolved[key] ?? "") ?? 0 }
}
func registeredBox(_ defaults: [String: String]) -> PreferenceBox {
var box = PreferenceBox()
box.register(defaults: defaults)
return box
}
Because the order of dictionaries is undefined, description prints the keys sorted. The empty state appears as -. This is also a choice: the rule compares resolved values and does not look at whether a value inside the store was written or is a default.
PS1. The model assumes that a write is seen immediately by later reads in the same process. When the value reaches the disk was not measured.
PS2. The model assumes that two screens share a single store. The case where each screen uses a separate store object was not built.
Measuring Three Operations
Three operations are built. The first is two screens writing different values to the same theme key. The second is two screens writing to different keys. The third is incrementing a counter by one each time the app launches.
// main.swift — two screens and a launch counter
func writeValue(_ value: String, forKey key: String) -> (PreferenceBox) -> PreferenceBox {
{ box in
var next = box
next.set(value, forKey: key)
return next
}
}
func incrementLaunchCount(_ box: PreferenceBox) -> PreferenceBox {
var next = box
next.set(next.integer(forKey: "launchCount") + 1, forKey: "launchCount")
return next
}
let sameKey = replay("two-screens-same-key", from: PreferenceBox(), writeValue("dark", forKey: "theme"), then: writeValue("light", forKey: "theme")).report()
let otherKeys = replay("two-screens-different-keys", from: PreferenceBox(), writeValue("dark", forKey: "theme"), then: writeValue("en", forKey: "language")).report()
let launches = replay("launch-counter", from: PreferenceBox(), incrementLaunchCount, then: incrementLaunchCount).report()
countSensitivities([sameKey, otherKeys, launches])
two-screens-same-key | once=theme=dark twice=theme=dark ab=theme=light ba=theme=dark -> order-sensitive two-screens-different-keys | once=theme=dark twice=theme=dark ab=language=en,theme=dark ba=language=en,theme=dark -> robust launch-counter | once=launchCount=1 twice=launchCount=2 ab=launchCount=2 ba=launchCount=2 -> repeat-sensitive robust: 1 repeat-sensitive: 1 order-sensitive: 1 doubly-sensitive: 0
In the first line, writing the same value twice did not change the result: once and twice are the same. But the order of two different values was decisive: ab is light, ba is dark. The last writer wins, and the write order decides the winner. That is why the class is order-sensitive.
In the second line, the two screens write to different keys. The write order does not change the result, and repeating the same write does not change it either. The class is robust. This line shows that the problem is not in the writing itself but in two writes sharing the same key.
In the third line, the counter became 1 when applied once and 2 when applied twice. The order is meaningless for this operation, because the two operations are identical; repetition, however, changes the result. The class is repeat-sensitive. Incrementing the counter requires a read followed by a write; the value written depends on the value read. An operation that writes a constant value to the same key does not change when repeated. That is the difference: set with a constant value is robust, and an operation that reads and increments is not.
The count is one robust, one repeat-sensitive, one order-sensitive.
Default Values
If a key has never been written, a read returns nil or 0. Rather than handling this case everywhere, the app registers default values when it starts: register(defaults:). Registered defaults do not count as written values; they are the fallback that applies when no written value exists. The registration repeats at every launch, because defaults are not stored persistently.
The default value is also the value read once a key is deleted. removeObject(forKey:) removes the written value and the read falls back to the default. How these two operations behave together changes with the default value, and that is the subject of this lesson’s bounding measurement.
Common Mistakes
- Using the same key on two screens for different purposes. The last writer wins, and each silently overwrites the other’s value.
- Reading and incrementing a counter without accounting for repetition. If the button is pressed twice or the launch code runs twice, the counter goes up by two.
- Writing a secret value to the preference store. The store provides no privacy boundary; secret values belong to the keychain.
- Writing the default value separately at every read site. The value diverges in two places, and which one is valid becomes unclear.
- Putting a large piece of data in the preference store. The store is designed for small values.
- Duplicating the key name as a string. A typo opens a new key, and the old value keeps being read.
Interpreting the Measured Result
When two screens wrote to the same key, the write order decided the result; repeating the same value did no harm. When they wrote to different keys, the two screens did not affect each other. The launch counter, on the other hand, changed when repeated. The three lines show that the preference store does not have a single behavior: the same set call falls into a different class depending on who shares the key and on whether the value is constant or computed.
In practice, two habits are enough. The first is that every key has a single owner: the screen that writes the value is known, and no other screen writes to the same key. The second is protecting counters and similar computed values against repetition: if an operation is considered to have run once, a separate record of that is kept. These two habits remove the source of the first and third lines above at the writing stage. The measurement shows that they are necessary, but it does not show when the two screens will actually write.
PS3. This measurement builds the increment as a read followed by a write, without interruption. The case where two increments run at the same time was not built.
The Bounding Measurement
The rule measures from a single starting state. The same pair of operations can fall into a different class from a different start. Here the pair is this: a deletes the key, and b writes dark to the same key. The start is built in three ways, depending on what the default value is.
// main.swift — the same operation pair from three different starts
func removeValue(forKey key: String) -> (PreferenceBox) -> PreferenceBox {
{ box in
var next = box
next.removeObject(forKey: key)
return next
}
}
func writeDark(_ box: PreferenceBox) -> PreferenceBox {
writeValue("dark", forKey: "theme")(box)
}
func writeValue(_ value: String, forKey key: String) -> (PreferenceBox) -> PreferenceBox {
{ box in
var next = box
next.set(value, forKey: key)
return next
}
}
let noDefault = replay("no-default", from: PreferenceBox(), removeValue(forKey: "theme"), then: writeDark).report()
let sameDefault = replay("default-dark", from: registeredBox(["theme": "dark"]), removeValue(forKey: "theme"), then: writeDark).report()
let otherDefault = replay("default-system", from: registeredBox(["theme": "system"]), removeValue(forKey: "theme"), then: writeDark).report()
countSensitivities([noDefault, sameDefault, otherDefault])
no-default | once=- twice=- ab=theme=dark ba=- -> order-sensitive default-dark | once=theme=dark twice=theme=dark ab=theme=dark ba=theme=dark -> robust default-system | once=theme=system twice=theme=system ab=theme=dark ba=theme=system -> order-sensitive robust: 1 repeat-sensitive: 0 order-sensitive: 2 doubly-sensitive: 0
The same two operations gave two classes from three starts. With no default and with the default system, the order is decisive: delete and then write dark gives dark, while write and then delete returns to the default or to empty. With the default dark, both orders give dark, because the value the deletion falls back to is already the value being written. The result comes out robust. This does not show that the operation is robust in every state; it shows only that the order is not visible from that start. Changing the default value changed the class without touching any operation code.
This measurement has a limit too. The rule does not look at whether the value came from the store itself or from the default; it compares only the resolved value. A written dark and a default dark count as the same.
Where It Shows Up in a Real App
The measured order sensitivity is not an artificial laboratory case. In an app, the theme choice can be changed both by the settings screen and by a quick switch on the home screen. When two screens write to the same key, the remaining value depends on which one wrote later. If the user changes the theme from the home screen while the settings screen is open, the settings screen can write its own old copy back when it closes. The result is that the change the user just made silently disappears. The bug report has no trace of it: no call failed, and each write worked correctly on its own.
The launch counter, on the other hand, is the typical example of repeat sensitivity. The code that increments the counter is written to run once each time the user opens the app. If the same code also runs when the screen reappears, when returning from the background, or on a retry, the counter ends up larger than the real number of launches. Decisions that depend on the counter, such as a welcome message or a request for a review, are triggered at the wrong time for the same reason. The twice column of the measurement shows exactly this situation: when the operation is repeated, the value has risen once more.
The default value issue is even subtler. Reading a key that was never written looks the same in two different situations: the app was just installed, and the app was updated from an older version. The bounding measurement showed this: the same pair of operations can fall into a different class depending on the starting state. So whether a preference is safe cannot be told from the operation alone; the question of which start the operation ran from is asked as well.
A Wrong Setup and Its Fix
The most common wrong setup is each screen keeping the preference as a copy of its own. The screen reads the value when it opens, updates the copy as the user changes it, and writes the whole copy to the store when it closes. In this arrangement, when the lifetimes of two screens overlap, the final write overwrites the other screen’s change.
The fix has two steps. The first is to write only the key the user changed, at the moment the user changed it, instead of writing the whole copy when the screen closes. This way screens writing to different keys do not affect each other, which the different-keys line in the measurement showed as robust. The second, if the same key has to be changed from more than one place, is to route the change through a single place and have the screens listen to that place instead of the store. This does not solve the order problem, but it makes the order visible in one place; the way an order-sensitive operation becomes safe is, more often than not, that the order can no longer change.
For the counter, the fix is different. Storing the information “has this launch been counted” instead of incrementing the counter protects the operation against repetition. A second call arriving in the same session sees that the flag is set and does nothing. In measurement terms, this means the operation stops being repeat-sensitive and becomes robust; but we did not measure that separately in this lesson, we are only stating the direction.
Reading the SOURCE Fragment
The unnamed fragment at the start of the lesson is not compiled, because a real preference store depends on the platform and on the app’s lifecycle. The fragment is still worth reading. Its read and write calls are the exact counterparts of the two operations in the model we measured: where the model reads and writes a key, the real fragment reads and writes the same key. The difference is that in the real store a write does not go to the disk immediately, and it is not guaranteed that reads see a change coming from another process. This difference is recorded as PS1 and PS2, and it does not change the measured class; it only says how close the real behavior is to the model.
The way the default value is supplied in the fragment also matters. When the key does not exist, the read call returns a value, and that value can conflict with a default defined separately elsewhere in the code. Defaults defined in two places are the real-life counterpart of the two different starting states in the measurement. Defining the default in a single place prevents the class shift shown by the bounding measurement from the start.
Summary
- The preference store keeps small, non-secret values as key-value pairs; secret values belong to the keychain.
- When two screens write different values to the same key, the write order decides the result (
abislight,baisdark); different keys do not affect each other. - An operation that reads and increments, like a launch counter, changes the result when repeated (
1, then2); writing a constant value does not. - A default value can move the same delete-and-write pair from robust to order-sensitive; the result is measured from a single start.
- The count of the three operations is one robust, one repeat-sensitive, one order-sensitive; the bounding measurement gives one robust and two order-sensitive.
- This lesson’s sensitivity: order-sensitive.
Next Step
The preference store is a place where values sit in the open. In the next lesson, The Keychain, we move to the store where secret values are kept, and we measure how adding the same item a second time changes the status code.
To keep your progress and take notes, Log in
My notes
Log in to take notes.