SwiftUI 与 Core Data —— 数据定义

SwiftUI 与 Core Data —— 数据定义

在上文中,我列举了一些在 SwiftUI 中运用 Core Data 所遇到的困惑及期许。在往后的文章中咱们将尝试用新的思路来创立一个 SwiftUI + Core Data 的 app,看看能否防止并改进之前的一些问题。本文将首先探讨如何界说数据。

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

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

从 Todo 开始

Todo 是我为这个系列文章预备的一个演示使用。我尽量让这个功用简单的 app 能够触及较多的 SwiftUI + Core Data 的开发场景。运用者能够在 Todo 中创立即将完结的作业( Task ),并能够经过 Task Group 以完结更好地管理。

能够在 此处 取得 Todo 的代码。代码仍在更新中,或许会呈现与文章中不彻底一致的状况。

SwiftUI 与 Core Data —— 数据定义

Todo 的代码有如下特色:

  • 采用模块化开发办法,数据界说、视图、DB 完结均处于各自的模块中
  • 除了用于串联的视图外,一切的细节视图均完结了与使用的数据流解耦。无需更改代码便能够习气不同的结构( 纯 SwiftUI 驱动、TCA 或其他的 Redux 结构 )
  • 一切的视图均能够完结在不运用任何 Core Data 代码的状况下进行预览,并可对 Mock 数据进行动态呼应

SwiftUI 与 Core Data —— 数据定义

先有鸡仍是先有蛋

Core Data 经过保管方针的办法来呈现数据( 界说的作业是在数据模型编辑器中进行的 )。如此一来,开发者能够用自己熟悉的办法来操作数据而无需了解耐久化数据的详细结构和安排办法。惋惜的是,保管方针关于以值类型为主的 SwiftUI 来说并不算友爱,因而,不少开发者都会在视图中将保管方针实例转化成一个结构体实例以便利接下来的操作( 如安在 Xcode 下预览含有 Core Data 元素的 SwiftUI 视图)。

因而,在传统的 Core Data 使用开发办法中,开发者为了创立上图中 Group Cell 视图,一般需求进行如下的过程( 以 Todo 使用中的 Task Group 举例 ):

SwiftUI 与 Core Data —— 数据定义

  • 在 Xcode 的数据模型编辑器中创立实体 C_Group( 包括与之有关系的其他实体 C_Task )

SwiftUI 与 Core Data —— 数据定义

  • 如有必要能够经过更改保管方针 C_Group 代码( 或添加计算特点 )的办法改进保管方针的类型兼容度
  • 界说便利在 SwiftUI 环境中运用的结构,并为保管方针创立扩展办法以完结转化
struct TodoGroup {
    var title: String
    var taskCount: Int // 当前 Group 中包括的 Task 数量
}
extension C_Group {
    func convertToGroup() -> TodoGroup {
        .init(title: title ?? "", taskCount: tasks?.count ?? 0)
    }
}
  • 创立 GroupCell 视图
struct GroupCellView:View {
    @ObservedObject var group:C_Group
    var body: some View {
        let group = group.convertToGroup()
        HStack {
            Text(group.title)
            Text("\(group.taskCount)")
        }
    }
}

根据上述流程,即便咱们不进行开始的建模作业,仅依托结构体 TodoGroup 已经彻底能够满意进行视图开发的需求。如此一来,流程顺序将改变为:

  • 界说 TodoGroup 结构体
  • 构建视图

此刻视图能够简化为:

struct GroupCellView:View {
    let group: TodoGroup
    var body: some View {
        HStack {
            Text(group.title)
            Text("\(group.taskCount)")
        }
    }
}

在开发的过程中,咱们能够根据需求随时调整 TodoGroup ,而无需过分考虑如安在 Core Data 以及数据库中安排数据( 仍需求开发者有必定的 Core Data 编程根底,防止创立彻底不切实际的数据格式 )。在最后阶段( 视图及其他逻辑处理都完结后 )才进行 Core Data 数据的建模以及转化作业。

