SwiftUI删除SwiftData数据导致预览报错Index out of range
SwiftUI删除SwiftData数据导致预览报错Index out of range

SwiftUI删除SwiftData数据导致预览报错Index out of range

问题复现

在运行SwiftUI删除SwiftData数据时,发现Xcode删除操作会导致预览报错。

import SwiftUI

struct ContentView: View {
    @State private var currentIndex = 0
    @State private var deletePrompt = false
    
    var body: some View {
        VStack {
            TabView(selection: $currentIndex) {
                ForEach(piggyBank.indices, id: \.self) { index in
                    GeometryReader { geometry in
                        VStack {
                            // 创建视图
                        }
                    }
                    .tag(0)
                    GeometryReader { geometry in
                        VStack {
                            Text(piggyBank[index].name)
                                .font(.headline)
                                .foregroundColor(.white)
                                .padding(.top)
                            // 删除该存钱罐
                            Button(action: {
                                deletePrompt.toggle()
                            }, label: {
                                Text("Delete this piggy bank")
                            })
                        }
                    }
                    .tag(index+1)
                    .alert("Delete prompt",isPresented: $deletePrompt){
                        Button("Confirm", role: .destructive) {
                            modelContext.delete(piggyBank[index])
                            do {
                                try modelContext.save() // 提交上下文中的所有更改
                                // 更新 currentIndex,确保其在 piggyBank 范围内
                            } catch {
                                print("保存失败: \(error)")
                            }
                        }
                    } message: {
                        Text("Are you sure you want to delete this piggy bank?")
                    }
                }
            }
        }
    }
}

经过排查分析,当点击删除按钮并执行相关代码时,Xcode就会报预览报错:

Button("Confirm", role: .destructive) {
    modelContext.delete(piggyBank[index])
    do {
        try modelContext.save() // 提交上下文中的所有更改
    } catch {
        print("保存失败: \(error)")
    }
}

问题原因

在 SwiftUI 中删除数据模型时,出现 “Index out of range” 的错误通常是由于数据状态和视图同步更新问题导致的。这种情况尤其容易发生在像 ForEach 这样的列表中,因为 SwiftUI 使用数据源(这里是 piggyBank)的索引来构建和更新视图。如果删除后的数据与视图的更新节奏不同步,就会导致索引越界。

1、删除操作和视图同步问题

当删除 SwiftData 对象时,piggyBank 是通过 @Query 属性从数据库动态获取的。删除操作会更新数据模型,但 SwiftUI 在 ForEach 内部使用的视图可能尚未正确地从新数据状态中更新。

如果删除的对象恰好是当前 currentIndex 所指向的对象,且 currentIndex 没有及时更新,那么后续访问会出现索引越界问题。

2、currentIndex 不正确

在 TabView 中使用索引时,如果删除的数据使当前索引无效(例如,删除后索引超出剩余数据的范围),而索引没有及时调整,就会导致视图尝试访问不存在的对象。

3、@Query的延迟更新

@Query 是 SwiftData 提供的属性包装器,用于绑定查询结果到视图。当数据库发生更改时,@Query 会异步更新绑定的数组。

删除操作完成后,@Query 的更新可能稍有延迟,而视图可能已经尝试访问最新的 piggyBank 数据,导致状态不一致的问题。

当点击删除按钮时,currentIndex索引如果大于等于piggyBank的长度,那么currentIndex减一,因为在我的代码中currentIndex的索引是由0(创建索引)和piggyBank的索引组成的。

如果piggyBank中有两个元素,那么currentIndex最大值为2,piggyBank的长度也是2。

视图中会根据piggyBank的长度显示三个元素,当删除piggyBank的一个元素时,currentIndex的最大值就应该减小,变成1。

假设当前currentIndex为最大值2,在删除piggyBank元素后,TabView绑定的currentIndex就无法找到对应的元素,因此导致数组越界的预览报错。

Button("Confirm", role: .destructive) {
    if currentIndex >= piggyBank.count {
        currentIndex = max(0, piggyBank.count - 1)
    }
    modelContext.delete(piggyBank[index])
    print("piggyBank.count\(piggyBank.count)")
    do {
        try modelContext.save() // 提交上下文中的所有更改
    } catch {
        print("保存失败: \(error)")
    }
}

