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

前言

Core Data 是一个令人又爱又恨的东西,爱它因为系统原生支持,可以和 Xcode 完美的结合,恨它因为在会在一些极端的情况下导致不可预测的问题,比如初始化时不可避免的时间消耗,各种主线程依赖操作等。据我所知,西瓜视频和今日头条原先强依赖 Core Data,但因为「某些性能」问题,均已全部撤出。

既然已经有了赤裸裸的教训,为什么我还要执意上 Core Data 呢?刚才也说了,因为「某些性能」问题才导致了这两款 app 下掉 Core Data,但一般的 side project 可以不用考虑这些问题,再加上 WWDC19 中与 Core Data 相关的 session 有四场,明星光环足够了!

Core Data 的封装使用

创建模型

首先来看完成图,

Masq

这是一个非常简单的列表,在 UIKit 中我们只需要 UITableView 一顿操作即可完事,代码不过区区几十行,用 SwiftUI 封装好的话,主列表只需要不到十行即可完成,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
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 或自己的服务器上后续的文章中再展开。

Core Data 官方组成图

从图中可以看出,我们的 Model 属于 NSManagerObjectModel,可以按照这篇文章 所描述的如何创建 .xcdatamodeld 文件。

创建完成后,我们可以根据之前的分析的 UI 组成把实体属性定义为如下图所示:

实体定义

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

我们可以选择让 Core Data 自动生成与模型相匹配的代码也可以自己写。通过阅读 「objc 中国」的 Core Data 书籍,了解原来自己写匹配的模型代码不会有太多的工作,而且还能加深对模型生成的理解过程(之前为了省事都是让 Core Data 自动生成,完成的模型代码如下:

1
2
3
4
5
6
7
8
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 去管理多个实体,且只有一个存储管理器。为了方便后续调用数据管理方法的便利,而且存储器不需要重复创建,我拉出了一个单例去管理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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()
}
}
}
}

在初始化时,我们可以这么用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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 数据查询来看,之前我是这么写的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 模型。

对于第一个问题,可以通过协议的方式去解决:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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 协议的类,通过这个类去做数据的发送:

1
2
3
4
class AritcleManager: NSObject, ObservableObject {

@Published var articles = [Article]()
}

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

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

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

在最新的 beta5 中,我们只需要 @Publisher 修饰需要监听数据变化的变量即可,Combine 会在数据发生变化时自动调用 send() 方法对外进行广播,不再需要我们手动调用,当然之前的方式也依然可以继续使用。

1
2
3
4
5
6
7
8
9
10
11
12
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 的代理方法,其中我的实现是:

1
2
3
4
5
extension AritcleManager: NSFetchedResultsControllerDelegate {
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
articles = controller.fetchedObjects as! [Article]
}
}

我并没有把所有的方法都实现完,如果我们是使用传统的 UITableView 去实现,可能会需要再把剩下的几个代理方法实现完。在此,我的个人推荐做法是,如果你的实体需要处理「某些事情」,那每一个实体最好都做一个 manager 去对 NSFetchedResultsControllerDelegate 协议做实现,因为很有可能每一个实体在 NSFetchedResultsControllerDelegate 协议中的各个代理方法需要关注的点都不一样,不能一巴掌拍死,什么都抽象。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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 实体数据发生改动时,会直接把改动发送到外部所有监听者。

我们现在来看看如何插入一条数据。我之前会这么做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 方法拉取最新的数据,但插入数据的一定得是「手动」完成的,必须是要显示调用。

因此,我们可以对这种「重复性」操作进行封装,不用再像我之前那样为每一个实体都写一个插入方法:

1
2
3
4
5
6
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 模型修改为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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 客户端