Propagate a @Published value from a Core Data ManagedContext

I have some code that has a long-running task inside of a .perform{} block. I'd like to be able to show the status of this long-running code in a Swift UI view by passing data through a @Published var. But .perform{} seems to prevent @Published from actually propagating to the view.

Is there a way to get the view to update in the middle of this perform{} block?

I've managed to boil my problem down to a playground that illustrates the issue . See the ????? comment.

import SwiftUI
import PlaygroundSupport
import CoreData

struct ContentView: View {
    @StateObject var contentModel = ContentModel()
    
    var body: some View {
        VStack {
            HStack {
                Text("Status:")
                Text(contentModel.status)
            }
            Spacer()
            Button("Do stuff.") {
                contentModel.updateStatus()
            }
        }
        Spacer()
    }
}

class ContentModel: ObservableObject {
    @Published var status = "foo"
    
    func updateStatus() {
        let managedObjectContext = PersistenceController.preview.container.viewContext
        
        // this perform is needed in the Real App™ to manage data fetch + output
        managedObjectContext.perform {
            
            // it doesn't matter that this is async
            DispatchQueue.main.async {
                let oldStatus = self.status
                
                // this status does not propagate into the @Published
                // until after the perform{} block finishes
                // (read: we don't see it show "X" in the UI)
                self.status = "X"
                
// ????? how can I make this propagate the the view right away?
                
                // simulate a long-running (blocking) operation
                sleep(3)
                
                // cheap toggle
                if oldStatus == "foo" {
                    self.status = "bar"
                } else {
                    self.status = "foo"
                }
            }
        }
    }
}

class PersistenceController {
    static let shared = PersistenceController()
    let container: NSPersistentContainer
    
    static var preview: PersistenceController = {
        let result = PersistenceController(inMemory: true)
        let viewContext = result.container.viewContext
        return result
    }()
    
    init(inMemory: Bool = false) {
        let model = NSManagedObjectModel()
        container = NSPersistentContainer(name: "Example", managedObjectModel: model)
        container.viewContext.automaticallyMergesChangesFromParent = true
        container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
    }
}

PlaygroundPage.current.setLiveView(ContentView())
Answered by bjhomer in 725717022

As the code is written above, the problem would be that this…

DispatchQueue.main.async {
  // ...
  self.status = "X"
  sleep(3)
  self.status = "bar"
}

is blocking the main thread. The @Published var may very be sending its updates, but because the main thread is blocked, you can't actually see that update in the UI. When you push a change to a @Published var, it sends a willSet event that lets the UI record the previous value. It then schedules an update on the next pass of the run loop to compare the previous value to the current value. That lets it schedule an animation from the previous state to the current state, for example.

But as you've got the code written there, the main thread is blocked until the entire operation is done. That means that UI will never update to see "X" in the status field; by the time that next run loop pass is actually able to run, we're already to "bar".

I would look at whether it's possible to move the long-running blocking operation to a background thread. You could then either use a completion handler to finish updating the UI, or wrap it in an async function. That is, I'm suggesting you use one of the two following patterns:

// Option A (Modern Concurrency)
Task { @MainActor in
  self.status = "X"
  await runLongOperation(input: "some input here")
  self.status = "bar"
}

// Since this is an async function, it will never run 
// on the main thread unless specifically annotated 
// with @MainActor. We haven't done that, so this
// automatically runs in the background.
func runLongOperation(input: String) async {
  sleep(3)
}

or...

// Option B (Legacy Concurrency)
DispatchQueue.main.async {
  self.status = "X"
  runLongOperation(input: "some input", completion: {
    DispatchQueue.main.async {
      self.status = "bar"
    }
  }
}

func runLongOperation(input: String, completion: @escaping ()->Void) {
  DispatchQueue.global().async {
    sleep(3)
    completion()
  }
}
Accepted Answer

As the code is written above, the problem would be that this…

DispatchQueue.main.async {
  // ...
  self.status = "X"
  sleep(3)
  self.status = "bar"
}

is blocking the main thread. The @Published var may very be sending its updates, but because the main thread is blocked, you can't actually see that update in the UI. When you push a change to a @Published var, it sends a willSet event that lets the UI record the previous value. It then schedules an update on the next pass of the run loop to compare the previous value to the current value. That lets it schedule an animation from the previous state to the current state, for example.

But as you've got the code written there, the main thread is blocked until the entire operation is done. That means that UI will never update to see "X" in the status field; by the time that next run loop pass is actually able to run, we're already to "bar".

I would look at whether it's possible to move the long-running blocking operation to a background thread. You could then either use a completion handler to finish updating the UI, or wrap it in an async function. That is, I'm suggesting you use one of the two following patterns:

// Option A (Modern Concurrency)
Task { @MainActor in
  self.status = "X"
  await runLongOperation(input: "some input here")
  self.status = "bar"
}

// Since this is an async function, it will never run 
// on the main thread unless specifically annotated 
// with @MainActor. We haven't done that, so this
// automatically runs in the background.
func runLongOperation(input: String) async {
  sleep(3)
}

or...

// Option B (Legacy Concurrency)
DispatchQueue.main.async {
  self.status = "X"
  runLongOperation(input: "some input", completion: {
    DispatchQueue.main.async {
      self.status = "bar"
    }
  }
}

func runLongOperation(input: String, completion: @escaping ()->Void) {
  DispatchQueue.global().async {
    sleep(3)
    completion()
  }
}

Thanks! I really appreciate the detail here. This makes sense (I think (-; ). I'll work on getting the blocking code out of the perform {…}.

Propagate a @Published value from a Core Data ManagedContext
 
 
Q