SwiftData 模型对象的多个实例在 SwiftUI 中不能及时同步的解决

231 阅读6分钟

在这里插入图片描述

概览

我们已经知道,用 CoreData 在背后默默支持的 SwiftUI 视图在使用 @FetchRequest 来查询托管对象集合时,若查询结果中的托管对象在别处被改变将不会在 FetchedResults 中得到及时的刷新。

在这里插入图片描述

那么这一“囧境”在 SwiftData 里是否也会“卷土重来”呢?空说无益,就让我们在这里来一场钩深索隐、推本溯源的探究之旅吧。

在本篇博文中,您将学到如下内容:

  1. CoreData 托管对象多个实例的同步问题
  2. SwiftData 是否会重蹈覆辙?
  3. SwiftData 超简洁的解决方案

相信学完本课后,小伙伴们一定会惊叹在 SwiftData 模型对象多个实例间的同步竟如此之简单,简直不可思议!

无需等待,让我们马上开始同步大冒险吧!Let's go!!!;)


1. CoreData 托管对象多个实例的同步问题

我们知道为了和 SwiftUI “亲密无间”,何曾几时(iOS 13.0+) CoreData 的托管类 NSManagedObject 也悄然遵守了 ObservableObject 协议。

在这里插入图片描述

从那一刻起,CoreData 托管对象便可以乖巧的作为 SwiftUI 视图中的状态“乐此不疲”。

在这里插入图片描述

不过,在 SwiftUI 视图 @FetchRequest 查询结果 FetchedResults 中的托管对象若在外部被修改,则该查询结果并不会自动进行同步:

@FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \Cave.name, ascending: false)], predicate: NSPredicate(format: "challenge.stateValue = \(ChallengeState.inProgress.rawValue)"), animation: .bouncy) var inProcessingCaves: FetchedResults<Cave>

拿上面的 inProcessingCaves 状态来说,它包括了所有正在“进行中”的 Cave 托管对象(用 NSPredicate 来过滤数据),这些对象都会显示在主视图顶部“正在进行”的 Section 里:

在这里插入图片描述

如果我们在子视图里将 inProcessingCaves 中的任何对象状态由“进行中”改成了“已失败”,那么它们理应从“正在进行”的 Section 中“销声匿迹”,但实际情况却事与愿违:

在这里插入图片描述

如上图所示:红色的“已失败”Cave 托管对象仍在“厚颜无耻”的占据着“正在进行” Section 中的宝贵空间。


关于上面 CoreData 中 @FetchRequest 托管对象的过滤结果不能被及时刷新的解决之道,请小伙伴们移步如下链接观赏精彩的内容:


那么这种情况在最新的 SwiftData 中还会存在吗?让我们探寻一番吧。

2. SwiftData 是否会重蹈覆辙?

SwiftData 是苹果在 WWDC 23 推出的完全符合 Swift 范儿的数据库框架,其描述性的语法非常适合托管表本身、表字段以及表间关系的构建。


更多 SwiftData 相关内容的介绍,请小伙伴们尽情观赏如下链接中的精彩内容:


为了模拟 CoreData 中的数据结构,我们分别创建了两个 SwiftData 数据模型:Item 和 SubItem,其中每个 Item 都至多包含一个 SubItem。

import SwiftData

@Model
final class Item {
    @Attribute(.unique) var id: UUID
    var timestamp: Date
    var name: String
    
    @Relationship
    var subItem: SubItem?
    
    init(timestamp: Date, name: String) {
        self.timestamp = timestamp
        self.name = name
        id = UUID()
    }
    
    static var sampleItems: [Item] = {
        var items = [Item]()
        let names = ["Apple", "Jujube", "Watermelon"]
        for name in names {
            let new = Item(timestamp: Date.now, name: name)
            let newSub = SubItem(timestamp: Date.now, name: "Sub \(name)", state: .unstarted)
            new.subItem = newSub
            items.append(new)
        }
        
        return items
    }()
    
