2016-01-13 / 23:02:13
剛才在微信上看到這篇由cocoachina翻譯小組成員翻譯的文章,覺得還是挺值得參考的,因此轉載至此,原文請移步:http://robots.thoughtbot.com/how-to-handle-large-amounts-of-data-on-maps/。
如何在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。分裂之后那些本來要放到當前結點桶中的點就都會放到子容器的桶中。
那么我們該如何來對四叉樹進行編碼呢?
我們先來定義基本的結構:
1 typedef struct TBQuadTreeNodeData { 2 double x; 3 double y; 4 void* data; 5 } TBQuadTreeNodeData; 6 TBQuadTreeNodeData TBQuadTreeNodeDataMake(double x, double y, void* data); 7
8 typedef struct TBBoundingBox { 9 double x0; double y0; 10 double xf; double yf; 11 } TBBoundingBox; 12 TBBoundingBox TBBoundingBoxMake(double x0, double y0, double xf, double yf); 13
14 typedef struct quadTreeNode { 15 struct quadTreeNode* northWest; 16 struct quadTreeNode* northEast; 17 struct quadTreeNode* southWest; 18 struct quadTreeNode* southEast; 19 TBBoundingBox boundingBox; 20 int bucketCapacity; 21 TBQuadTreeNodeData *points; 22 int count; 23 } TBQuadTreeNode; 24 TBQuadTreeNode* TBQuadTreeNodeMake(TBBoundingBox boundary, int bucketCapacity);
TBQuadTreeNodeData
結構包含了坐標點(緯度,經度)。
void *data
是一個普通的指針,用來存儲我們需要的其他信息,如旅館名跟電話號碼。
TBBoundingBox
代表一個用於范圍查詢的長方形,也就是之前談到(xMin<=x<=xMax&&yMin<=y<=yMax)
查詢的那個長方形。左上角是(xMin,yMin),右下角是(xMax,yMax)。
最后,我們看下TBQuadTreeNode
結構,這個結構包含了四個指針,每個指針分別指向這個結點的四個子節點。它還有一個外包圍框和一個數組(數組中就是那個包含一系列坐標點的桶)。
在我們建立完四叉樹的同時,空間上的索引也就同時形成了。這是生成四叉樹的演示動畫。
下面的代碼准確描述了以上動畫的過程:
1 void TBQuadTreeNodeSubdivide(TBQuadTreeNode* node) { 2 TBBoundingBox box = node->boundingBox; 3 4 double xMid = (box.xf + box.x0) / 2.0; 5 double yMid = (box.yf + box.y0) / 2.0; 6 7 TBBoundingBox northWest = TBBoundingBoxMake(box.x0, box.y0, xMid, yMid); 8 node->northWest = TBQuadTreeNodeMake(northWest, node->bucketCapacity); 9 10 TBBoundingBox northEast = TBBoundingBoxMake(xMid, box.y0, box.xf, yMid); 11 node->northEast = TBQuadTreeNodeMake(northEast, node->bucketCapacity); 12 13 TBBoundingBox southWest = TBBoundingBoxMake(box.x0, yMid, xMid, box.yf); 14 node->southWest = TBQuadTreeNodeMake(southWest, node->bucketCapacity); 15 16 TBBoundingBox southEast = TBBoundingBoxMake(xMid, yMid, box.xf, box.yf); 17 node->southEast = TBQuadTreeNodeMake(southEast, node->bucketCapacity); 18 } 19 20 bool TBQuadTreeNodeInsertData(TBQuadTreeNode* node, TBQuadTreeNodeData data) { 21 // Bail if our coordinate is not in the boundingBox 22 if (!TBBoundingBoxContainsData(node->boundingBox, data)) { 23 return false; 24 } 25 26 // Add the coordinate to the points array 27 if (node->count < node->bucketCapacity) { 28 node->points[node->count++] = data; 29 return true; 30 } 31 32 // Check to see if the current node is a leaf, if it is, split 33 if (node->northWest == NULL) { 34 TBQuadTreeNodeSubdivide(node); 35 } 36 37 // Traverse the tree 38 if (TBQuadTreeNodeInsertData(node->northWest, data)) return true; 39 if (TBQuadTreeNodeInsertData(node->northEast, data)) return true; 40 if (TBQuadTreeNodeInsertData(node->southWest, data)) return true; 41 if (TBQuadTreeNodeInsertData(node->southEast, data)) return true; 42 43 return false; 44 }
現在我們已經完成了四叉樹的構造,我們還需要在四叉樹上進行區域范圍查詢並返回TBQuadTreeNodeData
結構。以下是區域范圍查詢的演示動畫,在淺藍區域內的是所有的標注點。當標注點被查詢到在指定的區域范圍內,則會被標注為綠色。
以下是查詢代碼:
1 typedef void(^TBDataReturnBlock)(TBQuadTreeNodeData data); 2 3 void TBQuadTreeGatherDataInRange(TBQuadTreeNode* node, TBBoundingBox range, TBDataReturnBlock block) { 4 // If range is not contained in the node's boundingBox then bail 5 if (!TBBoundingBoxIntersectsBoundingBox(node->boundingBox, range)) { 6 return; 7 } 8 9 for (int i = 0; i < node->count; i++) { 10 // Gather points contained in range 11 if (TBBoundingBoxContainsData(range, node->points[i])) { 12 block(node->points[i]); 13 } 14 } 15 16 // Bail if node is leaf 17 if (node->northWest == NULL) { 18 return; 19 } 20 21 // Otherwise traverse down the tree 22 TBQuadTreeGatherDataInRange(node->northWest, range, block); 23 TBQuadTreeGatherDataInRange(node->northEast, range, block); 24 TBQuadTreeGatherDataInRange(node->southWest, range, block); 25 TBQuadTreeGatherDataInRange(node->southEast, range, block); 26 }
用四叉樹這種結構可以進行快速的查詢。在一個包含成百上千條數據的數據庫中,可以以60fps的速度查詢上百條數據。
用旅館數據來填充四叉樹
旅館的數據來自於POIplaza這個網站,而且已經格式化成csv文件。我們要從硬盤中讀取出數據並對數據進行轉換,最后用數據來填充四叉樹。
創建四叉樹的代碼在 TBCoordinateQuadTreeController
1 typedef struct TBHotelInfo { 2 char* hotelName; 3 char* hotelPhoneNumber; 4 } TBHotelInfo; 5 6 TBQuadTreeNodeData TBDataFromLine(NSString *line) { 7 // Example line: 8 // -80.26262, 25.81015, Everglades Motel, USA-United States, +1 305-888-8797 9 10 NSArray *components = [line componentsSeparatedByString:@","]; 11 double latitude = [components[1] doubleValue]; 12 double longitude = [components[0] doubleValue]; 13 14 TBHotelInfo* hotelInfo = malloc(sizeof(TBHotelInfo)); 15 16 NSString *hotelName = [components[2] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; 17 hotelInfo->hotelName = malloc(sizeof(char) * hotelName.length + 1); 18 strncpy(hotelInfo->hotelName, [hotelName UTF8String], hotelName.length + 1); 19 20 NSString *hotelPhoneNumber = [[components lastObject] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; 21 hotelInfo->hotelPhoneNumber = malloc(sizeof(char) * hotelPhoneNumber.length + 1); 22 strncpy(hotelInfo->hotelPhoneNumber, [hotelPhoneNumber UTF8String], hotelPhoneNumber.length + 1); 23 24 return TBQuadTreeNodeDataMake(latitude, longitude, hotelInfo); 25 } 26 27 - (void)buildTree { 28 NSString *data = [NSString stringWithContentsOfFile:[[NSBundle mainBundle] pathForResource:@"USA-HotelMotel" ofType:@"csv"] encoding:NSASCIIStringEncoding error:nil]; 29 NSArray *lines = [data componentsSeparatedByString:@"\n"]; 30 31 NSInteger count = lines.count - 1; 32 33 TBQuadTreeNodeData *dataArray = malloc(sizeof(TBQuadTreeNodeData) * count); 34 for (NSInteger i = 0; i < count; i++) { 35 dataArray[i] = TBDataFromLine(lines[i]); 36 } 37 38 TBBoundingBox world = TBBoundingBoxMake(19, -166, 72, -53); 39 _root = TBQuadTreeBuildWithData(dataArray, count, world, 4); 40 }
現在我們用iPhone上預加載的數據創建了一個四叉樹。接下來我們將處理app的下一個部分:合並數據(clustering)。
合並數據(clustering)
現在我們有了一個裝滿旅館數據的四叉樹,可以用來解決合並數據的問題了。首先,讓我們來探索下合並數據的原因。我們合並數據是因為我們不想因為數據過於龐大而使用戶迷惑。實際上有很多種方式可以解決這個問題。GoogleMaps根據地圖的縮放等級(zoomlevel)來顯示搜索結果數據中的一部分數據。地圖放的越大,就越能清晰的看到更細節的標注,直到你能看到所有有效的標注。我們將采用這種合並數據的方式,只顯示出來旅館的個數,而不在地圖上顯示出所有的旅館信息。
最終呈現的標注是一個中心顯示旅館個數的小圓圈。實現的原理跟如何把圖片縮小的原理差不多。我們先在地圖上畫一個格子。每個格子中包含了很多個小單元格,每個小單元格中的所有旅館數據合並出一個標注。然后通過每個小單元格中所有旅館的坐標值的平均值來決定合並后這個標注的坐標值。
這是以上處理的演示動畫。
以下是代碼實現過程。在TBCoordinateQuadTree
類中添加了一個方法。
1 - (NSArray *)clusteredAnnotationsWithinMapRect:(MKMapRect)rect withZoomScale:(double)zoomScale { 2 double TBCellSize = TBCellSizeForZoomScale(zoomScale); 3 double scaleFactor = zoomScale / TBCellSize; 4 5 NSInteger minX = floor(MKMapRectGetMinX(rect) * scaleFactor); 6 NSInteger maxX = floor(MKMapRectGetMaxX(rect) * scaleFactor); 7 NSInteger minY = floor(MKMapRectGetMinY(rect) * scaleFactor); 8 NSInteger maxY = floor(MKMapRectGetMaxY(rect) * scaleFactor); 9 10 NSMutableArray *clusteredAnnotations = [[NSMutableArray alloc] init]; 11 12 for (NSInteger x = minX; x <= maxX; x++) { 13 for (NSInteger y = minY; y <= maxY; y++) { 14 15 MKMapRect mapRect = MKMapRectMake(x / scaleFactor, y / scaleFactor, 1.0 / scaleFactor, 1.0 / scaleFactor); 16 17 __block double totalX = 0; 18 __block double totalY = 0; 19 __block int count = 0; 20 21 TBQuadTreeGatherDataInRange(self.root, TBBoundingBoxForMapRect(mapRect), ^(TBQuadTreeNodeData data) { 22 totalX += data.x; 23 totalY += data.y; 24 count++; 25 }); 26 27 if (count >= 1) { 28 CLLocationCoordinate2D coordinate = CLLocationCoordinate2DMake(totalX / count, totalY / count); 29 TBClusterAnnotation *annotation = [[TBClusterAnnotation alloc] initWithCoordinate:coordinate count:count]; 30 [clusteredAnnotations addObject:annotation]; 31 } 32 } 33 } 34 35 return [NSArray arrayWithArray:clusteredAnnotations]; 36 }
上面的方法在指定小單元格大小的前提下合並數據生成了最終的標注。現在我們需要做的就是把這些標注繪制到MKMapView
上。首先我們創建一個UIViewController
的子類,然后用MKMapView
作為它的view視圖。在可視區域改變的情況下,我們需要實時更新標注的顯示,所以我們要實現mapView:regionDidChangeAnimated:
的協議方法。
1 - (void)mapView:(MKMapView *)mapView regionDidChangeAnimated:(BOOL)animated { 2 [[NSOperationQueue new] addOperationWithBlock:^{ 3 double zoomScale = self.mapView.bounds.size.width / self.mapView.visibleMapRect.size.width; 4 NSArray *annotations = [self.coordinateQuadTree clusteredAnnotationsWithinMapRect:mapView.visibleMapRect withZoomScale:zoomScale]; 5 6 [self updateMapViewAnnotationsWithAnnotations:annotations]; 7 }]; 8 }
只添加必要的標注
在主線程中我們期望盡可能花費較少時間來做運算,這意味着我們要盡可能的把所有內容都放到后台的線程中。為了在主線程中花費更少的時間來做計算,我們只需要繪制一些必要的標注。這可以避免用戶滑動過程中感到很卡,從而保證流暢的用戶體驗。
開始之前,我們看一下下面的圖片:
左邊的屏幕截圖是地圖進行滑動前的地圖快照。這個快照中的標注就是目前mapView
中的標注,我們稱這個為"before
集合"。
右邊的屏幕截圖是地圖進行滑動后的地圖快照。這個快照中的標注就是從clusteredAnnotationsWithinMapRect:withZoomScale:
這個函數中得到的返回值。我們稱這個為"after
集合"。
我們期望保留兩個快照中都存在的標注點(即重合的那些標注點),去除在"after集合"中不存在的那些標注點,同時添加那些新的標注點。
1 - (void)updateMapViewAnnotationsWithAnnotations:(NSArray *)annotations { 2 NSMutableSet *before = [NSMutableSet setWithArray:self.mapView.annotations]; 3 NSSet *after = [NSSet setWithArray:annotations]; 4 5 // Annotations circled in blue shared by both sets 6 NSMutableSet *toKeep = [NSMutableSet setWithSet:before]; 7 [toKeep intersectSet:after]; 8 9 // Annotations circled in green 10 NSMutableSet *toAdd = [NSMutableSet setWithSet:after]; 11 [toAdd minusSet:toKeep]; 12 13 // Annotations circled in red 14 NSMutableSet *toRemove = [NSMutableSet setWithSet:before]; 15 [toRemove minusSet:after]; 16 17 // These two methods must be called on the main thread 18 [[NSOperationQueue mainQueue] addOperationWithBlock:^{ 19 [self.mapView addAnnotations:[toAdd allObjects]]; 20 [self.mapView removeAnnotations:[toRemove allObjects]]; 21 }]; 22 }
這樣我們盡可能的確保在主線程上做少量的工作,從而提升地圖滑動的流暢性。
接下來我們來看下如何繪制標注,並且在標注上顯示出來旅館的個數。最后我們給標注加上點擊事件,這樣使得app從頭到腳都可以表現的非常完美。
繪制標注
由於我們在地圖上並沒有完全顯示出全部旅館,所以我們需要在剩余的這些標注上表現出真實的旅館總量。
首先創建一個圓形的標注,中間顯示合並后的個數,也就是旅館的真實總量。這個圓形的大小同樣可以反映出合並后的個數。
為了實現這個需求,我們要找出一個方程式,允許我們在1到500+的數值中生成一個縮小后的數值。用這個數值來作為標注的大小。我們將用到以下的方程式。
x值較低的時候f(x)增長的比較快,x在值變大的時候f(x)增長變緩慢,β值用來控制f(x)趨於1的速度。α值影響最小值(在我們的項目中,我們的最小合並值(也就是1)能占總共最大值的60%)。
1 static CGFloat const TBScaleFactorAlpha = 0.3; 2 static CGFloat const TBScaleFactorBeta = 0.4; 3 4 CGFloat TBScaledValueForValue(CGFloat value) { 5 return 1.0 / (1.0 + expf(-1 * TBScaleFactorAlpha * powf(value, TBScaleFactorBeta))); 6 } 7 8 - (void)setCount:(NSUInteger)count { 9 _count = count; 10 11 // Our max size is (44,44) 12 CGRect newBounds = CGRectMake(0, 0, roundf(44 * TBScaledValueForValue(count)), roundf(44 * TBScaledValueForValue(count))); 13 self.frame = TBCenterRect(newBounds, self.center); 14 15 CGRect newLabelBounds = CGRectMake(0, 0, newBounds.size.width / 1.3, newBounds.size.height / 1.3); 16 self.countLabel.frame = TBCenterRect(newLabelBounds, TBRectCenter(newBounds)); 17 self.countLabel.text = [@(_count) stringValue]; 18 19 [self setNeedsDisplay]; 20 }
現在標注的大小已經OK了。讓我們再來把這個標注做漂亮些。
1 - (void)setupLabel { 2 _countLabel = [[UILabel alloc] initWithFrame:self.frame]; 3 _countLabel.backgroundColor = [UIColor clearColor]; 4 _countLabel.textColor = [UIColor whiteColor]; 5 _countLabel.textAlignment = NSTextAlignmentCenter; 6 _countLabel.shadowColor = [UIColor colorWithWhite:0.0 alpha:0.75]; 7 _countLabel.shadowOffset = CGSizeMake(0, -1); 8 _countLabel.adjustsFontSizeToFitWidth = YES; 9 _countLabel.numberOfLines = 1; 10 _countLabel.font = [UIFont boldSystemFontOfSize:12]; 11 _countLabel.baselineAdjustment = UIBaselineAdjustmentAlignCenters; 12 [self addSubview:_countLabel]; 13 } 14 15 - (void)drawRect:(CGRect)rect { 16 CGContextRef context = UIGraphicsGetCurrentContext(); 17 18 CGContextSetAllowsAntialiasing(context, true); 19 20 UIColor *outerCircleStrokeColor = [UIColor colorWithWhite:0 alpha:0.25]; 21 UIColor *innerCircleStrokeColor = [UIColor whiteColor]; 22 UIColor *innerCircleFillColor = [UIColor colorWithRed:(255.0 / 255.0) green:(95 / 255.0) blue:(42 / 255.0) alpha:1.0]; 23 24 CGRect circleFrame = CGRectInset(rect, 4, 4); 25 26 [outerCircleStrokeColor setStroke]; 27 CGContextSetLineWidth(context, 5.0); 28 CGContextStrokeEllipseInRect(context, circleFrame); 29 30 [innerCircleStrokeColor setStroke]; 31 CGContextSetLineWidth(context, 4); 32 CGContextStrokeEllipseInRect(context, circleFrame); 33 34 [innerCircleFillColor setFill]; 35 CGContextFillEllipseInRect(context, circleFrame); 36 }
添加最后的touch事件
目前的標注可以很好的呈現出我們的數據了,讓我們最后添加一些touch事件來讓我們的app用起來更有趣。
首先,我們需要為新添加到地圖上的標注做一個動畫。如果沒有添加動畫的話,新的標注就會在地圖上突然出現,體驗效果將會大打折扣。
1 - (void)addBounceAnnimationToView:(UIView *)view { 2 CAKeyframeAnimation *bounceAnimation = [CAKeyframeAnimation animationWithKeyPath:@"transform.scale"]; 3 4 bounceAnimation.values = @[@(0.05), @(1.1), @(0.9), @(1)]; 5 6 bounceAnimation.duration = 0.6; 7 NSMutableArray *timingFunctions = [[NSMutableArray alloc] init]; 8 for (NSInteger i = 0; i < 4; i++) { 9 [timingFunctions addObject:[CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]]; 10 } 11 [bounceAnimation setTimingFunctions:timingFunctions.copy]; 12 bounceAnimation.removedOnCompletion = NO; 13 14 [view.layer addAnimation:bounceAnimation forKey:@"bounce"]; 15 } 16 17 - (void)mapView:(MKMapView *)mapView didAddAnnotationViews:(NSArray *)views { 18 for (UIView *view in views) { 19 [self addBounceAnnimationToView:view]; 20 } 21 }
接下來,我們想要根據地圖的縮放比例來改變在合並時的小單元格(cell)的大小。在地圖進行放大時,小單元格變小。所以我們需要定義一下當前地圖的縮放比例。也就是scale=mapView.bounds.size.width/mapView.visibleMapRect.size.width
:
1 NSInteger TBZoomScaleToZoomLevel(MKZoomScale scale) { 2 double totalTilesAtMaxZoom = MKMapSizeWorld.width / 256.0; 3 NSInteger zoomLevelAtMaxZoom = log2(totalTilesAtMaxZoom); 4 NSInteger zoomLevel = MAX(0, zoomLevelAtMaxZoom + floor(log2f(scale) + 0.5)); 5 6 return zoomLevel; 7 }
我們為每個地圖縮放的比例都定義一個常量 zoomLevel
。
1 float TBCellSizeForZoomScale(MKZoomScale zoomScale) { 2 NSInteger zoomLevel = TBZoomScaleToZoomLevel(zoomScale); 3 4 switch (zoomLevel) { 5 case 13: 6 case 14: 7 case 15: 8 return 64; 9 case 16: 10 case 17: 11 case 18: 12 return 32; 13 case 19: 14 return 16; 15 16 default: 17 return 88; 18 } 19 }
現在我們放大地圖,我們將看到逐漸變小的標注,直到最后我們能看到代表每個旅館的那個標注。