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
?
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)
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.
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 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
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.
}
}
Partly unrelated to the actual review. The async
part was removed from this proposal and there's currently no support for actor
s 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.
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.
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
.
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.