确保运用不因 Core Data 的原因导致意外溃散是对开发者的起码要求。本文将介绍或许在视图中发生严峻错误的原因,怎么防止,以及在确保视图对数据改变实时呼应的前提下怎么为运用者供给更好、更精确的信息。因为本文会涉及很多前文中介绍的技巧和办法,因而最好一起阅览。

  • SwiftUI 与 Core Data —— 问题
  • SwiftUI 与 Core Data —— 数据界说
  • SwiftUI 与 Core Data —— 数据获取

能够在 此处 获取演示项目 Todo 的代码

原文宣布在我的博客wwww.fatbobman.com

欢迎订阅我的公共号:【肘子的Swift记事本】

保管目标与可选值

Core Data 实体特点的可选性理念早于 Swift 的存在,答应特点暂时无效。例如,当你创立一个带有字符串特点的新目标时,初始值( 在没有默认值的状况下 )是 nil,这在目标被验证之前( 一般在 save 时 )是没有问题的。

当开发者在模型编辑器中为特点设置了默认值( 撤销可选 ),在 Xcode 主动生成的保管目标类界说代码中仍会将不少类型声明为可选值类型。经过手动修正类型( 将 String? 修正为 String )当声明代码能够部分改善在视图中运用保管目标的友善度。

相较于将具有默认值的特点声明为可选值类型( 例如 String ),数值特点的声明则愈加令人困惑。例如下面的 count 特点( Integer 16 )在模型编辑器中被设定为可选,但在生成的代码中仍将为非可选值类型( Int16 )。

SwiftUI 与 Core Data —— 安全地响应数据

SwiftUI 与 Core Data —— 安全地响应数据

而且,开发者无法经过更改声明代码将该特点类型修正为 Int16? 。

SwiftUI 与 Core Data —— 安全地响应数据

这意味着,开发者在实体的某些特点类型上将失掉 Swift 中一个极有特色且功能强大的可选值才干。

之所以呈现上述的状况,是因为 Xcode 中模型编辑器中的 optional 并非对应 Swift 语言中的可选值。Core Data 受限于 Objective-C 中可表达的类型约束,在即便运用了标量转化的状况下( Scalar )也不具有与 Swift 原生类型对应的才干。

假如撤销标量类型,咱们能够让模型编辑器生成支持可选值的特定类型( 例如 NSNumber? ):

SwiftUI 与 Core Data —— 安全地响应数据

SwiftUI 与 Core Data —— 安全地响应数据

开发者能够经过为保管目标声明核算特点实现在 NSNumber? 与 Int16? 之间的转化。

或许开发者会有这样的疑问,假定某个实体的特点在模型中被界说为可选,且在保管目标的类型声明中也为可选值类型( 例如上方的 timestamp 特点 ),那么假如在能够确保 save 时必定有值的状况下,是否能够在运用中运用 ! 号对其进行强制解包?

事实上,在 Xcode 自带的 Core Data 模版中,就是这样运用的。

SwiftUI 与 Core Data —— 安全地响应数据

但这确实是正确的运用方式吗?是否会有严峻的安全隐患?在 timestamp 对应的数据库字段有值的状况下,timestamp 必定会有值吗?是否会有 nil 的或许?

删去与呼应式编程

保管目标的实例创立于保管上下文中,且仅能安全工作于其绑定的保管上下文地点的线程之中。每个保管目标都对应着耐久化存储中的一条数据( 不考虑联系的状况下 )。

为了节省内存,保管目标上下分一般会积极释放( retainsRegisteredObjects 默以为 false )失掉引证的保管目标实例所占用的空间。也就是说,假如一个用于显现保管目标实例数据的视图被销毁了,那么假定没有其他的视图或代码引证视图中显现的保管目标实例,保管上下文将从内存中将这些数据占用的内存释放掉。

在 retainsRegisteredObjects 为 true 的状况下,保管目标会在内部保存对该目标的强引证,即便没有外部代码引证该保管目标实例,目标实例也不会被销毁。

从另一个视点来看,即便在保管上下文中运用 delete 办法删去该实例在数据库中对应的数据,但假如该保管目标实例仍被代码或视图所引证,Swift 并不会销毁该实例,此刻,保管目标上下文会将该实例的 managedObjectContext 特点设置为 nil ,撤销其与保管上下文之间的绑定。此刻假如再访问该实例的可选值类型特点( 例如之前必定有值的 timestamp ),回来值则为 nil 。强制解包将导致运用溃散。

