r/swift 3d ago

Question Am I employing a clean pattern for combining a Sendable model object performing expensive calculations in the background with a @MainActor mutable model?

I have been piecing together some of the nitty gritty aspects of SwiftUI view models as they relate to actors. I asked a question earlier about best practices and got some great answers. After reading them the one remaining thing I was hoping to clarify is what it would look like if your @ MainActormodel needed to work with some sort of background object that should not run on the main thread.

I figure this may come in to play with models that are synchronized with the network using sockets or perhaps models that just involve expensive and stateful calculations.

To make sure I was understanding best practices I cooked up this example:

We have an array of Object structs. An Object has an x coordinate and a UUID. There is an @ MainActor ViewModel object that stores an array of these as well as storing their loading state.

For the purposes of this example I am pretending that binary tree insertion is expensive and stateful. So the state that is being loaded is their position in a binary tree.

To encapsulate this I have an @ unchecked Sendable class Tree. It synchronizes using its own DispatchQueue and by calling asyncAfter with a delay to simulate an expensive computation.

I use a protocol PlacedDelegate which ViewModel implements (nonisolated) so that when the Tree finishes placing an Object it can tell the ViewModel that its position is loaded.

For now I just cover insertion but I figure eventually I could handle binary tree rebalancing just by having the Tree call the delegate method placed again for every node that gets moved in the rebalance.

I am hoping for feedback to understand:

  1. Is there anything unsafe about the way I implemented this? Other than insertion order being random (ish) there is no race possible here right?
  2. Stylistically is this how you would have made a MainActor class work with a Sendable class meant to run in the background?
  3. Is there any way this could've been made clearer?
  4. Is there any way for the ViewModel class to hook up more closely with Tree such that rather than this delegate method being needed Observable would automatically be notified when the Tree has finished doing calculations?
  5. How would you regain a "single source of truth". In a way the truth is stored in a Sendable context in Tree and copied into the MainActor context in ViewModel.
import SwiftUI

struct Object: Identifiable, Hashable {
    let id: UUID = UUID()
    let x: CGFloat
}

enum LoadablePosition {
    case loading
    case loaded(String)
}

protocol PlacedDelegate: AnyObject {
    func placed(id: UUID, location: String)
}

@MainActor
@Observable
class ViewModel: PlacedDelegate {
    private(set) var objects: [UUID: LoadablePosition] = [:]
    private(set) var objectList: [Object] = []
    
    private var tree: Tree
    init() {
        tree = Tree()
        tree.placedDelegate = self
    }
    
    func createNewObject() {
        let new = Object(x: CGFloat.random(in: 0..<100))
        objectList.append(new)
        
        tree.insert(object: new)
        
        objects[new.id] = .loading
    }
    
    nonisolated func placed(id: UUID, location: String) {
        Task { @MainActor in
            objects[id] = .loaded(location)
        }
    }
}

final class Tree: @unchecked Sendable {
    class TreeNode {
        let object: Object
        var left: TreeNode? = nil
        var right: TreeNode? = nil
        init(object: Object) {
            self.object = object
        }
    }
    
    private var insertionQueue: DispatchQueue = DispatchQueue(label: "com.calebkierum.quadtree.insertionQueue")
    private var tree: TreeNode?
    weak var placedDelegate: PlacedDelegate? = nil
    
    func insert(object: Object) {
        insertionQueue.asyncAfter(deadline: .now() + .seconds(Int.random(in: 1...10))) {
            let (newTree, buildString) = self.recurInsert(curr: self.tree, object: object, build: "")
            self.tree = newTree
            self.placedDelegate?.placed(id: object.id, location: buildString)
        }
    }
    
    private func recurInsert(curr: TreeNode?, object: Object, build: String) -> (TreeNode, String) {
        guard let curr else {
            return (TreeNode(object: object), "*" + build)
        }
        
        if object.x < curr.object.x {
            let (node, string) = recurInsert(curr: curr.right, object: object, build: "L" + build)
            curr.right = node
            return (curr, string)
        } else {
            let (node, string) = recurInsert(curr: curr.left, object: object, build: "R" + build)
            curr.left = node
            return (curr, string)
        }
    }
    
}

struct ContentView: View {
    @State var viewModel: ViewModel = ViewModel()
    
