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

SwiftUI无法自动观察SwiftData关系更新的原因及解决方案咨询

SwiftUI无法自动观察SwiftData关系更新的原因及解决方案咨询

我最近在做一个基于SwiftData和SwiftUI的任务列表应用,遇到了一个让我摸不着头脑的问题,想请教大家。

先给大家看一下我的SwiftData模型:

@Model class TaskList {
    @Attribute(.unique)
    var name: String
    
    // Relationships
    var parentList: TaskList?
    
    @Relationship(deleteRule: .cascade, inverse: \TaskList.parentList)
    var taskLists: [TaskList]?
        
    init(name: String, parentTaskList: TaskList? = nil) {
        self.name = name
        self.parentList = parentTaskList
        self.taskLists = []
    }
}

我写了测试用例验证双向关系的自动维护:当我给子列表设置parentList后,父列表的taskLists数组会自动添加这个子列表,删除子列表时父数组也会自动更新,测试结果完全符合预期:

@Test("TaskList with children with independent saves are in the database")
func test_savingRootTaskIndependentOfChildren_SavesAllTaskLists() async throws {
    let modelContext = TestHelperUtility.createModelContext(useInMemory: false)
    let parentList = TaskList(name: "Parent")
    
    modelContext.insert(parentList)
    try modelContext.save()
    
    let childList = TaskList(name: "Child")
    childList.parentList = parentList
    modelContext.insert(childList)
    try modelContext.save()
    
    let fetchedResults = try modelContext.fetch(FetchDescriptor<TaskList>())
    let fetchedParent = fetchedResults.first(where: { $0.name == "Parent"})
    let fetchedChild = fetchedResults.first(where: { $0.name == "Child" })
    #expect(fetchedResults.count == 2)
    #expect(fetchedParent?.taskLists.count == 1)
    #expect(fetchedChild?.parentList?.name == "Parent")
    #expect(fetchedChild?.parentList?.taskLists.count == 1)
}

初始工作版本(用OutlineGroup)

一开始我用原生的OutlineGroup来展示嵌套列表,一切正常——比如把"Finance"从"Work"移动到"Home"时,UI会实时更新。相关代码如下:

ContentView

struct ContentView: View {
    @Query(sort: \TaskList.name) var taskLists: [TaskList]
    @State private var selectedList: TaskList?
    
    var body: some View {
        NavigationStack {
            List {
                ForEach(taskLists.filter({$0.parentList == nil})) { list in
                    OutlineGroup(list, children: \.taskLists) { list in
                        Text(list.name)
                            .onTapGesture {
                                selectedList = list
                            }
                    }
                }
            }
            .sheet(item: $selectedList, onDismiss: {
                selectedList = nil
            }) { list in
                TaskListEditorScreen(existingList: list)
            }
        }
    }
}

TaskListEditorScreen

struct TaskListEditorScreen: View {
    @Environment(\.dismiss) private var dismiss
    @Environment(\.modelContext) private var modelContext
    
    @State private var viewModel: TaskListEditorViewModel
    @Bindable var list: TaskList
    
    init(existingList: TaskList) {
        list = existingList
        viewModel = TaskListEditorViewModel(taskList: existingList)
    }
    
    var body: some View {
        NavigationView {
            TaskListFormView(viewModel: viewModel)
                .toolbar {
                    ToolbarItem {
                        Button("Cancel") {
                            dismiss()
                        }
                    }
                    
                    ToolbarItem {
                        Button("Save") {
                            list.name = viewModel.name
                            list.parentList = viewModel.parentTaskList
                            try! modelContext.save()
                            
                            dismiss()
                        }
                    }
                }
        }
    }
}

其他配套视图

TaskListFormView

struct TaskListFormView: View {
    @Bindable var viewModel: TaskListEditorViewModel
    
    var body: some View {
        VStack {
            Form {
                TextField("Name", text: $viewModel.name)
                
                NavigationLink {
                    TaskListPickerScreen(viewModel: self.viewModel)
                } label: {
                    Text(self.viewModel.parentTaskList?.name ?? "Parent List")
                }
            }
        }
    }
}

TaskListPickerScreen

struct TaskListPickerScreen: View {
    @Environment(\.dismiss) private var dismiss
    @Query(filter: #Predicate { $0.parentList == nil }, sort: \TaskList.name)
    private var taskLists: [TaskList]
    
    @Bindable var viewModel: TaskListEditorViewModel
    
    var body: some View {
        List {
            ForEach(taskLists) { list in
                OutlineGroup(list, children: \.taskLists) { child in
                    getRowForChild(child)
                }
            }
        }
        .toolbar {
            ToolbarItem {
                Button("Clear Parent") {
                    viewModel.parentTaskList = nil
                    dismiss()
                }
            }
        }
    }
    
    @ViewBuilder func getRowForChild(_ list: TaskList) -> some View {
        HStack {
            Text(list.name)
        }
        .onTapGesture {
            if list.name == viewModel.name {
                return
            }
            
            self.viewModel.parentTaskList = list
            dismiss()
        }
    }
}

TaskListEditorViewModel

@Observable class TaskListEditorViewModel {
    var name: String
    var parentTaskList: TaskList?
    
