最近新起了一個 side project,用於承載 WWDC19 裡公佈的內容。這篇文章主要講述了
SwiftUI
和Core Data
怎麼結合,以及自己遇到的問題和思考的第〇篇。
前言
Core Data
是一個令人又愛又恨的東西,愛它因為系統原生支援,可以和 Xcode 完美的結合,恨它因為在會在一些極端的情況下導致不可預測的問題,比如初始化時不可避免的時間消耗,各種主執行緒依賴操作等。據我所知,西瓜影片和今日頭條原先強依賴 Core Data
,但因為「某些效能」問題,均已全部撤出。
既然已經有了赤裸裸的教訓,為什麼我還要執意上 Core Data
呢?剛才也說了,因為「某些效能」問題才導致了這兩款 app 下掉 Core Data
,但一般的 side project 可以不用考慮這些問題,再加上 WWDC19 中與 Core Data
相關的 session 有四場,明星光環足夠了!
Core Data
的封裝使用
建立模型
首先來看完成圖,
這是一個非常簡單的列表,在 UIKit
中我們只需要 UITableView
一頓操作即可完事,程式碼不過區區幾十行,用 SwiftUI
封裝好的話,主列表只需要不到十行即可完成,如下所示:
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
或自己的伺服器上後續的文章中再展開。
從圖中可以看出,我們的 Model 屬於 NSManagerObjectModel
,可以按照這篇文章 所描述的如何建立 .xcdatamodeld
檔案。
建立完成後,我們可以根據之前的分析的 UI 組成把實體屬性定義為如下圖所示:
avatarColor
: 頭像分成為了「顏色」和「圖片」兩個部分,每一張圖片都是 帶透明通道的png
型別圖片。使用者可使用的顏色只能是 app 裡被定義好的幾種;avatarImage
:如上;content
:內容,該欄位在服務端原本是長文字,此處用String
保持一致;createdAt
:建立時間;type
:考慮到後續每一條推文都有可能是不同的形態,比如帶不帶flag
或link
;uid
:該條推文所需的使用者 ID。該欄位在此篇文章中所講述的內容是多餘欄位,你可以不用加上,之前是考慮到了後續的工作,後續再加也無妨。
我們可以選擇讓 Core Data
自動生成與模型相匹配的程式碼也可以自己寫。透過閱讀 「objc 中國」的 Core Data 書籍,瞭解原來自己寫匹配的模型程式碼不會有太多的工作,而且還能加深對模型生成的理解過程(之前為了省事都是讓 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
}
模型程式碼寫好後,再去 .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 客戶端
原文連結:Masq 開發總結之 SwiftUI 怎麼和 Core Data 結合?
本作品採用《CC 協議》,轉載必須註明作者和本文連結