自定義 Core Data 遷移似乎是一個不太起眼的話題。蘋果在這方面只提供了很少的文檔,若是初次涉足此方面內(nèi)容,很可能會變成一個可怕的經(jīng)歷。鑒于客戶端程序的性質(zhì),你無法測試你的用戶所生成的數(shù)據(jù)集的所有可能排列。此外,解決遷移過程中出現(xiàn)的問題會很困難,而因為極有可能你的代碼依賴于最新的數(shù)據(jù)模型,所以回退并不是一個可選的處理辦法。
在本文中,我們將走一遍搭建自定義 Core Data 遷移的過程,并著重于數(shù)據(jù)模型的重構(gòu)。我們將探討從舊模型中提取數(shù)據(jù)并使用這些數(shù)據(jù)來填充具有新的實體和關(guān)系的目標(biāo)模型。此外,會有一個包含單元測試的示例項目用于演示兩個自定義遷移。
需要注意的是,如果對數(shù)據(jù)模型的修改只有增加一個實體或可選屬性,輕量級的遷移是一個很好的選擇。它們非常易于設(shè)置,所以本文只會稍稍提及它們。若想知道輕量級遷移的應(yīng)用場合,請查看官方文檔。
這就是說,如果你需要快速地在你的數(shù)據(jù)模型上進行相對復(fù)雜的改變,那么自定義遷移就是為你準(zhǔn)備的。
當(dāng)你要升級你的數(shù)據(jù)模型到新版,你將先選擇一個基準(zhǔn)模型。對于輕量級遷移,持久化存儲會為你自動推斷一個映射模型。然而,如果你對新模型所做的修改并不被輕量級遷移所支持,那么你就需要創(chuàng)建一個映射模型。一個映射模型需要一個源數(shù)據(jù)模型和一個目標(biāo)數(shù)據(jù)模型。 NSMigrationManager 能夠推斷這兩個模型間的映射模型。這使得它很誘人,可用來一路創(chuàng)建每一個以前的模型到最新模型之間的映射模型,但這很快就會變成一團亂麻。對于每一個新版模型,你需要創(chuàng)建的映射模型的量將線性增長。這可能看起來不是個大問題,但隨之而來的是測試這些映射模型的復(fù)雜度大大提高了。
想像一下你剛剛部署一個包含版本 3 的數(shù)據(jù)模型的更新。你的某個用戶已經(jīng)有一段時間沒有更新你的應(yīng)用了,這個用戶還在版本 1 的數(shù)據(jù)模型上。那么現(xiàn)在你就需要一個從版本 1 到版本 3 的映射模型。同時你也需要版本 2 到版本 3 的映射模型。當(dāng)你添加了版本 4 的數(shù)據(jù)模型后,那你就需要創(chuàng)建三個新的映射模型。顯然這樣做的擴展性很差,那就來試試漸進式遷移吧。
與其為每個之前的數(shù)據(jù)模型到最新的模型間都建立映射模型,還不如在每兩個連續(xù)的數(shù)據(jù)模型之間創(chuàng)建映射模型。以前面的例子來說,版本 1 和版本 2 之間需要一個映射模型,版本 2 和版本 3 之間需要一個映射模型。這樣就可以從版本 1 遷移到版本 2 再遷移到版本 3。顯然,使用這種遷移的方式時,若用戶在較老的版本上遷移過程就會比較慢,但它能節(jié)省開發(fā)時間并保證健壯性,因為你只需要確保從之前一個模型到新模型的遷移工作正常即可,而更前面的映射模型都已經(jīng)經(jīng)過了測試。
總的想法就是手動找出當(dāng)前版本 v 和版本 v+1 之間的映射模型,在這兩者間遷移,接著繼續(xù)遞歸,直到持久化存儲與當(dāng)前的數(shù)據(jù)模型兼容。
這一過程看起來像下面這樣(完整版可以在示例項目里找到):
- (BOOL)progressivelyMigrateURL:(NSURL *)sourceStoreURL
ofType:(NSString *)type
toModel:(NSManagedObjectModel *)finalModel
error:(NSError **)error
{
NSDictionary *sourceMetadata = [NSPersistentStoreCoordinator metadataForPersistentStoreOfType:type
URL:sourceStoreURL
error:error];
if (!sourceMetadata) {
return NO;
}
if ([finalModel isConfiguration:nil
compatibleWithStoreMetadata:sourceMetadata]) {
if (NULL != error) {
*error = nil;
}
return YES;
}
NSManagedObjectModel *sourceModel = [self sourceModelForSourceMetadata:sourceMetadata];
NSManagedObjectModel *destinationModel = nil;
NSMappingModel *mappingModel = nil;
NSString *modelName = nil;
if (![self getDestinationModel:&destinationModel
mappingModel:&mappingModel
modelName:&modelName
forSourceModel:sourceModel
error:error]) {
return NO;
}
// 我們現(xiàn)在有了一個映射模型,開始遷移
NSURL *destinationStoreURL = [self destinationStoreURLWithSourceStoreURL:sourceStoreURL
modelName:modelName];
NSMigrationManager *manager = [[NSMigrationManager alloc] initWithSourceModel:sourceModel
destinationModel:destinationModel];
if (![manager migrateStoreFromURL:sourceStoreURL
type:type
options:nil
withMappingModel:mappingModel
toDestinationURL:destinationStoreURL
destinationType:type
destinationOptions:nil
error:error]) {
return NO;
}
// 現(xiàn)在遷移成功了,把文件備份一下以防不測
if (![self backupSourceStoreAtURL:sourceStoreURL
movingDestinationStoreAtURL:destinationStoreURL
error:error]) {
return NO;
}
// 現(xiàn)在數(shù)據(jù)模型可能還不是“最新”版,所以接著遞歸
return [self progressivelyMigrateURL:sourceStoreURL
ofType:type
toModel:finalModel
error:error];
}
這段代碼主要來源于 Marcus Zarra,他寫了一本很棒的關(guān)于 Core Data 的書,查看這里。
自 iOS 7 和 OS Mavericks以來,Apple 將 SQLite 的日志模式改寫為預(yù)寫式日志 (Write-Ahead Logging), 這意味著數(shù)據(jù)庫事務(wù)都被依附到一個 -wal 文件中。這有可能導(dǎo)致數(shù)據(jù)丟失和異常。為了數(shù)據(jù)的安全,我們會將日志模式改寫為回溯模式。而如果我們想要遷移數(shù)據(jù)(或者為了以后備份),我們可以將一個字典傳遞給 -addPersistentStoreWithType:configuration:URL:options:error: 來完成改寫。
@{ NSSQLitePragmasOption: @{ @"journal_mode": @"DELETE” } }
與 NSPersistentStoreCoordinator 相關(guān)的代碼可以在這里找到。
NSEntityMigrationPolicy 是自定義遷移過程的核心。 蘋果的文檔中有這么一句話:
NSEntityMigrationPolicy的實例為一個實體映射自定義的遷移策略。
簡單的說,這個類讓我們不僅僅能修改實體的屬性和關(guān)系,而且還能任意添加一些自定義的操作來完成每個實體的遷移。
假設(shè)我們有一個帶有簡單的數(shù)據(jù)模型的書籍應(yīng)用。這個模型有兩個實體: User 和 Book 。Book 實體有一個屬性叫做 authorName。我們想改善這個模型,添加一個新的實體: Author。同時我們想為 Book 和 Author 建立一個多對多的關(guān)系,因為一本書籍可有多個作者,而一個作者也可寫多本書籍。我們將從 Book 對象里取出 authorName 用于填充一個新的實體并建立關(guān)系。
一開始我們要做的是基于第一個數(shù)據(jù)模型增加一個新版模型。在這個例子里,我們添加了一個 Author 實體,它與 Book 還有多對多的關(guān)系。
http://wiki.jikexueyuan.com/project/objc/images/4-3.png" alt="" />
現(xiàn)在數(shù)據(jù)模型已經(jīng)是我們所需要的,但我們還需要遷移所有已存在的數(shù)據(jù),這就該 NSEntityMigrationPolicy 出場了。我們創(chuàng)建 NSEntityMigrationPolicy 的一個子類---- MHWBookToBookPolicy 。在映射模型里,我們選擇 Book 實體并設(shè)置它作為公共部分(Utilities section)中的自定義策略。
http://wiki.jikexueyuan.com/project/objc/images/4-4.png" alt="" />
同時我們使用 user info 字典來設(shè)置一個 modelVersion ,它將在未來的遷移中派上用場。
在 MHWBookToBookPolicy 中,我們將重載 -createDestinationInstancesForSourceInstance:entityMapping:manager:error: 方法,它允許我們自定義如何遷移每個 Book 實例。如果 modelVersion 的值不是 2,我們將調(diào)用父類的實現(xiàn),否則我們就要做自定義遷移。我們插入基于映射的目標(biāo)實體的新 NSManagedObject 對象到目標(biāo)上下文。然后我們遍歷目標(biāo)實例的屬性鍵值并與來自源實例的值一起填充它們。這將保證我們保留現(xiàn)存數(shù)據(jù)并避免設(shè)置任何我們已經(jīng)在目標(biāo)實例中移除的值。
NSNumber *modelVersion = [mapping.userInfo valueForKey:@"modelVersion"];
if (modelVersion.integerValue == 2) {
NSMutableArray *sourceKeys = [sourceInstance.entity.attributesByName.allKeys mutableCopy];
NSDictionary *sourceValues = [sourceInstance dictionaryWithValuesForKeys:sourceKeys];
NSManagedObject *destinationInstance = [NSEntityDescription insertNewObjectForEntityForName:mapping.destinationEntityName
inManagedObjectContext:manager.destinationContext];
NSArray *destinationKeys = destinationInstance.entity.attributesByName.allKeys;
for (NSString *key in destinationKeys) {
id value = [sourceValues valueForKey:key];
// 避免value為空
if (value && ![value isEqual:[NSNull null]]) {
[destinationInstance setValue:value forKey:key];
}
}
}
然后我們將基于源實例的值創(chuàng)建一個 Author 實體。但若多本書有同一個作者會發(fā)生什么呢?我們將使用 NSMigrationManager 的一個 category 方法來創(chuàng)建一個查找字典,確保對于同一個名字的作者,我們只會創(chuàng)建一個 Author。
NSMutableDictionary *authorLookup = [manager lookupWithKey:@"authors"];
// 檢查該作者是否已經(jīng)被創(chuàng)建了
NSString *authorName = [sourceInstance valueForKey:@"author"];
NSManagedObject *author = [authorLookup valueForKey:authorName];
if (!author) {
// 創(chuàng)建作者
// ...
// 更新避免重復(fù)
[authorLookup setValue:author forKey:authorName];
}
[destinationInstance performSelector:@selector(addAuthorsObject:) withObject:author];
最后,我們需要告訴遷移管理器在源存儲與目的存儲之間關(guān)聯(lián)數(shù)據(jù):
[manager associateSourceInstance:sourceInstance
withDestinationInstance:destinationInstance
forEntityMapping:mapping];
return YES;
NSMigrationManager 的 category 方法:
@implementation NSMigrationManager (Lookup)
- (NSMutableDictionary *)lookupWithKey:(NSString *)lookupKey
{
NSMutableDictionary *userInfo = (NSMutableDictionary *)self.userInfo;
// 這里檢查一下是否已經(jīng)建立了 userInfo 的字典
if (!userInfo) {
userInfo = [@{} mutableCopy];
self.userInfo = userInfo;
}
NSMutableDictionary *lookup = [userInfo valueForKey:lookupKey];
if (!lookup) {
lookup = [@{} mutableCopy];
[userInfo setValue:lookup forKey:lookupKey];
}
return lookup;
}
@end
過了一會,我們又想把 fileURL 這個屬性從 Book 實體里提出來,放入一個叫做 File 的新實體里。同時我們還想修改實體之間的關(guān)系,以便 User 可與 File 有一對多的關(guān)系,而反過來 File 和 Book 有多對一的關(guān)系。
http://wiki.jikexueyuan.com/project/objc/images/4-5.png" alt="" />
在之前的遷移中,我們只遷移了一個實體。而現(xiàn)在當(dāng)我們添加了 File 后,事情變得有些復(fù)雜了。我們不能簡單地在遷移一個 Book 時插入一個 File 實體并設(shè)置它與 User 的對應(yīng)關(guān)系,因為此時 User 實體還沒有被遷移,之間的關(guān)系也無從談起。我們必須考慮遷移的執(zhí)行順序。在映射模型中,是可以改變實體映射的順序的。具體到這里的例子,我們想將 UserToUser 映射放在 BookToBook 映射之上。這保證了 User 實體會比 Book 實體更早遷移。
http://wiki.jikexueyuan.com/project/objc/images/4-6.png" alt="" />
添加一個 File 實體的途徑和創(chuàng)建 Author 的過程相似。我們在 MHWBookToBookPolicy 中遷移 Book 實體時創(chuàng)建 File 對象。我們會查看源實例的 User 實體,為每個 User 實體創(chuàng)建一個新的 File 對象,并建立對應(yīng)關(guān)系:
NSArray *users = [sourceInstance valueForKey:@"users"];
for (NSManagedObject *user in users) {
NSManagedObject *file = [NSEntityDescription insertNewObjectForEntityForName:@"File"
inManagedObjectContext:manager.destinationContext];
[file setValue:[sourceInstance valueForKey:@"fileURL"] forKey:@"fileURL"];
[file setValue:destinationInstance forKey:@"book"];
NSInteger userId = [[user valueForKey:@"userId"] integerValue];
NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:@"User"];
request.predicate = [NSPredicate predicateWithFormat:@"userId = %d", userId];
NSManagedObject *user = [[manager.destinationContext executeFetchRequest:request error:nil] lastObject];
[file setValue:user forKey:@"user"];
}
如果你的存儲包含了大量數(shù)據(jù),以至到達一個臨界點,遷移就會消耗過多內(nèi)存,Core Data 提供了一個以數(shù)據(jù)塊(chunks)的方式遷移的辦法。蘋果的文檔有簡要地提到這件事。解決辦法是使用多映射模型分開你的遷移并為每個映射模型遷移一次。這要求你有一個對象圖(object graph),在其中,遷移可被分為兩個或多個部分。為了支持這一點而需要添加的代碼其實很少。
首先,我們更新遷移方法以支持使用多個映射模型來遷移。已知映射模型的順序很重要,我們將通過代理方法請求它們:
NSArray *mappingModels = @[mappingModel]; // 我們之前建立的那個模型
if ([self.delegate respondsToSelector:@selector(migrationManager:mappingModelsForSourceModel:)]) {
NSArray *explicitMappingModels = [self.delegate migrationManager:self
mappingModelsForSourceModel:sourceModel];
if (0 < explicitMappingModels.count) {
mappingModels = explicitMappingModels;
}
}
for (NSMappingModel *mappingModel in mappingModels) {
didMigrate = [manager migrateStoreFromURL:sourceStoreURL
type:type
options:nil
withMappingModel:mappingModel
toDestinationURL:destinationStoreURL
destinationType:type
destinationOptions:nil
error:error];
}
現(xiàn)在,我們?nèi)绾沃獣阅囊粋€映射模型被用于這個特定的源模型呢?此處的 API 可能顯得有些笨拙,但以下的解決方法確實完成了工作。在代理方法中,我們找出源模型的名字并返回相關(guān)的映射模型:
- (NSArray *)migrationManager:(MHWMigrationManager *)migrationManager
mappingModelsForSourceModel:(NSManagedObjectModel *)sourceModel
{
NSMutableArray *mappingModels = [@[] mutableCopy];
NSString *modelName = [sourceModel mhw_modelName];
if ([modelName isEqual:@"Model2"]) {
// 把該映射模型加入數(shù)組
}
return mappingModels;
}
我們將為 NSManagedObjectModel 添加一個 category,以幫助我們找出它的文件名:
We’ll add a category on NSManagedObjectModel that helps us figure out its filename:
- (NSString *)mhw_modelName
{
NSString *modelName = nil;
NSArray *modelPaths = // get paths to all the mom files in the bundle
for (NSString *modelPath in modelPaths) {
NSURL *modelURL = [NSURL fileURLWithPath:modelPath];
NSManagedObjectModel *model = [[NSManagedObjectModel alloc] initWithContentsOfURL:modelURL];
if ([model isEqual:self]) {
modelName = modelURL.lastPathComponent.stringByDeletingPathExtension;
break;
}
}
return modelName;
}
由于 User 在前面的例子(沒有源關(guān)系映射)中被從對象圖中隔離,因此遷移 User 的過程將省事很多。我們將從第一個映射模型中移除 UserToUser 映射,然后創(chuàng)建一個僅有 UserToUser 的映射。不要忘記在映射模型列表中返回新的 User 映射模型,因為我們正在其它映射中設(shè)置新關(guān)系
為此應(yīng)用建立單元測試異常簡單:
*這很容易完成,只需在模擬器里運行一下你應(yīng)用最新的版本(production version)即可
步驟 1 和 2 很簡單。步驟 3 留給讀者作為練習(xí),然后我會引導(dǎo)你通過第 4 步。
當(dāng)持久化存儲文件被添加到單元測試目標(biāo)上時,我們需要告知遷移管理器把那個存儲遷移至我們的目標(biāo)存儲。在示例項目中所示如下:
- (void)setUpCoreDataStackMigratingFromStoreWithName:(NSString *)name
{
NSURL *storeURL = [self temporaryRandomURL];
[self copyStoreWithName:name toURL:storeURL];
NSURL *momURL = [[NSBundle mainBundle] URLForResource:@"Model" withExtension:@"momd"];
self.managedObjectModel = [[NSManagedObjectModel alloc] initWithContentsOfURL:momURL];
NSString *storeType = NSSQLiteStoreType;
MHWMigrationManager *migrationManager = [MHWMigrationManager new];
[migrationManager progressivelyMigrateURL:storeURL
ofType:storeType
toModel:self.managedObjectModel
error:nil];
self.persistentStoreCoordinator = [[NSPersistentStoreCoordinator alloc] initWithManagedObjectModel:self.managedObjectModel];
[self.persistentStoreCoordinator addPersistentStoreWithType:storeType
configuration:nil
URL:storeURL
options:nil
error:nil];
self.managedObjectContext = [[NSManagedObjectContext alloc] init];
self.managedObjectContext.persistentStoreCoordinator = self.persistentStoreCoordinator;
}
- (NSURL *)temporaryRandomURL
{
NSString *uniqueName = [NSProcessInfo processInfo].globallyUniqueString;
return [NSURL fileURLWithPath:[NSTemporaryDirectory() stringByAppendingString:uniqueName]];
}
- (void)copyStoreWithName:(NSString *)name toURL:(NSURL *)url
{
// 每次創(chuàng)建一個唯一的url以保證測試正常運行
NSBundle *bundle = [NSBundle bundleForClass:[self class]];
NSFileManager *fileManager = [NSFileManager new];
NSString *path = [bundle pathForResource:[name stringByDeletingPathExtension] ofType:name.pathExtension];
[fileManager copyItemAtPath:path
toPath:url.path error:nil];
}
把下面的代碼放到一個父類,以便于在測試的類中復(fù)用:
- (void)setUp
{
[super setUp];
[self setUpCoreDataStackMigratingFromStoreWithName:@"Model1.sqlite"];
}
輕量級遷移是直接在 SQLite 內(nèi)部發(fā)生。這相對于自定義遷移來說非??焖偾矣行?。自定義遷移要把源對象讀入到內(nèi)存中,然后拷貝值到目標(biāo)對象,重新建立關(guān)系,最后插入到新的存儲中。這樣做不僅很慢,而且當(dāng)遷移大數(shù)據(jù)集時,由于內(nèi)存大小的限制,它還會引起系統(tǒng)強制回收內(nèi)存問題。
在處理任何數(shù)據(jù)持久性問題時最重要的事情之一就是仔細思考你的模型。我們希望模型是可持續(xù)發(fā)展的。在最開始創(chuàng)建模型的時候盡量考慮完全。添加空屬性或者空實體也比以后進行遷移時候創(chuàng)建好的多,因為遷移很容易出現(xiàn)錯誤,而未使用的數(shù)據(jù)就不會了。
測試遷移時一個有用的啟動參數(shù)是 -com.apple.CoreData.MigrationDebug。設(shè)置為 1 時,你會在控制臺收到關(guān)于遷移數(shù)據(jù)時特殊情況的信息。如果你熟悉 SQL 但不了解 Core Data,設(shè)置 -com.apple.CoreData.SQLDebug 為 1 可在控制臺看到實際操作的 SQL 語句。