[Second review] SE-0395: Observability

Yes, that’s true. It would really depend on the implementation. My thinking was it might be nice if the Observer doesn’t have to spawn a task just to pull out an AsyncSequence, but actually it’s six of one, half a dozen of the other. You’d end up doing it somewhere.

Just had a chance to play with @Observable and I noticed that structs cannot be equatable:

@Observable
struct Foo: Equatable {} 
// 🛑 Type 'Foo' does not conform to protocol 'Equatable'

The only reason why it isn't equatable is because ObservationRegistrar is not. I suppose ObservationRegistrar could trivially be made equatable by always returning true from ==.

Does that seem reasonable to do? And if not, what is the use case of observable structs? It doesn't seem very useful if they can't be made equatable, hashable, codable, etc…. Should @Observable restrict to only AnyObject?

6 Likes

I can't install new beta yet, if someone already has it installed can you test this please:

Given this view:

struct MyView: View {
    private var model = Model()
    
    var body: some View {
        let _ = print("body called")
        if model.greaterThan100 {
            ViewA()
        } else {
            ViewB()
        }
    }
}

What's the way to minimise number of times body is getting called?

Is it Model1:

@Observable final class Model1 {

    private var position: CGPoint = .zero // deliberately private
    
    public var greaterThan100: Bool {
        position.x > 100
    }
    
    init() {
        Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
            self.position.x += 0.01
        }
    }
}

Or Model2?

@Observable final class Model2 {

    private var position: CGPoint = .zero { // deliberately private
        didSet {
            greaterThan100 = position.x > 100
        }
    }
    
    public var greaterThan100: Bool = false
    
    init() {
        Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
            self.position.x += 0.01
        }
    }
}

Also, am I right assuming that as with @Observable / @Published before, position should be changed on main thread, otherwise it would be a runtime error / undefined behaviour?

I don’t think any of the observable machinery is supposed to be working with structs. Even without Equatable conformance it should produce an error. Protocol Observable inherits from AnyObject, so only classes can conform to it.

It does not inherit from AnyObject. It's the whole point why this feature shifted to use macros in the first place. First it was AnyObject, then potentially Identifiable and now without any constraints. structs are meant to be supported.

That's a very surprising and a non-obvious behaviour. For starters, my feedback would be that this should be explained in the proposal text - currently there is not a single mentioning of observable structs.

As far as I can see from code, identity needed for observation is provided by the buffer inside ObservationRegistrar. When @Observable macro is applied to a struct does it generate default initialiser for _$observationRegistrar?

If so, then in the following example x and y have equal identity from the point of view of the app. So subscribes to x should see changes made through y. But if new instance of buffer is created every time when RecordRef.init is called, this won't work.

@Observable struct RecordRef {
    let id: Int // Identity according to the app logic
}

let x = RecordRef(id: 0x123)
let y = RecordRef(id: 0x123)

I think in this case x and y should share a buffer inside ObservationRegistrar. Something like this:

var registrarsCache = ObservationRegistrarCache<Int>()
let x = RecordRef(_$observationRegistrar: registrarsCache.getOrCreate(0x123), id: 0x123)
let y = RecordRef(_$observationRegistrar: registrarsCache.getOrCreate(0x123), id: 0x123)
1 Like

I have two questions on the use of withObservationTracking().

Q1: the example in the proposal is in recursive style. I wonder if there is an example that are not recursive? If using withObservationTracking() typically involves a recursive call, it would be great to not require user to write the onChange closure explicitly.

Q2: is it possible to call withObservationTracking() inside the object's own method? An example scenario: in my SwiftUI app, I often implement view model as an ObservedObject as below.

class UserInput: ObservableObject {
    struct ValidValue {
        var x: Int
        var y: Int
        var z: Int
    }

   // These variables contain user's raw input
   @Published x: Int = 0
   @Published y: Int = 0
   @Published z: Int = 0

