2

After running a background-context core data task, Xcode displays the following purple runtime warning when the updates are published in a SwiftUI view:

"[SwiftUI] Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates."

Besides the ContentView.swift code below, I also added container.viewContext.automaticallyMergesChangesFromParent = true to init in the default Persistence.swift code.

How can I publish the background changes on the main thread to fix the warning? (iOS 14, Swift 5)

Edit: I've changed the code below, in response to the first answer, to clarify that I'm looking for a solution that doesn't block the UI when a lot of changes are saved.

struct PersistenceHelper {
    private let context: NSManagedObjectContext
    
    init(context: NSManagedObjectContext = PersistenceController.shared.container.viewContext) {
        self.context = context
    }
    
    public func fetchItem() -> [Item] {
        do {
            let request: NSFetchRequest<Item> = Item.fetchRequest()
            var items = try self.context.fetch(request)
            if items.isEmpty { // Create items if none exist
                for _ in 0 ..< 250_000 {
                    let item = Item(context: context)
                    item.timestamp = Date()
                    item.data = "a"
                }
                try! context.save()
                items = try self.context.fetch(request)
            }
            return items
        } catch { assert(false) }
    }

    public func updateItemTimestamp(completionHandler: @escaping () -> ()) {
        PersistenceController.shared.container.performBackgroundTask({ backgroundContext in
            let start = Date(), request: NSFetchRequest<Item> = Item.fetchRequest()
            do {
                let items = try backgroundContext.fetch(request)
                for item in items {
                    item.timestamp = Date()
                    item.data = item.data == "a" ? "b" : "a"
                }
                try backgroundContext.save() // Purple warning appears here

                let interval = Double(Date().timeIntervalSince(start) * 1000) // Artificial two-second delay so cover view has time to appear
                if interval < 2000 { sleep(UInt32((2000 - interval) / 1000)) }
                
                completionHandler()
            } catch { assert(false) }
        })
    }
}
// A cover view with an animation that shouldn't be blocked when saving the background context changes
struct CoverView: View {
    @State private var toggle = true
    var body: some View {
        Circle()
            .offset(x: toggle ? -15 : 15, y: 0)
            .frame(width: 10, height: 10)
            .animation(Animation.easeInOut(duration: 0.25).repeatForever(autoreverses: true))
            .onAppear { toggle.toggle() }
    }
}
struct ContentView: View {
    @State private var items: [Item] = []
    @State private var showingCoverView = false
    @State private var refresh = UUID()

    let persistence = PersistenceHelper()
    let formatter = DateFormatter()
    var didSave = NotificationCenter.default
        .publisher(for: .NSManagedObjectContextDidSave)
        // .receive(on: DispatchQuene.main) // Doesn't help
    
    var body: some View {
        ScrollView {
            LazyVStack {
                Button("Update Timestamp") {
                    showingCoverView = true
                    persistence.updateItemTimestamp(completionHandler: { showingCoverView = false })
                }
                ForEach(items, id: \.self) { item in
                    Text(formatter.string(from: item.timestamp!) + " " + (item.data ?? ""))
                }
            }
        }
        .id(refresh)
        .onAppear {
            formatter.dateFormat = "HH:mm:ss"
            items = persistence.fetchItem()
        }
        .onReceive(didSave) { _ in
            items = persistence.fetchItem()
        }
        .fullScreenCover(isPresented: $showingCoverView) {
            CoverView().onDisappear { refresh = UUID() }
        }
    }
}
jesseblake
  • 100
  • 1
  • 10

2 Answers2

0

Since you are performing a background task, you are on a background thread - rather than the main thread.

To switch to the main thread, change the line producing the runtime warning to the following:

DispatchQueue.main.async {
    try backgroundContext.save()
}
George
  • 25,988
  • 10
  • 79
  • 133
  • It isn't represented well in my example code but I'm using a background context to avoid blocking the UI on the main thread, and this solution blocks the UI when there are a lot of changes to save. Is there a way to save from within the background context and publish or signal the main thread afterwards? – jesseblake Sep 23 '21 at 23:29
  • Just as a side note, this breaks the artificial wait by making it two seconds in all conditions. – jesseblake Sep 23 '21 at 23:36
  • @jesseblake The problem is that (it appears) when you are saving your CoreData stuff, something is making a SwiftUI `@State`, `@Binding`, `@Publisher` etc variable update from a background thread. Can you show the `PersistenceController.shared.container.performBackgroundTask` method please? Edit your question to include it. Is there possibly a `@FetchRequest` in the `PersistenceController` model? May be useful to include all of it instead. – George Sep 23 '21 at 23:57
  • I added a `String` property to the `Item` entity, and the code creates `250_000` items now. An update takes about 5 seconds on my Mac. I also added an animation to the cover view to show that the original code doesn't block the UI when the save occurs in the background thread. – jesseblake Sep 24 '21 at 02:25
  • I can put `try backgroundContext.save()` and `completionHandler()` in `DispatchQueue.main.async` but the UI is blocked when the save occurs. – jesseblake Sep 24 '21 at 02:34
0

You should use Combine and observe changes to your background context and update State values for your UI to react.

@State private var coreDataAttribute = ""

 var body: some View {
Text(coreDataAttribute)
.onReceive(

            CoreDataManager.shared.moc.publisher(for: \.hasChanges)
            .subscribe(on: DispatchQueue.global())
            .receive(on: DispatchQueue.global())
                .map{_ in CoreDataManager.shared.fetchCoreDataValue()}
                .filter{$0 != coreDataAttribute}

            .receive(on: DispatchQueue.main))
        { value in
            coreDataAttribute = value
        }
}
Mohamed Wasiq
  • 490
  • 4
  • 17