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