现在的 Core Data,跟着云同步以及耐久化存储前史跟踪的普及,数据库中的某个数据或许在恣意时间被其他的设备或同一个设备中运用该数据库的其他进程所删去。开发者不能像之前那样假定自己对数据具有彻底的掌控才干。在代码或视图中,假如不为随时或许已被删去的数据做好安全准备,问题将十分地严峻。

回到 Xcode 创立的 Core Data 模版代码,咱们做如下的测验,在进入 NavigationLink 后一秒钟删去该数据:

ForEach(items) { item in
    NavigationLink {
        Text("Item at \(item.timestamp!, formatter: itemFormatter)")
            .onAppear{
                // 在进入 NavigationLink 后一秒钟删去该数据
                DispatchQueue.main.asyncAfter(deadline: .now() + 1){ 
                    viewContext.delete(item)
                    try! viewContext.save()
                }
            }
    } label: {
        Text(item.timestamp!, formatter: itemFormatter)
    }
}

SwiftUI 与 Core Data —— 安全地响应数据

彻底没有问题!并没有呈现溃散的状况。难道咱们上面的论说都是错误的?

因为在 Core Data 模版代码中,只运用了一行代码来声明次级视图:

Text("Item at \(item.timestamp!, formatter: itemFormatter)")

因而在 ContentView 的 ForEach 中,item 并不会被视为一个能够引发视图更新的 Source of truth ( 经过 Fetch Request 获取的 items 为 Source of truth )。在删去数据后,即便 item 的内容发生了改变,也并不会引发该行声明句子( Text )改写,然后不会呈现强制解包失利的状况。跟着 FetchRequest 的内容发生改变,List 将从头改写,因为 NavigationLink 对应的数据不复存在,因而 NavigationView 主动回来了根视图。

不过,一般咱们在子视图中,会用 ObservedObject 来标示保管目标实例,以实时呼应数据变动,因而假如咱们将代码调整成正常的编写模式就能看出问题地点了:

struct Cell:View {
    @ObservedObject var item:Item // 呼应数据改变
    @Environment(\.managedObjectContext) var viewContext
    var body: some View {
        Text("Item at \(item.timestamp!, formatter: itemFormatter)")
            .onAppear{
                DispatchQueue.main.asyncAfter(deadline: .now() + 1){
                    viewContext.delete(item)
                    try! viewContext.save()
                }
            }
    }
}
List {
    ForEach(items) { item in
        NavigationLink {
            Cell(item: item) // 传递保管目标
        } label: {
            Text(item.timestamp!, formatter: itemFormatter)
        }
    }
    .onDelete(perform: deleteItems)
}

SwiftUI 与 Core Data —— 安全地响应数据

在删去了数据后,保管上下文会将 item 的 manageObjectContext 设置为 nil。此刻受 item 的 ObjectWillChangePublisher 驱动,Cell 视图将改写,强制解包将导致运用溃散。

只需选用供给备选值的方式,即可防止上述问题的呈现。

Text("Item at \(item.timestamp ?? .now, formatter: itemFormatter)")

假如运用咱们在 SwiftUI 与 Core Data —— 数据界说 一文中评论的 ConvertibleValueObservableObject 协议呢?在 convertToValueType 中为特点供给备选值,是否能够防止呈现溃散的状况?答案是,原始的版别仍或许会呈现问题。

数据被删去后,保管目标实例的 manageObjectContext 被设置为 nil 。因为 AnyConvertibleValueObservableObject 契合 ObservableObject 协议,一样会引发 Cell 视图的更新,在新的一轮渲染中,假如咱们限定 convertToGroup 将转化进程工作于保管目标上下文地点的线程中,因为无法获取上下文信息,转化将失利。假定咱们不限定转化进程工作的线程,备选值的方式关于由视图上下文创立的保管目标实例仍将有用( 但有或许会呈现其它的线程错误 )。

为了让 ConvertibleValueObservableObject 协议能够满意各种场景,咱们需求做如下的调整:

public protocol ConvertibleValueObservableObject<Value>: ObservableObject, Equatable, Identifiable where ID == WrappedID {
    associatedtype Value: BaseValueProtocol
    func convertToValueType() -> Value? // 将回来类型修正为 Value?
}
public extension TestableConvertibleValueObservableObject where ObjectWillChangePublisher == ObservableObjectPublisher {
    ... 
    func convertToValueType() -> WrappedValue? { // 修正成回来 Value?
        _wrappedValue
    }
}
public class AnyConvertibleValueObservableObject<Value>: ObservableObject, Identifiable where Value: BaseValueProtocol {
    public var wrappedValue: Value? { // 修正成回来 Value?
        _object.convertToValueType()
    }
}

