如何在 SwiftUI 中观察一组 CoreData 对象?

How can I observe an array of CoreData objects in SwiftUI?

我是 Swift 的新手UI,我很难找到解决我遇到的问题的方法,我的 UI 在对 Core Data 对象进行更改时没有更新。在过去的三天里,我一直在研究这个错误,并通过 Whosebug 和其他来源进行了搜索,但我没有找到任何可以解决我的问题的方法。

本质上,我有一个核心数据实体“书籍”,它与其他实体(即“作者”、“编辑”、“流派”等)有关系,而这些实体又继承自抽象实体(“AbstractName” ):

我在 Swift 中创建了一个视图UI,可以毫无问题地显示图书数据。我使用全局视图模型来跟踪 selected 书(全局,以便我可以根据一本书是否 selected 在 macOS 上启用和禁用菜单栏项目)并注入它作为环境对象进入视图。一切正常,当我编辑这本书时,视图也正确地更新了除关系之外的所有内容。

它不适用于关系的原因是因为我使用子视图以一致的方式显示它们,而 SwiftUI 在数据更改时不会更新。我必须 select 另一本书,然后返回我刚刚编辑的那本书以更新数据。我知道这是行不通的,因为核心数据对象数组不符合 ObservableObject,因此您无法观察到它,但我不知道如何修复它。

