SwiftUI 怎么和 Core Data 結(jié)合?[轉(zhuǎn)]

前言

Core Data 是一個令人又愛又恨的東西,愛它因為系統(tǒng)原生支持,可以和 Xcode 完美的結(jié)合,恨它因為在會在一些極端的情況下導(dǎo)致不可預(yù)測的問題,比如初始化時不可避免的時間消耗,各種主線程依賴操作等。據(jù)我所知,西瓜視頻和今日頭條原先強依賴 Core Data,但因為「某些性能」問題,均已全部撤出。

既然已經(jīng)有了赤裸裸的教訓(xùn),為什么我還要執(zhí)意上 Core Data 呢?剛才也說了,因為「某些性能」問題才導(dǎo)致了這兩款 app 下掉 Core Data,但一般的 side project 可以不用考慮這些問題,再加上 WWDC19 中與 Core Data 相關(guān)的 session 有四場,明星光環(huán)足夠了!

Core Data 的封裝使用

創(chuàng)建模型

首先來看完成圖,

image.png

這是一個非常簡單的列表,在 UIKit 中我們只需要 UITableView 一頓操作即可完事,代碼不過區(qū)區(qū)幾十行,用 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))
        }
    }
}

現(xiàn)在假設(shè)我們的列表已經(jīng)做好了,現(xiàn)在先來思考列表上需要輸入的數(shù)據(jù),再來一張圖進(jìn)行解析:

image.png

每一個 Cell 里所需要輸入的數(shù)據(jù)有「頭像」、「創(chuàng)建時間」和「內(nèi)容」,在這一篇文章中我們只考慮存粹和 Core Data 進(jìn)行交互的第一步,如何讓 Core Data 的推上 CloudKit 或自己的服務(wù)器上后續(xù)的文章中再展開。

image.png

從圖中可以看出,我們的 Model 屬于 NSManagerObjectModel,可以按照這篇文章 所描述的如何創(chuàng)建 .xcdatamodeld 文件。

創(chuàng)建完成后,我們可以根據(jù)之前的分析的 UI 組成把實體屬性定義為如下圖所示:

image.png
  • avatarColor: 頭像分成為了「顏色」和「圖片」兩個部分,每一張圖片都是 帶透明通道的 png 類型圖片。用戶可使用的顏色只能是 app 里被定義好的幾種;
  • avatarImage:如上;
  • content:內(nèi)容,該字段在服務(wù)端原本是長文本,此處用 String 保持一致;
  • createdAt:創(chuàng)建時間;
  • type:考慮到后續(xù)每一條推文都有可能是不同的形態(tài),比如帶不帶 flaglink;
  • uid:該條推文所需的用戶 ID。該字段在此篇文章中所講述的內(nèi)容是多余字段,你可以不用加上,之前是考慮到了后續(xù)的工作,后續(xù)再加也無妨。

我們可以選擇讓 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 文件對應(yīng)的實體上選擇剛寫好的模型類和取消 Core Data 自動生成代碼的選項即可:

image.png

這一部分實際上我們做的是定義被存儲的實體結(jié)構(gòu),換句話說,通過上述操作去描述你要存儲的數(shù)據(jù)。

創(chuàng)建一個 Core Data 存儲結(jié)構(gòu)

在這個環(huán)節(jié)中,之前我的做法都是在 AppDelegate 中按照 Xcode 的生成模版創(chuàng)建的存儲器,以完成需求為導(dǎo)向,導(dǎo)致后續(xù)再繼續(xù)接入存儲其它實體時,代碼質(zhì)量比較粗糙,經(jīng)過一番學(xué)習(xí)后,調(diào)整了方向。

來看一張 「 objc 中國」上的 Core Data 的存儲結(jié)構(gòu)圖:

image.png

圖中已經(jīng)把我們可以怎么做說的非常明白了,可以有多個實體,通過 context 去管理各個實體的操作,context 再通過協(xié)調(diào)器跟存儲器產(chǎn)生交互,與底層數(shù)據(jù)庫產(chǎn)生交互。這張圖實際上與后續(xù)我們要把數(shù)據(jù)推上 CloudKit 的過程非常類似,但本篇文章中我們將使用「 objc 中國」的這張圖的方式去完成:

image.png

通過一個 context 去管理多個實體,且只有一個存儲管理器。為了方便后續(xù)調(diào)用數(shù)據(jù)管理方法的便利,而且存儲器不需要重復(fù)創(chuàng)建,我拉出了一個單例去管理:

