概览
我们已经知道,用 CoreData 在背后默默支持的 SwiftUI 视图在使用 @FetchRequest 来查询托管对象集合时,若查询结果中的托管对象在别处被改变将不会在 FetchedResults 中得到及时的刷新。
那么这一“囧境”在 SwiftData 里是否也会“卷土重来”呢?空说无益,就让我们在这里来一场钩深索隐、推本溯源的探究之旅吧。
在本篇博文中,您将学到如下内容:
- CoreData 托管对象多个实例的同步问题
- SwiftData 是否会重蹈覆辙?
- 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 相关内容的介绍,请小伙伴们尽情观赏如下链接中的精彩内容:
- iOS 18 中全新 SwiftData 重装升级,其中一个功能保证你们“爱不释手”
- SwiftData(iOS 17+)如何在数据新建和更新中途出错时恢复如初?
- 『第十二章』数据持久化: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-)