
做GIS开发的人应该都遇到过这种尴尬项目部署在隔离内网业务系统要求有大屏地图展示结果在线瓦片一张都加载不出来地图区域白花花一片。我最近完成的一个项目就是这种典型场景最终用 OpenLayers 做了一套离线地图方案把谷歌、高德、百度三种底图瓦片全部落到本地按项目需求随时切换。不是简单贴一个图层而是把瓦片获取、目录存储、坐标系对齐、加载性能整条链路都打通了。这套方案适合用 OpenLayers 做 GIS 开发的朋友也适合正在被“内网地图”需求折磨的前端或后端同学。这篇文章会把关键原理和完整实操过程都写出来尽量让新手拿着就能用。1. 为什么要把在线地图做成离线场景与技术选型1.1 离线地图解决的四个现实场景第一个场景是网络隔离。很多政企项目、科研项目、生产系统都部署在专网里物理隔离是硬性要求外网根本不通在线地图自然不可用。第二个场景是弱网环境。就算能连外网会议室、地下机房、偏远地区的网速也常常不稳定大屏演示的时候瓦片加载不出来非常尴尬。第三个场景是响应速度。在线地图每次打开都要请求远程服务器不管带宽多高延迟都在几十毫秒到几百毫秒本地瓦片从内网磁盘读取基本是毫秒级响应地图拖拽和缩放的手感完全不一样。第四个场景是数据边界。有些业务数据不允许进入外部地图服务离线部署可以确保所有地图数据和业务数据都留在可控环境内。我最早也走过弯路想着“既然在线地图慢那就配置一个代理缓存”。但代理缓存依然依赖源站源站一调整或者网络策略一变整套东西立刻失效。后来我意识到对于固定区域、固定层级的底图需求最稳的办法就是提前把瓦片下载到本地做成一个私有的瓦片仓库。离线地图真正解决的是你不再依赖任何一家在线服务的可用性。1.2 为什么选择 OpenLayers 而不是 Leaflet、Cesium做 Web GIS 的前端框架社区最常用的无非是 OpenLayers、Leaflet、Cesium 三家。Leaflet 轻量、上手快适合简单的点线面展示但我要在一个项目里同时兼容谷歌、高德、百度三种瓦片规则还需要做坐标系纠偏、瓦片网格自定义Leaflet 虽然也能做但扩展起来明显更费劲。Cesium 主打三维地球用来做二维栅格底图属于杀鸡用牛刀而且打包体积大内网部署的静态资源成本更高。OpenLayers 在这三者里是最平衡的选择。它本身对瓦片图层的处理非常原生ol.source.XYZ、ol.source.TileImage这些类就是专门处理切片数据的投影转换、瓦片网格、加载函数都可以自定义文档也足够多。还有一个现实好处OpenLayers 是纯前端库不依赖构建工具下载一个发行包放到内网目录就能用。这一点在离线环境里特别重要因为很多时候你根本没法访问 CDN。1.3 谷歌、高德、百度三套瓦片源的本质差异很多人以为离线地图就是“把 URL 从在线换成离线”其实没这么简单。三套瓦片源至少有三个方面不一样。第一是坐标系。谷歌地图用的底图坐标体系接近 WGS84高德地图对外使用的坐标是 GCJ-02百度地图则是在 GCJ-02 基础上再做一次 BD-09 偏移。这些差异会导致同一套 GPS 坐标在三种底图上显示的位置不一样偏差从几十米到几百米不等。第二是瓦片编码规则。谷歌和高德的瓦片 x、y、z 规则基本一致网格原点在左上角百度瓦片的 y 轴方向相反原点在左下角。如果你拿着谷歌的瓦片编号规则去拼百度瓦片地址地图能不能出来另说位置大概率是错乱的。第三是 URL 风格。每种底图的数据接口参数都不是一套直接写死字符串很容易踩到某个源下线或者参数变化。所以我把它们拆成了一整套可配置的图层体系每种底图单独建目录、单独写加载逻辑、单独处理坐标系而不是硬把它们叠在一张图上。下面详细讲每一步。2. 离线瓦片数据的获取与整理2.1 瓦片金字塔与 XYZ 编号规则离线地图的基本单元是瓦片。地图服务商把一张完整地图按缩放级别切成无数小方块每个小方块叫一个 tile。级别从 0 开始0 级是整个地球缩成一张瓦片每放大一级上一级的 1 张瓦片会被切成 4 张。所以全球范围的瓦片数量是 4 的 z 次方第 15 级全球大概有 10 亿张这个数量对工程来说是不可接受的。实际项目中我们只需要某块城市或园区范围。比如一个城市到 12 级可能只有几千张到 16 级就是几万张到 18 级就是几十万张。级别越高单张瓦片覆盖的地理范围越小地图上能看到的道路和建筑细节越多。但每提升一级瓦片数量变成 4 倍磁盘占用和下载时间也跟着涨。所以离线范围规划必须做两个限制一是范围限制只下载目标区域的瓦片二是级别限制业务需要看到哪个精度就下载到哪个级别。不是把网上所有级别都拉下来才叫离线。瓦片保存到本地一般遵循{z}/{x}/{y}.png的目录结构。z 是缩放级别x 是列号y 是行号。这套规则在 OpenLayers 里叫 XYZ 规则也是大多数地图服务商使用的公开切片规则。另一种叫 TMS 规则区别在于 y 轴方向相反TMS 的 y 从底部往上递增。离线工具导出目录时必须搞清楚导出的到底是 XYZ 还是 TMS否则加载时地图会上下撕裂。2.2 三套瓦片的 URL 模板与坐标原点差异既然要离线下载首先要识别在线瓦片的 URL 规则。下面给出三套底图的 URL 模板示例实际使用时请以服务商当前接口为准谷歌https://mt1.google.com/vt/lyrsmhlzh-CNglCNx{x}y{y}z{z} 高德https://webrd0{1-4}.is.autonavi.com/appmaptile?langzh_cnsize1scale1style8x{x}y{y}z{z} 百度https://maponline0{1-4}.bdimg.com/tile/?qtpcx{x}y{y}z{z}stylesplscaler1p1这里的{x} {y} {z}是占位符下载工具会用实际瓦片编号替换。谷歌和高德的瓦片编号从左上角开始y 在竖直方向向下递增百度的瓦片编号从地图的左下角开始y 在竖直方向向上递增。因此百度的 URL 模板虽然也写{y}但它的 y 编号和标准 XYZ 编号是相反的。举个例子某缩放级别为 18标准 XYZ 规则下第 200 行瓦片对应百度坐标应该是2^18 - 1 - 200 262143 - 200 261943。在写离线加载代码时如果工具导出的是标准目录而你直接拿百度的在线编号去匹配就会 404。这里很容易出问题后面第 4 节还会重点讲。2.3 离线瓦片下载的实操工作流我自己常用的下载流程分四步画范围、选底图、设级别、导出目录。不需要写复杂的爬虫脚本直接使用常见的离线地图下载工具即可。这类工具通常内置了谷歌、高德、百度的瓦片源你只需要在地图上画一个矩形或多边形范围选择需要的底图类型设置最小和最大缩放级别然后导出。导出格式要注意。优先选择“XYZ 目录”或“标准瓦片目录”格式这样目录层级恰好是{z}/{x}/{y}.png后端代码几乎不用改造。如果工具默认导出的是 TMS 格式目录里的 y 方向是反的OpenLayers 加载时就要用自定义 tileUrlFunction 把 y 反转。下载瓦片时有几个经验值得分享第一卫星图比路网图大得多一张瓦片可能几百 KB如果项目只需要路网底图就不要把卫星图也下载回来。第二有些工具会生成很多空白瓦片白白占用磁盘和 inode建议导出完成后检查一下空白瓦片比例必要时候用一张透明 PNG 统一占位。第三离线下载只应服务于有正式授权或者明确合规范围内的内部系统。如果是商用系统最好向地图服务商申请离线授权或者使用官方提供的离线 SDK。这一点不是走形式而是真踩过合作方被追责的例子。3. OpenLayers 加载离线地图的完整实现3.1 本地瓦片目录、静态服务与库文件离线化瓦片下载完成后我习惯把目录结构整理成下面这样offline-tiles/ ├── google/ │ ├── 10/ │ ├── 11/ │ └── ... ├── amap/ └── baidu/在每个底图目录里继续按z/x/y.png存放瓦片。目录名建议统一用英文小写避免中文路径在浏览器和 HTTP 服务之间产生编码问题。瓦片不能直接用file://协议打开页面去加载。因为浏览器的同源策略会把本地文件与 HTTP 页面当成不同源而且直接读本地文件也没有并发限制和缓存控制。正确做法是通过本地 HTTP 服务映射瓦片目录。如果项目里已经有 Nginx可以直接加一段静态资源配置server { listen 8080; server_name 127.0.0.1; root /data/offline-tiles; location /tiles/ { add_header Access-Control-Allow-Origin *; try_files $uri /tiles/blank.png; } }没有 Nginx 的环境开发调试时也可以用 Python 临时顶一下cd /data/offline-tiles python3 -m http.server 8080再补充一个容易被忽略的点OpenLayers 库本身也要离线化。到官网下载对应版本的发行包把ol.js和ol.css放到业务系统的静态目录页面里引用本地路径。否则瓦片离线了地图框架却还在依赖外网 CDN网一断一样白屏。3.2 初始化地图与 XYZ 图层配置基础地图初始化代码并不复杂。以下是以谷歌离线瓦片为底图的示例import Map from ol/Map; import View from ol/View; import TileLayer from ol/layer/Tile; import XYZ from ol/source/XYZ; import { fromLonLat } from ol/proj; const googleLayer new TileLayer({ source: new XYZ({ url: /tiles/google/{z}/{x}/{y}.png, projection: EPSG:3857, crossOrigin: anonymous }) }); const map new Map({ target: map, layers: [googleLayer], view: new View({ center: fromLonLat([120.0, 30.0]), // 示例点位生产环境替换为项目实际坐标 zoom: 12, maxZoom: 18 }) });projection默认就是 EPSG:3857OpenLayers 里绝大多数非标准情况都需要关注这个值。高德和谷歌的在线瓦片下载后按标准 XYZ 目录存放加载配置基本和这个一样只需要把 URL 改成对应底图目录。真正麻烦的是百度瓦片和坐标系。3.3 高德、百度瓦片的特殊处理高德瓦片如果导出的目录是标准 XYZ加载代码几乎不用改const amapLayer new TileLayer({ source: new XYZ({ url: /tiles/amap/{z}/{x}/{y}.png }) });但要注意高德瓦片本身基于 GCJ-02 加密坐标系。地图瓦片里的道路、建筑位置是经过偏移后的结果你如果直接用 GPS 的 WGS84 坐标往里叠加点会发现所有点都往某个方向漂了几百米。这个问题不是 OpenLayers 的 bug而是坐标体系不统一。百度瓦片则需要特殊处理 y 轴方向。OpenLayers 的 XYZ 图层默认按左上角原点计算瓦片坐标而百度目录里的 y 是从下往上递增的。加载百度瓦片时需要写一个自定义 URL 函数const baiduLayer new TileLayer({ source: new XYZ({ tileUrlFunction: function (tileCoord) { const z tileCoord[0]; const x tileCoord[1]; const y Math.pow(2, z) - tileCoord[2] - 1; return /tiles/baidu/${z}/${x}/${y}.png; } }) });这段代码的意思是把 OpenLayers 计算出来的左上角原点 y 值转换成百度左下角原点的 y 值。这里有个前提下载工具导出的百度瓦片目录是按百度原始坐标编号保存的。如果工具导出时已经帮你反转成了 XYZ 编号那就不需要再反转否则会重复反转导致错乱。拿到离线包后最好先抽查几个级别的瓦片路径确认一下到底是哪种规则。3.4 坐标系统一三个坐标系必须分清楚做离线地图最容易翻车的就是坐标系。网上很多教程直接忽略这个问题最后结果就是地图能显示业务点全飘在外面。需要分清楚的三种坐标系WGS84GPS、手机定位、遥感影像等常用地理坐标系GCJ-02高德地图、腾讯地图对外使用的加密坐标系BD-09百度地图在 GCJ-02 基础上再次偏移后的坐标系。它们的偏差并不是固定的十几米而是随经纬度变化的通常在几十米到几百米之间。正确做法是选择哪个底图项目里的业务数据就统一转换到哪个坐标系然后继续使用 OpenLayers 的 EPSG:3857 做前端显示。举三个具体场景底图用谷歌离线瓦片业务数据来自 GPS 或普通经纬度那数据不需要转换直接用 WGS84 转 Web Mercator 后叠加底图用高德离线瓦片业务数据来自高德 API那也不需要转因为数据已经是 GCJ-02瓦片和高德坐标一致底图用百度离线瓦片业务数据来自高德 API那需要先把 GCJ-02 转成 BD-09再叠加到百度瓦片上。千万不要试图在 OpenLayers 里通过自定义投影的方式“纠正”瓦片偏移。瓦片本身是栅格图片图片里的内容已经固定了加密后的位置改投影只能改变网格划分不能纠正内容偏移。正确做法永远是在业务数据侧做坐标转换。4. 常见问题与排查技巧实录4.1 瓦片 404、灰块、错位先查目录而不是查代码我踩过最无语的坑是瓦片加载出来一半灰块另一半正常。检查了一下午代码最后发现是下载工具导出时把部分目录命名成了10/123另一部分却多了一层10/123/123.png大小写和扩展名也没有完全统一。OpenLayers 的{z}/{x}/{y}.png是严格匹配路径的任何一层对不上就是 404。遇到这种情况第一步打开浏览器开发者工具的 Network 面板看请求的实际 URL。如果 URL 路径和磁盘上的目录结构完全一致但依然 404再看大小写、文件后缀、空白瓦片。如果 URL 路径本身已经错位比如 y 值明显很大那就是瓦片编号规则不对需要检查是否要反转 y或者下载工具是不是导出了 TMS 格式。还有一类“地图能显示但位置错乱”的问题其实是缩放级别不匹配。比如离线包只下载到 14 级但前端把maxZoom设置成了 18用户一放大到 15 级以上地图就只能显示空白或重复拉伸。离线瓦片包的下载级别范围必须和前端 View 的minZoom、maxZoom对齐。4.2 底图偏移几十米到几百米坐标系没有对齐偏移问题是最容易误判的。如果你发现地图显示正常但叠加的业务点整体往某个方向偏移而且偏移量在几百米左右那基本可以断定是坐标系冲突。比如用 WGS84 的点去叠高德瓦片或者用 GCJ-02 的点去叠谷歌瓦片都会出现这种情况。排查方法很简单找一个你已知准确坐标的参照点比如项目区域内的某个路口分别用三种方式定位在谷歌底图上定位一次在高德底图上定位一次在百度底图上定位一次。看哪个底图上点位和现场位置一致然后确认项目业务数据背后的坐标体系。如果业务数据已经大量存在没法全部重采那就写一个坐标转换模块统一在数据入库或读取时候转换。这里有个经验业务系统的坐标体系最好在项目启动时就定死不要“到时候再说”。后面数据一多坐标转换的边际成本会越来越高。坐标转换函数不要自己造轮子用成熟的开源坐标转换库输入输出都测试通过再上线。4.3 离线包体积爆炸控制瓦片范围与级别离线地图对范围非常敏感。我曾给一个大区域的系统做离线包下了 16 级卫星图结果硬盘直接吃掉了 200 多 GBNginx 一响应瓦片请求磁盘 IO 就满了。后来把范围裁剪到项目实际业务区卫星图级别降到 14路网图保留 16整体体积直接降到 12 GB。如果确实需要覆盖很大范围建议按“瓦片裁剪”思路处理。下载工具支持画多边形范围时尽量画准确的业务边界不要拉一个巨大的矩形。矩形范围会把大量无用道路、山体、农田都下载下来。PNG 瓦片的体积还和地图内容相关密集城区比乡村地区大很多。追求极致压缩时可以把瓦片从 PNG 转成 WebP但要看地图渲染和浏览器兼容性。另一个思路是直接用透明 PNG 做空白占位避免大量无内容的 404 请求拖慢加载速度。4.4 瓦片更新后不生效缓存与版本策略离线瓦片不是永远不变。道路新建、建筑拆迁、地名变更都会导致底图过时。更新瓦片的方式通常是重新下载对应范围和层级的瓦片覆盖到原目录。但这时候很容易出现一个现象明明磁盘文件已经更新了浏览器里却还是旧地图。原因是浏览器对同 URL 的图片做了强缓存。解决办法有两个一是在瓦片 URL 上加版本号比如/tiles/google/{z}/{x}/{y}.png?v20250201版本号一变浏览器会认为这是新资源二是在 Nginx 层面对瓦片目录禁用长缓存设置较短的Cache-Control。如果更新频率不高用版本号更简单直接如果瓦片经常更新就配置 Nginx 的缓存策略。另外瓦片目录更新时最好保持原子性不要在一个地图正在被大量访问的时候就删掉旧目录。先把新瓦片放到临时目录校验完整后再整体替换否则地图访问会出现大量 404。5. 离线地图扩展从单项目到基础设施5.1 瓦片服务独立拆分做完第一个离线地图项目后我发现这种能力完全可以抽出来做成公共基础设施。瓦片库本身是纯静态文件不依赖任何业务服务把 Nginx 配成一个独立的瓦片服务端口多个业务系统都能复用。前端只要配置一个/tiles/路径业务代码里连地图源细节都不用关心。独立瓦片服务的好处是权限控制、缓存策略、流量监控都可以统一管理。比如内网大屏项目可能同时有几十个页面在请求瓦片如果每个系统都自己存一份瓦片既浪费磁盘又难维护。拆分成独立服务后瓦片只维护一份其他系统通过 HTTP 访问即可。5.2 多项目复用的底图切换设计我在实践里会写一个简单的底图配置模块通过环境变量或配置文件控制当前项目默认使用哪套底图const MAP_SOURCE google; // 可选 google / amap / baidu const sourceMap { google: () new XYZ({ url: /tiles/google/{z}/{x}/{y}.png }), amap: () new XYZ({ url: /tiles/amap/{z}/{x}/{y}.png }), baidu: () new XYZ({ tileUrlFunction: function (tileCoord) { const z tileCoord[0]; const x tileCoord[1]; const y Math.pow(2, z) - tileCoord[2] - 1; return /tiles/baidu/${z}/${x}/${y}.png; } }) };切换底图时顺便把业务数据的坐标转换函数也一起切换。这样新项目接入时几乎不用改地图逻辑只要配好底图类型和数据坐标系即可。这里建议在前端做一层坐标转换封装不要在业务组件里到处写转换代码否则后期维护会非常痛苦。5.3 更进一步矢量瓦片与离线搜索栅格离线瓦片已经能解决 90% 的底图展示问题但体积和清晰度始终是短板。现在我更看好矢量瓦片方案。矢量瓦片存储的不是渲染好的图片而是道路、建筑、标注等几何数据前端渲染时再根据样式生成画面。同样范围下矢量瓦片体积通常比栅格瓦片小一个数量级而且放大不模糊。但矢量瓦片不是拿来就能用的需要准备 MVT 格式的数据源还要处理样式和字体库技术门槛比栅格方案高不少。如果你的项目对地图体积和流畅度要求极高可以考虑这个方向如果只是要快速交付一个内网大屏栅格离线方案依然是最稳的选择。最后说点实际操作中的体会这套离线地图方案我在真实项目里跑了一年多最大的感受是地图加载本身并不难难的是在项目最开始就定好“底图选谁、坐标用什么体系、离线包多大”。只要这三个问题想在前面后面大概率顺风顺水想不清楚后面光是处理偏移和错位就能磨掉好几天。另外离线地图不是把瓦片下完就一劳永逸道路和建筑变化之后记得更新数据包。更新时一定要用版本管理不然用户浏览器里的旧缓存会让你白忙一场。如果时间允许我还建议你做一个简单的离线包体检脚本定期检查瓦片目录的缺失率、文件大小异常和空白瓦片比例。很多问题在数据量小的时候暴露不出来数据一上 TB任何目录规则不一致都会被放大成严重的加载事故。选 OpenLayers 做离线底图方案只要前期的数据组织这一段做扎实后面基本可以安心交付。