Tracking properties in `@Observable` models internally

I have a view model type @Observable model object that reads inputs and processes them into outputs. With @ObservedObject and @Published wrappers it was straightforward to setup the pipelines using publishers. Now that I intend to migrate to @Observable, how do I observe and react to changes to the inputs within the model itself?

import SwiftUI

@main
struct CalculatorApp: App {

	var body: some Scene {
		WindowGroup {
			CalculatorView()
		}
	}

}

struct CalculatorView: View {

	@StateObject var calculatorObservableObject = CalculatorObservableObject()
	@Bindable var observableCalculator = ObservableCalculator()

	var body: some View {
		Form {
			Section("ObservedObject") {
				TextField("a", value: $calculatorObservableObject.a, format: .number)
				TextField("b", value: $calculatorObservableObject.b, format: .number)
				Text(calculatorObservableObject.sum, format: .number)
			}
			Section("Observable") {
				TextField("a", value: $observableCalculator.a, format: .number)
				TextField("b", value: $observableCalculator.b, format: .number)
				Text(observableCalculator.sum, format: .number)
			}
		}
	}

}

final class CalculatorObservableObject: ObservableObject {

	@Published var a: Int
	@Published var b: Int
	@Published private(set) var sum: Int

	init(a: Int = 0, b: Int = 0) {
		self.a = a
		self.b = b
		self.sum = a + b

		$a.combineLatest($b)
			.map(+)
			.assign(to: &$sum)
	}

}

@Observable final class ObservableCalculator {

	var a: Int
	var b: Int
	private(set) var sum: Int

	init(a: Int = 0, b: Int = 0) {
		self.a = a
		self.b = b
		self.sum = a + b

		//	How to track further changes to `a` and `b` and update `sum` accordingly?

		//	These are all the options I've tried so far, none of them work:
		withObservationTracking(
			{
				print(self.a)
				print(self.b)
			},
			onChange: {
				self.sum = self.a + self.b
			}
		)
		self.sum = withMutation(keyPath: \.sum) {
			self.a + self.b
		}
		withMutation(keyPath: \.sum) {
			self.sum = self.a + self.b
		}
	}

}

If it matters, I'm using Xcode 15b5 on macOS Ventura 13.4.1

4 Likes

Would this not be suitable?

	var a: Int
	var b: Int
	private var sum: Int { a + b }

That way you don't need to deal with tracking anything or intercepting withMutation events. Alternatively if you provide a withMutation function yourself that will be used instead of the synthesized version (however be careful not to be recursive within that - mutations inside of withMutation should be done such that it tests the keypath first and then mutates only when needed).

The code I've posted here is oversimplified for the sake of example. In my actual use case, calculating my private set property has O(n) complexity. Making it a computed property would cause the expensive operation to run each time it's accessed which is why I want to set it only when the dependent properties change.

3 Likes

If you only need to combine the latest values into some aggregate value, then I think the easiest way to approximate that would be to hook into the didSet of the dependency fields to call a shared compute function for performing the heavyweight work:

var a: Int {
  didSet { self.computeSum() }
}
var b: Int {
  didSet { self.computeSum() }
}
private var sum: Int
func computeSum() {
  self.sum = self.a + self.b
}

However, if you need to use other, more complex Combine operators, such as throttling, debounce, etc., then you will have to do a lot more work to capture that.

1 Like

I'm also using quite a few of those observers in my view models. As @prathameshk mentioned, it was super easy to set up those observations with a Combine pipeline in the view models initializer or a dedicated method.

Using computed properties is okay for some cases. And for other cases there exists the new withObservationTracking method. But in my opinion this is a huge step backwards. Especially since withObservationTracking is only executed once unless you wrap it in a function and call the function again in the onChange closure. This is a lot of boilerplate and rather awkward.

For example, I got a name property in my view model, when this property changes, I want to change the name property in an object that is held by the view model. I don't want to change the property of the object directly, and I also don't want this logic happen in my views.

Is there any way of doing this without using something like:

func observationOfName() {

    withObservationTracking {
        myObject.name = name
    } onChange: { [weak self] in
        self?.observationOfName()
    }
}

