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

7

我有一个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属性相关)都在主actor上执行。

然后,我有以下视图,它显示一个计数器和一个按钮来启动计算:

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会冻结,如何实现`.compute()`以使其不会阻塞UI更新?
我尝试过:
- 将`heavyWork`方法变成async方法,并在每次发布属性更新时散布`await Task.yield()`,这似乎有效,但是这种方法既麻烦又容易出错。而且它只允许一些UI更新,不能在后续的`Task.yield()`调用之间更新UI。 - 如果忽略紫色警告(UI更新应该在主actor上进行),删除`@MainActor`属性似乎可以解决问题(虽然这不是一个有效的解决方案)。
编辑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)
    }
}

1
不要将整个类标记为 @MainActor,只需将需要在主线程上运行的部分标记即可。或者将网络工作移动到不是 @MainActor 的另一个类中。或者使用 DisptachQueue.global().async 将繁重的工作移动到后台,然后使用 DispatchQueue.main.async 处理涉及 UI 的部分。 - timbre timbre
我尝试了一下,但不幸的是它不能很好地扩展,基本上需要在每个状态更新中包装一个 MainActor.run { },这甚至比 DispatchQueue 更加模板化。我认为 @MainActor 属性只会将状态更新分派到主要执行器,而不是整个方法调用。 DispatchQueue 肯定可以工作,但这违背了使用 Swift Concurrency 的初衷。 - Louis Lac
2个回答

8
问题在于heavyWork继承了ControllerMainActor隔离性,这意味着任务将在主线程上执行。 这是因为您已经用@MainActor注释了Controller,所以该类上的所有属性和方法默认都会继承MainActor隔离性。 同时,当你创建一个新的Task { }时,它会继承当前任务(即MainActor)的当前优先级和演员隔离性,从而强制heavyWork在主演员/线程上运行。
我们需要确保(1)我们以较低的优先级运行繁重的工作,这样系统就不太可能在UI线程上安排它。 这也需要是一个分离的任务,这将防止Task { }执行的默认继承。 我们可以使用带有低优先级(如.background.low)的Task.detached来完成这个任务。
然后(2),我们确保heavyWorknonisolated,这样它就不会从Controller中继承@MainActor上下文。 但是,这意味着您不能再直接修改Controller上的任何状态。 您仍然可以读取/修改演员的状态,如果您在访问读操作或调用修改状态的其他方法时使用了await。 在这种情况下,您需要将heavyWork设置为一个async函数。
然后(3),我们使用"任务处理程序"返回的value属性等待值被计算。 这允许我们从heavyWork函数中访问返回值(如果有的话)。
@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 上完成所有工作,然后在主线程上更新控制器状态)。我以为这就是 @MainActor 属性的作用,但它似乎只会在主 actor 上安排所有任务。 - Louis Lac
@LouisLac 你是指如何自动化这个过程?在这个例子中,主要的角色正在按预期工作。你已经告诉了 Controller 它的主要角色是隔离的,所以它“做”的一切都应该在主要角色/线程上完成。如果你想要退出主要角色隔离,可以使用 nonisolated,或者在其他地方运行重型任务的函数 - 但它无法自行确定什么是“重型”任务。有时候你需要使用分离的任务,有时候需要指定优先级,以更精细地控制任务执行。 - Bradley Mackey
顺便提一下,我需要从一个类中执行所有操作,并仅在主线程上更新驱动UI的状态。例如,一个ObservableObject始终在后台工作,并在主线程上更新其已发布的属性。 - Louis Lac
奇怪,这个解决方案在我的原始(更复杂)代码中不起作用,但通常的GCD方式完美地工作。使用Swift Concurrency时,我遇到了一个空指针异常(可能是由于我错误使用导致的猜测)。 - Louis Lac
@LouisLac,“空指针异常”对我来说听起来像是 Swift 的一个 bug。请尝试隔离这个问题并报告到 https://bugs.swift.org。 - Bradley Mackey
1
我进行了调查,问题很可能出在我的这一边:可能是由于递归调用太深而导致的堆栈溢出。减少深度解决了这个问题。无论如何,还是谢谢你的帮助! - Louis Lac

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"
  }
}

由于 MainActor 的上下文继承,compute() 函数在主线程上被调用,但是通过 actor 跳转,函数 heavyWork() 在不同的线程上执行,从而解除了 UI 的阻塞。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接