Handling a specific kind of over-observing using @Observable

Hi all,
I have a fairly large code base that is using RxSwift quite a lot. (RxSwift is similar to Combine for those who are not familiar with it.)
We have been replacing a lot of view models with @Observable types the past years, and for a large part the refactor has been trivial.

But recently I found a side effect of using RxSwift that I had not fully appreciated before.
In RxSwift it's very easy to 'focus in' on a nested child property of a changing value and only get signal updates for the changes to the nested child property that you care about.

Let's say that you have a type, BigStruct:

struct BigStruct {
    var nested: [String: Int] = [:]
    var value: String = ""
}

I know it's not that big, but it serves the purpose. :slight_smile:
Let's say that you have a source of BigStructs where nested change frequently but value does not.
With Rx you could have a view model like:

class ViewModel {
   let bigStructs: Observable<BigStruct>
   lazy var values: Observable<String> = bigStructs.map(\.value).distinctUntilChanged()
}

Now the 'values' is focused on a part of the BigStruct that changes more rarely than the entire value. When bound to a UI component, the component will only update on value changes.

I did not find an easy way to translate that to a corresponding @Observable view model.

And that of course got me wondering: is this a common pattern? Is my code base special in having the need for such a concept?
I don't recall having seen any discussions about anything similar.

I am currently experimenting with a macro: @ObservationDerived.
It should expand to something similar to):

    var value: String { _derived.value }

    @ObservationIgnored
    lazy var _derived: Derived<String, ViewModel> =
        Derived(source: self, transform: \.bigStruct.value)

Where Derived wraps a stored property (a cache so to say) and uses withObservationTracking to get updates.

This will naturally cause observation of the root value even though you may not be observing the derived value on every code path of the view, so it always does a bit extra work.
I found many use cases for this for generating calculated values that are derived from multiple entities and properties representing user choices from the UI.

Has anyone else had the need for such a pattern?
And if not, I am wondering why not? :slight_smile:

Sincerely,
Morten

1 Like

I haven't used Observable a lot in practice, but doesn't it have the same behavior?

Example: Setting (but not changing) a property doesn't trigger withObservationTracking function's onChange closure

The example is generated by AI:

import Foundation
import Observation

@Observable
class UserSettings {
    var username = "Alice"
    var score = 100
}

let settings = UserSettings()

@MainActor
func startTracking() {
    withObservationTracking {
        print("Current username is: \(settings.username)")
    } onChange: {
        print("The username changed!")
    }
}

startTracking()
settings.username = "Alice"  // Does not triggers onChange!

Looking at @Observable's source code, the behavior is due to the check in set accessor. Since SwiftUI is based on withObservationTracking, I expect it should have the same behavior. Am I misunderstanding what you asked about?

1 Like

An example of my issue Is when my Observable view model contains a big nested struct and my view only uses a single property of that struct.
With the default behavior I get a view update any time the struct value updates, and not only when the nested value I care about updates.

That’s the simple example, but I also have examples where I create derived values of something like an entity and a user selection. If the derived value is expensive to calculate and I have different ui elements showing properties of the derived value, then I would basically like to be able to cache the derived value, rather than calculating it on every access from the ui.

Does that make sense?

1 Like

If you use a nested @Observable class instead of a struct, this will work correctly.

1 Like

Thanks for the explanation. It makes sense indeed. In your BigStruct example, the behave was caused by the following code generated by the macro:

private func shouldNotifyObservers<Member: Equatable>(
  _ lhs: Member, _ rhs: Member
) -> Bool {
  lhs != rhs
}

IMO Observable macro should provide a way to customize the function. Perhaps it's worth submitting a feature request?

In addition to Jed's suggestion, my experiment showed this code also worked:

@Observable
class ViewModel {
   @ObservationIgnored var bigStructs = BigStruct() {
      didSet { value = bigStructs.value }
   }
   var value = ""
}
A complete example
import Foundation
import Observation

struct BigStruct {
    var nested: [String: Int] = [:]
    var value: String = ""
}

@Observable
class ViewModel {
   @ObservationIgnored var bigStructs = BigStruct() {
      didSet { value = bigStructs.value }
   }
   var value = ""
}

let vm = ViewModel()

@MainActor
func startTracking() {
    withObservationTracking {
        print("Current value: \(vm.value)")
    } onChange: {
        print("The value changed!")
    }
}

startTracking()

vm.bigStructs.nested["a"] = 1 // Not trigger change
vm.bigStructs.value = "ok" // Trigger change