Hi @marcoboerner, is it not sufficient to replay the name change in the didSet?

var name: Sting {
  didSet {
    self.myObject.name = name
  }
}

Hi @mbrandonw thanks for your advice! In this particular case this would work, but in some more advanced cases where I used a bunch of publisher operations before, that would mean I have to somehow recreate that functionality/logic within the didSet. It's doable, but I found Combine here much cleaner and easier to understand. :thinking: So far I'm not completely convinced.

Yeah, I generally agree that this will not work in all cases. It's always worth trying it out to see if it works, but if you need some of the more advanced tools of Combine (such as debounce, throttle, etc), then you will have to recreate that work in an ad-hoc manner.

1 Like

I found a solution from two parts:

  1. This gist that returns a publisher for a given keypath. The downside is there's no way to limit the keypath parameter to @ObservationTracked keypaths only, so a publisher can be formed for @ObservationIgnored keypaths.
  2. This gist from @jasdev that lets you assign to weakly captured self, as Combine's assign(to:on:) captures its object strongly.

Thanks to it, my observable model now looks like this:

@Observable final class ObservableCalculator {

	var a: Int
	var b: Int
	private(set) var sum: Int

	private var cancellables: Set<AnyCancellable> = []

	init(a: Int = 0, b: Int = 0) {
		self.a = a
		self.b = b
		self.sum = a + b

		publisher(keyPath: \.a)
			.combineLatest(publisher(keyPath: \.b))
			.map(+)
			.assign(to: \.sum, onWeaklyCaptured: self)
			.store(in: &cancellables)
	}

}

It still needs Combine to work and uses the recursive call to withObservationTracking under the hood, and does not address the fact that continued observation is still required in the library.

Edit: Welp never mind I may have spoken too soon. It does not seem to work.

Hi @prathameshk, I highly recommend you do not use that publisher tool. The use of Task { … } in withObservationTracking in order to get the changed value has potential race conditions. This test shows the problem:

@Observable
class Model {
  var count = 0
}

final class ObservablePublisherTests: XCTestCase {
  func testBasics() async throws {
    let model = Model()
    let countPublisher = model.publisher(keyPath: \Model.count)

    var values: [Int] = []
    let cancellable = countPublisher.sink { int in
      values.append(int)
    }

    let max = 10
    for _ in 1...max {
      model.count += 1
      try await Task.sleep(for: .seconds(0.01))
    }

    XCTAssertEqual(values.count, max)       // ✅
    XCTAssertEqual(values, Array(1...max))  // ❌

    _ = cancellable
  }
}

This test does pass sometimes, but it will fail if you run it repeatedly, and you will get a failure like this:

:stop_sign: XCTAssertEqual failed: ("[1, 2, 3, 4, 5, 6, 7, 7, 9, 10]") is not equal to ("[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]")

This shows that the 8 value was not emitted and instead a stale 7 value took its place. This is due to the race condition of using the unstructured Task.

4 Likes

Yikes @mbrandonw that's not good. We're back to square one then.

Yea I have this question as well. Especially if you want to observe a property internally to pipe into an AsyncSequence

I messed around some more over the weekend and found this solution. It involves inlining both the @Observable and @ObservationTracked macros to write to private @Published properties instead of @ObservationIgnored properties:

import Combine
import Observation
import SwiftUI

@main
struct CalculatorApp: App {

	@Bindable private var calculator = ObservableCalculator(a: 0, b: 0)

	var body: some Scene {
		WindowGroup {
			Form {
				Section {
					TextField("a", value: $calculator.a, format: .number)
					TextField("b", value: $calculator.b, format: .number)
				}
				Section {
					Text(calculator.sum, format: .number)
				}
			}
		}
	}

}

final class ObservableCalculator: Observation.Observable, ObservableObject {

	var a: Int {
		init(initialValue) initializes (__a) {
			__a = .init(initialValue: initialValue)
		}

		get {
			access(keyPath: \.a)
			return _a
		}

		set {
			withMutation(keyPath: \.a) {
				_a = newValue
			}
		}
	}
	@Published private var _a: Int

