SwiftUI 性能非常糟糕,许多矩形要着色

SwuftUI performance very bad with many Rectangles to be colored

我创建了一个像像素一样的矩形 LazyVGrid。我想延迟为部分或全部着色,以便在填充期间执行模拟动画,但性能非常糟糕,我认为每次更新都会刷新所有矩形。

行为

代码

struct Pixel: Identifiable, Hashable {
    var id: Int
    var isColored: Bool
}


class Model: ObservableObject {
    
    @Published var pixels: [Pixel]
    
    init(totalPixels: Int) {
        pixels = (1...totalPixels).map{ Pixel(id: [=10=], isColored: false)}
    }
    
    func pixelsRange(num:Int, clusterDimension:Int) -> [Pixel]{
        return Array(pixels[(num-1)*clusterDimension..<clusterDimension*num])
    }

    func startFillingAllAnimated() {
        for idx in pixels.indices {
            let addTime = idx
            DispatchQueue.main.asyncAfter(deadline: .now() + Double(addTime) * 0.1) {
                self.pixels[idx].isColored = true
            }
        }
    }
}
struct TotalView: View {
    
    static var totalPixels = 1280
    
    @StateObject var model = Model(totalPixels: totalPixels)
    
    var clusterDimension = 16
    
    static let bigSpacing:CGFloat = 2
    
    let bigColumns = [
        GridItem(.flexible(), spacing: bigSpacing),
        GridItem(.flexible(), spacing: bigSpacing),
        GridItem(.flexible(), spacing: bigSpacing),
        GridItem(.flexible(), spacing: bigSpacing),
        GridItem(.flexible(), spacing: bigSpacing),
        GridItem(.flexible(), spacing: bigSpacing),
        GridItem(.flexible(), spacing: bigSpacing),
        
        GridItem(.flexible(), spacing: bigSpacing)
    ]
    
    @State var numToBeColored: Int = 8
    
    var body: some View {
        
        VStack {

            Button("start") {
                model.startFillingAllAnimated()
            }
            ScrollView {
                LazyVGrid(columns: bigColumns, alignment: .center, spacing: 2){
                    ForEach(0..<TotalView.totalPixels/clusterDimension, id: \.self) { num in
                        ClusterView(pixels: $model.pixels, clusterNumber: num, clusterDimension: clusterDimension, color: .red)
                    }
                }
            }
            .padding(.horizontal, 4)
        }
        
    }
}





struct ClusterView: View {
    
    
    @Binding var pixels: [Pixel]
    let clusterNumber: Int
    let clusterDimension: Int
    let color: Color
    
    static let spacing:CGFloat = 2
    static let boxDimension:CGFloat = 9

    let columns = [
        GridItem(.fixed(boxDimension), spacing: spacing),
        GridItem(.fixed(boxDimension), spacing: spacing),
        GridItem(.fixed(boxDimension), spacing: spacing),
        GridItem(.fixed(boxDimension), spacing: spacing)
    ]

    var body: some View {
  
        LazyVGrid(columns: columns, alignment: .center, spacing: ClusterView.spacing) {
            ForEach(pixels[clusterNumber*clusterDimension..<clusterDimension*(clusterNumber+1)], id: \.self) { pixel in
                Rectangle()
                    .aspectRatio(1.0, contentMode: .fit)
                    .border(color)
                    .foregroundColor(pixel.isColored ? color:.clear)
            }
        }
        
    }
}

struct TotalView_Previews: PreviewProvider {
    static var previews: some View {
        TotalView()
    }
}

尝试用这个改变你的方法:

func startFillingAllAnimated() {
    DispatchQueue.global().async {
        for idx in self.pixels.indices {
            Thread.sleep(forTimeInterval: 0.03)
            DispatchQueue.main.async {
                self.pixels[idx].isColored = true
            }
        }
    }
}

部分问题是这段代码:

func startFillingAllAnimated() {
    
    for idx in pixels.indices {
        let addTime = idx
        DispatchQueue.main.asyncAfter(deadline: .now() + Double(addTime) * 0.1) {
            self.pixels[idx].isColored = true
        }
    }
}

在一个非常紧凑的循环中运行,几乎在瞬间完全执行。

