最近新起了一個 side project,用於承載 WWDC19 裏公佈的內容。這篇文章主要講述了
SwiftUI
和Core Data
怎麼結合,以及本身遇到的問題和思考的第〇篇。git
Core Data
是一個使人又愛又恨的東西,愛它由於系統原生支持,能夠和 Xcode 完美的結合,恨它由於在會在一些極端的狀況下致使不可預測的問題,好比初始化時不可避免的時間消耗,各類主線程依賴操做等。據我所知,西瓜視頻和今日頭條原先強依賴 Core Data
,但由於「某些性能」問題,均已所有撤出。github
既然已經有了赤裸裸的教訓,爲何我還要執意上 Core Data
呢?剛纔也說了,由於「某些性能」問題才致使了這兩款 app 下掉 Core Data
,但通常的 side project 能夠不用考慮這些問題,再加上 WWDC19 中與 Core Data
相關的 session 有四場,明星光環足夠了!數據庫
Core Data
的封裝使用首先來看完成圖,swift
這是一個很是簡單的列表,在 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)) } } } 複製代碼
如今假設咱們的列表已經作好了,如今先來思考列表上須要輸入的數據,再來一張圖進行解析:服務器
每個 Cell 裏所須要輸入的數據有「頭像」、「建立時間」和「內容」,在這一篇文章中咱們只考慮存粹和 Core Data
進行交互的第一步,如何讓 Core Data
的推上 CloudKit
或本身的服務器上後續的文章中再展開。markdown
從圖中能夠看出,咱們的 Model 屬於 NSManagerObjectModel
,能夠按照這篇文章 所描述的如何建立 .xcdatamodeld
文件。session
建立完成後,咱們能夠根據以前的分析的 UI 組成把實體屬性定義爲以下圖所示:app
avatarColor
: 頭像分紅爲了「顏色」和「圖片」兩個部分,每一張圖片都是 帶透明通道的 png
類型圖片。用戶可以使用的顏色只能是 app 裏被定義好的幾種;avatarImage
:如上;content
:內容,該字段在服務端本來是長文本,此處用 String
保持一致;createdAt
:建立時間;type
:考慮到後續每一條推文都有多是不一樣的形態,好比帶不帶 flag
或 link
;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
的存儲結構圖:
圖中已經把咱們能夠怎麼作說的很是明白了,能夠有多個實體,經過 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()
方法的返回值拿到的數據後,得手動的同步 UITableView
作 reloadData()
,但如今咱們使用的但是 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
協議。因此,咱們能夠對 Article
的 Core 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 } } 複製代碼
你會發現到這裏,咱們實際上並無對 SwiftUI
與 Core Data
作其它的上下文依賴工做,這是由於咱們使用了 NSFetchedResultsController
去動態監聽的 Article
實體的數據改動,而後經過 @Publisher
修飾的對象調用 send()
方法發送更新後的數據。
在這篇文章中使用的 Combine
主要體如今 Core Data
的數據獲取和更新不須要主動的告知 UI。固然,若是你硬是要說這些事情並不須要的 Combine
去支持也是能夠的,由於基於 Notification
確實也能夠作到。關於 Combine
更細節的內容將會隨着本項目的進展進行完善。
注意:本篇文章中的部份內容由於項目在持續進展,部份內容實現會不太符合最終或目前常規作法。
項目地址:Masq iOS 客戶端