   // This contains validated input values.
   @Published validValue: ValidValue?

   init() {
       // Set up validation rules using Combine's Publisher API. Example rules:
       // 1) x, y, and z should be larger than 0
       // 2) y should be larger than x.
       // If all rules are met, save the valid value in validValue property; otherwise set that property to nil
       ...
   }
}

I wonder how can I do the above validation using the new API? Does the following code work, or do I have to use other mechanisms (e.g. didSet, etc)?

@Observable public class UserInput {    
    struct ValidValue {
        var x: Int
        var y: Int
        var z: Int
    }

    var x: Int = 0
    var y: Int = 0
    var z: Int = 0

    var validValue: ValidValue?

    init() {
       onValueChange()
    }

    mutating func onValueChange() {
        withObservationTracking {
            // Implement rules here
            ...
        } onChange: {
            onValueChange()
        }
    }
}

The macro synthesis for the method withMutation is user-replaceable.

    internal nonisolated func withMutation<Member>(of keyPath: KeyPath<UserInput, Member>, _ mutation: () throws -> T) rethrows {
        defer { if keyPath != \.validValue { onValueChange() } }
        return try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
    }

That would replicate what the macro does for mutations and add that value validation. It is worth noting that check on the key path is required to prevent recursion.

2 Likes

Answering myself:

  • Model1 works but causes excessive body callouts.

Edit: Perhaps this is inevitable, unless the observation machinery can somehow collect the snapshot of all actual values being used in the body call and compare it with the previous snapshot – something similar to what SwiftUI is already doing when comparing the result of one body call with another, just on the "data level", which presumably could be done faster.

  • With Model2 @Observable is doing something bad that causes a compilation error :frowning: Removing @Observable fixes the compilation error (but obviously nothing works afterwards).

  • With Model1: I can change model state from a background thread and there are no subsequent runtime errors :+1:

Full example.
import SwiftUI
import Observation

@Observable class Model1 {
    static let shared = Model1()
    
    var position: CGPoint = .zero // deliberately private

    public var condition: Bool {
        (position.x * 100).remainder(dividingBy: 100) < 0
    }
    
    init() {
        Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
            DispatchQueue.global().async {
                self.position.x += 0.01
            }
        }
    }
}

@Observable // comment this - and it compiles fine
class Model2 {
    static let shared = Model2()

    private var position: CGPoint = .zero { // deliberately private
        didSet {
            condition = (position.x * 100).remainder(dividingBy: 100) < 0
            // 🛑 Instance member 'condition' cannot be used on type 'Model2'; did you mean to use a value of this type instead?
        }
    }
    
    public var condition: Bool = false

    init() {
        Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
            self.position.x += 0.01
        }
    }
}

struct MyView: View {
    private var model = Model1.shared
    static var count = 0
    
    var body: some View {
        let _ = print("body called \(Self.count)")
        let _ = (Self.count += 1)
        if model.condition {
            Text("Hello")
        } else {
            Color.green
        }
    }
}

struct ContentView: View {
    var body: some View { MyView() }
}

#Preview {
    ContentView()
}

@main struct iOSApp: App {
    var body: some Scene {
        WindowGroup { ContentView() }
    }
}

A quick further question if you don't mind. What if I need to modify properties when doing validation? Will that triggers another withMutation call?

An example scenario. Suppose the validation rules are (it's more than validation, because it also makes automatic adjustment. See rule2).

Rule 1) x > 0, y > 1, z > 2
Rule 2) when x changes, automatically change y to have the same value

I think the defer code might be like the following:

defer {
    if keyPath == \.x {
        // Implment rule 2
        // Note: I assume this will trigger OnMutation again.
        y = x 
        return 
    } else if keyPath != \.validValue {
        // Implement rule 1
        ...
    }
}

The issue with the code is it's difficult to understand and maintain. In contrast, the code using Combine's Publisher API is much simpler, due to its declarative API style. I suspect this might not be the focus of the current proposal, but since the new API means "Combine-free" code, it has impact on how one should implement the above scenario.