    static func createItems(_ context: ModelContext) throws {
        for item in sampleItems {
            context.insert(item)
        }
    }
}

enum SubItemState: Int, CaseIterable, Identifiable {
    case unstarted = 0
    case processing
    case failed
    case finished
    
    var id: Int {
        rawValue
    }
    
    var title: String {
        switch self {
        case .unstarted:
            "未开始"
        case .processing:
            "进行中"
        case .failed:
            "失败"
        case .finished:
            "完成"
        }
    }
}

@Model
final class SubItem {
    var timestamp: Date
    var name: String
    var stateValue: Int
    
    @Relationship
    weak var item: Item?
    
    init(timestamp: Date, name: String, state: SubItemState) {
        self.timestamp = timestamp
        self.name = name
        self.stateValue = state.rawValue
    }
    
    var state: SubItemState {
        get {
            SubItemState(rawValue: stateValue)!
        }
        
        set {
            stateValue = newValue.rawValue
        }
    }
}

值得注意的是,在上面代码中 Item 和 SubItem 两个表之间的关系是通过 @Relationship 宏来联结的:

@Relationship
var subItem: SubItem?

随后,为了 Xcode 中预览的运行可以顺畅完成,我们再创建一个专门为预览服务的模型容器 previewModelContainer:

var previewModelContainer: ModelContainer = {
    let schema = Schema([
        Item.self,
    ])
    let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)

    do {
        return try ModelContainer(for: schema, configurations: [modelConfiguration])
    } catch {
        fatalError("Could not create ModelContainer: \(error)")
    }
}()

在搞定了数据模型之后,我们现在来专注于 SwiftUI 中界面布局的构建:

import SwiftData

struct ItemView: View {
    @Environment(\.modelContext) private var modelContext
    @Bindable var item: Item
    
    @State private var currentItemState = SubItemState.unstarted
    
    var body: some View {
        VStack {
            Text(item.id.uuidString)
                .font(.title2)
            
            Text("\(item.timestamp.formatted())")
                .font(.headline)
                .foregroundStyle(.gray)
            
            Spacer()
            
            Picker("", selection: $currentItemState) {
                ForEach(SubItemState.allCases) { state in
                    Text(state.title).tag(state)
                }
            }
            .pickerStyle(.segmented)
            
            Button("应用 Item 状态!") {
                item.subItem?.state = currentItemState
            }
            .font(.title)
            .buttonStyle(.borderedProminent)
            .padding()
        }
        .padding()
        .navigationTitle(item.name)
        .task {
            currentItemState = item.subItem!.state
        }
    }
}

struct ItemCell: View {
    
    let item: Item
    
    private var itemBGColor: Color {
        switch item.subItem!.state {
        case .unstarted:
            .yellow
        case .processing:
            .green
        case .failed:
            .red
        case .finished:
            .blue
        }
    }
    
    var body: some View {
        VStack {
            Text(item.name)
                .font(.title)
            Spacer()
            Text(item.id.uuidString.suffix(8))
                .font(.headline)
                .foregroundStyle(.gray)
        }
        .padding()
        .frame(width: 121, height: 200)
        .background {
            RoundedRectangle(cornerRadius: 15)
                .foregroundStyle(itemBGColor)
        }
    }
}

let ProcessingItemValue = SubItemState.processing.rawValue

