最近我踩了一个很多同事都觉得绕的坑也正好回答了一个被问过好几次的问题Cesium 加载非 ArcGIS Server 发布的 ArcGIS 切片到底行不行事情是这样的我手里有一个某单位的历史影像项目数据是当年用 ArcGIS 生态切好的瓦片缓存服务早就下线了只剩下磁盘上一堆文件——有 TPK 切片包有紧凑缓存 bundle还有几个 XML/JSON 的切片方案描述。按惯性思维第一反应是重新装一个 ArcGIS Server把地图服务发布出来再喂给 Cesium。但仔细想切片本来就是“地图瓦片 切片方案”两个东西服务端只是提供 URL 的宿主Cesium 真正需要的是“能按行列号取到瓦片”的能力。既然数据在找个方式把瓦片按规则吐出来不就行了这篇文章就是我基于这次项目经验整理出来的完整过程包含三种最常见的切片形态、三条可行的加载路线、可直接抄的代码和几个生产环境里才会遇到的坑。如果你也手握 ArcGIS 切片但没有 GIS 服务器这篇文章应该能帮你省下大半天时间。1. 先盘清楚你手里的“ArcGIS切片”到底是什么形态不管用什么路线第一步永远是打开缓存目录看一眼搞清楚手里到底是哪种切片。我见过太多人一上来就写代码结果连文件目录长什么样都没看白白浪费时间。ArcGIS 生态里的切片缓存最常见的是下面三种形态它们对应的加载策略完全不同。1.1 散列瓦片缓存最省事的一类散列缓存是最原始也最通用的形态目录一层层往下每一级是一个L{level}目录里面是R{row}目录再里面是C{col}.png这样的瓦片文件。有些旧数据还会按L{level}/R{row}/C{col}.jpg命名本质一样。这种形态下瓦片就是普通图片文件只要能通过 URL 访问到它们Cesium 就能加载。你需要做的只有三件事把目录丢到静态文件服务里确认行列号命名规则然后在 Cesium 里把 URL 模板写对。这里有个容易忽略的细节——ArcGIS 的散列缓存文件名大多会补零比如R00000000、C00000000而 Cesium 的模板变量{x}、{y}不会自动补零。所以要么改名去零要么用customTags在模板替换时补零我后面会给具体代码。1.2 TPK一个披着 .tpk 外衣的 SQLite 库TPK 在 ArcGIS 生态里非常常见很多人以为它是个专有格式其实它就是一个 SQLite 数据库文件。你可以用任意 SQLite 工具打开它里面通常有一张tiles表字段是level、row、col、tiletile字段存的就是图片二进制另有一张metadata表存的是切片方案 JSON。这一条信息非常关键因为 TPK 本质是数据库你完全不需要把它解压成散列文件直接用 SQL 按行列号查瓦片再返回给前端就行。我最早知道这个事实时吃了一惊因为文档里从来不会说“这是个 SQLite 库”。如果你拿到 TPK第一步先拿 SQLite 工具看一眼表结构比什么逆向都可靠。我还会额外提一句还有一种 TPKX 格式本质是 ZIP 压缩包里面装的是紧凑缓存 bundle不是 SQLite。遇到 TPKX 时走下面的紧凑缓存路线别拿 SQLite 去开。1.3 紧凑缓存.bundle 和 .bundlx 的组合紧凑缓存是 ArcGIS Server 切片时默认的存储方式目的是减少小文件数量提升磁盘 IO。它的目录不再有散落的 png而是把大量瓦片按行列打包进.bundle二进制块同目录下放一个.bundlx索引文件。读取时要先解析索引拿到某个瓦片在 bundle 文件里的偏移量和长度再精确读一段字节。从工程角度看紧凑缓存是三种形态里最麻烦的因为它不能直接被静态服务承载。好消息是它的索引规则是公开且稳定的很多开源工具都实现了读取逻辑照着思路写一个 Node/Python 读取模块并不难。后面我会专门用一个章节讲它的坑。在动手前建议先做一张表确认手头的资源属于哪一类我按最常见的判断逻辑整理如下表现特征常见格式能不能直接静态加载主要适配成本目录里能看到 L{level}/R{row}/C{col}.png散列缓存能补零与坐标系设置单个文件扩展名 .tpkSQLite 库不能写读取服务或解包导出扩展名 .tpkxZIP 内装 bundle不能解包后再走紧凑缓存路线目录里是 .bundle .bundlx紧凑缓存不能写 bundle 解析模块一个目录带 conf.xml/tileInfo.json上述任一种的元数据视情况先读元数据再决定读元数据往往是被忽略的一步。我建议无论什么形态先找到描述切片方案的文件并保存成一份固定 JSON后面所有路线的配置都从这份 JSON 生成能少踩很多坑。2. 不打服务器也能加载三条路线怎么选同样一份切片至少有三条路可以到 Cesium但各自的适用边界差得很远。我先把三条路线摊开讲清楚再给你一张决策表。2.1 路线A静态目录 UrlTemplateImageryProvider这是最轻量的一条路只适合散列缓存。把 L/R/C 目录原封不动放到 Nginx、Apache 或者对象存储上确认 URL 能按路径访问到 png然后在 Cesium 里配置一个UrlTemplateImageryProvider。前端代码量极小加载效率也高因为静态文件服务器处理这种零碎图片是拿手好戏。要注意的是这条路只解决“URL 能不能拼对”不解决“切片坐标对不对”。一个最常见的翻车点是把 Cesium 的{z}/{x}/{y}直接套到 ArcGIS 的 R/C 命名上结果加载出来全是乱位。ArcGIS 模板变量里 R 是行号、C 是列号对应 Cesium 里的{y}和{x}顺序别搞反。2.2 路线B轻量代理服务把 TPK/bundle 伪装成瓦片接口针对 TPK、TPKX 和紧凑缓存最实用的做法是写一个几十行的后端代理服务。它监听 HTTP 请求拿到 z/y/x 后去 SQLite 或者 bundle 里把瓦片字节读出来设置好 Content-Type 返回。前端完全无感仍然用UrlTemplateImageryProvider请求/tile/{z}/{y}/{x}。为什么这是最实用的路线因为它的成本和收益最平衡。后端一个进程可以同时支持 TPK 和紧凑缓存不用改前端代码数据如果以后迁移到真 Server只要改 URL 前缀。缺点是必须有一个常驻进程并且要自己处理并发和缓存否则瓦片量大时体验会明显变差。2.3 路线C前端直接读数据文件前端直读听起来很酷用 sql.js 这类 WASM 方案把 TPK 整个加载到内存再查瓦片或者用浏览器文件系统 API 去读本地 bundle。这种方案最大的优点是完全不需要后端适合内网离线工具的演示场景。但它有三个硬伤文件必须整体加载或授权访问、数据量大了内存顶不住、浏览器兼容性参差不齐。几千张瓦片的小工具可以玩几个 TB 的影像服务千万别这么干。所以我只在数据量可控、不允许部署后端服务的时候推荐这条路。2.4 一张决策表解决选择困难场景特征推荐路线理由散列缓存有静态文件服务器路线A零后端、并发高、实现最快TPK/TPKX需要长期在线服务路线B前后端解耦数据切换灵活紧凑缓存 bundle不能改目录结构路线B静态和前端都难直接读内网小工具几万级瓦片没后端路线C单机可用部署最干净几十 TB 影像正式对外服务路线B 导出散列代理只做索引瓦片交给 Nginx 缓存不确定数据形态先做技术验证先路线A试用最小成本验证行列号规则还有一个经验如果你连“切片长什么样”都不知道就先用 SQLite 打开看有没有 tiles 表没有再用静态路径试一下目录里的 png 能不能直接访问。数据形态确认之前选路线大概率会推翻重来。3. UrlTemplate 落地真正决定成败的是切片方案不是 URL很多教程都停在“写一个 URL 模板”这一步但实际跑起来你会发现URL 只是表面的胶水切片方案才是地基。切片方案决定 Cesium 怎么把地理范围划分成瓦片网格如果和源数据不匹配URL 拼得再对也是白搭。3.1 先从元数据里把切片方案读出来ArcGIS 切片数据集里一般会有 conf.xml、tileInfo.json 或 cache 描述文件里面记录了几件关键信息坐标系3857 还是 4326、切图原点、瓦片像素尺寸、每一级的分辨率 scale。一个典型的 tileInfo 结构大概是{ spatialReference: { wkid: 102100 }, tileOrigin: { x: -20037508.3427892, y: 20037508.3427892 }, tileSize: { width: 256, height: 256 }, lods: [ { level: 0, resolution: 156543.033928, scale: 591657527.591555 }, { level: 1, resolution: 78271.516964, scale: 295828763.795777 } ] }这段信息对应到 Cesium 就是三个配置tilingScheme用WebMercatorTilingScheme还是GeographicTilingScheme、rectangle数据实际覆盖范围、maximumLevel缓存切到哪一级。我强烈建议把这份 JSON 单独存一份用程序自动生成 Cesium 配置。以后从 Cesium 换到 Leaflet、MapLibre 或者别的地图库这套切片方案也能直接复用。3.2 写 URL 模板时把补零放在自定义标签里如果没条件改文件名customTags 是最干净的补零方案。代码里要注意回调函数的参数 x 是瓦片列号、y 是行号而 ArcGIS 的目录命名是 R 行号、C 列号const provider new Cesium.UrlTemplateImageryProvider({ url: https://your-host/cache/L{level}/R{row}/C{col}.png, tilingScheme: new Cesium.WebMercatorTilingScheme(), minimumLevel: 0, maximumLevel: 18, tileWidth: 256, tileHeight: 256, customTags: { level: (imageryProvider, x, y, level) String(level).padStart(2, 0), row: (imageryProvider, x, y, level) String(y).padStart(8, 0), col: (imageryProvider, x, y, level) String(x).padStart(8, 0) } }); viewer.imageryLayers.addImageryProvider(provider);这里有两个容易错的地方。第一别名里我故意用 level/row/col 而不是 z/x/y是为了代码可读性也防止把语义搞混。第二如果源数据是 WGS84 地理坐标系切的tilingScheme要换成GeographicTilingScheme并且rectangle通常要设置成数据的经纬度范围否则地图会整体错位或请求大量不存在的瓦片。3.3 加载偏移花屏时优先排查这三个地方我自己在验证时踩过最深的坑是加载出来的影像整体往南偏了几公里。逐行排查后才发现切片元数据里写的是 4326 经纬度切图我却用了WebMercatorTilingScheme。这种错位很隐蔽因为影像看起来是完整连贯的只是位置不对。碰到花屏、偏移、灰块按优先级做下面三件事确认坐标系用GeographicTilingScheme和WebMercatorTilingScheme各试一次看影像是否归位。确认层级数某些方案的 level 不是从 0 开始或者最小级别不等于 0需要在minimumLevel里指定否则 Cesium 会请求一堆不存在的瓦片。确认 rectangle如果元数据里写了 fullExtent尽量把 rectangle 设置成这个范围。不要迷信“全球瓦片”很多单位只切了城市范围不设 rectangle 会让 Cesium 在边界外反复请求空瓦片。4. 从 TPK 和 bundle 里榨出瓦片一套可以落地的后端实现如果手里是 TPK 或紧凑缓存只靠静态目录解决不了只能写一点后端代码。这一节我给出一套可以落地的实现从查看 TPK 表结构到写一个瓦片服务再到解析 bundle 索引。4.1 先用 SQLite 看一眼 TPK 的内部结构拿到 TPK 后不要直接写代码先花两分钟认识它的内部表。用命令行 sqlite3 或者任意图形化工具执行SELECT name, type FROM sqlite_master WHERE typetable;正常你会看到类似 tiles、metadata 的表。接着查一条瓦片记录确认字段顺序和图片类型SELECT level, row, col, length(tile) FROM tiles LIMIT 5;level、row、col 对应金字塔层级和行列号length(tile) 是图片字节数。这个字段结构不同版本可能略有差异所以先看一眼永远是最稳的。碰到表名不是 tiles 的情况也别慌从 sqlite_master 里就能找到真正存瓦片的那张表。4.2 用 Node 写一个几十行的 TPK 瓦片代理TPK 内部是 SQLite查询单张瓦片非常快所以一个基于 better-sqlite3 的代理也就几十行。下面这个例子可以直接当作最小可运行版本const express require(express); const Database require(better-sqlite3); const path require(path); const app express(); const db new Database(path.join(__dirname, cache.tpk), { readonly: true }); const stmt db.prepare( SELECT tile FROM tiles WHERE level ? AND row ? AND col ? ); app.get(/tile/:z/:y/:x, (req, res) { const z Number(req.params.z); const y Number(req.params.y); const x Number(req.params.x); const row stmt.get(z, y, x); if (!row) { res.status(404).end(); return; } res.setHeader(Content-Type, image/png); res.setHeader(Cache-Control, public, max-age86400); res.send(row.tile); }); app.listen(8080, () console.log(tile proxy on 8080));前端配置不变把UrlTemplateImageryProvider的 URL 指到 http://localhost:8080/tile/{z}/{y}/{x} 即可。读这个代码时注意两个设计。第一我用了 readonly 打开数据库这是防止并发写锁的最佳实践第二查询不到的瓦片返回 404 而不是一张透明 PNG因为 Cesium 收到 404 会走正常的“该级缺失”逻辑不会把透明图当成有效瓦片缓存起来后续层级切换更干净。如果你想在代理层模拟 ArcGIS REST 的路径把 route 改成/arcgis/rest/services/cache/MapServer/tile/:z/:y/:x即可但这真的不是必需品。Cesium 用UrlTemplateImageryProvider完全够强行模拟 REST 还要额外处理服务元数据接口不划算。4.3 紧凑缓存解析 bundlx 索引再读取 bundle紧凑缓存没有 SQLite 可以查核心思路是先定位瓦片在哪个 bundle 里然后从 bundlx 索引文件中读取它在 bundle 中的偏移量和长度。我拿 v2 缓存举例bundlx 文件头部有一段保留字节之后每条索引记录包含文件偏移和长度读取流程大致是function tileOffsetFromBundlx(bundlxBuffer, tileIndex) { const headerSize 16; const recordSize 64; // 不同版本有差异以实际为准 const start headerSize tileIndex * recordSize; const offset bundlxBuffer.readBigUInt64LE(start); const size bundlxBuffer.readBigUInt64LE(start 8); return { offset, size }; }拿到 offset 和 size 之后用 fs 从对应 .bundle 文件的这个位置读出 size 字节就是一张瓦片图片。这里我必须强调不同版本 ArcGIS 生成的紧凑缓存头部大小、记录条数、记录字段定义可能有差异。我见过有人直接抄网上代码发现读出来全是乱码就是因为把 v1 和 v2 的索引规则搞混了。稳妥的做法是先用十六进制工具打开一个 .bundlx 文件看看头部字节和记录间隔验证一下再写死或者直接找一套已经适配好当前版本的开源解析逻辑去改。4.4 内存缓存与并发控制最后补一个性能建议。SQLite 单条查询本身很快但瓦片服务在大并发下真正的瓶颈是连接建立和 IO 调度。我在项目里加了两个简单的优化用 Map 做最近 N 块瓦片的内存缓存key 用 z/y/x 拼字符串命中率高得惊人代理端控制并发数量比如同一时间最多 16 个瓦片读取任务避免瞬间请求把磁盘 IO 打满。这两个优化加起来不超过五十行代码却能明显改善 Cesium 快速旋转视角时的体验。5. 生产环境跑下来最值得记的五个坑如果只是写 Demo前面四章已经够用。但真正放到生产环境里跑还有一批问题是文档里不会写的。我挑五个出现频率最高、影响最大的逐一说明。5.1 坐标系看似都是 3857实际却差着整张图Cesium 默认地球展示用的网格是 WebMercatorArcGIS 切片也大量使用 3857看起来应该兼容。但实际项目中我见过至少两次元数据写着 102100/3857tilingScheme 也建的 WebMercator影像却依然偏移。原因往往出在切图原点和 LOD 定义上。ArcGIS 缓存的原点是左上角但不同单位切图时的起始范围可能不一样有些数据虽然用 3857 坐标系可只是把当地平面坐标硬包了一层。遇到这种数据光看 wkid 不够必须把 tileOrigin 和第一级 resolution 和 Cesium 的 tilingScheme 对齐。我的验证办法是抓一次浏览器请求看请求的 x/y/level 对应的瓦片再和元数据里的行列号对照差一个级别或差一个象限都能快速定位。5.2 补零坑请求 404 不要归咎于数据缺失散列缓存的补零问题我在前面提过但生产环境里它还会造成一个特别迷惑的现象地图缩放时大片区域明明有数据却一直请求不存在的 R00000000/C00000000 文件看起来像是切片切漏了。排查时先在浏览器 Network 里随便点开一个瓦片 URL拼一下行列号去本地目录里找对应文件。如果文件在而 URL 请求不到十有八九是补零规则不一致。我那次的解法是把 customTags 统一改成 padStart 到固定位数因为历史缓存的位数都是定死的比如行号 8 位、列号 8 位、层级 2 位。别用“补足最小位数”这种动态逻辑位数不齐的旧数据会再次出问题。5.3 缺失层级与透明边缘ArcGIS 切片经常不是完整金字塔比如只切了 0-10 级和 14-18 级中间 11-13 级没有数据。Cesium 在缺失层级上的表现是灰块不是自动用上一级模糊放大。想要好的体验要么把缺失的层级补切要么在代理端做“向上找最近有效层级”的逻辑返回放大后的影像并在 URL 参数或响应头里告诉 Cesium 这是替代瓦片。透明边缘则是另一个容易忽略的问题。PNG 瓦片如果带 alpha 通道瓦片边缘的半透明像素会在拼接时产生细缝尤其影像数据叠加到三维地形上时特别明显。处理办法是保证源数据瓦片是不透明底图或在预览层下方叠加一层纯色底图。5.4 大并发下的代理服务优化我第一次把小工具直接暴露给业务用户使用时早上九点被大家集体打开瓦片服务瞬间收到几百个并发请求。Node 单线程的 SQLite 查询其实不慢但突然涌入的请求会让文件描述符和连接池迅速打满表现为地图白屏、加载超时。优化路径我踩过之后总结为三步先在代理层加一个简单的限流队列然后把最近访问过的瓦片放进内存 LRU最后把高频瓦片或者整包小数据预生成成散列 png交给 Nginx 直接 serve。如果数据量特别大还可以考虑把瓦片写回某个对象存储用 CDN 扛流量代理只负责冷数据回源。5.5 元数据丢失时的兜底办法最后一种情况最让人头疼数据文件都在但 conf.xml 和 tileInfo.json 都丢了切片方案完全不知道。这时不要乱猜用“反推法”还原找一个你确信的坐标点在 Cesium 中定位到某级看 console 里请求的行列号再对照在线地图查找该级全球行列数基本能推出原点、坐标系和最大层级。如果反推太慢还有个土办法在数据目录里看最大层级 L 目录下的文件数量推算该级全球瓦片覆盖范围是否吻合再结合几个已知地物坐标反推切图原点。这个过程比较费时间但至少能让你在元数据丢失时有个起点。如果只让我留一个习惯那一定是“拿到数据先提炼切片方案”。把 tileInfo 转成一份标准 JSON 存到数据旁边等于把切片方案和瓦片文件解耦了之后无论是 Cesium、Leaflet 还是 MapLibre一行配置就能还原。还有一个很小的技巧也值得分享在瓦片代理里把缺失瓦片的 404 响应时间压到 1ms 内并且对同一个空白坐标做短期缓存避免 Cesium 频繁请求同一个空洞——我用这个办法把瓦片请求量降了差不多四成。希望这篇经验能让你少走点弯路。