往 Core Data 應(yīng)用中導(dǎo)入大數(shù)據(jù)集是個(gè)很常見(jiàn)的問(wèn)題。鑒于數(shù)據(jù)的特點(diǎn)你可以采用以下幾種方法:
對(duì)某些應(yīng)用場(chǎng)景后兩種選擇作為可行的方案經(jīng)常被忽視了。因此,在本文中我們將進(jìn)一步的了解他們,并總結(jié)一下如何高效地把web服務(wù)上的數(shù)據(jù)導(dǎo)入到一個(gè)動(dòng)態(tài)的應(yīng)用中。
當(dāng)用大量數(shù)據(jù)來(lái)填充 Core Data 時(shí),通過(guò)傳輸或下載預(yù)先生成的 SQLite 文件是一個(gè)可行的方案,并且比在客戶端創(chuàng)建數(shù)據(jù)更加高效。如果源數(shù)據(jù)庫(kù)包含靜態(tài)數(shù)據(jù),并且能夠相對(duì)獨(dú)立地與潛在的用戶產(chǎn)生的數(shù)據(jù)共存,這就是該技術(shù)的使用場(chǎng)景。
Core Data 框架在 iOS 和 OS X 間是共用的,因此,我們可以創(chuàng)建 OS X 上的命令行工具來(lái)產(chǎn)生 SQLite 數(shù)據(jù)庫(kù)文件,并且將該文件用在 iOS 應(yīng)用中。
在我們的例子中 (你可以在 Github 上找到),我們創(chuàng)建了一個(gè)命令行工具,它接受兩個(gè)柏林城市的數(shù)據(jù)集文件作為輸入,并把它們插入到 Core Data SQLite 數(shù)據(jù)庫(kù)中。這個(gè)數(shù)據(jù)集包含大約 13,000 逗留記錄及三百萬(wàn)逗留時(shí)間記錄。
對(duì)于該技術(shù)最重要的是,命令行工具和客戶端應(yīng)用使用了相同的數(shù)據(jù)模型。如果數(shù)據(jù)模型隨著時(shí)間發(fā)生了改變,當(dāng)你更新應(yīng)用并傳輸新的源數(shù)據(jù)時(shí),你要仔細(xì)地管理數(shù)據(jù)模型的版本。有一個(gè)好的建議就是不要復(fù)制.xcdatamodel文件,而是從命令行工具項(xiàng)目中把它鏈接到客戶端應(yīng)用項(xiàng)目。
另一個(gè)有用的步驟是在產(chǎn)生的 SQLite 文件上執(zhí)行 VACUUM 命令。它會(huì)減小文件大小,因此根據(jù)你傳輸文件方式的不同,應(yīng)用程序包的尺寸,或是要下載的數(shù)據(jù)庫(kù)的尺寸也會(huì)相應(yīng)減小。
除了這些,對(duì)于該過(guò)程真的沒(méi)有別的方法了;在我們的案例項(xiàng)目中你也看到了,它就是些簡(jiǎn)單的標(biāo)準(zhǔn) Core Data 代碼。既然生成 SQLite 文件不是性能關(guān)鍵的任務(wù),你也沒(méi)必要花大力氣去優(yōu)化它的性能。如果你想讓它更快,后面針對(duì)高效地導(dǎo)入大數(shù)據(jù)集到動(dòng)態(tài)應(yīng)用中所作的總結(jié)規(guī)則同樣適用。
我們經(jīng)常會(huì)有這樣的場(chǎng)景,希望有一個(gè)可用的大的源數(shù)據(jù)集,但是也想能存儲(chǔ)和修改一些用戶產(chǎn)生的數(shù)據(jù)。同樣,有幾種方法來(lái)解決這個(gè)問(wèn)題。
首先要考慮的是,用戶產(chǎn)生的數(shù)據(jù)是否真的需要用 Core Data 來(lái)存儲(chǔ)。如果我們能把這些數(shù)據(jù)存儲(chǔ)到 plist 文件中,就不要亂動(dòng)已建好的 Core Data 數(shù)據(jù)庫(kù)。
如果我們想用 Core Data 來(lái)存儲(chǔ),另一個(gè)需要考慮的問(wèn)題是,在將來(lái)是否需要通過(guò)傳輸更新的預(yù)先建好的 SQLite 文件來(lái)更新源數(shù)據(jù)集。如果這種情況不會(huì)發(fā)生,我們可以安全地把用戶生產(chǎn)的數(shù)據(jù)包含到相同的數(shù)據(jù)模型和配置中。然而,如果我們想傳輸一個(gè)新源數(shù)據(jù)庫(kù),我們必須要分離源數(shù)據(jù)與用戶產(chǎn)生的數(shù)據(jù)。
這個(gè)完全可以通過(guò)建立第二個(gè)完全獨(dú)立的,使用自己的數(shù)據(jù)模型的 Core Data 來(lái)實(shí)現(xiàn),或者通過(guò)在兩個(gè)持久性存儲(chǔ)間分發(fā)相同有數(shù)據(jù)模型的數(shù)據(jù)。對(duì)此,我們需要在同一個(gè)數(shù)據(jù)模型中創(chuàng)建第二個(gè)配置,它保存用戶產(chǎn)生的數(shù)據(jù)的實(shí)體。當(dāng)配置 Core Data 棧時(shí),我們將實(shí)例化兩個(gè)持久化存儲(chǔ),其中一個(gè)包含 URL 和源數(shù)據(jù)庫(kù)的配置,另一個(gè)包含 URL 和用戶產(chǎn)生數(shù)據(jù)的數(shù)據(jù)庫(kù)的配置。
使用兩個(gè)獨(dú)立的 Core Data 棧是一種更簡(jiǎn)單明了的方法。如果這個(gè)方法恰好能解決你的問(wèn)題的話,我們強(qiáng)烈推薦使用它。然而,如果你想在用戶產(chǎn)生的數(shù)據(jù)與源數(shù)據(jù)間建立關(guān)系,Core Data 不能幫你實(shí)現(xiàn)。即使你把所有的東西包含在一個(gè)擴(kuò)展到兩個(gè)持久化存儲(chǔ)的數(shù)據(jù)模型中,你依然不能像通常那樣在這些實(shí)體間定義關(guān)系,但是當(dāng)獲取某一特定屬性時(shí),你可以用 Core Data 中的 fetched properties 從不同的存儲(chǔ)中自動(dòng)獲取對(duì)象。
如果我們想往應(yīng)用程序里傳輸一個(gè)預(yù)先生成的 SQLite 文件,我們必須檢測(cè)出最新更新的應(yīng)用是否是第一次打開(kāi),并把程序外部的數(shù)據(jù)庫(kù)文件復(fù)制到目標(biāo)目錄:
NSFileManager* fileManager = [NSFileManager defaultManager];
NSError *error;
if([fileManager fileExistsAtPath:self.storeURL.path]) {
NSURL *storeDirectory = [self.storeURL URLByDeletingLastPathComponent];
NSDirectoryEnumerator *enumerator = [fileManager enumeratorAtURL:storeDirectory
includingPropertiesForKeys:nil
options:0
errorHandler:NULL];
NSString *storeName = [self.storeURL.lastPathComponent stringByDeletingPathExtension];
for (NSURL *url in enumerator) {
if (![url.lastPathComponent hasPrefix:storeName]) continue;
[fileManager removeItemAtURL:url error:&error];
}
// 處理錯(cuò)誤
}
NSString* bundleDbPath = [[NSBundle mainBundle] pathForResource:@"seed" ofType:@"sqlite"];
[fileManager copyItemAtPath:bundleDbPath toPath:self.storeURL.path error:&error];
注意我們首先要?jiǎng)h除之前的數(shù)據(jù)庫(kù)文件。這不像你想的那樣簡(jiǎn)單明了,因?yàn)榭赡軙?huì)存在不同的附屬文件(如日志或?qū)懬叭罩疚募┡c主要的 .sqlite 文件相關(guān)。因此我們必須遍歷目錄里的每一項(xiàng),刪除所有的與存儲(chǔ)文件名字匹配不帶擴(kuò)展名的文件。
然而,我們也需要一個(gè)方法確保這件事我們只做了一次。一個(gè)很明顯的方法就是從程序中把源數(shù)據(jù)庫(kù)刪除。雖然在模擬器上管用,但是因?yàn)闄?quán)限的問(wèn)題,在真機(jī)上會(huì)失敗。有很多方案來(lái)解決這個(gè)問(wèn)題,如在 user defaults 中設(shè)置一個(gè) key,它包含了最新導(dǎo)入的數(shù)據(jù)的版本信息:
NSString* bundleVersion = [infoDictionary objectForKey:(NSString *)kCFBundleVersionKey];
NSString *seedVersion = [[NSUserDefaults standardUserDefaults] objectForKey@"SeedVersion"];
if (![seedVersion isEqualToString:bundleVersion]) {
// 復(fù)制源數(shù)據(jù)庫(kù)
}
// ... 導(dǎo)入成功后
NSDictionary *infoDictionary = [NSBundle mainBundle].infoDictionary;
[[NSUserDefaults standardUserDefaults] setObject:bundleVersion forKey:@"SeedVersion"];
或者舉個(gè)例子,我們也可以復(fù)制存在的數(shù)據(jù)庫(kù)到一個(gè)包含源版本的路徑來(lái)檢測(cè)它是否存在, 從而避免做兩個(gè)相同的導(dǎo)入。有很多可行的方法供你選擇,這取決于你的應(yīng)用場(chǎng)景最重要的是什么。
如果出于某些原因我們不想把源數(shù)據(jù)庫(kù)包放在應(yīng)用程序中(如,它會(huì)導(dǎo)致程序大小超過(guò)手機(jī)下載的閾值),我們可以從 web 服務(wù)器上下載。過(guò)程與我們把數(shù)據(jù)庫(kù)文件放在設(shè)備上是一樣的。但是得保證,服務(wù)器提供的數(shù)據(jù)庫(kù)版本要與客戶端的數(shù)據(jù)模型兼容,因?yàn)椴煌膽?yīng)用版本數(shù)據(jù)模型可能會(huì)改變。
這不僅僅是通過(guò)下載來(lái)替換應(yīng)用程序中的一個(gè)文件,這個(gè)方案也使得填充更多的數(shù)據(jù)而不導(dǎo)致在客戶端動(dòng)態(tài)地導(dǎo)入數(shù)據(jù)引發(fā)的性能與電量損耗成為可能。
為了產(chǎn)生馬上可用的 SQLite 文件,我們可以像前面那樣在 (OS X) 服務(wù)器上運(yùn)行類似的命令行導(dǎo)入程序。無(wú)可否認(rèn)地,鑒于數(shù)據(jù)集的大小及要服務(wù)的請(qǐng)求數(shù),對(duì)每一個(gè)請(qǐng)求該操作所需的計(jì)算資源可能不允許。一個(gè)可行的替代方案是定期地生成SQLite文件,給客戶端發(fā)送這些現(xiàn)成的文件。
為了提供 SQLite 下載的 API,在服務(wù)器端及客戶端當(dāng)然需要額外的邏輯,SQLite 的下載可以為自上次源文件生成后已經(jīng)發(fā)生改變的客戶端提供數(shù)據(jù)。整個(gè)過(guò)程有點(diǎn)復(fù)雜,但是可以讓你更容易的用任意大小的動(dòng)態(tài)數(shù)據(jù)來(lái)填充 Core Data,而且沒(méi)有性能問(wèn)題(除了帶寬限制)。
最后,讓我們看看如何從 web 服務(wù)器上導(dǎo)入大量的數(shù)據(jù),如 JSON 格式的數(shù)據(jù)。
如果我們要導(dǎo)入有關(guān)系的不同對(duì)象類型,我們需要在處理它們間的關(guān)系前先獨(dú)立地導(dǎo)入所有的對(duì)象。如果我們能在 server 端保證客戶端是以正確的順序收到的對(duì)象,我們可以馬上處理它們間的關(guān)系,而且不用為此擔(dān)心。但大部分情況這是不可能的。
為在不影響用戶界面響應(yīng)前提下進(jìn)行導(dǎo)入操作,我們必須在后臺(tái)線程中執(zhí)行導(dǎo)入操作。在第二期中,Chris寫了一篇在后臺(tái)使用 Core Data 的簡(jiǎn)單方式。如果做的正確,多核設(shè)備可以在不影響用戶界面響應(yīng)的情況下在后臺(tái)執(zhí)行導(dǎo)入操作。注意,并發(fā)地使用 Core Data 也有可能在不同的托管對(duì)象的上下文間產(chǎn)生沖突。你需要提出一種策略來(lái)預(yù)防或處理這些情況。
在本文中,理解 Core Data 的并發(fā)工作是很重要的。因?yàn)槲覀円呀?jīng)在兩個(gè)線程上建立了兩個(gè)被管理對(duì)象上下文,這并不表示它們兩個(gè)會(huì)同時(shí)去訪問(wèn)數(shù)據(jù)庫(kù)。從托管對(duì)象上下文發(fā)出的每個(gè)請(qǐng)求會(huì)對(duì)上下文的對(duì)象及 SQLite 文件加上鎖。例如,如果你在主上下文的一個(gè)子上下文中觸發(fā)了一個(gè)讀請(qǐng)求,為了執(zhí)行這個(gè)請(qǐng)求,主上下文,持久化存儲(chǔ)協(xié)調(diào)器,持久化存儲(chǔ),以及 SQLite 文件都會(huì)被加鎖(盡管加在 SQLite 文件上的鎖比其他對(duì)象要去除的快)。在此期間,其他在 Core Data 棧上每個(gè)對(duì)象會(huì)被阻塞等著這個(gè)請(qǐng)求的完成。
在后臺(tái)上下文中大量導(dǎo)入數(shù)據(jù)的例子中,這意味著導(dǎo)入操作的保存請(qǐng)求會(huì)不斷地在持久化存儲(chǔ)協(xié)調(diào)器上加鎖。在此期間,像為了更新用戶界面而進(jìn)行的讀取請(qǐng)求,是不能在主上下文中執(zhí)行的,而必須等待保存請(qǐng)求完成。因?yàn)?Core Data 的 API 是同步的,因此主線程會(huì)被阻塞,用戶界面的響應(yīng)會(huì)受影響。
如果在你的應(yīng)用場(chǎng)景中這是個(gè)問(wèn)題,你應(yīng)該考慮為后臺(tái)上下文使用帶有自己的持久化存儲(chǔ)協(xié)調(diào)器的獨(dú)立 Core Data 棧。在這種情況下,在后臺(tái)上下文與主上下文間唯一共享的資源就是 SQLite 文件,鎖競(jìng)爭(zhēng)會(huì)比之前有所減少。特別地,當(dāng) SQLite 文件以 write-ahead loggin 的方式執(zhí)行 (在 iOS7 和 OS X 10.9 是默認(rèn)的) 時(shí),即使在 SQLite 文件級(jí)別,你也會(huì)得到真正并發(fā)。多個(gè)讀和一個(gè)寫可以同時(shí)來(lái)訪問(wèn)數(shù)據(jù)庫(kù)(看這里 WWDC 2013 session "What's New in Core Data and iCloud" )
最后,在大量導(dǎo)入數(shù)據(jù)時(shí),實(shí)時(shí)地把修改通知合并到主上下文中一般不會(huì)是個(gè)好的做法。如果用戶界面對(duì)這些變化自動(dòng)響應(yīng)的話(通過(guò)使用NSFetchResultsController),應(yīng)用界面會(huì)陷入停頓。其實(shí),我們可以在整個(gè)導(dǎo)入完成時(shí)發(fā)送一個(gè)自定義通知,讓用戶界面重新加載數(shù)據(jù)。
如果應(yīng)用場(chǎng)景是想在導(dǎo)入數(shù)據(jù)期間就實(shí)時(shí)的更新UI界面,我們可以考慮過(guò)濾掉特定實(shí)體類型的保存通知,把它們按批聚集起來(lái),或是其他減少界面更新頻率的方式,來(lái)確保界面可以響應(yīng)。然而,在大多數(shù)情況下并不值得這么做,因?yàn)閷?duì)界面的頻繁更新會(huì)讓用戶覺(jué)得更加迷惑,而非更有幫助。
在通過(guò)實(shí)際的導(dǎo)入例子講述了設(shè)置方法和操作手法后,我們?cè)賮?lái)看一些讓它盡可能高效的特殊方法。
為了高效導(dǎo)入數(shù)據(jù),我們的第一個(gè)建議就是通讀 Apple 關(guān)于這個(gè)主題的指導(dǎo)。我們也會(huì)強(qiáng)調(diào)該文檔中經(jīng)常容易被忘記的幾個(gè)方面。
首先,你要在用于導(dǎo)入的上下文中把 undoManager 置為 nil。盡管這個(gè)只適用于 OS X,因?yàn)樵?iOS 上,上下文默認(rèn)沒(méi)有 undo manager。把 undoManager 屬性置空會(huì)帶來(lái)重大的性能提升。
其次,訪問(wèn)具有相互引用關(guān)系的對(duì)象會(huì)產(chǎn)生引用環(huán)。如果你使用了設(shè)計(jì)良好的自動(dòng)釋放池后,還是看到在導(dǎo)入過(guò)程中內(nèi)存使用不斷增加,那就應(yīng)該注意導(dǎo)入部分代碼中的陷阱了。蘋果在這里描述了如何使用refreshObject:mergeChanges:來(lái)去掉這些環(huán)。
當(dāng)你導(dǎo)入可能已經(jīng)在數(shù)據(jù)庫(kù)中存在的數(shù)據(jù)時(shí),你需要實(shí)現(xiàn)一些查找及創(chuàng)建的算法,以防止產(chǎn)生重復(fù)。對(duì)每一個(gè)對(duì)象執(zhí)行讀取請(qǐng)求效率很低,因?yàn)槊總€(gè)讀取請(qǐng)求都需要 Core Data 到硬盤上從存儲(chǔ)文件里讀取數(shù)據(jù)。然而,通過(guò)按批導(dǎo)入數(shù)據(jù)并使用在上面提到的文檔中 Apple 提供的高效查找創(chuàng)建算法,可以很容易避免這個(gè)問(wèn)題。
當(dāng)建立新導(dǎo)入的對(duì)象間的關(guān)系時(shí),類似的問(wèn)題也經(jīng)常產(chǎn)生。用一個(gè)讀取請(qǐng)求獨(dú)立地獲得每一個(gè)相關(guān)的對(duì)象是非常低效的。有兩種可能的解決方法:一是像按批導(dǎo)入數(shù)據(jù)那樣按批處理它們間的關(guān)系,二是緩存已經(jīng)導(dǎo)入的對(duì)象的ID。
按批處理關(guān)系可以使我們大大地減少一次獲取大量相關(guān)對(duì)象的讀取請(qǐng)求次數(shù)。不用擔(dān)心可能很長(zhǎng)的查詢語(yǔ)句,如:
[NSPredicate predicateWithFormat:@"identifier IN %@", identifiersOfRelatedObjects];
處理一個(gè)在IN (...)從句中帶有很多標(biāo)識(shí)符的查詢語(yǔ)句,總是比去硬盤上單獨(dú)地讀取每個(gè)對(duì)象更高效。
然而,也有一種可以完全避免讀取請(qǐng)求的方法,(前提是你只需要在剛導(dǎo)入的對(duì)象間建立關(guān)系)。如果你緩存導(dǎo)入的所有對(duì)象的 IDs (實(shí)際上在大多數(shù)情況下數(shù)據(jù)量也不大),之后你可以用 objectWithID: 方法為相關(guān)的對(duì)象建立關(guān)系。
// 在一堆對(duì)象已經(jīng)被導(dǎo)入并保存之后
for (MyManagedObject *object in importedObjects) {
objectIDCache[object.identifier] = object.objectID;
}
// ... 之后在解決關(guān)系時(shí)
NSManagedObjectID objectID = objectIDCache[object.foreignKey];
MyManagedObject *relatedObject = [context objectWithID:objectId];
object.toOneRelation = relatedObject;
注意,這個(gè)例子假設(shè) identifier 屬性在所有的實(shí)體類型中是唯一的,否則,我們就得為我們多緩存的不同類型的對(duì)象 IDs 創(chuàng)建重復(fù)的標(biāo)識(shí)符。
當(dāng)你遇到需要導(dǎo)入大量數(shù)據(jù)到 Core Data 中時(shí),在做大量 JSON 數(shù)據(jù)的實(shí)時(shí)導(dǎo)入前,盡量先不要按常規(guī)來(lái)思考。特別是如果你能控制客戶端和服務(wù)器端,經(jīng)常會(huì)有很多解決該問(wèn)題的高效方法。但是如果你不得不忍痛做大量后臺(tái)導(dǎo)入工作,保證盡可能與主線程一樣獨(dú)立高效地進(jìn)行。