现在终于有了一些代码。我已经稍微简化了代码以仅包含必要的部分,但我的项目也在 GitHub (https://github.com/eiskalteschatten/BookJournalSwift) 上,所以我将 post 和 link 到代码片段下方的完整文件。我将关系传递给的子视图称为 WrappingSmallChipsWithName.

显示图书数据的视图:

import SwiftUI

struct MacBookView: View {
    @EnvironmentObject private var globalViewModel: GlobalViewModel
    
    var body: some View {
        if let book = globalViewModel.selectedBook {
            let offset = 100.0
            let maxWidth = 800.0
            let textWithLabelSpacing = 50.0
            let groupBoxSpacing = 20.0
            let groupBoxWidth = (maxWidth / 2) - (groupBoxSpacing / 2)

            ScrollView {
                    LazyVStack(spacing: groupBoxSpacing) {
                        if book.authors != nil && book.sortedAuthors.count > 0 {
                            WrappingSmallChipsWithName<Author>(data: book.sortedAuthors, chipColor: AUTHOR_COLOR)
                        }
                        
                        Group {
                            HStack(alignment: .top, spacing: groupBoxSpacing) {
                                MacBookViewGroupBox(title: "Editors", icon: "person.2.wave.2", width: groupBoxWidth) {
                                    if book.editors != nil && book.sortedEditors.count > 0 {
                                        WrappingSmallChipsWithName<Editor>(data: book.sortedEditors, chipColor: EDITOR_COLOR, alignment: .leading)
                                    }
                                    else {
                                        Text("No editors selected")
                                    }
                                }
                                
                                MacBookViewGroupBox(title: "Genres", icon: "text.book.closed", width: groupBoxWidth) {
                                    if book.genres != nil && book.sortedGenres.count > 0 {
                                        WrappingSmallChipsWithName<Genre>(data: book.sortedGenres, chipColor: GENRE_COLOR, alignment: .leading)
                                    }
                                    else {
                                        Text("No genres selected")
                                    }
                                }
                            }
                            
                            HStack(alignment: .top, spacing: groupBoxSpacing) {
                                MacBookViewGroupBox(title: "Lists", icon: "list.bullet.rectangle", width: groupBoxWidth) {
                                    if book.lists != nil && book.sortedLists.count > 0 {
                                        WrappingSmallChipsWithName<ListOfBooks>(data: book.sortedLists, chipColor: LIST_COLOR, alignment: .leading)
                                    }
                                    else {
                                        Text("No lists selected")
                                    }
                                }
                                
                                MacBookViewGroupBox(title: "Tags", icon: "tag", width: groupBoxWidth) {
                                    if let unwrappedTags = book.tags {
                                        if unwrappedTags.allObjects.count > 0 {
                                            WrappingSmallChipsWithName<Tag>(data: unwrappedTags.allObjects as! [Tag], chipColor: TAG_COLOR, alignment: .leading)
                                        }
                                        else {
                                            Text("No tags selected")
                                        }
                                    }
                                }
                            }
                            
                            HStack(alignment: .top, spacing: groupBoxSpacing) {
                                MacBookViewGroupBox(title: "Translators", icon: "person.2", width: groupBoxWidth) {
                                    if book.translators != nil && book.sortedTranslators.count > 0 {
                                        WrappingSmallChipsWithName<Translator>(data: book.sortedTranslators, chipColor: TRANSLATOR_COLOR, alignment: .leading)
                                    }
                                    else {
                                        Text("No translators selected")
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

https://github.com/eiskalteschatten/BookJournalSwift/blob/main/Shared/Books/BookView/macOS/MacBookView.swift

显示关系数据的WrappingSmallChipsWithName子视图:

import SwiftUI
import WrappingHStack

struct WrappingSmallChipsWithName<T: AbstractName>: View {
    var title: String?
    var data: [T]
    var chipColor: Color = .gray
    var alignment: HorizontalAlignment = .center
    
    var body: some View {
        VStack(alignment: alignment, spacing: 1) {
            if title != nil {
                Text(title!)
            }
            
            if data.count > 0 {
                Spacer()
                
                WrappingHStack(data, id: \.self, alignment: alignment) { item in
                    SmallChip(background: chipColor) {
                        HStack(alignment: .center, spacing: 4) {
                            if let name = item.name {
                                Text(name)
                            }
                        }
                    }
                    .padding(.horizontal, 1)
                    .padding(.vertical, 3)
                    .contextMenu {
                        let copyButtonLabel = item.name != nil ? "Copy \"\(item.name!)\"" : "Copy"
                        Button(copyButtonLabel) {
                            if let name = item.name {
                                copyTextToClipboard(name)
                            }
                        }
                        .disabled(item.name == nil)
                    }
                }
                .frame(minWidth: 250)
            }
        }
    }
}

https://github.com/eiskalteschatten/BookJournalSwift/blob/main/Shared/Elements/NamedElements/WrappingSmallChipsWithName.swift

全局视图模型:

import SwiftUI
import CoreData

final class GlobalViewModel: ObservableObject {
    private let defaults = UserDefaults.standard
    private var viewContext: NSManagedObjectContext?
    private let selectedBookURLKey = "GlobalViewModel.selectedBookURL"
    
    @Published var selectedBook: Book? {
        didSet {
            // Use user defaults instead of @SceneStorage so it can be initialized in the constructor
            defaults.set(selectedBook?.objectID.uriRepresentation(), forKey: selectedBookURLKey)
        }
    }
    
    #if os(iOS)
    @Published var globalError: String?
    @Published var globalErrorSubtext: String?
    @Published var showGlobalErrorAlert: Bool = false {
        didSet {
            if !showGlobalErrorAlert {
                globalError = nil
                globalErrorSubtext = nil
            }
        }
    }
    #endif
    
    static let shared: GlobalViewModel = GlobalViewModel()
    
    private init() {
        let persistenceController = PersistenceController.shared
        viewContext = persistenceController.container.viewContext
        
        if let url = defaults.url(forKey: selectedBookURLKey),
           let objectID = viewContext!.persistentStoreCoordinator!.managedObjectID(forURIRepresentation: url),
           let book = try? viewContext!.existingObject(with: objectID) as? Book {
                selectedBook = book
        }
    }
    
    #if os(macOS)
    func promptToDeleteBook() {
        let alert = NSAlert()
        alert.messageText = "Are you sure you want to delete this book?"
        alert.informativeText = "This is permanent."
        alert.addButton(withTitle: "No")
        alert.addButton(withTitle: "Yes")
        alert.alertStyle = .warning
        
        let delete = alert.runModal() == NSApplication.ModalResponse.alertSecondButtonReturn
        
        if delete {
            deleteBook()
        }
    }
    
    func deleteBook() {
        withAnimation {
            if let unwrappedBook = selectedBook {
                selectedBook = nil
                viewContext!.delete(unwrappedBook)
                
                do {
                    try viewContext!.save()
                } catch {
                    handleCoreDataError(error as NSError)
                }
            }
        }
    }
    #endif
}

https://github.com/eiskalteschatten/BookJournalSwift/blob/main/Shared/Models/GlobalViewModel.swift

定义“sortedAuthors”、“sortedEditors”等的扩展:

import SwiftUI

extension Book {
    public var sortedAuthors: [Author] {
        let set = authors as? Set<Author> ?? []
        return set.sorted {
            [=13=].wrappedName < .wrappedName
        }
    }
    
    public var sortedEditors: [Editor] {
        let set = editors as? Set<Editor> ?? []
        return set.sorted {
            [=13=].wrappedName < .wrappedName
        }
    }
    
    public var sortedTranslators: [Translator] {
        let set = translators as? Set<Translator> ?? []
        return set.sorted {
            [=13=].wrappedName < .wrappedName
        }
    }
    
    public var sortedLists: [ListOfBooks] {
        let set = lists as? Set<ListOfBooks> ?? []
        return set.sorted {
            [=13=].wrappedName < .wrappedName
        }
    }
    
    public var sortedGenres: [Genre] {
        let set = genres as? Set<Genre> ?? []
        return set.sorted {
            [=13=].wrappedName < .wrappedName
        }
    }
}

extension AbstractName {
    public var wrappedName: String {
        name ?? "Unnamed"
    }
}

https://github.com/eiskalteschatten/BookJournalSwift/blob/main/Shared/Extensions/CoreData.swift

我只 post 编辑了用于 macOS 的视图的代码。我也有一个类似的用于 iOS,但它使用相同的子视图 (WrappingSmallChipsWithName) 并且有同样的问题。

我知道我需要以某种方式将可以观察到的对象或视图模型传递给子视图,但是由于子视图是通用的并且需要访问不同类型的实体(它们都继承自同一个抽象实体),我只是不确定该怎么做。

这里有人有什么建议吗?我开始为这个看似很小的问题而烦恼。

我们使用FetchRequest(或@FetchRequest)来观察托管对象数组。要观察相关对象的数组,请提供 NSPredicate,例如观察作者使用的书籍数组:"author = %@", author。例如

private var fetchRequest: FetchRequest<Book>
private var books: FetchedResults<Book> {
    fetchRequest.wrappedValue
}

init(author: Author) {
    let sortDescriptors = [SortDescriptor(\Book.timestamp, order: sortAscending ? .forward : .reverse)]
    fetchRequest = FetchRequest(sortDescriptors: sortDescriptors, predicate: NSPredicate(format: "author = %@", author), animation: .default)

FetchRequest 是一个 DynamicProperty 结构,与普通结构相比,它具有一些特殊的能力。在 SwiftUI 调用 body 之前,它会在所有动态属性上调用 update,而在 FetchRequest 的情况下,它会从 @Environment.[=33= 中读取托管对象上下文]

当使用 Core Data 时,我们可以生成一个 NSManagedObject subclass 或扩展来添加额外的功能,例如class Book: NSManagedObject。在模型编辑器中,从菜单中选择 generate model subclass。将 BookModel 中的所有内容移动到 Book 扩展中。这些托管对象确实符合 ObservableObject,它允许您使用 @ObservedObject,这再次使 View 结构值类型表现得像视图模型对象,因为当它检测到更改时,body 将被调用。

在 SwiftUI 中,我们尽量不将对象用于视图状态,因为 SwiftUI 使用值类型旨在消除对象的典型错误,因此我建议删除 GlobalViewModel class 并改为使用 SwiftUI 特性 @State@AppStorage 这使得 View 结构值类型具有视图模型对象语义。