You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

MVVM架构下数组增删触发数组越界致命错误的解决请求

SwiftUI 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.

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.15 06:55:05