这一看似简单的转化 —— 从鸡( 保管方针 )到蛋( 结构体 )转化至从鸡( 结构体 )到蛋( 保管方针 ),将彻底颠覆咱们之前习气的开发流程。

保管方针的其他优势

在视图中用结构体直接表示数据当然便利,但咱们仍不能忽略保管方针的其他优势。关于 SwiftUI 来说,保管方针具有两个非常明显的特色:

  • 懒加载

    保管方针的所谓保管是指:该方针被保管上下文所创立并持有。仅在需求的时分,才从数据库( 或行缓存 )中加载所需的数据。合作 SwiftUI 的懒加载容器( List、LazyStack、LazyGrid ),能够完美地在性能与资源占用间取得平衡

  • 实时呼应改变

    保管方针( NSManagedObject )契合 ObservableObject 协议,当数据发生改变时,能够告诉视图进行刷新

因而无论如何,咱们都应该在视图中保存保管方针的上述优点,如此,上面的代码将会演变成下面的容貌:

struct GroupCellViewRoot:View {
    @ObservedObject var group:C_Group
    var body:some View {
        let group = group.convertToGroup()
        GroupCellView(group:group)
    }
}

很惋惜,如同一切又回到了原点。

为了保存 Core Data 的优势,咱们不得不在视图中引进保管方针,引进了保管方针就不得不先建模,再转化。

是否能够创立一种既可保存保管方针优势一起又不用在代码中显式引进特定保管方针的办法呢?

面向协议编程

面向协议编程是贯穿 Swift 言语的基本思想,也是其主要特色之一。经过让不同的类型恪守相同的协议,开发者便能够从详细的类型中解放出来。

BaseValueProtocol

回到 TodoGroup 这个类型。这个类型除了用于为 SwiftUI 的视图供给数据外,一起也会被用于为其他的数据流供给有用信息,例如,在类 Redux 结构中,经过 Action 为 Reducer 供给所需数据。因而,咱们能够为一切的相似数据创立一个统一的协议 —— BaseValueProtocol。

public protocol BaseValueProtocol: Equatable, Identifiable, Sendable {}

越来越多的类 Redux 结构要求 Action 契合 Equatable 协议,因而作为或许作为某个 Action 的相关参数的类型,也有必要遵从该协议。一起考虑到未来 Reducer 有被移出主线程的趋势,让数据契合 Sendable 也能防止呈现多线程方面的问题。因为每个结构体实例必然需求对应一个保管方针实例,让结构体类型契合 Identifiable 也能更好地为两者之间创立联络。

现在咱们首先让 TodoGroup 来恪守这个协议:

struct TodoGroup: BaseValueProtocol {
    var id: NSManagedObjectID // 一个能够联络两种之间的枢纽,目前暂时用 NSManagedObjectID 代替
    var title: String
    var taskCount: Int
}

在上面的完结中,咱们用 NSManagedObjectID 作为 TodoGroup 的 id 类型,但因为 NSManagedObjectID 相同需求在保管环境中才干创立,因而鄙人文中,它将会被其他的自界说类型所替代。

ConvertibleValueObservableObject

无论是首先界说数据模型仍是首先界说结构体,终究咱们都需求为保管方针供给转化至对应结构体的办法,因而咱们能够以为一切能够转化成指定结构体( 契合 BaseValueProtocol )的保管方针应该都能够遵从下面的协议:

public protocol ConvertibleValueObservableObject<Value>: ObservableObject, Identifiable {
    associatedtype Value: BaseValueProtocol
    func convertToValueType() -> Value
}

例如:

extension C_Group: ConvertibleValueObservableObject {
    public func convertToValueType() -> TodoGroup {
        .init(
            id: objectID, // 相互间对应的标识
            title: title ?? "",
            taskCount: tasks?.count ?? 0
        )
    }
}

两者间的枢纽 —— WrappedID

因为 NSManagedObjectID 的存在,上面的两个协议仍无法脱离保管环境( 并非指 Core Data 结构 )。因而咱们需求创立一种能够在保管环境和非保管环境中均能运转的中心类型用作两者的标识。