如此一来,便能够经过在视图代码运用 if let 来确保不会呈现上文提到的溃散问题:

public struct Cell: View {
    @ObservedObject var item: AnyConvertibleValueObservableObject<Item>
    public var body: some View {
        if let item = item.wrappedValue {
           Text("Item at \(item.timestamp, formatter: itemFormatter)")
        }
    }
}

为了做到能够支持在恣意保管上下文线程中进行转化,convertToValueType 中的实现将为( 以 Todo 中的 TodoGroup 为例 ):

extension C_Group: ConvertibleValueObservableObject {
    public var id: WrappedID {
        .objectID(objectID)
    }
    public func convertToValueType() -> TodoGroup? {
        guard let context = managedObjectContext else { // 判断是否能获取上下文
            return nil
        }
        return context.performAndWait { // 在上下文的线程中执行,确保线程安全
            TodoGroup(
                id: id,
                title: title ?? "",
                taskCount: tasks?.count ?? 0
            )
        }
    }
}

因为同步版别的 performAndWait 并不支持回来值,咱们需求对其作必定的增强:

extension NSManagedObjectContext {
    @discardableResult
    func performAndWait<T>(_ block: () throws -> T) throws -> T {
        var result: Result<T, Error>?
        performAndWait {
            result = Result { try block() }
        }
        return try result!.get()
    }
    @discardableResult
    func performAndWait<T>(_ block: () -> T) -> T {
        var result: T?
        performAndWait {
            result = block()
        }
        return result!
    }
}

在呼应式编程中,开发者不应假定每个部件均能处于抱负环境中,需求尽力确保它们能够任何状况下均确保安全安稳,如此才干确保整个体系的安稳工作。

为已删去的保管目标实例供给正确的备选内容

必定会有人对本节的标题感到奇怪,数据已经删去了,还需求供给什么信息?

在上节的演示中,当数据被删去后( 经过 onAppear 闭包中的推迟操作 ),NavigationView 会主动回来到根视图中。在这种状况下,持有该数据的视图将伴跟着数据删去一起消失。

但在非常多的状况下,开发者并不会运用演示中运用的 NavigationLink 版别,为了对视图具有更强地控制力,开发者一般会选择具有可编程特性的 NavigationLink 版别。此刻,当数据被删去后,运用并不会主动退回至根视图。别的,在其他的一些操作中,为了确保模态视图的安稳,咱们一般也会将模态视图挂载到 List 的外面。例如:

@State var item: Item?
List {
    ForEach(items) { item in
        VStack {
            Text("\(item.timestamp ?? .now)")
            Button("Show Detail") {
                self.item = item // 显现模态视图
                // 模仿推迟删去
                DispatchQueue.main.asyncAfter(deadline: .now() + 2){
                    viewContext.delete(item)
                    try! viewContext.save()
                }
            }
            .buttonStyle(.bordered)
        }
    }
    .onDelete(perform: deleteItems)
}
// 模态视图
.sheet(item: $item) { item in
    Cell(item: item)
}
struct Cell: View {
    @ObservedObject var item: Item
    var body: some View {
        // 方便看清楚改变。 当 timestamp 为 nil 时,将显现当前时间
        Text("\((item.timestamp ?? .now).timeIntervalSince1970)")
    }
}

工作上面的代码,在数据被删去后,Sheet 视图中的 item 会因 managedObjectContext 为 nil 而运用备选数据,如此一来会让用户感到疑惑。

SwiftUI 与 Core Data —— 安全地响应数据

咱们能够经过保存有用值的方式防止呈现上述的问题。

struct Cell: View {
    let item: Item // 无需运用 ObservedObject
    /*
    假如运用的是 MockableFetchRequest ,则为
    let item: AnyConvertibleValueObservableObject<ItemValue>
    */
    @State var itemValue:ItemValue?
    init(item: Item) {
        self.item = item
        // 初始化时,获取有用值
        self._itemValue = State(wrappedValue: item.convertToValueType())
    }
    var body: some View {
        VStack {
            if let itemValue {
                Text("\((itemValue.timestamp).timeIntervalSince1970)")
            }
        }
        .onReceive(item.objectWillChange){ _ in 
            // item 发生改变后,假如能转化成有用值,则更新视图
            if let itemValue = item.convertToValueType() {
                self.itemValue = itemValue
            }
        }
    }
}
public struct ItemValue:BaseValueProtocol {
    public var id: WrappedID
    public var timestamp:Date
}
extension Item:ConvertibleValueObservableObject {
    public var id: WrappedID {
        .objectID(objectID)
    }
    public func convertToValueType() -> ItemValue? {
        guard let context = managedObjectContext else { return nil}
        return context.performAndWait{
            ItemValue(id: id, timestamp: timestamp ?? .now)
        }
    }
}

