MVVM架构下数组增删触发数组越界致命错误的解决请求
问题场景
在SwiftUI中采用MVVM架构,通过绑定Word结构体到子视图,用TextField展示数组元素。点击按钮会在数组头部插入空白Word元素,通过ForEach渲染成子视图。当空白TextField失去焦点时,调用父视图的删除函数移除该元素,此时触发「Fatal error: Index out of range」错误。调试确认元素已被移除,但错误来自后续ForEach遍历数组的过程。
完整代码
父视图(CardView相关代码)
struct Word: Identifiable, Equatable { var id: String var word: String var definition: String? init(_ word: String, definition: String?) { self.word = word self.definition = definition self.id = UUID().uuidString } static func ==(lhs: Word, rhs: Word) -> Bool { return lhs.id == rhs.id } } class CardModel: ObservableObject { var word: Binding<Word> @Published var loading: Bool = false init(word: Binding<Word>) { self.word = word } } enum Focusable: Hashable { case none case row(id: String) } struct CardView: View { @ObservedObject var model: CardModel @FocusState var focused: Focusable? let onEmpty: () -> Void // 删除当前Word元素 var body: some View { VStack(spacing: 0) { HStack { TextField("", text: model.word.word) .font(.title) .focused($focused, equals: .row(id: model.word.id)) Spacer() } if model.loading { Text("Loading...") .frame(maxWidth: .infinity, alignment: .leading) } if let definition = model.word.definition.wrappedValue { Text(definition) .frame(maxWidth: .infinity, alignment: .leading) } } .onChange(of: focused) { oldValue, newValue in // 当当前元素失去焦点且内容为空时,触发删除 if oldValue == .row(id: model.word.wrappedValue.id) && model.word.wrappedValue.word.isEmpty { onEmpty() } } } }
子视图(StudyView相关代码)
import SwiftUI class StudyModel: ObservableObject { @Published var words: [Word] = [ Word("sonder", definition: "The realization that each passerby has a life as vivid as your own."), // 其他示例元素... ] } struct StudyView: View { @Namespace private var namespace @State private var cardsHeight: CGFloat = 0 @FocusState var focusedReminder: Focusable? @StateObject var model: StudyModel = StudyModel() var body: some View { VStack(spacing: 20) { HStack { Image(systemName: "gear") Spacer() Button { addWordCard() } label: { Image(systemName: "plus.circle") } .tint(.black) } .font(.title) ScrollView { VStack(spacing: 20) { ForEach($model.words) { $word in CardView(model: CardModel(word: $word), focused: _focusedReminder) { deleteWord(word) } } } } } } } // 辅助方法 extension StudyView { private func addWordCard() { withAnimation(.spring) { let newWord = Word("", definition: nil) model.words.insert(newWord, at: 0) focusedReminder = .row(id: newWord.id) } } private func deleteWord(_ word: Word) { withAnimation(.spring) { model.words.removeAll { $0.id == word.id } } } }
问题原因
触发错误的核心原因是:在onChange(of: focused)的回调中直接修改数据源数组,此时SwiftUI的视图更新流程还未完成,ForEach仍在依赖旧的数组索引进行渲染。当元素被移除后,后续遍历会访问已失效的索引,导致越界。
修复方案
方案1:延迟执行删除操作
通过DispatchQueue.main.async将删除操作推迟到当前视图更新周期结束后执行,让SwiftUI先完成焦点变化的视图更新,再处理数组修改:
修改CardView中的onChange回调:
.onChange(of: focused) { oldValue, newValue in if oldValue == .row(id: model.word.wrappedValue.id) && model.word.wrappedValue.word.isEmpty { // 延迟执行删除,避开当前视图更新周期 DispatchQueue.main.async { onEmpty() } } }
方案2:优化焦点管理逻辑
在删除元素前先清空焦点,避免SwiftUI后续尝试访问已被移除元素的焦点状态,配合方案1使用效果更佳:
修改CardView的onChange回调:
.onChange(of: focused) { oldValue, newValue in if oldValue == .row(id: model.word.wrappedValue.id) && model.word.wrappedValue.word.isEmpty { focused = .none // 先清空焦点 DispatchQueue.main.async { onEmpty() } } }
方案3:改用基于ID的绑定遍历
避免直接使用$model.words的绑定遍历,改用基于id的ForEach,通过Binding的下标获取单个元素的绑定,减少视图与数据源的强耦合:
修改StudyView中的ForEach部分:
ForEach(model.words) { word in if let index = model.words.firstIndex(where: { $0.id == word.id }) { CardView(model: CardModel(word: $model.words[index]), focused: _focusedReminder) { deleteWord(word) } } }
最终推荐方案
优先选择方案1 + 方案2的组合:既通过延迟执行避开当前更新周期,又提前清空焦点避免无效状态,同时保留原有绑定逻辑的简洁性,是最稳妥的修复方式。
内容的提问来源于stack exchange,提问作者Samuel B.