然后重点讲一下@Query的延迟更新问题。

上面的代码处理了currentIndex导致的数组越界问题,但是当使用modelContext删除piggyBank对应的对象后,print输出的count是删除前的数量。

在我的代码示例中,piggyBank的长度为3,也就意味着里面有三个元素。当我通过modelContext删除一个元素后,输出的piggyBank长度仍然是3。

piggyBank.count3

同时发生了预览报错的问题,这就是因为@Query导致的延迟更新问题,在代码中删除modelContext后,视图中的piggyBank数组没有立即更新,但实际上已经删除了一个元素,这也会导致出现数组越界的问题。

解决方案

struct ManagingView: View {
    @State private var currentIndex = 0
    @State private var deletePrompt = false
    @State private var refreshID = UUID() // 新增刷新标识符
    var body: some View {
        VStack {
            TabView(selection: $currentIndex) {
                ...
                Button("Confirm", role: .destructive) {
                    currentIndex = min(currentIndex, piggyBank.count - 1)
                    DispatchQueue.main.async {
                        modelContext.delete(piggyBank[index]) // 删除数据
                        refreshID = UUID() // 强制刷新视图
                        print("piggyBank.count:\(piggyBank.count)")
                    }
                    do {
                        try modelContext.save() // 提交上下文中的所有更改
                    } catch {
                        print("保存失败: \(error)")
                    }
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                           // 在删除后打印 piggyBank 的实际状态
                           print("删除完成后 piggyBank.count: \(piggyBank.count)")
                       }
                }
            }
            .id(refreshID) // 强制刷新
        }
        .onAppear {
            currentIndex = piggyBank.count
        }
    }
}

解决方案解析

1、提前调整 currentIndex

currentIndex = min(currentIndex, piggyBank.count - 1)

删除操作之前,currentIndex 被限制在有效范围内(0 ≤ currentIndex < piggyBank.count)。

这样可以防止用户删除的对象超出剩余数据范围。

删除后,如果当前存钱罐是最后一个,将索引重置为最后一个有效对象,避免越界访问。

2、使用UUID()强制刷新TabView

@State private var refreshID = UUID() // 新增刷新标识符
TabView(selection: $currentIndex) {
    DispatchQueue.main.async {
        modelContext.delete(piggyBank[index]) // 删除数据
        refreshID = UUID() // 强制刷新视图
        print("piggyBank.count:\(piggyBank.count)")
    }
}
.id(refreshID) // 强制刷新

在SwiftUI视图中创建一个@State变量,每个视图都有一个唯一的标识符,用于区分不同的视图。当标识符发生变化时,SwiftUI 会认为这是一个新的视图实例,从而强制销毁旧的视图并重新创建一个新的视图。这种机制可以用来强制刷新视图的状态。

这里使用.id(refreshID) 是为 TabView 指定了一个标识符。

当 refreshID 改变时,TabView 会被重新创建并完全刷新其内容。

通过刷新UUID,SwiftUI会销毁旧的TabView,重新加载视图,避免因 @Query 的异步更新导致视图与数据不一致。

3、延迟执行 modelContext.delete

DispatchQueue.main.async {
    modelContext.delete(piggyBank[index])
    refreshID = UUID()
}

使用 DispatchQueue.main.async 将删除操作推迟到下一个运行循环,确保 UI 和数据模型的状态在删除操作时是同步的。

4、检查@Query数据状态

DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
    print("删除完成后 piggyBank.count: \(piggyBank.count)")
}

通过设置DispatchQueue.main.asyncAfter,延迟 0.1 秒执行状态检查,给 SwiftData 的 @Query 机制足够的时间同步数据。

通过检查 piggyBank.isEmpty 的状态,重置 currentIndex,确保视图在存钱罐完全被清空时正确更新。

在Xcode预览中,可以看到延迟的piggyBank.count与实际的piggyBank的长度保持一致,这也进一步验证了@Query的延迟更新问题。

