[Second review] SE-0395: Observability

Hm, a bit strange to have a language feature limited by some framework. :thinking: There are multiple use cases outside SwiftUI where observability could be useful.
I get the reason, but doesn't it hurt Swift's image outside Apple's platforms? Still see comments that result builder was forced and people are not sure about language. :man_shrugging:

6 Likes

Note that in this case the blame is on the Observation, as SwiftUI team would rightfully point out that "the Observation itself is not back deployable", and subsequently "they can't make SwiftUI + Observation back deployable". Was Observation itself back deployable – the blame and pressure would have been on the SwiftUI team.

As a user I wouldn't mind (and as a developer I would welcome) if quasi back deployment is achieved by installing a x.x.X or x.X update, similar to how security updates are installed on old OS versions.

It's not about blaming. Both variants have its own pros and cons, and can get why it was considered to be back ported at the beginning, but then turned out.
Just saying that it could look a bit off from non-iOS/macOS perspective.

Ah, I can assure you that most iOS/macOS developers would be equally unhappy they can't start using new Observation as they need to support, say, iOS 15 / macOS 10.15.

Could we have this as an alternative?

@MAGIC class Model {
    @ANOTHER_MAGIC var field = 42
}

struct MyView: View {
    @YET_ANOTHER_MAGIC var model = Model()
    ...
}

That will translate accordingly to either @Observable & Co / @ObservableObject & Co depending upon OS version?

1 Like

SwiftUI's usage of Observability is their own macro, closed-sourced. So not back-portable.

You can use the open-source back-portable Observability on normal and general Swift Programming, which is still useful

I personally don't think so. I've been using SwiftUI since iOS 14, and with every new OS release I fully expect new features to be restricted to the latest version of iOS (and the equivalent version for other platforms).

It's a very pleasant surprise if a feature is backported, but I wouldn't expect older versions of SwiftUI to gain support for new Swift capabilities. Swift concurrency, for example, is compatible with iOS 14 but the task(priority:_:) modifier is only available on iOS 15 or later.

2 Likes

There’s been a lot of discussion and confusion about whether and how structs can be @Observable, but no clear answer as far as I can tell.

Since structs are value types, isn’t the formal model that any mutation of a struct’s properties is actually a replacement of the entire struct? How can a thing be observable if can only be replaced wholesale?

3 Likes

Yeah, the registrar is actually a reference type so now the value has reference semantics (at least in regard to the registrar) so each copy is carrying a reference to the original registrar. Now, when a copy is mutated all observers of the original value get notified – at least as far as I can tell.

Maybe there’s some plan to resolve this, but seems to be undesirable as is.

1 Like

Can withObservationTracking(_:onChange:) be called recursively? I'm experimenting with it on Xcode 15 beta and encountered this crash:

  • What is your evaluation of the proposal?

+0.75

  • Is the problem being addressed significant enough to warrant a change to Swift?
  • Does this proposal fit well with the feel and direction of Swift?

Yes, it's great with generic support for observability. There seems to be room for a richer scope for a module going into the standard library than solely what seems to be (great) support for SwiftUI.

  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

I've followed the pitches, read the proposal several times and now after the drop of the WWDC toolchains/beta I got an environment up to I could actually experiment a bit with it (as we have a non-SwiftUI use case where we want to use observability). Hands on experience was useful as it shows that the current API seems to be very specialised in utility (surely good for SwiftUI though!).

I will just throw out here what we would have good use for, basically we'd want something like (there may surely be better ways to do it, I just want to try to communicate our use case):

    public func addObservationTracking(
        _ closure: @autoclosure () -> @Sendable () -> Void) -> Void

and

    public func withObservationNotifications<T>(_ closure: () -> Void) -> Void

So it would be possible to write code like:

    addObservationTracking {
      // run code that potential can access multiple properties in multiple observed type instances
      // this closure will be executed and the tracked properties will be updated for each subsequent
      // run to a new set of tracked properties (as the code might conditionally touch different
      // properties on each run) . This is not one-shot as the proposed withObservationTracking is.
    } 

    withObservationNotifications {
      // update multiple different properties in possibly multiple observed type instances
      // which will execute any closure that is affected and added by addObservationTracking
      // just once, as time passes withObservationNotifications can be called many times and
      // cause an execution of a closure added multiple times
    } 

The semantics here are different in that addObservationTracking would actually execute the closure to get a baseline of observation - it would be expected that the closure would yield results on e.g. async streams so it doesn't directly return any result.

