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.
CodePudding user response:
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 async
-await
) and enjoy efficient thread utilization.
is GCD the right tool here to use?
GCD works, but I would also consider the async
-await
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 async
-await
arguably handle this more elegantly. So, while you could use GCD, I would personally suggest async
-await
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 async
-await
, 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 = ...
}
CodePudding user response:
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()
}
}
}