What would be the go-to pattern consuming updates from Actors from MainActor?

Imagine I have this Model type of class running on a Custom Global Actor:

@GlobalActor
@Observable
class Model {
    let channel = AsyncChannel<Int>()
    private(set) var count = 0

    func click() async {
        count += 1

        await channel.send(count)
    }
}

The examples I'm giving are extremely simplified and could easily be handled without any actor, they're just here to give a minimum problem reproduction.

I would like to consume its updates from a ViewModel running on @MainActor.

I imagine @Observable would be out of the question? At least: I couldn't wrap my head around how to do this between actors.

So I implemented an AsyncChannel and that works really well :+1:

I imagine it could even send Model as it's data type and just publish "there's an update on me" if there would be many fields on this Global Actor-bound class.

The doubts I have started to arise when I tried to have a ViewModel to consume it's updates and transform them to @Observable fields for my View:

@MainActor
struct ContentView: View {
    @State var viewModel: ViewModel = ViewModel()

    var body: some View {
        VStack {
            Text("Clicked: \(viewModel.clicked)")
            Button {
                Task {
                    await viewModel.click()
                }
            } label: {
                Text("Click Me")
            }
        }
        .padding()
    }
}

Since the ViewModel needs to be initialized in a synchronous @MainActor context I cannot make it's constructor async nor can I inject the Model easily as it lives in a different context:

@MainActor
@Observable
class ViewModel {
    private(set) var clicked: Int

    private var model: Model! // Brittle

    init() {
        clicked = 0 // Not entirely true
        Task {
            model = await Container.model // Brittle
            clicked = await model.count

            Task { @MainActor in
                await self.observe(model: model)
            }
        }
    }

    func click() async {
        await model.click()
    }

    private func observe(model: Model) async {
        for await count in model.channel {
            self.clicked = count
        }
    }
}

I don't like the code in this ViewModel

Since the initializer cannot be async but the Model is a hard requirement for this class, I'm now writing code that feels overly brittle. Wouldn't even pass my linter because of the forced unwrap, and making it optional would be a lie as well since it's absolutely elementary to make this class work.

  • What would be the go-to pattern for observability between actors?
  • Is there any way to make it work more elegant and safe?

If your model as simple as in this example in overall — it doesn’t involve heavy operations — it will make more sense to have it isolated on main actor as well. But I suppose that is not the case, so I would consider two options:

  1. Decouple loading from initialization. If there are heavy operations, it makes sense to introduce additional step of loading, that will be performed once view appeared. It’s the most agile option as for me.
  2. Decouple initialization from view, and make init async. In that way your view will receive already created view model with async operations already done. As downside of this, you have to manage this loading on the view creation side, which might be also inconvenient, yet provides more possibilities.
1 Like

Thanks!

So there would not be a lot of heavy work done in the Actor when it's created, but the amount of updates would still overload the main thread and impact performance. Imagine it juggling a history of clipboard items (like copied images) and the UI only wants to show how many items we have.

  1. For example a State enum pattern where the ViewModel gets set in a second case after loading? This would be the case for every ViewModel that would observe this Model though
  2. Thought of it, but how to inject this ViewModel in the View that needs it in an elegant way?

Well, if the actual loading is as simple as getting a counter, we can use that fact to provide extremely simple load method in ViewModel:

func load() async {
    clicked = await model.count
}

Since it won't be heavy, there is no need to worry about state and usage by several views.

Also, a ViewModel in SwiftUI is kinda redundant, SwiftUI views are already can be treated as view models, so I would eliminate it at all and moved its logic to the view itself:

@MainActor
struct ContentView: View {
    // I haven't worked with new Observable macro,
    // so I'm using observed object property wrapper
    @ObservedObject
    private var model: Model

    @State 
    private var clicksCount = 0

    var body: some View {
        VStack {
            Text("Clicked: \(clicksCount)")
            Button {
                Task {
                    await model.click()
                }
            } label: {
                Text("Click Me")
            }
        }
        .padding()
        .task {
            clicksCount = await model.count
            for await count in model.channel {
                clicksCount = count
            }
        }
    }
}

I prefer to use a coordinator pattern, where all the routing is incapsulated with coordinator object. NavigationStack available from iOS 16 can be considered as one, but in large apps with complex routing I have found it hard to use. Anyway, the pattern itself decouples initialisation from view, so it becomes something like that roughly:

@MainActor
struct OriginView: View {
    var body: some View {
        Button(
            action: {
                Task { await onClipboardHistory() }
            }, 
            label: { Text("Clipboard History") }
        )
        ProgressView()
            .progressViewStyle(.circular)
            .hidden(!showActivityIndicator)
    }
    
    private func onClipboardHistory() async {
        // this could be also delayed for better UX 
        // if opening doesn't take a lot of time
        showActivityIndicator = true
        await openClipboard()
        showActivityIndicator = false
    }
    
    @State
    var showActivityIndicator = false
    let openClipboard: () async -> Void
}

final class ViewsCoordinator {
    func openOrigin() {
        let view = OriginView(openClipboard: { [weak self] in 
            await self?.openClipboard() 
        })
        navigationController.viewControllers = [UIHostingController(rootView: view)]
    }
    
