How should I get asynchronously computed state to show up in SwiftUI

For context, I've got a macOS desktop app that runs a server. The app has a SwiftUI View that controls starting and stopping the server, editing its configuration, and (I wish) reporting on the server's state (number of connections, total messages processed, etc.).

Now, the server sends all the actual work off to background tasks so that it doesn't block the UI nor client requests. Its state is held in an actor, to make sure that all the state updates and reads happen in a nice orderly fashion. However. Reading any property from the state actor winds up being an async operation.

So, how should I get the async properties into my View? I'm currently sort of flailing around, making more and more elaborate chains of structs and notifications, but that all seems like way more code than one would expect, which makes me think I'm going in the wrong direction.

Could you provide a simplified example of the kind of interaction that's tripping you up?

I'll try to pare it down to bare bones, here.


struct ServerView: View {
    @ObservedObject var theState: ServerState
    @State var tupleCount = 0

...

  var body: some View {
    Text("The server has processed \(tupleCount) tuples")
  }
}

actor ServerState: ObservableObject {
  private var tuplesProcessed: Int

  public func getTupleCount() async -> Int {
    return tuplesProcessed
  }

  public func didSomeWork() async {
    tuplesProcessed += 1
    DispatchQueue.main.async {
      self.objectWillChange.send()
    }
  }
}

What I'd really like is to have a closure somewhere that gets the objectWillChange notification from the ServerState actor and then fetches the updated tupleCount from the actor and puts it into the tupleCount state variable on the view. I'm just not sure where that closure should live, nor how to wire it up.

OMG, and as soon as I hit reply, I had an idea and it worked. So, thanks for making me explain it!

In case anyone else has this question, here's my solution, for the above pared-down code:

...
  var body: some View {
    Text("The server has processed \(tupleCount) tuples")
      .onReceive(theState.objectWillChange) {
        Task {
          tupleCount = await theState.getTupleCount()
        }
      }
  }
3 Likes

It's called rubber duckie debugging. Rubber duck debugging - Wikipedia

1 Like

I'm afraid it's generally a bad idea to use an actor as your SwiftUI view's observable object – @ObservedObjects are supposed to always run on the main thread. I'd advise you use:

@MainActor
class ViewModel: ObservableObject { /* ... */ }

And then factor out any of your "server" state and logic to an actor which you store in your view model, so that you can more cleanly separate your logic and empirically know when there's going to be a thread hop. Right now, with the solution you found, you're creating and destroying a subscription to your actor's objectWillChange publisher every time you refresh your view – which definitely isn't free.

1 Like

That feels like a problematic area of SwiftUI's design. Is objectWillChange created anew each access? Or is onReceive for some reason creating and destroying its subscription each time the view is rendered?

Thanks for the feedback, although I think that's not really the way I should go. It's kind of a bad idea to make the client/server connections synchronize/lock on the UI. That, in my experience, is even more of a bad idea.

In fact, the way that I'm implementing the listener, neither the ObservedObject nor the listener updates the UI -- that happens inside the Task and that, combined with the @State property wrapper, lets the compiler handle hopping back onto the main thread.

All of this is expensive, sure, but it's expensive in a way that the client will not see, and that's what I care about in a server application.

That isn't really that expensive; the ObservableObject implementation caches the objectWillChange construction in its accessible (per object) storage. This means that it is pretty darned fast to re-build that back up anyhow.

Making an ObservableObject an actor is problematic not just for the affinity of access but also for that storage - so much the case that I would state that no ObservableObject should ever be an actor. (marking it as @MainActor is fine however, and likely expected when using that with SwiftUI).

Thankfully @Published is thread safe so that can be accessed by multiple tasks just fine, but be warned SwiftUI does not like that being modified on non main actor contexts.

1 Like

Would you please elaborate on the problem of storage? I'm not sure I understand your point, and I really don't want to be making something horrible.

The root cause is that ObservableObject will reach into the @Published properties to store the objectWillChange publisher. Doing so on an actor will effectively permit non actor isolated access to actor protected memory. The practical side to that is that means that the access no longer has any actor based isolation (but is expecting it) which means you may easily be setting up for a race (likely creating a heap corruptor).

3 Likes