I'm not sure whether your example code should be taken verbatim or as a rough approximation (hey, it's a forum post), but for the sake of posterity and any little birdies observing our thread here, I'll comment on it as-written before following up with edits to it and how that behaves.
The task modifier you suggest above:
.task {
while true {
model.score = 0
model.score = 1
try? await Task.sleep(for: .milliseconds(100))
model.score = 2
}
}
In practice that that task body is isolated to the main actor. I can't tell why, I just know that it is. Even on iOS 18 minimum APIs the public declaration doesn't appear to capture the caller's isolation:
@inlinable nonisolated public func task(
priority: TaskPriority = .userInitiated,
_ action: @escaping @Sendable () async -> Void
) -> some View
But in practice it seems to be isolated to the main actor. So as-written its not going to produce examples of off-main mutations, but that's simple enough to fix:
.task {
await Task.detached {
while true {
assert(Thread.isMainThread == false)
await model.score = 0
await model.score = 1
try? await Task.sleep(for: .milliseconds(100))
await model.score = 2
}
}.value
}
That does what we want: makes a hellacious number of mutations of the Model
's property outside the isolation of the main actor. Running the code that way, the consuming SwiftUI now looks like this:
struct ContentView: View {
@State var model = Model()
var body: some View {
Text("\(model.score)")
.foregroundStyle(color)
.task {
await Task.detached {
while true {
assert(Thread.isMainThread == false)
await model.score = 0
await model.score = 1
try? await Task.sleep(for: .milliseconds(100))
await model.score = 2
}
}.value
}
}
var color: Color {
switch model.score {
case 0: .red
case 1: .green
case 2: .blue
default: .black
}
}
}
Running that code on actual hardware (iPhone 16 Pro Max FWIW), what I observe is this: the text appears to flicker between 0 and 1, but lingers far longer on 1 than on 0. I think this behavior is what I expect. SwiftUI never promises to consume all incremental changes to an observed property. It just flips a dirty bit indicating that an update pass is required. If, after that dirty bit is flipped but before the next update pass, the observed state changes further, it won't trigger an additional update. When the pending update flows through, the view will pick up whatever the value happens to be at the moment the property is read.
The order of events looks like this:
Clock |
Main Actor |
Global Executor |
1 |
|
count set to 0 |
2 |
view needs update |
|
3 |
view updated "0" |
|
4 |
|
count set to 1 |
5 |
view needs update |
sleeping |
6 |
view updated "1" |
sleeping |
7 |
|
sleeping |
8 |
|
count set to 2 |
9 |
view needs update |
|
10 |
|
count set to 0 |
11 |
view needs update |
|
12 |
view updated "0" |
|
... |
... |
... |
And the loop continues. Note that the view never updates to display "2" because there's never enough time for an update pass between "2" and the next "0". Now, why we ever see the "0" at all is a mystery to me. I suppose it's just a hardware-sensitive race condition between the quality of service of that detached task and of the SwiftUI engine.
The Important Part
Now, across all my experiments, I have never once seen a message in the console about off-main mutation, or a warning from the compiler about binding view properties to mutable properties not isolated to the main actor.
This is different than with @Published
properties. Well, technically @Published
does not care about the distinction between main and non-main mutation. What's discouraged is driving a view update in response to a @Published
property of an ObservableObject
being mutated outside the main thread. That combination of things specifically will cause runtime console messages. There's nothing about @Published alone that will issue warnings or wrist-slapping console messages.
Whether the lack of such discouragement from @Observable
is an oversight, or a deliberate design goal, I can't tell. I strongly suspect it is a deliberate design goal.
It is incredibly common for developers to deliver updates to view-observed @Published properties outside the main actor. Even Apple's own sample code does it. Perhaps Apple grew tired of dealing with the repercussions of those design decisions and has decided instead to just allow mutations to happen anywhere, and to bake synchronization into the SwiftUI internals so that there's no longer any reason to fret about where mutations are happening.