    func openClipboard() async {
        let viewModel = await ViewModel(model: Model())
        let view = ClipboardView(viewModel: viewModel)
        navigationController.pushViewController(UIHostingController(rootView: view), animated: true)
    }
}

In more simple apps (straight navigation logic, few screens) it would be easier of course to remove coordinator and perform this inside a view, to not introduce extra abstraction level in form of a coordinator.

EDIT: StateObject was incorrect, replaced with ObservedObject, since model will be passed from the outside.

1 Like

Thank you for being my second brain! I ended up doing the following:

@main
struct SynchronizingActorsApp: App {
    enum AppState {
        case loading
        case loaded(model: Model)
    }

    @State private var state: AppState = .loading

    var body: some Scene {
        WindowGroup {
            switch state {
            case .loading:
                ProgressView()
                    .task {
                        state = await .loaded(model: Model())
                    }
            case let .loaded(model):
                ModelView()
                    .environment(model)
            }
        }
    }
}

I think it's OK to think of the @GlobalActor bound Model as something like an external service that needs to be loaded asynchronously. Actually, in most real-world applications there would be a state-restoration phase.

The model can also be injected in the environment this way, which is great:

@MainActor
struct ModelView: View {
    @Environment(Model.self) var model: Model
    @State private var clicksCount = 0

    var body: some View {
        VStack {
            Text("Clicked: \(clicksCount)")
            Button {
                Task {
                    await model.click()
                }
            } label: {
                Text("Click Me")
            }
        }
        .padding()
        .task {
            clicksCount = await model.count
            for await count in model.channel {
                clicksCount = count
            }
        }
    }
}

Though I'm effectively not using the @Observable macro, it does allow me to inject the Model later on to ensure generational safety.

I'll play a bit more with different scenarios here, especially converting larger amounts of changes and seeing how ViewModel scenarios can play out. I still like the idea of hiding all of the observation and conversion logic from the View through a ViewModel, without disagreeing at all about your statement that Model-View seems to be becoming the most sensible pattern in SwiftUI.

But it looks elegant, safe and readable this way, which is what I was looking for :+1:

2 Likes

Yes, looks great! With state on parent view code is much cleaner.

As a side note, I find using @Environment to pass dependencies harmful for the project in long term. First time when SwiftUI has become more usable than not, I thought this property wrapper would be a huge game-changer for dependency management and I've adopted it on exploration project. Soon enough I have found it wasn't designed for this, and actually made views implicitly dependent from many things, along with huge complications on previews creation, which I consider one of the strongest side of SwiftUI. So far I prefer to pass dependencies explicitly on initialisation, and environment either not use at all or leave it to actual view environment, like styling data.

1 Like

I'm still on the fence about this, there are some really obvious downsides to keyless Environment and EnvironmentObject:

  • Only works through the View hierarchy, not so great for more service driven applications with a thin UI
  • Only work with concrete implementation type, not on protocols, so hard to test
  • When not set, they crash or you leave them optional, which could lead to silent failure

Previews creation is indeed a headache with environment, but it's also really hard to get it right with Actors as #Preview does not allow await.

#Preview {
    struct SynchronizingActorsPreview: View {
        enum AppState {
            case loading
            case loaded(model: Model)
        }

        @State private var state: AppState = .loading

        var body: some View {
            switch state {
            case .loading:
                ProgressView()
                    .task {
                        state = await .loaded(model: Model())
                    }
            case let .loaded(model):
                ModelView()
                    .environment(model)
            }
        }
    }

    return SynchronizingActorsPreview()
        .frame(minWidth: 100, minHeight: 100)
}

In this case, I would got for the most "vanilla" approach possible as it's just demo code. People might be distracted by stuff that looks different, that's why it also was a good idea to get rid of the ViewModel.

And for anyone interested, the ViewModel version looks like this:

@MainActor
@Observable
class ViewModel {
    private(set) var clicked: Int

    private let model: Model

    init(model: Model) async {
        self.model = model
        clicked = await model.count

        Task {
            await self.observe(model: model)
        }
    }

    func click() async {
        await model.click()
    }

    private func observe(model: Model) async {
        for await model in model.channel {
            self.clicked = await model.count
        }
    }
}

Already a lot cleaner than what I started with, and it's also safer.

The View cleaned up quite a bit as well:

@MainActor
struct ContentView: View {
    @Environment(ViewModel.self) var viewModel: ViewModel

    var body: some View {
        VStack {
            Text("Clicked: \(viewModel.clicked)")
            Button {
                Task {
                    await viewModel.click()
                }
            } label: {
                Text("Click Me")
            }
        }
        .padding()
    }
}

Finally the main app:

@main
struct SynchronizingActorsApp: App {
    enum AppState {
        case loading
        case loaded(viewModel: ViewModel)
    }

    @State private var state: AppState = .loading

    var body: some Scene {
        WindowGroup {
            switch state {
            case .loading:
                ProgressView()
                    .task {
                        state = await .loaded(viewModel: ViewModel(model: Model()))
                    }
            case let .loaded(viewModel):
                ContentView()
                    .environment(viewModel)
            }
        }
    }
}

This is basically the same as the version without the ViewModel. Slightly bothered by SynchronizingActorsApp knowing so much about the ViewModel of one of it's subviews but it's minor.

2 Likes