H5端高德地图海量点渲染性能优化实践 > 作者:王宇 ## 一、背景 最近在做一个项目的重构,其中需要重构一个 `H5`
作者:王宇
一、背景
最近在做一个项目的重构,其中需要重构一个 H5
端的地图,该地图使用高德 JS-SDK
实现并进行海量点的渲染,历史的实现存在一些性能问题,在低端机型上会有明显的卡顿现象 ,在重构时,除了基本的功能调整外,性能提升更是一个很重要的关键点,那么如何进行性能提升呢,本文将对过程进行详细分析并最终实现性能优化。
二、历史实现
1、页面解读
首先看一下页面,大致就是下面这个样子【其中网络请求看板为一次地图挪动后不同实体的接口请求情况】
解读:地图中渲染了多种类的实体,之前为了优先保证数据的实时性,在用户滑动或缩放地图时,会根据当前视野范围通过接口动态查询数据,然后本地实时计算,进而决定使用聚合或散列状态展示(这里使用了高德地图的MarkerClusterer
进行聚合散列状态的动态展示,其内部逻辑为:将屏幕分割成一定尺寸的单元格,单元格内数量超过设定值则聚合展示,低于则散列展示),聚合状态由于渲染更少的 dom
节点,性能会比离散状态直接渲染全量的点好,但即便如此,依然存在性能问题;
2、性能分析
页面动效图
Performance
面板 (有大量的丢帧,红色和黄色部分)
整体
虽然页面中使用了聚合态的形式通过渲染更少的 dom
节点,可以提升一部分性能,但实际用户机型使用起来还是会有卡顿的现象:
1、由于地图视野变动是像素级别的回调, 做了500ms
的防抖处理,也即下方 Performance
面板中的红色条状部分【这部分时间是固定的】;
2、紧接着会并行请求各实体的数据,该过程一般在100- 300ms
之间;
3、在分别获取到各实体数据后,MarkerClusterer
会本地计算进而决定是聚合态还是离散态进行展示;从最后一个接口拿到数据到本地计算后最后一帧渲染完毕,大概用了 60ms
左右【这是在电脑上的效果,该部分受机器性能影响较大,在工作人员的低端工作手机上,差异会大很多】
在性能良好的电脑上,一共硬性耗时 500ms + (100ms 至 300ms) + (60ms以上)
= 66ms 至 1s 左右
也即用户拖动或缩放一次地图,大概需要1s
左右才会有界面响应(而实际手机上目测有 2s
左右甚至更多),在这 1s
内用户等待焦虑会触发持续频繁操作,进而连环触发并重叠执行该过程,就会出现较严重的页面卡顿现象;
那么总结下来:其实影响性能的关键有3个点,1、防抖的 500ms
2、数据实时获取 3、实时本地计算;也即下图中的红色部分;最要命的地方在于用户每触发一次地图缩放或平移,都会重复执行该过程;
经过分析后,那么就可以有针对性的进行优化了
3、历史代码
部分 hook
封装在古茗内部 SDK
中,不用太关注实现
import { useInitMap } from '@guming/amap';
import { useState , useRef } from 'react';
export default () => {
const ClusterRef = useRef();
// 地图实例化
const {Map, mapRef, mapInstance, currentZoom, currentBound} = useInitMap(config);
// 地图视野发生变动 防抖获取各种实体类型在视野范围内数据 性能损耗的关键
useDebounceEffect(()=>{
fetchShopDataAndDraw(currentBound);
//... fetchSomeOtherTypeData(currentBound);
},[currentBound]);
const fetchShopDataAndDraw = async (bounds)=> {
const massData = await fetchShopData({bounds});
drawMarkerOrCluster(massData)
};
// 渲染数据到地图中 【离散态、聚合态的频繁切换、计算 】
const drawMarkerOrCluster = (massData = []) => {
if(!ClusterRef.current){
ClusterRef.current = new AMap.MarkerClusterer(mapRef.current, massData, {
gridSize: 150, // 网格像素大小
minClusterSize: 50, // 聚合的最小量
averageCenter: true,
maxZoom: 17,
// 渲染聚合态
renderClusterMarker(context) {
const { marker, count } = context;
marker.setAnchor('center');
marker.setContent(
`<div class="cluster-circle ${type}">${count}</div>`
);
marker.setzIndex(10);
},
// 渲染散列态
renderMarker(context) {
const { marker, data } = context;
const markerData = data[0];
const clueIconWidth = 26;
// 考虑性能 在一定级别只展示icon 到达一定级别后同时展示icon + name
if ((mapRef.current?.getZoom() || currentZoom) >= showNameZoom) {
marker.setContent(createdMarker.getContent());
} else {
marker.setIcon(...);
}
marker.on('click', () => handleClickMarker(markerData, marker));
},
});
ClusterRef.current.on('click', doSomeThing);
}else{
ClusterRef.current.setData(massData)
}
};
return <Map />
}
三、优化实现
1、方案
1.1、数据获取
历史的数据获取是视野范围内的数据,这里则是获取全量数据,数据量上有了增加,所以在数据获取这一块需要做一些调整优化:
- 页面初次进入获取所有实体数据;页面被激活时,对于数量庞大且实时性要求不高的竞品参考数据,则不再获取,使用旧数据;
- 对于数量庞大且实时性要求不高的竞品参考性数据,则静默多分页并行获取,并逐步展示;(主要是优化
handleQueryCrawler
方法,这里不再详细做代码阐述)
const handleQueryClue = () => handleQuery('clue');
const handleQueryCrawler = () => handleQuery('crawler');
const queryAllTypeExcludeCarwlersData = async () => {
// 数据量不大但实时性要求相对较高数据
handleQueryClue();
// ....其他实体数据
};
useDidShow(() => {
queryAllTypeExcludeCarwlersData();
// 数量庞大且实时性要求不高的数据 这里可以调整成多分页并行请求 加快展示呈现
if (!typesDataAndApiConfig.current.crawler.hasLoaded) {
handleQueryCrawler();
}
});
}
1.2、渲染
- 使用可以不需要动态计算的
DistrictCluster
进行聚合态渲染【聚合态下渲染的 dom 节点极少,此时减少计算,就可以达到良好的性能】
// 实例化聚合态 DistrictCluster
clusterRef.current = new DistrictCluster({
map: mapInstance, //所属的地图实例
zIndex: 11,
topAdcodes: [330100],
getPosition: function (item) {
return !item ? null : [item.lng, item.lat];
},
boundsQuerySupport: true,
renderOptions: {
getClusterMarkerPosition:
DistrictCluster.ClusterMarkerPositionStrategy.AVERAGE_POINTS_POSITION,
getClusterMarker: function (feature, dataItems, recycledMarker) {
if (!dataItems.length) return null;
const counts = dataItems.reduce((ret, { dataItem }) => {
if (!ret[dataItem.type]) {
ret[dataItem.type] = 1;
} else {
ret[dataItem.type] += 1;
}
return ret;
}, {});
const content = `${feature.properties.name} :${Object.keys(counts)
.map((type) => `<div>${type}:<span style="color:red">${counts[type]}个</span></div>`)
.join('')}`;
const label = {
offset: new AMap.Pixel(0, 0), //修改label相对于marker的位置
content,
};
//存在可回收利用的marker
if (recycledMarker) {
//直接更新内容返回
recycledMarker.setLabel(label);
return recycledMarker;
}
//返回一个新的Marker
return new AMap.Marker({
// ... 标记点配置
});
},
},
});
// 配置 散列状态的渲染
const { clearAll: clearPoints, renderAll: renderPoints } = useRenderLabelMarker({
mapInstance,
dataKey: 'id',
labelMarkerConfig({ dataItem }) {
return {
// ... 标记点配置
};
},
});
// 配置散列状态文字渲染
const { clearAll: clearText, renderAll: renderText } = useRenderText({
mapInstance,
dataKey: 'id',
labelMarkerConfig({ dataItem }) {
return {
// ... 标记点配置
};
},
});
- 随着视野范围内点位数的不断减少,逐步切换至离散态、逐步渲染名称
// 渲染控制
useDebounceEffect(
() => {
if (!currentBound) return;
const inViewDatas = clusterRef.current?.getDataItemsInView() || [];
clearPoints();
clearText();
// 视野内数量大于500 则聚合展示
if (inViewDatas?.length >= 500) {
clusterRef.current?.show();
} else {
// 否则使用 labelMarker 散列展示
clusterRef.current?.hide();
renderPoints(inViewDatas.map(({ dataItem }) => dataItem));
// 视野内数量少于150 则展示图标名字
if(inViewDatas?.length <= 150){
renderText();
}
}
},
[currentBound,currentZoom],
{ wait: 280 }
);
2、结果
2.1、优化效果
下图为 6267
个数据点,从大视野聚合状态 -> 小视野散列状态 -> 散列状态 -> 散列状态下平移缩放 的页面动效图及 Performance
性能面板
页面动效图
Performance
性能面板(从大聚合态到小聚合态)
红色部分是切换过程中的丢帧 基本在16ms
以内
Performance
性能面板 ( 从小聚合态到散列态)
Performance
性能面板(滑动地图动态绘制不同视野范围内数据)
整体
2.2、前后对比
下面为一次用户完整的操作流程的前后对比
总结就是:在同样的交互流程下,浏览器花了更少的时间,流畅的完整了渲染任务
四、总结
本文的主要优化方向
- 减少数据请求频次【避免每次视图变更都去获取数据】
- 减少海量计算频次【使用可以不需要频繁计算的
DistrictCluster
进行渲染、取消特定场景下的海量计算】 - 减少不必要的信息渲染、已渲染点的批量更新【视野内一定数量下离散展示图标、更少数量下开始展示文字、图标和文字分开渲染】
地图性能优化,主要整体思路是:通过方案调整,在各个层面进行协调,避免将有限的算力消耗在价值不大或非必须的场景中; 达到良好的性能的目的,技术层面并没有高深的地方,但这个分析的过程和解决的思路在对于产品理解、技术实现上有更好的理解和把控,同时以后遇到类似的性能问题时,也会有一些好的参考思路 ~~
转载自:https://juejin.cn/post/7378452954221150218