感謝翻譯小組成員
崩月姐姐熱心翻譯。本篇文章是咱們每週推薦優秀國外的技術類文章的其中一篇。若是您有不錯的原創或譯文,歡迎提交給咱們,更歡迎其餘朋友加入咱們的翻譯小組(聯繫qq:2408167315)。
如何在iOS地圖上以用戶能夠理解並樂於接受的方式來處理和顯示大量數據?這個教程將會給你們進行示例說明。
咱們要開發一款iOS的app應用,這個應用包含有87000個旅館的信息,每一個旅館的信息中包括有一個座標值,一個旅館名跟一個電話號碼。這款app能夠在用戶拖動、放大縮小地圖時更新旅館數據,而不須要用戶從新進行搜索。
爲了達到這個目的,咱們須要構造一個可快速檢索的數據結構。C語言的性能高,因此咱們用C語言來構造這個數據結構。爲了確保大量的數據不會讓用戶感到迷惑,因此咱們還須要想出一個合併數據的解決方案。最後,爲了更好的適應市場,咱們須要把app作的更完善一些。
完成這個教學後,你將學到這款app的全部核心內容。
數據結構
首先咱們先來分析下數據,搞清咱們要如何處理數據。旅館數據中包含了一系列的座標點(包括緯度和經度),咱們須要根據這些座標點在地圖上進行標註。地圖可 以任意的拖動並放大縮小,因此咱們不須要把全部的點都所有繪製出來,咱們只須要繪製能夠顯示在屏幕上的點。核心問題是:咱們須要查詢出顯示在屏幕上的全部 的點,因此咱們要想出一個查找算法,查找存在於一個矩形範圍內的全部點。
一個簡單的解決方式就是遍歷全部的點,而後判斷(xMin<=x<=xMax而且yMin<=y<=yMax),很不幸,這是一個複雜度爲O(N)的算法,顯然不適合咱們的狀況。
這兒有個更好的解決方法,就是咱們能夠利用對稱性來減小咱們的查詢範圍。那麼如何能經過查詢的每一次的迭代來減小查詢的範圍呢?咱們能夠在每一個區域內都加 索引,這樣能夠有效減小查詢的範圍。這種區域索引的方式能夠用四叉樹來實現,查詢複雜度爲O(H)(H是查詢的那個點所在的樹的高度)
四叉樹
四叉樹是一個數據結構,由一系列的結點(node)構成。每一個結點包含一個桶(bucket)跟一個包圍框(boundingbox)。每一個桶裏面有一系 列的點(point)。若是一個點包含在一個外包圍框A中,就會被添加到A所在結點的桶(bucket)中。一旦這個結點的桶滿了,這個結點就會分裂成四 個子結點,每一個子節點的包圍框分別是當前結點包圍框的1/4。分裂以後那些原本要放到當前結點桶中的點就都會放到子容器的桶中。
那麼咱們該如何來對四叉樹進行編碼呢?
咱們先來定義基本的結構:
- typedef struct TBQuadTreeNodeData {
- double x;
- double y;
- void* data;
- } TBQuadTreeNodeData;
- TBQuadTreeNodeData TBQuadTreeNodeDataMake(double x, double y, void* data);
-
- typedef struct TBBoundingBox {
- double x0; double y0;
- double xf; double yf;
- } TBBoundingBox;
- TBBoundingBox TBBoundingBoxMake(double x0, double y0, double xf, double yf);
-
- typedef struct quadTreeNode {
- struct quadTreeNode* northWest;
- struct quadTreeNode* northEast;
- struct quadTreeNode* southWest;
- struct quadTreeNode* southEast;
- TBBoundingBox boundingBox;
- int bucketCapacity;
- TBQuadTreeNodeData *points;
- int count;
- } TBQuadTreeNode;
- TBQuadTreeNode* TBQuadTreeNodeMake(TBBoundingBox boundary, int bucketCapacity);
TBQuadTreeNodeData結構包含了座標點(緯度,經度)。void*data是一個普通的指針,用來存儲咱們須要的其餘信息,如旅館名跟電 話號碼。TBBoundingBox表明一個用於範圍查詢的長方形,也就是以前談到(xMin<=x<=xMax&& yMin<=y<=yMax)查詢的那個長方形。左上角是(xMin,yMin),右下角是(xMax,yMax)。
最後,咱們看下TBQuadTreeNode結構,這個結構包含了四個指針,每一個指針分別指向這個結點的四個子節點。它還有一個外包圍框和一個數組(數組中就是那個包含一系列座標點的桶)。
在咱們創建完四叉樹的同時,空間上的索引也就同時造成了。這是生成四叉樹的演示動畫。
下面的代碼準確描述了以上動畫的過程:
- void TBQuadTreeNodeSubdivide(TBQuadTreeNode* node)
- {
- TBBoundingBox box = node->boundingBox;
-
- double xMid = (box.xf + box.x0) / 2.0;
- double yMid = (box.yf + box.y0) / 2.0;
-
- TBBoundingBox northWest = TBBoundingBoxMake(box.x0, box.y0, xMid, yMid);
- node->northWest = TBQuadTreeNodeMake(northWest, node->bucketCapacity);
-
- TBBoundingBox northEast = TBBoundingBoxMake(xMid, box.y0, box.xf, yMid);
- node->northEast = TBQuadTreeNodeMake(northEast, node->bucketCapacity);
-
- TBBoundingBox southWest = TBBoundingBoxMake(box.x0, yMid, xMid, box.yf);
- node->southWest = TBQuadTreeNodeMake(southWest, node->bucketCapacity);
-
- TBBoundingBox southEast = TBBoundingBoxMake(xMid, yMid, box.xf, box.yf);
- node->southEast = TBQuadTreeNodeMake(southEast, node->bucketCapacity);
- }
-
- bool TBQuadTreeNodeInsertData(TBQuadTreeNode* node, TBQuadTreeNodeData data)
- {
-
- if (!TBBoundingBoxContainsData(node->boundingBox, data)) {
- return false;
- }
-
-
- if (node->count < node->bucketCapacity) {
- node->points[node->count++] = data;
- return true;
- }
-
-
- if (node->northWest == NULL) {
- TBQuadTreeNodeSubdivide(node);
- }
-
-
- if (TBQuadTreeNodeInsertData(node->northWest, data)) return true;
- if (TBQuadTreeNodeInsertData(node->northEast, data)) return true;
- if (TBQuadTreeNodeInsertData(node->southWest, data)) return true;
- if (TBQuadTreeNodeInsertData(node->southEast, data)) return true;
-
- return false;
- }
如今咱們已經完成了四叉樹的構造,咱們還須要在四叉樹上進行區域範圍查詢並返回TBQuadTreeNodeData結構。如下是區域範圍查詢的演示動畫,在淺藍區域內的是全部的標註點。當標註點被查詢到在指定的區域範圍內,則會被標註爲綠色。
如下是查詢代碼:
- typedef void(^TBDataReturnBlock)(TBQuadTreeNodeData data);
-
- void TBQuadTreeGatherDataInRange(TBQuadTreeNode* node, TBBoundingBox range, TBDataReturnBlock block)
- {
-
- if (!TBBoundingBoxIntersectsBoundingBox(node->boundingBox, range)) {
- return;
- }
-
- for (int i = 0; i < node->count; i++) {
-
- if (TBBoundingBoxContainsData(range, node->points[i])) {
- block(node->points[i]);
- }
- }
-
-
- if (node->northWest == NULL) {
- return;
- }
-
-
- TBQuadTreeGatherDataInRange(node->northWest, range, block);
- TBQuadTreeGatherDataInRange(node->northEast, range, block);
- TBQuadTreeGatherDataInRange(node->southWest, range, block);
- TBQuadTreeGatherDataInRange(node->southEast, range, block);
- }
用四叉樹這種結構能夠進行快速的查詢。在一個包含成百上千條數據的數據庫中,能夠以60fps的速度查詢上百條數據。
用旅館數據來填充四叉樹
旅館的數據來自於POIplaza這個網站,並且已經格式化成csv文件。咱們要從硬盤中讀取出數據並對數據進行轉換,最後用數據來填充四叉樹。
建立四叉樹的代碼在TBCoordinateQuadTree類中:
- typedef struct TBHotelInfo {
- char* hotelName;
- char* hotelPhoneNumber;
- } TBHotelInfo;
-
- TBQuadTreeNodeData TBDataFromLine(NSString *line)
- {
-
-
-
- NSArray *components = [line componentsSeparatedByString:@","];
- double latitude = [components[1] doubleValue];
- double longitude = [components[0] doubleValue];
-
- TBHotelInfo* hotelInfo = malloc(sizeof(TBHotelInfo));
-
- NSString *hotelName = [components[2] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
- hotelInfo->hotelName = malloc(sizeof(char) * hotelName.length + 1);
- strncpy(hotelInfo->hotelName, [hotelName UTF8String], hotelName.length + 1);
-
- NSString *hotelPhoneNumber = [[components lastObject] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
- hotelInfo->hotelPhoneNumber = malloc(sizeof(char) * hotelPhoneNumber.length + 1);
- strncpy(hotelInfo->hotelPhoneNumber, [hotelPhoneNumber UTF8String], hotelPhoneNumber.length + 1);
-
- return TBQuadTreeNodeDataMake(latitude, longitude, hotelInfo);
- }
-
- - (void)buildTree
- {
- NSString *data = [NSString stringWithContentsOfFile:[[NSBundle mainBundle] pathForResource:@"USA-HotelMotel" ofType:@"csv"] encoding:NSASCIIStringEncoding error:nil];
- NSArray *lines = [data componentsSeparatedByString:@"\n"];
-
- NSInteger count = lines.count - 1;
-
- TBQuadTreeNodeData *dataArray = malloc(sizeof(TBQuadTreeNodeData) * count);
- for (NSInteger i = 0; i < count; i++) {
- dataArray[i] = TBDataFromLine(lines[i]);
- }
-
- TBBoundingBox world = TBBoundingBoxMake(19, -166, 72, -53);
- _root = TBQuadTreeBuildWithData(dataArray, count, world, 4);
- }
如今咱們用iPhone上預加載的數據建立了一個四叉樹。接下來咱們將處理app的下一個部分:合併數據(clustering)。
合併數據(clustering)
如今咱們有了一個裝滿旅館數據的四叉樹,能夠用來解決合併數據的問題了。首先,讓咱們來探索下合併數據的緣由。咱們合併數據是由於咱們不想由於數據過於龐 大而使用戶迷惑。實際上有不少種方式能夠解決這個問題。GoogleMaps根據地圖的縮放等級(zoomlevel)來顯示搜索結果數據中的一部分數 據。地圖放的越大,就越能清晰的看到更細節的標註,直到你能看到全部有效的標註。咱們將採用這種合併數據的方式,只顯示出來旅館的個數,而不在地圖上顯示 出全部的旅館信息。
最終呈現的標註是一箇中心顯示旅館個數的小圓圈。實現的原理跟如何把圖片縮小的原理差很少。咱們先在地圖上畫一個格子。每一個格子中包含了不少個小單元格, 每一個小單元格中的全部旅館數據合併出一個標註。而後經過每一個小單元格中全部旅館的座標值的平均值來決定合併後這個標註的座標值。
這是以上處理的演示動畫。
如下是代碼實現過程。在TBCoordinateQuadTree類中添加了一個方法。
- - (NSArray *)clusteredAnnotationsWithinMapRect:(MKMapRect)rect withZoomScale:(double)zoomScale
- {
- double TBCellSize = TBCellSizeForZoomScale(zoomScale);
- double scaleFactor = zoomScale / TBCellSize;
-
- NSInteger minX = floor(MKMapRectGetMinX(rect) * scaleFactor);
- NSInteger maxX = floor(MKMapRectGetMaxX(rect) * scaleFactor);
- NSInteger minY = floor(MKMapRectGetMinY(rect) * scaleFactor);
- NSInteger maxY = floor(MKMapRectGetMaxY(rect) * scaleFactor);
-
- NSMutableArray *clusteredAnnotations = [[NSMutableArray alloc] init];
-
- for (NSInteger x = minX; x <= maxX; x++) {
- for (NSInteger y = minY; y <= maxY; y++) {
-
- MKMapRect mapRect = MKMapRectMake(x / scaleFactor, y / scaleFactor, 1.0 / scaleFactor, 1.0 / scaleFactor);
-
- __block double totalX = 0;
- __block double totalY = 0;
- __block int count = 0;
-
- TBQuadTreeGatherDataInRange(self.root, TBBoundingBoxForMapRect(mapRect), ^(TBQuadTreeNodeData data) {
- totalX += data.x;
- totalY += data.y;
- count++;
- });
-
- if (count >= 1) {
- CLLocationCoordinate2D coordinate = CLLocationCoordinate2DMake(totalX / count, totalY / count);
- TBClusterAnnotation *annotation = [[TBClusterAnnotation alloc] initWithCoordinate:coordinate count:count];
- [clusteredAnnotations addObject:annotation];
- }
- }
- }
-
- return [NSArray arrayWithArray:clusteredAnnotations];
- }
上面的方法在指定小單元格大小的前提下合併數據生成了最終的標註。如今咱們須要作的就是把這些標註繪製到MKMapView上。首先咱們建立一個 UIViewController的子類,而後用MKMapView做爲它的view視圖。在可視區域改變的狀況下,咱們須要實時更新標註的顯示,因此我 們要實現mapView:regionDidChangeAnimated:的協議方法。
- - (void)mapView:(MKMapView *)mapView regionDidChangeAnimated:(BOOL)animated
- {
- [[NSOperationQueue new] addOperationWithBlock:^{
- double zoomScale = self.mapView.bounds.size.width / self.mapView.visibleMapRect.size.width;
- NSArray *annotations = [self.coordinateQuadTree clusteredAnnotationsWithinMapRect:mapView.visibleMapRect withZoomScale:zoomScale];
-
- [self updateMapViewAnnotationsWithAnnotations:annotations];
- }];
- }
只添加必要的標註
在主線程中咱們指望儘量花費較少時間來作運算,這意味着咱們要儘量的把全部內容都放到後臺的線程中。爲了在主線程中花費更少的時間來作計算,咱們只須要繪製一些必要的標註。這能夠避免用戶滑動過程當中感到很卡,從而保證流暢的用戶體驗。
開始以前,咱們看一下下面的圖片:
左邊的屏幕截圖是地圖進行滑動前的地圖快照。這個快照中的標註就是目前mapView中的標註,咱們稱這個爲「before集合」。
右邊的屏幕截圖是地圖進行滑動後的地圖快照。這個快照中的標註就是從clusteredAnnotationsWithinMapRect:withZoomScale:這個函數中獲得的返回值。咱們稱這個爲「after集合」。
咱們指望保留兩個快照中都存在的標註點(即重合的那些標註點),去除在「after集合」中不存在的那些標註點,同時添加那些新的標註點。
- - (void)updateMapViewAnnotationsWithAnnotations:(NSArray *)annotations
- {
- NSMutableSet *before = [NSMutableSet setWithArray:self.mapView.annotations];
- NSSet *after = [NSSet setWithArray:annotations];
-
-
- NSMutableSet *toKeep = [NSMutableSet setWithSet:before];
- [toKeep intersectSet:after];
-
-
- NSMutableSet *toAdd = [NSMutableSet setWithSet:after];
- [toAdd minusSet:toKeep];
-
-
- NSMutableSet *toRemove = [NSMutableSet setWithSet:before];
- [toRemove minusSet:after];
-
-
- [[NSOperationQueue mainQueue] addOperationWithBlock:^{
- [self.mapView addAnnotations:[toAdd allObjects]];
- [self.mapView removeAnnotations:[toRemove allObjects]];
- }];
- }
這樣咱們儘量的確保在主線程上作少許的工做,從而提高地圖滑動的流暢性。
接下來咱們來看下如何繪製標註,而且在標註上顯示出來旅館的個數。最後咱們給標註加上點擊事件,這樣使得app從頭到腳均可以表現的很是完美。
繪製標註
因爲咱們在地圖上並無徹底顯示出所有旅館,因此咱們須要在剩餘的這些標註上表現出真實的旅館總量。
首先建立一個圓形的標註,中間顯示合併後的個數,也就是旅館的真實總量。這個圓形的大小一樣能夠反映出合併後的個數。
爲了實現這個需求,咱們要找出一個方程式,容許咱們在1到500+的數值中生成一個縮小後的數值。用這個數值來做爲標註的大小。咱們將用到如下的方程式。
x值較低的時候f(x)增加的比較快,x在值變大的時候f(x)增加變緩慢,β值用來控制f(x)趨於1的速度。α值影響最小值(在咱們的項目中,咱們的最小合併值(也就是1)能佔總共最大值的60%)。
- static CGFloat const TBScaleFactorAlpha = 0.3;
- static CGFloat const TBScaleFactorBeta = 0.4;
-
- CGFloat TBScaledValueForValue(CGFloat value)
- {
- return 1.0 / (1.0 + expf(-1 * TBScaleFactorAlpha * powf(value, TBScaleFactorBeta)));
- }
-
- - (void)setCount:(NSUInteger)count
- {
- _count = count;
-
-
- CGRect newBounds = CGRectMake(0, 0, roundf(44 * TBScaledValueForValue(count)), roundf(44 * TBScaledValueForValue(count)));
- self.frame = TBCenterRect(newBounds, self.center);
-
- CGRect newLabelBounds = CGRectMake(0, 0, newBounds.size.width / 1.3, newBounds.size.height / 1.3);
- self.countLabel.frame = TBCenterRect(newLabelBounds, TBRectCenter(newBounds));
- self.countLabel.text = [@(_count) stringValue];
-
- [self setNeedsDisplay];
- }
如今標註的大小已經OK了。讓咱們再來把這個標註作漂亮些。
- - (void)setupLabel
- {
- _countLabel = [[UILabel alloc] initWithFrame:self.frame];
- _countLabel.backgroundColor = [UIColor clearColor];
- _countLabel.textColor = [UIColor whiteColor];
- _countLabel.textAlignment = NSTextAlignmentCenter;
- _countLabel.shadowColor = [UIColor colorWithWhite:0.0 alpha:0.75];
- _countLabel.shadowOffset = CGSizeMake(0, -1);
- _countLabel.adjustsFontSizeToFitWidth = YES;
- _countLabel.numberOfLines = 1;
- _countLabel.font = [UIFont boldSystemFontOfSize:12];
- _countLabel.baselineAdjustment = UIBaselineAdjustmentAlignCenters;
- [self addSubview:_countLabel];
- }
-
- - (void)drawRect:(CGRect)rect
- {
- CGContextRef context = UIGraphicsGetCurrentContext();
-
- CGContextSetAllowsAntialiasing(context, true);
-
- UIColor *outerCircleStrokeColor = [UIColor colorWithWhite:0 alpha:0.25];
- UIColor *innerCircleStrokeColor = [UIColor whiteColor];
- UIColor *innerCircleFillColor = [UIColor colorWithRed:(255.0 / 255.0) green:(95 / 255.0) blue:(42 / 255.0) alpha:1.0];
-
- CGRect circleFrame = CGRectInset(rect, 4, 4);
-
- [outerCircleStrokeColor setStroke];
- CGContextSetLineWidth(context, 5.0);
- CGContextStrokeEllipseInRect(context, circleFrame);
-
- [innerCircleStrokeColor setStroke];
- CGContextSetLineWidth(context, 4);
- CGContextStrokeEllipseInRect(context, circleFrame);
-
- [innerCircleFillColor setFill];
- CGContextFillEllipseInRect(context, circleFrame);
- }
添加最後的touch事件
目前的標註能夠很好的呈現出咱們的數據了,讓咱們最後添加一些touch事件來讓咱們的app用起來更有趣。
首先,咱們須要爲新添加到地圖上的標註作一個動畫。若是沒有添加動畫的話,新的標註就會在地圖上忽然出現,體驗效果將會大打折扣。
- - (void)addBounceAnnimationToView:(UIView *)view
- {
- CAKeyframeAnimation *bounceAnimation = [CAKeyframeAnimation animationWithKeyPath:@"transform.scale"];
-
- bounceAnimation.values = @[@(0.05), @(1.1), @(0.9), @(1)];
-
- bounceAnimation.duration = 0.6;
- NSMutableArray *timingFunctions = [[NSMutableArray alloc] init];
- for (NSInteger i = 0; i < 4; i++) {
- [timingFunctions addObject:[CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]];
- }
- [bounceAnimation setTimingFunctions:timingFunctions.copy];
- bounceAnimation.removedOnCompletion = NO;
-
- [view.layer addAnimation:bounceAnimation forKey:@"bounce"];
- }
-
- - (void)mapView:(MKMapView *)mapView didAddAnnotationViews:(NSArray *)views
- {
- for (UIView *view in views) {
- [self addBounceAnnimationToView:view];
- }
- }
接下來,咱們想要根據地圖的縮放比例來改變在合併時的小單元格(cell)的大小。在地圖進行放大時,小單元格變小。因此咱們須要定義一下當前地圖的縮放 比例。也就是scale=mapView.bounds.size.width/mapView.visibleMapRect.size.width:
- NSInteger TBZoomScaleToZoomLevel(MKZoomScale scale)
- {
- double totalTilesAtMaxZoom = MKMapSizeWorld.width / 256.0;
- NSInteger zoomLevelAtMaxZoom = log2(totalTilesAtMaxZoom);
- NSInteger zoomLevel = MAX(0, zoomLevelAtMaxZoom + floor(log2f(scale) + 0.5));
-
- return zoomLevel;
- }
咱們爲每一個地圖縮放的比例都定義一個常量。
- float TBCellSizeForZoomScale(MKZoomScale zoomScale)
- {
- NSInteger zoomLevel = TBZoomScaleToZoomLevel(zoomScale);
-
- switch (zoomLevel) {
- case 13:
- case 14:
- case 15:
- return 64;
- case 16:
- case 17:
- case 18:
- return 32;
- case 19:
- return 16;
-
- default:
- return 88;
- }
- }
如今咱們放大地圖,咱們將看到逐漸變小的標註,直到最後咱們能看到表明每一個旅館的那個標註。