d5automations
d5automations

Reputation: 559

SwiftUI - make sure to publish values from the main thread (via operators like receive(on:)) on model updates

My app contains a resource heavy operation that populates an Array based on data pulled from an XML feed. I do not want this operation to lock up the main thread (and the UI when the array is given new data), so it's done in the background.

let dispatchQueue = DispatchQueue(label: "concurrent.queue", qos: .utility, attributes: .concurrent)

class XMLHandler: ObservableObject {
    let context: NSManagedObjectContext

    @Published var myArray: [CustomObject] = []

    init(context: NSManagedObjectContext) {
        self.context = context
    }
    
    ...some code...
    
    func populateArray {
      dispatchQueue.async {
       ...xml parsing happens...
       (xmlOutputObject) in 
          for x in xmlOutputObject {
              self.myArray.append(x)
          }
      }
    }
}

Elsewhere, my SwiftUI View uses myArray to populate it's List:

struct MyView: View {

 @EnvironmentObject var handler: XMLHandler
    var body: some View {
            List{
                ForEach(handler.myArray) { CustomObject in
              ... generate rows ...
                    }
                }
            }

My error on runtime occurs when my app tries to update @Published var myArray: [CustomObject] = [].

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.

I know this is something to do with adopting Combine, but I honestly have no idea where to start. Any help would be appreciated.

I simply want the following to happen:

  1. User presses button that initiates the XML data pull that populates myArray
  2. myArray is populated on background thread, keeping UI responsive
  3. List in MyView automatically updates upon task completion. At present, I have to navigate away from the view and back again for the List to refresh.

Upvotes: 40

Views: 49412

Answers (6)

Hollycene
Hollycene

Reputation: 590

In situations where the @MainActor attribute doesn't work for you, it might be due to updating the UI from completion handlers executed on a background thread.

For instance, when using biometric authentication like Face ID or Touch ID and calling evaluatePolicy() to authenticate the user, the completion handler may execute on a background thread despite the method being marked as @MainActor. In such cases, using @MainActor alone might not work because the completion handler executes on the background thread.

To solve this, you can use a Task executed directly on the @MainActor, eliminating the need for DispatchQueue.main.async{}.

Task { @MainActor in
    // Update the @Published property here
    self.title = "Evaluation successful"
}

This approach ensures that the update to the @Published property occurs on the main thread, even when handling tasks or completion handlers executed on a background thread.

Upvotes: 7

Arjun Patel
Arjun Patel

Reputation: 1510

  • fetchedMovies is object Which values you get from API response.

  • movies is @Published variable which use to updates UI of View class

    await MainActor.run {
    
     movies = fetchedMovies
    
    }
    

Upvotes: 5

Max MacLeod
Max MacLeod

Reputation: 26652

iOS expects all UI changes to be on the main thread. SwiftUI is no exception. It is acceptable to perform UI related work off the main thread but sooner or later those changes need to be brought onto the main thread to be rendered.

It doesn’t seem that you are using Combine. However, if you were then this error;

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.

is best solved using the suggested Combine scheduling operator:

.receive(on: DispatchQueue.main)

This is detailed in Move Work Between Dispatch Queues With Scheduling Operators in Apple’s Processing URL Session Data Task Results with Combine article:

cancellable = urlSession
    .dataTaskPublisher(for: url)
    .receive(on: DispatchQueue.main)
    .sink(receiveCompletion: { print ("Received completion: \($0).") },
          receiveValue: { print ("Received data: \($0.data).")})

Upvotes: 14

Dave Kozikowski
Dave Kozikowski

Reputation: 651

DispatchQueue.main.async {
    nc.post(name: Notification.Name(NotificationStrings.AccountPostReceived), object: nil)
}

Upvotes: 14

xander1100001
xander1100001

Reputation: 569

Just put @MainActor before defining your class that is supposed to act in the main thread. And it's so simple in terms of using new Swift concurrency.

@MainActor class DocumentsViewModel: ObservableObject { ... }

There's a lot of information about that in new WWDC 2021 videos or articles like that: Using the MainActor attribute to automatically dispatch UI updates on the main queue

Upvotes: 56

Rob Napier
Rob Napier

Reputation: 299345

Since the appending happens in a loop, you need to decide if you want to emit a new value once per item, or once for the whole update. If you're not certain, update it once for the whole update.

Then perform that action on the main queue:

  ...xml parsing happens...
   (xmlOutputObject) in 
      DispatchQueue.main.async {   // <====
          self.append(contentsOf: xmlOutputObject)
      }

The key point is that you cannot read or write properties on multiple queues. And in this case the queue you want to use is the main one (because it's driving the UI). So you must make sure that all property access happen on that queue.

Upvotes: 21

Related Questions