Publish background context Core Data changes in a SwiftUI view without blocking the UI

1.4k views Asked by At

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() }
        }
    }
}
3

There are 3 answers

5
George On

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()
}
0
Mohamed Wasiq On

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
        }
}
0
Peyman On

The issue is that:

    .onReceive(didSave) {
        ⠇
    }

…runs on the background thread that did the saving and you shouldn't touch the UI from a background thread.

After the background context has saved, the UI/view context will merge in its changes (assuming your view context has automaticallyMergesChangesFromParentLater set to true). When this happens, CoreData fires the NSManagedObjectContextObjectsDidChange notification where the notification's object is set to the view context. To observe this notification, use:

    .onReceive(NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: context)) { notification in
        DispatchQueue.main.async {
            …
        }
    }

context should be your view context.

This block will run any time objects in the view context have changed. You may want to look at the notification's userInfo dictionary to see if any of the objects your UI is interested in have changed before updating your UI.