ForEach 和 @State 数组可能存在并发问题

问题描述 投票:0回答:2

我在下面添加了屏幕录制,因此问题更加清晰。我对当前的答案不满意,因为我不明白为什么绑定(异步)在处理数组时更新不同,而在处理单个结构时更新更可靠(即使它们是相同类型)

--

在更新 @State 变量之前,我正在使用

try await Task.sleep(for: .seconds(1))
在 iOS 上编写一个基本的倒计时器。当变量
MyStruct
时,它似乎更新得很好,但如果我在数组中放置一些
MyStruct
-s,那么我会得到奇怪的更新时间,从而破坏了基本计时器倒计时的功能。

这是我的孤立示例 - 您可能需要 刷新应用程序几次或用 Button 替换我的 onAppear 才能查看此行为有多不一致。

import SwiftUI

struct ContentView: View {
    @State var first = MyStruct(setTime: 15)
    @State var second = MyStruct(setTime: 10)
    @State var third = MyStruct(setTime: 5)
    
    @State var array = [MyStruct(setTime: 15), MyStruct(setTime: 10), MyStruct(setTime: 5)]
    
    @State var hasNotAppearedYet = true
    
    var body: some View {
        VStack {
            Text("Seconds: \(Int(array[0].currentTime))").padding().font(.title3)
            Text("Seconds: \(Int(array[1].currentTime))").padding().font(.title3)
            Text("Seconds: \(Int(array[2].currentTime))").padding().font(.title3)
            
            Divider()
            
            Text("Seconds: \(first.currentTime)").padding().font(.title3)
            Text("Seconds: \(second.currentTime)").padding().font(.title3)
            Text("Seconds: \(third.currentTime)").padding().font(.title3)
        }
        .padding()
        .onAppear(){
            if(hasNotAppearedYet){
                $array[0].startTimer(name: "arrayElement0")
                $array[1].startTimer(name: "arrayElement1")
                $array[2].startTimer(name: "arrayElement2")
                
                $first.startTimer(name: "FIRST")
                $second.startTimer(name: "SECOND")
                $third.startTimer(name: "THIRD")
                
                hasNotAppearedYet = false
            }
        }
    }
}

struct MyStruct {
    var setTime: Double
    var currentTime: Double = 0
}

extension Binding<MyStruct> {
    func startTimer(name: String){
        Task {
            wrappedValue.currentTime = wrappedValue.setTime
            print(name, wrappedValue.currentTime)
            
            while (wrappedValue.currentTime > 0) {
                try await Task.sleep(for: .seconds(1))
                try Task.checkCancellation()
                wrappedValue.currentTime -= 1
                print(name, wrappedValue.currentTime)
            }
        }
    }
}

#Preview {
    ContentView()
}

模拟器和真实 iPhone 上的奇怪结果相同。

ios swift swiftui concurrency
2个回答
2
投票

我认为你所看到的是

async await
的自然行为,“演员”决定做什么以及何时做。

在本例中,演员需要兼顾计时器和 UI 更新。

您可以采取一些措施来稳定

while

  1. 去掉

    Binding
    extension
    ,因为
    Binding
    不是
    Sendable
    ,因此在
    async/await
    内使用不安全。您可以打开严格并发来查看警告。
    Binding
    本身就会引入数据竞争。

  2. 使用

    .task
    代替
    Task
    ,这样你就可以抓住并稳定“计时器”。

  3. 设置

    task
    的优先级,它不会是完美的第二个,但应该会好得多。
    await
    是一秒,但还有其他代码行,因此真实时间可能是 1 秒以上。

    import SwiftUI

    struct TimerTestView: View {
        @State var first = MyStruct(setTime: 15)
        @State var second = MyStruct(setTime: 10)
        @State var third = MyStruct(setTime: 5)
        
        @State var array = [MyStruct(setTime: 15), MyStruct(setTime: 10), MyStruct(setTime: 5)]
        
        @State var hasNotAppearedYet = true
        @State private var date: Date = Calendar.current.date(byAdding: .second, value: 16, to: .init())!
        
        var body: some View {
            VStack { 
                Text(date, style: .timer) //Added an Apple timer to compare
                HStack { //Changed UI so I can see things side by side 
                    MyStructView(item: $array[0])
                    MyStructView(item: $first)
                }
                HStack {
                    MyStructView(item: $array[1])
                    MyStructView(item: $second)
                }
                HStack {
                    MyStructView(item: $array[2])
                    MyStructView(item: $third)
                }
            }
            .padding()
            
        }
    }

    struct MyStruct: Identifiable, Sendable {
        let id: UUID = .init()
        var setTime: Double
        var currentTime: Double = 0
    }


    #Preview {
        TimerTestView()
    }

    @MainActor
    struct MyStructView: View {
        @Binding var item: MyStruct
        var body: some View {
            Text("Seconds:\n \(item.currentTime)").padding().font(.title3)
            // Use the id to stabilize the task
                .task(id: item.id, priority: .userInitiated) { //Set the priority & hold on to the Task
                    do {
                        try await startTimer(name: item.id.uuidString)
                    } catch {
                        print(error)
                    }
                }
        }
        func startTimer(name: String) async throws { 
            let start = Date()
            item.currentTime = item.setTime
            print(name, item.currentTime)
            
            while (item.currentTime > 0) {
                try await Task.sleep(for: .seconds(1))
                try Task.checkCancellation()
                item.currentTime -= 1
                print(name, item.currentTime)
            }
        }
    }

1
投票

虽然此代码中存在很多问题,但根本问题是您有多个线程同时更新同一数组中的各个值类型实例。

具体来说,

startTimer
是一个非隔离的
async
函数,它(凭借SE-0338)不在主线程上运行。因此,您有多个线程更新同一数组中的各个值,但没有足够的同步。很难具体说明所表现的行为(因为它依赖于许多 Foundation 实现细节),但同时从不同线程改变同一数组中的多个值是有问题的,这一点也不奇怪。

FWIW,将

startTimer
隔离到全局参与者可以解决眼前的问题。在这个简单的示例中,当您更新
View
使用的属性时,我会将其与主要参与者隔离。

但这不是全部答案:至少,我建议将“严格并发检查”构建设置更改为“完成”,并检查/解决它提出的所有警告。

© www.soinside.com 2019 - 2024. All rights reserved.