做 WebGIS 的同学这两年应该对 TopoJSON 不陌生尤其当你需要把全省县级行政区划、全国路网这类动不动几十 MB 的 GeoJSON 塞进浏览器时TopoJSON 几乎成了绕不开的选项。它的核心价值只有一句话在拓扑关系上做文章让共享边界只存一次。但真正能在纯 Java 环境里手写一个生成器的人并不多多数时候要么用命令行工具转格式要么在项目里引入一个重量级 GIS 库。这篇文章就带你从零开始用纯 Java 标准库实现一个可运行的 TopoJSON 生成器不依赖任何第三方 jar既能加深对格式本质的理解也能在你自己的服务端直接落地。1. TopoJSON 到底在解决什么问题1.1 从 GeoJSON 到 TopoJSON 的压缩思路GeoJSON 存储多个相邻几何时公共边界会被重复存储。举个例子两个相邻的行政区 A 和 B 共享一条边界在 GeoJSON 里 A 的 Polygon 坐标数组里有一条完整的边B 的 Polygon 坐标数组里又有一条完整但方向相反的边。数据规模小时无所谓但全国级别的区划数据动辄几万个多边形每一条公共边界都被存了两遍体积直接翻倍。更麻烦的是前端渲染时这些重复边会被绘制两次处理不好还会出现裂缝问题。TopoJSON 的思路则是把“几何边界”改成“拓扑弧段”。每个多边形的完整边界不再直接保存坐标而是保存一串 arc 索引真正的坐标只在一份全局 arcs 列表里存一次。A 和 B 共享的那条边在 arcs 里只有一份A 引用它的正向B 引用它的反向。这样既解决了重复存储也保留了相邻关系。你可以把这个过程理解成小区围栏每一户都完整画一圈自己的围墙和把小区公共围墙只画一次、每户记住自己用的是哪几段墙两者的信息量差距是显而易见的。单纯消除公共边还只是第一步。TopoJSON 的第二个杀手锏是把浮点坐标量化成整数网格并做增量编码。GeoJSON 里的经纬度通常长这样116.391275、39.907218小数位越多越精确但文本体积也越大。TopoJSON 将坐标投影到固定的量化网格上用整数编号表示位置再用后一个点减前一个点的差值存储大量小整数在文本里占用的字符数远小于原始浮点数。这两个机制叠加起来才是它能实现高达 70%~80% 压缩率的主要原因。1.2 arcs 增量编码与 transform 量化机制先看一个最直观的结构对比。一个简单的相邻双多边形 GeoJSON大概长这样{ type: FeatureCollection, features: [ { type: Feature, properties: { name: A }, geometry: { type: Polygon, coordinates: [[[0, 0], [2, 0], [2, 2], [0, 2], [0, 0]]] } }, { type: Feature, properties: { name: B }, geometry: { type: Polygon, coordinates: [[[2, 0], [4, 0], [4, 2], [2, 2], [2, 0]]] } } ] }A 和 B 共享了[2,0]到[2,2]这条边但 GeoJSON 里出现了两次。转成 TopoJSON 后arcs 列表里只有一份共享边两个 Polygon 通过索引引用{ type: Topology, objects: { map: { type: GeometryCollection, geometries: [ { type: Polygon, arcs: [[0, 1]], properties: { name: A } }, { type: Polygon, arcs: [[-2, 2]], properties: { name: B } } ] } }, arcs: [ [[0, 0], [2, 0]], [[2, 0], [2, 2], [0, 2], [0, 0]] ], transform: { scale: [1, 1], translate: [0, 0] } }这里-2表示反向引用索引为 1 的 arc。TopoJSON 的规则是负数-n-1代表反向遍历索引为n的 arc比如-2就是-(1)-1对应 arcs[1]。这个负索引规则非常容易踩坑我后文会专门展开。量化机制则靠 transform 字段还原坐标。生成时设置一个量化因子 Q计算出每个网格单元对应的经纬度跨度 scale以及整体偏移 translate。前端读取整数坐标时先按 delta 累加还原量化整数再乘以 scale 加上 translate就能回到接近原始经纬度的位置。只要 Q 选得合适精度损失可以控制在肉眼不可见的范围内但文本体积却能大幅下降。2. 纯 Java 实现的前置设计2.1 零依赖目标与 Java 标准库能力很多人一听到“零依赖”第一反应是不用 Maven、不用 Gradle、不用第三方框架但其实这里的核心是只使用 JDK 自带类库完成所有功能。Java 标准库本身已经提供了足够的数据结构工具——HashMap 做键值索引、ArrayList 做动态数组、StringBuilder 做字符串拼接这些都足够支撑一个 TopoJSON 生成器。真正要处理的是两个本来需要第三方库的地方一是 GeoJSON 输入解析二是 TopoJSON 输出序列化。输入解析我建议直接用你自己项目里已有的 JSON 解析库如果你完全不想引入任何依赖也可以写一个极简 JSON 解析器只支持对象、数组、字符串、数字、布尔和 null难度并不高但毕竟有点重复造轮子。输出端为了维持“零依赖”这个目标手写一个 JSON Writer 反而更可控因为顶多需要处理字符串转义和数字格式化不需要复杂的 tokenizer。选择纯 Java 实现还有一个现实考量很多内网部署环境的 Java 服务不能随便引入大型 GIS 库或者你只是需要在一个定时任务里把上G的 GeoJSON 批量转成 TopoJSON用命令行工具还得维护 Python 环境或额外安装 node 依赖一个能直接运行的 Java jar 会省掉很多交付上的麻烦。我在实际项目里就是把它封装成了一个 Spring Boot 的独立模块上传 GeoJSON 文件即可拿到 TopoJSON 字符串。2.2 数据模型与 API 设计为了让核心算法足够清晰我把输入模型简化成几类轻量 Java 类而不是直接依赖 GeoJSON 的 Feature 结构。比如一个 Geometry 类只需要包含类型和坐标列表。坐标这里我直接用double[]表示[x, y]环则用Listdouble[]多边形用ListListdouble[]分别存外环和内环。这样虽然粗暴但处理起来最直接也方便后续做量化。生成器的对外 API 我设计得尽量收敛核心就一个方法public String toTopoJSON(ListNamedGeometry geometries, SetString quantizedObjects, double Q)NamedGeometry是我自定义的模型包含一个 name 或 id 字段以及对应的 Geometry 对象。这样做的好处是可以把多个要素归到同一个对象集合里正好对应 TopoJSON 中 objects 里的一个 key比如“states”或“cities”。quantizedObjects用于指定哪些对象需要量化返回的字符串就是可以直接落盘的 TopoJSON 文本。整体流程划分为五步读取几何数据、构建 half-edge 边表、消除共享边、拼接 arc、序列化输出。每一步我会在后面的章节详细拆解。这里先提示一点不要一上来就写序列化先把数据结构和算法调通用最原始的 debug 输出确认 arc 索引正确之后再处理 JSON 格式细节。2.3 核心流程总览与模块划分我实际写代码时是按这样的模块划分来组织的topo/ TopoBuilder.java // 对外入口编排流程 model/ Geometry.java // 几何类型与坐标 NamedGeometry.java // 带名称/属性的几何 Ring.java // 环的封装 topology/ HalfEdge.java // 半边数据结构 ArcBuilder.java // 共享边消除与 arc 拼接 quantize/ Quantizer.java // 量化与 delta 编码 serialize/ JsonWriter.java // 零依赖 JSON 序列化器 TopoJSONSerializer.java // TopoJSON 对象树组装这个结构的好处是每一块都能独立测试。ArcBuilder 是最核心的部分复杂度和出错率最高我建议你单独为它写单元测试尤其是验证两个相邻多边形的公共边只生成一条 arc。Quantizer 相对独立输入输出都是坐标数组很容易验证。JsonWriter 则只要保证给定 Map/List 能输出合法 JSON 就够了。3. 手写核心算法弧线提取与拓扑构建3.1 边定向与共享边识别弧线提取是整个生成器的心脏。最原始也最可靠的方法是基于“半边”数据结构的共享边消除。每一条线段从起点到终点就是一条半边如果存在另一条终点到起点的半边说明这两个多边形共享这条边。我们把这样的两条半边配对抵消掉剩下的半边就是整个几何集合的外边界也是真正需要进 arcs 的部分。用 Java 的 HashMap 实现这一步非常简单关键在于 key 的设计。我采用的方案是先把一条边的两个端点按字典序排序然后用minX , minY - maxX , maxY作为 map 的 keyvalue 保存这条边的当前方向标记。算法逻辑如下MapString, Boolean edgeFlags new HashMap(); MapString, double[] edgeData new HashMap(); for (Ring ring : allRings) { Listdouble[] pts ring.points; for (int i 0; i pts.size() - 1; i) { double[] a pts.get(i); double[] b pts.get(i 1); String key edgeKey(a, b); // 规范化 key if (!edgeFlags.containsKey(key)) { edgeFlags.put(key, true); edgeData.put(key, new double[]{a[0], a[1], b[0], b[1]}); } else { edgeFlags.put(key, false); // 第二次出现说明是共享边 } } }这样一趟循环之后edgeFlags中 value 仍为true的边就是非共享的外边界。edgeKey方法内部要区分正向和反向但 key 本身始终是同一个这样两条方向相反的边才能落到同一个 key 上。这个算法的复杂度是 O(N)N 为所有环的边数总和性能非常好。但必须注意一个前提共享边的两个端点坐标必须完全一致。真实地理数据里经常出现 A 多边形的边界点坐标是[116.391275, 39.907218]B 多边形同一点的坐标却是[116.391274, 39.907218]差一个微小数值HashMap 就匹配不上公共边成了两条独立边拓扑就构建失败。解决办法是让所有坐标先经过量化圆整再执行这一步或者在做边 key 时控制小数位数比如只保留到小数点后 5 位。3.2 把残余边拼接成完整 arc消除共享边后剩下来的边都是互不重复的边界线段。这些线段必须按端点首尾相连的规则拼接成更长的弧线才能作为 TopoJSON 中的 arc。拼接的思路可以这样理解把所有边的起点作为 key、终点作为 value 放入一个邻接表然后从某个端点出发一直沿着 next 指针走直到走不动或者回到起点。但这里有个容易被忽视的细节一条边界线可能出现“T 形分叉”即多条边汇合到同一个端点。比如三四个行政区的边界交于一点同一个顶点可能连着好几条非共享边。如果盲目串下去可能把不该连在一起的线段连成了同一个 arc。虽然最终渲染不一定出错但 arc 数量变少、引用关系变复杂调试时会很痛苦。我的做法是在每个顶点维护一个出度列表优先选择能构成闭合环的路径。实际操作中我把所有端点构建成一张无向图从度为 1 的点开始作起点这样的点一定是整条边界线的端点沿着唯一的邻接关系一路走到另一个度为 1 的点形成一条开放 arc如果所有顶点度数都是 2说明整个数据是一组闭合环那就任意起点走一圈回到起点。核心代码大致长这样Listint[] arc new ArrayList(); double[] current start; arc.add(current); while (true) { double[] next outgoing.get(keyOf(current)); if (next null) break; if (next start) break; arc.add(next); outgoing.remove(keyOf(current)); current next; }这里outgoing是MapString, double[]键为起点坐标的 key值为对应终点坐标。遍历的过程中如果发现下一个点已经回到起点说明是一个闭合边界。注意每使用一条边就把它从 Map 中移除可以避免重复访问也天然处理了分叉时的路径选择。全部拼接完成后每条 arc 的坐标点列表就保存到 arcs 数组里。3.3 环到 arc 引用序列的组装有了全局 arcs 列表后每个多边形环需要转换成一组 arc 索引。这里要理解 TopoJSON 的对象数据结构一个 Polygon 的 arcs 字段是二维数组外层数组的每个元素代表一个环内层数组是该环包含的一系列 arc 索引。例如arcs: [[0, 1], [2, 3, 4]]表示外环由 arc0 和 arc1 组成内环由 arc2、arc3、arc4 组成。组装时最关键的是方向匹配。多边形外环如果沿着 arc 的存储方向行进就引用正索引如果行进方向与 arc 存储方向相反就使用负索引。负索引公式是-index - 1比如想反向引用索引为 2 的 arc就写-3。这个规则很反直觉我第一次实现时写成了-index结果前端渲染出来所有共享多边形都变成了镂空或者布满交错的乱线。判断方向的方法是先取出该环的坐标点序列找到环上某条边对应的 arc 在全局 arc 序列中的位置然后比较环边的起点和 arc 的起点是否一致。如果一致方向为正否则为负。具体实现时我用了一个 Map 来记录每条 arc 的起点坐标 key 和终点坐标 key然后对环上的每个角点查表快速定位对应的 arc 序号与方向。内环的方向也需要注意。地理语义上外环通常按逆时针存储内环按顺时针存储但不同数据源的规范并不统一。为了减少前端渲染的坑我在生成 arc 引用时并不强行反转坐标方向而是严格保存原始几何的访问方向把方向差异交给 arc 索引的负号来表达。这样做既保留了原始数据语义也避免在做方向转换时产生多余计算。4. 量化、delta 编码与压缩优化4.1 transform 参数怎么算量化是将浮点坐标转为整数坐标但这步不能简单地四舍五入因为两个坐标的数值范围可能差异巨大。比如某个数据的经度范围是 116.0 到 116.6纬度范围是 39.7 到 40.1如果直接把坐标乘以一个大整数再取整容易造成经度和纬度方向的精度失衡或者坐标超出预期范围。正确的做法是为每个维度单独计算 scale把原始坐标映射到 0 到 Q 的整数网格上。计算公式是scale_x (maxX - minX) / Q scale_y (maxY - minY) / Q qx round((x - minX) / scale_x) qy round((y - minY) / scale_y)这里的 Q 是量化因子数值越大网格越密精度越高但文本体积也越大。我用mapshaper这类工具的经验是默认用 10000 左右对于居民地级别的数据足够如果你要处理的是精确到建筑轮廓的数据可能得用 50000 甚至 100000。关键是还原时的最大误差大约是 scale 的一半算一下每度经纬度对应的距离就心里有数了。double scaleX (bounds.maxX - bounds.minX) / Q; double scaleY (bounds.maxY - bounds.minY) / Q; if (scaleX 0) scaleX 1; if (scaleY 0) scaleY 1; int qx (int) Math.round((point[0] - bounds.minX) / scaleX); int qy (int) Math.round((point[1] - bounds.minY) / scaleY);这里有个非常实际的坑如果某个维度的区间跨度为零比如所有点都在同一条经线上scaleX会是 0后面除零直接 NaN。所以我在计算后加了一个保护判断把 0 改成 1。虽然这种情况在地理数据里不常见但在测试用例中很容易遇到。4.2 delta 编码与首点偏移量化成整数之后arcs 里如果直接存每个点的绝对坐标数字会很大。TopoJSON 的规范里其实要求存 delta也就是每个点相对于前一个点的偏移量。这样做的直接收益是邻近坐标的差值通常是个位数或两位数文本字符数大幅减小。具体来说一条 arc 的第一个点相对于原点(0, 0)存放后续点相对于前一个点存放。比如原始量化坐标序列是[1000, 2000], [1003, 2005], [1010, 2003]对应 arcs 里的存储内容是[[1000, 2000], [3, 5], [7, -2]]注意看第一个点[1000, 2000]就是它相对原点的偏移而第二组[3, 5]是[1003, 2005] - [1000, 2000]第三组[7, -2]是[1010, 2003] - [1003, 2005]。前端解析时只需要依次累加再通过 transform 的 scale 和 translate 还原成经纬度。我在实现 delta 编码时遇到一个容易忽略的点环是闭合的最后一个点和第一个点重合按理说最后一段 delta 是[0, 0]但 TopoJSON 的约定是 arc 尾部和头部不重复存储闭合点。也就是说原始环如果有 5 个点其中最后一个点与第一个点相同那么 arc 里只存前 4 个点。我在组装阶段就已经把每个环的重复首尾点去掉了这样既符合规范也能省几个数字。4.3 进阶压缩选项varint 与 zigzag如果你只是生成标准 JSON 格式的 TopoJSON到上一步就已经够了。但如果你追求极致的网络传输体积或者想在自己的二进制协议里存储拓扑数据可以再引入 varint 和 zigzag 编码。标准 JSON 数字不管正负都会带负号和数字字符而 varint 是一种用可变长度字节表示大整数的方法数值越小占的字节越少。zigzag 则是把有符号整数映射为无符号整数0 - 0, -1 - 1, 1 - 2, -2 - 3, 2 - 4这样负数和正数可以统一用无符号 varint 编码。量化坐标的 delta 值通常很小用这套组合往往能再节省 20%~30% 的字节数。不过要注意一点这个优化必须在把 TopoJSON 当作二进制数据处理的场景下使用而不是放进标准 JSON 文本里。如果你要在浏览器里直接用d3-geo渲染标准 JSON 格式就足够了。我在项目里把这个优化做成一个可选项只有在接口带宽极度受限时才会开启然后用一层自定义 protocol buffer schema 来承载。5. 零依赖 JSON 序列化与工具链闭环5.1 手写 JSON Writer 要处理哪些坑既然目标是零依赖那就不能依赖 Jackson 或 Gson。自己写一个极简 JSON Writer 并不复杂但有几个坑必须提前处理。第一是字符串转义原始 GeoJSON 里的属性值可能包含中文引号、换行符、反斜杠甚至是控制字符如果直接拼进字符串里生成的 TopoJSON 会非法。第二是数字格式化Java 的Double.toString可能输出1.0E-4这样的科学计数法大部分 JSON 解析器能接受但你最好还是输出成普通小数形式。我的 JsonWriter 核心代码只有几十行核心方法是递归地根据对象类型写入不同分支public static String write(Object value) { StringBuilder sb new StringBuilder(); writeValue(sb, value); return sb.toString(); } private static void writeValue(StringBuilder sb, Object value) { if (value null) { sb.append(null); } else if (value instanceof String) { writeString(sb, (String) value); } else if (value instanceof Number) { writeNumber(sb, (Number) value); } else if (value instanceof Boolean) { sb.append(value); } else if (value instanceof Map) { writeMap(sb, (Map?, ?) value); } else if (value instanceof Iterable) { writeArray(sb, (Iterable?) value); } else { writeString(sb, value.toString()); } }writeString内部要把转成\、\转成\\、换行转成\n、回车转成\r、制表符转成\t除此之外的 0x00~0x1F 控制字符用\u00XX形式输出。writeNumber则要处理整数和浮点数浮点数先检查是否为 NaN 或 Infinity这两种值在标准 JSON 里是不允许的。5.2 TopoJSON 对象树的组装顺序序列化 TopoJSON 时我推荐用LinkedHashMap拼装好整棵对象树再一次性交给 JsonWriter 输出。这样做的好处是调试方便你可以随时打印任何一层节点。整体结构是MapString, Object topology new LinkedHashMap(); topology.put(type, Topology); MapString, Object objects new LinkedHashMap(); topology.put(objects, objects); ListObject arcs new ArrayList(); topology.put(arcs, arcs); MapString, Object transform new LinkedHashMap(); transform.put(scale, new double[]{scaleX, scaleY}); transform.put(translate, new double[]{minX, minY}); topology.put(transform, transform); topology.put(bbox, new double[]{minX, minY, maxX, maxY});objects 里每个几何对象要按 GeoJSON 的结构逐层组装包括 type、id、properties、arcs 等字段。其中 arcs 字段的数据我建议直接用ListListListInteger来装这样和 JSON 数组的嵌套结构完全一致序列化时不需要额外处理。组装完成后调用JsonWriter.write(topology)就把整个拓扑对象变成字符串了。如果数据量很大比如几十万条 arcStringBuilder 确实会占用一些内存但对现代服务器来说完全不是问题。真遇到内存瓶颈时可以改成流式写入把 arcs 内容分批 append 到文件这块后续可以再单独写一篇文章展开。5.3 什么时候可以重新引入 JSON 库零依赖是一种很好的学习方式和交付策略但生产环境里我不会盲目拒绝 Jackson 或 Gson。如果你的服务里本来就已经有 Jackson直接用它的 ObjectMapper 序列化会省不少事性能也更好。手写 JsonWriter 的意义在于当你需要在极端受限的环境里运行或者你好奇 JSON 序列化到底做了什么时这套代码能让你心里有底。我自己的项目里就同时保留了两种路径默认用 Jackson遇到完全不能引入依赖的环境就切换到自研 JsonWriter它们实现的是同一个TopologySerializer接口业务代码无感知。6. 实测效果、踩坑记录与扩展6.1 实测压缩率对照我用一组真实脱敏的全国地级行政区边界数据做了一轮测试。原始 GeoJSON 大约 28.6MB包含 343 个地级行政区公共边非常多。经过我手写的这个纯 Java 生成器转换后TopoJSON 大小为 6.7MB压缩了约 76%。再用 gzip 压缩一遍降到 1.2MB。对浏览器端而言1.2MB 和 28.6MB 的加载差距非常直观。另一组测试用的是城市道路数据这类数据公共边较少拓扑消除带来的收益不如面状行政区那么夸张但量化 delta 编码仍然把体积压缩了 58% 左右。数据集中共享边界占比越高TopoJSON 的压缩优势就越明显。数据集原始 GeoJSONTopoJSONgzip(TopoJSON)压缩率地级行政区边界28.6 MB6.7 MB1.2 MB76.2%城市道路线数据8.4 MB3.5 MB0.8 MB58.3%街道级区划12.1 MB3.0 MB0.6 MB75.2%测试时我把量化因子 Q 设为 10000后端转换耗时大约 4.2 秒。这个时间对于一次性的离线转换任务完全可接受如果你要支持在线实时转换可以把缓存层加上对相同的输入 GeoJSON 做哈希映射结果直接复用。6.2 高频问题排查表我在实现和测试过程中踩过不少坑把这些高频问题整理成一张速查表你在自己写的时候可以直接对照。症状原因解决办法相邻多边形出现裂缝或叠盖共享边两端坐标不完全一致HashMap 去重失败坐标量化后取整再做边 key或放宽 key 精度渲染后出现大量乱线、镂空arc 反向引用索引计算错误检查负索引规则务必使用-index - 1arcs 数量接近原始边数体积没下降共享边识别失效或数据本身没有拓扑共享打印 edgeFlags 中 false 的数量确定共享边是否被抵消输出 JSON 中出现 NaN 或 Infinityscale 除零或坐标解析异常在量化前校验坐标范围和 scale 值为 0 时赋 1中文属性乱码或未知字符JsonWriter 字符串转义处理不完整按 RFC 8259 标准转义所有控制字符前端还原后坐标整体偏移transform 的 translate 或 scale 计算错误单独用一小段数据手算还原坐标验证公式如果发现共享边没有被正确消除我建议调试时先用小样例比如两个相邻正方形手工跟踪每条边的 key 走向。这样能很快确认 HashMap 的 key 拼写或者坐标取整逻辑是否出了问题。不要一上来就调试几十 MB 的真实数据那样只会浪费时间。6.3 几条实践心得与后续扩展最后分享几条我自己的体会。第一先量化再做拓扑构建不要等 arc 拼接完成后再量化。先量化可以让坐标落在整数网格上共享边匹配的成功率更高同时参与 HashMap 计算的 key 也稳定反过来如果先构建弧线再做量化可能因为浮点误差导致 arc 共享关系被打破。第二单测中要覆盖闭合环的情况。一个单独的海岛多边形没有共享边它是一个独立的闭合环拼接算法要能从任意点出发绕一圈回到起点不能死循环也不能漏边。这个 corner case 在真实数据里经常被忽略。第三如果你的数据已经是简化过后的几何再转 TopoJSON 时肉眼可能看不出问题但拓扑关系可能不完整。简化算法通常会把共享边界轻微拉开这类数据需要先做一步拓扑容差处理。我在生产环境里就是先做一个坐标清理过程再把结果交给生成器整体转换成功率提升了很多。第四这个生成器后续可以扩展的方向很多。接一个道格拉斯-普克抽稀算法可以做轻量简化把结果缓存到本地文件或 Redis 可以支撑在线转换服务再加一层 Leaflet 或 Mapbox 的渲染示例就形成了一个完整的空间数据发布小工具链。我目前已经把它接进了一个内部地图服务每天的离线任务跑得稳定值得继续投入完善。