piggyBank.count:3
删除完成后 piggyBank.count: 2
piggyBank.count:2
删除完成后 piggyBank.count: 1
piggyBank.count:1
删除完成后 piggyBank.count: 0

数组索引问题的根本原因

数组索引问题主要与以下两个方面有关:

1、删除后数组大小发生变化

当删除存钱罐后,数组的大小 piggyBank.count 减少。

如果在删除之前没有调整 currentIndex,currentIndex 可能指向被删除的元素或超出数组范围的索引。

访问超出范围的数组元素会导致运行时崩溃。

解决方法

在删除之前,确保 currentIndex 不大于 piggyBank.count – 1。

删除后,检查是否需要进一步调整 currentIndex。

2、@Query 的延迟更新

@Query 是 SwiftData 提供的属性包装器,用于绑定查询结果到视图。当数据库发生更改时,@Query 会异步更新绑定的数组。

删除操作完成后,@Query 的更新可能稍有延迟,而视图可能已经尝试访问最新的 piggyBank 数据,导致状态不一致的问题。

解决方法

使用 DispatchQueue.main.async 和 DispatchQueue.main.asyncAfter 来等待 @Query 更新完成。

强制刷新视图(refreshID = UUID())以解决视图状态可能滞后的问题。

具体问题分析

删除操作中的索引调整

在删除时,currentIndex 需要保持在 piggyBank.count – 1 范围内,这是因为 TabView 和数组必须同步。如果删除的对象位于 currentIndex 指向的位置,但没有及时调整索引,TabView 会尝试访问无效的对象。

视图和数据模型的同步

删除对象后,piggyBank 的数据变化是异步的,而 SwiftUI 的视图是立即更新的。如果在 piggyBank 更新完成之前访问其状态,可能会出现不一致问题。

DispatchQueue.main.async 的作用

通过将删除操作包裹在 DispatchQueue.main.async 中,可以:

1、等待 UI 和数据模型状态同步。

2、确保删除完成时视图已经正确刷新,避免越界访问。

总结

数组索引问题主要来源于 删除引起的大小变化视图与数据模型的异步更新,而解决的关键是 索引范围调整延迟更新机制 的合理利用。

解决方案

1、提前调整 currentIndex 避免越界。

2、删除操作和视图刷新通过异步调度分离,确保 @Query 和 TabView 的同步。

最后,实现SwiftData数据删除而不引起数组越界的问题。

完整代码

//
//  ManagingView.swift
//  piglet
//
//  Created by 方君宇 on 2025/1/15.
//

import SwiftUI
import SwiftData

struct ManagingView: View {
    @Environment(\.modelContext) var modelContext
    @Environment(\.colorScheme) var colorScheme
    @Environment(\.dismiss) var dismiss
    @Query var piggyBank: [PiggyBank]
    @State private var currentIndex = 0
    @State private var deletePrompt = false
    @State private var refreshID = UUID() // 新增刷新标识符
    
    // 动态调整存钱罐图标大小
    private func getSize(for index: Int, geometry: GeometryProxy) -> CGFloat {
        let screenWidth = UIScreen.main.bounds.width
        let distance = abs(screenWidth / 2 - geometry.frame(in: .global).midX)
        let scaleFactor: CGFloat = 1.5 // 控制缩放程度
        let maxSize: CGFloat = 60
        let minSize: CGFloat = 30
        
        let size = max(maxSize - (distance / screenWidth * scaleFactor * maxSize), minSize)
        return size
    }
    
    // 动态调整存钱罐图标大小
    private func getCreateSize(geometry: GeometryProxy) -> CGFloat {
        let screenWidth = UIScreen.main.bounds.width
        let distance = abs(screenWidth / 2 - geometry.frame(in: .global).midX)
        let scaleFactor: CGFloat = 1.5 // 控制缩放程度
        let maxSize: CGFloat = 60
        let minSize: CGFloat = 30
        
        let size = max(maxSize - (distance / screenWidth * scaleFactor * maxSize), minSize)
        return size
    }
    
