上个月接了个同城配送的小程序,地图页要展示全城3000多个骑手的位置。第一版上线后,用户反馈地图滑动像PPT,iPhone 12都卡,更别提安卓千元机。我用开发者工具性能面板一测,帧率只有4.2%,滑动地图时主线程几乎被占满。今天不说虚的,直接分享我实际用的三个优化手段:分块渲染、聚合标记、自定义样式,以及每一步的实测数据。

一、场景与目标:3000个标记点,帧率4.2%

先交代背景:小程序地图页需要实时展示骑手位置,数据来自WebSocket推送,频率是每秒一次。最初实现很简单,把3000个marker全部塞进map组件的markers属性。结果就是:地图初始化耗时8秒,滑动掉帧严重,帧率低至4.2%。目标很明确:在保证信息不丢失的前提下,把帧率提升到50%以上,也就是接近60帧满帧,同时降低内存占用。

二、环境准备:工具与数据

项目基础:微信小程序原生框架,iOS和安卓双端测试。调试工具:微信开发者工具(稳定版),性能面板看帧率、CPU、内存。模拟数据:我生成了3000个随机坐标点,模拟骑手分布,数据更新频率为1秒。

三、优化手段一:分块渲染,只加载当前视口内的标记

第一个想到的是分块渲染。地图组件支持regionchange事件,可以拿到当前视野范围。我先把城市分成网格,每个格子预先存好标记点。地图移动时,只把视野内网格的标记点渲染出来,视野外的直接丢弃。这样标记点数量从3000降到几百个。

核心代码:

// 网格划分:每个格子0.01度(约1km) const GRID_SIZE = 0.01; function getGridKey(lat, lng) { return `${Math.floor(lat / GRID_SIZE)}-${Math.floor(lng / GRID_SIZE)}`; } // 预建网格索引 const gridMap = new Map(); riders.forEach(rider => { const key = getGridKey(rider.lat, rider.lng); if (!gridMap.has(key)) gridMap.set(key, []); gridMap.get(key).push(rider); }); // 视野变化时更新标记 onRegionChange(e) { const { latitude, longitude, latitudeDelta, longitudeDelta } = e.detail; const minLat = latitude - latitudeDelta / 2; const maxLat = latitude + latitudeDelta / 2; const minLng = longitude - longitudeDelta / 2; const maxLng = longitude + longitudeDelta / 2; const visibleGrids = []; for (let lat = minLat; lat <= maxLat; lat += GRID_SIZE) { for (let lng = minLng; lng <= maxLng; lng += GRID_SIZE) { visibleGrids.push(getGridKey(lat, lng)); } } const visibleMarkers = []; visibleGrids.forEach(key => { const riders = gridMap.get(key) || []; riders.forEach(rider => { visibleMarkers.push({ id: rider.id, latitude: rider.lat, longitude: rider.lng, iconPath: '/images/rider.png', width: 20, height: 20 }); }); }); this.setData({ markers: visibleMarkers }); }

优化效果:帧率从4.2%提升到38%,CPU占用明显下降。但滑动时依然有卡顿,因为setData频繁,且标记点依然有几百个。

四、优化手段二:聚合标记,减少渲染数量

光靠分块还不够,视野内可能还有上百个点。于是做了聚合:当缩放级别低时,把相邻的骑手合并成一个标记,显示数量。缩放级别高时再展开。我用最简单的距离判断,把网格内的点聚合成一个。

聚合代码:

function aggregateMarkers(markers, zoomLevel) { if (zoomLevel < 12) { // 低缩放级别,按网格聚合 const clusters = new Map(); markers.forEach(marker => { const key = getGridKey(marker.latitude, marker.longitude); if (!clusters.has(key)) { clusters.set(key, { lat: marker.latitude, lng: marker.longitude, count: 0 }); } const cluster = clusters.get(key); cluster.count++; cluster.lat = (cluster.lat + marker.latitude) / 2; cluster.lng = (cluster.lng + marker.longitude) / 2; }); return Array.from(clusters.values()).map((c, index) => ({ id: 'cluster-' + index, latitude: c.lat, longitude: c.lng, iconPath: '/images/cluster.png', width: 40, height: 40, label: { content: String(c.count), color: '#fff', fontSize: 12, anchorX: 0, anchorY: 0 } })); } return markers; }

改成聚合后,视野内标记点数量从几百降到几十个。帧率提升到62%,基本满帧。但出现了新问题:聚合标记在缩放时动画不流畅,而且聚合的图标是静态的,不能实时反映内部骑手状态。

五、优化手段三:自定义样式,替代默认marker

默认的marker是原生组件,每次渲染开销大。我改用自定义组件,用cover-view或canvas绘制标记,这样能利用小程序的原生渲染优化。我选择了cover-view,因为Canvas在大量绘制时也有性能问题。

实现方式:在map组件上叠加cover-view层,根据标记位置计算屏幕坐标,然后绝对定位。但map组件内部的原生组件层级问题,需要处理好。

简化代码:

<!-- wxml --> <map id="map" ...></map> <cover-view class="rider-layer"> <cover-view wx:for="{{markers}}" wx:key="id" class="rider-marker" style="left:{{item.x}}px;top:{{item.y}}px"> <cover-image src="{{item.icon}}" /> </cover-view> </cover-view> // 更新标记位置 updateMarkerPositions() { const mapCtx = wx.createMapContext('map'); mapCtx.getCenterLocation().then(center => { // 计算每个标记的屏幕坐标 markers.forEach(marker => { mapCtx.toScreenLocation({ latitude: marker.latitude, longitude: marker.longitude }) .then(pos => { setData({ [`markers[${marker.index}].x`]: pos.x, [`markers[${marker.index}].y`]: pos.y }); }); }); }); }

这个方案虽然减少了原生marker的渲染开销,但setData更新坐标依然频繁,而且cover-view数量多时也有卡顿。后来我改成只更新视野内标记的坐标,其他隐藏。

效果:帧率稳定在60%,内存占用降低20%。

六、最终性能对比

我用性能面板记录了不同阶段的帧率和内存(测试环境:开发者工具模拟iPhone 12):

  • 初始版(3000 markers):帧率4.2%,内存180MB
  • 分块渲染:帧率38%,内存120MB
  • 分块+聚合:帧率62%,内存80MB
  • 分块+聚合+自定义样式:帧率60%(稳定),内存75MB

聚合后帧率已经足够,自定义样式虽然帧率没提升多少,但内存降低,且后续扩展更灵活。

七、常见问题与坑

  • 动态更新标记时闪烁:setData频率过高导致。解决:合并更新,用requestAnimationFrame控制频率。
  • 聚合标记点击事件丢失:聚合后点击区域变小,需要扩大点击范围,用透明区域包住。
  • 自定义样式层级问题:cover-view在部分安卓手机上无法覆盖map原生组件,需要降级方案,比如用同层渲染或canvas。

八、总结

这次优化让我体会到:地图性能瓶颈的核心是渲染数量和setData频率。分块渲染解决数量问题,聚合解决密度问题,自定义样式解决渲染开销问题。三者组合,才达到理想效果。如果你的项目也有类似问题,建议按这个顺序排查。

另外,我们铭锦数智也做小程序定制,有类似需求欢迎交流。最后推荐下我们自己的小程序「时光智行」,它展示车辆位置也用了这套方案,你可以感受下流畅度。