struct ItemsView: View {
    @Environment(\.modelContext) private var modelContext
    @Query private var items: [Item]
    @Query(filter: #Predicate<Item> {
        $0.subItem?.stateValue == ProcessingItemValue
    }, sort: [.init(\Item.name, order: .forward)], animation: .bouncy) private var processingItems: [Item]
    
    @State private var tappingItem: Item?
    
    private let gridItems = [GridItem](repeating: .init(.flexible()), count: 2)
    
    var body: some View {
        Form {
            Section("进行中 Items") {
                LazyVGrid(columns: gridItems, spacing: 16) {
                    ForEach(processingItems) { item in
                        Button(action: {
                            tappingItem = item
                        
                        }, label: {
                            ItemCell(item: item)
                        })
                    }
                }
                .buttonStyle(.borderless)
            }
            
            Section("所有内置 Items") {
                LazyVGrid(columns: gridItems, spacing: 16) {
                    ForEach(items) { item in
                        
                        Button(action: {
                            tappingItem = item
                        
                        }, label: {
                            ItemCell(item: item)
                        })
                    }
                }
                .buttonStyle(.borderless)
            }
        }
        .navigationDestination(item: $tappingItem) { item in
            ItemView(item: item)
        }
        
    }
}

struct ContentView: View {

    var body: some View {
        NavigationStack {
            TabView {
                Tab("Items", systemImage: "eraser.fill") {
                    ItemsView()
                }
                
                Tab("Secrets", systemImage: "character.book.closed.fill.he") {
                    Text("Secrets")
                }
                
                Tab("Settings", systemImage: "gear") {
                    Text("Settings")
                }
            }
        }
    }
}

#Preview {
    let container = previewModelContainer
    let context = ModelContext(container)

    try! Item.createItems(context)
    
    return ContentView()
        .modelContainer(container)
}

上面代码看起来很长,其实却很简单。它主要做了下面几件事:

  • 在 ItemView 中“恣意”修改 Item.subItem 的状态;
  • 在 ItemsView 中用 @Query 宏创建了 items 和 processingItems 两个查询结果集合,分别用于表示全部 Item 和正在“进行中”的 Item 对象;
  • 在 #Preview 预览宏中使用之前创建的 previewModelContainer 模型容器来产生预览数据;

将上面的代码全部整合到一起,现在我们运行来看看在 ItemView 中修改 SubItem 的状态究竟能不能引起 @Query processingItems 里对应的托管对象被正确刷新:

在这里插入图片描述

可以看到,在子视图 ItemView 中改变 SubItem 的状态无法导致主视图 ItemsView 中 @Query processingItems 结果的刷新:虽然将 SubItem 状态由“未开始”变为“进行中”,但其并未出现在“进行中 Items”的 Section 中。

看来在 SwiftUI 中, SwiftData 也会出现和 CoreData 类似的“毛病”。那么,此时我们又该何去何从呢?

3. SwiftData 超简洁的解决方案

不像 CoreData 在 SwiftUI 中的 @FetchRequest 属性包装器,SwiftData 中的 @Query 宏无法主动刷新其内容;而且 SwiftData 中的模型上下文对象 ModelContext 也并未提供 CoreData 托管对象上下文所拥有的刷新机制。

不过,SwiftData 这个现代化数据库模型似乎更加深谋远虑、看的更长远。

在 SwiftData 中关于此问题的解决方案出奇的简单 —— 是的,你没看错,只需一行代码即可:我们只需要在 SwiftData 模型对象 SubItem 状态更改时手动保存便可“万事大吉”!

将 ItemView 中按钮的 action 逻辑修改为如下代码:

Button("应用 Item 状态!") {
    item.subItem?.state = currentItemState
    // 增加如下一句,确保刷新模型上下文中的所有已修改对象
    try! modelContext.save()
}

什么!我们还没发力就已经结束了?

现在回到 Xcode 预览中,再观察一下执行效果:

在这里插入图片描述

可以看到现在 ItemView 子视图修改状态后的 SubItem 对象,在主视图 @Query 的查询结果里已被正确的刷新啦!打完收工,大功告成!棒棒哒!💯

总结

在本篇博文中,我们介绍了在 SwiftData 里也同样会出现在 CoreData 中 @FetchRequest 查询托管对象不能被及时被刷新的情况。并给出了简单的令人“难以置信”的解决方案。

感谢观赏,再会吧!8-)