ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用 Java 生成 MVT 矢量瓦片:从坐标换算到 JTS 编码实践

用 Java 生成 MVT 矢量瓦片:从坐标换算到 JTS 编码实践 简介面向需要搭建矢量地图服务的 Java 后端与 Web 前端开发者这份资源围绕“Java 生成 MVT 切片 Mapbox GL JS 前端调用”的完整链路展开。内容涵盖 GeoJSON 与 MVT 转换原理、坐标系统与几何对象处理、按缩放级别分块生成切片的服务接口设计实践中可借助 GeoTools 或 mapbox-vector-tile 库完成数据读取、坐标转换、几何简化与分块编码进而封装为按 z/x/y 请求的 HTTP 接口最终可供前端按需加载。资源包共 13 个文件大小约 2.1MB以 7 个 js、3 个 html 为主另有 2 个 css 与 1 个 bak 备份文件js 涵盖 Mapbox GL、绘图插件、Turf 空间计算等前端依赖html 则是切片加载与图层渲染的演示页面bak 文件可用于对照修改前后的差异整体结构清晰便于调试学习。目前已有 297 人学习下载适合对 MVT 切片原理有基础认知、希望通过实际示例理清切片加载与图层渲染完整流程的地图应用开发者。1. 用 Java 生成 MVT 切片不是切图是给前端一套带坐标的矢量数据后端要吐矢量瓦片在 Java 服务里其实就是三件事算瓦片范围、按范围裁几何、编码成 protobuf。用 Java 生成 MVT 切片难的不是编码本身而是坐标换算、裁剪边界和简化容差这些细节。MVT 不是一张图片它是一包带坐标和属性的矢量数据交给 Mapbox GL JS 或 Cesium 在前端自己画。适合谁后端要接矢量瓦片但不想引入外部切图工具、希望在自己服务里按需生成瓦片的团队以及被「前端要 MVT 我怎么给」问住的 Java 开发者。2. 切片前的数学准备Web Mercator 与 z/x/y 瓦片范围换算2.1 为什么必须是 Web MercatorMVT 的坐标系约束MVT 规范里所有瓦片坐标都建立在 Web MercatorEPSG:3857上这是一个绕不开的约束。Google 地图、Mapbox、Cesium 的切片体系全部按这套规则来把地球近似成球体赤道周长固定为 40075016.68557849 米经度范围 -180 到 180 映射到 x 轴 -20037508.342789244 到 20037508.342789244 米。纬度方向同样映射到 y 轴但因为 Web Mercator 把高纬度拉伸得厉害y 轴并不是严格的纬度等距。很多第一次写切片的人在这里翻车拿着 WGS84 经纬度直接算瓦片坐标出来的结果是要素整体偏移、形变。正确做法是数据入库时就统一成 3857或者在查询时用 PostGIS 现转。我在实际项目里更倾向于前者——入库转一次切片时少一个环节速度也快。如果你的 Shapefile 是 4326用 GeoTools 读出来后先做一次 reproject再进切片逻辑。搞清楚投影后还要理解一个关键概念瓦片坐标是离散的z 是缩放级别x/y 是列和行每放大一级瓦片数量翻 4 倍。z0 只有 1 片z1 有 4 片z15 有 2^30 片。后端按请求的 z/x/y 反算出这片瓦片覆盖的地理范围从库里把落在范围内的要素捞出来再编码成 MVT 返回。2.2 z/x/y 到瓦片范围一套公式通吃 256 与 4096瓦片范围的计算不复杂但单位一定要统一。下面的方法输入 z/x/y输出 Web Mercator 米制范围public class TileMath { private static final double ORIGIN_SHIFT 20037508.342789244; private static final double TILE_SIZE 40075016.68557849; public static double[] tileToMeters(int z, int x, int y) { double tileSize TILE_SIZE / Math.pow(2, z); double minX x * tileSize - ORIGIN_SHIFT; double maxX (x 1) * tileSize - ORIGIN_SHIFT; double minY y * tileSize - ORIGIN_SHIFT; double maxY (y 1) * tileSize - ORIGIN_SHIFT; return new double[]{minX, minY, maxX, maxY}; } }这里TILE_SIZE / Math.pow(2, z)是当前 zoom 下每片瓦片在墨卡托米制下的边长z 越大边长越小。ORIGIN_SHIFT是 x/y 轴的偏移量因为瓦片原点在左上角而墨卡托坐标原点在中心所以要各减一半。这个方法返回的四个值直接可以拼成 PostGIS 的ST_MakeEnvelope也可以喂给 JTS 的Envelope做几何裁剪。拿到米制范围后几何数据还要换成瓦片像素坐标。MVT 的 extent 通常取 4096意思是每片瓦片内部有 4096×4096 个坐标单位。像素换算的核心是平移加缩放y 轴方向还要翻转因为瓦片像素原点在左上角而墨卡托 y 轴是向上增长的public static Geometry toPixel(Geometry geom, double[] range, int extent) { double minX range[0], minY range[1]; double maxX range[2], maxY range[3]; double scale extent / (maxX - minX); AffineTransformation tx new AffineTransformation(); tx.translate(-minX, -maxY); tx.scale(scale, -scale); return tx.transform(geom); }这段变换先平移到原点再缩放和翻转。注意translate第二个参数用-maxY配合scale里 y 轴的负号正好把墨卡托的向上增长翻成像素的向下增长。extent 传 4096 时一个像素单位对应 4096 分之一片瓦片如果前端要用 256 的 extent把参数改成 256 即可但建议统一用 4096精度高库也默认支持。3. 核心切片实现JTS 裁剪 VectorTileEncoder 编码拼出一个完整 MVT3.1 工程依赖三个库就够Java 生成 MVT 的常见组合是 JTS 做几何处理、mapbox-vector-tile-java 做编码、jts2geojson 做格式桥接。mapbox-vector-tile-java 的编码器接收的是 GeoJSON 结构所以 JTS 几何要先转成 GeoJSON 再进 Feature。Maven 依赖如下版本号以你仓库实际能解析到的为准dependency groupIdorg.wololo/groupId artifactIdmapbox-vector-tile/artifactId version2.0.0/version /dependency dependency groupIdorg.wololo/groupId artifactIdjts2geojson/artifactId version1.0.0/version /dependency dependency groupIdorg.locationtech.jts/groupId artifactIdjts-core/artifactId version1.19.0/version /dependency需要说明的是mapbox-vector-tile 2.x 的VectorTileEncoder构造参数是(extent, buffer, autoScale)。extent 用 4096buffer 是瓦片边界外扩的像素数默认 64autoScale 一般传 true让编码器自动处理超出 extent 的坐标。数据源方面如果你要读 Shapefile加上 GeoTools 的 gt-shapefile 依赖如果直接连 PostGIS用 JDBC 就够不需要额外库。3.2 按瓦片范围从 PostGIS 取数并做几何裁剪数据源是 PostGIS 时先按瓦片范围过滤一遍再做精确裁剪。过滤用操作符判断 bbox 相交这一步能走 GIST 索引快精确相交用ST_Intersects确保边缘要素不被漏掉。假设表结构是roads(gid, name, type, geom)geom 为 4326String sql SELECT gid, name, type, geom FROM roads WHERE geom ST_Transform(ST_MakeEnvelope(?, ?, ?, ?, 3857), 4326) AND ST_Intersects(geom, ST_Transform(ST_MakeEnvelope(?, ?, ?, ?, 3857), 4326)); try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setDouble(1, minX); ps.setDouble(2, minY); ps.setDouble(3, maxX); ps.setDouble(4, maxY); // 参数重复一轮 ps.setDouble(5, minX); ps.setDouble(6, minY); ps.setDouble(7, maxX); ps.setDouble(8, maxY); ResultSet rs ps.executeQuery(); }注意ST_MakeEnvelope后面带的 3857 是输入坐标的 SRIDST_Transform(..., 4326)把它转成和表一致的坐标系。如果你的表本来就是 3857可以直接ST_MakeEnvelope(?, ?, ?, ?, 3857)并跳过ST_Transform省一次转换开销。这套查询返回的是原始范围中的完整几何还没按瓦片边界裁需要到 Java 侧处理。裁剪用 JTS 的intersection方法把瓦片范围构造成一个矩形几何和要素求交。这里有个性能细节不要对整张表的所有要素逐条求交先按 bbox 过滤后再求交否则低 zoom 下会慢到不可用。GeometryFactory gf new GeometryFactory(); Envelope env new Envelope(minX, maxX, minY, maxY); Geometry tilePolygon gf.toGeometry(env); while (rs.next()) { Geometry geom new WKTReader().read(rs.getString(geom)); Geometry clipped geom.intersection(tilePolygon); if (clipped.isEmpty()) { continue; } // 后续转像素、编码 }intersection返回的可能是 Multi 几何比如一条路被瓦片边界切成两段这是正常的MVT 允许一个 Feature 对应 MultiLineString。另外一个坑是数据库里如果存的是 GeometryCollectionintersection之后需要遍历子几何逐个处理否则编码器会报类型不支持。3.3 坐标转像素 编码成 MVT 字节流几何裁剪完先转像素坐标再交给编码器。这一步的顺序不能反先裁剪再转换转换后还能用 JTS 的方法做简化如果先转换再裁剪坐标体系已经不是米制裁剪条件就乱了。VectorTileEncoder encoder new VectorTileEncoder(4096, 64, true); while (rs.next()) { Geometry geom readGeometry(rs); Geometry clipped geom.intersection(tilePolygon); if (clipped.isEmpty()) { continue; } // 转成 4096 像素坐标 Geometry pixelGeom TileMath.toPixel(clipped, range, 4096); // 属性规整MVT 只接受有限类型 MapString, Object props new HashMap(); props.put(name, rs.getString(name)); if (rs.getString(type) ! null) { props.put(type, rs.getString(type)); } // JTS Geometry - GeoJSON GeoJSON geoJson new Jts2GeoJson().toGeoJson(pixelGeom); Feature feature new Feature(geoJson, props); encoder.addFeature(roads, feature); } byte[] mvt encoder.encode();addFeature的第一个参数是图层名前端会按这个名字在 style 里找到对应的图层第二个参数是 Feature 对象包含几何和属性。多图层的情况下比如道路、建筑、水系可以在同一个 encoder 里依次addFeature也可以分开编码再合并前者更省空间因为 MVT 头信息只写一次。编码完成后返回的是 protobuf 字节数组直接写进 HTTP 响应体Content-Type 用application/x-protobuf或application/vnd.mapbox-vector-tile。字节流验证有个简单办法用 protobuf 工具解出来看 layer 数量和 feature 数量或直接丢给前端看渲染效果。后端自测时可以写个单测断言mvt.length 0再取第一个瓦片的 feature 数和属性值核对。4. 简化与属性规整控制瓦片体积保住渲染效果4.1 按 zoom 动态调整简化阈值原始几何往往比显示精度要精细得多比如一条路的坐标点每隔三五米一个在 z10 的瓦片上十几个点挤在一个像素里白白增大瓦片体积。JTS 的DouglasPeuckerSimplifier按容差简化折线容差设成「该 zoom 下一个像素对应的地面距离」这样简化后误差正好控制在一个像素以内。public static double toleranceForZoom(int zoom) { double resolution 40075016.68557849 / 256 / Math.pow(2, zoom); return resolution * 0.7; } Geometry simplified DouglasPeuckerSimplifier.simplify(clipped, toleranceForZoom(zoom));40075016.68557849 / 256是 z0 时一个像素的米数除以2^zoom是当前 zoom 的像素对应地面距离乘 0.7 留了 30% 的余量避免简化后刚好卡在边界。这个 0.7 是我常用的经验值如果你发现渲染时小弯曲丢失可以降到 0.4如果瓦片还是大可以对不重要图层提到 1.0比如背景填充面比道路更舍得简化。这里有一个容易被忽略的点简化要在转像素之前做用米制容差转像素之后做简化容差单位变成像素容易把几何细节推过头。还有simplify之后必须检查几何是否有效详见下一章避坑。4.2 属性字段白名单与类型规整MVT 属性字段类型受限编码器遇到不认识的类型会直接抛异常。常见做法是维护一个白名单只把前端需要的字段带进瓦片属性越少瓦片越小。类型映射上String 对应 stringLong 对应 int64Double 对应 doubleBoolean 对应 bool。注意数据库里 Number 类型的字段要统一成 Long 或 Double不要让它以 BigDecimal 之类的方式透传进来。private MapString, Object normalizeProperties(MapString, Object raw) { MapString, Object props new HashMap(); raw.forEach((key, value) - { if (value null) { return; } if (value instanceof String || value instanceof Boolean || value instanceof Long) { props.put(key, value); } else if (value instanceof Number) { props.put(key, ((Number) value).doubleValue()); } }); return props; }这段规整逻辑把数字统一成 Double把字符串和布尔原样保留其他类型丢弃。为什么数字统一成 Double因为 MVT 编码器对 int64 和 double 的处理路径更稳而数据库里常见的 Integer、Short、BigDecimal 直接塞进去容易触发类型映射问题。如果你确实需要 int64 保留整数语义把Number分支改成value instanceof Integer || value instanceof Long后转 Long其余数字转 Double。属性规整还有个用途控制输出体积。一条道路如果带上十几二十个业务字段瓦片体积会肉眼可见地涨。前端 style 里用不到的都别带尤其是文本描述类字段动辄几十字节乘以几万个要素累积很可观。我一般只保留 ID、名称和类型这三类不够再加。5. 避坑实录MVT 切片最常见的五个事故现场5.1 瓦片渲染整体偏移、点线对不上现象前端加载瓦片后要素整体偏移几十米甚至几百米zoom 越高偏移越明显。原因数据源是 WGS84 经纬度没转成 Web Mercator 就直接做了像素坐标换算或者像素坐标的 y 轴方向反了导致南北颠倒。这是切片新手最高频的翻车点根因是对坐标系没做统一。解决入库时把几何统一成 3857查询后在 Java 侧不要再做任何投影假设。如果用的是 4326 数据查询时ST_Transform(geom, 3857)先转一次。像素换算时严格按toPixel里的翻转逻辑y 轴负号不能省。5.2 相邻瓦片接缝处要素被截断现象一条完整的道路在瓦片边界处断成两截拖拽地图时断口跟着边界走。原因瓦片查询范围正好卡在要素边界上要素被intersection硬切。MVT 设计上允许要素延伸到瓦片外部但 JTS 裁剪把外部部分全去掉了。解决VectorTileEncoder构造器第二参数 buffer 设成 64 或 128同时查询 bbox 向四周外扩对应的米数。外扩量等于buffer / 4096 * 瓦片边长也就是 buffer 占 extent 的比例乘以瓦片实际米制尺寸。这样相邻瓦片各自多裁了一块重叠区域前端渲染时接缝就消失了。5.3 空白区域前端一直转圈加载现象滚动到海洋、沙漠等无数据区域地图一直显示加载中请求反复发出。原因后端对空结果返回了 204 或 404前端认为瓦片请求失败会退避重试。空瓦片和失败请求在 HTTP 语义上不是一回事。解决即使是空数据也返回一个合法的空 MVT 字节流状态码 200Content-Type 照常。空 MVT 可以只含一个空的 layer 定义或者只有 protobuf 头。我一般对无数据的瓦片直接返回一个只初始化未 addFeature 的 encoder 的输出一行代码前端就安静了。5.4 简化后出现自相交多边形渲染成乱麻现象多边形要素渲染后出现打结、扭曲形状明显不是原始数据的样子。原因DouglasPeuckerSimplifier的容差设太大多边形内部的凹陷处被简化过头折线自相交。简化是纯几何操作不保证拓扑有效性。解决简化后用Geometry.isValid()校验无效几何用buffer(0)做一次拓扑修复。buffer(0) 是 JTS 里经典的 noding 修正手段能消除大部分自相交问题。另一个治本的方法是降低容差系数从 0.7 降到 0.4 再观察。5.5 低 zoom 取数慢到接口超时现象z0 到 z5 的瓦片每片耗时 1 秒以上并发一高直接打满数据库。原因低 zoom 下瓦片范围覆盖几万平方公里表里几百万条要素都在查询范围内逐条intersection是灾难。解决在WHERE里先用做 bbox 过滤并在 geom 列上建 GIST 索引。更进一步低 zoom 的瓦片根本不需要原始精度的几何查询时用ST_Simplify(geom, 容差)或ST_Subdivide预化简数据量能降一个量级。我一般在 z6 以下用简化后的形状表z7 以上才查全量明细表。6. 进阶增量预生成与目录缓存和前端联调验证按需切片适合数据量小或更新频繁的场景数据量大时更稳的做法是预生成瓦片文件按tiles/{z}/{x}/{y}.mvt落地到磁盘再用 Nginx 直接 serve。预生成可以按 zoom 分层跑低 zoom 全量生成高 zoom 按热点区域生成配合一个简单的目录缓存File tileFile new File(tiles/ zoom / x / y .mvt); if (tileFile.exists()) { return Files.readAllBytes(tileFile.toPath()); } byte[] mvt generateTile(zoom, x, y); tileFile.getParentFile().mkdirs(); Files.write(tileFile.toPath(), mvt);和前端联调时先验证请求 URL 和返回类型再查渲染细节。Mapbox GL JS 里 source 配type: vectortiles 指向后端接口style 里按图层名关联。Cesium 加载 MVT 要绕一步因为 Cesium 原生不认识 MVT常见做法是后端把 MVT 解码回 GeoJSON 再喂给 GeoJsonDataSource或者用第三方解析库在 canvas 上绘制本质都是先确认 z/x/y 字节流能被正确解码。用 curl 验最直接curl -s -H Accept: application/x-protobuf \ http://localhost:8080/mvt/roads/14/13712/6389.mvt | wc -c返回字节数在几百字节到几十 KB 之间都算正常如果到几百 KB 就该检查属性字段是不是带多了。从那以后我每接一个 MVT 相关的活儿都会先拿最低 zoom 的全球范围算一遍体量再决定要不要预生成、要不要上目录缓存而不是直接怼到线上。希望帮到你。本文还有配套的精品资源点击获取
返回列表