如何使用 Swift Concurrency 在后台执行 CPU 密集型任务而不阻塞 UI 更新?

Lou*_*Lac 10 swift swiftui swift-concurrency

我有一个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
    }
}
Run Code Online (Sandbox Code Playgroud)

我使用 aTask在后台使用新的并发功能进行计算。这需要使用该@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
            }
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

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

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


我尝试过的

  • 每次更新已发布的属性时创建heavyWork和异步方法以及分散似乎可行,但这既麻烦又容易出错。await Task.yield()此外,它允许一些UI 更新,但不允许在后续Task.yield()调用之间更新。
  • @MainActor如果我们忽略紫色警告,说明应在主要参与者上进行 UI 更新(但这不是有效的解决方案),则删除该属性似乎可行。

编辑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)
    }
}
Run Code Online (Sandbox Code Playgroud)

Bra*_*key 9

问题是heavyWork继承了MainActor的隔离性Controller,这意味着工作将在主线程上执行。\n这是因为您已使用 进行注释,因此Controller默认@MainActor情况下,该类上的所有属性和方法都将继承隔离MainActor性。\ n当您创建一个新的 时Task { },它将继承当前任务(即该MainActor任务)的当前优先级和执行者隔离\xe2\x80\x93,强制heavyWork在主执行者/线程上运行。

\n

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

\n

然后 (2),我们确保 is heavyWorknonisolated因此它不会@MainActor从 继承上下文Controller。\n但是,这确实意味着您不能再Controller直接改变 上的任何状态。\n您仍然可以读取/修改 的状态如果您await访问读取操作或await调用 Actor 上修改状态的其他方法,则为 Actor。\n在这种情况下,您需要创建一个heavyWork函数async

\n

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

\n
@MainActor\nfinal class Controller: ObservableObject {\n    @Published private(set) var isComputing: Bool = false\n    \n    func compute() {\n        if isComputing { return }\n        Task {\n            isComputing = true\n\n            // (1) run detached at a non-UI priority\n            let work = Task.detached(priority: .low) {\n                self.heavyWork()\n            }\n\n            // (3) non-blocking wait for value\n            let result = await work.value\n            print("result on main thread", result)\n\n            isComputing = false\n        }\n    }\n    \n    // (2) will not inherit @MainActor isolation\n    nonisolated func heavyWork() -> String {\n        sleep(5)\n        return "result of heavy work"\n    }\n}\n
Run Code Online (Sandbox Code Playgroud)\n


DSo*_*ldo 5

也许我已经找到了替代方案,使用actor hopping.

使用ContentView与上面相同的方法,我们可以将 移动heavyWork到标准 actor 上:

@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"
  }
}
Run Code Online (Sandbox Code Playgroud)

由于 的上下文继承,该函数compute()在主线程上被调用MainActor,但随后,由于 actor 跳跃,该函数heavyWork()在另一个线程上被调用,从而解除了 UI 的阻塞。