[Pitch] Observation (Revised)

@Observable won't magically make things observable that were not able to be observed in the first place. Particularly in the case of UIKit the tool for that type of observation (if available is KVO) since that interoperates with objc.

The requirements for the @Observable macro are that the type must be compiled in swift and must have the application of the macro (or call out to the registrar manually). Those restrictions are a direct consequence of the primary goal for ensuring visibility is enforced; i.e. private values stay private.

For this reason it is not allowed to apply the macro to extensions of types.

3 Likes

I'm not sure if this is a good place to discuss the excellent preview of Observation we get in Xcode 15 beta 1. Please let me know if another place is better for this.

My question is regarding name clashes between Observable (not the macro, but the protocol) from Observation and Observable from RxSwift.

A part of the macro expansion of:

@Observable class Test {
}

generates:

extension Test : Observable  {}

and when importing RxSwift, this reference to Observable is ambiguous.

Could the macro expand this to fully qualified types instead - like:

extension Test : Observation.Observable  {}

If this is possible, it would seem like the safer bet in most cases.

11 Likes

That seems like an excellent suggestion for all macros.

3 Likes

I have a PR that addresses precisely this up - [Observation] Refinements for init, peer macros, and qualified type emissions by phausler · Pull Request #66288 · apple/swift · GitHub

So I wholeheartedly agree that it is probably good practice to always use fully qualified type emission for macros.

6 Likes

Perfect! Thanks for the update! :heart:

1 Like

@Observable currently fails when the class has lazy properties.
This seems to be because @ObservationTracked turns it into a computed property, but the lazy attribute is still preserved.

Since lazy computed properties are invalid, this results in the error:

'lazy' cannot be used on a computed property

This feels like an oversight to me. @ObservationTracked should be able to remove lazy from the computed property, while preserving it for the @ObservationUntracked backing copy.

I manually recreated this special handling of lazy and it appears to work as expected:

1 Like

@hborla might have better insight on this. But the long/short is that lazy cannot be removed by macros.

4 Likes

I’ve already asked this in the review thread, but didn’t get any answers, so let me ask it again here.

Why onChange is Sendable?

If we can access stored properly inside the apply , that means that apply and entire call to withTracking() is happening inside the corresponding isolation context. When properly is mutated and onChange is called, we are in the same isolation context. So calls to the withTracking() and onChange() happen within the same isolation context. So looks like onChange is not required to be Sendable.

Properties can change on any task/actor - the onChange must be able to be run in that actor context so the closure then must be Sendable.

1 Like

Can they change on a task/actor different from the call to withTracking()? Won’t it be a data race?

1 Like

yes they could; but it is the responsibility of those types to provide thread safety for those changes.
The observation machinery itself is thread safe but the types that store data must bring their own synchronization systems.

With changes(for:) and values(for:) no longer in the proposal, how are we to observe an @Observable from something that is not a SwiftUI view - something that may itself be Observable.

My use case may not be common, but when using Combine, I often broke the pipelines into smaller pieces and various structs would subscribe to upstream publishers and modify or join them before republishing them. In this way we could create small pieces that did one thing that could be appropriately named and flexibly assembled. I was thinking we could do the same with @Observables upstream that we might assemble with things from async algorithms etc.

Then again, I may be trying to hold it wrong.

Thanks,

Daniel

1 Like

Is there replacement for @AppStorage?

1 Like

Yup. This is a pattern I would use frequently, too. Especially important across module boundaries or for limiting view refreshes.

The two recommendations are to a) use computed properties, or b) use this pattern:

However, method A suffers from unnecessary view refreshes, while method B suffers from the async hop (which is one of the reasons async sequences were dropped) which will cause broken view invariants/ non synchronised view updates. I wrote a bit more about that here.

Yeah I am using a but there are times when I’d prefer b but as you note it’s always one step behind - like using observableobjects with uikit instead of SwiftUI.

I’ve also experimented with filling an async stream from b but again it is always one element behind

Unfortunately, this is a limitation of using asynchronous sequences and I'm not sure if there's any elegant way around it. I tried to go into it a bit in the other thread.

To make this work how we want it, we need Observable to generate trailing edge (didSet) synchronous notifications.

SwiftUI works by creating an animation transaction which includes a before changes snapshot and an after changes snapshot. And critically it all needs to happen in the same event loop cycle – so no actor hops or async calls.

As it stands, SwiftUI can create its 'before changes' snapshot when it's triggered by the first leading edge willSet notification of the event loop.

However, in order for dependent observers to make any changes they need to make, we need trailing edge didSet observations to occur before the end of the current event loop cycle. Trailing edge, because we need to know what the changes are in order that we can respond to them appropriately.

Finally, once the event loop cycle has concluded, SwiftUI can generate its 'after changes' snapshot and everything works as expected.

I don't really see any other way it could work.

6 Likes

I've missed the wagon on this, but seems like the Observable macro is conflicting with RxSwift's almost 6-year old Observable type (which is a common industry standard under Reactive Extensions).

Seems to me this could have been taken into consideration, knowing this will cause this issue:

import SwiftUI
import RxSwift

struct ContentView: View {
    var body: some View {
        Color.black
    }

    func observable() -> Observable<Int> { // Protocol 'Observable' does not have primary associated types that can be constrained
        .just(1)
    }
}

(Name Conflict from Xcode 15 · Issue #2532 · ReactiveX/RxSwift · GitHub)

I'm not sure what is a reasonable solution here aside for asking developers to set-up a manual typealias on each module they own. There are tens of thousands of apps using RxSwift and this basically makes all of them non-compilable under Xcode 15 beta 2.

The issue is with the new re-exporting of Observation, which wasn't an issue in beta 1.

I'd appreciate your guidance, and if you prefer this to be a new thread, I'm happy to do so.

1 Like

It was a consideration and addressed by [Observation] Ensure type access is qualified to the module name by phausler · Pull Request #66367 · apple/swift · GitHub which hopefully should address that concern.

That's great. So I'm guessing this will be a beta 3 thing that just didn't make the cut to beta 2 ?
Is there any way to test this ahead to confirm it will work?

We're actually part of the Swift Compatibility Suite, so thought this change would run against it:

Thank you !

The source compat suite cannot flag this, because you don’t import the new module. Both have to be imported for there to be an issue.

1 Like