class MASCoreData {
    static let shared = MASCoreData()
    var persistentContainer: NSPersistentContainer!
    /// 創(chuàng)建一個存儲容器
    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: 這么做有些粗暴,不能數(shù)據(jù)庫創(chuàng)建失敗就頁面白屏,本篇文章只考慮需求實現(xiàn),剩下內(nèi)容后續(xù)文章講解
    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 初始化時,就創(chuàng)建好了一個可用的存儲器。

數(shù)據(jù)交互

模型有了,存儲器有了,那就要開始做增刪改查了。實際上對 Core Data 的增刪改查實現(xiàn),已經(jīng)有了眾多的文章去講解,在此不做展開。以我之前做 Core Data 數(shù)據(jù)查詢來看,之前我是這么寫的:

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 []}
        // 做一些數(shù)據(jù)讀取出來的操作 ......

        print("查詢成功")
        return finalModels
    }
    catch {
        print("查詢失?。篭(error)")
        return []
    }
}

其實一眼看上去也還好,我之前也覺得很好,但是當(dāng)我寫了三四個實體后,發(fā)現(xiàn)每個新建實體的查詢方法都需要去復(fù)制之前寫好的查詢方法,改改參數(shù)就用了,當(dāng)時覺得有些不太對勁的地方,因為重復(fù)的工作一直在做,現(xiàn)在會怎么做呢?

首先分析出每次創(chuàng)建一個 NSFetchRequest 都必須要硬編碼進(jìn)實體名字,并且還需要創(chuàng)建多個中間實體對象和真正對象模型的中間代碼,因為存入 Core Data 的數(shù)據(jù)字段全部依賴 API 模型字段是肯定不行的,所以幾乎在每一個視圖查詢方法里都寫了大量的兼容代碼,很是難看。

最后在這個項目里,又遇到了同樣的問題。第二個問題基本無解,就是得要寫兩個模型,否則你的 Core Data 模型字段就會變得「無比巨大」,所以還是寫了兩個 model 分別針對 Core Data 和 API 模型。

對于第一個問題,可以通過協(xié)議的方式去解決:

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 協(xié)議可以可以通過 entityName 屬性獲取到實體名字,而不需要硬編碼字符串去做識別了。按照 UI 圖中所展示的內(nèi)容,基本上也都是按推文的創(chuàng)建時間倒序排序,所以為了不用在每個 NSFetchRequest 中都寫 sortDescriptors 也給了一個默認(rèn)實現(xiàn),查詢數(shù)據(jù)時只需要通過調(diào)用 sortedFetchRequest 屬性即可配置完畢。

現(xiàn)在什么都配置好了,就差把數(shù)據(jù)切上列表進(jìn)行展示了。如果是按照我之前的寫法,通過 allxxxModels() 方法的返回值拿到的數(shù)據(jù)后,得手動的同步 UITableViewreloadData(),但現(xiàn)在我們使用的可是 SwiftUI 啊~如果還用之前 UIKit 的方法肯定是不符合 SwiftUI 的 workflow。

如果你關(guān)注過 SwiftUI 那對 @State、@BindingObject@EnvironmentObject 肯定不陌生,這幾個修飾詞的定義我是從組件的角度出發(fā)去看的,當(dāng)然還可以有其它的一些使用思路。三個屬性在我的使用過程中我是這么定義的:

  • @State:組件內(nèi)數(shù)據(jù)或狀態(tài)的傳遞;
  • @BindingObject:跨組件間的數(shù)據(jù)傳遞;
  • @EnvironmentObject:跨組件間的數(shù)據(jù)傳遞。從名字上看出,也可以設(shè)置一些不可變的環(huán)境值,后續(xù)會嘗試用在用戶管理部分。

如果要做到符合 SwiftUI 官方推薦的數(shù)據(jù)流處理方式,我們需要定義一個遵守 ObservableObject 協(xié)議的類,通過這個類去做數(shù)據(jù)的發(fā)送:

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> 的解釋是,第一個參數(shù)表示此次通知拋出去的數(shù)據(jù)是什么,Void 表示全部拋出去,有些文章中寫的本類名,本質(zhì)上是一個意思。第二個參數(shù)表示此次拋出通知時的錯誤定義,如果遇到錯誤了,要拋出什么類型的錯誤,Never 代表不處理錯誤。這點其實不好,應(yīng)該根據(jù)實際上會遇到的問題拋出異常,后續(xù)文章會繼續(xù)完善。

