Making Friends with AttributeGraph

If you’ve used SwiftUI for long enough, you’ve probably noticed that the public Swift APIs it provides are really only half the story. Normally inconspicuous unless something goes exceedingly wrong, the private framework called AttributeGraph tracks almost every single aspect of your app from behind the scenes to make decisions on when things need to be updated. It would not be much of an exaggeration to suggest that this C++ library is actually what runs the show, with SwiftUI just being a thin veneer on top to draw some platform-appropriate controls and provide a stable interface to program against. True to its name, AttributeGraph provides the foundation of what a declarative UI framework needs: a graph of attributes that tracks data dependencies.

Mastering how these dependencies work is crucial to writing advanced SwiftUI code. Unfortunately, being a private implementation detail of a closed-source framework means that searching for AttributeGraph online usually only yields results from people desperate for help with their crashes. (Being deeply unpleasant to reverse-engineer definitely doesn’t help things, though some have tried.) Apple has several videos that go over the high-level design, but unsurprisingly they shy away from mentioning the existence of AttributeGraph itself. Other developers do, but only fleetingly.

This puts us in a real bind! We can Self._printChanges() all day and still not understand what is going on, especially if problems we have relate to missing updates rather than too many of them. To be honest, figuring out what AttributeGraph is doing internally is not all that useful unless it is not working correctly. We aren’t going to be calling those private APIs anyways, at least not easily, so there’s not much point exploring them. What’s more important is understanding what SwiftUI does and how the dependencies need to be set up to support that. We can take a leaf out of the generative AI playbook and go with the approach of just making guesses as how things are implemented. Unlike AI, we can also test our theories. We won’t know whether our speculation is right, but we can definitely check to make sure we’re not wrong!

Introducing Setting

As we explore how SwiftUI propagates changes, it will be very helpful to have an real example to work with. Say hello to Setting, a simple UserDefaults wrapper similar to AppStorage:

@propertyWrapper
struct Setting<T> {
	init(_ key: String, defaultValue: T)
	var wrappedValue: T { get set }
	var projectedValue: Binding<T> { get }
	var isSet: Bool { get }
	func reset()
}

We haven’t implemented it yet, but it should be fairly straightforward to see how you might use this:

@Setting("favoriteNumber", defaultValue: 42)
var favoriteNumber

favoriteNumber = 69
print(favoriteNumber) // 69
print($favoriteNumber.isSet) // true
$favoriteNumber.reset()
print($favoriteNumber.isSet) // false
print(favoriteNumber) // 42

Our API is slightly more explicit about how unset values should work, but it is otherwise almost identical to Apple’s: we even expose a Binding as the projectedValue so Setting can be used directly with SwiftUI controls. Here’s what a preliminary implementation might look like. None of it should be particularly surprising:

@propertyWrapper
struct Setting<T> {
	let key: String
	let defaultValue: T

	init(_ key: String, defaultValue: T) {
		self.key = key
		self.defaultValue = defaultValue
	}

	var wrappedValue: T {
		get {
			UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
		}
		set {
			UserDefaults.standard.setValue(newValue, forKey: key)
		}
	}

	var projectedValue: Binding<T> {
		Binding(
			get: {
				wrappedValue
			},
			set: {
				wrappedValue = $0
			}
		)
	}

	var isSet: Bool {
		UserDefaults.standard.value(forKey: key) != nil
	}

	func reset() {
		UserDefaults.standard.removeObject(forKey: key)
	}
}

This almost works. However, we have a compiler error in our implementation for projectedValue–it captures self, and when the Binding updates it tries to call wrappedValue’s setter. But since self is immutable, the compiler will not let us modify wrappedValue. Or will it?

We’re not really modifying self here: the setter just writes to user defaults. In particular, it doesn’t touch any members of the struct at all, so self doesn’t change. Swift has a special way to accommodate this: a nonmutating setter. This is actually what State uses itself to let you assign to it, even in contexts where the view that owns it is not mutable: it stores its state outside of itself, just like we do. Let’s update our implementation of wrappedValue to use this.

