將對象從存儲中取出來的方法之一是使用 NSFetchRequest
。但是請注意,一個最常見的錯誤是在你不需要的時候去讀取數(shù)據(jù)。請確保你已經(jīng)閱讀并理解了獲取對象一節(jié)中的內(nèi)容。大多數(shù)時候,遍歷關(guān)系更加有效,而使用 NSFetchRequest
往往成本很高。
通常有兩個原因使用 NSFetchRequest
來執(zhí)行數(shù)據(jù)獲?。?1) 你需要為匹配特定謂詞 (predicate) 的對象搜索整個對象圖;或者 (2) 你想要在比如 table view 這樣的地方顯示所有的對象。其實還有第三種,也是一個較不常見的情況,就是在遍歷關(guān)系的同時卻想要更高效地預(yù)先獲取數(shù)據(jù)。我們也將簡單深入這個問題。不過我們先來看看兩個主要原因,它們更加常見并且每個都具有自己的復(fù)雜性。
在這里我們不會涉及基礎(chǔ)內(nèi)容,因為一個關(guān)于 Core Data 的名為 Fetching Managed Objects 的 Xcode 文檔已經(jīng)涵蓋了大量基本原理。我們將深入到一些更專業(yè)的方面。
在我們的 交通數(shù)據(jù)的例子 中,我們有 12,800 個車站,其中有接近 3,000,000 個停留時間相互關(guān)聯(lián)。對接近北緯 52° 29' 57.30",東經(jīng) +13° 25' 5.40" 的車站,如果我們想要按照發(fā)車時間介于 8:00 和 8:30 之間的條件來進(jìn)行查找,我們不會想要在這個 context 中加載所有的 12,800 個 車站
對象和所有三百萬的 停留時間
對象,然后再對它們進(jìn)行循環(huán)訪問。如果我們這樣做,將不得不花費大量時間以及相當(dāng)大的存儲空間以將所有的對象加載到存儲器中。取而代之,我們想要的是使用 SQLite 來縮減進(jìn)入內(nèi)存的的對象的數(shù)量。
讓我們從小處開始,為位置接近北緯 52° 29' 57.30" 東經(jīng) +13° 25' 5.40" 的車站創(chuàng)建一個 fetch 請求。首先我們創(chuàng)建這個 fetch 請求:
NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:[Stop entityName]]
我們使用 Florian 的 data model 文章中 中提到的 +entityName
方法。然后,我們需要將結(jié)果限定為那些接近我們的點的結(jié)果。
我們可以簡單的用一個 (不完全) 正方形區(qū)域圍繞我們的興趣點。實際在數(shù)學(xué)上這有些復(fù)雜,因為地球恰好有點類似于一個橢球。但是如果我們假設(shè)地球是球體,則可以得到這個公式:
D = R * sqrt( (deltaLatitude * deltaLatitude) +
(cos(meanLatitidue) * deltaLongitude) * (cos(meanLatitidue) * deltaLongitude))
我們最后可以得到以下內(nèi)容 (均為近似值):
static double const R = 6371009000; // 地球半徑(單位:米)
double deltaLatitude = D / R * 180 / M_PI;
double deltaLongitude = D / (R * cos(meanLatitidue)) * 180 / M_PI;
我們的感興趣的點是:
CLLocation *pointOfInterest = [[CLLocation alloc] initWithLatitude:52.4992490
longitude:13.4181670];
我們想在 ±263 英尺(80 米)內(nèi)進(jìn)行搜索:
static double const D = 80. * 1.1;
double const R = 6371009.; // 地球半徑(單位:米)
double meanLatitidue = pointOfInterest.latitude * M_PI / 180.;
double deltaLatitude = D / R * 180. / M_PI;
double deltaLongitude = D / (R * cos(meanLatitidue)) * 180. / M_PI;
double minLatitude = pointOfInterest.latitude - deltaLatitude;
double maxLatitude = pointOfInterest.latitude + deltaLatitude;
double minLongitude = pointOfInterest.longitude - deltaLongitude;
double maxLongitude = pointOfInterest.longitude + deltaLongitude;
(當(dāng)我們接近 180° 經(jīng)線的時候,這個運算不成立。由于我們的交通數(shù)據(jù)源于離 180° 經(jīng)線很遠(yuǎn)很遠(yuǎn)的柏林,所以我們忽略這個問題。)
request.result = [NSPredicate predicateWithFormat:
@"(%@ <= longitude) AND (longitude <= %@)"
@"AND (%@ <= latitude) AND (latitude <= %@)",
@(minLongitude), @(maxLongitude), @(minLatitude), @(maxLatitude)];
指定一種排序描述符毫無意義,因為我們會在內(nèi)存中做第二次遍歷。不過我們將讓 Core Data 在返回對象里填上所有值。
request.returnsObjectsAsFaults = NO;
如果不做這個設(shè)置,Core Data 將把值取入持久化存儲協(xié)調(diào)器的行緩存 (row cache) 中,而不是填充實際對象。通常來說這是沒問題的,不過由于我們將立刻訪問所有對象,所以我們并不希望出現(xiàn)這種行為。
編者注 把屬性值先取入緩存中,在對象需要的時候再進(jìn)行一次訪問,這在 Core Data 中是默認(rèn)行為,這種技術(shù)稱為 Faulting。這么做可以避免降低內(nèi)存開銷,但是如果你確定將訪問結(jié)果對象的具體屬性值時,可以禁用 Faults 以提高獲取性能。
為安全防范考慮,最好加上:
request.fetchLimit = 200;
執(zhí)行這條 fetch 請求
NSError *error = nil;
NSArray *stops = [moc executeFetchRequest:request error:&error];
NSAssert(stops != nil, @"Failed to execute %@: %@", request, error);
獲取操作失敗唯一 (可能) 的原因是儲存器損壞(文件被刪除等等),否則就是 fetch 請求中出現(xiàn)了語法錯誤。所以在這里使用 NSAssert()
是安全的。
我們現(xiàn)在使用 Core Locations,對內(nèi)存中的數(shù)據(jù)做第二次遍歷。
NSPredicate *exactPredicate = [self exactLatitudeAndLongitudePredicateForCoordinate:self.location.coordinate];
stops = [stops filteredArrayUsingPredicate:exactPredicate];
和:
- (NSPredicate *)exactLatitudeAndLongitudePredicateForCoordinate:(CLLocationCoordinate2D)pointOfInterest;
{
return [NSPredicate predicateWithBlock:^BOOL(Stop *evaluatedStop, NSDictionary *bindings) {
CLLocation *evaluatedLocation = [[CLLocation alloc] initWithLatitude:evaluatedStop.latitude longitude:evaluatedStop.longitude];
CLLocationDistance distance = [self.location distanceFromLocation:evaluatedLocation];
return (distance < self.distance);
}];
}
至此我們完成了全部設(shè)置。
使用裝載了 SSD 硬盤的新一代 MacBook Pro 讀取這些數(shù)據(jù)平均約需要 360μs,也就是說,你每秒可以做大約 2800 次請求。iPhone 5 平均約需要 1.67ms,每秒 600 次請求。
如果加上 -com.apple.CoreData.SQLDebug1
作為啟動參數(shù)傳遞給應(yīng)用程序,我們將得到如下輸出:
sql: SELECT 0, t0.Z_PK, t0.Z_OPT, t0.ZIDENTIFIER, t0.ZLATITUDE, t0.ZLONGITUDE, t0.ZNAME FROM ZSTOP t0 WHERE (? <= t0.ZLONGITUDE AND t0.ZLONGITUDE <= ? AND ? <= t0.ZLATITUDE AND t0.ZLATITUDE <= ?) LIMIT 100
annotation: sql connection fetch time: 0.0008s
annotation: total fetch execution time: 0.0013s for 15 rows.
除開一些 (對于存儲本身的) 統(tǒng)計信息外,實際為讀取數(shù)據(jù)而生成的 SQL 是:
SELECT 0, t0.Z_PK, t0.Z_OPT, t0.ZIDENTIFIER, t0.ZLATITUDE, t0.ZLONGITUDE, t0.ZNAME FROM ZSTOP t0
WHERE (? <= t0.ZLONGITUDE AND t0.ZLONGITUDE <= ? AND ? <= t0.ZLATITUDE AND t0.ZLATITUDE <= ?)
LIMIT 200
這正是我們所期望的。如果我們想要對這個性能進(jìn)行調(diào)查研究,我們可以使用 SQL EXPLAIN
命令。為此,我們可以像下面這樣使用命令行 sqlite3
來打開數(shù)據(jù)庫:
% cd TrafficSearch
% sqlite3 transit-data.sqlite
SQLite version 3.7.13 2012-07-17 17:46:21
Enter ".help" for instructions
Enter SQL statements terminated with a ";"
sqlite> EXPLAIN QUERY PLAN SELECT 0, t0.Z_PK, t0.Z_OPT, t0.ZIDENTIFIER, t0.ZLATITUDE, t0.ZLONGITUDE, t0.ZNAME FROM ZSTOP t0
...> WHERE (13.30845219672199 <= t0.ZLONGITUDE AND t0.ZLONGITUDE <= 13.33441458422844 AND 52.42769566863058 <= t0.ZLATITUDE AND t0.ZLATITUDE <= 52.44352370653525)
...> LIMIT 100;
0|0|0|SEARCH TABLE ZSTOP AS t0 USING INDEX ZSTOP_ZLONGITUDE_INDEX (ZLONGITUDE>? AND ZLONGITUDE<?) (~6944 rows)
這告訴我們 SQLite 為 (ZLONGITUDE>? AND ZLONGITUDE<?)
條件使用了 ZSTOP_ZLONGITUDE_INDEX
。我們像 model 文章 中描述的那樣使用復(fù)合索引則會做的更好。由于我們總是同時搜索經(jīng)度和緯度的組合,這么做會更高效,而且我們可以去掉經(jīng)度和緯度各自的索引。
這將使輸出像下面這樣:
0|0|0|SEARCH TABLE ZSTOP AS t0 USING INDEX ZSTOP_ZLONGITUDE_ZLATITUDE (ZLONGITUDE>? AND ZLONGITUDE<?) (~6944 rows)
在我們的簡單案例中,加上復(fù)合索引幾乎不影響性能。
就像在 SQLite 文檔中的說明一樣,如果你的輸出里含有 SCAN TABLE
的話,你就要提高警惕了,這基本上意味著 SQLite 需要遍歷 所有的 記錄來看看那些是相匹配的。除非你只存儲了很少的幾個對象,否則你都應(yīng)該使用使用 index。
假設(shè)我們只想要那些接近我們的且在接下來 20 分鐘之內(nèi)提供服務(wù)的車站。
我們可以像這樣為 StopTimes 的實體創(chuàng)建一個謂詞:
NSPredicate *timePredicate = [NSPredicate predicateWithFormat:@"(%@ <= departureTime) && (departureTime <= %@)",
startDate, endDate];
但是如果我們想要的謂詞是可以基于與 StopTimes 停留時間 對象的關(guān)系而過濾出那些 Stop 車站 對象,而不是 停留時間 對象本身的話,我們可以使用一個這樣的 子查詢
:
NSPredicate *predicate = [NSPredicate predicateWithFormat:
@"(SUBQUERY(stopTimes, $x, (%@ <= $x.departureTime) && ($x.departureTime <= %@)).@count != 0)",
startDate, endDate];
請注意,如果接近午夜,這個邏輯是稍有瑕疵的,因為我們應(yīng)當(dāng)將謂詞一分為二。不過該邏輯在這個例子中是可行的。
對于限制數(shù)據(jù)在關(guān)系之上的,子查詢非常有用。在 Xcode 文檔 -[NSExpression expressionForSubquery:usingIteratorVariable:predicate:]
中有更多信息。
我們可以簡單的使用 and
或者 &&
來組合兩個謂詞,例如:
[NSPredicate predicateWithFormat:@"(%@ <= departureTime) && (SUBQUERY(stopTimes ....
或者在代碼中使用 +[NSCompoundPredicate andPredicateWithSubpredicates:]
。
我們用一個像這樣的謂詞來作為結(jié)束:
(lldb) po predicate
(13.39657778010461 <= longitude AND longitude <= 13.42266155792719
AND 52.63249629924865 <= latitude AND latitude <= 52.64832433715332)
AND SUBQUERY(
stopTimes, $x, CAST(-978250148.000000, "NSDate") <= $x.departureTime
AND $x.departureTime <= CAST(-978306000.000000, "NSDate")
).@count != 0
如果我們看一下生成的 SQL,它會像下面這樣:
sql: SELECT 0, t0.Z_PK, t0.Z_OPT, t0.ZIDENTIFIER, t0.ZLATITUDE, t0.ZLONGITUDE, t0.ZNAME FROM ZSTOP t0
WHERE ((? <= t0.ZLONGITUDE AND t0.ZLONGITUDE <= ? AND ? <= t0.ZLATITUDE AND t0.ZLATITUDE <= ?)
AND (SELECT COUNT(t1.Z_PK) FROM ZSTOPTIME t1 WHERE (t0.Z_PK = t1.ZSTOP AND ((? <= t1.ZDEPARTURETIME AND t1.ZDEPARTURETIME <= ?))) ) <> ?)
LIMIT 200
這個 fetch 請求在新一代 MacBook Pro 上運行大約需要 12.3 ms。在 iPhone 5 上,大約需要 110 ms。請注意,我們有 300 萬 個停留時間 和將近 13,000 個車站。
explan 這個查詢,結(jié)果如下:
sqlite> EXPLAIN QUERY PLAN SELECT 0, t0.Z_PK, t0.Z_OPT, t0.ZIDENTIFIER, t0.ZLATITUDE, t0.ZLONGITUDE, t0.ZNAME FROM ZSTOP t0
...> WHERE ((13.37190946378911 <= t0.ZLONGITUDE AND t0.ZLONGITUDE <= 13.3978625285315 AND 52.41186440524024 <= t0.ZLATITUDE AND t0.ZLATITUDE <= 52.42769244314491) AND
...> (SELECT COUNT(t1.Z_PK) FROM ZSTOPTIME t1 WHERE (t0.Z_PK = t1.ZSTOP AND ((-978291733.000000 <= t1.ZDEPARTURETIME AND t1.ZDEPARTURETIME <= -978290533.000000))) ) <> ?)
...> LIMIT 200;
0|0|0|SEARCH TABLE ZSTOP AS t0 USING INDEX ZSTOP_ZLONGITUDE_ZLATITUDE (ZLONGITUDE>? AND ZLONGITUDE<?) (~3472 rows)
0|0|0|EXECUTE CORRELATED SCALAR SUBQUERY 1
1|0|0|SEARCH TABLE ZSTOPTIME AS t1 USING INDEX ZSTOPTIME_ZSTOP_INDEX (ZSTOP=?) (~2 rows)
請注意,我們?nèi)绾螌χ^詞排序非常重要。我們希望把經(jīng)緯度放在前面,因為代價低,而子查詢由于代價高則放在語句最后。
搜索文本是一種常見的情況。在我們的例子中,來看看使用名稱來搜索 車站 實體。
柏林有個被稱為 "U G?rlitzer Bahnhof (Berlin)" 的車站。一種很傻很天真的搜索該站的方法如下:
NSString *searchString = @"U G?rli";
predicate = [NSPredicate predicateWithFormat:@"name BEGINSWITH %@", searchString];
如果你想按照如下所示做的話,事情會變得更糟 (比如進(jìn)行一項大小寫和(或)音調(diào)不敏感的查詢。):
name BEGINSWITH[cd] 'u gorli'
事實上,事情并不是那么簡單。Unicode 非常復(fù)雜,并且有很多陷阱。首要的是很多字符可以通過多種方式來表示。 U+00F6 和 U+006F都代表 U+0308 都可以表示 "?."。如果你身處 ASCII 碼的世界之外時,像大寫 / 小寫這樣的概念就會非常復(fù)雜。
SQLite 會為你減輕負(fù)擔(dān),但它是要付出代價的。雖然它看起來很直接,但事實并非如此。對于字符串搜索,我們想做的是在我們有一個規(guī)范化的版本可以在其中進(jìn)行搜索。我們將消除音調(diào)符號,把字符串變成小寫字母,然后將其放入一個 normalizedName
字段中。然后我們將對用于搜索的字符串做同樣的事情。然后 SQLite 就不必考慮音調(diào)和大小寫,在大小寫和音調(diào)不敏感的情況下,搜索就仍會很快。但是我們必須先完成一系列繁重的任務(wù)。
在新一代 MacBook Pro 上,使用示例代碼使用 BEGINSWITH[cd]
和示例的字符串搜索需要 7.6ms (130 次搜索 / 秒),在 iPhone 5 上這個數(shù)字是每次搜索 47ms,每秒進(jìn)行 21 次搜索。
為了將字符串轉(zhuǎn)換為小寫并移除其音調(diào),我們可以使用 CFStringTransform()
:
@implementation NSString (SearchNormalization)
- (NSString *)normalizedSearchString;
{
// 參考 <http://userguide.icu-project.org/transforms>
NSString *mutableName = [self mutableCopy];
CFStringTransform((__bridge CFMutableStringRef) mutableName, NULL,
(__bridge CFStringRef)@"NFD; [:Nonspacing Mark:] Remove; Lower(); NFC", NO);
return mutableName;
}
@end
我們將更新 Stop
類來自動更新 normalizedName
:
@interface Stop (CoreDataForward)
@property (nonatomic, strong) NSString *primitiveName;
@property (nonatomic, strong) NSString *primitiveNormalizedName;
@end
@implementation Stop
@dynamic name;
- (void)setName:(NSString *)name;
{
[self willAccessValueForKey:@"name"];
[self willAccessValueForKey:@"normalizedName"];
self.primitiveName = name;
self.primitiveNormalizedName = [name normalizedSearchString];
[self didAccessValueForKey:@"normalizedName"];
[self didAccessValueForKey:@"name"];
}
// ...
@end
有了這些,我們就可以用 BEGINSWITH
代替 BEGINSWITH[cd]
來搜索了:
predicate = [NSPredicate predicateWithFormat:@"normalizedName BEGINSWITH %@", [searchString normalizedSearchString]];
在新一代 MacBook Pro 上,使用示例代碼中的示例字符串搜索 BEGINSWITH
需要 6.2ms(160 次搜索 / 秒),在 iPhone 5 大約上需要 40ms,25 次搜索 / 秒。
我們的搜索還只能在字符串的開頭和搜索字符串相匹配的情況下有效。要解決這個問題就要創(chuàng)建另一個用來搜索的實體。我們稱這個實體為 SearchTerm
,給其一個 normalizedWord
屬性,以及一個和 Stop
的關(guān)系。對于每個車站
我們將規(guī)范它們的名稱,并將其拆分成一個個詞。例如:
"Gedenkst?tte Dt. Widerstand (Berlin)"
-> "gedenkstatte dt. widerstand (berlin)"
-> "gedenkstatte", "dt", "widerstand", "berlin"
對于每個詞。我們創(chuàng)建一個 SearchTerm
和一個從 Stop
到它的所有 SearchTerm
對象的關(guān)系。當(dāng)用戶輸入一個字符串,我們用以下代碼在 SearchTerm
對象的 normalizedWord
上搜索:
predicate = [NSPredicate predicateWithFormat:@"normalizedWord BEGINSWITH %@", [searchString normalizedSearchString]]
這也可以在 Stop
對象中直接用子查詢完成。
如果我們的獲取請求中沒有設(shè)置謂詞,我們將為獲取到給定 實體 的所有對象。如果我們對 StopTimes
實體這樣做的話,我們將會牽涉 300 萬個對象。這將會變得緩慢,以及占用大量內(nèi)存。然而有時候,我們就是需要獲取所有對象。常見的例子是我們想要在一個 table view 中顯示所有對象。
在這種情況中,我們要做的是設(shè)置批處理量:
request.fetchBatchSize = 50;
當(dāng)我們設(shè)置了批處理量運行 -[NSManagedObjectContext executeFetchRequest:error:]
的時候,我們?nèi)匀粫玫揭粋€返回的數(shù)組。我們可以查詢它的元素數(shù)量(對于 StopTimes
實體而言,這將接近 300 萬),不過 Core Data 將只會隨著我們對數(shù)組的循環(huán)訪問將對象填充進(jìn)去。如果這些對象不再被訪問,Core Data 則會再次清理對象。簡單來說,數(shù)組的批處理量為 50(在這個例子中)。Core Data 將一次獲取 50 個對象。一旦有超過一定數(shù)量的批量對象,Core Data 將釋放最舊一批對象。于是,你就可以在這樣的數(shù)組中循環(huán)訪問所有對象,而無需在存儲器中同時存所有 300 萬個對象。
在 iOS 中,如果你使用 NSFetchedResultsController
且有很多對象,請確保你的 fetch 請求中設(shè)置了 fetchBatchSize
。你需要實際實驗以確定多少的處理量更適合你。一般來說,將其設(shè)置為你要顯示的數(shù)目的兩倍,會是一個不錯的開始。