Note: Defining value as a computed property doesn't work, because 1) a computed property isn't tracked if the stored property it depends on is not tracked, 2) while we can use public API like access() and withMutation() to explicitly track a computed property, they don't work if user mutate bigStructs.value directly.

Does the above approach plus a memoize function work in this case?

A random thought on how to support it:

  • Introduce a new protocol:

    protocl ObservedValue {
      func shouldNotifyObservers(_ lhs: Self, _ rhs: Self) -> Bool
    }
    
  • Customize BigStruct's behavior by conforming to this protocol

    extension BigStruct: ObservedValue {
      func shouldNotifyObservers(_ lhs: Self, _ rhs: Self) -> Bool { lhs.value != rhs.value }
    }
    
  • Observable macro currently generates all 4 shouldNotifyObservers() overloads. Unfortunately introducing a new protocol like the above one would introduce ambiguity because, for example, a value may conform to both Equatable and the new protocol. To resolve the issue, a flag needs to be introduced somehow to generate only the customized shouldNotifyObservers.

This approach has a limitation: all view models will use the same custom function.

This came up recently here, and I pointed to a discussion in the review for @Observable starting around here and ending here. The summary is that @Observable only minimizes observations when it is reference types all the way down. There is no way to detect a change in a small part of a big value type.

5 Likes

Thanks for your input, @rayx , @j-f1 and @mbrandonw .

So my little experiment is kind of working, but perhaps not as nicely as it could be.

The premises are:

  1. I would like to keep my domain modeling with structs and not refactor the models to be reference types - and still be able to 'focus in' on a nested property of a struct and only get UI updates when that property changes.

  2. I would also sometimes like to derive new values from multiple observable properties and have the result cached, rather than performing the same computation in a computed property over and over.

Both of these are things that I am used to having by manipulating and combining signals in Rx.

So far I have:

@MainActor
@Observable
final class ViewModel {
    
    var bigStruct: BigStruct = .init()
    
    
    @ObservationDerived(\Self.bigStruct.value)
    @ObservationIgnored
    var value: String

    var userSelection: Int = 1

    private var expensiveCalculation: String {
        bigStruct.dict.filter { $0.value == userSelection }.map(\.key).joined(separator: ", ")
    }
    
    @ObservationDerived(\Self.expensiveCalculation)
    @ObservationIgnored
    var cached: String
}

The cached property will currently update on every change of userSelection or bigStruct, but the view will only re-render when the cached value changes.

If I use the cached property many different places in the view, it would still only recompute once per change of the underlying values - as compared to just using the computed property directly.

