📒 SwiftUI + CoreData 的實戰心得🔥

SwiftUI + CoreData

file

這篇文章會紀錄我在目前的 side project 上,如何在 SwiftUI 下導入 CoreData;
而如同 在 SwiftUI 處理中文輸入法所會遇上的問題 所提及的,SwiftUI 身為一個還在測試階段的 framework,我們必須將當下的開發環境紀錄下來,以避免造成日後官方修正所造成的誤解。

開發環境

  • Xcode 11 Beta 7(但顯示為 Beta 6 - 11M392r )
  • macOS Catalina - 19A546d
  • iOS 13.1 Beta

使用情境

我要做一個貨幣的列表,並讓使用者可以對相對應的貨幣做隱藏與否,所以規格大概是需要一個 List,而 Row 裡頭呈現貨幣的名稱以及用 Toggle 來做控制隱藏的開關。

CoreData Model - Currency

file

Row 的部分

Xcode 11 Beta 5 之後NSManagedObject 可以視為一個 @ObservedObject,所以我們可以不必再弄一個 ManageRowModel,而是直接使用 NSManagedObject 來連動。
這邊我需要以英文大寫來顯示貨幣名稱以及一個控制是否顯示在主畫面上的開關。

struct ManageRow: View {
    @ObservedObject var currency: Currency

    var body: some View {
        HStack {
            VStack {
                Text(currency.name?.uppercased() ?? String())
                    .font(.title)
                    .fontWeight(.bold)
            }
            .padding()
            Toggle(isOn: $currency.isPresented) {
                Text(String())
            }
            .padding()
        }
    }
}

畫面如下方所呈現的樣式:

file

我們可以直接讓 Toggle(isOn: _)Currency.isPresented 連動,這樣便可以直接修改到相對應的值。

View 的部分

在 SwiftUI 裡頭有提供一個 @FetchRequest(fetchRequest: 的 propertyWrapper,而若要使用的話,記得要一併宣告 NSManagedObjectContext@Environment 之中,否則會報錯:

struct ManageView: View {
    @Environment(\.managedObjectContext) private var context
    @FetchRequest(fetchRequest: fetchRequest()) var currencies: FetchedResults

    var body: some View {
        List(currencies, rowContent: ManageRow.init)
    }

    static func fetchRequest() -> NSFetchRequest<Currency> {
        let request: NSFetchRequest = Currency.fetchRequest()
        request.sortDescriptors = [NSSortDescriptor(keyPath: \Currency.name, ascending: true)]
        return request
    }
}

這樣就可以在畫面建立時,透過 SwiftUI 的機制去執行 fetchRequest 並呈現出來。

⚠️ 注意事項

Identifible 的使用

若要在 List 之中直接使用 NSManagedObject 作為 RandomAccessCollection,可以讓它符合 Identifible

extension Currency: Identifible {}

但這個意味著我們會以預設的 id,如 NSManagedObject.objectId 作為是否需要重新繪製畫面的依據,而在這邊會遇上一個問題:
當我們將這個 List 作為另一個畫面的 Sheet 時,當它出現時,並不會重新繪製 List
也就是說已經在畫面上的 Row,會出現第一次畫面的狀態且之後 Sheet 出現都還會保持一樣的畫面。

簡單來說就是開關的狀態不會改變,除非使用者自己上下滑動觸動 SwiftUI 重新繪製的機制

所以我必須讓系統知道,isPresented 若有改變過的話,需要更新 row 的畫面

extension Currency: Identifiable {
    public var id: String { "\(String(describing: name)) \(isPresented)" }
}

這邊我目前的作法是將 id 包含了 isPresented 的狀態,所以當同一個 objectId 有不同的 isPresented 時,對於 List 是不同的 Row,這時就會重新繪製成正確的畫面。

@Environment(.managedObjectContext) 的使用

我這邊的使用情境是,使用者點擊一個設定的按鈕會跳出一個 ManageView,而 ManageView 可以點擊儲存或是直接滑掉放棄當前操作。
所以在前一個畫面上,我們需要為它設立 NSManagedObjectContext,並在 onDismiss 時捨棄掉這次的操作。

struct ContentView: View {
    ...
    var body: some View {
        ...
        .sheet(isPresented: $isManagePresented,
                   onDismiss: { CoreDataStack.shared.backgroundContext.rollback() },
                   content: manageView)
    }

    private func manageView() -> some View {
        ManageView().environment(\.managedObjectContext, CoreDataStack.shared.backgroundContext)
    }
}

以上便是目前在 SwiftUI 上實作 CoreData 的分享📒

CoreData with background task

前言

雖然在 iOS 上開發了幾年的時間,但一直到最近才開始使用 CoreData;之前在第一份工作的專案之中是使用 FMDB 來處理資料存取,而後續則是用了 Realm

選擇的原因分別是當時 FMDB 的速度較其餘兩者快速,而後來看上 Realm 的跨平台特色,不過近期開發的感想是能以原生為主的話,就儘量降低對於第三方套件的依賴性。

這篇文章會記錄些什麼

其實這篇文章並不會從頭到尾寫下教學,而把重點放在一些我踩到的雷上,像是⋯⋯

記得要附上 sqlite 的路徑

原先我的 persistentContainer 的產生方式如下

lazy var persistentContainer: NSPersistentContainer = {
    let container = NSPersistentContainer(name: "OfflineWallet")
    let description = NSPersistentStoreDescription()
    description.shouldInferMappingModelAutomatically = true
    description.shouldMigrateStoreAutomatically = true
    container.persistentStoreDescriptions = [description]
    container.loadPersistentStores { _, error in
        if let error = error {
            fatalError("Unresolved error \(error), \(String(describing: error._userInfo))")
        }
    }
    return container
}()

在模擬器上存取了幾次,每次都有 save 且第二次進入畫面的時候,都可以 fetch 得到資料,但是只要重開 App 就會從頭來過⋯⋯

也就是說其實都只是像是存在 NSManagedObjectContext 上,而並沒有實際地轉成 sqlite

補上指定的 URL 即可解決。

...
let documentsDirectoryURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
let sqliteURL = documentsDirectoryURL?.appendingPathComponent("OfflineWallet.sqlite")
description.url = sqliteURL
...

performBackgroundTask v.s newBackgroundContext

要實作 backgroundTask 有兩種作法

NSPersistentContainer.performBackgroundTask

persistentContainer.performBackgroundTask { context in
    //do something in background thread
}

NSPersistentContainer 會建立一個 context 在這個 closure 裡頭使用,而重點是當這條 thread 結束之後,這個 context 所管理的物件(NSManagedObjectModel)的所有變數會釋放掉,也就是都會變成 nil

適合用於取得資料並轉型成其他 class / structure 的時候使用。

newBackgroundContext

而如果你必須在後續的程式之中繼續使用 context 所產生的 NSManagedObjectModel 時,你就得要保存其 context;像是建立一個 backgroundContext 並存下來使用:

lazy var backgroundContext = persistentContainer.newBackgroundContext()

//you should use this context to do something what you want
backgroundContext.perform {
//do something like before
}

此時由於這個 context 並沒有被釋放掉,所以其 NSManagedObjectModel 的所有變數便也會持有著;而依然是在其他 thread 上進行,並不會佔據 main thread。

待續

分頁讀取

筆記一下 NSFetchRequest 有提供 fetchLimit 以及 fetchOffset 可以用來做分頁讀取的功能。

Bitnami