您可以通过在末尾添加 print() 语句来确认:

func startFillingAllAnimated() {
    
    for idx in pixels.indices {
        let addTime = idx
        DispatchQueue.main.asyncAfter(deadline: .now() + Double(addTime) * 0.1) {
            self.pixels[idx].isColored = true
        }
    }

    print("returning")
}

在第一个网格方块变为已填充之前,您将在调试控制台中看到“正在返回”。

因此,所有对 .asycAfter 的调用都已排队,UI 更新被“阻塞”。

您可能想尝试这种方法...

我们将创建一个计时器,每次计时器重复时填充数组中的下一个网格方块。这样我们一次只在一个方块上设置 .isColored,而且,作为一个额外的好处,它为我们提供了一种在“网格填充”之前 停止 过程的方法”已完成(例如添加一个“停止”按钮与“开始”按钮一起使用:

class Model: ObservableObject {
    
    @Published var pixels: [Pixel]
    
    @Published var myTimer: Timer? = nil

    init(totalPixels: Int) {
        pixels = (1...totalPixels).map{ Pixel(id: [=12=], isColored: false)}
    }
    
    func pixelsRange(num:Int, clusterDimension:Int) -> [Pixel]{
        return Array(pixels[(num-1)*clusterDimension..<clusterDimension*num])
    }
    
    func startFillingAllAnimated() {
        // local index var
        var idx: Int = 0
        // create and start a Timer
        myTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in
            // if we've reached the end of the array, Stop the Timer
            if idx == self.pixels.count {
                timer.invalidate()
                return
            }
            self.pixels[idx].isColored = true
            idx += 1
        }
    }

    // call this if we want to Stop the Timer
    //  before the grid has been completely filled
    func stopFilling() {
        if let t = myTimer {
            t.invalidate()
        }
    }
    
}

编辑

如果以这种方式使用定时器太慢,您可以尝试这种方法。

为循环使用后台线程,具有(非常小的).sleep 延迟,可以使其更快。

这是您的 Model class 的另一个版本。我加入了“开始/停止/恢复”按钮,这样填充就可以被中断,然后要么从头开始重新开始,要么从点击停止按钮时的位置恢复:

    Button("start") {
        model.startFillingAllAnimated()
    }
    Button("stop") {
        model.stopFilling()
    }
    Button("resume") {
        model.resumeFilling()
    }

并且 Model class 变为:

class Model: ObservableObject {
    
    @Published var pixels: [Pixel]
    
    // so we can interrupt the filling loop
    @Published var keepRunning: Bool = false
    
    init(totalPixels: Int) {
        pixels = (1...totalPixels).map{ Pixel(id: [=14=], isColored: false)}
    }
    
    func pixelsRange(num:Int, clusterDimension:Int) -> [Pixel]{
        return Array(pixels[(num-1)*clusterDimension..<clusterDimension*num])
    }
    
    func startFillingAllAnimated() {
        // if "start" button tapped,
        //  "un-color" all the pixels
        for idx in self.pixels.indices {
            self.pixels[idx].isColored = false
        }
        // start filling them
        resumeFilling()
    }
    
    func resumeFilling() {
        // if ALL pixels are already "colored" don't do anything (just return)
        guard let i = pixels.firstIndex(where: {[=14=].isColored == false}) else { return }
        // set the running flag
        keepRunning = true
        DispatchQueue.global().async {
            // start at the first non-colored pixel
            for idx in i..<self.pixels.count {
                // insert a slight delay
                //  based on quick testing...
                //  0.0020 will take about 3 seconds to fill them all
                //  0.0010 will take about 1.5 seconds to fill them all
                //  0.0002 will take about 0.3 seconds to fill them all
                //  anything shorter pretty much fills them all instantly
                //  so, you probably want somewhere between
                Thread.sleep(forTimeInterval: 0.0010)
                DispatchQueue.main.async {
                    self.pixels[idx].isColored = true
                }
                // if keepRunning was set to false, break out of the loop
                if !self.keepRunning {
                    break
                }
            }
        }
    }

    // call this if we want to Stop
    //  before the grid has been completely filled
    func stopFilling() {
        self.keepRunning = false
    }
    
}