SwiftUI 与 Core Data —— 安全地响应数据

在视图之外传递值类型

在上节的代码中,咱们为子视图传递都是保管目标实例自身( AnyConvertibleValueObservableObject 也是对保管目标实例的二度包装 )。但在类 Redux 结构中,为了线程安全( Reducer 未必工作于主线程,具体请参阅之前的文章 )咱们不会将保管目标实例直接发送给 Reducer,而是传递转化后的值类型。

下面的代码来自 Todo 项目中 TCA Target 的 TaskListContainer.swift

SwiftUI 与 Core Data —— 安全地响应数据

尽管值类型协助咱们规避了或许存在的线程风险,但又呈现了视图无法对保管目标实例的改变进行实时呼应的新问题。经过在视图中获取值类型数据对应的保管目标实例,便能够既确保安全,又保持了呼应的实时性。

为了演示方便,仍以一般的 SwiftUI 数据流举例:

@State var item: ItemValue? // 值类型
List {
    ForEach(items) { item in
        VStack {
            Text("\(item.timestamp ?? .now)")
            Button("Show Detail") {
                self.itemValue = item.convertToValueType() // 传递值类型
                // 模仿推迟修正内容
                DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
                    item.timestamp = .now
                    try! viewContext.save()
                }
            }
            .buttonStyle(.bordered)
        }
    }
    .onDelete(perform: deleteItems)
}
.sheet(item: $itemValue) { item in
    Cell(itemValue: item) // 参数为值类型
}
struct Cell: View {
    @State var itemValue: ItemValue // 值类型
    @Environment(\.managedObjectContext) var context
    var body: some View {
        VStack {
            if let itemValue {
                Text("\((itemValue.timestamp).timeIntervalSince1970)")
            }
        }
        // 在视图中获取对应的保管目标实例,并实时呼应改变
        .task { @MainActor in
            guard case .objectID(let id) = itemValue.id else {return}
            if let item = try? context.existingObject(with: id) as? Item {
                for await _ in item.objectWillChange.values {
                    if let itemValue = item.convertToValueType() {
                        self.itemValue = itemValue
                    }
                }
            }
        }
    }
}

以我个人的经验来说,为了确保线程安全,保管目标只应在视图之间进行传递,一起用于视图显现的数据最好也只在视图之内进行获取。任何或许脱离视图的传递进程都应运用保管目标实例对应的值类型版别。

在更改数据时进行二次承认

为了防止对主线程形成过多的影响,咱们一般会在私有上下文中进行会对数据发生改变的操作。将操作办法的参数设置为值类型,将迫使开发者在对数据进行操作时( 增加、删去、更改等 )首要需求承认对应数据( 数据库中 )是否存在。

例如( 代码来自 Todo 项目中 DB 库中的 CoreDataStack.swift ):

@Sendable
func _updateTask(_ sourceTask: TodoTask) async {
    await container.performBackgroundTask { [weak self] context in
        // 首要承认 task 是否存在
        guard case .objectID(let taskID) = sourceTask.id,
              let task = try? context.existingObject(with: taskID) as? C_Task else {
            self?.logger.error("can't get task by \(sourceTask.id)")
            return
        }
        task.priority = Int16(sourceTask.priority.rawValue)
        task.title = sourceTask.title
        task.completed = sourceTask.completed
        task.myDay = sourceTask.myDay
        self?.save(context)
    }
}

经过 existingObject ,咱们将确保只在数据有用的状况下才进行下一步的操作,如此能够防止操作已被删去的数据而形成的意外溃散状况。

下文介绍

在下篇文章中,咱们将讨论有关模块化开发的问题。怎么将具体的保管目标类型以及 Core Data 操作从视图、Features 中解耦出来。

希望本文能够对你有所协助。一起也欢迎你经过 Twitter、 Discord 频道 或博客的留言板与我进行交流。

订阅下方的 邮件列表,能够及时取得每周的 Tips 汇总。

原文宣布在我的博客wwww.fatbobman.com

欢迎订阅我的公共号:【肘子的Swift记事本】