Swift 定制 Core Data 遷移
前言
隨著應用程序和用戶群的增長,你需要添加新功能,刪除其他功能,并改變應用程序的工作方式。這是軟件開發生命周期的自然結果,我們應該接受。
隨著應用程序的發展,你的數據模型也會發生變化。你需要更改數據結構的方式,以適應新功能,同時確保用戶不會在不同版本之間丟失任何數據。如果你使用 Core Data 在應用程序中持久化信息,那么 Core Data 遷移就會發揮作用。
什么是 Core Data 遷移?
Core Data 遷移是將數據模型從一個版本更新到另一個版本的過程,因為數據的形狀發生了變化(例如,添加或刪除新屬性)。
在大多數情況下,Core Data 將自動處理遷移過程。但是,有些情況下,你需要通過提供一個映射模型來自定義遷移過程,告訴 Core Data 究竟如何從源模型遷移到目標模型中的每個屬性和實體。
甚至有些情況下,映射模型是不夠的,你需要編寫自定義遷移策略來處理特定情況。這是本文要重點討論的情況。
示例
讓我們考慮一個應用程序,在 Core Data 棧中存儲表示音樂曲目的對象。模型非常簡單,只包含一個實體:Track
,Track.swift 代碼如下:
Copy code
Track.swift
import Foundation
import CoreData
@objc(Track)
public class Track: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Track> {
return NSFetchRequest<Track>(entityName: "Track")
}
@NSManaged public var imageURL: String?
@NSManaged public var json: String?
@NSManaged public var lastPlayedAt: Date?
@NSManaged public var title: String?
@NSManaged public var artistName: String?
}
上面的 Track 實體有五個屬性:
- imageURL:表示曲目封面圖像的 URL 的字符串。
- json:表示來自服務器的原始 JSON 數據響應的字符串。
- lastPlayedAt:表示上次播放曲目的日期。
- title:表示曲目的標題的字符串。
- artistName:表示藝術家的名稱的字符串。
Core Data 棧不會與 iCloud 同步,并具有以下設置,CoreDataStack.swift 文件代碼如下:
Copy code
CoreDataStack.swift
import CoreData
struct PersistenceController {
static let shared = PersistenceController()
let container: NSPersistentContainer
init(inMemory: Bool = false) {
container = NSPersistentContainer(name: "CustomMigration")
if inMemory {
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
}
container.viewContext.automaticallyMergesChangesFromParent = true
if let description = container.persistentStoreDescriptions.first {
description.shouldMigrateStoreAutomatically = true
description.shouldInferMappingModelAutomatically = false
}
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
}
}
如果你仔細觀察上面的示例,你會注意到我們告訴 Core Data 自動遷移存儲,因為我們不想做漸進式遷移,這種遷移速度慢得多且更復雜,并且我們還告訴 Core Data 不要自動推斷映射模型,這意味著我們將不得不為每個遷移提供一個映射模型文件,并且可以允許我們自定義這個過程。
持久化了一首歌曲后,使用 Core Data Lab 檢查數據庫,我們可以看到屬性被相應保存:
更新模型
當前版本的模型存在一些可擴展性問題:
- 模型僅允許每個曲目有一個藝術家,而實際上,一個曲目可以有多個藝術家。
- 模型存儲一個表示曲目數據的原始 JSON 字符串,這不太高效,當應用程序需要解析 JSON 字符串以顯示曲目數據以獲取藝術家列表時,可能會導致性能問題。
為了解決這些問題,讓我們刪除 artistName 和 json 屬性,采用一個新的 Artist 實體,該實體將與 Track 實體建立一對多的關系。
Artist 實體將具有一個表示藝術家名稱的 name 屬性,以及 id 和 imageURL 屬性,我們將從原始 JSON 字符串中獲取它們。
創建一個新的模型版本
首先,讓我們通過選擇 .xcdatamodeld 文件,然后從菜單欄中選擇 Editor > Add Model Version... 來創建一個新的模型版本。
給它起一個名稱,并以第一個模型版本為基礎:
現在,讓我們創建 Artist 實體并添加所有字段:
也讓我們為新的 Artist 實體創建 NSManagedObject 子類,Artist.swift 代碼如下:
Copy code
import Foundation
import CoreData
@objc(Artist)
public class Artist: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Artist> {
return NSFetchRequest<Artist>(entityName: "Artist")
}
@NSManaged public var name: String?
@NSManaged public var id: String?
@NSManaged public var imageURL: String?
@NSManaged public var tracks: NSSet?
@objc(addTracksObject:)
@NSManaged public func addToTracks(_ value: Track)
@objc(removeTracksObject:)
@NSManaged public func removeFromTracks(_ value: Track)
@objc(addTracks:)
@NSManaged public func addToTracks(_ values: NSSet)
@objc(removeTracks:)
@NSManaged public func removeFromTracks(_ values: NSSet)
}
正如你在上面的示例中看到的那樣,我們將向 Track 實體添加一個對多的 artists 關系,還將向 Artist 實體添加一個對多的 tracks 關系。
現在,讓我們為 Track 實體添加缺失的關系,并刪除 artistName 和 json 屬性:
并更新 NSManagedObject 子類以反映更改,Track.swift 文件代碼如下:
import Foundation
import CoreData
@objc(Track)
public class Track: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Track> {
return NSFetchRequest<Track>(entityName: "Track")
}
@NSManaged public var imageURL: String?
@NSManaged public var lastPlayedAt: Date?
@NSManaged public var title: String?
@NSManaged public var artists: NSSet?
@objc(addArtistsObject:)
@NSManaged public func addToArtists(_ value: Artist)
@objc(removeArtistsObject:)
@NSManaged public func removeFromArtists(_ value: Artist)
@objc(addArtists:)
@NSManaged public func addToArtists(_ values: NSSet)
@objc(removeArtists:)
@NSManaged public func removeFromArtists(_ values: NSSet)
}
最后但并非最不重要的,讓我們將新的模型設置為 .xcdatamodeld 文件的當前模型:
創建映射模型
由于我們告訴 Core Data 不要自動推斷映射模型,所以我們將不得不創建一個映射模型文件來在兩個版本之間建立橋梁。
從菜單欄中選擇 File > New > File...,然后選擇 Mapping Model。
然后,選擇源模型:
最后,選擇目標模型:
編寫自定義遷移策略
默認情況下,Core Data 將盡力映射屬性,并且大部分工作都將由它自動完成(包括已刪除的屬性)。
然而,由于我們創建了一個新的實體,并且我們希望保留現有數據,因此我們需要告訴 Core Data 如何遷移。
我們將創建一個新的類,該類繼承自 NSEntityMigrationPolicy,并在舊的 Track 實體上創建并鏈接一個新的關系到 Artist 實體,V2MigrationPolicy.swift 文件代碼如下:
Copy code
import CoreData
struct Song: Decodable {
let artists: [Artist]
struct Artist: Decodable {
let id: String
let name: String
let imageURL: String
}
}
class V2MigrationPolicy: NSEntityMigrationPolicy {
private let decoder = JSONDecoder()
override func createDestinationInstances(forSource sInstance: NSManagedObject, in mapping: NSEntityMapping, manager: NSMigrationManager) throws {
// 1
let sourceKeys = sInstance.entity.attributesByName.keys
let sourceValues = sInstance.dictionaryWithValues(forKeys: sourceKeys.map { $0 as String })
// 2
let destinationInstance = NSEntityDescription.insertNewObject(forEntityName: mapping.destinationEntityName!, into: manager.destinationContext)
let destinationKeys = destinationInstance.entity.attributesByName.keys.map { $0 as String }
// 3
for key in destinationKeys {
if let value = sourceValues[key] {
destinationInstance.setValue(value, forKey: key)
}
}
if let jsonString = sInstance.value(forKey: "json") as? String {
// 3
let jsonData = Data(jsonString.utf8)
let object = try? decoder.decode(Song.self, from: jsonData)
// 4
let artists: [NSManagedObject] = object?.artists.map { jsonArtist in
// 5
let request = Artist.fetchRequest()
request.fetchLimit = 1
request.predicate = NSPredicate(format: "name == %@", jsonArtist.name)
// Do not add duplicates to the list...
if let matchedArtists = try? manager.destinationContext.fetch(request), let matchedArtist = matchedArtists.first {
return matchedArtist
}
// 6
let artist = NSEntityDescription.insertNewObject(forEntityName: "Artist", into: manager.destinationContext)
artist.setValue(jsonArtist.name, forKey: "name")
artist.setValue(jsonArtist.imageURL, forKey: "imageURL")
artist.setValue(jsonArtist.id, forKey: "id")
return artist
} ?? []
// 7
destinationInstance.setValue(Set<NSManagedObject>(artists), forKey: "artists")
}
// 8
manager.associate(sourceInstance: sInstance, withDestinationInstance: destinationInstance, for: mapping)
}
}
讓我們逐步解釋上面的代碼:
- 獲取源實體的屬性名稱和值。
- 創建與源實體相同類型的全新目標實體。
- 將源實體的屬性值復制到目標實體。
- 如果源實體具有 json 屬性,則將其解析為 Song 對象。
- 為避免重復項,請檢查藝術家是否已經存在于目標上下文中。
- 如果藝術家不存在,則創建一個新的 Artist 實體,將其插入到上下文中,并設置其屬性。
- 設置目標實體上的新藝術家關系。
- 將源和目標實例關聯起來。
最后,讓我們將此自定義策略添加到映射模型中:
現在,如果我們再次運行應用程序并使用 Core Data Lab 檢查數據庫,我們可以看到一個新的實體已經填充了正確的數據。
總結
文章介紹了在應用程序發展過程中,數據模型可能需要進行更改的情況下,如何使用 Core Data 遷移來保持數據的一致性和完整性。首先,它解釋了什么是 Core Data 遷移,以及為什么需要進行遷移。接著,通過一個示例應用程序,詳細介紹了如何更新數據模型,添加新實體和關系,以解決現有模型的可擴展性問題。然后,文章介紹了如何創建映射模型來定義不同模型版本之間的映射關系,并演示了如何編寫自定義遷移策略來處理特定情況,例如將舊模型數據遷移到新模型的新關系中。最后,通過將自定義遷移策略添加到映射模型中,完成了整個遷移過程。