var wrappedValue: T {
	get {
		UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
	}
	nonmutating set {
		UserDefaults.standard.setValue(newValue, forKey: key)
	}
}

With that, the code compiles, so let’s take it for a spin:

struct ContentView: View {
	@Setting("enabled", defaultValue: false)
	var enabled

	var body: some View {
		Toggle("Item", isOn: $enabled)
	}
}

Handling updates

If you copied the code above into a test project of your own, you might have noticed that it doesn’t exactly seem to work as we’d hope. You can toggle the switch all you want, but the UI doesn’t update. What’s going on? We made a Binding and everything!

With a debugger, it’s easy to verify that the Toggle is actually invoking the Binding callback as it should. When we flip the switch, it calls through the set implementation, and from there to our wrappedValue setter, which writes to user defaults. In fact if we relaunch the app the UI will read the correct state that has been being saved in user defaults all this time, so the problem is in how SwiftUI updates the view rather than the backing store for the value. Since we have a debugger attached, we can see that the getter does get called a few times, which must be how the framework sets the initial state of the view. After that, though, the UI does not update, even though the getter returns the new value. Notably, ContentView’s body is not evaluated again, even though it should because of the new state.

Examining State

If we replace Setting in our sample code with State, things work as expected. This is entirely unsurprising, because this is how we’re supposed to do things. Somehow SwiftUI knows when States change and triggers a view update in response. To do this, it must somehow be instrumenting stores to the underlying value…wait, this is exactly what property wrappers are for! When you call the setter you get a chance to run your own code, and State must use it to tell SwiftUI about the change. Putting aside our earlier conversation about nonmutating, State probably looks something like this:

struct State<Value> {
	var _value: Value

	var wrappedValue: Value {
		get {
			_value
		}
		set {
			_value = newValue
			SwiftUI._noteChanges(self)
		}
	}
}

Even though this explains why our code doesn’t work, this is still a problem for us, because we can’t call this method ourselves. It’s internal to SwiftUI, and only State (and the other built-in types) know how to do it. However, we don’t have to. The framework provides an affordance to let solve our issue: composing property wrappers.

Composition with DynamicProperty

Since only built-in types know how to update the UI, SwiftUI allows us to extend this system with composition. If a property wrapper contains a State inside of itself, then mutating it can initiate a refresh. Let’s make a few changes to the property wrapper (and skip the parts that remain unmodified):

@propertyWrapper
struct Setting<T> {
	let key: String
	let defaultValue: T
	
	@State
	var value: T?

	init(_ key: String, defaultValue: T) {
		self.key = key
		self.defaultValue = defaultValue
		self.value = UserDefaults.standard.object(forKey: key) as? T
	}

	var wrappedValue: T {
		get {
			value ?? defaultValue
		}
		nonmutating set {
			value = newValue
			UserDefaults.standard.setValue(newValue, forKey: key)
		}
	}
}

Note the addition of value, the new State<T> variable that we return instead. In the wrappedValue setter, we write to user defaults as before, but we also update value, which (as a State) can go and trigger a view refresh. Or, it would, but we’re not quite done yet. SwiftUI doesn’t know about the State we put inside our property wrapper yet: to let it peer inside and hook things up we need to conform Setting to the DynamicProperty protocol. We’ll look into why this needs to be the case a little later, but with the change this code finally works. Toggling the switch updates the UI and it also writes to defaults, and this value persists across launches. Success!

Abusing dependencies

Even though our design works, it’s a little…unpleasant? The “source of truth” is in two places: in the State variable value, and in user defaults. UserDefaults is plenty fast and doesn’t need us to cache values on its behalf. The old design we had, where defaults was the backing store and we talked to it directly, was definitely cleaner. If the State setter’s side effects are all we need, can we clean the code up a little bit? What if we did this?

@propertyWrapper
struct Setting<T>: DynamicProperty {
	let key: String
	let defaultValue: T
	
	@State
	var value: T? = nil

	init(_ key: String, defaultValue: T) {
		self.key = key
		self.defaultValue = defaultValue
	}