The closure is then called at later times whenever a property that it depends on is updated within an withObservationNotifications closure.

The main difference here is that instead of the current approach of declaring dependencies, and then later execute onChange, we'd like to unconditionally execute some code (and register its dependencies) and then be able to choose when to update multiple type instances/properties transactionally and just generate a single execution of that same code again (and then reset the dependencies to the new state).

This would allow for fundamentally automated dependency management and greatly simplify end user code for our libraries as we could hide huge amount of boilerplate - I'm sure it could be useful for others too.

Much of the work is done with this pitch, I'd just like to mention this for possible future directions, as I think observability is useful for much more than just SwiftUI integration.

I’m wondering if it would be possible for developers to easily write an ObservableObject whose objectWillChange publisher emits values when the Observation API tracks a change?

2 Likes

Adding @Observable results in a compilation error here:

@Observable
struct S {
    var value = 0
}

S(value: 42) // 🛑 Argument passed to call that takes no arguments

A reference? ObservationRegistrar is a value type and its fields are value types, AFAICT.

To clarify, It has reference semantics as it uses the _ManagedCriticalState type which stores the state of the type within a class instance.

Can it make into having a value semantics in turn? (similar to how Array has value semantics even if it references some class objects internally).

Otherwise there might be a problem making a type always returning true in its EQ and 0 in its hash. Example:

extension ObservationRegistrar: Hashable {
    public var hashValue: Int { 0 }
    public static func == (lhs: Self, rhs: Self) -> Bool { true }
}

@Observable struct Val: Hashable {
    var x = 1
}

func foo() {
    let a = Val()
    let b = Val()
    
    withObservationTracking {
        _ = a.x
    } onChange: {
        print("changed!")
    }
    
    var vals = Set<Val>()
    
    vals.insert(a)
    vals.insert(b)
//    vals.insert(b)
//    vals.insert(a)
    
    for var val in vals {
        val.x = 42
    }
    
    print("done")
}

This app will behave differently depending upon the order of inserting items in the set. This doesn't look right.

This hybrid solution seems to be working † indeed:

import SwiftUI
import Observation

@Observable class Model: ObservableObject {
    static let shared = Model()
    var relevant = 0
    var irrelevant = 0
    init() {
        Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
            self.relevant += 1
        }
        Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
            self.irrelevant += 1
        }
    }
}

struct ContentView: View {
    @ObservedObject private var model = Model.shared
    
    var body: some View {
        let _ = print("body called")
        withObservationTracking {
            VStack {
                Text(String(model.relevant)).font(.largeTitle)
            }
        } onChange: {
            DispatchQueue.main.async {
                model.objectWillChange.send()
            }
        }
    }
}

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

Could be another way how to approach back deployment.

† although to truly test it I need to somehow disable the new machinery in this line:

 @ObservedObject private var model = Model.shared
3 Likes

View should not be responsible to post VMs events. That’s violation of encapsulation and will lead to a huge mess if VM is shared between several views.

Better approach would be to override withMutation() in VM:

    internal func withMutation<Member, T>(
        keyPath: KeyPath<Model, Member>,
        _ mutation: () throws -> T
    ) rethrows -> T {
        self.objectWillChange.send()
        try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
    }
2 Likes

I think structs need to be addressed in the proposal, and the impact of the compatibility between @Observable and value types considered.

Philippe's excellent WWDC video uses class for things that in the past would be struct (like Donut), and I'd like to know if this is a change in guidance or just a choice that made sense for this app.

Is it still reasonable to use FileDocument, and a data model for an app that is based on a tree of value types?

Thanks

2 Likes

To be a bit clearer with my response: even though it is permitted to use a structure - it is a more advanced case and should be done with care since struct does NOT infer value semantics (it just commonly is associated with value semantics).

I have some time later today scheduled to draft up a bit more detail around that and modify the proposal to include the details in that regards. Once we have a more of a plan there I will make sure to post here as well to give a bit more background.

6 Likes

@Philippe_Hausler While i understand the desire to prevent users from expecting this to work with SwiftUI i have some usecases outside of SwiftUI and.

Was this the only reason or was there something else that makes this unworkable?

I assumed the observation machinery would be backwards deployable since its in its own module (package?)

Is there maybe some way the usage from SwiftUI can be prevented/discouraged?

Or would it maybe possible to adapt the macro to allow it to operate in a backwards compatible mode.
For instance by requiring opting into it @Observable(yesIKnowThisWontWorkInSwiftUI: true)