自定義 Core Data 遷移似乎是一個(gè)不太起眼的話題。蘋果在這方面只提供了很少的文檔,若是初次涉足此方面內(nèi)容,很可能會(huì)變成一個(gè)可怕的經(jīng)歷。鑒于客戶端程序的性質(zhì),你無法測(cè)試你的用戶所生成的數(shù)據(jù)集的所有可能排列。此外,解決遷移過程中出現(xiàn)的問題會(huì)很困難,而因?yàn)闃O有可能你的代碼依賴于最新的數(shù)據(jù)模型,所以回退并不是一個(gè)可選的處理辦法。
在本文中,我們將走一遍搭建自定義 Core Data 遷移的過程,并著重于數(shù)據(jù)模型的重構(gòu)。我們將探討從舊模型中提取數(shù)據(jù)并使用這些數(shù)據(jù)來填充具有新的實(shí)體和關(guān)系的目標(biāo)模型。此外,會(huì)有一個(gè)包含單元測(cè)試的示例項(xiàng)目用于演示兩個(gè)自定義遷移。
需要注意的是,如果對(duì)數(shù)據(jù)模型的修改只有增加一個(gè)實(shí)體或可選屬性,輕量級(jí)的遷移是一個(gè)很好的選擇。它們非常易于設(shè)置,所以本文只會(huì)稍稍提及它們。若想知道輕量級(jí)遷移的應(yīng)用場(chǎng)合,請(qǐng)查看官方文檔。
這就是說,如果你需要快速地在你的數(shù)據(jù)模型上進(jìn)行相對(duì)復(fù)雜的改變,那么自定義遷移就是為你準(zhǔn)備的。
當(dāng)你要升級(jí)你的數(shù)據(jù)模型到新版,你將先選擇一個(gè)基準(zhǔn)模型。對(duì)于輕量級(jí)遷移,持久化存儲(chǔ)會(huì)為你自動(dòng)推斷一個(gè)映射模型。然而,如果你對(duì)新模型所做的修改并不被輕量級(jí)遷移所支持,那么你就需要?jiǎng)?chuàng)建一個(gè)映射模型。一個(gè)映射模型需要一個(gè)源數(shù)據(jù)模型和一個(gè)目標(biāo)數(shù)據(jù)模型。 NSMigrationManager
能夠推斷這兩個(gè)模型間的映射模型。這使得它很誘人,可用來一路創(chuàng)建每一個(gè)以前的模型到最新模型之間的映射模型,但這很快就會(huì)變成一團(tuán)亂麻。對(duì)于每一個(gè)新版模型,你需要?jiǎng)?chuàng)建的映射模型的量將線性增長。這可能看起來不是個(gè)大問題,但隨之而來的是測(cè)試這些映射模型的復(fù)雜度大大提高了。
想像一下你剛剛部署一個(gè)包含版本 3 的數(shù)據(jù)模型的更新。你的某個(gè)用戶已經(jīng)有一段時(shí)間沒有更新你的應(yīng)用了,這個(gè)用戶還在版本 1 的數(shù)據(jù)模型上。那么現(xiàn)在你就需要一個(gè)從版本 1 到版本 3 的映射模型。同時(shí)你也需要版本 2 到版本 3 的映射模型。當(dāng)你添加了版本 4 的數(shù)據(jù)模型后,那你就需要?jiǎng)?chuàng)建三個(gè)新的映射模型。顯然這樣做的擴(kuò)展性很差,那就來試試漸進(jìn)式遷移吧。
與其為每個(gè)之前的數(shù)據(jù)模型到最新的模型間都建立映射模型,還不如在每兩個(gè)連續(xù)的數(shù)據(jù)模型之間創(chuàng)建映射模型。以前面的例子來說,版本 1 和版本 2 之間需要一個(gè)映射模型,版本 2 和版本 3 之間需要一個(gè)映射模型。這樣就可以從版本 1 遷移到版本 2 再遷移到版本 3。顯然,使用這種遷移的方式時(shí),若用戶在較老的版本上遷移過程就會(huì)比較慢,但它能節(jié)省開發(fā)時(shí)間并保證健壯性,因?yàn)槟阒恍枰_保從之前一個(gè)模型到新模型的遷移工作正常即可,而更前面的映射模型都已經(jīng)經(jīng)過了測(cè)試。
總的想法就是手動(dòng)找出當(dāng)前版本 v 和版本 v+1 之間的映射模型,在這兩者間遷移,接著繼續(xù)遞歸,直到持久化存儲(chǔ)與當(dāng)前的數(shù)據(jù)模型兼容。
這一過程看起來像下面這樣(完整版可以在示例項(xiàng)目里找到):
- (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)在有了一個(gè)映射模型,開始遷移
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)在遷移成功了,把文件備份一下以防不測(cè)
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ù)都被依附到一個(gè) -wal 文件中。這有可能導(dǎo)致數(shù)據(jù)丟失和異常。為了數(shù)據(jù)的安全,我們會(huì)將日志模式改寫為回溯模式。而如果我們想要遷移數(shù)據(jù)(或者為了以后備份),我們可以將一個(gè)字典傳遞給 -addPersistentStoreWithType:configuration:URL:options:error:
來完成改寫。
@{ NSSQLitePragmasOption: @{ @"journal_mode": @"DELETE” } }
與 NSPersistentStoreCoordinator
相關(guān)的代碼可以在這里找到。
NSEntityMigrationPolicy
是自定義遷移過程的核心。 蘋果的文檔中有這么一句話:
NSEntityMigrationPolicy
的實(shí)例為一個(gè)實(shí)體映射自定義的遷移策略。
簡單的說,這個(gè)類讓我們不僅僅能修改實(shí)體的屬性和關(guān)系,而且還能任意添加一些自定義的操作來完成每個(gè)實(shí)體的遷移。
假設(shè)我們有一個(gè)帶有簡單的數(shù)據(jù)模型的書籍應(yīng)用。這個(gè)模型有兩個(gè)實(shí)體: User
和 Book
。Book
實(shí)體有一個(gè)屬性叫做 authorName
。我們想改善這個(gè)模型,添加一個(gè)新的實(shí)體: Author
。同時(shí)我們想為 Book
和 Author
建立一個(gè)多對(duì)多的關(guān)系,因?yàn)橐槐緯捎卸鄠€(gè)作者,而一個(gè)作者也可寫多本書籍。我們將從 Book
對(duì)象里取出 authorName
用于填充一個(gè)新的實(shí)體并建立關(guān)系。
一開始我們要做的是基于第一個(gè)數(shù)據(jù)模型增加一個(gè)新版模型。在這個(gè)例子里,我們添加了一個(gè) Author
實(shí)體,它與 Book
還有多對(duì)多的關(guān)系。
http://wiki.jikexueyuan.com/project/objc/images/4-3.png" alt="" />
現(xiàn)在數(shù)據(jù)模型已經(jīng)是我們所需要的,但我們還需要遷移所有已存在的數(shù)據(jù),這就該 NSEntityMigrationPolicy
出場(chǎng)了。我們創(chuàng)建 NSEntityMigrationPolicy
的一個(gè)子類---- MHWBookToBookPolicy
。在映射模型里,我們選擇 Book
實(shí)體并設(shè)置它作為公共部分(Utilities section)中的自定義策略。
http://wiki.jikexueyuan.com/project/objc/images/4-4.png" alt="" />
同時(shí)我們使用 user info 字典來設(shè)置一個(gè) modelVersion
,它將在未來的遷移中派上用場(chǎng)。
在 MHWBookToBookPolicy
中,我們將重載 -createDestinationInstancesForSourceInstance:entityMapping:manager:error:
方法,它允許我們自定義如何遷移每個(gè) Book 實(shí)例。如果 modelVersion
的值不是 2,我們將調(diào)用父類的實(shí)現(xiàn),否則我們就要做自定義遷移。我們插入基于映射的目標(biāo)實(shí)體的新 NSManagedObject
對(duì)象到目標(biāo)上下文。然后我們遍歷目標(biāo)實(shí)例的屬性鍵值并與來自源實(shí)例的值一起填充它們。這將保證我們保留現(xiàn)存數(shù)據(jù)并避免設(shè)置任何我們已經(jīng)在目標(biāo)實(shí)例中移除的值。
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];
}
}
}
然后我們將基于源實(shí)例的值創(chuàng)建一個(gè) Author
實(shí)體。但若多本書有同一個(gè)作者會(huì)發(fā)生什么呢?我們將使用 NSMigrationManager
的一個(gè) category 方法來創(chuàng)建一個(gè)查找字典,確保對(duì)于同一個(gè)名字的作者,我們只會(huì)創(chuàng)建一個(gè) 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];
最后,我們需要告訴遷移管理器在源存儲(chǔ)與目的存儲(chǔ)之間關(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
過了一會(huì),我們又想把 fileURL
這個(gè)屬性從 Book
實(shí)體里提出來,放入一個(gè)叫做 File
的新實(shí)體里。同時(shí)我們還想修改實(shí)體之間的關(guān)系,以便 User
可與 File
有一對(duì)多的關(guān)系,而反過來 File
和 Book
有多對(duì)一的關(guān)系。
http://wiki.jikexueyuan.com/project/objc/images/4-5.png" alt="" />
在之前的遷移中,我們只遷移了一個(gè)實(shí)體。而現(xiàn)在當(dāng)我們添加了 File
后,事情變得有些復(fù)雜了。我們不能簡單地在遷移一個(gè) Book
時(shí)插入一個(gè) File
實(shí)體并設(shè)置它與 User
的對(duì)應(yīng)關(guān)系,因?yàn)榇藭r(shí) User
實(shí)體還沒有被遷移,之間的關(guān)系也無從談起。我們必須考慮遷移的執(zhí)行順序。在映射模型中,是可以改變實(shí)體映射的順序的。具體到這里的例子,我們想將 UserToUser
映射放在 BookToBook
映射之上。這保證了 User
實(shí)體會(huì)比 Book
實(shí)體更早遷移。
http://wiki.jikexueyuan.com/project/objc/images/4-6.png" alt="" />
添加一個(gè) File
實(shí)體的途徑和創(chuàng)建 Author
的過程相似。我們?cè)?MHWBookToBookPolicy
中遷移 Book
實(shí)體時(shí)創(chuàng)建 File
對(duì)象。我們會(huì)查看源實(shí)例的 User
實(shí)體,為每個(gè) User
實(shí)體創(chuàng)建一個(gè)新的 File
對(duì)象,并建立對(duì)應(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"];
}
如果你的存儲(chǔ)包含了大量數(shù)據(jù),以至到達(dá)一個(gè)臨界點(diǎn),遷移就會(huì)消耗過多內(nèi)存,Core Data 提供了一個(gè)以數(shù)據(jù)塊(chunks)的方式遷移的辦法。蘋果的文檔有簡要地提到這件事。解決辦法是使用多映射模型分開你的遷移并為每個(gè)映射模型遷移一次。這要求你有一個(gè)對(duì)象圖(object graph),在其中,遷移可被分為兩個(gè)或多個(gè)部分。為了支持這一點(diǎn)而需要添加的代碼其實(shí)很少。
首先,我們更新遷移方法以支持使用多個(gè)映射模型來遷移。已知映射模型的順序很重要,我們將通過代理方法請(qǐng)求它們:
NSArray *mappingModels = @[mappingModel]; // 我們之前建立的那個(gè)模型
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)绾沃獣阅囊粋€(gè)映射模型被用于這個(gè)特定的源模型呢?此處的 API 可能顯得有些笨拙,但以下的解決方法確實(shí)完成了工作。在代理方法中,我們找出源模型的名字并返回相關(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
添加一個(gè) 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)系映射)中被從對(duì)象圖中隔離,因此遷移 User
的過程將省事很多。我們將從第一個(gè)映射模型中移除 UserToUser
映射,然后創(chuàng)建一個(gè)僅有 UserToUser
的映射。不要忘記在映射模型列表中返回新的 User
映射模型,因?yàn)槲覀冋谄渌成渲性O(shè)置新關(guān)系
為此應(yīng)用建立單元測(cè)試異常簡單:
*這很容易完成,只需在模擬器里運(yùn)行一下你應(yīng)用最新的版本(production version)即可
步驟 1 和 2 很簡單。步驟 3 留給讀者作為練習(xí),然后我會(huì)引導(dǎo)你通過第 4 步。
當(dāng)持久化存儲(chǔ)文件被添加到單元測(cè)試目標(biāo)上時(shí),我們需要告知遷移管理器把那個(gè)存儲(chǔ)遷移至我們的目標(biāo)存儲(chǔ)。在示例項(xiàng)目中所示如下:
- (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)建一個(gè)唯一的url以保證測(cè)試正常運(yùn)行
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];
}
把下面的代碼放到一個(gè)父類,以便于在測(cè)試的類中復(fù)用:
- (void)setUp
{
[super setUp];
[self setUpCoreDataStackMigratingFromStoreWithName:@"Model1.sqlite"];
}
輕量級(jí)遷移是直接在 SQLite 內(nèi)部發(fā)生。這相對(duì)于自定義遷移來說非??焖偾矣行?。自定義遷移要把源對(duì)象讀入到內(nèi)存中,然后拷貝值到目標(biāo)對(duì)象,重新建立關(guān)系,最后插入到新的存儲(chǔ)中。這樣做不僅很慢,而且當(dāng)遷移大數(shù)據(jù)集時(shí),由于內(nèi)存大小的限制,它還會(huì)引起系統(tǒng)強(qiáng)制回收內(nèi)存問題。
在處理任何數(shù)據(jù)持久性問題時(shí)最重要的事情之一就是仔細(xì)思考你的模型。我們希望模型是可持續(xù)發(fā)展的。在最開始創(chuàng)建模型的時(shí)候盡量考慮完全。添加空屬性或者空實(shí)體也比以后進(jìn)行遷移時(shí)候創(chuàng)建好的多,因?yàn)檫w移很容易出現(xiàn)錯(cuò)誤,而未使用的數(shù)據(jù)就不會(huì)了。
測(cè)試遷移時(shí)一個(gè)有用的啟動(dòng)參數(shù)是 -com.apple.CoreData.MigrationDebug
。設(shè)置為 1 時(shí),你會(huì)在控制臺(tái)收到關(guān)于遷移數(shù)據(jù)時(shí)特殊情況的信息。如果你熟悉 SQL 但不了解 Core Data,設(shè)置 -com.apple.CoreData.SQLDebug
為 1 可在控制臺(tái)看到實(shí)際操作的 SQL 語句。