Skip to content

Instantly share code, notes, and snippets.

@BigZaphod
Created March 8, 2024 15:53
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save BigZaphod/874a9458c3fc5f546ea1e2b186118cc3 to your computer and use it in GitHub Desktop.
Save BigZaphod/874a9458c3fc5f546ea1e2b186118cc3 to your computer and use it in GitHub Desktop.
final actor Worker: ObservableObject {
@MainActor @Published private(set) var lastWorkDoneAt: Date?
private var counter = 0
func doWork() {
counter += 1
DispatchQueue.main.async {
self.lastWorkDoneAt = .now
}
}
func fetchWorkCounter() -> Int {
return counter
}
}
struct MyTestView: View {
@StateObject private var worker = Worker()
@State private var queuedWorkCount: Int = 0
@State private var fetchedWorkCount: Int = 0
var body: some View {
VStack {
Text("Queued Work Count: \(queuedWorkCount)")
Text("Fetched Work Count: \(fetchedWorkCount)")
Divider()
Button {
Task {
for i in 1...100 {
queuedWorkCount += 1
await worker.doWork()
}
}
} label: {
Text("Enqueue 100 Work")
}
}
.task(id: worker.lastWorkDoneAt) {
fetchedWorkCount = await worker.fetchWorkCounter()
}
}
}
@BigZaphod
Copy link
Author

This code doesn't quite do what I expected it to...

Tapping the "Enqueue 100 Work" button starts a loop that ultimately just increments a counter inside the Worker actor. Meanwhile, the task view is supposed to re-run whenever the worker's lastWorkDoneAt changes. lastWorkDoneAt is changed by the doWork() function via a block that runs on the main dispatch queue (because that property is isolated to the main actor so it can be observed by SwiftUI).

What ends up happening is that the queuedWorkCount and the fetchedWorkCount ultimately fall out of sync:

RPReplay_Final1709913285.mov

Based on some behavior I've observed in the past, I think SwiftUI will re-run the layout multiple times per update cycle as state changes but there's a limit (maybe around 5 or 10) after which point it gives up and displays what it's got. (This is likely to avoid infinite loop hangs and such.)

So my theory here is that the worker is changing lastWorkDoneAt multiple times within a single SwiftUI update cycle and it might re-run its view update and re-run the task that is fetching the work count a few times during the current update cycle, but eventually it gives up and renders the UI anyway with what are immediately out of date and incorrect values.

For the last update, when the work is just finishing up, this presents a major problem - the task runs and fetchedWorkCount is updated a few times during the update cycle and then SwiftUI gives up and decides to render the UI anyway, but in the meantime lastWorkDoneAt has changed a few more times. By the time SwiftUI is on the next runloop cycle and ready to potentially respond to new changes, the worker actor has finished and never changes lastWorkDoneAt again so SwiftUI doesn't notice any changes and doesn't update. And the UI is now out stuck of sync.

So my question here is... there has to be a better way to do something like this - to get a signal that's like "please refresh your views" back to SwiftUI and the main thread, but in a way that not only doesn't cause multiple updates per SwiftUI rendering cycle, but doesn't miss the final changes.

@kylebshr
Copy link

kylebshr commented Mar 8, 2024

Would an approach more like this work, where you publish the completed work and use that directly in the view?

final actor Worker: ObservableObject {
    @MainActor @Published private(set) var publishedCount: Int?

    @Published private(set) var counter = 0

    func doWork() async {
        counter += 1

        Task { @MainActor [counter] in
            self.publishedCount = counter
        }
    }

    func fetchWorkCounter() -> Int {
        return counter
    }
}
struct MyTestView: View {
    @StateObject private var worker = Worker()
    @State private var queuedWorkCount: Int = 0
    @State private var fetchedWorkCount: Int = 0
    
    var body: some View {
        VStack {
            Text("Queued Work Count: \(queuedWorkCount)")
            Text("Fetched Work Count: \(worker.publishedCount ?? 0)")
            Divider()
            Button {
                Task {
                    for i in 1...100 {
                        queuedWorkCount += 1
                        await worker.doWork()
                    }
                }
            } label: {
                Text("Enqueue 100 Work")
            }
        }
    }
}

@BigZaphod
Copy link
Author

That's not the same as what I am going for, I think. The real Worker actor is managing a database, so I was looking for a way to generate an event that basically just means, "some data has changed, you need to re-run your query and update your UI."

So somewhere else in the app, a record may be added to the worker actor with something like: await worker.save(record) - possibly even as a background process or something triggered by a timer somewhere. After the record is actually been saved, I wanted to trigger a signal so UI elsewhere in the app can re-query their view and see the changes. Of course it's not as perfectly efficient as noting exactly what changed and only updating exactly those objects and views or whatever, but I figured this would be close enough. I think the idea would work okay except I ran into this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment