skip to Main Content

To simplify my problem: I have a huge calculation function with 3 inputs and 2 outputs. My input variables are @Published SwiftUI variables inside a SwiftUI ObservableObject. The UI does modify these input variables quite often, if the user takes actions. Output variables are also @Published variables inside the same class. As said, the background calculation is huge – therefore, I need to run the calculation all the time when one of the three inputs changes. If the inputs change during calculation, a new calculation is needed with the current (new) values. This should be as efficient and performant as it gets.

I come from Android programming and in Java I created a very efficient solution by having a background thread that runs all the time, sleeps when no new calculation is needed and gets woken up (notified in Java language) when one of the inputs changed. It then calculates the new results by request the most-current input parameters. If these input parameters changed during calculation, the thread does another calculation with the most-current, and so on…

But, I’m not sure how to achieve this in Swift. I see we can submit tasks to GCD on an extra queue for background processing. So, it is not possible to have a background thread running, being paused and waiting for new inputs, and calculating them – correct? I would need to send calculation tasks to the GCD queues all the time. I read and tried around a lot and created my first solution, which works but doesn’t work well and efficient, I think:

class ViewModel: ObservableObject, CalculatorDelegate {
  var calculator = Calculator()
  @Published var input1: Date
  @Published var input2: Int
  @Published var input3: Bool

  init() {
    calculator.delegate = self

    $input1.sink { [weak self] in
      self?.calculator.requestUpdate()
    }.store(...)

    $input2.sink { [weak self] in
      self?.calculator.requestUpdate()
    }.store(...)

    $input3.sink { [weak self] in
      self?.calculator.requestUpdate()
    }.store(...)
  }

  func createRequest() -> Data.Request { return Data.Request(input1, input2, input3) }
  func setResult(result: Data) { ... }
}

And the calculator itself:

class Calculator {
    var delegate: CalculatorDelegate?
    
    private static let queue = DispatchQueue(label: "asdf", qos: .userInteractive)
    private let blInstance = BL()
    private var reqForUpdate = false
    
    func register(for requestProvider: CalculatorDelegate) {
        self.delegate = requestProvider
    }
    
    func requestUpdate() {
        if !reqForUpdate {
            reqForUpdate = true
            scheduleUpdate()
        }
    }
    
    private func scheduleUpdate() {
        Calculator.queue.async { [weak self] in
            if let request = self?.delegate?.createRequest() {
                self?.reqForUpdate = false
                let result = self?.blInstance.calc(request: request)
                
                if let result = result {
                    DispatchQueue.main.async {
                        self?.delegate?.setResult(result: result)
                    }
                }
            }
        }
    }
}

protocol CalculatorDelegate {
    func createRequest() -> Data.Request
    func setResult(result: Data)
}

Is this a good strategy? I think it’s too complicated and it does not seem to be as efficient as the Android version I described above (having a endless running calculation background thread waiting for new data or processing as long as new data is there). Furthermore, is GCD the right tool here to use? I’m not sure…

I simplified the code to underline the important parts. Thanks a lot for your help or hints.

2

Answers


  1. You asked:

    So, it is not possible to have a background thread running, being paused and waiting for new inputs, and calculating them – correct?

    GCD does something very similar to this: It has a pool of “worker threads”, and when you dispatch something to a queue, it just grabs an available worker thread to run the dispatched code. This eliminates the overhead of spinning up a new thread. This results in a highly performant multi-threading mechanism.

    GCD abstracts the developers away from managing threads and avoids the creation and destruction of threads unnecessarily. Just use GCD (or operation queues or asyncawait) and enjoy efficient thread utilization.

    is GCD the right tool here to use?

    GCD works, but I would also consider the asyncawait concurrency system or operation queues.

    Specifically, a key design requirement is that you want to be able to cancel your calculation (so you can presumably start another). GCD’s DispatchWorkItem supports cancelation, but operation queues and asyncawait arguably handle this more elegantly. So, while you could use GCD, I would personally suggest asyncawait if supported by my target OS (e.g., iOS 13 and later), and use operation queue if I needed to support older operating systems.

    But whether dispatch queue, operation queue, or asyncawait, you will want to participate in “cooperative cancelation”. Specifically, you will have your time-consuming calculation periodically check the respective isCancelled property, exiting the calculation if true.

    @IBOutlet weak var calculateButton: UIButton!
    @IBOutlet weak var cancelButton: UIButton!
    @IBOutlet weak var label: UILabel!
    
    private var task: Task<Void, Error>?
    
    @IBAction func didTapCalculate(_ sender: Any) {
        ...
    
        task = Task.detached {
            try await self.calculate()
    
            await MainActor.run { [weak self] in
                ...
    
                self?.task = nil
            }
        }
    }
    
    @IBAction func didTapCancel(_ sender: Any) {
        task?.cancel()
        task = nil
    }
    
    func calculate() async throws {
        ...
    
        repeat {
            // check to see if task has been canceled
    
            try Task.checkCancellation()                // or `if Task.isCancelled { return }`
    
            // perform iteration of calculations here
    
            // now update the UI if necessary
    
            if shouldUpdateUI {
                Task { [value] in
                    await updateLabel(with: value)
                }
            }
        } while shouldKeepCalculating
    }
    
    @MainActor
    func updateLabel(with value: Double) async {
        label.text = ...
    }
    
    Login or Signup to reply.
  2. Your use of Combine’s ObservableObject and sink for the inputs is highly non-standard. That should only be used when you want to assign the output of a Combine pipeline to an @Published so isn’t the right approach here. I recommend a standard SwiftUI approach like this async calculator example below. Note the use of @Binding and .task(id:).

    let formatter: NumberFormatter = {
           let formatter = NumberFormatter()
           formatter.numberStyle = .decimal
           return formatter
       }()
    
    struct CalculatorView: View {
        @State var calc = Calculator()
        
        var body: some View {
            InputsView(calc: $calc)
            OutputsView(calc: calc)
        }
    }
    
    struct Calculator: Equatable {
        var input1: Int = 0
        var input2: Int = 0
        
        func calculate() async -> Int {
            try? await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC)
            return input1 + input2
        }
    }
    
    struct InputsView: View {
        @Binding var calc: Calculator // gives write-access to the inputs
        
        var body: some View {
            HStack {
                Text("Input 1")
                TextField("Text", value: $calc.input1, formatter: formatter)
            }
            HStack {
                Text("Input 2")
                TextField("Text", value: $calc.input2, formatter: formatter)
            }
        }
    }
    
    struct OutputsView: View {
        let calc: Calculator // gives read access to the inputs and body is called when they change.
        @State var result = 0
        
        var body: some View {
            HStack {
                Text("Result")
                Text("(result)")
            }
            .task(id: calc) { // runs on appear and when calc inputs changes, automatically cancelled if already started and on disappear.
                result = await calc.calculate()
            }
        }
    }
    

    Screenshot

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search