    var body: some View {
        VStack {
            TabView(selection: $currentIndex) {
                GeometryReader { geometry in
                    VStack {
                        Spacer()
                        Button(action: {
                            
                        }, label: {
                            Image(systemName: "plus.app.fill") // 替换为存钱罐图标
                                .resizable()
                                .scaledToFit()
                                .frame(width: getCreateSize( geometry: geometry), height: getCreateSize(geometry: geometry))
                                .foregroundColor(currentIndex == 0 ? .white : .gray)
                        })
                        Text("Create")
                            .font(.headline)
                            .foregroundColor(currentIndex == 0 ? .white : .gray)
                            .padding(.top)
                        Spacer()
                            .frame(height: 40)
                    }
                    .frame(maxWidth: .infinity,maxHeight: .infinity)
                    .animation(.easeInOut, value: currentIndex)
                }
                .tag(0)
                ForEach(piggyBank.indices, id: \.self) { index in
                    GeometryReader { geometry in
                        VStack {
                            Spacer()
                            Image(systemName: "\(piggyBank[index].icon)") // 替换为存钱罐图标
                                .resizable()
                                .scaledToFit()
                                .frame(width: getSize(for: index, geometry: geometry), height: getSize(for: index, geometry: geometry))
                                .foregroundColor(.white)
                            Text(piggyBank[index].name)
                                .font(.headline)
                                .foregroundColor(.white)
                                .padding(.top)
                            // 设为主存钱罐
                            Group {
                                Button(action: {
                                    guard !piggyBank[index].isPrimary else {
                                        return
                                    }
                                    // Step 1: 查询所有存钱罐
                                    let fetchRequest = FetchDescriptor<PiggyBank>()
                                    let existingPiggyBanks = try? modelContext.fetch(fetchRequest)
                                    
                                    // Step 2: 将所有存钱罐的 isPrimary 设置为 false
                                    existingPiggyBanks?.forEach { bank in
                                        bank.isPrimary = false
                                    }
                                    
                                    // Step 3: 当点击的存钱罐设置为true
                                    withAnimation {
                                        piggyBank[index].isPrimary = true
                                    }
                                }, label: {
                                    Text(piggyBank[index].isPrimary ? "Main piggy bank" : "Set as primary piggy bank")
                                })
                                // 删除该存钱罐
                                Button(action: {
                                    deletePrompt.toggle()
                                }, label: {
                                    Text("Delete this piggy bank")
                                })
                            }
                            .font(.system(size: getSize(for: index, geometry: geometry) / 4))
                            .padding(.vertical,10)
                            .padding(.horizontal,20)
                            .fontWeight(.bold)
                            .foregroundColor(Color(hex: "ec5a29"))
                            .background(.white)
                            .cornerRadius(10)
                            
                        }
                        .frame(maxWidth: .infinity)
                        .animation(.easeInOut, value: currentIndex)
                    }
                    .tag(index+1)
                    .alert("Delete prompt",isPresented: $deletePrompt){
                        Button("Confirm", role: .destructive) {
                            currentIndex = min(currentIndex, piggyBank.count - 1)
                            DispatchQueue.main.async {
                                modelContext.delete(piggyBank[index]) // 删除数据
                                refreshID = UUID() // 强制刷新视图
                                print("piggyBank.count:\(piggyBank.count)")
                            }
                            do {
                                try modelContext.save() // 提交上下文中的所有更改
                            } catch {
                                print("保存失败: \(error)")
                            }
                            DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                                   // 在删除后打印 piggyBank 的实际状态
                                   print("删除完成后 piggyBank.count: \(piggyBank.count)")
                               }
                        }

                    } message: {
                        Text("Are you sure you want to delete this piggy bank?")
                    }
                }
            }
            .id(refreshID) // 强制刷新
            .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
            .frame(height: 250)
            .padding()
        }
        .background(
            Image("switchPiggyBank")
                .resizable()
        )
        .onAppear {
            currentIndex = piggyBank.count
        }
        
    }
}

#Preview {
    ManagingView()
        .modelContainer(PiggyBank.preview)
}

如果您认为这篇文章给您带来了帮助,您可以在此通过支付宝或者微信打赏网站开放者。

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注