文章目录
-
- 一、整体架构设计
- 二、第一步:数据库优化(PostGIS,核心是空间索引)
-
- [1. 表结构设计(含空间索引)](#1. 表结构设计(含空间索引))
- [2. 高效查询SQL(空间过滤+几何简化)](#2. 高效查询SQL(空间过滤+几何简化))
- 三、第二步:Java后端实现(查询+Protobuf序列化)
-
- [1. 依赖引入(Maven)](#1. 依赖引入(Maven))
- [2. Protobuf协议定义(核心:压缩几何数据)](#2. Protobuf协议定义(核心:压缩几何数据))
- [3. 生成Java Protobuf代码](#3. 生成Java Protobuf代码)
- [4. Java核心代码实现](#4. Java核心代码实现)
- 四、第三步:前端天地图加载(Protobuf解析+矢量渲染)
-
- [1. 前端依赖](#1. 前端依赖)
- [2. 前端核心代码](#2. 前端核心代码)
- 五、性能优化关键技巧
-
- [1. 数据库层](#1. 数据库层)
- [2. 后端层](#2. 后端层)
- [3. 前端层](#3. 前端层)
- 六、扩展方案(海量数据场景)
承接上一篇文章中提到的 Protocol Buffers(PB,极致高效)方案,本文将基于该方案,打造一套 "Java 高效查询海量 MULTIPOLYGON 数据 + Protobuf 序列化压缩 + 天地图前端加载" 的完整落地方案。方案将围绕 数据存储优化、查询性能提升、序列化压缩、前端解析渲染 四大核心环节深度设计,最终结合实操过程中的关键步骤、优化细节及踩坑总结,形成可直接复用的技术方案,助力解决海量空间地理数据(MULTIPOLYGON 类型)在查询、传输、渲染全链路的效率瓶颈。
一、整体架构设计
Plain
[PostGIS数据库] → [Java后端] → [Protobuf序列化] → [HTTP/WebSocket传输] → [前端天地图]
(空间索引) (查询优化) (压缩传输) (高效加载) (矢量渲染)
核心目标:
-
数据库层:用空间索引加速海量MULTIPOLYGON查询
-
后端层:批量查询+几何简化+Protobuf压缩(比JSON小50%+)
-
前端层:分片加载+天地图矢量叠加(避免卡顿)
二、第一步:数据库优化(PostGIS,核心是空间索引)
海量空间数据(MULTIPOLYGON)查询的瓶颈在数据库,必须依赖PostGIS空间索引 和优化查询SQL。
(我的4895条图斑数据的查询使用了8170ms,确实需要优化)
数据样例:

优化前的查询全部结果:

于是乎经过一顿操作最终达到了1048ms。(我的数据与文中的案例有点差别,但是原理时一样的)具体操作如下
首先增加索引:

查询语句增加几何简化:

再次查询全部:

总结下来就是下面几点,继续看吧!
1. 表结构设计(含空间索引)
sql
-- 创建空间表(存储MULTIPOLYGON)
CREATE TABLE spatial_data (
id BIGSERIAL PRIMARY KEY, -- 唯一ID
name VARCHAR(100), -- 名称(如行政区、地块名)
geom MULTIPOLYGON(4326), -- 空间几何(4326=WGS84坐标系,天地图兼容)
attributes JSONB, -- 附加属性(如面积、类型,JSONB高效查询)
create_time TIMESTAMP DEFAULT NOW()
);
-- 创建GIST空间索引(核心!加速空间查询)
CREATE INDEX idx_spatial_geom ON spatial_data USING GIST (geom);
-- 可选:属性+空间联合索引(如果查询带属性过滤)
CREATE INDEX idx_spatial_attr_geom ON spatial_data USING GIST (geom) INCLUDE (name, attributes);
2. 高效查询SQL(空间过滤+几何简化)
-
用
ST_Intersects过滤视野内的图形(避免全表扫描) -
用
ST_SimplifyPreserveTopology简化几何(减少顶点数,降低传输压力) -
批量查询,限制单次返回数量(避免内存溢出)
sql
-- 示例:查询天地图视野范围内(minLng, minLat, maxLng, maxLat)的MULTIPOLYGON
SELECT
id,
name,
ST_AsText(ST_SimplifyPreserveTopology(geom, :tolerance)) AS geom_wkt, -- 几何简化(tolerance=0.001≈100米,按需调整)
attributes
FROM spatial_data
WHERE
ST_Intersects(
geom,
ST_MakeEnvelope(:minLng, :minLat, :maxLng, :maxLat, 4326) -- 视野范围(天地图前端传入)
)
LIMIT :limit OFFSET :offset; -- 分页(单次1000条以内)
关键参数说明:
-
tolerance:几何简化容差(单位:度),值越大顶点越少(如0.001≈100米,适合大范围视图;0.0001≈10米,适合小范围详图) -
视野范围:前端通过天地图
moveend事件获取当前视野的经纬度边界,传给后端
三、第二步:Java后端实现(查询+Protobuf序列化)
核心需求:高效查询PostGIS、几何数据处理、Protobuf压缩序列化。
在这里我在上面sql优化的基础上又一顿改造最终得到了如下结果(pd序列化后的结果耗时:1199 ms 序列化之前的耗时差距不大,性能很强啊) :

具体代码:

还有一些protobut生成的java文件:

具体怎么操作请看下面
1. 依赖引入(Maven)
xml
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
<version>3.25.6</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java-util</artifactId>
<version>3.25.6</version>
</dependency>
2. Protobuf协议定义(核心:压缩几何数据)
创建spatial.proto文件,定义MULTIPOLYGON的序列化格式(用经纬度数组存储,比WKT字符串压缩80%+):
java
syntax = "proto3";
package com.example.spatial;
// 单个点(经纬度)
message PointProto {
double lng = 1; // 经度
double lat = 2; // 纬度
}
// 单个多边形(外环+内环,MULTIPOLYGON的子项)
message PolygonProto {
repeated PointProto outer_ring = 1; // 外环(必填)
repeated PointProto inner_rings = 2; // 内环(可选,孔洞)
}
// 空间数据实体(MULTIPOLYGON+属性)
message SpatialDataProto {
int64 id = 1;
string name = 2;
repeated PolygonProto polygons = 3; // MULTIPOLYGON(多个多边形)
map<string, string> attributes = 4; // 附加属性(键值对)
}
// 批量响应(前端一次加载的数据集)
message SpatialBatchResponse {
repeated SpatialDataProto data_list = 1;
bool has_more = 2; // 是否还有更多数据
int64 total = 3; // 总条数(可选)
}
3. 生成Java Protobuf代码
执行编译命令(需安装Protobuf编译器protoc):
bash
protoc --java_out=src/main/java spatial.proto
生成SpatialProto.java文件,用于序列化/反序列化。
我在这里使用maven的插件:
xml
<build>
<extensions>
<extension>
<groupId>kr.motd.maven</groupId>
<artifactId>os-maven-plugin</artifactId>
<version>1.7.0</version>
</extension>
</extensions>
<plugins>
<plugin>
<groupId>org.xolstice.maven.plugins</groupId>
<artifactId>protobuf-maven-plugin</artifactId>
<version>0.6.1</version>
<configuration>
<protocArtifact>com.google.protobuf:protoc:3.24.4:exe:${os.detected.classifier}</protocArtifact>
<pluginId>grpc-java</pluginId>
<pluginArtifact>io.grpc:protoc-gen-grpc-java:1.58.0:exe:${os.detected.classifier}</pluginArtifact>
<!-- proto 文件目录 -->
<protoSourceRoot>${project.basedir}/src/main/proto</protoSourceRoot>
<!-- 生成代码输出目录 -->
<outputDirectory>src/main/java</outputDirectory>
<clearOutputDirectory>false</clearOutputDirectory>
</configuration>
<executions>
<execution>
<goals>
<goal>compile</goal>
<goal>compile-custom</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
配置好之后 mvn clean compile 就得到对应的代码可以直接引入使用了。
4. Java核心代码实现
(1)几何工具类(WKT转Protobuf)
将PostGIS查询返回的WKT字符串(如MULTIPOLYGON(((116.3,39.9),...)))解析为JTS几何对象,再转成Protobuf格式:
java
import org.locationtech.jts.geom.*;
import org.locationtech.jts.io.WKTReader;
import com.example.spatial.SpatialProto;
public class GeometryConvertUtil {
private static final WKTReader WKT_READER = new WKTReader();
private static final GeometryFactory GEOMETRY_FACTORY = new GeometryFactory();
// WKT字符串 → SpatialDataProto
public static SpatialProto.SpatialDataProto convertToProto(
Long id, String name, String wkt, String attributesJson) {
try {
// 1. 解析WKT为JTS几何对象
Geometry geometry = WKT_READER.read(wkt);
if (!(geometry instanceof MultiPolygon multiPolygon)) {
throw new IllegalArgumentException("非MULTIPOLYGON类型");
}
// 2. 构建Protobuf的Polygons
SpatialProto.SpatialDataProto.Builder dataBuilder = SpatialProto.SpatialDataProto.newBuilder()
.setId(id)
.setName(name);
// 遍历MULTIPOLYGON中的每个Polygon
for (int i = 0; i < multiPolygon.getNumGeometries(); i++) {
Polygon polygon = (Polygon) multiPolygon.getGeometryN(i);
SpatialProto.PolygonProto.Builder polygonBuilder = SpatialProto.PolygonProto.newBuilder();
// 处理外环
Coordinate[] outerCoords = polygon.getExteriorRing().getCoordinates();
for (Coordinate coord : outerCoords) {
polygonBuilder.addOuterRing(SpatialProto.PointProto.newBuilder()
.setLng(coord.x)
.setLat(coord.y)
.build());
}
// 处理内环(孔洞)
for (int j = 0; j < polygon.getNumInteriorRing(); j++) {
Coordinate[] innerCoords = polygon.getInteriorRingN(j).getCoordinates();
for (Coordinate coord : innerCoords) {
polygonBuilder.addInnerRings(SpatialProto.PointProto.newBuilder()
.setLng(coord.x)
.setLat(coord.y)
.build());
}
}
dataBuilder.addPolygons(polygonBuilder.build());
}
// 3. 解析属性(JSON→Map)
if (attributesJson != null) {
com.alibaba.fastjson.JSONObject attrObj = com.alibaba.fastjson.JSON.parseObject(attributesJson);
attrObj.forEach((k, v) -> dataBuilder.putAttributes(k, v.toString()));
}
return dataBuilder.build();
} catch (Exception e) {
throw new RuntimeException("几何转换失败", e);
}
}
}
(2)PostGIS查询服务(批量查询+分页)
在增加分页之前我想看看不分页的情况下前端的表现,于是乎... (不想看我的操作过程直接跳过)
生成proto 编译成js
bash
protoc --js_out=import_style=jspb,binary:./ ./*.proto
生成的两个js文件拷贝至vue3工程的public目录下

编写vue页面
html
<template>
<!-- 地图容器,必须设置宽高 -->
<div id="tianditu-container" style="width: 100vw; height: 100vh;"></div>
</template>
<script setup>
import {onMounted, onUnmounted, ref} from 'vue';
import {getFishnetProtobuf} from "/@/views/system/finsnet/fishnet.api";
import wellknown from 'wellknown';
const tiandituTk = '2cda5caa13d033e23a39xxxxxxxxxb';
// 全局变量定义(地图实例、protobuf根对象)
let mapInstance = ref(null); // 天地图实例
// 1. Promise 方式加载天地图 API(核心封装函数)
const loadTiandituApi = (tk) => {
return new Promise((resolve, reject) => {
if (window.T) {
console.log("天地图 API 已存在,无需重复加载");
resolve();
return;
}
const script = document.createElement('script');
script.src = `http://api.tianditu.gov.cn/api?v=4.0&tk=${tk}`;
script.type = 'text/javascript';
// 超时处理
const timeoutTimer = setTimeout(() => {
reject(new Error("天地图 API 加载超时(10秒)"));
document.head.removeChild(script);
}, 10000);
// 加载成功
script.onload = () => {
clearTimeout(timeoutTimer);
if (window.T) {
console.log("天地图 API 异步加载成功");
resolve();
} else {
reject(new Error("天地图 API 加载异常:未获取全局 T 对象"));
}
};
// 加载失败
script.onerror = (err) => {
clearTimeout(timeoutTimer);
reject(new Error(`天地图 API 加载失败:${err.message}`));
document.head.removeChild(script);
};
document.head.appendChild(script);
});
};
// 2. 先加载 jspb 运行时库,再加载 protobuf 编译文件(解决 jspb is not defined)
const loadProtobufWithJspb = () => {
return new Promise((resolve, reject) => {
// 先判断是否已加载 jspb 和 protobuf 编译文件
if (window.jspb && window.proto && window.proto.org && window.proto.org.jeecg) {
resolve();
return;
}
// 步骤1:加载 Google Protobuf JavaScript 运行时(jspb)- CDN 方式
const jspbScript = document.createElement('script');
// 稳定版 jspb CDN 地址,提供全局 jspb 对象
jspbScript.src = '/google-protobuf.js';
jspbScript.type = 'text/javascript';
// jspb 加载失败
jspbScript.onerror = () => {
reject(new Error("jspb 运行时库加载失败,无法解决 jspb is not defined"));
document.head.removeChild(jspbScript);
};
// jspb 加载成功后,加载两个 protobuf 编译文件
jspbScript.onload = () => {
console.log("jspb 运行时库加载成功,jspb 对象已可用");
// 计数器,确保两个 protobuf 文件都加载完成
let loadedCount = 0;
const totalFiles = 2;
// 检查是否所有文件加载完成
const checkAllLoaded = () => {
loadedCount++;
if (loadedCount === totalFiles) {
// 验证 proto 对象是否正常导出
if (window.proto && window.proto.org && window.proto.org.jeecg) {
resolve();
} else {
reject(new Error("protobuf 编译文件加载成功,但未正确导出 proto 对象"));
}
}
};
// 步骤2:加载 fishnetsingle.js
const singleScript = document.createElement('script');
singleScript.src = '/fishnetsingle.js'; // 替换为你的实际文件路径(public 下直接写文件名)
singleScript.type = 'text/javascript';
singleScript.onload = checkAllLoaded;
singleScript.onerror = () => reject(new Error("fishnetsingle.js 加载失败"));
// 步骤3:加载 fishnetlist.js
const listScript = document.createElement('script');
listScript.src = '/fishnetlist.js'; // 替换为你的实际文件路径
listScript.type = 'text/javascript';
listScript.onload = checkAllLoaded;
listScript.onerror = () => reject(new Error("fishnetlist.js 加载失败"));
// 添加到页面头部
document.head.appendChild(singleScript);
document.head.appendChild(listScript);
};
document.head.appendChild(jspbScript);
});
};
// 3. 请求Protobuf二进制接口并解析
const requestAndParseProtobuf = async () => {
try {
// 3.1 发送请求(关键:responseType设为arraybuffer,获取二进制数据)
const arrayBuffer = await getFishnetProtobuf();
console.info('获取到ArrayBuffer类型数据:', arrayBuffer);
console.info('ArrayBuffer字节长度:', arrayBuffer.byteLength); // 打印长度,判断是否有效
// 3.2 校验ArrayBuffer是否有效(非空)
if (!arrayBuffer || arrayBuffer.byteLength === 0) {
throw new Error("获取到空的Protobuf二进制数据,无法解析");
}
// 3.3 获取二进制数据
const uint8Array = new Uint8Array(arrayBuffer);
// 3.4 校验Protobuf编译文件是否加载完成(避免解析时找不到类)
if (!window.proto || !window.proto.org?.jeecg?.modules?.fishnet?.entity?.proto?.FishnetList) {
throw new Error("Protobuf编译文件(fishnetsingle.js/fishnetlist.js)未加载或加载失败");
}
// 3.5 使用生成的protobuf类解析数据
const FishnetList = window.proto.org.jeecg.modules.fishnet.entity.proto.FishnetList;
const fishnetListMsg = FishnetList.deserializeBinary(uint8Array);
// 3.6 转换为普通JavaScript对象
const fishnetListObj = FishnetList.toObject(true, fishnetListMsg);
console.log("Protobuf解析成功:", fishnetListObj);
return fishnetListObj.fishnetitemsList;
} catch (error) {
console.error("请求或解析Protobuf失败:", error);
// 友好提示,区分场景
const errorMsg = error.message || "Protobuf处理失败,请检查数据或编译文件";
throw new Error(errorMsg);
}
};
// 4. 初始化天地图
const initTianditu = async () => {
try {
// 4.1 创建地图实例
mapInstance.value = new T.Map("tianditu-container");
// 4.2 设置地图中心点和缩放级别(按需调整)
const center = new T.LngLat(111.67817177859233, 40.82267330645965); // 北京坐标,可替换为你的业务区域坐标
mapInstance.value.centerAndZoom(center, 11); // 缩放级别10,按需调整
// 4.3 开启鼠标滚轮缩放
mapInstance.value.enableScrollWheelZoom(true);
// 4.4 添加天地图图层(矢量底图+矢量注记)
// 矢量底图
const vecLayer = new T.TileLayer(
"http://t0.tianditu.gov.cn/vec_w/wmts",
{
layer: "vec",
style: "default",
tileMatrixSet: "w",
format: "tiles",
tk: tiandituTk // 再次传入密钥,与index.html保持一致
}
);
// 矢量注记
const cvaLayer = new T.TileLayer(
"http://t0.tianditu.gov.cn/cva_w/wmts",
{
layer: "cva",
style: "default",
tileMatrixSet: "w",
format: "tiles",
tk: tiandituTk
}
);
// 添加图层到地图
// mapInstance.value.addLayer(vecLayer);
// mapInstance.value.addLayer(cvaLayer);
console.log("天地图初始化成功");
} catch (error) {
console.error("天地图初始化失败:", error);
throw error;
}
};
// 5. 将WKT几何数据加载到天地图(高效兼容版:支持多要素、自动纠错、防报错)
// 声明全局图层组实例(替代FeatureLayer,兼容天地图v4.0)
let fishnetLayerGroup = null;
// 存储多边形实例+属性(用于销毁和交互)
let polygonWithAttrList = [];
// 销毁渔网图层组,释放资源
const destroyFishnetLayerGroup = () => {
// 移除所有多边形实例
polygonWithAttrList.forEach(item => {
if (mapInstance.value && item.polygon) {
mapInstance.value.removeLayer(item.polygon);
}
});
// 清空存储列表
polygonWithAttrList = [];
// 销毁图层组
if (mapInstance.value && fishnetLayerGroup) {
mapInstance.value.removeLayer(fishnetLayerGroup);
fishnetLayerGroup = null;
}
};
const loadWktToTianditu = async (fishnetItems) => {
// 前置校验:防止无效数据和地图未初始化
if (!mapInstance.value || !fishnetItems || !Array.isArray(fishnetItems) || fishnetItems.length === 0) {
console.warn("无效的地图实例或渔网数据,跳过加载");
return;
}
try {
// 1. 先销毁旧图层,避免重复加载(高效复用图层资源)
destroyFishnetLayerGroup();
// 2. 初始化图层组(批量管理要素,比单个添加更高效)
fishnetLayerGroup = new T.LayerGroup();
mapInstance.value.addLayer(fishnetLayerGroup);
// 3. 批量处理fishnetItems(forEach遍历高效,支持大数据量)
fishnetItems.forEach((item, index) => {
// 3.1 单个要素数据校验(防报错:过滤无效项)
if (!item || !item.geom || typeof item.geom !== 'string') {
console.warn(`第${index + 1}条数据无效(缺少geom或geom格式错误),已过滤`, item);
return;
}
let geoJson = null;
try {
// 3.2 使用wellknown解析WKT为GeoJSON(稳定兼容多种要素类型:POLYGON/MULTIPOLYGON等)
geoJson = wellknown(item.geom);
if (!geoJson || !geoJson.type || !geoJson.coordinates) {
throw new Error("WKT解析后无有效GeoJSON坐标");
}
} catch (wktError) {
console.error(`第${index + 1}条数据WKT解析失败,已过滤`, item, wktError);
return;
}
let tGeometry = null;
try {
// 3.3 根据GeoJSON类型自动适配,支持多要素类型
switch (geoJson.type.toUpperCase()) {
case 'POLYGON':
// 多边形坐标排序纠错:确保坐标闭合、顺序正确(防止天地图渲染异常)
const correctedPolygonCoords = correctGeoJsonCoords(geoJson.coordinates);
// 转换为天地图LngLat数组
const lngLatArr = correctedPolygonCoords.map(ring => {
return ring.map(coord => {
// 校验经纬度有效性(防止非法坐标报错)
const lng = Number(coord[0]);
const lat = Number(coord[1]);
if (isNaN(lng) || isNaN(lat) || lng < -180 || lng > 180 || lat < -90 || lat > 90) {
throw new Error(`非法经纬度:${lng}, ${lat}`);
}
return new T.LngLat(lng, lat);
});
});
tGeometry = new T.Polygon(lngLatArr);
break;
case 'MULTIPOLYGON':
// 1. 先对多多边形坐标进行纠错(闭合、过滤无效环)
const correctedMultiPolygonCoords = correctGeoJsonCoords(geoJson.coordinates);
// 2. 严格按三层层级遍历转换,适配标准MULTIPOLYGON格式
// 第一层:多多边形数组(包含多个单个多边形)
const multiLngLatArr = correctedMultiPolygonCoords.map(singlePolygon => {
// 校验单个多边形(第二层:环数组)是否有效
if (!Array.isArray(singlePolygon) || singlePolygon.length === 0) {
return []; // 无效单个多边形,返回空数组后续过滤
}
// 第二层:遍历单个多边形内的所有坐标环(外环+内环)
return singlePolygon.map(coordRing => {
// 校验坐标环(第三层:坐标点数组)是否有效(至少3个点)
if (!Array.isArray(coordRing) || coordRing.length < 3) {
return []; // 无效环,返回空数组后续过滤
}
// 第三层:遍历坐标环内的每个具体坐标点 [lng, lat]
return coordRing.map(coord => {
// 先校验坐标点是否为有效二维数组
if (!Array.isArray(coord) || coord.length < 2) {
throw new Error(`无效的坐标点格式:${JSON.stringify(coord)},需为[lng, lat]二维数组`);
}
// 转换为数字并校验经纬度合法性
const lng = Number(coord[0]);
const lat = Number(coord[1]);
if (isNaN(lng) || isNaN(lat) || lng < -180 || lng > 180 || lat < -90 || lat > 90) {
throw new Error(`非法经纬度:${lng}, ${lat},超出有效范围或非数字`);
}
// 转换为天地图经纬度实例
return new T.LngLat(lng, lat);
});
}).filter(coordRing => coordRing.length >= 3); // 过滤单个多边形内的无效空环
}).filter(singlePolygon => singlePolygon.length > 0); // 过滤多多边形内的无效空多边形
// 3. 最终校验:转换后是否存在有效坐标
if (multiLngLatArr.length === 0) {
throw new Error("MULTIPOLYGON转换后无有效坐标数据,无法渲染");
}
// 4. 创建天地图多多边形实例(天地图T.Polygon原生支持多多边形层级格式)
tGeometry = new T.Polygon(multiLngLatArr);
break;
default:
console.warn(`第${index + 1}条数据不支持的要素类型:${geoJson.type},已过滤`, item);
return;
}
} catch (geoError) {
console.error(`第${index + 1}条数据要素转换失败,已过滤`, item, geoError);
return;
}
// 3.4 设置要素样式(可选,可自定义)
tGeometry.setStyle({
color: "#2f86eb", // 边框颜色
weight: 2, // 边框宽度
opacity: 0.8, // 边框透明度
fillColor: "#2f86eb", // 填充颜色
fillOpacity: 0.3 // 填充透明度
});
// 3.5 批量添加到图层组(比逐个添加到地图更高效,减少DOM操作)
fishnetLayerGroup.addLayer(tGeometry);
// 3.6 存储多边形实例+属性(用于后续销毁、交互等)
polygonWithAttrList.push({
polygon: tGeometry,
attr: item // 存储原始属性(id、xzqmc等)
});
});
// 关键优化:手动计算所有要素的边界范围(替代fishnetLayerGroup.getBounds())
if (polygonWithAttrList.length > 0 && mapInstance.value) {
// 1. 初始化边界变量(取第一个要素的边界作为初始值)
let bounds = null;
for (let i = 0; i < polygonWithAttrList.length; i++) {
const item = polygonWithAttrList[i];
// 校验多边形实例有效性
if (!item || !item.polygon) {
continue;
}
// 2. 获取单个多边形的边界(天地图多边形实例自带getBounds()方法)
const singleBounds = item.polygon.getBounds();
if (!singleBounds) {
continue;
}
// 3. 累加合并所有多边形的边界
if (!bounds) {
// 首次初始化边界
bounds = singleBounds;
} else {
// 扩展边界,包含当前多边形
const newMinLng = Math.min(bounds.getWest(), singleBounds.getWest());
const newMaxLng = Math.max(bounds.getEast(), singleBounds.getEast());
const newMinLat = Math.min(bounds.getSouth(), singleBounds.getSouth());
const newMaxLat = Math.max(bounds.getNorth(), singleBounds.getNorth());
// 重新创建天地图边界实例(T.LngLatBounds)
bounds = new T.LngLatBounds(
new T.LngLat(newMinLng, newMinLat), // 西南角(最小经纬度)
new T.LngLat(newMaxLng, newMaxLat) // 东北角(最大经纬度)
);
}
}
// 4. 若获取到有效边界,适配地图视图
if (bounds) {
mapInstance.value.fitBounds(bounds, {
padding: [50, 50, 50, 50] // 预留边距,避免要素贴边
});
}
}
console.log(`成功加载${polygonWithAttrList.length}个渔网要素到天地图`, polygonWithAttrList);
} catch (error) {
console.error("加载WKT数据到天地图失败", error);
// 异常时销毁图层,防止残留
// destroyFishnetLayerGroup();
}
};
// 辅助函数:GeoJSON坐标排序纠错(自动闭合、规范顺序,防止渲染报错)
const correctGeoJsonCoords = (coords) => {
if (!Array.isArray(coords)) {
return [];
}
// 递归处理坐标(兼容Polygon和MultiPolygon)
const processRing = (ring) => {
if (!Array.isArray(ring) || ring.length < 3) {
return []; // 无效环:至少3个点
}
// 检查坐标是否闭合(第一个点和最后一个点是否一致)
const firstPoint = ring[0];
const lastPoint = ring[ring.length - 1];
const isClosed = firstPoint && lastPoint &&
Number(firstPoint[0]) === Number(lastPoint[0]) &&
Number(firstPoint[1]) === Number(lastPoint[1]);
// 未闭合则补充最后一个点,闭合坐标
if (!isClosed) {
ring.push([...firstPoint]);
}
// 简单顺序纠错:确保多边形顶点顺序符合天地图渲染要求(逆时针外环,顺时针内环)
// 计算多边形面积,判断顺序是否正确
const getRingArea = (r) => {
let area = 0;
for (let i = 0; i < r.length - 1; i++) {
const x1 = r[i][0], y1 = r[i][1];
const x2 = r[i + 1][0], y2 = r[i + 1][1];
area += (x1 * y2) - (x2 * y1);
}
return Math.abs(area / 2);
};
const ringArea = getRingArea(ring);
// 若面积异常小,视为无效环,过滤掉
if (ringArea < 0.000001) {
return [];
}
return ring;
};
const processCoords = (c) => {
return c.map(item => {
if (Array.isArray(item[0]) && Array.isArray(item[0][0])) {
// MultiPolygon 层级:[[[lng,lat],...], ...]
return processCoords(item);
} else if (Array.isArray(item[0])) {
// Polygon 层级:[[lng,lat], ...]
const processedRing = processRing(item);
return processedRing.length > 0 ? processedRing : [];
} else {
// 无效坐标层级
return [];
}
}).filter(ring => ring.length > 0); // 过滤无效环
};
return processCoords(coords);
};
// 6. 主流程:按顺序执行所有操作
const mainProcess = async () => {
try {
// 优先加载天地图 API(Promise 方式,等待加载完成)
await loadTiandituApi(tiandituTk);
await initTianditu(); // 步骤2:初始化天地图
// 步骤2:加载 Google Closure Library + protobuf 编译文件(解决 goog is not defined)
// 2. 先加载 jspb 运行时库,再加载 protobuf 编译文件(解决 jspb is not defined)
await loadProtobufWithJspb();
const fishnetItems = await requestAndParseProtobuf();
await loadWktToTianditu(fishnetItems);
} catch (error) {
console.error("主流程执行失败:", error);
}
};
// 组件挂载后初始化地图
onMounted(() => {
mainProcess();
});
// 组件销毁前销毁地图实例,释放资源
// onUnmounted(() => {
// if (mapInstance.value) {
// mapInstance.value.destroy();
// mapInstance.value = null;
// }
// // 销毁渔网要素图层
// destroyFishnetLayerGroup();
// });
</script>
查询接口的方法
javascript
import {defHttp} from '/@/utils/http/axios';
enum Api {
getByXzqdm = '/fishnet/byXzqdm',
}
/**
* 列表接口(获取Protobuf二进制数据)
* @param params 请求参数(设置默认值,支持无参数调用)
*/
export const getFishnetProtobuf = async (params = {}) => { // 关键:添加params默认值{}
return defHttp.get({
url: Api.getByXzqdm,
params: params,
headers: {
'Accept': 'application/octet-stream', // 指定接收二进制数据
},
responseType: 'arraybuffer', // 关键:axios返回arraybuffer类型
},
{isTransformResponse: false} // 关键:关闭JeecgBoot默认响应转换,保留原始二进制数据
);
};
最终实测效果达标:放大、缩小与拖拽等核心交互操作执行流畅,无明显卡顿延迟,性能优化效果显著。
最终呈现的交互效果优异,拖拽缩放操作全程流畅无卡顿,相比优化前,响应速度提升效果明显。


当前该功能已达成基本可用状态。考虑到当前数据量较小,我们省去了拖拽放大缩小操作时的接口重复调用流程,经实测验证,数据查询到页面渲染完成的整体耗时,已从原先的 9 秒左右优化至 2 秒左右,优化效果超出预期。后续将针对大数据量场景,进一步实现分页处理、WebSocket 数据推送与页面加载渲染的全流程落地。
懒... 不想弄了
java
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import com.example.spatial.SpatialProto;
import javax.annotation.Resource;
import java.util.List;
import java.util.Map;
@Service
public class SpatialQueryService {
@Resource
private JdbcTemplate jdbcTemplate;
// 批量查询视野范围内的MULTIPOLYGON
public SpatialProto.SpatialBatchResponse querySpatialData(
double minLng, double minLat, double maxLng, double maxLat,
double tolerance, int limit, int offset) {
// 1. SQL查询(空间过滤+几何简化)
String sql = "SELECT id, name, ST_AsText(ST_SimplifyPreserveTopology(geom, ?)) AS geom_wkt, attributes " +
"FROM spatial_data " +
"WHERE ST_Intersects(geom, ST_MakeEnvelope(?, ?, ?, ?, 4326)) " +
"LIMIT ? OFFSET ?";
List<Map<String, Object>> resultList = jdbcTemplate.queryForList(
sql, tolerance, minLng, minLat, maxLng, maxLat, limit, offset);
// 2. 转换为Protobuf对象
SpatialProto.SpatialBatchResponse.Builder batchBuilder = SpatialProto.SpatialBatchResponse.newBuilder();
for (Map<String, Object> map : resultList) {
Long id = ((Number) map.get("id")).longValue();
String name = (String) map.get("name");
String geomWkt = (String) map.get("geom_wkt");
String attributesJson = map.get("attributes") != null ? map.get("attributes").toString() : null;
SpatialProto.SpatialDataProto dataProto = GeometryConvertUtil.convertToProto(id, name, geomWkt, attributesJson);
batchBuilder.addDataList(dataProto);
}
// 3. 判断是否还有更多数据(简化:通过是否满limit判断)
boolean hasMore = resultList.size() >= limit;
// 4. 统计总条数(可选,大数据量建议异步统计)
Long total = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM spatial_data WHERE ST_Intersects(geom, ST_MakeEnvelope(?, ?, ?, ?, 4326))",
Long.class, minLng, minLat, maxLng, maxLat);
return batchBuilder.setHasMore(hasMore).setTotal(total).build();
}
}
(3)Web接口(Protobuf二进制传输)
java
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.example.spatial.SpatialProto;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletResponse;
import java.io.OutputStream;
@RestController
public class SpatialController {
@Resource
private SpatialQueryService spatialQueryService;
// 接口:返回Protobuf二进制数据(Content-Type: application/x-protobuf)
@GetMapping(value = "/api/spatial/data", produces = "application/x-protobuf")
public void getSpatialData(
@RequestParam double minLng,
@RequestParam double minLat,
@RequestParam double maxLng,
@RequestParam double maxLat,
@RequestParam(defaultValue = "0.001") double tolerance,
@RequestParam(defaultValue = "1000") int limit,
@RequestParam(defaultValue = "0") int offset,
HttpServletResponse response) throws Exception {
// 1. 查询并生成Protobuf响应
SpatialProto.SpatialBatchResponse batchResponse = spatialQueryService.querySpatialData(
minLng, minLat, maxLng, maxLat, tolerance, limit, offset);
// 2. 二进制写入响应(Protobuf高效序列化)
response.setContentType("application/x-protobuf");
response.setContentLength(batchResponse.getSerializedSize());
try (OutputStream os = response.getOutputStream()) {
batchResponse.writeTo(os);
os.flush();
}
}
}
四、第三步:前端天地图加载(Protobuf解析+矢量渲染)
核心需求:解析Protobuf二进制数据,在天地图上叠加MULTIPOLYGON矢量图形,支持分片加载。
1. 前端依赖
-
天地图JS API:
http://api.tianditu.gov.cn/api?v=4.0&tk=你的天地图密钥 -
Protobuf JS:
https://cdn.jsdelivr.net/npm/protobufjs@7.2.5/dist/protobuf.min.js
2. 前端核心代码
html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>天地图加载海量MULTIPOLYGON</title>
<!-- 天地图API -->
<script src="http://api.tianditu.gov.cn/api?v=4.0&tk=你的天地图密钥"></script>
<!-- Protobuf JS -->
<script src="https://cdn.jsdelivr.net/npm/protobufjs@7.2.5/dist/protobuf.min.js"></script>
<style>
#mapContainer { width: 100vw; height: 100vh; }
</style>
</head>
<body>
<div id="mapContainer"></div>
<script>
// 1. 加载Protobuf协议(spatial.proto的文本内容,需转义)
const protoStr = `
syntax = "proto3";
package com.example.spatial;
message PointProto { double lng = 1; double lat = 2; }
message PolygonProto { repeated PointProto outer_ring = 1; repeated PointProto inner_rings = 2; }
message SpatialDataProto { int64 id = 1; string name = 2; repeated PolygonProto polygons = 3; map<string, string> attributes = 4; }
message SpatialBatchResponse { repeated SpatialDataProto data_list = 1; bool has_more = 2; int64 total = 3; }
`;
// 2. 初始化天地图
let map = null;
let vectorLayer = null; // 矢量图形图层
let isLoading = false; // 防止重复加载
function initMap() {
// 创建地图(中心点:北京,缩放级别10)
map = new T.Map("mapContainer");
map.centerAndZoom(new T.LngLat(116.404, 39.915), 10);
map.addControl(new T.Control.MapType()); // 地图类型控件
map.enableScrollWheelZoom(); // 滚轮缩放
// 创建矢量图层(叠加在天地图上)
vectorLayer = new T.VectorLayer();
map.addLayer(vectorLayer);
// 地图视野变化时加载数据(moveend事件)
map.addEventListener("moveend", loadSpatialData);
// 初始加载一次
loadSpatialData();
}
// 3. 加载后端Protobuf数据并渲染
async function loadSpatialData() {
if (isLoading) return;
isLoading = true;
// 获取当前地图视野边界
const bounds = map.getBounds(); // {minLng, minLat, maxLng, maxLat}
const minLng = bounds.minLng.toFixed(6);
const minLat = bounds.minLat.toFixed(6);
const maxLng = bounds.maxLng.toFixed(6);
const maxLat = bounds.maxLat.toFixed(6);
// 根据缩放级别调整几何简化容差(缩放越大,容差越小,图形越精细)
const zoom = map.getZoom();
const tolerance = zoom >= 14 ? 0.0001 : (zoom >= 10 ? 0.001 : 0.01);
try {
// 4. 请求后端Protobuf数据(二进制格式)
const response = await fetch(`/api/spatial/data?minLng=${minLng}&minLat=${minLat}&maxLng=${maxLng}&maxLat=${maxLat}&tolerance=${tolerance}&limit=1000&offset=0`, {
method: "GET",
headers: { "Accept": "application/x-protobuf" }
});
if (!response.ok) throw new Error("请求失败");
// 5. 解析Protobuf二进制数据
const protobufRoot = await protobuf.parse(protoStr).root;
const SpatialBatchResponse = protobufRoot.lookupType("com.example.spatial.SpatialBatchResponse");
const buffer = await response.arrayBuffer();
const batchData = SpatialBatchResponse.decode(new Uint8Array(buffer));
// 6. 清空图层并渲染新图形
vectorLayer.clearLayers();
renderPolygons(batchData.dataList);
console.log(`加载完成:${batchData.dataList.length}条数据,总条数:${batchData.total}`);
} catch (e) {
console.error("加载失败:", e);
} finally {
isLoading = false;
}
}
// 7. 渲染MULTIPOLYGON到天地图
function renderPolygons(dataList) {
dataList.forEach(data => {
// 遍历MULTIPOLYGON中的每个Polygon
data.polygons.forEach(polygonProto => {
// 转换外环为天地图LngLat数组
const outerRing = polygonProto.outerRing.map(point =>
new T.LngLat(point.lng, point.lat)
);
// 转换内环(孔洞)为天地图LngLat数组
const innerRings = polygonProto.innerRings.length > 0
? [polygonProto.innerRings.map(point => new T.LngLat(point.lng, point.lat))]
: [];
// 创建天地图多边形(蓝色边框,透明填充)
const polygon = new T.Polygon(
[outerRing, ...innerRings], // 外环+内环
{
color: "#1E90FF", // 边框颜色
weight: 2, // 边框宽度
opacity: 0.8, // 边框透明度
fillColor: "#1E90FF", // 填充颜色
fillOpacity: 0.2 // 填充透明度
}
);
// 添加到矢量图层
vectorLayer.addOverlay(polygon);
// 可选:添加点击事件(显示属性)
polygon.addEventListener("click", () => {
alert(`名称:${data.name}\nID:${data.id}\n属性:${JSON.stringify(data.attributes)}`);
});
});
});
}
// 初始化地图
window.onload = initMap;
</script>
</body>
</html>
五、性能优化关键技巧
1. 数据库层
-
必建
GIST空间索引,避免全表扫描 -
用
ST_SimplifyPreserveTopology简化几何(减少数据量) -
分页查询(单次返回≤1000条,避免内存溢出)
-
避免
SELECT *,只查询必要字段(id、name、geom、attributes)
2. 后端层
-
Protobuf序列化(比JSON小50%-80%,传输更快)
-
几何简化动态调整(根据前端缩放级别传不同
tolerance) -
连接池优化(PostgreSQL连接池最大连接数=CPU核心数×2+1)
-
缓存热点数据(如常用区域的图形,用Redis缓存Protobuf二进制)
3. 前端层
-
分片加载(视野变化时只加载当前视野数据,不加载全量)
-
矢量渲染(天地图
T.VectorLayer比图片叠加更高效) -
防抖处理(
moveend事件添加防抖,避免频繁请求) -
按需加载(缩放级别低时加载简化图形,缩放级别高时加载精细图形)
六、扩展方案(海量数据场景)
如果数据量超1000万条,需进一步优化:
-
空间分区:PostGIS按区域分区(如按省/市分区表)
-
瓦片化查询:后端预生成空间瓦片,前端按瓦片加载
-
WebSocket推送:实时数据更新时,后端主动推送变更的图形
-
GeoJSON压缩 :如果不使用Protobuf,可用
Turf.js压缩GeoJSON顶点
该方案可支持千万级MULTIPOLYGON的高效查询与加载,前端渲染无卡顿,传输带宽占用低,适用于天地图、高德地图等主流GIS平台。