public enum WrappedID: Equatable, Identifiable, Sendable, Hashable {
    case string(String)
    case integer(Int)
    case uuid(UUID)
    case objectID(NSManagedObjectID)
    public var id: Self {
        self
    }
}

相同出于该类型或许被用于 Action 的相关参数以及作为 ForEach 中视图的显式标识,咱们需求让该类型契合 Equatable、Identifiable、Sendable,、Hashable 这些协议。

因为 WrappedID 需求契合 Sendable ,因而上面的代码在编译时将呈现如下正告( NSManagedObjectID 不契合 Sendable ):

SwiftUI 与 Core Data —— 数据定义

幸亏的是,NSManagedObjectID 是线程安全的,能够被标示为 Sendable( 这点已经在 Ask Apple 10 月的问答中得到了官方的承认 )。添加如下代码即可消除上面的正告:

extension NSManagedObjectID: @unchecked Sendable {}

让咱们对之前的 BaseValueProtocol 和 ConvertibleValueObservableObject 进行调整:

public protocol BaseValueProtocol: Equatable, Identifiable, Sendable {
    var id: WrappedID { get }
}
public protocol ConvertibleValueObservableObject<Value>: ObservableObject, Identifiable where ID == WrappedID {
    associatedtype Value: BaseValueProtocol
    func convertToValueType() -> Value
}

截至目前咱们创立了两个协议和一个新类型 —— BaseValueProtocol、ConvertibleValueObservableObject、WrappedID ,不过如同看不出它们有什么详细的作用。

为 Mock 数据预备的协议 —— TestableConvertibleValueObservableObject

还记得咱们开始的宗旨吗?在不创立 Core Data 模型的状况下,完结绝大多数的视图和逻辑代码。因而,咱们有必要能够让 GroupCellViewRoot 视图承受一种仅从结构体( TodoGroup )即可创立的与保管方针行为相似的通用类型。TestableConvertibleValueObservableObject 便是完结这一方针的基石:

@dynamicMemberLookup
public protocol TestableConvertibleValueObservableObject<WrappedValue>: ConvertibleValueObservableObject {
    associatedtype WrappedValue where WrappedValue: BaseValueProtocol
    var _wrappedValue: WrappedValue { get set }
    init(_ wrappedValue: WrappedValue)
    subscript<Value>(dynamicMember keyPath: WritableKeyPath<WrappedValue, Value>) -> Value { get set }
}
public extension TestableConvertibleValueObservableObject where ObjectWillChangePublisher == ObservableObjectPublisher {
    subscript<Value>(dynamicMember keyPath: WritableKeyPath<WrappedValue, Value>) -> Value {
        get {
            _wrappedValue[keyPath: keyPath]
        }
        set {
            self.objectWillChange.send()
            _wrappedValue[keyPath: keyPath] = newValue
        }
    }
    func update(_ wrappedValue: WrappedValue) {
        self.objectWillChange.send()
        _wrappedValue = wrappedValue
    }
    static func == (lhs: Self, rhs: Self) -> Bool {
        lhs._wrappedValue == rhs._wrappedValue
    }
    func convertToValueType() -> WrappedValue {
        _wrappedValue
    }
    var id: WrappedValue.ID {
        _wrappedValue.id
    }
}

让咱们界说一个 Mock 数据类型来查验成果:

public final class MockGroup: TestableConvertibleValueObservableObject {
    public var _wrappedValue: TodoGroup
    public required init(_ wrappedValue: TodoGroup) {
        self._wrappedValue = wrappedValue
    }
}

现在,在 SwiftUI 的视图中,MockGroup 将具有与 C_Group 几乎一样的能力,仅有不同的是,它是经过一个 TodoGroup 实例构建的。

let group1 = TodoGroup(id: .string("Group1"), title: "Group1", taskCount: 5)
let mockGroup = MockGroup(group1)

因为 WrappedID 的存在,mockGroup 能够在没有保管环境的状况下运用。

AnyConvertibleValueObservableObject

