iOS / Swift(UI)始终运行的后台计算线程的高效实现-GCD案例?

Posted

技术标签:

【中文标题】iOS / Swift(UI)始终运行的后台计算线程的高效实现-GCD案例?【英文标题】:iOS/Swift(UI) Efficient implementation of Background calculation thread always running - GCD case? 【发布时间】:2021-12-28 11:26:48 【问题描述】:

为了简化我的问题:我有一个 巨大的计算函数,有 3 个输入2 个输出。我的输入变量是 SwiftUI ObservableObject 中的 @Published SwiftUI 变量。如果用户采取行动,UI 会经常修改这些输入变量。输出变量也是同一类中的@Published 变量。如前所述,背景计算量很大 - 因此,当三个输入之一发生变化时,我需要一直运行计算。如果在计算期间输入发生变化,则需要使用当前(新)值进行新计算。这应该尽可能高效和高效。

我来自 android 编程,在 Java 中我创建了一个非常有效的解决方案,它拥有一个始终运行的后台线程,在不需要新计算时休眠,并在其中一个输入时唤醒(用 Java 语言通知)改变了。然后它通过请求最新的输入参数来计算新的结果。如果这些输入参数在计算过程中发生了变化,线程会以最新的进行另一次计算,以此类推……

但是,我不确定如何在 Swift 中实现这一点。我看到我们可以在一个额外的队列上向 GCD 提交任务以进行后台处理。因此,不可能让后台线程运行、暂停并等待新输入并计算它们——对吗?我需要一直将计算任务发送到 GCD 队列。我阅读并尝试了很多,并创建了我的第一个解决方案,我认为该解决方案有效,但效果不佳:

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)  ... 

还有计算器本身:

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)

这是一个好的策略吗?我认为它太复杂了,它似乎没有我上面描述的Android版本那么高效(有一个无休止的运行计算后台线程等待新数据或只要有新数据就可以处理)。此外,GCD 是否适合在这里使用?我不确定...

我简化了代码以强调重要部分。非常感谢您的帮助或提示。

【问题讨论】:

Java 线程只是封装工作单元的抽象。它不是连续运行的。它只是封装状态并在有工作要做时调度执行。在 ios 上,您可以将工作分派到 GCD 队列上,或者您可以使用 OperationQueue 甚至只是一个组合管道。你能解释一下你的计算是如何运作的吗?它会在新数据到来之前完成吗?如果新数据在完成之前到达,您是否需要能够中断/重新启动计算? 【参考方案1】:

你问:

因此,不可能让后台线程运行、暂停并等待新输入并计算它们 - 对吗?

GCD 做的事情与此非常相似:它有一个“工作线程”池,当您将某些内容分派到队列时,它只是抓取一个可用的工作线程来运行分派的代码。这消除了启动新线程的开销。这产生了一种高性能的多线程机制。

GCD 将开发人员从管理线程中抽象出来,并避免不必要地创建和销毁线程。只需使用 GCD(或操作队列或async-await)即可享受高效的线程利用率。

GCD 是否适合在这里使用?

GCD 有效,但我也会考虑async-await 并发系统或操作队列。

具体来说,一个关键的设计要求是您希望能够取消您的计算(这样您就可以开始另一个计算)。 GCD 的 DispatchWorkItem 支持取消,但操作队列和 async-await 可以说更优雅地处理这个问题。因此,虽然您可以使用 GCD,但如果我的目标操作系统(例如 iOS 13 及更高版本)支持,我个人建议async-await,如果我需要支持旧操作系统,请使用操作队列。

但无论是调度队列、操作队列,还是async-await,你都会想参与“合作取消”。具体来说,您将让您的耗时计算定期检查相应的isCancelled 属性,如果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 = ...

【讨论】:

【参考方案2】:

您使用 Combine 的 ObservableObjectsink 作为输入是非常不标准的。仅当您想将组合管道的输出assign@Published 时才应使用此方法,因此这里的方法不正确。我推荐一个标准的 SwiftUI 方法,比如下面这个异步计算器示例。注意@Binding.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()
        
    

【讨论】:

嗨@malhal!非常感谢您的回复!这似乎是一个很棒的 Swift(UI)y 解决方案。虽然,我使用的是定义为 ObservableObject 的 ViewModel,将这些输入变量保存为 Published 属性。整个应用程序取决于他们。对我来说,将这些输入变量打包成一个结构(代码中的Calculator)似乎不是“最佳”解决方案。否则,我会在 UI 中单独访问它们。在 UI 中为所有三个输入变量添加三次 task(id: viewModel.varX) ... 是否有问题?当然,最初计算会进行 3 次... 如果在有新值并开始新值之前任务尚未完成,则该任务被取消。但是,一直支持取消异步任务是一项挑战,您需要做的是让它只执行一次完整的计算,而不是三个。 啊,我忘了,谢谢!您是否还有其他想法,我如何将这三个变量分开放在 ViewModel 中,而不是将它们全部打包在一个结构中?那将完全改变代码,我不确定我是否仍然能够单独收听它们。其次,我不确定是否还有比这个(伟大的)更好的解决方案,因为我的计算总是使用当前值,我希望计算总是完成(以获得中间值)。如果输入变量发生变化,它应该在完成后重新计算。 另一种方法是使其成为模型而不是视图模型。然后可以使用 ObservableObject 并且您也可以使用 Combine 来处理数据,例如 Publishers.CombineLatest($input1, $input2, $input3)。

以上是关于iOS / Swift(UI)始终运行的后台计算线程的高效实现-GCD案例?的主要内容,如果未能解决你的问题,请参考以下文章

iOS 14,Swift UI 2 更改 NavigationLink 内选定列表项的背景颜色

即使应用程序在后台运行,如何使用 swift 在 ios 中获取位置更新

应用程序进入后台时如何保持 Swift Socket IO 运行?

如何在后台线程swift中运行for循环

Swift IOS在弹出后保持视图控制器在后台运行

更改UI标签栏宽度ios 13 swift