    init(taskList: TaskList) {
        name = taskList.name
        parentTaskList = taskList.parentList
    }
}

数据容器与测试数据

#Preview {
    ContentView()
        .modelContext(DataContainer.preview.dataContainer.mainContext)
}

@MainActor class DataContainer {
    let schemaModels = Schema([ TaskList.self ])
    let dataConfiguration: ModelConfiguration
    
    static let shared = DataContainer()
    static let preview = DataContainer(memoryDB: true)
    
    init(memoryDB: Bool = false) {
        dataConfiguration = ModelConfiguration(isStoredInMemoryOnly: memoryDB)
    }
    
    lazy var dataContainer: ModelContainer = {
        do {
            let container = try ModelContainer(for: schemaModels)
            seedData(context: container.mainContext)
            return container
        } catch {
            fatalError("\(error.localizedDescription)")
        }
    }()
    
    func seedData(context: ModelContext) {
        let lists = try! context.fetch(FetchDescriptor<TaskList>())
        if lists.count == 0 {
            Task { @MainActor in
                SampleData.taskLists.filter({ $0.parentList == nil }).forEach {
                    context.insert($0)
                }
                
                try! context.save()
            }
        }
    }
}

struct SampleData {
    static let taskLists: [TaskList] = {
        let home = TaskList(name: "Home")
        let work = TaskList(name: "Work")
        let remodeling = TaskList(name: "Remodeling", parentTaskList: home)
        let kidsBedroom = TaskList(name: "Kids Room", parentTaskList: remodeling)
        let livingRoom = TaskList(name: "Living Room", parentTaskList: remodeling)
        let management = TaskList(name: "Management", parentTaskList: work)
        let finance = TaskList(name: "Finance", parentTaskList: work)
                
        return [home, work, remodeling, kidsBedroom, livingRoom, management, finance]
    }()
}

问题出现:自定义嵌套视图替代OutlineGroup后

因为需要自定义行布局和交互,我用自己写的嵌套视图替换了OutlineGroup,相关视图代码如下:

struct TaskListRowContentView: View {
    @Environment(\.modelContext) private var modelContext
    @Bindable var taskList: TaskList
    @State var isShowingEditor: Bool = false
    
    init(taskList: TaskList) {
        self.taskList = taskList
    }
    
    var body: some View {
        HStack {
            Text(taskList.name)
        }
        .contextMenu {
            Button("Edit") {
                isShowingEditor = true
            }
        }
        .sheet(isPresented: $isShowingEditor) {
            TaskListEditorScreen(existingList: taskList)
        }
    }
}

struct TaskListRowParentView: View {
    @Bindable var taskList: TaskList
    @State private var isExpanded: Bool = true
    
    var children: [TaskList] {
        taskList.taskLists!.sorted(by: { $0.name < $1.name })
    }
    
    var body: some View {
        DisclosureGroup(isExpanded: $isExpanded) {
            ForEach(children) { child in
                if child.taskLists!.isEmpty {
                    TaskListRowContentView(taskList: child)
                } else {
                    TaskListRowParentView(taskList: child)
                }
            }
        } label: {
            TaskListRowContentView(taskList: self.taskList)
        }
    }
}

struct TaskListRowView: View {
    @Bindable var taskList: TaskList
    
    var body: some View {
        if taskList.taskLists!.isEmpty {
            TaskListRowContentView(
                taskList: taskList)
        } else {
            TaskListRowParentView(taskList: taskList)
        }
    }
}

然后更新了ContentView的列表部分:

List {
    ForEach(taskLists.filter({$0.parentList == nil})) { list in
        TaskListRowView(taskList: list)
    }
}

这时候问题来了:当我把"Finance"从"Work"移动到"Home"时,调试能看到SwiftData已经自动更新了两个父列表的taskLists数组——Work的数组长度减1,Home的数组长度加1,重启App后UI也能显示正确的结构,但实时UI完全没反应。

直到我在保存代码里手动加了一行newParent.taskLists?.append(list),UI才会实时更新,哪怕调试时看到这个数组里已经存在"Finance"了:

ToolbarItem {
    Button("Save") {
        list.name = viewModel.name
        list.parentList = viewModel.parentTaskList
        
        // 手动添加到新父列表,不加这行UI就不更新
        if let newParent = viewModel.parentTaskList {
            newParent.taskLists?.append(list)
        }
        
        try! modelContext.save()
        
        dismiss()
    }
}

我的困惑

  1. 为什么SwiftData自动维护的数组变化(父列表的taskLists更新)没有被SwiftUI观察到?测试里明明是正常的,而且数据库也确实更新了。
  2. 为什么手动执行append(哪怕元素已经在数组里,SwiftData不会重复添加)就能触发UI更新?这一步到底做了什么让SwiftUI感知到变化?
  3. 我以后是不是必须手动维护这些双向关系的数组,才能让SwiftUI实时更新?

备注:内容来源于stack exchange,提问作者Johnathon Sullinger

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.04.15 09:48:06