考虑到 @ObservedObject 只能承受详细类型的数据( 无法运用 any ConvertibleValueObservableObject ),因而咱们需求创立一个类型擦除容器,让 C_Group 和 MockGroup 都能在 GroupCellViewRoot 视图中运用。

public class AnyConvertibleValueObservableObject<Value>: ObservableObject, Identifiable where Value: BaseValueProtocol {
    public var _object: any ConvertibleValueObservableObject<Value>
    public var id: WrappedID {
        _object.id
    }
    public var wrappedValue: Value {
        _object.convertToValueType()
    }
    init(object: some ConvertibleValueObservableObject<Value>) {
        self._object = object
    }
    public var objectWillChange: ObjectWillChangePublisher {
        _object.objectWillChange as! ObservableObjectPublisher
    }
}
public extension ConvertibleValueObservableObject {
    func eraseToAny() -> AnyConvertibleValueObservableObject<Value> {
        AnyConvertibleValueObservableObject(object: self)
    }
}

现在对 GroupCellViewRoot 视图进行如下调整:

struct GroupCellViewRoot:View {
    @ObservedObject var group:AnyConvertibleValueObservableObject<TodoGroup>
    var body:some View {
        let group = group.wrappedValue
        GroupCellView(group:group)
    }
}

咱们已经完结了第一个与保管环境解耦的视图链条。

创立预览

let group1 = TodoGroup(id: .string("Group1"), title: "Group1", taskCount: 5)
let mockGroup = MockGroup(group1)
struct GroupCellViewRootPreview: PreviewProvider {
    static var previews: some View {
        GroupCellViewRoot(group: mockGroup.eraseToAny())
            .previewLayout(.sizeThatFits)
    }
}

SwiftUI 与 Core Data —— 数据定义

或许会有人觉得,用了如此多的代码,终究仅为完结能够承受 Mock 数据的预览非常不划算。假如仅为达到此意图,直接对 GroupCellView 视图进行预览就好了,为什么要如此大费周章?

假如没有 AnyConvertibleValueObservableObject ,开发者仅能对使用中的部分视图进行预览( 在不创立保管环境的状况下 ),而经过 AnyConvertibleValueObservableObject ,咱们则能够完结将一切的视图代码均从保管环境中解放出来的愿望。经过结合之后介绍的与 Core Data 数据操作进行解耦的办法,无需编写任何 Core Data 代码,就能够完结完结使用中一切视图和数据操作逻辑代码的方针。而且全程可预览,可交互,可测试。

回忆

不要被上面的代码所利诱,运用本文介绍的办法后,从头整理的开发流程如下:

  • 界说 TodoGroup 结构体
struct TodoGroup: BaseValueProtocol {
    var id: WrappedID
    var title: String
    var taskCount: Int // 当前 Group 中包括的 Task 数量
}
  • 创立 TodoGroupView( 此刻已无需 TodoGroupViewRoot )
struct TodoGroupView:View {
    @ObservedObject var group:AnyConvertibleValueObservableObject<TodoGroup>
    var body:some View {
        let group = group.wrappedValue
        HStack {
            Text(group.title)
            Text("\(group.taskCount)")
        }
    }
}
  • 界说 MockGroup 数据类型
public final class MockGroup: TestableConvertibleValueObservableObject {
    public var _wrappedValue: TodoGroup
    public required init(_ wrappedValue: TodoGroup) {
        self._wrappedValue = wrappedValue
    }
}
let group1 = TodoGroup(id: .string("id1"), title: "Group1", taskCount: 5)
let mockGroup = MockGroup(group1)
  • 创立预览视图
struct GroupCellViewPreview: PreviewProvider {
    static var previews: some View {
        GroupCellView(group: mockGroup.eraseToAny())
    }
}

下文介绍

鄙人篇文章中,咱们将介绍如安在视图从 Core Data 中获取数据的操作这一过程中完结与保管环境解耦,创立一个能够承受 Mock 数据的自界说 FetchRequest 类型。

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

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

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

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