SwiftUI 怎么和 Core Data 结合?

最近新起了一个 side project,用于承载 WWDC19 里公布的内容。这篇文章主要讲述了 SwiftUICore Data 怎么结合,以及本身遇到的问题和思考的第〇篇。git

前言

Core Data 是一个使人又爱又恨的东西,爱它由于系统原生支持,能够和 Xcode 完美的结合,恨它由于在会在一些极端的状况下致使不可预测的问题,好比初始化时不可避免的时间消耗,各类主线程依赖操做等。据我所知,西瓜视频和今日头条原先强依赖 Core Data,但由于「某些性能」问题,均已所有撤出。github

既然已经有了赤裸裸的教训,为何我还要执意上 Core Data 呢?刚才也说了,由于「某些性能」问题才致使了这两款 app 下掉 Core Data,但通常的 side project 能够不用考虑这些问题,再加上 WWDC19 中与 Core Data 相关的 session 有四场,明星光环足够了!数据库

Core Data 的封装使用

建立模型

首先来看完成图,swift

Masq

这是一个很是简单的列表,在 UIKit 中咱们只须要 UITableView 一顿操做便可完事,代码不过区区几十行,用 SwiftUI 封装好的话,主列表只须要不到十行便可完成,以下所示:api

struct MASSquareListView : View {

    @State var articleManage = AritcleManager()
    @State var squareListViewModel: MASSquareListViewModel
    
    var body: some View {
        List(self.articleManage.articles, id: \.createdAt) { article in
            MASSquareNormalCellView(article: article)
                .padding(EdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5))
        }
    }
}
复制代码

如今假设咱们的列表已经作好了,如今先来思考列表上须要输入的数据,再来一张图进行解析:服务器

UI 分析

每个 Cell 里所须要输入的数据有「头像」、「建立时间」和「内容」,在这一篇文章中咱们只考虑存粹和 Core Data 进行交互的第一步,如何让 Core Data 的推上 CloudKit 或本身的服务器上后续的文章中再展开。markdown

Core Data 官方组成图

从图中能够看出,咱们的 Model 属于 NSManagerObjectModel,能够按照这篇文章 所描述的如何建立 .xcdatamodeld 文件。session

建立完成后,咱们能够根据以前的分析的 UI 组成把实体属性定义为以下图所示:app

实体定义

  • avatarColor: 头像分红为了「颜色」和「图片」两个部分,每一张图片都是 带透明通道的 png 类型图片。用户可以使用的颜色只能是 app 里被定义好的几种;
  • avatarImage:如上;
  • content:内容,该字段在服务端本来是长文本,此处用 String 保持一致;
  • createdAt:建立时间;
  • type:考虑到后续每一条推文都有多是不一样的形态,好比带不带 flaglink
  • uid:该条推文所需的用户 ID。该字段在此篇文章中所讲述的内容是多余字段,你能够不用加上,以前是考虑到了后续的工做,后续再加也无妨。

