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
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.
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.
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 @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. 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.
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.
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:
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.
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.
I believe you are supposed to compute the sum within withObservationTracking so it knows what properties the sum depends on, e.g. something like:
@Observable
class ObservableCalculator {
var a = 0
var b = 0
var _sum: Int?
var sum: Int {
if _sum == nil {
_sum = withObservationTracking {
a + b // simulating an expensive calculation we don't want to compute on every access to this var.
}, onChange: { [weak self] in
self?._sum = nil
}
}
return _sum!
}
}
Setting _sum nil in onChange happens when either a or b are set and the sum is recomputed and tracking is reconfigured on next call to the public getter.
Since the tracking is based upon the graph of access you don't need to have this so complicated. The following will 'just work'
@Observable
class ObservableCalculator {
var a = 0
var b = 0
var sum: Int { a + b }
}
When you access sum it will access both a and b so for example SwiftUI (or any usage of withObservationTracking) will get the update call for the first change to either a or b. No need to roll your own calls to withObservationTracking.
I believe in this example we are simulating an expensive computation like a sort, where we are trying to move it from a computed var into the model. E.g. where this wouldn't do:
struct MyView: View {
let x: Int
let y: Int
@State var counter = 0 // change to this would result in unecessary recomputation of sum
var sum: Int {
x + y // trying to prevent this from being computed if there are unrelated vars
}
var body: some View {
Text("\(sum, format: .number)")
Button("Inc \(counter)") {
counter += 1
}
}
}
But there is a valid argument you could just break up the View into smaller ones where only the params needed by sum are included, so the only time the sum is computed is when the related params change. However that might not always be possible and goes against the design of Previews where we aren't supposed to care how big a View is and certain property wrappers call body regardless if anything has changed.