CoreDataRepository

CI codecov

📣 查看关于 SwiftData 的讨论

📣 v3.0 的重大更改正在进行中 v3.0

CoreDataRepository 是一个在后台队列上使用 CoreData 的库。它具有用于 CRUD、批量、获取和聚合操作的端点。此外,它还为获取和读取提供类似流的订阅。

由于 NSManagedObject 不是线程安全的,因此每个 NSManagedObject 子类都必须存在值类型模型。

文档

动机

CoreData 是 Apple 平台上用于本地持久化的出色框架。但是,很容易在整个应用程序中对其创建强依赖性。更糟糕的是,viewContext 与 UI 一起在主 DispatchQueue 上运行。即使从存储中获取数据也可能足以引起性能问题。

CoreDataRepository 的目标是

NSManagedObject 映射到值类型

在本地持久化之上添加此抽象层以及在对象和值类型之间进行映射的开销,可能会让人觉得很复杂。类似于仅向视图公开它们所需的最少数据的动机,为什么模型层应该关注持久化层的细节?NSManagedObject 是复杂的类型,确实应该尽可能隔离。

为了给这个想法增加一些分量,以下是 Andy Matuschak 在 这次 演讲的问答环节中的引述

问:依赖关系是如何解决的?似乎使用值最大的价值在于模型层,但那也是您在应用程序其余部分(可能是在 Objective-C 中)具有最多依赖关系的层。

Andy:以我的经验,我们有一个 CoreData 堆栈,这与隔离相反。我们的策略是在 CoreData 层之上放置一个层,该层将执行查询并返回值。但是我们会在模型层中的哪里添加功能呢?就视图层中使用值而言,我们实际上做了很多。我们有一个一直到堆栈底部的表格视图单元格,它将呈现一些图标和一个标签。传统的方法是将该内容的 ManagedObject 传递给单元格,但它不需要这样做。没有理由在单元格和模型所知道的一切之间创建这种依赖关系,因此我们制作了这些视图需要的小型轻量级值类型。视图的所有者可以填充该值类型并将其提供给视图。我们制作了这些称为演示器的东西,它们在给定某些模型的情况下可以计算视图数据。然后,拥有演示器的东西可以将结果传递到视图中。

基本用法

模型桥接

有两个协议处理值类型和托管模型之间的桥接。

RepositoryManagedModel

@objc(RepoMovie)
public final class RepoMovie: NSManagedObject {
    @NSManaged var id: UUID?
    @NSManaged var title: String?
    @NSManaged var releaseDate: Date?
    @NSManaged var boxOffice: NSDecimalNumber?
}

extension RepoMovie: RepositoryManagedModel {
    public func create(from unmanaged: Movie) {
        update(from: unmanaged)
    }

    public typealias Unmanaged = Movie
    public var asUnmanaged: Movie {
        Movie(
            id: id ?? UUID(),
            title: title ?? "",
            releaseDate: releaseDate ?? Date(),
            boxOffice: (boxOffice ?? 0) as Decimal,
            url: objectID.uriRepresentation()
        )
    }

    public func update(from unmanaged: Movie) {
        id = unmanaged.id
        title = unmanaged.title
        releaseDate = unmanaged.releaseDate
        boxOffice = NSDecimalNumber(decimal: unmanaged.boxOffice)
    }

    static func fetchRequest() -> NSFetchRequest<RepoMovie> {
        let request = NSFetchRequest<RepoMovie>(entityName: "RepoMovie")
        return request
    }
}

UnmanagedModel

public struct Movie: Hashable {
    public let id: UUID
    public var title: String = ""
    public var releaseDate: Date
    public var boxOffice: Decimal = 0
    public var url: URL?
}

extension Movie: UnmanagedModel {
    public var managedRepoUrl: URL? {
        get {
            url
        }
        set(newValue) {
            url = newValue
        }
    }

    public func asRepoManaged(in context: NSManagedObjectContext) -> RepoMovie {
        let object = RepoMovie(context: context)
        object.id = id
        object.title = title
        object.releaseDate = releaseDate
        object.boxOffice = boxOffice as NSDecimalNumber
        return object
    }
}

CRUD

var movie = Movie(id: UUID(), title: "The Madagascar Penguins in a Christmas Caper", releaseDate: Date(), boxOffice: 100)
let result: Result<Movie, CoreDataRepositoryError> = await repository.create(movie)
if case let .success(movie) = result {
    os_log("Created movie with title - \(movie.title)")
}

Fetch

let fetchRequest = NSFetchRequest<RepoMovie>(entityName: "RepoMovie")
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \RepoMovie.title, ascending: true)]
fetchRequest.predicate = NSPredicate(value: true)
let result: Result<[Movie], CoreDataRepositoryError> = await repository.fetch(fetchRequest)
if case let .success(movies) = result {
    os_log("Fetched \(movies.count) movies")
}

Fetch 订阅

类似于常规获取

let result: AnyPublisher<[Movie], CoreDataRepositoryError> = repository.fetchSubscription(fetchRequest)
let cancellable = result.subscribe(on: userInitSerialQueue)
            .receive(on: mainQueue)
            .sink(receiveCompletion: { completion in
                switch completion {
                case .finished:
                    os_log("Fetched a bunch of movies")
                default:
                    fatalError("Failed to fetch all the movies!")
                }
        }, receiveValue: { value in
            os_log("Fetched \(value.items.count) movies")
        })
...
cancellable.cancel()

聚合

let result: Result<[[String: Decimal]], CoreDataRepositoryError> = await repository.sum(
    predicate: NSPredicate(value: true),
    entityDesc: RepoMovie.entity(),
    attributeDesc: RepoMovie.entity().attributesByName.values.first(where: { $0.name == "boxOffice" })!
)
if case let .success(values) = result {
    os_log("The sum of all movies' boxOffice is \(values.first!.values.first!)")
}

批量

let movies: [[String: Any]] = [
    ["id": UUID(), "title": "A", "releaseDate": Date()],
    ["id": UUID(), "title": "B", "releaseDate": Date()],
    ["id": UUID(), "title": "C", "releaseDate": Date()],
    ["id": UUID(), "title": "D", "releaseDate": Date()],
    ["id": UUID(), "title": "E", "releaseDate": Date()]
]
let request = NSBatchInsertRequest(entityName: RepoMovie.entity().name!, objects: movies)
let result: Result<NSBatchInsertResult, CoreDataRepositoryError> = await repository.insert(request)

或者

let movies: [[String: Any]] = [
    Movie(id: UUID(), title: "A", releaseDate: Date()),
    Movie(id: UUID(), title: "B", releaseDate: Date()),
    Movie(id: UUID(), title: "C", releaseDate: Date()),
    Movie(id: UUID(), title: "D", releaseDate: Date()),
    Movie(id: UUID(), title: "E", releaseDate: Date())
]
let result: (success: [Movie], failed: [Movie]) = await repository.create(movies)
os_log("Created these movies: \(result.success)")
os_log("Failed to create these movies: \(result.failed)")

待办事项

贡献

我欢迎任何反馈或贡献。最好先创建一个 issue 来讨论任何可能的更改,然后再进行工作并创建 PR。

如果您想贡献代码但还没有想好要进行哪些更改,那么上面的待办事项部分是一个不错的起点。