What does KVO do to an observed object at run time, and how can you see it?

4.11.2 · L5 · Interview

Predict first

Two properties are observed, one dynamic and one not. What is logged?

final class Model: NSObject {
    @objc dynamic var first = "Ada"
    @objc var plain = 0
}
let m = Model()
nonisolated(unsafe) var log: [String] = []
let t1 = m.observe(\.first, options: [.new]) { _, c in log.append("first \(c.newValue ?? "-")") }
let t2 = m.observe(\.plain, options: [.new]) { _, c in log.append("plain \(c.newValue ?? -1)") }
m.first = "Grace"
m.plain = 5
print(log)
print(NSStringFromClass(object_getClass(m)!).contains("NSKVONotifying_"))
_ = (t1, t2)
  • ["first Grace", "plain 5"] / false
  • ["first Grace"] / true
  • [] / true

Mental model. KVO swaps the building’s doorbell for one that also rings an announcement. If you walk in through a side window (a direct write), the announcement never fires.

When you observe an object, the runtime creates (once per class) a subclass named NSKVONotifying_Model and switches the instance’s isa to it with object_setClass. The subclass overrides each observed setter so that it calls willChangeValue(forKey:), the original setter, then didChangeValue(forKey:), which delivers the notification, and it overrides -class so the swap is hidden. Nothing is added to your code: the observed property is just a method the generated class can intercept. That is the same mechanism as the runtime-class question.

This is why the property must be @objc dynamic (the basics are in the earlier KVO question): a direct write bypasses the generated override and nothing fires, and the compiler warns that observing a non-dynamic property “may lead to unexpected behavior”. You can see the swap yourself: object_getClass reports the generated subclass while the object is observed. The modern API is observe(\.name, options:) { object, change in }, which returns an NSKeyValueObservation token: the observation lives exactly as long as you keep the token, and invalidate() (or releasing it) ends it. The old addObserver(_:forKeyPath:) has a context pointer and a removeObserver that must balance.

Only the dynamic property is intercepted. While observed, the instance’s real class is the generated NSKVONotifying_… subclass.

Common traps

  • Observing a property that is not dynamic. It compiles and silently never notifies.
  • Dropping the token. The observation ends when the returned NSKeyValueObservation is released, so store it.

They might follow up with

  • Does KVO work on a Swift struct property? No; it needs an NSObject instance and the runtime.
  • When would you use Combine or Observation instead? For pure Swift models; KVO remains the answer for UIKit, AVFoundation and Foundation properties.

Related questions

  • 2.4.5 How does KVO work in Swift, and why does it sometimes never fire?
  • 4.1.4 What is isa, and why does your object’s runtime class sometimes differ from its Swift type?
  • 4.10.4 How do you create or retarget a class at run time, and why does Swift sometimes ignore it?

← What is key-value coding, what can it do that normal property access…   How do you make a computed property observable in KVO, and when do… →