如何在不阻塞 UI 更新的情况下使用 Swift 并发在后台执行 CPU 绑定任务?

How to execute a CPU-bound task in background using Swift Concurrency without blocking UI updates?

我有一个 ObservableObject 可以完成 CPU 繁重的工作:

import Foundation
import SwiftUI

@MainActor
final class Controller: ObservableObject {
    @Published private(set) var isComputing: Bool = false
    
    func compute() {
        if isComputing { return }
        
        Task {
            heavyWork()
        }
    }
    
    func heavyWork() {
        isComputing = true
        sleep(5)
        isComputing = false
    }
}

我使用 Task 使用新的并发功能在后台进行计算。这需要使用 @MainActor 属性来确保所有 UI 更新(此处绑定到 isComputing 属性)在主要参与者上执行。

然后我有以下视图,其中显示了一个计数器和一个启动计算的按钮:

struct ContentView: View {
    @StateObject private var controller: Controller
    @State private var counter: Int = 0
    
    init() {
        _controller = StateObject(wrappedValue: Controller())
    }
    
    var body: some View {
        VStack {
            Text("Timer: \(counter)")
            Button(controller.isComputing ? "Computing..." : "Compute") {
                controller.compute()
            }
            .disabled(controller.isComputing)
        }
        .frame(width: 300, height: 200)
        .task {
            for _ in 0... {
                try? await Task.sleep(nanoseconds: 1_000_000_000)
                counter += 1
            }
        }
    }
}

问题是计算似乎阻塞了整个UI:计数器冻结。

为什么 UI 冻结以及如何以不阻止 UI 更新的方式实施 .compute()


我试过的


编辑 1

感谢@Bradley 提出的答案,我得到了这个按预期工作的解决方案(并且非常接近通常的 DispatchQueue 方式):

@MainActor
final class Controller: ObservableObject {
    @Published private(set) var isComputing: Bool = false
    
    func compute() {
        if isComputing { return }
        
        Task.detached {
            await MainActor.run {
                self.isComputing = true
            }
            await self.heavyWork()
            await MainActor.run {
                self.isComputing = false
            }
        }
    }
    
    nonisolated func heavyWork() async {
        sleep(5)
    }
}

问题是 heavyWorkController 继承了 MainActor 隔离,这意味着工作将在主线程上执行。 这是因为你用 @MainActor 注释了 Controller 所以这个 class 上的所有属性和方法将默认继承 MainActor 隔离。 而且 当您创建一个新的 Task { } 时,它会继承当前任务(即 MainActor 的)当前优先级和 actor 隔离 - 强制 heavyWork 到主要 actor/thread 上的 运行。

我们需要确保 (1) 我们 运行 低优先级的繁重工作,因此系统不太可能将其安排在 UI 线程上。 这也需要是一个分离的任务,这将阻止 Task { } 执行的默认继承。 我们可以通过使用低优先级的 Task.detached 来做到这一点(比如 .background.low)。

然后(2),我们保证heavyWorknonisolated,所以它不会继承Controller@MainActor上下文。 但是,这确实意味着您不能再直接改变 Controller 上的任何状态。 如果您 await 访问读取操作或 await 调用 actor 上修改状态的其他方法,您仍然可以 read/modify actor 的状态。 在这种情况下,您需要将 heavyWork 设为 async 函数。

然后(3),我们等待使用“任务句柄”编辑的value 属性 return计算值。 这允许我们从 heavyWork 函数访问 return 值,如果有的话。

@MainActor
final class Controller: ObservableObject {
    @Published private(set) var isComputing: Bool = false
    
    func compute() {
        if isComputing { return }
        Task {
            isComputing = true

            // (1) run detached at a non-UI priority
            let work = Task.detached(priority: .low) {
                self.heavyWork()
            }

            // (3) non-blocking wait for value
            let result = await work.value
            print("result on main thread", result)

            isComputing = false
        }
    }
    
    // (2) will not inherit @MainActor isolation
    nonisolated func heavyWork() -> String {
        sleep(5)
        return "result of heavy work"
    }
}

也许我找到了替代方法,使用 actor hopping

使用与上面相同的ContentView,我们可以在标准演员上移动heavyWork

@MainActor
final class Controller: ObservableObject {

  @Published private(set) var isComputing: Bool = false

  func compute() {
    
    if isComputing { return }
    
    Task {
        
        isComputing = true
        
        print("Controller isMainThread: \(Thread.isMainThread)")
        let result = await Worker().heavyWork()
        print("result on main thread", result)

        isComputing = false
    }
  }
}

actor Worker {

  func heavyWork() -> String {

    print("Worker isMainThread: \(Thread.isMainThread)")
    sleep(5)
    return "result of heavy work"
  }
}

由于 MainActor 的上下文继承,函数 compute() 在主线程上被调用,但是由于 actor 跳跃,函数 heavyWork() 在主线程上被调用不同线程解锁 UI.