1. 高德地图瓦片的内网加载思路先弄清楚问题卡在哪1.1 瓦片地图的基本原理做内网 GIS 项目的人十有八九被地图瓦片折腾过。你页面上嵌入的高德地图本质上不是一次性加载一张完整的城市地图而是把全世界的地图切成无数个小方格浏览器只请求当前屏幕范围内、当前缩放级别下需要的那些 256x256 的小图。这种小图就是瓦片每一张都由三个数字决定缩放级别 z列号 x行号 y。平时联调比较顺利是因为浏览器能直接访问高德的瓦片服务器。一旦系统要部署到隔离内网机房只给了几个固定 IP外网请求全部被防火墙拦住地图页面的 JS 文件还在瓦片却一张都拉不回来。最常见的表现就是地图控件正常、缩放按钮能用但主区域一片灰底偶尔还有几个残破的半截图。所以内网加载高德地图瓦片核心就两件事第一把需要的瓦片提前下载到本地第二在内网环境里提供一个和浏览器直接对话的静态资源服务把瓦片按原来的路径规则吐给前端。两条做好页面里那句L.tileLayer或高德 JS API 的地图请求就全都落在内网不依赖任何公网资源。1.2 高德瓦片的基础约定先看一个高德 Web 端常用的瓦片地址模板https://webrd0{1-4}.is.autonavi.com/appmaptile?langzh_cnsize1scale1style8x{x}y{y}z{z}这里面{s}对应 1 到 4 四个子域名z/x/y就是要请求的瓦片编号。高德瓦片采用的是互联网地图通用的 XYZ 规范坐标系原点在左上角y 轴向下递增缩放级别 z 从 0 开始。这个规则和 OpenStreetMap 的瓦片编号基本一致和 TMS 的 y 轴正好相反。做离线下载时最省心的思路就是把z/x/y一个个算出来拼成 URL 去请求下载后按同样的目录层级存好内网服务器再按原路径提供访问。这里有一个特别容易踩的坑高德地图用的是 GCJ-02 加偏坐标系也就是大家常说的火星坐标系。如果你手里拿到的边界经纬度是 WGS-84 标准坐标直接去算瓦片编号下载下来的地图范围会整体偏移轻则边界对不上重则图层漂移。正确做法是先把 WGS-84 坐标转成 GCJ-02再参与瓦片编号计算。实际项目里很多人下载完发现地图和标注错位第一反应是 Leaflet 配错了其实往往是这一步漏掉了。1.3 内网加载的两条路线在线缓存与离线预取在动手下载之前先确认业务场景属于哪一类。内网加载瓦片不是只有全部下载这一条路很多时候还有更轻量的做法。在线缓存方案内网机器本身能访问公网但带宽有限、不想让大量瓦片请求直接穿透出去或者希望下次访问更快。思路是在内网起一个静态文件服务第一次请求某张瓦片时从高德拉回来并落盘后面再有相同请求直接读本地文件。这类方案适合访问范围可控、操作主要集中在某个城市核心区的系统缺点是实现起来要维护一套缓存逻辑而且首次访问还是会缓慢。离线预取方案内网完全隔离连公网都不通或者不允许运行时访问外部地图服务。这种情况没有捷径必须把可能用到的行政区范围、缩放级别范围内的瓦片全部下载提前部署到内网。这也是本文重点展开的方案。判断标准其实很直接先问现场网络能不能访问高德域名。不能访问就直接做离线预取能访问再考虑是加一层缓存还是全量下载。不要一上来就花几个小时下载几十 GB 瓦片结果业务只用到某个区和十个缩放级别浪费磁盘也浪费带宽。1.4 先算一笔账范围和缩放级别决定资源量很多人下载瓦片时有个惯性思维下载得越多越全越好。实际操作里这种想法会立刻被磁盘空间和下载时间教育。瓦片数量随缩放级别指数增长级别每加一瓦片数大概翻四倍。一个地级市在 10 到 14 级的数据量和 10 到 16 级的数据量完全不是一个量级。举个实际例子以北京中心城区边界范围估算按照上面说的高德瓦片公式各个缩放级别的瓦片数量大致如下缩放级别x 方向瓦片数y 方向瓦片数瓦片总数约1062112611114145112218117011341161660114813212600115161641103201163211281411201只看 10 到 14 级瓦片总数大约 3.5 万张假设平均一张 20 KB总数据量约 700 MB。如果一路下载到 16 级总量直接冲到 55 万张按同样的单张大小就是 10 GB 以上。很多内网系统实际上把最大缩放级别控制在 16 或 17日常使用到 15 级已经够细了。所以下载前先定两个参数行政边界范围和最高缩放级别能省下大量时间。2. 地图瓦片资源下载工具选型与脚本方案2.1 现成工具MOBAC 和地图下载器如果只是临时做一个内网演示不想写代码可以先用现成工具。Mobile Atlas Creator也就是 MOBAC是一个比较老牌的开源离线地图工具支持框选范围、选择缩放级别、批量下载最后导出成文件夹、SQLite 或 MBTiles 格式。它默认带了 OpenStreetMap、OpenTopoMap 等源用高德需要自己配置 XML 格式的自定义源把高德瓦片 URL 模板填进去。但这项目更新慢遇到高德改 URL 参数或者请求头校验经常要调试半天。更省事的是商业地图下载器比如水经注、BigeMap 这类工具。它们的好处是能选行政区边界边界信息内置在软件里还支持设置下载级别、并发数量、是否包含卫星图下载成果直接能生成瓦片目录或 MBTiles。缺点是部分功能收费大范围下载时对网络要求高卡死中断的情况我也遇到过。如果公司或项目预算允许买一个处理一次性任务很划算但如果是长期维护的产品系统我不太建议把下载链路挂在一个闭源工具上出问题没法自己排查。不管用哪种工具都要注意一点高德地图数据本身受服务条款约束批量下载瓦片用于内网并不是随意copy的正当理由。公司内部测试、风险评估、已获得授权的前提下使用是可以讨论的但不建议把下载的瓦片直接用于公开商业产品或二次分发。做技术分享归技术分享合规的弦还是要绷着。2.2 自己写 Python 脚本坐标转瓦片编号自己写脚本最大的好处是可控。你清楚每张瓦片从哪来、放哪里、怎么校验换一个城市只需要改一下边界范围。整个脚本核心其实就是一个坐标换算函数和一套文件下载函数。瓦片编号的计算公式如下n 2^z x floor((lon 180) / 360 * n) y floor((1 - ln(tan(lat_rad) 1 / cos(lat_rad)) / π) / 2 * n)其中lat_rad是纬度对应的弧度。这个公式是 Web 墨卡托投影下的标准 XYZ 瓦片算法高德瓦片与它兼容。实现出来就是下面这段 Pythonimport math import os import time import requests from concurrent.futures import ThreadPoolExecutor, as_completed TILE_URL ( https://webrd0{s}.is.autonavi.com/appmaptile ?langzh_cnsize1scale1style8x{x}y{y}z{z} ) SUBDOMAINS [1, 2, 3, 4] HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) } def lon_to_tile_x(lon, z): n 2 ** z return int((lon 180.0) / 360.0 * n) def lat_to_tile_y(lat, z): n 2 ** z lat_rad math.radians(lat) return int((1.0 - math.log(math.tan(lat_rad) 1 / math.cos(lat_rad)) / math.pi) / 2.0 * n) def get_tile_range(bbox, z): min_lon, min_lat, max_lon, max_lat bbox x_min lon_to_tile_x(min_lon, z) x_max lon_to_tile_x(max_lon, z) # 纬度越高y 值越小这里取上下边界 y_min lat_to_tile_y(max_lat, z) y_max lat_to_tile_y(min_lat, z) return x_min, x_max, y_min, y_max def download_tile(session, z, x, y, out_root): out_path os.path.join(out_root, str(z), str(x), f{y}.jpg) if os.path.exists(out_path) and os.path.getsize(out_path) 1024: return True sub SUBDOMAINS[(x y z) % len(SUBDOMAINS)] url TILE_URL.replace({s}, sub) url url.replace({x}, str(x)).replace({y}, str(y)).replace({z}, str(z)) for attempt in range(3): try: resp session.get(url, headersHEADERS, timeout10) if resp.status_code 200 and len(resp.content) 1024: os.makedirs(os.path.dirname(out_path), exist_okTrue) with open(out_path, wb) as fp: fp.write(resp.content) return True except Exception: pass time.sleep(0.5 * (attempt 1)) return False这段代码里我默认瓦片保存成 JPG 格式。高德的普通道路图通常返回 JPEG卫星图也是 JPEG但如果你抓包发现某些图层返回的是 PNG就把文件后缀和前端 URL 模板里的后缀一起改掉。2.3 计算瓦片范围并启动多线程下载有了上面的基础函数下载主流程就很简单了定义城市边界确定缩放级别列表遍历所有 z 值算出每个级别下的 x、y 范围然后逐张下载。BBOX [115.7, 39.4, 117.5, 41.1] # 经度范围、纬度范围 ZOOMS range(10, 15) # 下载 10 到 14 级 OUT_ROOT /data/tiles def main(): failed 0 # 每个线程使用独立 Session避免连接池争用 workers 8 def run_one(z, x, y): sess requests.Session() return download_tile(sess, z, x, y, OUT_ROOT) with ThreadPoolExecutor(max_workersworkers) as executor: futures [] for z in ZOOMS: x_min, x_max, y_min, y_max get_tile_range(BBOX, z) for x in range(x_min, x_max 1): for y in range(y_min, y_max 1): futures.append(executor.submit(run_one, z, x, y)) for fut in as_completed(futures): if not fut.result(): failed 1 print(download finished, failed:, failed) if __name__ __main__: main()这里有个经验并发数别开太大。之前我试过 32 个线程同时拉最开始速度确实快跑了十几分钟对面直接开始拒连接返回 403。高德虽然不会把普通大众用户识别成恶意爬虫但短时间大量请求依然会触发临时限制优先级是下载完整度大于下载速度。8 个线程是我实测比较平衡的值配合每张瓦片之间简单的延时不容易被限制。另外requests.Session在多线程场景下不建议直接共享。它内部有连接池多个线程同时使用并不是官方推荐用法容易出条件竞争。最简单的处理是每次下载时新建一个 Session虽然有一点连接建立开销但可靠且代码好理解。3. 下载脚本的完整设计与优化细节3.1 行政边界如何取、如何外扩城市边界从哪来是个现实问题。最粗暴的办法是打开在线地图手动框一个矩形。适合快速验证但会出现一个问题矩形边界往往把周边县市也带进来了数据量变大反过来如果恰好卡在城区边缘真正用到的区域反而缺了一块。更合理的做法是找一个行政区的 GeoJSON 边界数据然后用边界外包矩形再加上一层缓冲。缓冲距离通常取 0.05 到 0.1 个经度/纬度目的就是防止道路、地标刚好落在瓦片边缘时出现白边。实际操作里下载完瓦片以后地图边缘偶尔会有到边界就断掉的观感这就是因为你按精确边界下载没有留余量。多 0.1 度看起来范围变化不大但用户使用体验差别很明显。拿到边界或外包矩形之后还要考虑坐标系问题。如果你下载的是 GeoJSON 标准数据坐标通常是 WGS-84需要先转成 GCJ-02 再参与计算。如果你手里直接有一份基于高德坐标系的边界那就可以跳过这一步。3.2 单线程还是多线程下载限速与失败重试批量下载瓦片本质上是一个可控的网络爬虫但你要控制好频率。我从实践里总结出的参数是这样8 个线程每个线程在下载完一张瓦片后 sleep 0.05 到 0.1 秒遇到超时或 5xx 错误后指数退避第一次等 0.5 秒第二次等 1 秒第三次等 2 秒同一张瓦片最多重试三次。这样做的好处是偶发的网络抖动不会立刻导致整批失败频繁重试也不会把自己打成攻击者。还有一点图片下载完整度不能只靠status_code 200判断。高德偶尔会返回一个很小的占位图或者错误页HTTP 状态码也是 200。我在校验时加了一个条件文件字节数必须大于 1024 才认为有效。如果你下载卫星图占位图可能更小也可以把阈值改到 2048 或者 4096具体看你抓包看到的结果。最稳妥的是用 Python 读取图片头判断文件真正是 JPEG 或 PNG但内网瓦片下载这种场景文件大小已经够用。可以在下载结束后跑一遍扫描脚本找出空文件和小文件import os def scan_tiles(root, min_size1024): bad_files [] for z_name in os.listdir(root): z_path os.path.join(root, z_name) if not os.path.isdir(z_path): continue for x_name in os.listdir(z_path): x_path os.path.join(z_path, x_name) if not os.path.isdir(x_path): continue for y_name in os.listdir(x_path): full os.path.join(x_path, y_name) if os.path.getsize(full) min_size: bad_files.append(full) return bad_files拿到列表后可以重新跑一遍下载脚本因为脚本开头会判断文件是否存在且大于阈值已经下载成功的不重复请求效率很高。3.3 瓦片目录结构的内外一致性下载完成后的目录结构非常重要因为它直接决定 Nginx 配置怎么写前端 URL 模板怎么填。推荐按z/x/y.jpg的方式组织也就是/data/tiles ├── 10 │ ├── 841 │ │ ├── 108.jpg │ │ ├── 109.jpg │ │ └── ... │ └── 842 │ └── ... ├── 11 │ └── ... └── 14 └── ...这种目录结构和高德 URL 里的{z}/{x}/{y}是一一对应的Nginx 可以直接用 root 指向/data/tiles前端请求http://内网IP:8080/12/21/81.jpg时实际命中的就是/data/tiles/12/21/81.jpg。不要自己发明目录规则不然 Leaflet 的 URL 模板和你的文件路径对不上排错会非常痛苦。4. 内网瓦片服务器搭建与前端接入4.1 用 Nginx 起一个瓦片静态服务瓦片下载完之后内网加载就变成一个普通的静态文件服务问题了。Nginx 处理这种场景几乎是零成本。写一个非常简单的配置server { listen 8080; server_name _; root /data/tiles; location / { add_header Access-Control-Allow-Origin *; expires 7d; access_log off; } }这个配置没有做任何动态处理纯粹是把z/x/y路径映射到磁盘文件。expires 7d让浏览器把瓦片缓存一个星期内网用户重复缩放地图时大部分请求直接走浏览器缓存能大幅减轻服务器压力。CORS 头这里我单独解释一下。如果你的地图页面和瓦片服务是同一个域名和端口其实不用跨域加了也无所谓。但很多内网系统地图页面跑在 3000 端口或 8081 端口瓦片服务跑在 8080浏览器从不同端口请求图片就属于跨域。Nginx 上加一行add_header Access-Control-Allow-Origin *是最省事的解决办法。如果前端设置了更严格的跨域策略就改成你允许的具体域名。4.2 Leaflet 接入内网瓦片Leaflet 接入自定义瓦片源非常简单只需要把 URL 模板指向内网地址const map L.map(map).setView([39.9042, 116.4074], 12); L.tileLayer(http://192.168.5.10:8080/{z}/{x}/{y}.jpg, { minZoom: 10, maxZoom: 14, tileSize: 256, attribution: Internal Tile Service }).addTo(map);这里有一个常见配置陷阱如果下载时使用的 scale 参数不是 1而是 2返回的瓦片实际尺寸可能是 512x512此时 Leaflet 的tileSize必须对应设置或者用高德 JS API 的瓦片配置设置图片尺寸和tileSize一致。否则地图会出现瓦片重叠、错位或者拉伸。普通内网应用建议统一使用scale1图片 256x256前端tileSize保持默认 256最不容易出问题。另外建议在前端限制minZoom和maxZoom。如果你只下载了 10 到 14 级但 Leaflet 的maxZoom默认是 18用户缩放到 15 级时请求不到瓦片只有灰底显示很容易被当成系统 bug。这一步虽然不起眼但我在交付项目时至少碰到过三次上线后有人来回切换缩放级别然后来问我地图怎么白了。4.3 OpenLayers 和小程序接入的不同点如果是 OpenLayers 项目接入方式和 Leaflet 类似new ol.layer.Tile({ source: new ol.source.XYZ({ url: http://192.168.5.10:8080/{z}/{x}/{y}.jpg, maxZoom: 14, minZoom: 10 }) })OpenLayers 的好处是内置了对瓦片缩放级别的精细控制但如果你的瓦片目录里缺少某几级处理起来一样会请求失败。小程序的情况比较特殊。微信小程序自带的地图组件不支持自定义瓦片 URL你只能使用腾讯地图或高德小程序的在线数据。如果内网环境要求完全离线小程序原生的 map 组件基本没法直接用离线瓦片。我见过两种可行路径一种是把地图页面做成 H5放在 WebView 里用 Leaflet 或 OpenLayers 加载内网瓦片另一种是自己在 Canvas 上实现一套简化地图渲染只显示点、线、面不加载复杂底图。前者实现成本低适合大多数项目后者适合需求简单、只展示业务点位的小程序界面。4.4 哪些图层离线得了哪些图层离线不了这里要特别说明高德在线地图里能看到的东西不全是瓦片。普通道路底图、卫星图、部分标注图层本质上是静态图片瓦片可以下载。但实时路况、红绿灯倒计时、实时公交位置、天气雷达动态效果这类数据属于动态图层靠的是接口实时返回不是预先渲染好的图片。这类功能在完全离线环境下做不了本地静态化只能靠业务系统自己接数据源或者和内网里的数据服务对接再叠到地图上。我见过一些项目在做离线化方案时把下载瓦片误当成把整个高德离线掉结果部署后发现路况不显示了才意识到这是另一个层面的问题。提前在需求阶段分清哪些是底图数据、哪些是动态业务数据能省掉很多返工。5. 常见问题与排错实录5.1 瓦片间出现缝隙怎么办Leaflet 在 Chrome 浏览器里偶尔会出现瓦片和瓦片之间有一条细缝视觉上就是地图像瓷砖一样拼不严。这个现象最常见的原因是图片作为内联元素带来的默认间距尤其当你自定义过 CSS 或者页面里有一些全局样式覆盖时容易出现。最直接的办法是强制重置瓦片图层的 CSS.leaflet-tile { margin: 0; padding: 0; border: none; }如果加了 CSS 还是有缝那就去检查瓦片尺寸。假设你下载出来的瓦片实际是 512x512但 Leaflet 配置的tileSize是 256浏览器缩放时拉伸计算会出现亚像素误差缝隙就会非常明显。这种问题优先改配置不要靠 CSS 死磕。还有一个容易被忽略的点如果下载时 URL 里有scale2而前端模板没有对应调整也会出现缝隙因为浏览器拿到大图和网格尺寸不匹配。5.2 下载到一堆空瓦片或 403批量下载瓦片时最烦的是一批请求返回 403。403 通常是请求头被识别成低可信客户端比如没有带 User-Agent或者拼 URL 时子域名写错。高德的子域名是webrd01到webrd04不是webrd0就能通配替换{s}时一定要把整段webrd0{s}一起替换否则域名可能不存在。返回空白小图是另一种情况。我之前下载某个郊县区域发现很多瓦片文件只有几百字节打开看要么是纯色占位图要么是请求频率过高后触发的限流回应。解决办法就是扫描小文件重新下载同时把线程数降低。还有一种可能是缩放级别超过高德实际提供的范围比如下载 17 级以上某些区域压根没有图返回的就是空白占位图。确认你规划的缩放级别是否在你需要的区域内真的存在不要只看理论最大级别。我处理这种场景的排错顺序是先单独挑一张失败的瓦片 URL 放进浏览器打开确认 URL 本身能不能出图能出图说明脚本请求头或者参数替换有问题不能出图说明源站确实有问题或边界越界。这个单点验证能快速把问题定位到特定环节比瞎调参数高效得多。5.3 内网页面白屏、加载不出瓦片内网加载瓦片白屏首先要区分是前端脚本问题还是瓦片请求问题。打开浏览器开发者工具看 Network 面板里瓦片请求的状态。如果请求根本没发生多半是前端地图初始化失败比如高德或 Leaflet 的 JS 文件没引对或者初始化时的经度纬度设置有问题。如果请求发出了但状态是 404说明你的 URL 模板和目录结构对不上去检查 Nginx root 和文件路径。如果请求状态是 200但地图不显示检查文件格式。有些工具下载时会把图片保存成.png但内容其实是 JPEG或者反过来前端 URL 模板和实际文件后缀不一致。Nginx 其实不关心后缀但浏览器会根据 Content-Type 和文件内容判断图片能否解码。建议下载一张瓦片用file命令看一下实际格式file /data/tiles/12/21/81.jpg如果输出显示 PNG说明你保存的文件后缀和内容格式不匹配需要统一改成.png或者在代码里做内容探测后再定后缀。5.4 磁盘空间和并发数量怎么平衡全量下载一个大城市的瓦片数据量很容易冲到 10 GB 以上。部署前要确认内网服务器磁盘有富余空间同时考虑地图更新频率。内网地图不是一劳永逸的道路、建筑、行政区划会变隔几个月可能就需要增量更新。增量更新时没必要全量重下只需要把对应缩放级别和新增区域重新下载覆盖即可这也是我推荐自建脚本而不是依赖某些工具的一个重要原因。并发数量的平衡逻辑很简单低并发慢但稳高并发快但容易被限流。内网项目对下载时间通常没有那么苛刻完全可以用 4 到 8 线程慢慢跑几个小时。如果覆盖范围大可以按缩放级别分批次下载比如先下 10 到 12 级部署上去让系统先跑起来再在后台补 13 到 14 级。这样用户至少能看到完整轮廓而不是等一个 10 GB 的下载任务全部跑完才能演示。5.5 高德瓦片请求被浏览器跨域拦截有一种情况是瓦片服务正常、文件路径也对但浏览器控制台出现跨域错误。内网系统常见部署是前端在 80 端口瓦片在 8080 端口跨域就发生了。解决方案还是那句话在 Nginx 的 server 块里加上 CORS 响应头。如果你使用的是 Nginx 外的静态服务比如 Python 的http.server它也支持自定义头但配置起来不如 Nginx 直观。稳妥的方法是前端页面和瓦片服务尽量放在同一个域名端口下或者统一由 Nginx 托管按路径区分/map/和/tiles/。6. 一些实践后的经验补充6.1 关于合规和边界不是能下载就一定能用关于瓦片数据的使用合规我在前面提过一次这里还是想再说清楚。高德地图瓦片是高德提供的在线地图服务内容批量下载后放到内网本质上绕过了它原有的访问控制通道。个人学习和内部技术验证通常不会有人追究但商业项目、对外发布、或者把下载的数据二次分发风险很高。比较规范的做法是如果项目确实有离线地图需求优先咨询高德官方是否存在可授权的离线部署方案或者使用开源社区维护的合规地图数据源。技术上是另一个问题但先把合规边界摸清楚项目才不会做到一半翻车。6.2 一个小技巧地图缩放到边缘时不要露馅内网系统交付时我习惯把地图初始缩放级别设在下载范围的中部比如你下载了 10 到 14 级就把默认视角放在 12 级同时把 Leaflet 的minZoom和maxZoom限制死。这样用户打开页面第一眼看到的一定是完整地图而不是边界上的空白。很多人只记得调maxZoom忘了限制minZoom结果用户缩到全国范围底色全灰第一印象就崩了。这个细节很小但影响很直接。瓦片下载和内网部署的链路说到底就是目录结构对齐 URL 规则这件事。只要把坐标换算、文件下载、静态服务、前端模板这几个环节对齐了换到哪个城市、换成哪种地图源都是同一套思路。