細讀百度地圖點聚合原始碼(上)
之前在專案中需要用到百度地圖的點聚合,看了百度提供的demo之後,稍微讀了一些原始碼就能達到需求了,所以並未深入解讀原始碼。
最近有空就把百度實現點聚合的原始碼從裡到外仔細研究了一遍受益良多,在此分享一下。
為了方便研究我把百度demo中點聚合相關的類抽出來,新建了個工程,有需要可以下載來研究。ClusterDemo
整個原始碼分析過程我分為三個部分:
1.整體結構分析
2.核心演算法分析
3.實現點聚合
本篇為上篇,主要分析1,2部分。之後還會有個下篇,著重分析具體如何實現marker點聚合以及一些動畫處理,這一部分百度處理的非常精妙。
廢話少說,進入正文。
1.整體結構
先上一張思維導圖:
根據上圖我們可以知道,要實現點聚合功能主要分為兩部分:
一個是ClusterItem介面,需要我們提供一個實現類,此類主要用來生成地圖最終顯示的marker,所以包含了經緯度座標,marker的icon圖示,
以及一些其他我們需要儲存在marker中的資訊。
看程式碼,這是demo實現的一個ClusterItem的實現類
比較簡單,就不多說了。/** * 每個Marker點,包含Marker點座標以及圖示 */ public class MyItem implements ClusterItem { private final LatLng mPosition; public MyItem(LatLng latLng) { mPosition = latLng; } @Override public LatLng getPosition() { return mPosition; }//返回marker的座標 @Override public BitmapDescriptor getBitmapDescriptor() {//返回marker的圖示 return BitmapDescriptorFactory .fromResource(R.drawable.icon_gcoding); } }
另一個是ClusterManager,從圖中可以看到它實現了兩個百度地圖的介面,一個是監聽地圖狀態變化(地圖縮放時,執行點聚合),另一個是監聽marker的點選事件。
所以在Demo中給地圖設定監聽介面時,必須設定成我們的ClusterManager. 然後,在ClusterManager中有一個ClusterTask任務執行緒,由cluster()方法啟動這個執行緒。
這個執行緒會在後臺執行核心演算法將所有的ClusterItem根據一個給定的距離範圍進行分組,返回一個cluster集合,這個集合裡面的每個cluster都包含若干個ClusterItem,並且每個cluster都達到了進行聚合操作的要求。
而返回的cluster集合,就交由一個Renderer類進行處理。這個類實現了ClusterRenderer介面,主要負責處理各個marker的顯示以及它們的的動畫。這部分留在
下一篇文章再細說。
另外,ClusterManager中提供了MarkerClickListener介面。
這裡貼一下demo中對BaiduMap的一些初始化設定,跟ClusterManager有關。
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_marker_cluster_demo);
mMapView = (MapView) findViewById(R.id.bmapView);
ms = new MapStatus.Builder().target(new LatLng(39.914935, 116.403119)).zoom(8).build();
mBaiduMap = mMapView.getMap();
mBaiduMap.setOnMapLoadedCallback(this);
mBaiduMap.animateMapStatus(MapStatusUpdateFactory.newMapStatus(ms));
// 定義點聚合管理類ClusterManager
mClusterManager = new ClusterManager<MyItem>(this, mBaiduMap);
// 新增Marker點
addMarkers();
// 設定地圖監聽,當地圖狀態發生改變時,進行點聚合運算
mBaiduMap.setOnMapStatusChangeListener(mClusterManager);
}
這是Demo的Activity的onCreate函式的內容。
我們看到new了一個ClusterManager,並且設定了mBaiduMap的Listener,也就是說ClusterManager能夠監聽到地圖狀態的改變包括縮放,旋轉等等。
值得一提的是addMarkers()函式,這裡也貼一下吧。
/**
* 向地圖新增Marker點
*/
public void addMarkers() {
// 新增Marker點
LatLng llA = new LatLng(39.963175, 116.400244);
LatLng llB = new LatLng(39.942821, 116.369199);
LatLng llC = new LatLng(39.939723, 116.425541);
LatLng llD = new LatLng(39.906965, 116.401394);
LatLng llE = new LatLng(39.956965, 116.331394);
LatLng llF = new LatLng(39.886965, 116.441394);
LatLng llG = new LatLng(39.996965, 116.411394);
List<MyItem> items = new ArrayList<MyItem>();
items.add(new MyItem(llA));
items.add(new MyItem(llB));
items.add(new MyItem(llC));
items.add(new MyItem(llD));
items.add(new MyItem(llE));
items.add(new MyItem(llF));
items.add(new MyItem(llG));
mClusterManager.addItems(items);
其實很簡單,就是建立一些我們的MyItem物件,然後新增到ClusterManager中。但是這個新增過程其實是比較複雜的,就跟核心演算法有關了,下面會分析。
如果不去管核心演算法和Renderer類的細節,ClusterManager類還是很簡單的,這裡就不貼程式碼了。有興趣的可以去看一看原始碼。
2.核心演算法
下面我們來分析百度工程師是如何判斷這些marker該不該聚合的。
首先我們來看上面提到的ClusterManager的addItems()方法
public void addItems(Collection<T> items) {
mAlgorithmLock.writeLock().lock();
try {
mAlgorithm.addItems(items);
} finally {
mAlgorithmLock.writeLock().unlock();
}
}
我們看到它內部做了同步處理,然後這些ClusterItem是新增到mAlgorithm物件中的。
那麼mAlgorithm是什麼東西呢?
根據名字我們也能知道,這個就是演算法類了。
ClusterManager是這樣定義mAlgorithm的:
mAlgorithm = new PreCachingAlgorithmDecorator<T>(new NonHierarchicalDistanceBasedAlgorithm<T>());
這個物件是在ClusterManager的建構函式中初始化的,我們看到其實這裡有兩個類
PreCachingAlgorithmDecorator類和NonHierarchicalDistanceBaseAlgorithm類
前一個是個裝飾類,主要負責管理快取的一些操作,真正的演算法是在NonHierarchicalDistanceBaseAlgorithm中實現的。
ClusterManager的addItems方法就是呼叫的它的addItems方法,那麼我們來看一下里面的addItems方法的真面目
@Override
public void addItem(T item) {
final QuadItem<T> quadItem = new QuadItem<T>(item);
synchronized (mQuadTree) {
mItems.add(quadItem);
mQuadTree.add(quadItem);
}
}
首先,再強調一點,演算法類從頭到尾都是在後臺執行緒中執行的,所以對引用物件的操作都添加了執行緒同步操作。
這裡將我們定義的MyItem物件全部轉換成QuadItem物件,然後儲存到QuadTree樹中。這是一種四叉樹,在空間
查詢中很常用,有興趣的可以從這裡瞭解QuadTree_wiki。文章中就不詳細說明。
那為什麼我們要轉換成QuadItem物件呢?
QuadItem內部儲存著我們的MyItem物件,並且有position(經緯度)和point(點座標)。
到這裡就有必要說一下這個演算法的原理了。
首先,說一個概念——世界寬度(worldWidth)
百度地圖跟谷歌地圖不同,它把整個地球是按照一個平面來展開,而谷歌是按照球面展開的。
把整個地球按照平面來展開之後,我們就能算出這個地球平面的寬度,也就是世界寬度。
計算公式是這樣的:256*(zoom^2) == worldWidth
其中zoom就是地圖的層級,我們縮放地圖也是根據zoom的變化來判斷是放大還是縮小的。
使用過百度地圖的朋友應該知道,zoom越大,地圖就放的越大,根據上面的公式我們可以知道worldWidth也會越大。
這個很好理解吧。
那說了這麼多,這個worldWidth到底有什麼用呢?
有了它,我們就可以把不利於計算的經緯度position轉換成我們熟悉的二維座標point了。兩個point計算距離,這可就簡單多了。
將position轉換成point的方法如下,由
public Point toPoint(final LatLng latLng) {
final double x = latLng.longitude / 360 + .5;
final double siny = Math.sin(Math.toRadians(latLng.latitude));
final double y = 0.5 * Math.log((1 + siny) / (1 - siny)) / -(2 * Math.PI) + .5;
return new Point(x * mWorldWidth, y * mWorldWidth);
}
都是死公式,純數學問題不多說了(丫的,高數那麼久遠的東西誰還記得...)
好,我們把MyItem物件轉換成QuadItem了,也全都儲存到QuadTree裡面了,是不是要開始做正事了?
其實後面的計算就很簡單了,轉換一下問題就成了:一個二維座標中有若干個點,把它們按照一個給定的distance來進行分組。
迭代所有的QuadItem,以此QuadItem為中心畫一個框框,框框裡面的點就算是一個cluster簇,
也就是可以聚合成一個點的QuadItems。框框的大小就是我們定義的可以執行聚合的距離。
當然啦,這話其實並不準確,會有些點同時被好多個框框給框住,那就算距離最小的那個cluster裡面的點。
還是用程式碼來說話吧!我先強調一點,cluster就是若干個item的集合,這些item是被判定可以進行聚合的。
/**
* cluster演算法核心
* @param zoom map的級別
* @return
*/
@Override
public Set<? extends Cluster<T>> getClusters(double zoom) {
final int discreteZoom = (int) zoom;
final double zoomSpecificSpan = MAX_DISTANCE_AT_ZOOM / Math.pow(2, discreteZoom) / 256;//定義的可進行聚合的距離
final Set<QuadItem<T>> visitedCandidates = new HashSet<QuadItem<T>>(); //遍歷QuadItem時儲存被遍歷過的Item
final Set<Cluster<T>> results = new HashSet<Cluster<T>>(); //儲存要返回的cluster簇,每個cluster中包含若干個MyItem物件
final Map<QuadItem<T>, Double> distanceToCluster = new HashMap<QuadItem<T>, Double>(); //Item --> 此Item與所屬的cluster中心點的距離
final Map<QuadItem<T>, mapapi.clusterutil.clustering.algo.StaticCluster<T>> itemToCluster =
new HashMap<QuadItem<T>, mapapi.clusterutil.clustering.algo.StaticCluster<T>>(); //Item物件 --> 此Item所屬的cluster
synchronized (mQuadTree) {
for (QuadItem<T> candidate : mItems) {<span style="white-space:pre"> </span>//遍歷所有的QuadItem
if (visitedCandidates.contains(candidate)) {<span style="white-space:pre"> </span>//如果此Item已經被別的cluster框住了,就不再處理它
continue;
}
Bounds searchBounds = createBoundsFromSpan(candidate.getPoint(), zoomSpecificSpan);//這個就是我們說的,根據給定距離生成一個框框
Collection<QuadItem<T>> clusterItems;
// search 某邊界範圍內的clusterItems
clusterItems = mQuadTree.search(searchBounds); //從quadTree中搜索出框框內的點
if (clusterItems.size() == 1) {
// 如果只有一個點,那麼這一個點就是一個cluster,QuadItem也實現了Cluster介面,也可以當作Cluster物件
results.add(candidate);
visitedCandidates.add(candidate);
distanceToCluster.put(candidate, 0d);
continue;<span style="white-space:pre"> </span>//並且結束此次迴圈
}
mapapi.clusterutil.clustering.algo.StaticCluster<T> cluster =
new mapapi.clusterutil.clustering.algo
.StaticCluster<T>(candidate.mClusterItem.getPosition());//如果搜尋到多個點,那麼就以此item為中心建立一個cluster
results.add(cluster);
for (QuadItem<T> clusterItem : clusterItems) {<span style="white-space:pre"> </span>//遍歷所有框住的點
Double existingDistance = distanceToCluster.get(clusterItem);//獲取此item與原來的cluster中心的距離(如果之前已經被其他cluster給框住了)
double distance = distanceSquared(clusterItem.getPoint(), candidate.getPoint());<span style="white-space:pre"> </span>//獲取此item與現在這個cluster中心的距離
if (existingDistance != null) {
// 判斷那個距離跟小
if (existingDistance < distance) {
continue;
}
//如果跟現在的cluster距離更近,則將此item從原來的cluster中移除
itemToCluster.get(clusterItem).remove(clusterItem.mClusterItem);
}
distanceToCluster.put(clusterItem, distance); //儲存此item到cluster中心的距離
cluster.add(clusterItem.mClusterItem);<span style="white-space:pre"> </span>//將此item新增到cluster中
itemToCluster.put(clusterItem, cluster);<span style="white-space:pre"> </span>//建立item -- cluster 的map
}
visitedCandidates.addAll(clusterItems);//將所有框住過的點新增到已訪問的List中。
}
}
return results;
}
返回一個cluster的集合,演算法類的工作就算完成了。
那麼上篇到此就結束了,下篇中將分析如何在地圖上處理這些item以及實現動畫,
值得一提的是,那裡的絕大部分工作都是在UI執行緒完成的,是不是很神奇!!?