问题复现
在运行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)
}