	var b: Int {
		init(initialValue) initializes (__b) {
			__b = .init(initialValue: initialValue)
		}

		get {
			access(keyPath: \.b)
			return _b
		}

		set {
			withMutation(keyPath: \.b) {
				_b = newValue
			}
		}
	}
	@Published private var _b: Int

	private(set) var sum: Int {
		init(initialValue) initializes (__sum) {
			__sum = .init(initialValue: initialValue)
		}

		get {
			access(keyPath: \.sum)
			return _sum
		}

		set {
			withMutation(keyPath: \.sum) {
				_sum = newValue
			}
		}
	}
	@Published private var _sum: Int

	private let _$observationRegistrar = Observation.ObservationRegistrar()
	private var cancellables: Set<AnyCancellable> = []

	init(a: Int, b: Int) {
		self.a = a
		self.b = b
		self.sum = a + b

		Publishers.CombineLatest($_a, $_b)
			.map(+)
			.assign(to: \.sum, onWeaklyCaptured: self)
			.store(in: &cancellables)
	}

	internal nonisolated func access<Member>(
		keyPath: KeyPath<ObservableCalculator, Member>
	) {
		_$observationRegistrar.access(self, keyPath: keyPath)
	}

	internal nonisolated func withMutation<Member, T>(
		keyPath: KeyPath<ObservableCalculator, Member>,
		_ mutation: () throws -> T
	) rethrows -> T {
		try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
	}

}

extension Publisher where Failure == Never {

	func assign<Root: AnyObject>(
		to keyPath: ReferenceWritableKeyPath<Root, Output>,
		onWeaklyCaptured object: Root
	) -> AnyCancellable {
		sink { [weak object] value in
			object?[keyPath: keyPath] = value
		}
	}

}

As a bonus, you could conform your model to ObservableObject and get both the new and old style of observability in one definition.

I wrote a basic unit test to confirm that publishing works correctly, but more testing is likely needed.

final class CalculatorTests: XCTestCase {

	func testCalculator() async throws {
		let calculator = ObservableCalculator(a: 0, b: 0)

		var values: [Int] = []
		let cancellable = calculator.$_sum
			.dropFirst()
			.sink { values.append($0) }

		let inputsA = Array(1...10)
		for _ in inputsA {
			calculator.a += 1
			try await Task.sleep(for: .milliseconds(1))
		}
		let inputsB = Array(1...10)
		for _ in inputsB {
			calculator.b += 1
			try await Task.sleep(for: .milliseconds(1))
		}

		XCTAssertEqual(values.count, inputsA.count + inputsB.count)
		XCTAssertEqual(values, Array(1...20))

		_ = cancellable
	}

}

Finally, if someone who knows macros better than me could come along and repackage all the inlined macro-generated code into a new macro that would be great.

2 Likes

You could wrap withObservationTracking in AsyncStream as follows:

import SwiftUI
import AsyncAlgorithms

@MainActor
struct Calculator {
    
    @Observable
    class Model {
        var a: Int?
        var b: Int?
        var sum: Int = 0
    }
    
    let model = Model()
    static var shared = Calculator()
    
    init() {
        Task { [model] in
            let aDidChange = AsyncStream {
                await withCheckedContinuation { continuation in
                    let _ = withObservationTracking {
                        model.a
                    } onChange: {
                        continuation.resume()
                    }
                }
                return model.a
            }
            .compacted().removeDuplicates()
            
            let bDidChange = AsyncStream {
                await withCheckedContinuation { continuation in
                    let _ = withObservationTracking {
                        model.b
                    } onChange: {
                        continuation.resume()
                    }
                }
                return model.b
            }
            .compacted().removeDuplicates()
            
            for await x in combineLatest(aDidChange, bDidChange).map(+) {
                model.sum = x
            }
        }
    }
}

struct ContentView: View {
    let calculator = Calculator.shared
    
    var body: some View {
        Form {
            Section("ObservedObject") {
                let bindable = Bindable(calculator.model)
                TextField("a", value: bindable.a, format: .number)
                TextField("b", value: bindable.b, format: .number)
                
                Text(calculator.model.sum, format: .number)
            }
        }
    }
}

I also blogged about this here.