ahe*_*eze 38 ios async-await swift swiftui swift-concurrency
我有一个ObservableObject
类和一个 SwiftUI 视图。当点击按钮时,我会在其中创建Task
并调用(异步函数)。populate
我以为这会populate
在后台线程上执行,但整个 UI 都冻结了。这是我的代码:
class ViewModel: ObservableObject {
@Published var items = [String]()
func populate() async {
var items = [String]()
for i in 0 ..< 4_000_000 { /// this usually takes a couple seconds
items.append("\(i)")
}
self.items = items
}
}
struct ContentView: View {
@StateObject var model = ViewModel()
@State var rotation = CGFloat(0)
var body: some View {
Button {
Task {
await model.populate()
}
} label: {
Color.blue
.frame(width: 300, height: 80)
.overlay(
Text("\(model.items.count)")
.foregroundColor(.white)
)
.rotationEffect(.degrees(rotation))
}
.onAppear { /// should be a continuous rotation effect
withAnimation(.easeInOut(duration: 2).repeatForever()) {
rotation = 90
}
}
}
}
Run Code Online (Sandbox Code Playgroud)
结果:
按钮停止移动,populate
完成后突然弹回。
奇怪的是,如果我将 移动Task
到populate
自身并摆脱async
,旋转动画不会卡顿,所以我认为循环实际上是在后台执行的。但是我现在收到Publishing changes from background threads is not allowed
警告。
func populate() {
Task {
var items = [String]()
for i in 0 ..< 4_000_000 {
items.append("\(i)")
}
self.items = items /// Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.
}
}
/// ...
Button {
model.populate()
}
Run Code Online (Sandbox Code Playgroud)
结果:
如何确保我的代码在后台线程上执行?我认为这可能与此有关,MainActor
但我不确定。
Rob*_*Rob 47
首先,作为一般观察,在 WWDC 2021\xe2\x80\x99s Discover concurrency in SwiftUI中,他们建议您将ObservableObject
对象与主要参与者隔离。
但 UI 中的问题是由于主要参与者被这个缓慢的进程阻塞而引起的。所以我们必须把这个任务交给主角。有几种可能的方法:
\n您可以将慢速同步进程移至 \xe2\x80\x9cdetached\xe2\x80\x9d 任务。当Task {\xe2\x80\xa6}
代表当前 actor\xe2\x80\x9d 启动一个新的顶级任务 \xe2\x80\x9con 时,分离任务是一个 \xe2\x80\x9c 的非结构化任务,\xe2\x80\x99s 不是当前 actor 的一部分。演员\xe2\x80\x9d。因此,detached
任务将避免阻塞当前参与者:
@MainActor\nclass ViewModel: ObservableObject {\n @Published var items = [String]()\n\n func populate() async {\n let task = Task.detached {\n var items: [String] = []\n\n for i in 0 ..< .random(in: 4_000_000...5_000_000) { // made it random so I could see values change\n items.append("\\(i)")\n }\n return items\n }\n\n items = await task.value\n }\n}\n
Run Code Online (Sandbox Code Playgroud)\n请注意,虽然这解决了阻塞问题,但不幸的是,Task.detached {\xe2\x80\xa6}
(如Task {\xe2\x80\xa6}
)是非结构化并发。因此,这可能不是我通常推荐的模式(除非您将其包装在 中withTaskCancellationHandler
)。但请参阅下面第 4 点中有关取消的更多信息。
从 Swift 5.7 开始,可以使用以下函数实现相同的行为async
(nonisolated
请参阅SE-0338)。这使我们保持在结构化并发的范围内,但仍然可以减轻当前参与者的工作:
@MainActor\nclass ViewModel: ObservableObject {\n @Published var items = [String]()\n\n private nonisolated func generate() async -> [String] {\n var items: [String] = []\n\n for i in 0 ..< .random(in: 4_000_000...5_000_000) { // made it random so I could see values change\n items.append("\\(i)")\n }\n return items\n }\n\n func populate() async {\n items = await generate()\n }\n}\n
Run Code Online (Sandbox Code Playgroud)\n或者我们可以使用单独的actor
耗时过程来完成此操作,这再次将任务从视图模型 \xe2\x80\x99s actor 中解放出来:
@MainActor\nclass ViewModel: ObservableObject {\n @Published var items = [String]()\n private let generator = Generator()\n\n private actor Generator {\n func generate() async -> [String] {\n var items: [String] = []\n\n for i in 0 ..< .random(in: 4_000_000...5_000_000) { // made it random so I could see values change\n items.append("\\(i)")\n }\n return items\n }\n }\n\n func populate() async {\n items = await generator.generate()\n }\n}\n
Run Code Online (Sandbox Code Playgroud)\n我建议添加取消逻辑(以防用户想要中断计算并开始另一个计算)try Task.checkCancellation()
。
另外,在 Swift 并发中,我们永远不应该违反 \xe2\x80\x9censure 前进进度\xe2\x80\x9d 的契约,或者,如果必须的话,定期Task.yield
确保该并发系统的正常运行。正如SE-0296所说:
\n\n由于潜在的挂起点只能出现在异步函数中显式标记的点处,因此长计算仍然会阻塞线程。当调用只执行大量工作的同步函数时,或者遇到直接在异步函数中编写的特别密集的计算循环时,可能会发生这种情况。在任何一种情况下,线程都无法在这些计算运行时交错代码,这通常是正确性的正确选择,但也可能成为可扩展性问题。需要进行密集计算的异步程序通常应该在单独的上下文中运行。当\xe2\x80\x99s不可行时,将有库设施人为地暂停并允许其他操作交错。
\n
现在,前面提到的技术(上面第 1-3 点)通过防止主要参与者被阻塞来解决您的主要问题。但这里更深入的观察是,我们确实应该避免阻止任何使用 \xe2\x80\x9clong 运行 \xe2\x80\x9d 工作的演员。但 Task.yield
解决了这个问题。
无论如何,考虑到这个nonisolated
例子,我们可以处理取消和交错:
@MainActor\nclass ViewModel: ObservableObject {\n @Published var items = [String]()\n\n private nonisolated func generate() async throws -> [String] {\n var items = [String]()\n for i in 0 ..< .random(in: 4_000_000 ... 5_000_000) {\n if i.isMultiple(of: 1000) {\n await Task.yield()\n try Task.checkCancellation()\n }\n items.append("\\(i)")\n }\n try Task.checkCancellation()\n return items\n }\n\n func populate() async throws {\n items = try await generate()\n }\n}\n
Run Code Online (Sandbox Code Playgroud)\n或者该actor
方法将遵循相同的模式:
@MainActor\nclass ViewModel: ObservableObject {\n @Published var items = [String]()\n private let generator = Generator()\n\n private actor Generator {\n func generate() async throws -> [String] {\n var items = [String]()\n for i in 0 ..< .random(in: 4_000_000 ... 5_000_000) {\n if i.isMultiple(of: 1000) {\n await Task.yield()\n try Task.checkCancellation()\n }\n items.append("\\(i)")\n }\n try Task.checkCancellation()\n return items\n }\n }\n\n func populate() async throws {\n items = try await generator.generate()\n }\n}\n
Run Code Online (Sandbox Code Playgroud)\n只有在编写我们自己的计算密集型任务时才需要定期检查取消和让出。大多数 Apple\xe2\x80\x98s async
API(例如URLSession
等)已经为我们处理了这些问题。
无论如何,所有关于取消的讨论都回避了一个问题:如何取消先前的任务。只需将其保存Task
在与参与者隔离的视图模型的属性中,然后在开始下一个之前取消前一个。例如:
private var task: Task<Void, Error>?\n\nfunc start() {\n task?.cancel() // cancel prior one, if any\n task = Task { try await populate() }\n}\n
Run Code Online (Sandbox Code Playgroud)\n无论如何,这些模式将允许缓慢的进程不会阻塞主线程,从而产生不间断的 UI。在这里,我点击了按钮两次:
\n\n不用说,这是没有 \xe2\x80\x9ccancel 先前的 \xe2\x80\x9d 逻辑。通过这种逻辑,您可以点击多次,之前的所有一次都将被取消,并且您只会看到一个更新,从而避免了一堆冗余任务可能对系统造成过度负担。但想法是一样的,即在执行复杂任务时提供流畅的用户界面。
\n请参阅 WWDC 2021 视频Swift 并发:幕后花絮、使用 Swift Actor 保护可变状态和Swift 并发:更新示例应用程序,所有这些在尝试从 GCD 过渡到 Swift 并发时都非常有用。
\nahe*_*eze 11
22 年 6 月 10 日更新:在 WWDC 上,我向一些 Apple 工程师询问了这个问题 \xe2\x80\x94 这确实与 actor 继承有关。然而,Xcode 14 Beta 中存在一些编译器级别的更改。例如,这将在 Xcode 14 上顺利运行,但在 Xcode 13 上会滞后:
\nclass ViewModel: ObservableObject {\n @Published var items = [String]()\n\n func populate() async {\n var items = [String]()\n for i in 0 ..< 4_000_000 { /// this usually takes a couple seconds\n items.append("\\(i)")\n }\n\n /// explicitly capture `items` to avoid `Reference to captured var \'items\' in concurrently-executing code; this is an error in Swift 6`\n Task { @MainActor [items] in\n self.items = items\n }\n }\n}\n\nstruct ContentView: View {\n @StateObject var model = ViewModel()\n @State var rotation = CGFloat(0)\n\n var body: some View {\n Button {\n Task {\n\n /// *Note!* Executes on a background thread in Xcode 14.\n await self.model.populate()\n }\n } label: {\n Color.blue\n .frame(width: 300, height: 80)\n .overlay(\n Text("\\(model.items.count)")\n .foregroundColor(.white)\n )\n .rotationEffect(.degrees(rotation))\n }\n .onAppear { /// should be a continuous rotation effect\n withAnimation(.easeInOut(duration: 2).repeatForever()) {\n rotation = 90\n }\n }\n }\n}\n
Run Code Online (Sandbox Code Playgroud)\n同样,Task
继承调用它的上下文。
Task
从普通类中调用的调用将ObservableObject
在后台运行,因为该类不是主要参与者。Task
内调用的 aButton
可能会在主要参与者上运行,因为 aButton
是一个 UI 元素。不过,Xcode 14 改变了一些东西,它实际上也在后台运行......为了确保函数在后台线程上运行,独立于继承的 Actor 上下文,您可以添加nonisolated
.
nonisolated func populate() async {\n\n}\n
Run Code Online (Sandbox Code Playgroud)\n注意:可视化和优化 Swift 并发视频非常有帮助。
\n首先,你不能两全其美;要么在主线程上执行 CPU 密集型工作(并对 UI 产生负面影响),要么在另一个线程上执行该工作,但需要将 UI 更新显式分派到主线程上。
然而,你真正想问的是
(通过使用
Task
)我认为这会在后台线程上执行填充,但整个 UI 都会冻结。
当您使用 a 时,Task
您正在使用非结构化并发,并且当您Task
通过init(priority:operation)初始化 任务时...继承调用者的优先级和参与者上下文。
虽然 aTask
是异步执行的,但它使用调用者的 actor 上下文来执行此操作,在 a 的上下文中,它View
body
是主要 actor。这意味着,虽然您的任务是异步执行的,但它仍然在主线程上运行,并且该线程在处理时不可用于 UI 更新。所以你是对的,这与一切有关MainActor
。
当您将其移入时,Task
它populate
不再在MainActor
上下文中创建,因此不会在主线程上执行。
正如您所发现的,您需要使用第二种方法来避免主线程。您需要对代码执行的操作就是使用以下命令将最终更新移回主队列MainActor
:
func populate() {
Task {
var items = [String]()
for i in 0 ..< 4_000_000 {
items.append("\(i)")
}
await MainActor.run {
self.items = items
}
}
}
Run Code Online (Sandbox Code Playgroud)
您还可以Task.detached()
在正文上下文中使用来创建Task
不附加MainActor
上下文的对象。
正如其他人所提到的,这种行为的原因是Task.init
自动继承参与者上下文。您正在从按钮回调调用您的函数:
Button {
Task {
await model.populate()
}
} label: {
}
Run Code Online (Sandbox Code Playgroud)
按钮回调位于主要参与者上,因此传递给Task
初始化器的闭包也位于主要参与者上。
一种解决方案是使用分离任务:
func populate() async {
Task.detached {
// Calculation here
}
}
Run Code Online (Sandbox Code Playgroud)
虽然分离任务是非结构化的,但我想建议结构化任务,例如async let
任务:
@MainActor
class ViewModel: ObservableObject {
@Published var items = [String]()
func populate() async {
async let newItems = { () -> [String] in
var items = [String]()
for i in 0 ..< 4_000_000 {
items.append("\(i)")
}
return items
}()
items = await newItems
}
}
Run Code Online (Sandbox Code Playgroud)
populate
当您希望函数异步返回某个值时,这非常有用。这种结构化任务方法还意味着取消可以自动传播。例如,如果你想在短时间内多次点击按钮时取消计算,你可以这样做:
@MainActor
class ViewModel: ObservableObject {
@Published var items = [String]()
func populate() async {
async let newItems = { () -> [String] in
var items = [String]()
for i in 0 ..< 4_000_000 {
// Stop in the middle if cancelled
if i % 1000 == 0 && Task.isCancelled {
break
}
items.append("\(i)")
}
return items
}()
items = await newItems
}
}
struct ContentView: View {
@StateObject var model: ViewModel
@State var task: Task<Void, Never>?
init() {
_model = StateObject(wrappedValue: ViewModel())
}
var body: some View {
Button {
task?.cancel() // Cancel previous task if any
task = Task {
await model.populate()
}
} label: {
// ...
}
}
}
Run Code Online (Sandbox Code Playgroud)
此外,withTaskGroup
还创建结构化任务,您也可以避免继承参与者上下文。当您的计算有多个可以同时进行的子任务时,它会很有用。
归档时间: |
|
查看次数: |
27703 次 |
最近记录: |