I would love to be able to skip the \Self. and use short-hand \. and also it would be nice if `@ObservationIgnored' could be skipped too, and the type of the 'derived' value be inferred, but as far as I understand it those niceties can't currently be achieved.

Do you think that a macro like this would be useful as a general-purpose tool?

Sincerely,
Morten

One note; the macro actually detects if you are providing your own implementations and defers to the programmer if they offer a more specific one.

2 Likes

@Philippe_Hausler Thanks for the information. That helps me think of a nice solution to OP's first question (sending notification only when a specific portion of a property changes).

struct BigStruct {
    var value1: [String: Int] = [:]
    var value2: String = ""
}

@Observable
class ViewModel {
    var bigStruct = BigStruct()

    // Customize the check for bigStruct property.
    private func shouldNotifyObservers(_ lhs: BigStruct, _ rhs: BigStruct) -> Bool {
       lhs.value2 != rhs.value2
    }
}

This custom funciton doesn't surpress the generation of the four generic overloads. So it customizes only the behavior of bigStruct property and doesn't affect other properties. In contrast, defining a custom generic funciton shouldNotifyObservers<Member: Equatable>(_ lhs: Member, _ rhs: Member) -> Bool would affect other properties.

However, as documented in the code modify accessor doesn't call this function. That makes the approach useless.

Can you show the expanded code? I haved read your description of ObservationDerived in the original post but don't quite understand it.

IIUC what you are trying to achieve is this scenario:

  • You have a dictionary containing raw data.
  • When user input a value, you compute a result based on the dictionary and user input.

I think caching is unrelated and should/could be handled separately, so let's ignore it. A problem is how to update the result when user input (or the raw data) changes. In your example they are in the same struct or class, so I'll use didset for simplicity (it might not be applicable in practical scenarios).

@Observable
class ViewModel {
    var dict: [String: Int] = [:] {
      didSet { result = compute() }
    }
    var userSelection: Int = 1 { 
      didSet { result = compute() }
    }
    var result: String = ""

    // Emulate an expensive computation
    func compute() -> String {
        dict.filter { $0.value == userSelection }.map(\.key).joined(separator: ", ")
    }
}

Since the view in SwiftUI just access result, the change of dict itself wouldn't re-render view. If dict or userSelection change but the result of compute() doesn't change, I don't think the above code would send nofication, because 1) result value conforms to Equatable, and 2) result = compute() uses set accessor rather than modify accessor. Also, you can add check in didSet closure to not set result if it doesn't change.

I suspect your scenario must be more complex. How is it different?

I think of a better solution which uses accessors. It implements the custom check in set accessor (modify accessor should work too).

import Foundation
import Observation

struct BigStruct {
    var nested: [String: Int] = [:]
    var value: String = ""
}

@Observable
class ViewModel {
    @ObservationIgnored
    var _bigStructs = BigStruct()

    var bigStructs: BigStruct {
        get {
            access(keyPath: \.bigStructs)
            return _bigStructs
        }
        set {
            // Only send notification when _bigStructs.value changes
            if newValue.value == _bigStructs.value {
                _bigStructs = newValue
            } else {
                withMutation(keyPath: \.bigStructs) {
                    _bigStructs = newValue
                }
            }
        }
    }
}

let vm = ViewModel()

@MainActor
func startTracking() {
    withObservationTracking {
        print("Current value: \(vm.bigStructs.value)")
    } onChange: {
        print("The value changed!")
    }
}

startTracking()

vm.bigStructs.nested["a"] = 1 // Not trigger change
vm.bigStructs.value = "ok" // Trigger change

Hi Huan,

Your example is very good. This is exactly what I am trying to achieve, but without having to remember to call 'compute' manually in all situations where the dependent values may have changed.

That's why I am building a macro for it.

Regarding your accessor example:
In my UI I may have multiple different use cases for different parts of bigStruct, so the accessor implementation is basically 'too specific', since it exposes one specific use case.

With the macro I can 'focus in' on different properties of bigStruct for different use cases.

And since I get the 'focusing' in through caching a value, the macro works equally well for eliminating over-observing when you are just interested in a nested value, but also for caching computations.

The expanded macro is quite big. It uses the new 'options' of withObservationTracking to recalculate after didSet. For previous versions we have to perform a thread hop for the dependent values to be updated.

I did get help from a chat bot to generate the macro code, so please excuse any weirdness here - it's still just an experiment:

    @ObservationIgnored
    var cached: String{
        get {
            access(keyPath: \.cached)
            if let cached = _derived_cached {
                return cached
            }
            let value = _derivedRecompute_cached()
            _derived_cached = value
            return value
        }
    }
    
    @ObservationIgnored private var _derived_cached: String? = nil
    
    private func _derivedRecompute_cached() -> String {
        if #available(iOS 27.0, macOS 27.0, tvOS 27.0, watchOS 27.0, visionOS 27.0, *) {
            return withObservationTracking(options: .didSet) {
                self.expensiveCalculation
            } onChange: { [weak self] _ in
                // Delivered synchronously on the mutating context,
                // which for a '@MainActor' type is the main actor.
                MainActor.assumeIsolated {
                    self?._derivedApply_cached()
                }
            }
        } else {
            return withObservationTracking {
                self.expensiveCalculation
            } onChange: { [weak self] in
                Task { @MainActor in
                    self?._derivedApply_cached()
                }
            }
        }
    }
    
    private func _derivedApply_cached() {
        let newValue = _derivedRecompute_cached()
        guard _derived_cached != newValue else {
            return
        }
        withMutation(keyPath: \.cached) {
            _derived_cached = newValue
        }
    }

Yes, didSet approach doesn't scale well. I haven't used function body macro myself, but I wonder if it's a better approach in this case (for example, applying the macro to every mutating function)?

A quick question. The code sends notification when cached property changes. But I think what you're interested is if the returned value from _derivedApply_cached changes? In the case of cache hit, it may occur that the returned value is different than the previously returned value but your code doesn't send change notification.

Slightly off-topic, does this mean you mix modal (bigStruct value) and view modal (derived values to show in UI) in a single struct? I haven't used Observable macro in my app, so I'm curious what's the common pratice to do that? In the pre-Observation projects, I think they were typically decoupled and got synchronized using Combine.

FWIW, I asked AI how to sync changes from model to view model when both are implemented using Observable macro. AI recommended using computed property in view model to access model because no sync is needed and the change tracking works out of the box. Stored property needs to sync'ed explicitly, using either withObservationTracking() or AsyncSequence.

I wonder how do you think about the following code? It decouples model and view model and is quite straightforward. One caveat: if model changes but viewmodel's result doesn't change, SwiftUI would still get notification (though it perhaps doesn't re-render view because SwiftUI has its own check). I don't think it's possible to customize a computed property behavior to not send notification when its underlying stored property changes but itself doesn't change, and I really doubt it's worth the effort.

import Observation

@Observable
class Model: Hashable, Equatable {
    var dict: [String: Int] = [:]

    // Equatable and Hashable conformance
    static func == (lhs: Model, rhs: Model) -> Bool {
        lhs.dict == rhs.dict
    }
    func hash(into hasher: inout Hasher) {
        hasher.combine(dict)
    }
}

@Observable
class ViewModel {
    let model = Model()
    var userSelection: Int = 1
    var result: String {
        compute(model, userSelection)
    }

    let compute: (Model, Int) -> String = memoize { model, userSelection in
        // Emulate an expensive computation
        model.dict.filter { $0.value == userSelection }.map(\.key).joined(separator: ", ")
    }
}

// memoize function

struct Args<Arg1: Hashable, Arg2: Hashable>: Hashable {
    let arg1: Arg1
    let arg2: Arg2
}

func memoize<Arg1: Hashable, Arg2: Hashable, Result>(
    _ function: @escaping (Arg1, Arg2) -> Result
) -> (Arg1, Arg2) -> Result {
    var cache: [Args<Arg1, Arg2>: Result] = [:]
    
    return { arg1, arg2 in
        let key = Args(arg1: arg1, arg2: arg2)
        
        if let value = cache[key] {
            return value
        }
        
        let result = function(arg1, arg2)
        cache[key] = result
        return result
    }
}

I'm not sure if this is exactly applicable to your context, but when editing complex nested structs in SwiftUI I use custom bindings to gain control over individual properties. For example, here outline is a struct within SymbolLayerDefinition and I can pass this as a binding to the StrokeSpec editor:

struct SimpleFillLayerEditor: View {

  @Binding var symLayer: SymbolLayerDefinition

  private var outline: Binding<StrokeSpec> {

    Binding(

      get: { symLayer.outline ?? StrokeSpec() },

      set: { symLayer.outline = $0 })

  }

...
}

Hi Huan,
Thanks for the suggestion using the memoize function. It is indeed conceptually close to what I wish to do.
I just tested the code and it does produce a UI refresh on every change of Model. I guess that is natural enough since the model value is being used as the key.
As such it doesn't reduce the frequency of updates - although it does cache the value across multiple uses from within the same view.

But the @ObservationDerived both caches and reduces UI refreshes.

Can you see any downsides to using that?

Thanks for the input @willtemperley ! Yes, this is definitely in the domain of the issue.

In your example, do you know if your StrokeSpec editor gets a UI refresh when other properties of the symLayer change?

Yes, I did an experiment to verify it. It appears that SwiftUI invalidates a view as long as its dependency (model.dict in this case) changes. It doesn't check if the view's value actually changes before calling view's body.

Experiment 1: computed property
import SwiftUI
import Observation

@Observable
class Model {
    var dict: [String: Int] = ["a":1, "b":2, "c":3]
}

@Observable
class ViewModel {
    let model = Model()
    var userSelection: Int = 1
    var result: String {
        compute(model.dict, userSelection)
    }

    let compute: (Dictionary<String, Int>, Int) -> String = memoize { dict, userSelection in
        // Emulate an expensive computation
        dict.filter { $0.value == userSelection }.map(\.key).joined(separator: ", ")
    }
}

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup("Test") {
            MyView()
                .frame(minWidth: 760, minHeight: 590)
        }
    }
}

private struct MyView: View {
    @State private var viewModel = ViewModel()

    var body: some View {
        let _ = print("\(Date()): view body is called")
        let _ = Self._printChanges()
        VStack(spacing: 20) {
            Text(viewModel.result)
                .font(.largeTitle)
                .bold()
            TextField("Please input", value: $viewModel.userSelection, format: .number)
        }.task {
            DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
                viewModel.model.dict["b"] = 4
            }
        }
    }
}

I find using stored property may have the same behavior. Below is an example. At first I thought it was due to the access to model.dict in ViewModel.startTracking, but if so I think Self._printChanges() should print something like "model.dict changed" rather than "@self changed". I'm not sure what's the exactly reason.

EDIT: I figured out why. When model changes, WindowGroup's closure gets called and creates a new instance of MyView. That's why SwiftUI reported "@self changed". What's more, I later realized the approach in experiment 2 wasn't good for a few reasons: a) The idiomatic way to pass model to view is using Environment property wrapper, b) The use of stored property and startTracking() is unidiomatic. It makes the change tracking quite hard to reason about. In contrast, if view model's methods access model, change tracking works out of the box (as in the situation of using computed property), c) view model is unnecessary in this simple case.

Experiment 2: stored property
import Observation
import SwiftUI

@Observable
class Model {
    var dict: [String: Int] = ["a":1, "b":2, "c":3]
}

@Observable
class ViewModel {
    let model: Model
    var userSelection: Int
    var result: String

    let compute: (Dictionary<String, Int>, Int) -> String = memoize { dict, userSelection in
        // Emulate an expensive computation
        dict.filter { $0.value == userSelection }.map(\.key).joined(separator: ", ")
    }
    
    init(_ model: Model) {
        self.model = model
        userSelection = 1
        result = compute(model.dict, 1)
        startTracking()
    }
    
    private func startTracking() {
        withObservationTracking {
            _ = model.dict
            _ = userSelection
        } onChange: { [weak self] in
            // Enqueue the operation becasue the change hasn't actually happen
            DispatchQueue.main.async {
                guard let self = self else { return }
                self.result = self.compute(self.model.dict, self.userSelection)
                self.startTracking()
            }
        }
    }
}

@main
struct MyApp: App {
    @State private var model = Model()
    var body: some Scene {
        WindowGroup("Test") {
            MyView(model)
                .frame(minWidth: 760, minHeight: 590)
        }
    }
}

private struct MyView: View {
    @State private var viewModel: ViewModel

    init(_ model: Model) {
        _viewModel = State(wrappedValue: ViewModel(model))
    }
    
    var body: some View {
        let _ = print("\(Date()): view body is called")
        let _ = Self._printChanges()
        VStack(spacing: 20) {
            Text(viewModel.result)
                .font(.largeTitle)
                .bold()
            TextField("Please input", value: $viewModel.userSelection, format: .number)
        }.task {
            DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
                viewModel.model.dict["b"] = 4
            }
        }
    }
}

I don't think this is the reason, because the cache is referenced by the closure and the reference itself doesn't change (the cache itself isn't tracked by Observable macro). BTW, in my earlier code I passed class instances as cache keys and changed those instance contents in view.body, that caused crashes. I have fixed it in my newer code by passing Model.dict as key.

My concern is readability. I found the generated code was quite hard to follow (it might help if you could show a simple complete example without using macro). But this is a subjective opinion and it's fine if you have no problem in using it.

In addition, I doubt it's worth the effort to implement it. IMO calling View.body to recreate view descriptions is very fast (assuming using cache to avoid repeated heavy computation), especially when compared with the underlying widget update. I don't know what's the common practice. Is there any way to measure it?

Yes, confirmed with a quick experiment, putting a breakpoint in the StrokeSpec editor, it gets a refresh when I change a sibling property, like changing the fill in a sibling editor.

This makes sense with value semantics I suppose, changing one property of a struct means the whole struct value is changed and the views need to be recomputed.

I think my first message was misleading though, the custom bindings don't really have anything to do with using structs, that was for dealing with optional properties. So a keypath version, e.g. $symLayer.fillStyle works fine for non-optional properties in structs.

The only drawback being this is very coarse, in that everything associated with the struct gets recomputed.

In the context of SwiftUI views I do not get what the problem is? A quick test shows that it is trivial to isolate one part of the structure from another:

import SwiftUI

struct BigStruct {
    var nested: [String: Int] = [:]
    var value: String = ""
}

@Observable final class Storage {
    var big: BigStruct = .init()
}

struct ContentView: View {
    @State var model: Storage = .init()
    
    var body: some View {
        ZStack {
            TitleView(title: model.big.value)
                .padding(.bottom, 100)
            DataCounter(data: model.big.nested)
                .padding(50)
        }.overlay(alignment: .bottom) {
            HStack(spacing: 40) {
                Button("Change data") {
                    let token = Int.random(in: -200...200)
                    model.big.nested["\(token)"] = token
                }
                Button("Change title") {
                    model.big.value = UUID().uuidString
                }
            }
        }
    }
}

struct DataCounter: View {
    let data: [String: Int]
    
    var body: some View {
        Text("Tokens: \(data.count)")
    }
}

struct TitleView: View {
    let title: String
    
    var body: some View {
        Self._printChanges()
        return Text("~~ \(title) ~~")
            .fontWeight(.bold)
            .fontDesign(.monospaced)
    }
}

Here, when nested is changed, the TitleView is not accessed. Granted, ContentView's body will be recalculated on every change in a structure, but then why not keep this view simple? If that's not enough then you don't have to keep BigStruct and instead just have two different properties in Storage.