在上一篇博客中关于 Vue 表单验证的话题里,我提到了这段时间在做的城市配载功能,这个功能主要着眼于,如何为客户提供一条路线最优、时效最短、装载率最高的路线。事实上,这是目前物流运输行业智能化、专业化的一个趋势,即面向特定行业的局部最优解问题,简单来说,怎么样能在装更多货物的同时满足运费更低的条件,同时要考虑超载等等不可抗性因素,所以,这实际上是一个数学问题。而作为这个功能本身,在地图上加载大量标注更是基础中的基础,所以,今天这篇博客想说说,通过百度地图 API 加载海量标注时,关于性能优化方面的一点点经验。
问题还原
根据 IP 定位至用户所在城市后,后台一次性查询出近一个月内的订单,然后将其全部在地图上展示出来。当用户点击或者框选标注物时,对应的订单配载到当前运单中。当用户再次点击标注物,则对应的订单从当前运单中删除。以西安市为例,一次性加载 850 个左右的订单,用户操作一段时间后,Chrome 内存占用达 250 多兆,拖拽地图的过程中可以明显地感觉到页面卡顿。因为自始至终,地图上的订单数量不变,即不会移除覆盖物,同时需要在内存中持久化订单相关的信息。所以,在城市配载 1.0 版本的时候,测试同事给我提了一个性能方面的 Bug。可开始提方案并坚持这样做的,难道不是产品吗?为什么要给开发提 Bug 呢?OK,我们来给不靠谱的产品一点点填坑吧,大概想到了下面三种方案,分别是标注物聚合 、Canvas API 和视野内可见。
标注物聚合方案
所谓“标注物聚合”,就是指在一定的地图层级上,地图上的覆盖物主要是以聚合的形式显示的,譬如显示某一个省份里共有多少个订单,而不是把所有订单都展示出来,除非地图放大到一定的层级。这种其实在我们产品上是有应用的,比如运单可视化基本上是全国范围内的车辆位置,这个时候在省一级缩放比例上使用聚合展示就非常有必要。可在城市配载这里就相当尴尬啦,因为据说客户会把地图放大到市区街道这种程度来对订单进行配载,所以,这种标注物聚合方案的效果简直是微乎其微,而且更尴尬的问题在于,官方的 MarkerClusterer 插件支持的是标准的覆盖物,即 Marker 类。而我们的产品为了好看、做更复杂的交互,设计了更复杂的标记物原型,这就迫使我们必须使用自定义覆盖物,而自定义覆盖物通常会用 HTML+CSS 来实现。
所以,一个简洁的 Marker 类和复杂的 DOM 结构,会在性能上存在巨大差异,这恰恰是我们加载了 800 多个点就产生性能问题的原因,因为一个“好看”的标注物,居然由 4 个 DOM 节点组成,而这个“好看”的标注物还不知道要怎么样实现 Marker 类里的右键菜单。所以,追求“好看”有问题吗?没有,可华而不实的“好看”,恰恰是性能降低的万恶之源,更不用说,因为覆盖物不会从地图上删除,每次框选都要进行 800 多次的点的检测了,而这些除了开发没有人会在乎,总有人摆出一副“这个需求很简单,怎么实现我不管”的态度……虽然这种方案已经被 Pass 掉了,这里我们还是通过一个简单的示例,来演示下 MarkerClusterer 插件的简单使用吧!以后对于前端类的代码,博主会优先使用 CodePen 进行展示,因为这样子显然比贴代码要生动呀!
See the Pen Marker-Clusterer by qinyuanpei (@qinyuanpei) on CodePen.
这里稍微提带说一下这个插件的优化,经博主测试,在标记物数目达到 100000 的时候,拖拽地图的时候可以明显的感觉的卡顿,这一点大家可以直接在 CodePen 中进行测试。产生性能问题的原因主要在以下代码片段:
/**
* 向该聚合添加一个标记。
* @param {Marker} marker 要添加的标记。
* @return 无返回值。
*/
Cluster.prototype.addMarker = function(marker){
if(this.isMarkerInCluster(marker)){
return false;
}//也可用marker.isInCluster判断,外面判断OK,这里基本不会命中
if (!this._center){
this._center = marker.getPosition();
this.updateGridBounds();//
} else {
if(this._isAverageCenter){
var l = this._markers.length + 1;
var lat = (this._center.lat * (l - 1) + marker.getPosition().lat) / l;
var lng = (this._center.lng * (l - 1) + marker.getPosition().lng) / l;
this._center = new BMap.Point(lng, lat);
this.updateGridBounds();
}//计算新的Center
}
marker.isInCluster = true;
this._markers.push(marker);
var len = this._markers.length;
if(len < this._minClusterSize ){
this._map.addOverlay(marker);
//this.updateClusterMarker();
return true;
} else if (len === this._minClusterSize) {
for (var i = 0; i < len; i++) {
this._markers[i].getMap() && this._map.removeOverlay(this._markers[i]);
}
}
this._map.addOverlay(this._clusterMarker);
this._isReal = true;
this.updateClusterMarker();
return true;
};
这段代码主要的问题在于频繁地向地图添加覆盖物,换言之,在这里产生了对 DOM 的频繁修改,具体可参考 _addToClosestCluster
方法。一种比较好的优化是,等所有计算结束后再一次性应用到 DOM。所以,这里我们可以封装一个 render()
方法:
Cluster.prototype.render = function(){
var len = this._markers.length;
if (len < this._minClusterSize) {
for (var i = 0; i < len; i++) {
this._map.addOverlay(this._markers[i]);
}
} else {
this._map.addOverlay(this._clusterMarker);
this._isReal = true;
this.updateClusterMarker();
}
}
关于原理介绍及性能对比方面的内容,大家可以参考这篇文章:百度地图点聚合 MarkerClusterer 性能优化
Canvas API 方案
OK,接下来介绍第二种方案,其实从 Canvas API 你就可以想到我要说什么了。Canvas API 是 HTML5 中提供的图形绘制接口,类似于我们曾经接触过的 GDI/GDI+、Direct2D、OpenGL 等等。有没有觉得和游戏越来越近啦,哈哈!百度地图 API v3 中提供了基于 Canvas API 的接口,我们可以把这些“好看”的覆盖物绘制到一个层上面去,显然这种方式会比 DOM 更高效,因为博主亲自做了实验,一次性绘制 10 万个点放到地图上,真的是一点都不卡诶,要说缺点的话嘛,嗯,你想嘛,这都是不是 DOM 了,产品经理那些吊炸天的脑洞还怎么搞?比如最基本的点击,可能要用简单的 2D 碰撞来处理啦,然后就是常规的坐标系转换,听起来更像是在做游戏了,对不对?谁让那么多的游戏都是用 HTML5 开发的呢?同样的,这里给出一个简单的示例:
See the Pen Marker-Clusterer by qinyuanpei (@qinyuanpei) on CodePen.
这个方案真正尝试去做的时候,发现最难的地方是给 Canvas 里的元素绑定事件,细心的朋友会发现,博主在这里尝试了两种方案。**第一种,通过判断点是否在矩形内来判断是否完成了点击,主要的问题是随着点的数目的增加判断的量级会越来越大。第二种,通过 addHitRegion()增加一个可点击区域,这种的性能明显要比第一种好,唯一的限制在于浏览器的兼容性。**目前,需要在 Chrome 中开启 Experimental Web Platform features
。这个探索的过程是相当不易的,大家可以通过 CodePen 进一步感受一下哈!
视野内可见方案
相信大家听完前两个方案都相当失望吧,一个方案用不了,一个方案太麻烦,那这个肯定就是最终可行的方案了吧!猜对了,这真的是体现了大道至简,一开始试着从内存里持久化的数据入手,可最终收到效果的反而是这个最不起眼的方案。简单来说,就是把视野内的覆盖物设为 visible,而把视野外的覆盖物设置 hidden。相当朴素的一种思维对吧,百度地图 API 中有一个返回当前视野的接口 GetBounds(),它回返回一个矩形。所以,我们只需要调用百度接口判断覆盖物在不在这个矩形里就可以了,显然,这里又会循环 800 多次,不过产品经理们都不在乎对吧……顺着这个思路,我们可以写出下面的代码,并在拖动地图和缩放地图的时候调用它:
//监听地图缩放/拖拽事件
map.addEventListener("moveend", showOverlaysByView);
map.addEventListener("zoomend", showOverlaysByView);
//根据视野来显示或隐藏覆盖物
function showOverlaysByView() {
var bounds = map.getBounds();
for (var i = 0; i < overlays.length; i++) {
var overlay = overlays[i];
var point = overlay._point;
if (BMapLib.GeoUtils.isPointInRect(point, bounds) || BMapLib.GeoUtils.isPointOnRect(point, bounds)) {
overlay.show();
} else {
overlay.hide();
}
}
}
现在,我只能说,效果挺显著,拖动地图的时候不会卡顿了,因为 visible 和 hidden 的切换会引发浏览器重绘,对于这一切我个人表示满意。当然,这一切离好还很遥远,因为,人类的需要是永无止境的啊。
本文小结
就在我写下这篇博客的时候,产品经理热情洋溢地给我描述了城市配载 2.0 的设想。看了看同类产品的相关设计,我预感这个功能会变成一个以地图为核心的可视化运输系统,这符合国内用户一贯的“大而全”的使用习惯,地图上的交互会更加复杂,需要展示的信息会越来越多,所以,这篇文章里提到的优化,在未来到底有没有用犹未可知。我只能告诉你这样几个原则:尽可能的使用 Marker 类;尽可能的简化 DOM 结构;地图层级变化越大越要考虑使用聚合;视野外的覆盖物该隐藏就隐藏(反正看不到咯……)。一次性加载百万级别数据要求,我从来不觉得合理,因为就算我能加载出来,你能看的过来吗?本身就是伪需求好吧(逃……好了,这就是这篇博客的全部内容啦,以上……