	var wrappedValue: T {
		get {
			UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
		}
		nonmutating set {
			value = newValue
			UserDefaults.standard.setValue(newValue, forKey: key)
		}
	}
}

Even though we store things in value to trigger updates, we don’t use it as the source of truth anymore. Instead, we just return whatever user defaults has in it. In the last version we were careful to have it match value at all times, so this should produce the same value.

If you try out this version, you’ll find that it doesn’t work anymore! The reason for this is actually quite subtle. If you’ve been following along so far and want a quick puzzle, see if you can debug what is going on before moving on to the next section. Hint: try printing value in wrappedValue’s getter.


The hint I gave was slightly misleading. Yes, printing value in wrappedValue’s getter does tell you that its value matches what is in user defaults (well, except until it is set for the first time, but you can add that code back in and check that it doesn’t matter). But more importantly, adding the print statement makes it work again! If you remove the print statement, it stops working, and if you add it back, it works again.

If you test a little bit more, you’ll see that it’s not the print statement that’s important, but the access of value itself. Even this code works:

get {
	_ = value
	return UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
}

This is not a fluke of the optimizer. State.wrappedValue’s getter actually does something special: it returns the value, but it also notes to SwiftUI that the value has been “read”. This is actually how SwiftUI optimizes view updates to only change parts of the UI that matter. When invoking a view’s body, it must keep a list of all the state that is read during the pass. If any of the state changes in the future, it knows which views to update based on which ones access that state, and it will skip redrawing views that are unrelated. For us, this means that we cannot just set value in our setter: we also have to access it in the getter, to indicate to SwiftUI that we depend on it.

It’s important to note at this point that this mechanism, while clever, is also somewhat crude. SwiftUI has no idea what we are doing with the value, only that we have accessed it. It cannot, because we can perform arbitrary computation with it to generate all of our “derived” properties. In fact the type of the State we update does not have to match the eventual dependent value (you can imagine a legitimate case where the wrappedValue was, say, the description of the State’s value). And we don’t even have to do anything with the value we access. It’s an access for the side effects of the access, and an update for the side effects of the update. This, you could argue, is a lot worse than our proper State-based solution, but it’s far more flexible. Here’s what it looks like (again, skipping redundant parts):

@propertyWrapper
struct Setting<T>: DynamicProperty {
	// Dummy state that SwiftUI thinks we depend on
	@State
	var _update = false

	var wrappedValue: T {
		get {
			_ = _update
			return UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
		}
		nonmutating set {
			_update.toggle()
			UserDefaults.standard.setValue(newValue, forKey: key)
		}
	}
}

State lifecycle

Our Setting is pretty neat, but it’s missing an important feature: user defaults can be updated externally, not just from the changes we make in the app. UserDefaults is KVO compliant to allow us to respond to these changes. Since we know how to set up dependencies ourselves, updating our code to handle this isn’t too hard:

@propertyWrapper
struct Setting<T>: DynamicProperty {
	class Observer: NSObject {
		let key: String
		let _update: State<Bool>

		init(key: String, _update: State<Bool>) {
			self.key = key
			self._update = _update
			super.init()
			UserDefaults.standard.addObserver(self, forKeyPath: key, context: nil)
		}
		
		deinit {
			UserDefaults.standard.removeObserver(self, forKeyPath: key)
		}

		override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) {
			_update.wrappedValue.toggle()
		}
	}
	
	let observer: Observer

	init(_ key: String, defaultValue: T) {
		self.key = key
		self.defaultValue = defaultValue
		self.observer = Observer(key: key, _update: __update)
	}
}

Unfortunately, we can’t use Swift’s type-safe KeyPath-based callback API because key is a string, so we need a bit more boilerplate involving a NSObject subclass. We can pass it the State that we’re using to manage updates and it can inform SwiftUI using that when the value changes. Otherwise, everything else can stay the same.

As you’ve probably guessed by now, this doesn’t work. At least it doesn’t break in-app behavior this time: it just doesn’t update when changes happen externally. While the KVO callback does get invoked, poking _update from inside it does not have any visible effects.