咱们能够选择让 Core Data 自动生成与模型相匹配的代码也能够本身写。经过阅读 「objc 中国」的 Core Data 书籍,了解原来本身写匹配的模型代码不会有太多的工做,并且还能加深对模型生成的理解过程(以前为了省事都是让 Core Data 自动生成,完成的模型代码以下:async

final class Article: NSManagedObject {
    @NSManaged var content: String
    @NSManaged var type: Int16
    @NSManaged var uid: Int32
    @NSManaged var avatarImage: Int16
    @NSManaged var avatarColor: Int16
    @NSManaged internal var createdAt: Date
}
复制代码

模型代码写好后,再去 .xcdatamodeld 文件对应的实体上选择刚写好的模型类和取消 Core Data 自动生成代码的选项便可:

配置好对应的选项

这一部分实际上咱们作的是定义被存储的实体结构,换句话说,经过上述操做去描述你要存储的数据。

建立一个 Core Data 存储结构

在这个环节中,以前个人作法都是在 AppDelegate 中按照 Xcode 的生成模版建立的存储器,以完成需求为导向,致使后续再继续接入存储其它实体时,代码质量比较粗糙,通过一番学习后,调整了方向。

来看一张 「objc 中国」上的 Core Data 的存储结构图:

Core Data 存储结构图

图中已经把咱们能够怎么作说的很是明白了,能够有多个实体,经过 context 去管理各个实体的操做,context 再经过协调器跟存储器产生交互,与底层数据库产生交互。这张图实际上与后续咱们要把数据推上 CloudKit 的过程很是相似,但本篇文章中咱们将使用「objc 中国」的这张图的方式去完成:

经过一个 context 去管理多个实体,且只有一个存储管理器。为了方便后续调用数据管理方法的便利,并且存储器不须要重复建立,我拉出了一个单例去管理:

class MASCoreData {
    static let shared = MASCoreData()
    var persistentContainer: NSPersistentContainer!
    /// 建立一个存储容器
    class func createMASDataModel(completion: @escaping () -> ()) {
        // 名字要与 `.xcdatamodeleld` 文件名一致
        let container = NSPersistentContainer(name: "MASDataModel")
        
        container.loadPersistentStores { (_, err) in
            guard err == nil else { fatalError("Failed to load store: \(err!)") }
            DispatchQueue.main.async {
                self.shared.persistentContainer = container
                completion()
            }
        }
    }
}
复制代码

在初始化时,咱们能够这么用:

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    
    //TODO: 这么作有些粗暴,不能数据库建立失败就页面白屏,本篇文章只考虑需求实现,剩下内容后续文章讲解
    MASCoreData.createMASDataModel {
        if let windowScene = scene as? UIWindowScene {
            
            let window = UIWindow(windowScene: windowScene)
            window.rootViewController = UIHostingController(rootView:
                MASSquareHostView()
                    .environmentObject(MASSquareListViewModel())
            )
            
            self.window = window
            window.makeKeyAndVisible()
        }
    }
}
复制代码

代码中的 environmentObject 是上一篇文章中须要控制菜单的显示和隐藏所加,在这篇文章中能够不用管。经过以上方法,咱们就在 app 初始化时,就建立好了一个可用的存储器。

数据交互

模型有了,存储器有了,那就要开始作增删改查了。实际上对 Core Data 的增删改查实现,已经有了众多的文章去讲解,在此不作展开。以我以前作 Core Data 数据查询来看,以前我是这么写的:

func allxxxModels() -> [PJxxxModel] {
    var finalModels = [PJModel]()
    let fetchRequest = NSFetchRequest<xxxModel>(entityName: "xxxModel")
    do {
        let fetchedObjects = try context?.fetch(fetchRequest).reversed()
        guard fetchedObjects != nil else { return []}
        // 作一些数据读取出来的操做 ......
       
        print("查询成功")
        return finalModels
    }
    catch {
        print("查询失败:\(error)")
        return []
    }
}
复制代码

其实一眼看上去也还好,我以前也以为很好,可是当我写了三四个实体后,发现每一个新建实体的查询方法都须要去复制以前写好的查询方法,改改参数就用了,当时以为有些不太对劲的地方,由于重复的工做一直在作,如今会怎么作呢?

首先分析出每次建立一个 NSFetchRequest 都必需要硬编码进实体名字,而且还须要建立多个中间实体对象和真正对象模型的中间代码,由于存入 Core Data 的数据字段所有依赖 API 模型字段是确定不行的,因此几乎在每个视图查询方法里都写了大量的兼容代码,非常难看。

最后在这个项目里,又遇到了一样的问题。第二个问题基本无解,就是得要写两个模型,不然你的 Core Data 模型字段就会变得「无比巨大」,因此仍是写了两个 model 分别针对 Core Data 和 API 模型。

对于第一个问题,能够经过协议的方式去解决:

protocol Managed: class, NSFetchRequestResult {
    static var entityName: String { get }
    static var defaultSortDescriptors: [NSSortDescriptor] { get }
}

extension Managed {
    static var defaultSortDescriptors: [NSSortDescriptor] {
        return []
    }

    static var sortedFetchRequest: NSFetchRequest<Self> {
        let request = NSFetchRequest<Self>(entityName: entityName)
        request.sortDescriptors = defaultSortDescriptors
        return request
    }
}

extension Managed where Self: NSManagedObject {
    static var entityName: String { return entity().name!  }
}
复制代码

