13

Is there a way to specify that count should only publish on the main thread? I've seen docs that talk about setting up your Publisher using receive(on:), but in this case the @Publisher wrapper hides that logic.

import SwiftUI
import Combine

class MyCounter: ObservableObject {
  @Published var count = 0

  public static let shared = MyCounter()
  
  private init() { }
}

struct ContentView: View {
    @ObservedObject var state = MyCounter.shared
    var body: some View {
        return VStack {
            Text("Current count: \(state.count)")
            Button(action: increment) {
                HStack(alignment: .center) {
                    Text("Increment")
                        .foregroundColor(Color.white)
                        .bold()
                }
            }
        }
    }
    
    private func increment() {
        NetworkUtils.count()
    }
}

public class NetworkUtils {

    public static func count() {
        guard let url = URL.parse("https://www.example.com/counter") else {
            return
        }
        
        var request = URLRequest(url: url)
        request.httpMethod = "GET"
        
        let task = URLSession.shared.dataTask(with: request) { data, response, error in
            if let response = response as? HTTPURLResponse {
                let statusCode = response.statusCode
                if statusCode >= 200 && statusCode < 300 {
                    do {
                        guard let responseData = data else {
                            return
                        }
                        
                        guard let json = try JSONSerialization.jsonObject(with: responseData, options: []) as? [String: Any] else {
                            return
                        }
                        
                        if let newCount = json["new_count"] as? Int{
                            MyCounter.shared.count = newCount
                        }
                    } catch  {
                        print("Caught error")
                    }
                }
            } 
        }
        task.resume()
    }   
}

As you can see from my updated example, This is a simple SwiftUI view that has a button that when clicked makes a network call. The network call is asynchronous. When the network call returns, the ObservableObject MyCounter is updated on a background thread. I would like to know if there is a way to make the ObservableObject publish the change on the main thread. The only way I know to accomplish this now is to wrap the update logic in the network call closure like this:

DispatchQueue.main.async {
    MyCounter.shared.count = newCount
}
Paulo Mattos
  • 18,845
  • 10
  • 77
  • 85
TALE
  • 960
  • 13
  • 22

3 Answers3

9

Instead of using URLSession.shared.dataTask(with: request), you can use URLSession.shared.dataTaskPublisher(for: request) (docs) which will allow you to create a Combine pipeline. Then you can chain the receive(on:) operator as part of your pipeline.

URLSession.shared.dataTaskPublisher(for: request)
  .map { response in ... }
  ...
  .receive(on: RunLoop.main)
  ...

Also check out heckj's examples, I've found them to be very useful.

Gil Birman
  • 35,242
  • 14
  • 75
  • 119
5

If you try to set value marked @Published from a background thread you will see this error:

Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive

So you have to make sure anywhere you set the value that it this done on the main thread, the values will always be published on the main thread.

LuLuGaGa
  • 13,089
  • 6
  • 49
  • 57
  • 3
    Right. That's the error I'm seeing and I want to know if there is a way to configure the Publisher used when you use the @Published wrapper to automatically move the update to the main thread. Right now, I'm just updating those fields I use by switching to the main thread using DispatchQueue.main.async {}. – TALE Nov 27 '19 at 15:38
0

The Combine way to accomplish this (for API that do not provide Publishers) could be replacing

MyCounter.shared.count = newCount
                  

with

Just(newCount).receive(on: RunLoop.main).assign(to: &MyCounter.shared.$count)

And here is how we can do it using Modern Concurrency async/await syntax.

import SwiftUI

final class MyCounter: ObservableObject {
    @Published var count = 0
    public static let shared = MyCounter()
    
    private init() {}
    
    @MainActor func setCount(_ newCount: Int) {
        count = newCount
    }
}

struct ContentView: View {
    @ObservedObject var state = MyCounter.shared
    var body: some View {
        return VStack {
            Text("Current count: \(state.count)")
            Button(action: increment) {
                HStack(alignment: .center) {
                    Text("Increment")
                        .bold()
                }
            }
            .buttonStyle(.bordered)
        }
    }
    
    private func increment() {
        Task {
            await NetworkUtils.count()
        }
    }
}

class NetworkUtils {
    
    static func count() async {
        guard let url = URL(string: "https://www.example.com/counter") else {
            return
        }
        
        var request = URLRequest(url: url)
        request.httpMethod = "GET"
        
        do {
            let (data, response) = try await URLSession.shared.data(for: request)
            await MyCounter.shared.setCount(Int.random(in: 0...100)) // FIXME: Its just for demo
            if let response = response as? HTTPURLResponse,
               response.statusCode >= 200 && response.statusCode < 300 { throw URLError(.badServerResponse) }
            guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
                throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Expected [String: Any]"))
            }
            if let newCount = json["new_count"] as? Int {
                await MyCounter.shared.setCount(newCount)
            }
        } catch  {
            print("Caught error :\(error.localizedDescription)")
        }
    }
}
Paul B
  • 3,989
  • 33
  • 46