Reflection introspection

Even though State looks opaque to us, it must maintain some private data inside of itself. At the very least, it must have some sort of identity: even though it’s a struct, it manages data that has a lifetime longer than any particular State instance. This state (pun unintended) is not accessible to us, but holds the key to the behavior we are seeing. Even though we are trying to update the “same” State in our code, something must be different about the two for one change to go through and another to get dropped.

Fortunately, Swift binaries typically contain fairly rich metadata that can help expose these internals for us. Mirror uses this to back its reflection APIs, but the debugger also knows how to read it, allowing us to poke around at the innards of types we don’t own. Let’s try that by printing __update (note the extra leading underscore to refer to the property wrapper itself) in wrappedValue, where we know it is ready to publish our changes:

(lldb) po __update
▿ State<Bool>
- _value : false
▿ _location : Optional<AnyLocation<Bool>>
  ▿ some : <StoredLocation<Bool>: 0x600002f88c00>

As expected, State is a composite of several properties. _value is fairly self-explanatory, and we guessed its existence earlier. However, the other property, _location, is a little more enigmatic. We can drill down into it further using LLDB (for example, we can see that there is a flag called _wasRead, as well as a connection to AttributeGraph), but before dive too deeply let’s do the same dump in Observer.observeValue(forKeyPath:of:change:context:):

(lldb) po _update
▿ State<Bool>
  - _value : false
  - _location : nil

Aha! This time, _location is nil. This _location must (among other things) contain the connection back to SwiftUI used when notifying it of changes. Since it’s not set at this point, there’s nobody there to listen to our updates.

It’s worth thinking about how this might happen. Even though both Setting and Observer are using the “same” State, they actually don’t get a coherent view of _update. States are structs as mentioned earlier, so the Observer gets a copy of the state when it is initialized, which is when Setting is initialized. If we put a breakpoint in that initializer, we can see that _location is nil at that point. Observer’s own local copy is made then, and never sees any changes. On the other hand, Setting’s _update also has a nil _location at this point, but sometime in the future this changes to something valid. Who is doing it? When, and how?

The answer, of course, is that SwiftUI does it for us. Shortly before a view’s body is computed, it goes through and sets the _location on all relevant State variables, so that they are ready for dependency tracking. Typically, a property wrapper does not have the ability to grab context from outside of itself (for example, by looking up who owns it). SwiftUI can use reflection much like we did to discover State members that it needs to install _location on, sidestepping this issue. To discover State in nested types, it needs a little bit of help: this is why we had to add a DynamicProperty conformance earlier. In that case, it uses reflection to look for DynamicProperty members instead and then does a search for State inside of those.

SwiftUI does the same inside of our Setting property wrapper when the view that owns it goes for an update. This means that we need Observer._update to be set then, rather than in the initializer. Fortunately, the framework calls DynamicProperty.update() at exactly the right point in time to let us do this. Let’s update Setting to use it:

@propertyWrapper
struct Setting<T>: DynamicProperty {
	let key: String
	let defaultValue: T

	@State
	var _update = false

	class Observer: NSObject {
		let key: String
		var _update: State<Bool>!

		init(key: String) {
			self.key = key
			super.init()
			UserDefaults.standard.addObserver(self, forKeyPath: key, context: nil)
		}
		
		deinit {
			UserDefaults.standard.removeObserver(self, forKeyPath: key)
		}

		override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) {
			_update.wrappedValue.toggle()
		}
	}
	
	let observer: Observer

	init(_ key: String, defaultValue: T) {
		self.key = key
		self.defaultValue = defaultValue
		self.observer = Observer(key: key)
	}
	
	func update() {
		observer._update = __update
	}
}

With that, external updates (e.g. using defaults write) update the view as well. With that, our Setting is functional and ready to use with SwiftUI. Here’s the final version that we created, for reference:

While it is probably not worth using this code directly in your app (note the disclaimer above!), understanding how and why it works the way it does might be helpful in debugging your own SwiftUI projects.