经过以上方式,只要 NSManagedObject 类型的对象遵循了 Managed 协议能够能够经过 entityName 属性获取到实体名字,而不须要硬编码字符串去作识别了。按照 UI 图中所展现的内容,基本上也都是按推文的建立时间倒序排序,因此为了避免用在每一个 NSFetchRequest 中都写 sortDescriptors 也给了一个默认实现,查询数据时只须要经过调用 sortedFetchRequest 属性便可配置完毕。

如今什么都配置好了,就差把数据切上列表进行展现了。若是是按照我以前的写法,经过 allxxxModels() 方法的返回值拿到的数据后,得手动的同步 UITableViewreloadData(),但如今咱们使用的但是 SwiftUI 啊~若是还用以前 UIKit 的方法确定是不符合 SwiftUI 的 workflow。

若是你关注过 SwiftUI 那对 @State@BindingObject@EnvironmentObject 确定不陌生,这几个修饰词的定义我是从组件的角度出发去看的,固然还能够有其它的一些使用思路。三个属性在个人使用过程当中我是这么定义的:

  • @State:组件内数据或状态的传递;
  • @BindingObject:跨组件间的数据传递;
  • @EnvironmentObject:跨组件间的数据传递。从名字上看出,也能够设置一些不可变的环境值,后续会尝试用在用户管理部分。

若是要作到符合 SwiftUI 官方推荐的数据流处理方式,咱们须要定义一个遵照 ObservableObject 协议的类,经过这个类去作数据的发送:

class AritcleManager: NSObject, ObservableObject {
    
    @Published var willChange = PassthroughSubject<Void, Never>()
    
    var articles = [Article]() {
        willSet {
            willChange.send()
        }
    }
}
复制代码

注意,这是我从 SwiftUI beta4 迁移到 beta5 的代码,使用 beta5 以前的版本都跑不起来。其中特别扎眼的是 @Published var willChange = PassthroughSubject<Void, Never>() 这行代码,在 beta5 以前,这行代码会这么写 var willChange = PassthroughSubject<Void, Never>()

其中 <Void, Never> 的解释是,第一个参数表示这次通知抛出去的数据是什么,Void 表示所有抛出去,有些文章中写的本类名,本质上是一个意思。第二个参数表示这次抛出通知时的错误定义,若是遇到错误了,要抛出什么类型的错误,Never 表明不处理错误。这点其实很差,应该根据实际上会遇到的问题抛出异常,后续文章会继续完善。

其实代码中已经说的很明白了,当咱们修改 articles 时,触发 willSet 方法调用 send() 方法触发通知的发送,接着咱们在其它地方经过 @BindObject 去监听这个通知便可:

struct MASSquareListView : View {
    // 在内部实例化便可,由于只有该 `View` 使用到
    @State var articleManage = AritcleManager()
    @State var squareListViewModel: MASSquareListViewModel
    
    var body: some View {
        List(self.articleManage.articles, id: \.createdAt) { article in
            MASSquareNormalCellView(article: article)
                .padding(EdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5))
        }
    }
}
复制代码

因此若是咱们直接按照以前的作法,经过 NSFetchRequest 拿到的数据后,在更新 articles 的值也能完成需求,这也是我以前的作法,但总不能一个实现直接套在多个项目中对吧,那这样也太没劲了,所以为了更好切合 Core Data 的使用方式,咱们用上 NSFetchedResultsController 来管理数据。

使用 NSFetchedResultsController 来管理数据,咱们能够不用理会 Core Data 数据增删改查的变化,只须要关注 NSFetchedResultsController 的代理方法,其中个人实现是:

extension AritcleManager: NSFetchedResultsControllerDelegate {
    func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
        articles = controller.fetchedObjects as! [Article]
    }
}
复制代码

我并无把全部的方法都实现完,若是咱们是使用传统的 UITableView 去实现,可能会须要再把剩下的几个代理方法实现完。在此,个人我的推荐作法是,若是你的实体须要处理「某些事情」,那每个实体最好都作一个 manager 去对 NSFetchedResultsControllerDelegate 协议作实现,由于颇有可能每个实体在 NSFetchedResultsControllerDelegate 协议中的各个代理方法须要关注的点都不同,不能一巴掌拍死,什么都抽象。

