--- title: 'SwiftUI CoreData' tags: SwiftUI disqus: hackmd --- **目錄:** [TOC] ## 設定 當您創建 Xcode 專案時,不要選"use coreData"框,因為雖然它消除了一些無聊的設定程式碼,但它還添加了一大堆額外的範例程式碼,這些程式碼毫無意義,只是需要被刪除。 因此,您將學習如何手動設定核心數據。它需要三個步驟,從我們定義要在應用程式中使用的資料開始。 ### 建立 Bookworm.xcdatamodeld 我們建立一個:按 Cmd+N 建立一個新文件,從模板清單中選擇資料模型,然後將模型命名為 Bookworm.xcdatamodeld。 ![截圖 2025-01-06 下午5.09.13](https://hackmd.io/_uploads/Skip6GF8Jg.png) 設定要儲存的參數 ![image](https://hackmd.io/_uploads/r1JlJQFLke.png) ### 建立 DataComtroller 建立一個名為 DataController.swift 的新 Swift 文件,並將其新增到其行上方import Foundation: ```swift= import CoreData ``` 創建一個 ObservableObject 在這個類別中,我們將新增一個 type 屬性NSPersistentContainer,它是核心資料類型,負責載入資料模型並讓我們存取內部資料。 ```swift= class DataController: ObservableObject { let container = NSPersistentContainer(name: "Bookworm") } ``` 這告訴 Core Data 我們想要使用 Bookworm 資料模型。 這邊只是單純宣告,它實際上並沒有加載 要實際載入資料模型,我們需要呼叫 loadPersistentStores() 容器,它告訴 Core Data 根據 Bookworm.xcdatamodeld 中的資料模型存取我們保存的資料。 這不會同時將所有資料載入到記憶體中,因為這會造成浪費,但至少 Core Data 可以看到我們擁有的所有資訊。 ```swift= init() { container.loadPersistentStores { description, error in if let error = error { print("Core Data failed to load: \(error.localizedDescription)") } } } ``` 這段完成了 DataController 的實作,接下來的最後一步是創建一個 DataController 的實例,並將其傳入 SwiftUI 的環境中。 因為大多數應用程式通常只會使用一個 Core Data 存儲。 與其讓每個視圖各自創建自己的存儲,不如在應用啟動時只創建一次,然後將其存放在 SwiftUI 的環境中,這樣應用的其他部分就能方便地使用它了。 要完成這個操作,請打開 App.swift,並在結構體中新增以下屬性: ```swift= @StateObject private var dataController = DataController() WindowGroup { ContentView() .environment(\.managedObjectContext, dataController.container.viewContext) } ``` ## 讀取、寫入、刪除與更新 SwiftUI 提供了一個屬性包裝器(property wrapper),稱為 @FetchRequest,至少需要一個參數來描述結果的排序方式,它的格式比較特定。 ```swift= @FetchRequest(sortDescriptors: []) var students: FetchedResults<Student> ``` 這段代碼創建了一個沒有排序的提取請求,並將其放入名為 students 的屬性中,該屬性的類型是 FetchedResults<Student>。 讀取: ```swift= VStack { List(students) { student in Text(student.name ?? "Unknown") } } ``` ::: info student.name 是一個可選值,它可能有值,也可能沒有值。 這是 Core Data 中一個讓人頗感煩惱的地方之一,它有可選數據的概念,但這與 Swift 的可選值概念是完全不同的。 如果我們告訴 Core Data「這個屬性不能是可選的」(你可以在模型編輯器中設定),它仍然會生成可選的 Swift 屬性,因為對於 Core Data 來說,只要在保存時這些屬性有值就可以,其他時候它們可以是 nil。 ::: 寫入、更新: - 寫入與更新一致 ```swift= // 利用環境變數中的 managedObjectContext 來保存數據 @Environment(\.managedObjectContext) var moc ``` ```swift= let student = Student(context: moc) // 創建一個新的 Student 對象 student.id = UUID() student.name = "\(firstNames.randomElement() ?? "Unknown") \(lastNames.randomElement() ?? "Unknown")" try? moc.save() // 保存變更到托管上下文 ``` 刪除: - 單一刪除 ```swift= if let student = students.first { moc.delete(student) try? moc.save() // 記得要保存,否則不會更新DB } ``` - 全部刪除 ```swift= private func deleteAllData() { let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "Student") let batchDeleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) do { try context.executeAndMergeChanges(using: batchDeleteRequest) // 重置緩存,避免讀取到舊數據 context.reset() } catch { // handle error here print("BATCH DELETE FAILED") } fetchStudents() } extension NSManagedObjectContext { /// Executes the given `NSBatchDeleteRequest` and directly merges the changes to bring the given managed object context up to date. /// /// - Parameter batchDeleteRequest: The `NSBatchDeleteRequest` to execute. /// - Throws: An error if anything went wrong executing the batch deletion. public func executeAndMergeChanges(using batchDeleteRequest: NSBatchDeleteRequest) throws { batchDeleteRequest.resultType = .resultTypeObjectIDs let result = try execute(batchDeleteRequest) as? NSBatchDeleteResult // 執行刪除 let changes: [AnyHashable: Any] = [NSDeletedObjectsKey: result?.result as? [NSManagedObjectID] ?? []] NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [self]) // 合併變更 } } ``` | 步驟 | 目的 | | -------- | -------- | | NSBatchDeleteRequest | 直接刪除 Persistent Store 中的資料 | | execute(batchDeleteRequest) | 執行批次刪除 | | resultType = .resultTypeObjectIDs | 取得刪除的 NSManagedObjectID | | mergeChanges(fromRemoteContextSave:) | 通知 NSManagedObjectContext 更新變更 | | context.reset() | 清除快取,確保 UI 不會讀取到已刪除的資料 | | fetchStudents() | 重新載入資料,確保 UI 立即更新 | ## 如何限制取得請求中的項目數量 ```swift= @FetchRequest(fetchRequest: fetchRequestLimit10()) var students: FetchedResults<Student> static func fetchRequestLimit10() -> NSFetchRequest<Student> { let request: NSFetchRequest<Student> = Student.fetchRequest() request.fetchLimit = 10 request.sortDescriptors = [NSSortDescriptor(keyPath: \Student.name, ascending: true)] return request } ``` :::danger 特別要注意的是,FetchedResultsController是依賴普通的NSFetchRequest的,而這個request至少需要指定一種sort,否則就會報一個錯誤: ::: ```swift= 'NSInvalidArgumentException', reason: 'An instance of NSFetchedResultsController requires a fetch request with sort descriptors' ``` ## 關聯 以下是幾種常見的資料庫關聯關係: 一對一(One-to-One)關聯: 在一對一關聯中,一個表的一筆記錄只與另一個表的一筆記錄關聯。 這種關聯適用於將兩個獨立的實體資訊分開存儲,但有時需要將它們連結在一起。 如前言所述,一個「使用者」表可以與一個「使用者主頁」表關聯,使每個使用者記錄關聯一個唯一的使用者主頁記錄。 一對多(One-to-Many)關聯: 在一對多關聯中,一個表格的一筆記錄可以與另一個表格的多筆記錄關聯。 這是最常見的關聯類型之一。 如前言所述,一個「使用者」表可以與一個「訂單」表關聯,一個使用者可以下多個訂單。 多對多(Many-to-Many)關聯: 多對多關聯表示兩個表中的多個記錄可以相互關聯。 為了實現這種關聯,通常需要一個中間表來連接這兩個表。 一個「使用者」表和一個「角色」表可以透過一個「使用者角色」表來實現多對多關聯,表示一個使用者可以兼任多個角色,一個角色可以賦予多個使用者。 關聯關係使資料庫能夠更好地管理資料之間的複雜關係,同時也能夠提高查詢的效率和資料的一致性。在設計資料庫時,正確使用關聯關係是十分重要的,這有助於保持資料的完整性和減少資料冗餘。 ### 一對一 建立實體 建立 User 使用者實體 ![image](https://hackmd.io/_uploads/Bynbs8qL1x.png) 建立 Page 主頁實體 ![image](https://hackmd.io/_uploads/HkMvnI5Ukl.png) 回到 User 配置關聯 ![image](https://hackmd.io/_uploads/Sy6epIqL1x.png) 配置說明: Relationship 為 page,表示指定關聯關係的名稱為 page,這個關係名稱可以隨意取,但最好得有意義,一眼能看出這個關係代表什麼,這裡用page表示用戶的主頁 Destination 為 Page,表示 User 實體的關聯對方為 Page 實體 右側面板中的Type則很重要,決定實體的關聯關係,這裡我們選擇的是(To One)即一對一 一對一的資料操作 ```swift= @Environment(\.managedObjectContext) var viewContext // 用户列表 @FetchRequest(sortDescriptors: [ NSSortDescriptor(keyPath: \User.timestamp, ascending: true) ]) var users: FetchedResults<User> ``` 讀取 ```swift= let title = user.page?.title ?? "" ``` 新增 ```swift= let user = User(context: viewContext) let page = Page(context: viewContext) user.name = "小明" user.score = 50 user.timestamp = Date() page.title = "小明的主頁" page.content = "主頁展示的内容" user.page = page // 注意這邊 try? viewContext.save() ``` ### 反向一對一 一對一和反向一對一隻是站在不同的角度上看待事物的關係,如果我們以User為主,正向一對一即 User 關聯 Page,在這個關係中我們說 Page 則是 User 的反向一對一關係。 我們進入Core Data模型檔案中,配置反向關聯關係 ![image](https://hackmd.io/_uploads/B1FgZDc8Je.png) 這裡我們是以 Page 實體為主題,要反向關聯的是用戶,等於是我們需要在 Page 的實例中,添加一個用戶的實例信息,而第二步關聯關係中的 Inverse 則要指定 User 關聯關係的 page 來建立反向關聯關係 ```swift= @FetchRequest(sortDescriptors: [ NSSortDescriptor(keyPath: \Page.title, ascending: true) ]) var pages: FetchedResults<Page> ``` 讀取 ```swift= let pageName = page.user?.name ?? "" ``` 新增(其實就是倒過來換成 pencil 塞 student 而已) ```swift= let user = User(context: viewContext) page.user = user // 注意這邊 try? viewContext.save() ``` ### 一對多 建立 Order 實體 ![image](https://hackmd.io/_uploads/r1I0nP9U1x.png) 配置User實體與Order實體的關聯關係 ![image](https://hackmd.io/_uploads/HJE1awqUJx.png) 上圖步驟一中,我們配置 Relationship 中的關係名稱為 orders,名稱採用複數寓意這個關係屬性下有多個訂單。 步驟二 Type 類型則也要選擇 To Many 一對多數據的操作 ```swift= @Environment(\.managedObjectContext) var viewContext // 用户列表 @FetchRequest(sortDescriptors: [ NSSortDescriptor(keyPath: \User.timestamp, ascending: true) ]) var users: FetchedResults<User> ``` 讀取 ```swift= if let orders = user.orders?.allObjects as? [Order] { print(orders) } ``` 新增 ```swift= let user = User(context: viewContext) user.name = "小明" user.score = 50 user.timestamp = Date() let order1 = Order(context: viewContext) order1.no = UUID() order1.total = 100 let order2 = Order(context: viewContext) order2.no = UUID() order2.total = 200 user.orders = [order1, order2] try? viewContext.save() ``` 新增邏輯與一對一唯一不同點在於 user.page 接受一個 Page 對象,而user.orders 接受的是一個數組,我們只需要實例多個Order並拼裝成一個數組傳入即可。 渲染邏輯則是透過 User 實例的關聯關係屬 user.orders?.allObjects 拿到其所有關聯物件再循環渲染 ### 多對多 用戶與用戶角色我們認為可以是多對多的關係,一個用戶可以兼任多個角色,一個角色可以賦予多個用戶 建立 Role ![image](https://hackmd.io/_uploads/SJnc4_981l.png) 在 User 使用者實體中配置與 Role 角色實體的關係 ![image](https://hackmd.io/_uploads/SklnEOcUkx.png) 在 Role 角色實體中設定與 User 使用者實體的關係 ![image](https://hackmd.io/_uploads/SJPMBd58Jl.png) 多對多的概念是站在上帝視角去看使用者和角色的關係,但站在其中任意一方的視角上雙方都互為對方的一對多。 在實際的應用中,同樣我們只會以一方為主體去關聯查詢另一方的數據,所以配置時在雙方的Type都是選擇To Many(一對多) 當維度降到一對多時,程式碼和資料操作就跟上一小節的一對多毫無差別,因此這邊讀取與新增都跟上面沒差別 ## 版本化與遷移 輕量級遷移的運作 輕量級遷移可以自動處理: • 新增或刪除表。 • 在現有表中新增屬性(欄位)。 • 將屬性設置為可選或添加默認值。 • 基本的屬性類型轉換(例如從 Int 到 Double)。 那為何需要手動遷移呢 即使輕量級遷移處理了大部分場景,也有一些變更可能會導致問題,例如: 1. 刪除屬性或表 如果刪除了某個屬性或表,Core Data 需要知道如何處理已有的數據,這可能需要手動遷移。 2. 將欄位從可選改為必填 這需要為所有現有數據提供默認值,否則可能導致遷移失敗。 3. 改變欄位的類型或關聯結構 例如,將一對多的關聯改為多對多,可能需要自定義遷移。 ### 1.添加新版本 在 Xcode 中,創建新版本的操作如下: 1. 打開 .xcdatamodeld 文件。 2. 點擊左下角的齒輪按鈕,選擇 Add Model Version。 3. 給新版本命名,例如 MyModel 2。 4. 確保新版本的基礎是現有版本(它會自動複製現有結構)。 5. 編輯新版本的結構(例如新增表、新增欄位等)。 ![截圖 2025-01-10 下午2.56.24](https://hackmd.io/_uploads/HyIySHR8kg.png) ### 2.設置當前版本 完成新版本的編輯後: 1. 選擇 .xcdatamodeld 文件。 2. 在右側的 Model Version 區域中,將新版本設置為 Current。 ![截圖 2025-01-10 下午2.58.06](https://hackmd.io/_uploads/SygHHHAIyx.png) 這樣,Xcode 會將新版本設為默認模型版本,應用啟動時將使用它。