最近由于在工作中涉及到了海量图形渲染的问题,因此我开始研究相关的解决方案。其中我最先尝试的方案就是做数据的视口裁剪。简单来说这就是一种懒加载,只在地图上渲染可见范围内的图形,可以有效的减少渲染图形的数量。
一、矢量数据源的bbox策略
错误的使用方式
我首先查到了矢量数据源有一个strategy
策略属性,在Openlayers中给我们准备了三种预设的策略:all策略
、bbox策略
和tile策略
,如果使用bbox策略
,此时就只会渲染视口范围内的图形。
JavaScript
import { bbox as bboxStrategy } from "ol/loadingstrategy";
hydrody1DLayer = new VectorLayer({
source: new VectorSource({
format: new GeoJSON({
dataProjection: "EPSG:4547",
featureProjection: "EPSG:4326",
}),
url: "src/assets/geojson/BJ.json",
strategy: bboxStrategy, // 使用 bbox 策略
}),
});
window.map.addLayer(hydrody1DLayer);
我通过以上的代码进行了尝试,结果另我大跌眼镜,使用了bbox策略
后好像更卡了。当时我推测可能有两方面的原因 :
- 我的需要渲染的图形十分集中,所以即使只渲染视口范围内的图形,图形的数量依旧非常多。
- 当视图发生变化时(平移或缩放),
bbox策略
都会进行数据的请求、解析与渲染,这导致了比原来更加卡顿。
从源码的角度看bbox策略
bbox策略
居然没有生效,这个真的很奇怪。于是我便想尝试去查看一下源码,看看能否找出bbox策略
失效的原因。
首先来看一下bbox
的源码,可以看到预设的这三个策略(all
、bbox
和tile
)其实都是函数,函数会返回一个extent
数组(extent
就是一个范围,格式为[minX, minY, maxX, maxY]
),像all策略
就返回了一个无限大的范围,而bbox策略
则返回了传入的范围,这个范围应当就当前的视口范围。
再看一下在 矢量数据源(VectorSource
)中是怎样使用strategy
属性的。
在构造函数中会将strategy
属性赋值给strategy_
并且可以看到默认值是all策略
。
之后在一个loadFeatrues
方法中会调用策略函数strategy_
,以得到 需要加载的范围 (extentsToLoad
),另外还获取了一个loadedExtentsRtree
,这个数据其实是矢量数据源中缓存的 已加载的范围。
之后会将 已加载的范围 与 需要加载的范围 进行比较,检查是否有已加载的范围包含需要加载的范围,可以看到这里调用了containsExtent
方法,它其实就是用来进行范围之间包含关系的比较的。
如果策略函数返回的范围没有被加载过,那么就要进行加载了。大致有三步:
- 触发
featureLoadStart
事件 - 调用
loader
函数,虽然不知道这个函数的详细内容,但是推测应该就是用来加载范围内的图形的 - 将当前的这个范围保存到缓存中
通过查看源码,我大致了解了策略究竟是什么,以及策略在矢量数据源中是怎样被使用的。可惜的是我的疑问没有得到解答。
矢量数据源策略的正确用法
后来我才知道,我其实对bbox策略
的理解是错的。下面是官网上的一段代码,这才是策略的正确用法。实际上策略strategy
是用来与loader
函数配合,以实现动态请求远程的图形数据。
JavaScript
import Vector from 'ol/source/Vector.js';
import GeoJSON from 'ol/format/GeoJSON.js';
import {bbox} from 'ol/loadingstrategy.js';
const vectorSource = new Vector({
format: new GeoJSON(),
loader: function(extent, resolution, projection, success, failure) {
const proj = projection.getCode();
const url = 'https://ahocevar.com/geoserver/wfs?service=WFS&' +
'version=1.1.0&request=GetFeature&typename=osm:water_areas&' +
'outputFormat=application/json&srsname=' + proj + '&' +
'bbox=' + extent.join(',') + ',' + proj;
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
const onError = function() {
vectorSource.removeLoadedExtent(extent);
failure();
}
xhr.onerror = onError;
xhr.onload = function() {
if (xhr.status == 200) {
const features = vectorSource.getFormat().readFeatures(xhr.responseText);
vectorSource.addFeatures(features);
success(features);
} else {
onError();
}
}
xhr.send();
},
strategy: bbox,
});
也就是说其实bbox策略
本身并不能够实现渲染视口范围内图形的功能,而是需要借助一个接口来实现,这个接口能够接受一个范围并返回范围内的图形数据。而我之前使用的是一个本地的静态数据,自然无法实现按需加载。
二、基于bbox策略实现本地数据视口裁剪
了解了bbox策略
的正确用法法后就可以利用其实现数据视口裁剪,但是有一个问题我并没有请求远程数据的接口。
我只能利用我手上的本地数据来模拟一个这种接口,于是我准备了一个如下的getFeaturesByExtent
方法,它会接收一个范围,然后利用containsExtent
方法从本地数据riverFeatures
中过滤出视口范围内的图形。
JavaScript
import { containsExtent } from "ol/extent";
// 过滤出在视口内的要素
function getFeaturesByExtent(extent) {
const featuresInView = [];
const { length } = riverFeatures;
let index = length;
while (index--) {
const feature = riverFeatures[index];
const polygonExtent = riverFeaturesExtent[index];
if (containsExtent(extent, polygonExtent)) {
featuresInView.push(feature);
}
}
return featuresInView;
}
之后就仿照之前的案例,利用bbox策略
+loader
的方式创建矢量数据源,就可以实现只渲染视口范围内的图形。
JavaScript
import { Vector as VectorLayer } from "ol/layer";
import { Vector as VectorSource } from "ol/source";
import { GeoJSON } from "ol/format";
import { bbox as bboxStrategy } from "ol/loadingstrategy";
import { containsExtent } from "ol/extent";
// 本地的数据
import BJGrid from "@/assets/geojson/BJ.json";
let riverLayer, riverSource;
let riverFeatures, riverFeaturesExtent, lastExtent;
function addRiver_bbox() {
// 读取GeoJSON数据
riverFeatures = new GeoJSON().readFeatures(BJGrid, {
dataProjection: "EPSG:4547",
featureProjection: "EPSG:4326",
});
riverFeaturesExtent = riverFeatures.map(feature =>
feature.getGeometry().getExtent()
);
riverSource = new VectorSource({
loader: function (extent, resolution, projection, success, failure) {
if (lastExtent && containsExtent(lastExtent, extent)) return;
try {
const featuresInView = getFeaturesByExtent(extent);
riverSource.clear();
riverSource.addFeatures(featuresInView);
lastExtent = extent;
success(featuresInView);
} catch (error) {
riverSource.removeLoadedExtent(extent);
failure();
}
},
strategy: bboxStrategy, // 使用 bbox 策略
});
riverLayer = new VectorLayer({
source: riverSource,
});
window.map.addLayer(riverLayer);
}
在实现的过程中有可能会遇到移动视图之后渲染的图形不会更新的问题,这可能是由于数据缓存导致的,此时视图变化后不会重新调用loader
函数,需要在loader
函数中调用source.clear()
清除缓存。
三、自定义实现数据视口裁剪
我们也可以选择不使用矢量数据源的bbox策略
,通过自定义的方式实现数据视口裁剪。这需要我们解决三个问题:
- 如何获取当前的视口范围?
- 如何知道视口范围是否发生变化?
- 如何知道哪些图形在视口范围内?
使用如下的方法可以解决第一个问题:
JavaScript
const getExtent = map => {
return map.getView().calculateExtent(map.getSize());
};
当缩放或平移地图时会触发地图的moveend
事件,这样第二个问题也解决了。
之前封装的getFeaturesByExtent
方法可以解决第三个问题。
JavaScript
import * as radash from "radash";
import { Vector as VectorLayer } from "ol/layer";
import { Vector as VectorSource } from "ol/source";
import { GeoJSON } from "ol/format";
import { containsExtent } from "ol/extent";
import { getExtent } from "@/utils/view";
import BJGrid from "@/assets/geojson/BJ.json";
let riverLayer, riverSource;
let riverFeatures, riverFeaturesExtent, lastExtent;
// 手动实现视口裁剪
function addRiver_manualBbox() {
// 创建矢量图层
riverSource = new VectorSource({
features: [],
});
riverLayer = new VectorLayer({
source: riverSource,
});
// 将矢量图层添加到地图
window.map.addLayer(riverLayer);
// 读取GeoJSON数据
riverFeatures = new GeoJSON().readFeatures(BJGrid, {
dataProjection: "EPSG:4547",
featureProjection: "EPSG:4326",
});
riverFeaturesExtent = riverFeatures.map(feature =>
feature.getGeometry().getExtent()
);
onMoveEnd();
// 监听地图移动事件(当视口变化时,重新过滤要素)
window.map.on("moveend", radash.debounce({ delay: 1000 }, onMoveEnd)); //使用了防抖,以减少不必要的计算
}
function onMoveEnd() {
// 计算视口范围
const viewExtent = getExtent(window.map);
if (lastExtent && containsExtent(lastExtent, viewExtent)) return;
// 过滤出在视口内的要素
const featuresInView = getFeaturesByExtent(viewExtent);
// 清空原有要素,添加在视口内的要素
riverSource.clear();
riverSource.addFeatures(featuresInView);
lastExtent = viewExtent;
}
这种自定义实现方式有一个好处就是我可以通过防抖来控制图形更新的频率。
四、基于bbox策略加载WFS
我原本已经将这篇文章写完了,但之后我在浏览OpenLayers的示例集时看到了一个"当视口范围发生变化时从GeoServer中加载新的图形"的示例(WFS)。这个示例中展示的正是bbox策略
+ WFS 的组合,GeoServer的WFS服务正好就可以充当一个远程请求图形的数据的接口,它可以支持按照extent返回图形数据。
我尝试仿照示例中的写法又实现了一版的数据视口裁剪:
JavaScript
function addRiver_wfs() {
riverSource = new VectorSource({
format: new GeoJSON(),
url: function (extent) {
return (
"http://localhost:8080/geoserver/wfs?service=WFS&" +
"version=1.1.0&request=GetFeature&typename=BeiJiang:bj&" +
"outputFormat=application/json&srsname=EPSG:4326&" +
"bbox=" +
extent.join(",") +
",EPSG:4326"
);
},
strategy: bboxStrategy,
});
riverLayer = new VectorLayer({
source: riverSource,
});
window.map.addLayer(riverLayer);
}
有一点值得注意的是,在这里我没有使用加载器loader
而是直接使用了一个函数形式的url
。
首先loader
与url
实际上是关系密切的,从官方的介绍中可以看到,如果设置了url
没有设置loader
那么将会基于url
创建并使用一个XHR图形加载器。所以url
可以说是一个简化版的loader
。
不过像实例中的这种直接返回字符串的url函数的用法倒是没有看到,只看到url
可以被设置为一个FeatureUrlFunction
,FeatureUrlFunction
本质上就是一个预先被封装好的加载器模版。
总结
我在尝试使用视口数据裁剪的方案后发现效果很有限,我的地图依旧很卡。我分析主要还是因为我要加载的图形非常密集,即使进行了数据裁剪,数据量依旧很大,可能还是有几万个图形需要渲染。
因此可以得出结论1:
数据视口裁剪不适合图形密集的场景,因为此时即使只加载视口范围内的图形,数量依旧可能很庞大;它会更适合图形离散的场景,例如图形遍布全国,但是我们平时只展示一个省市范围,此时视口中可能就只有几千个几百个图形,在这种情况下对性能的提升就会比较明显。
之后我尝试使用bbox策略
+ WFS 实现数据视口裁剪,会发现这种实现方式会比之前的实现方式性能更好,当然依旧还是会卡顿。我猜测这还是因为我在加载本地数据的时候,要在前端进行图形的检查过滤,这个过程计算量大可能会加剧卡顿,而改用WFS后相当于过滤数据的过程放到了服务端,从而减小了web端的压力。
因此可以得出结论2:
数据视口裁剪的数据源应当尽量选择远程的数据源(例如:WFS或者一个自定义的后台接口),这样做可以减小前端的计算压力,提高渲染性能。