经过 NSFetchedResultsController 实现数据的改动监听后,在实例化 AritcleManager 时,要作补上一些配置工做:

class AritcleManager: NSObject, ObservableObject {
    
    @Published var willChange = PassthroughSubject<Void, Never>()
    
    var articles = [Article]() {
        willSet {
            willChange.send()
        }
    }
    fileprivate var fetchedResultsController: NSFetchedResultsController<Article>
    
    override init() {

        let request = Article.sortedFetchRequest
        request.fetchBatchSize = 20
        request.returnsObjectsAsFaults = false
        self.fetchedResultsController = NSFetchedResultsController(fetchRequest: request, managedObjectContext: MASCoreData.shared.persistentContainer.viewContext, sectionNameKeyPath: nil, cacheName: nil)
        
        super.init()
        
        fetchedResultsController.delegate = self
        
        // 执行方法后,当即返回
        try! fetchedResultsController.performFetch()
        articles = fetchedResultsController.fetchedObjects!
    }
}
复制代码

经过以上代码的操做,咱们就完成当 Core Data 中的 Article 实体数据发生改动时,会直接把改动发送到外部全部监听者。

咱们如今来看看如何插入一条数据。我以前会这么作:

func addxxxModel(models: [xxxModel]) -> Bool{
    
    for model in models {
        let entity = NSEntityDescription.insertNewObject(forEntityName: "xxxModel", into: context!) as! xxxModel
        
        // 作一些插入前的最后准备工做
    }
    do {
        try context?.save()
        print("保存成功")
        return true
    } catch {
        print("不能保存:\(error)")
        return false
    }
}
复制代码

能够看出插入数据时仍是得依赖 context 去作管理,按照咱们以前的想法,经过 NSFetchedResultsController 去监听的数据的改变是为了达到不须要每次都经过 context 调用 fetch 方法拉取最新的数据,但插入数据的必定得是「手动」完成的,必须是要显示调用。

所以,咱们能够对这种「重复性」操做进行封装,不用再像我以前那样为每个实体都写一个插入方法:

extension NSManagedObjectContext {
    func insertObject<T: NSManagedObject>() -> T where T: Managed {
        guard let obj = NSEntityDescription.insertNewObject(forEntityName: T.entityName, into: self) as? T else { fatalError("error object type") }
        return obj
    }
}
复制代码

使用泛型限定方法内返回对象的调用方是 NSManagedObject 类型,使用 where 限定调用方必须遵循 Managed 协议。因此,咱们能够对 ArticleCore Data 模型修改成:

final class Article: NSManagedObject {
    @NSManaged var content: String
    @NSManaged var type: Int16
    @NSManaged var uid: Int32
    @NSManaged var avatarImage: Int16
    @NSManaged var avatarColor: Int16
    @NSManaged internal var createdAt: Date
    
    static func insert(viewModel: Article.ViewModel) -> Article {
        
        let context = MASCoreData.shared.persistentContainer.viewContext
        
        let p_article: Article = context.insertObject()
        p_article.content = viewModel.content
        p_article.avatarColor = Int16(viewModel.avatarColor)
        p_article.avatarImage = Int16(viewModel.avatarImage)
        p_article.type = Int16(viewModel.type)
        p_article.uid = Int32(2015011206)
        p_article.createdAt = Date()
        
        return p_article
    }
}
复制代码

后记

你会发现到这里,咱们实际上并无对 SwiftUICore Data 作其它的上下文依赖工做,这是由于咱们使用了 NSFetchedResultsController 去动态监听的 Article 实体的数据改动,而后经过 @Publisher 修饰的对象调用 send() 方法发送更新后的数据。

在这篇文章中使用的 Combine 主要体如今 Core Data 的数据获取和更新不须要主动的告知 UI。固然,若是你硬是要说这些事情并不须要的 Combine 去支持也是能够的,由于基于 Notification 确实也能够作到。关于 Combine 更细节的内容将会随着本项目的进展进行完善。

注意:本篇文章中的部份内容由于项目在持续进展,部份内容实现会不太符合最终或目前常规作法。

参考资料

Core Data

项目地址:Masq iOS 客户端

原文连接:Masq 开发总结之 SwiftUI 怎么和 Core Data 结合?

相关文章
相关标签/搜索