Update Persisted Model in Background Thread

219 Views Asked by At

Every time the user needs to compute the data of a persisted item, task that is expensive, I show a computing text while in background thread I compute the data and when is ready I show it to the user.

The app works as expected but

  1. I am not really sure 100% that my approach is the most efficient
  2. one warning appears:

Capture of 'self' with non-sendable type 'ItemEditView' in a @Sendable closure. Consider making struct 'ItemEditView' conform to the 'Sendable' protocol

struct ItemEditView: View {
    @Environment(\.modelContext) private var modelContext
    let item: Item
    @State private var text = "Computing..."
    
    var body: some View {
        HStack{
            Text(text)
        }
        .task{
            Task.detached{
                do {
                    let dataManager = DataManager(modelContainer: modelContext.container) //warning here!
                    try await dataManager.computeData(for: item.id)
                    text = String(item.data!)
                    // Not sure if saving the main context is also needed
                    try modelContext.save()
                } catch {
                    print(error.localizedDescription)
                }
            }
        }
    }
}
@ModelActor
actor DataManager{
    
    func computeData(for itemID: PersistentIdentifier) async throws {
        let item = modelContext.model(for: itemID) as! Item
        item.data = expensiveWork()
        try modelContext.save()
    }
}
@Model
final class Item {
    var name: String
    var data: Double?
    
    init(name: String) {
        self.name = name
    }
}
struct ContentView: View {
    @Query private var items: [Item]
    @Environment(\.modelContext) private var modelContext

    var body: some View {
        NavigationView {
            List {
                ForEach(items) { item in
                    VStack(alignment: .leading){
                        Text("Name: \(item.name)")
                        if let data = item.data{
                            Text("Data: \(data)")
                        }
                        NavigationLink("Compute Data"){
                            ItemEditView(item: item)
                        }
                    }
                }
                .onDelete(perform: deleteItems)
            }
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    EditButton()
                }
                ToolbarItem {
                    Button(action: addItem) {
                        Label("Add Item", systemImage: "plus")
                    }
                }
            }
        }
    }

    private func addItem() {
        withAnimation {
            let newItem = Item(name: "Name\(items.count)")
            modelContext.insert(newItem)
        }
    }

    private func deleteItems(offsets: IndexSet) {
        withAnimation {
            for index in offsets {
                modelContext.delete(items[index])
            }
        }
    }
}

Edit

extension DataManager {
    private func expensiveWork() -> Double {
        for i in 0..<100_000 {
            print("\(i)")
        }
        return .random(in: 0...100)
    }
}
1

There are 1 best solutions below

1
On BEST ANSWER

To use a detached Task without getting any warnings about Sendable from the compiler we need to declare any local variables before the Task closure rather than inside, that way we won't capture self.

So we move the declaration of dataManager and we also need to declare a variable for model id or we get the same warning

.task {
    let dataManager = DataManager(modelContainer: modelContext.container)
    let id = model.persistentModelID
    //...
}

And for the same reason we can't update text inside the closure but we can fix that by running the task using await and assign text afterwards

.task {
    let dataManager = DataManager(modelContainer: modelContext.container)
    let id = model.persistentModelID
    let _ = await Task.detached {
        do {
            try await dataManager.computeData(for: id)
        } catch {
            print(error.localizedDescription)
        }
    }.result

    text = String(model.doubleValue)
}

(The code is from my test project so some properties and functions aren't named exactly the same but the logic and flow should match.)

If you want to run this every time a new object is shown in the view I would recommend using task(id:) instead of task

.task(id: model.id) { ... }

I am not convinced you need to use Task.detached at all so here is my version without it

.task(id: model.id) {
    let dataManager = DataManager(modelContainer: modelContext.container)
    do {
        try await dataManager.computeData(for: model.id)
        text = String(model.doubleValue)
    } catch {
        print(error.localizedDescription)
    }
}

And I simulated a long operation by using try await Task.sleep(for: .seconds(2)) rather than a for loop.