Reference: the code using Combine API
    // Rule 2
    $x
        .sink { x in
            self.y = x
        }
        .store(in: &cancellables)

    // Rule 1
    Publishers.CombineLatest3($x, $y, $z)
        .map { x, y, z in
            if x > 0, y > 1, z > 2 {
                return ValidValue(x: x, y: y, z: z)
            }
            return nil
        }
        .assign(to: \.validValue, on: self)
        .store(in: &cancellables)

I just read through both review threads as well as the proposal. I'm generally +1, and I think value observation is very important for the language.

(TLDR: Please add a way to be notified when there are both some observers and no observers as a Future Direction.)

That being said, as best I can tell, there is currently no way for an Observable thing to know whether it is being observed or not.

I feel that this is very useful missing functionality, and the same concern I believe was raised in the initial review thread: SE-0395: Observability - #25 by Jon_Shier

Consider this use-case for such information:

@Observable
class Camera {
    
    // Not shown, but this is set with the most recent sample buffer.
    var latestSample: CMSampleBuffer? = nil
    
    func start() {
        // This turns the camera on.
        // If **somebody** is observing `latestSample`, the camera should turn on.
    }
    
    func stop() {
        // This turns the camera off.
        // If **nobody** is observing `latestSample`, the camera should turn off, to save power and resources.
    }
}
2 Likes

Partly unrelated to the actual review. The async part was removed from this proposal and there's currently no support for actors as far as I can tell. Are there any plans to push this topic forward in the very near future so that it can potentially land in the new OSs?

Without knowing if true or not, but the SwiftData framework seems to be incomplete in some ways. There's a ModelActor protocol, but there doesn't seem to any other async parts. For example, fetching seems to only be synchronous and run on the main thread only. If the data set is very large and the query somewhat complex, then I can imagine that it will block the main thread for too long. I can only guess that it's partly related to @Observable macro not providing any support for async stuff initially.

Another personal example is that I would like to convert an actor with @MainActor isolated stored properties to a @Model (which is also @Observable).

actor A: ObservableObject, Identifiable {
  var actorIsolatedString: String = "foo"

  @MainActor
  var globalActorIsolatedString: String = "bar"
}

The example I shared is totally legal and safe. The id is nonisolated by default and the rest is explained here by John:

TLDR; I would like to have observable actors (as models) which also have other observable stored properties that are isolated by global actors.

There is only one part (yet a key bit of functionality to the @Observable macro) that is missing: is KeyPath support for actors.

Note: Using an actor with ObservableObject is not exactly safe - if you have any @Published properties that will try and access values in a non-isolated manner.

Philippe, can you expand upon this? I'm not yet familiar enough with the macro flow to understand where/how this replacement would be done and would appreciate a bit more detailed clarification.

So the macro synthesis generates functions: but ONLY adds them if the type does not already have a function of that signature.

so if you write this in your type:

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

The macro won't synthesize that method but will still call it. Thusly allowing access while mutations occur... it is worth noting that this can get recursive - so be careful not to call back into your own property's mutation.

1 Like

EDIT: I think I had my function signature slightly off; I directly copied from expand macro and now I see the behavior.

Correct func definition:

