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

SwiftUI中如何让枚举驱动的可选列表兼容CloudKit?

问题背景

我正在开发一款基于SwiftUI的读书俱乐部应用,使用NavigationSplitView实现布局:

  • 左侧列表(leading列)是可选List:第一项是"Book",选中后跳转至书籍PDF;
  • 下方有带加号图标的按钮,点击可添加读者,新增的读者名称会显示在按钮下方的列表中,选中读者可进入其个人主页;
  • 要求"Book"和读者列表整合为一个可选List,选中项高亮且自动取消前一项的高亮。

接入CloudKit后出现两个问题:

  1. 偶尔添加读者时触发致命错误:

Fatal error: Duplicate keys of type 'Reader' were found in a Dictionary. This usually means either that the type violates Hashable's requirements, or that member of such a dictionary were mutated after insertion.

  1. 新增的读者无法通过CloudKit在其他设备同步(其他页面同步功能正常)。

移除enum ListItem和顶部的"Book"项,仅用读者列表时一切功能正常,推测问题出在枚举整合列表的实现上。


相关代码

视图与枚举实现

import SwiftUI
import SwiftData

enum ListItem: Hashable
{
    case book
    case reader(Reader)
}

struct ProjectView: View
{
    private let project: Project
    
    @Environment(\.modelContext) private var modelContext
    
    @Query private var readers: [Reader]
    
    @State private var alertPresented = false
    @State private var userInput = ""
    @State private var selectedListItem: ListItem?
    
    init(project: Project)
    {
        self.project = project
    }
    
    var body: some View
    {
        NavigationSplitView
        {
            List(selection: self.$selectedListItem)
            {
                Text("Book").tag(ListItem.book)
                
                Divider()
                
                Button(action:
                {
                    self.alertPresented = true
                },
                label:
                {
                    Label("", systemImage: "plus")
                })
                .buttonStyle(PlainButtonStyle())
                
                .alert("Reader Name:", isPresented: self.$alertPresented)
                {
                    TextField("", text: self.$userInput)
                    
                    Button("OK")
                    {
                        let reader = Reader(name: self.userInput)
                        self.modelContext.insert(reader)
                        self.project.readers?.append(reader)
                        self.selectedListItem = ListItem.reader(reader)
                        self.userInput = ""
                    }
                    .keyboardShortcut(.defaultAction)
                    
                    Button("Cancel", role: .cancel) { }
                }
                                    
                ForEach (self.readers , id: \.self)
                {
                    reader in Text(reader.name).tag(ListItem.reader(reader))
                }
            }
            .onAppear
            {
                self.selectedListItem = ListItem.book
            }
        }
        detail:
        {
            Text("Detail")
        }
    }
}

模型定义

import Foundation
import SwiftData

@Model
class Project
{
    var name: String = ""
    @Relationship(deleteRule: .cascade, inverse: \Reader.projects) var readers : [Reader]?
    
    init(name: String)
    {
        self.name = name
        self.readers = []
    }
}

@Model
class Reader
{
    var name: String = ""
    
    var projects: [Project]?
    
    init(name: String)
    {
        self.name = name
    }
}

问题分析与解决方案

问题根源

  1. Hashable不稳定:SwiftData的@Model类默认Hashable实现依赖对象标识符,CloudKit同步时对象会经历临时ID到最终ID的切换,导致同一个Reader实例的哈希值变化;ListItem.reader(Reader)的哈希依赖Reader的哈希,进而触发列表字典键重复错误。
  2. 查询范围未限定:当前@Query会加载所有Reader实例,同步时其他项目的读者也会被混入列表,同时新增读者后无法精准触发当前项目的查询更新。
  3. 可选关系数组隐患:readers和projects为可选数组,nil处理逻辑容易导致同步时的关系绑定异常。

修复方案

1. 重构枚举的Hashable实现

改用SwiftData对象的PersistentIdentifier作为枚举关联值,保证哈希稳定:

enum ListItem: Hashable
{
    case book
    case reader(PersistentIdentifier)
    
    // 便捷初始化器,快速从Reader创建枚举值
    init(_ reader: Reader) {
        self = .reader(reader.persistentModelID)
    }
}

2. 限定查询范围并优化视图逻辑

修改ProjectView的查询条件,只加载当前项目关联的读者,同时调整列表选中逻辑:

struct ProjectView: View
{
    private let project: Project
    
    @Environment(\.modelContext) private var modelContext
    
    // 仅查询当前项目关联的读者
    @Query private var readers: [Reader]
    
    @State private var alertPresented = false
    @State private var userInput = ""
    @State private var selectedListItem: ListItem?
    
    init(project: Project)
    {
        self.project = project
        // 构建查询谓词,过滤当前项目的读者
        let predicate = #Predicate<Reader> { $0.projects.contains(project) }
        self._readers = Query(filter: predicate, sort: [SortDescriptor(\Reader.name)])
    }
    
    var body: some View
    {
        NavigationSplitView
        {
            List(selection: $selectedListItem)
            {
                Text("Book")
                    .tag(ListItem.book)
                
                Divider()
                
                Button(action: { alertPresented = true }) {
                    Label("", systemImage: "plus")
                }
                .buttonStyle(PlainButtonStyle())
                .alert("Reader Name:", isPresented: $alertPresented) {
                    TextField("", text: $userInput)
                    
                    Button("OK") {
                        let reader = Reader(name: userInput)
                        modelContext.insert(reader)
                        project.readers.append(reader)
                        selectedListItem = ListItem(reader)
                        userInput = ""
                    }
                    .keyboardShortcut(.defaultAction)
                    
                    Button("Cancel", role: .cancel) { }
                }
                
                ForEach(readers) { reader in
                    Text(reader.name)
                        .tag(ListItem(reader))
                }
            }
            .onAppear {
                selectedListItem = .book
            }
        } detail: {
            // 根据选中项展示对应详情
            if let selected = selectedListItem {
                switch selected {
                case .book:
                    Text("书籍PDF页面") // 替换为实际PDF视图
                case .reader(let id):
                    if let reader = try? modelContext.fetch(FetchDescriptor<Reader>(predicate: #Predicate { $0.persistentModelID == id })).first {
                        Text("读者主页:\(reader.name)") // 替换为实际读者主页视图
                    } else {
                        Text("读者不存在")
                    }
                }
            } else {
                Text("请选择一项")
            }
        }
    }
}

3. 优化模型关系定义

将可选数组改为非可选,减少nil处理的潜在问题:

@Model
class Project
{
    var name: String = ""
    @Relationship(deleteRule: .cascade, inverse: \Reader.projects) var readers: [Reader] = []
    
    init(name: String)
    {
        self.name = name
    }
}

@Model
class Reader
{
    var name: String = ""
    @Relationship(inverse: \Project.readers) var projects: [Project] = []
    
    init(name: String)
    {
        self.name = name
    }
}

修复说明

  • 使用PersistentIdentifier作为枚举关联值,彻底避免CloudKit同步时ID变化导致的哈希冲突;
  • 限定查询范围后,列表仅展示当前项目的读者,同步逻辑精准,不会混入其他项目的读者;
  • 非可选关系数组让SwiftData的同步绑定更清晰,减少nil引发的同步异常;
  • 详情页通过PersistentIdentifier重新查询读者,避免持有过时的实例引用。

内容的提问来源于stack exchange,提问作者Ser Pounce

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.19 18:16:01