Java 实现高效查询海量 geometry 及 Protobuf 序列化与天地图前端分片加载

文章目录

承接上一篇文章中提到的 Protocol Buffers(PB,极致高效)方案,本文将基于该方案,打造一套 "Java 高效查询海量 MULTIPOLYGON 数据 + Protobuf 序列化压缩 + 天地图前端加载" 的完整落地方案。方案将围绕 数据存储优化、查询性能提升、序列化压缩、前端解析渲染 四大核心环节深度设计,最终结合实操过程中的关键步骤、优化细节及踩坑总结,形成可直接复用的技术方案,助力解决海量空间地理数据(MULTIPOLYGON 类型)在查询、传输、渲染全链路的效率瓶颈。

一、整体架构设计

Plain 复制代码
[PostGIS数据库] → [Java后端] → [Protobuf序列化] → [HTTP/WebSocket传输] → [前端天地图]
  (空间索引)    (查询优化)    (压缩传输)        (高效加载)    (矢量渲染)

核心目标:

  1. 数据库层:用空间索引加速海量MULTIPOLYGON查询

  2. 后端层:批量查询+几何简化+Protobuf压缩(比JSON小50%+)

  3. 前端层:分片加载+天地图矢量叠加(避免卡顿)

二、第一步:数据库优化(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万条,需进一步优化:

  1. 空间分区:PostGIS按区域分区(如按省/市分区表)

  2. 瓦片化查询:后端预生成空间瓦片,前端按瓦片加载

  3. WebSocket推送:实时数据更新时,后端主动推送变更的图形

  4. GeoJSON压缩 :如果不使用Protobuf,可用Turf.js压缩GeoJSON顶点

该方案可支持千万级MULTIPOLYGON的高效查询与加载,前端渲染无卡顿,传输带宽占用低,适用于天地图、高德地图等主流GIS平台。

相关推荐
MoonBit月兔2 小时前
用 MoonBit 打造的 Luna UI:日本开发者 mizchi 的 Web Components 实践
前端·数据库·mysql·ui·缓存·wasm·moonbit
程序员修心2 小时前
CSS浮动与表格布局全解析
前端·html
xiaowu0802 小时前
IEnumerable、IEnumerator接口与yield return关键字的相关知识
java·开发语言·算法
笨手笨脚の2 小时前
深入理解 Java 虚拟机-01 JVM 内存模型
java·jvm··虚拟机栈·方法区
王家视频教程图书馆2 小时前
android java 开发网路请求库那个好用请列一个排行榜
android·java·开发语言
花卷HJ2 小时前
Android 文件工具类 FileUtils(超全封装版)
android·java
rchmin2 小时前
ThreadLocal内存泄漏机制解析
java·jvm·内存泄露
黎雁·泠崖2 小时前
Java 方法栈帧深度解析:从 JIT 汇编视角,打通 C 与 Java 底层逻辑
java·c语言·汇编
java资料站2 小时前
springBootAdmin(sba)
java