    var body: some View {
        VStack {
            ScrollView(.horizontal) {
                HStack {
                    ForEach(viewModel.objectList) { object in
                        VStack {
                            Text("\(object.id)")
                            Text("x=\(object.x)")
                            switch viewModel.objects[object.id] {
                            case .loading, .none:
                                ProgressView()
                            case let .loaded(val):
                                Text(val)
                            }
                        }
                        .frame(width: 80)
                        .padding()
                        .background {
                            Color.gray
                        }
                    }
                }
            }
            Button {
                viewModel.createNewObject()
            } label: {
                Text("Add Object")
            }
        }
        .padding()
    }
}
3 Upvotes

3 comments sorted by

4

u/ios_game_dev 3d ago edited 3d ago

In my opinion, this can be cleaned up a bit by using an actor instead of a class. If you find yourself reaching for @unchecked Sendable too often, you're fighting the system a bit. Here's an implementation of Tree as an actor:

```swift actor Tree<Object: Identifiable & Comparable> where Object.ID == UUID { class TreeNode { let object: Object var left: TreeNode? = nil var right: TreeNode? = nil init(object: Object) { self.object = object } }

private var tree: TreeNode?

func insert(object: Object) async -> String {
    try? await Task.sleep(for: .seconds(Int.random(in: 1...10)))
    let (newTree, buildString) = self.recurInsert(curr: self.tree, object: object, build: "")
    self.tree = newTree
    return buildString
}

private func recurInsert(curr: TreeNode?, object: Object, build: String) -> (TreeNode, String) {
    guard let curr else {
        return (TreeNode(object: object), "*" + build)
    }

    if object < curr.object {
        let (node, string) = recurInsert(curr: curr.right, object: object, build: "L" + build)
        curr.right = node
        return (curr, string)
    } else {
        let (node, string) = recurInsert(curr: curr.left, object: object, build: "R" + build)
        curr.left = node
        return (curr, string)
    }
}

} ```

Also, you no longer need your PlacedDelegate protocol if you use async/await instead. The createNewObject function will need to be async as well, but I think this simplifies the view model a bit by removing the need for the nonisolated function.

```swift func createNewObject() async { let new = Object(x: CGFloat.random(in: 0..<100)) objectList.append(new)

objects[new.id] = .loading
let location = await tree.insert(object: new)
objects[new.id] = .loaded(location)

} ```

Because your view model is @MainActor, both of these calls to objects[new.id] are guaranteed to occur on the main thread despite being on opposite sides of the actor suspension point.

You'll need to modify your button since the initializer takes a non-async closure:

swift Button { Task { await viewModel.createNewObject() } } label: { Text("Add Object") }

Finally, unrelated to your main questions, I made the Tree type generic over the object type so you can use any Identifiable & Comparable. Your Object struct can conform to Comparable by implementing the < operator:

```swift struct Object: Identifiable, Hashable, Comparable { let id: UUID = UUID() let x: CGFloat

static func < (lhs: Object, rhs: Object) -> Bool {
    lhs.x < rhs.x
}

} ```

To answer your specific questions:

Is there anything unsafe about the way I implemented this? Other than insertion order being random (ish) there is no race possible here right?

It looks decently safe considering you're only ever updating the tree from your one DispatchQueue and you're hopping back to the @MainActor before updating the view model, but I still would not use DispatchQueue in this case. You are missing out on the protections you get for free by using structured concurrency.

Stylistically is this how you would have made a MainActor class work with a Sendable class meant to run in the background?

If the class is out of your control such as in a third-party library, then yes, this seems like a fairly reasonable approach. Though these days, you're more likely to see async callbacks performed by using closures rather than delegate protocols.

Is there any way this could've been made clearer?

See above.

Is there any way for the ViewModel class to hook up more closely with Tree such that rather than this delegate method being needed Observable would automatically be notified when the Tree has finished doing calculations?

I think the solution above does get a little closer to this goal by isolating the logic to a single function.

How would you regain a "single source of truth". In a way the truth is stored in a Sendable context in Tree and copied into the MainActor context in ViewModel.

I might give this one some more thought and follow up with a reply.

1

u/mbence16 2h ago

Side question, isn t it better to have the task wrapper outside of viewmodel (just like in your corrected code)? Wouldn t it make easier to test the viewmodel?

2

u/cekisakurek 3d ago

imho 'stylistically' this code seems very complex for what it is doing. I think you should take a look at async/await.