SwiftUI 怎麼和 Core Data 結合?

PJHubs 發表於2019-08-13

最近新起了一個 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 封裝好的話,主列表只需要不到十行即可完成,如下所示:

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 自動生成,完成的模型程式碼如下:

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 結合?

優秀的人遵守規則,頂尖的人創造規則