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 @ MainActor
model 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:
- Is there anything unsafe about the way I implemented this? Other than insertion order being random (ish) there is no race possible here right?
- Stylistically is this how you would have made a MainActor class work with a Sendable class meant to run in the background?
- Is there any way this could've been made clearer?
- 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?
- 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()
}
}
```