文章最后有效果图
需求
在小程序上绘制 40000+ 的点位。
难点
众所周知小程序的 map 组件性能低下,同时渲染几百个 marker 就会卡顿,一旦加上 callout 弹窗,这个数量可能会降到几十个,如果使用了 自定义弹窗(custom-callout) 会更卡,所以渲染 4w+ 的点,用常规方法是不可能实现的。
方案
按需加载
按需加载即按屏幕坐标加载,只显示视野范围内的点位,需要后端配合在接口中新增 bbox(Bounding box) 参数,再从数据库中查出范围内的点。
小程序端需要使用视野变化监听方法实时更新,虽然请求和渲染频繁,但是在缩放等级较大时,有很高的性能:
ini
<map bindregionchange="regionChanged" markers="{{markers}}">
scss
regionChanged(e){
this.data.bbox = [ [e.detail.region.southwest.longitude, e.detail.region.southwest.latitude],
[e.detail.region.northeast.longitude, e.detail.region.northeast.latitude],
]
// 执行获取点、渲染点的操作
}
需要注意的是,目前的微信版本(8.0.47),基础库3.3.4该方法不可用,见 微信开放社区
如果遇到 bindregionchange 不可用时,可以用 bind:touchend 方法代替,手动获取范围
javascript
setBbox() {
mapCtx = wx.createMapContext('map', this)
mapCtx.getRegion({
success: (res) => {
let bbox = [
[res.southwest.longitude, res.southwest.latitude],
[res.northeast.longitude, res.northeast.latitude],
]
// 执行获取点、渲染点的操作
})
})
}
使用了按需渲染后,在缩放等级较大时,已经可以有很好的效果,移动屏幕时基本可以秒加载出新的点,同时清除掉屏幕范围外的点。
然而,在点位多的时候,我们收到了 setData 长度超出的报错,页面也异常卡顿。
优化渲染方式
小程序的 setData 方法最多只能更新 1M 的数据,超过这个数据会报错,并严重卡顿,即使不超过,在数据量较大时,也会非常卡顿,为了解决这个问题,我们不能再使用 setData 去渲染数据。
小程序提供了专门渲染点的方法: addMarkers
在 // 执行获取点、渲染点的操作
处,使用该方法,并设置 clear: true
。这样就达到了上面说的,更新点时,旧的点会被清除。
然而,这并没有解决根本问题,我们现在可以做到渲染远远大于1M的数据,并渲染时不会报错,但是由于小程序 map 组件的渲染策略,我们的点会一个一个渲染上去,我们知道更新 canvas 代价是很大的,尤其是像 marker 这种携带很多必要信息的东西。
这里我们尝试将 marker 携带的参数压缩到极致,仅保留经纬度、颜色状态信息、id、callout,效果依然差强人意。
并且,由于小程序 marker 的 callout 不是互斥的,且没有给我们预留参数去设置这一点,所以在我们切换 marker 选中状态时,需要把 marker 数组完全遍历一遍,移除其他的 callout , 并添加新的 callout,这个开销也是巨大不可接受的。
优化选中策略
为了解决切换 marker 选中状态时的开销问题,我们想了一个绝妙的主意,就是将 marker 数组中的 callout 完全移除,只保留 id 等必要字段,在点击时,添加一个新的带 callout 点上去,盖住原来的点,这样看起来就是原有的点被选中了,这样既压缩了 marker 携带参数,又解决了切换选中时必须遍历 marker 数组的问题。
less
height: 20,
width: 17,
iconPath: this.data.markerIcons[this.getMarkerType(item)],
latitude: item.point[1],
longitude: item.point[0],
id: this.getUniqueNumber(item.uid), //id 必须是数字
storeCode: item.uid,
//callout:{...} // 不要此项
customCallout: {} //必须加,不然会有一个没有内容的弹窗,这个可以阻止默认弹窗弹出
优化海量点渲染策略
经过上面的优化,我们的小程序已经可以高性能的显示点位了,但是当缩放等级低时(12以下),点位多起来了,我们目前的方法就显得力不从心了。
如果点位无限多,我们又该如何优化呢?
聚合
聚合指的是将临近的点位聚合成一个大点,从而达到渲染点数变少、提高性能的方法。
此方法经过实测,发现当点达到一定量级的时候,用了反而比不用还卡,因为每当你缩放地图时,都需要计算聚合,当计算压力大于渲染压力时,聚合反而成了一种负担,而不是优化了。
所以我们不用聚合。
小程序个性化图层
小程序提供了付费功能:个性化图层,可以上传海量数据并生成一个小程序支持加载的图层。遗憾的是这种方法只适合静态数据,对于经常需要变动的数据,这种方法的实时性得不到保证,只能通过手动在后台更新数据。
所以此方案也不可用。
瓦片
小程序 map 是不支持瓦片(个性化图层除外)加载的,但是我们知道,瓦片就是一张图片而已,那么小程序可以在地图上放图片吗,答案是可以:addGroundOverlay
我们决定朝着此方案努力,请看下文。
搭建 geoserver
首先到 geoserver官网 下载geoserver本体,geoserver是为数不多几个推荐 windows 平台的大型工具软件,下载前注意,geoserver对 jdk 版本有要求,版本不一致会导致 geoserver 启动失败等问题。
我们的服务器是 linux ,所以下载了linux版本,到服务器找个位置 直接 unzip 就可以了。
安装完之后,需要先编辑 start.ini
调整一个合适的空闲端口,作为后面web端管理页面的地址端口。别忘了在防火墙开启此端口。
最后在 bin 中有一个 startup.sh
, 使用 nohup 命令设置后台运行。
此时在浏览器输入服务器地址和你刚刚设置的端口号,最后加上 /geoserver,即可看到geoserver的管理页面。
初始用户名密码:admin geoserver
登录完成后可以看到全部功能
点击数据存储 -> 添加新的数据存储,即可添加数据并发布图层。
可以看到支持 PostGis,使用 PostGis 作为数据源,图层会实时更新,也就是说,当数据变化时,无需任何代码和人工干预。
当数据源添加完成后,需要新建一个图层,并指定为刚刚新建的数据源。
此时,在图层预览页面即可看到刚刚创建的图层了,当然此时的图层使用的是默认样式,需要编写SLD(xml格式)的样式文件去指定样式,这对于我们来说无疑是一种负担。
好在 geoserver 有 css 插件,安装此插件并重启geoserver,即可使用 css 编写图层样式。
css
* {
mark-size:8px;
}
[control_sts == 1] {
mark:url("https://entropy.xxx.cn/xx/dotgreen.png");
}
[control_sts == 0] {
mark:url("https://entropy.xxx.cn/xx/dotgray.png");
}
可以看到,它与标准css还是有一些差异的,像mark、mark-size在标准css中是不存在的。
指定样式后,在图层预览页面,可以看到效果
打开控制台,可以看到网络请求中的地址长这样:
perl
http://xxx:8089/geoserver/cite/wms?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&FORMAT=image%2Fpng&TRANSPARENT=true&STYLES&LAYERS=cite%3Axc_store_geo&exceptions=application%2Fvnd.ogc.se_inimage&SRS=EPSG%3A4326&WIDTH=670&HEIGHT=768&BBOX=114.4720458984375%2C37.7874755859375%2C118.1524658203125%2C42.0062255859375
放到浏览器窗口打开,发现是一张png图片,那么我们刚好可以使用小程序的 addGroundOverlay 添加到地图上。
vbnet
SERVICE: WMS
VERSION: 1.1.1
REQUEST: GetMap
FORMAT: image/png
TRANSPARENT: true
STYLES:
LAYERS: xx:xxxx
exceptions: application/vnd.ogc.se_inimage
SRS: EPSG:4326
WIDTH: 670
HEIGHT: 768
BBOX: 114.4720458984375,37.7874755859375,118.1524658203125,42.0062255859375
看一下这些参数,出了 BBOX ,其他的写固定值就可以了。
这里注意,宽高值,需要设置为小程序中地图元素的大小,单位是 px。
在小程序中拼装WMS地址
比较简单,直接看代码:
typescript
setTileImage(params: { LAYERS: string[], BBOX: string, SCREEN_WIDTH: number, SCREEN_HEIGHT: number, CQL_FILTER: string }) {
mapCtx = wx.createMapContext('map', this)
this.removeTileImage().then(() => {
for (let index in params.LAYERS) {
let id = +(9999 + index)
!this.data.groundOverlayIds.includes(id) && this.data.groundOverlayIds.push(id)
let data: any = {
id: +(9999 + index),
zIndex: 999,
src: `http://xxx:8089/geoserver/cite/wms?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&LAYERS=${params.LAYERS[index]}&STYLES=&exceptions=application/vnd.ogc.se_inimage&FORMAT=image/png&TRANSPARENT=true&FORMAT_OPTIONS=antialias:full&SRS=EPSG:4326&BBOX=${params.BBOX}&WIDTH=${params.SCREEN_WIDTH * 2}&HEIGHT=${params.SCREEN_HEIGHT * 2}&CQL_FILTER=${params.CQL_FILTER}`,
bounds: {
southwest: {
latitude: +params.BBOX.split(',')[1],
longitude: +params.BBOX.split(',')[0]
},
northeast: {
latitude: +params.BBOX.split(',')[3],
longitude: +params.BBOX.split(',')[2]
}
}
}
mapCtx.addGroundOverlay({
...data,
})
}
})
},
我这里封装了一个可以接受多个图层的方法,这里值得注意的是,我没有使用 updateGroundOverlay 方法去更新图层,而是先使用 removeGroundOverlay 移除,再重新添加的,这是因为updateGroundOverlay有一个bug,我不说,你可以自己试试。
完成
至此已经完全实现了小程序的海量点的渲染,无论点有多少,我们都只需要渲染一张图片而已,性能好的一批。