internal nonisolated func withMutation<Member, T>(keyPath: KeyPath<TestModel, Member>, _ mutation: () throws -> T) rethrows -> T {

Incorrect func definition (note the extra 'of' before keyPath):

internal nonisolated func withMutation<Member, T>(of keyPath: KeyPath<TestModel, Member>, _ mutation: () throws -> T) rethrows -> T {

Interesting; testing this out in Xcode beta my instance of withMutation is not being triggered, or at least a breakpoint within it isn't being hit.

Here's my super simplistic sample:

@Observable
class TestModel {
    var testField: Int = 1
    var otherTestField: Int = 1
    
    func onTestFieldValueChange() {
        debugPrint("Triggered")
    }
    
    func onOtherTestFieldValueChange() {
        debugPrint("Triggered")
    }
    
    internal nonisolated func withMutation<Member, T>(of keyPath: KeyPath<TestModel, Member>, _ mutation: () throws -> T) rethrows -> T {
        defer {
            switch keyPath {
            case \.testField:
                onTestFieldValueChange()
            case \.otherTestField:
                onOtherTestFieldValueChange()
            default:
                break
            }
        }
        return try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
    }
}

struct ContentView: View {
    var model: TestModel = TestModel()
    
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text("Hello, world!")
            
            Text("Current Value of testField: \(model.testField)")
            Text("Current Value of otherTestField: \(model.otherTestField)")
            
            Button {
                model.testField += 1
            } label: {
                Text("Increment A")
            }

            Button {
                model.otherTestField += 1
            } label: {
                Text("Increment B")
            }
        }
        .padding()
    }
}

Observation is supported on iOS 17+ / macOS 14+. I wonder what is the main blocker that prevents it from being back deployed to older OS versions.

We've found some very promising uses for @Observation when applied to structs, and so wanted to surface this question again just so that it doesn't get lost.

One of the cooler things it unlocks is the ability to hold onto structs inside an @Observable class and be more selective with what is observed. For example, suppose you had a struct model that held lots of fields

struct Episode {
  let id: UUID
  let title: String
  let transcript: String
  let publishedAt: Date
  let subtitle: String
}

And you held that value in an observable model:

@Observable
class FeatureModel {
  var episode: Episode
}

And then in the view you needed wanted to display the title of the episode:

struct FeatureView: View {
  var model: FeatureModel
  var body: some View {
    Text(model.episode.title)
  }
}

The way things are today (and I believe the way things must be), the view will now observe all of episode even though all it wants is the title. And the episode state could be quite large and lots of different parts could be mutated a bunch, none of which requires the view to be re-rendered, but nonetheless will cause re-renders.

Well, if structs could be observable then we could simply do this:

+@Observable
 struct Episode {
   let id: UUID
   let title: String
   let transcript: String
   let publishedAt: Date
   let subtitle: String
 } 
 @Observable
 class FeatureModel {
+  @ObservationIgnored
   var episode: Episode
 }

Now the access of model.episode.title doesn't observe all of episode and instead only observes title. So, if you were seeing performance problems with over-renders you would have this tool available to really whittle down the state to something smaller.

So, I'm really glad that structs are observable (and we have found a few other use cases in our popular library TCA), but the fact that it doesn't play nicely with all the nice protocols we expect of structs (Hashable, Codable, ...) is a bummer. It would be nice if ObservationRegistrar could confirm to those protocols with the minimal, trivial implementation.

2 Likes

I am currently looking into the feasibility of making that work; there is a set of strange consequences however. The registrar would be then equatable; but it would always return true. The hash function would never hash anything into the hasher (effectively being a hashValue of 0 + salt).

If those don't cause any issue I don't see why we couldn't make ObservationRegistrar conform to Hashable and Codable.

6 Likes

Should it though?

So, I started with a model:

@Observable class Model3 {
    var relevant = 0
    var irrelevant = 0

    init() {
        Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
            self.irrelevant += 1
        }
    }
}

having a view that depends on the "relevant" field and the body of the view is not getting called (as I expect).

Then, for some external reasons I do a "benign" refactoring, moving the contents of the class into a struct:

struct S {
    var relevant = 0
    var irrelevant = 0
}

@Observable class Model4 {
    var s = S()

    init() {
        Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
            self.s.irrelevant += 1
        }
    }
}

and suddenly this is a behaviour change and my view's body started getting called on irrelevant field changes.

I can see how we got ourselves into this situation, just from a naïve usability pov this is somewhat surprising.

3 Likes