其實代碼中已經(jīng)說的很明白了,當(dāng)我們修改 articles 時,觸發(fā) willSet 方法調(diào)用 send() 方法觸發(fā)通知的發(fā)送,接著我們在其它地方通過 @BindObject 去監(jiān)聽這個通知即可:

struct MASSquareListView : View {
    // 在內(nèi)部實例化即可,因為只有該 `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 拿到的數(shù)據(jù)后,在更新 articles 的值也能完成需求,這也是我之前的做法,但總不能一個實現(xiàn)直接套在多個項目中對吧,那這樣也太沒勁了,因此為了更好切合 Core Data 的使用方式,我們用上 NSFetchedResultsController 來管理數(shù)據(jù)。

使用 NSFetchedResultsController 來管理數(shù)據(jù),我們可以不用理會 Core Data 數(shù)據(jù)增刪改查的變化,只需要關(guān)注 NSFetchedResultsController 的代理方法,其中我的實現(xiàn)是:

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

我并沒有把所有的方法都實現(xiàn)完,如果我們是使用傳統(tǒng)的 UITableView 去實現(xiàn),可能會需要再把剩下的幾個代理方法實現(xiàn)完。在此,我的個人推薦做法是,如果你的實體需要處理「某些事情」,那每一個實體最好都做一個 manager 去對 NSFetchedResultsControllerDelegate 協(xié)議做實現(xiàn),因為很有可能每一個實體在 NSFetchedResultsControllerDelegate 協(xié)議中的各個代理方法需要關(guān)注的點都不一樣,不能一巴掌拍死,什么都抽象。

通過 NSFetchedResultsController 實現(xiàn)數(shù)據(jù)的改動監(jiān)聽后,在實例化 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

        // 執(zhí)行方法后,立即返回
        try! fetchedResultsController.performFetch()
        articles = fetchedResultsController.fetchedObjects!
    }
}

通過以上代碼的操作,我們就完成當(dāng) Core Data 中的 Article 實體數(shù)據(jù)發(fā)生改動時,會直接把改動發(fā)送到外部所有監(jiān)聽者。

我們現(xiàn)在來看看如何插入一條數(shù)據(jù)。我之前會這么做:

func addxxxModel(models: [xxxModel]) -> Bool{

    for model in models {
        let entity = NSEntityDescription.insertNewObject(forEntityName: "xxxModel", into: context!) as! xxxModel

        // 做一些插入前的最后準(zhǔn)備工作
    }
    do {
        try context?.save()
        print("保存成功")
        return true
    } catch {
        print("不能保存:\(error)")
        return false
    }
}

可以看出插入數(shù)據(jù)時還是得依賴 context 去做管理,按照我們之前的想法,通過 NSFetchedResultsController 去監(jiān)聽的數(shù)據(jù)的改變是為了達(dá)到不需要每次都通過 context 調(diào)用 fetch 方法拉取最新的數(shù)據(jù),但插入數(shù)據(jù)的一定得是「手動」完成的,必須是要顯示調(diào)用。

因此,我們可以對這種「重復(fù)性」操作進(jìn)行封裝,不用再像我之前那樣為每一個實體都寫一個插入方法:

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
    }
}

使用泛型限定方法內(nèi)返回對象的調(diào)用方是 NSManagedObject 類型,使用 where 限定調(diào)用方必須遵循 Managed 協(xié)議。所以,我們可以對 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
    }
}

后記

你會發(fā)現(xiàn)到這里,我們實際上并沒有對 SwiftUICore Data 做其它的上下文依賴工作,這是因為我們使用了 NSFetchedResultsController 去動態(tài)監(jiān)聽的 Article 實體的數(shù)據(jù)改動,然后通過 @Publisher 修飾的對象調(diào)用 send() 方法發(fā)送更新后的數(shù)據(jù)。

在這篇文章中使用的 Combine 主要體現(xiàn)在 Core Data 的數(shù)據(jù)獲取和更新不需要主動的告知 UI。當(dāng)然,如果你硬是要說這些事情并不需要的 Combine 去支持也是可以的,因為基于 Notification 確實也可以做到。關(guān)于 Combine 更細(xì)節(jié)的內(nèi)容將會隨著本項目的進(jìn)展進(jìn)行完善。

注意:本篇文章中的部分內(nèi)容因為項目在持續(xù)進(jìn)展,部分內(nèi)容實現(xiàn)會不太符合最終或目前常規(guī)做法

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容