去年有个项目让我头疼了整整一周。客户的内网系统里要嵌入一张城市地图,页面早就做好,地图容器也画出来了,但打开之后整张底图全是灰的,连一条道路都看不见。我当时第一反应是JS代码报错,查了半小时也没查出问题。后来打开开发者工具才发现,浏览器一直在向高德的瓦片服务器发请求,全部超时失败。原因很直白:内网访问不了外网,而地图底图本身就是一张张动态请求下来的瓦片图片。
这次我不打算只贴一段下载代码,而是把“高德地图瓦片”从URL规则、下载器编写、内网服务搭建到前端接入这条链路完整拆开讲。读完之后你至少能解决三件事:搞懂高德瓦片URL的规律,写一个能断点续传的瓦片下载器,把瓦片用Nginx托管起来并让Leaflet或高德JS API正常加载。适合正在做内网GIS、数据大屏、离线地图项目的开发者参考。
1. 为什么内网里地图总是一片空白:瓦片加载机制与隔离网络困境
1.1 地图瓦片到底是什么
地图上那套连续光滑的画面,其实被切成了固定大小的小方块,每个小方块就是一张瓦片,最常见的规格是256x256像素,高清屏有512x512。所有瓦片按缩放级别组织:级别越高,地图范围越精细,瓦片数量越多;同一个级别下,瓦片按x(列号)和y(行号)排列,从左上角开始计数。
我经常用一个拼图比喻来解释:缩放级别是拼图的精细度,x和y是拼图块的行列坐标,z是整套拼图的页码。浏览器里的高德地图,本质上就是根据当前视野范围、中心点和缩放级别,动态计算需要哪几页拼图的哪些块,然后向服务器把对应图片一张张取回来,拼到屏幕对应的位置上。没有这些瓦片图片,底图就什么都画不出来。
1.2 高德JS API加载底图的完整链路
高德JS API在页面上初始化Map对象之后,会自动维护视图状态,包括中心点经纬度、缩放级别、DOM容器尺寸等。只要视图有变化,不管是拖动、缩放还是窗口尺寸变化,它都会基于瓦片编号算法算出当前视野覆盖的瓦片集合,生成一组图片URL,把图片元素插入到地图容器里。
这些URL指向的是高德的瓦片服务地址,结构大致如下:
https://webrd0{1-4}.is.autonavi.com/appmaptile?x={x}&y={y}&z={z}&lang=zh_cn&size=1&scale=1&style=8浏览器向这个地址发起HTTP请求,高德服务器返回对应的PNG图片。整个过程对前端使用者是透明的:你只看到地图“画”出来了,背后其实是几十张图片在快速加载和拼接。只要中间任何一条链路走不通,底图就会空白。内网环境最典型的问题就是域名解析不到外网,或者外网请求直接被防火墙拦截。
1.3 内网环境下的三种典型需求
我实际接触过的内网地图需求大概分三类,出发点各不相同,但最终都落到同一个技术动作上:
- 业务系统地图展示:内部管理平台需要在地图上叠加资产点位、人员轨迹、区域边界,底图必须放在内网,这是最基础的要求。
- 数据可视化大屏:大屏项目普遍追求高德风格的街道图或卫星图效果,但又不能在演示现场赌外网带宽,瓦片本地化成了唯一靠谱方案。
- 应急离线地图:一些现场环境网络状态不稳定,提前把目标区域瓦片下载到本地,现场直接用静态服务撑住整个演示,方便又省心。
这三类需求落到技术动作上完全一致:先把瓦片下载到内网,再让前端从内网加载。下面我从瓦片URL规则开始拆解。
2. 拆解高德瓦片URL规律:z/x/y与坐标系里藏着的门道
2.1 从浏览器开发者工具抓一个瓦片请求
写下载器之前,先得把瓦片URL的规律摸清楚。方法很简单:打开一个使用高德地图的网页,按F12切到Network面板,过滤Image类型请求,拖动一下地图,就能看到瓦片请求像瀑布一样刷下来。
一个典型的高德街道图瓦片请求是这样的:
https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x=227&y=95&z=8其中webrd01是瓦片服务器编号,高德做了负载均衡,webrd02、webrd03、webrd04也能取到同样的瓦片。z是缩放级别,x和y是瓦片行列号,style=8表示街道图,scale=1是普通分辨率,返回256x256;scale=2返回512x512,也就是高清瓦片。把这些参数拆清楚之后,下载器的URL模板就可以直接构造了。
2.2 经纬度与瓦片行列号的换算公式
瓦片行列号不是随机生成的,它基于Web Mercator投影(EPSG:3857)做了全球划分。把世界地图看成一个矩形平面,最左上角是(0,0),向右x增大,向下y增大。在缩放级别z下,全世界横向和纵向都分成2的z次方份,所以第z级总共有2^z乘以2^z张瓦片。
已知经纬度转瓦片行列号的公式,用Python可以写成这样:
import math def lonlat_to_tile(lon, lat, z): n = 2 ** z x = int((lon + 180.0) / 360.0 * n) rad = math.radians(lat) y = int((1.0 - math.asinh(math.tan(rad)) / math.pi) / 2.0 * n) return x, y这里asinh(tan(rad))和常用的ln(tan(rad) + 1/cos(rad))是等价的,只是写起来更简洁。Web Mercator在纵坐标上用了非线性变换,所以纬度越高,相邻瓦片代表的实际面积越小,这也解释了为什么高纬度地区在地图上看起来被明显放大了。
2.3 GCJ-02坐标系差异:为什么用WGS-84坐标算出来的瓦片会偏移
高德瓦片最容易踩的隐性坑就是坐标系。高德的坐标体系是GCJ-02,俗称火星坐标,而GPS设备、第三方矢量数据常用的却是WGS-84。瓦片行列号本身基于投影平面计算,但在高德体系内,这个平面坐标对应的经纬度基准是GCJ-02,不是WGS-84。
如果你拿着一批GPS采集的WGS-84经纬度坐标,直接套前面的公式算x/y去请求高德瓦片,边界框整体会偏移几百米甚至上千米。结果就是你想下载A区域的瓦片,实际拿到的却是A区域偏移后的范围。解决办法是先做WGS-84到GCJ-02的坐标转换,再参与行列号计算。如果项目里的坐标本来就来自高德JS API或者高德地图拾取器,那它们已经在GCJ-02体系内,直接用即可。
坐标转换不建议自己造轮子,用现成的coordtransform库,或者找一份成熟的七参数转换实现都可以。这里不展开,但它确实是整个链路里最容易出问题的一环,后面第5章我会再提排查思路。
2.4 瓦片规格:普通屏与高清屏的差异
高德瓦片接口的scale参数直接影响显示效果和存储成本。scale=1返回256x256,单张大约10-20KB;scale=2返回512x512,单张大约30-60KB。同样的范围,高清瓦片占用的存储空间通常翻三到四倍。
做普通PC端Web系统,scale=1完全够用。做数字大屏或者需要截图输出的场景,我建议直接用scale=2,否则画面放大后全是锯齿。关键点是:下载时用了什么scale,前端URL模板就要保持一致,混用会导致瓦片物理尺寸不一致,拼接出来非常难看。这个我在第5章的踩坑记录里还会再讲。
3. 写一个瓦片下载器:从边界框计算到并发断点续传
3.1 规划下载范围:用经纬度边界框确定瓦片数量
下载瓦片的第一步不是写循环,而是先明确下载范围。范围一般用经纬度矩形边界框描述,也就是最小经度、最小纬度、最大经度、最大纬度。缩放级别范围取决于业务,城区大屏项目通常15到17级就够,18级以上瓦片数量会暴涨,下载时间和磁盘占用都不划算。
我习惯先估算瓦片数量再动手。以北京城区大约1.5度经度跨度、1.2度纬度跨度为例,在17级下的数量大概是这样的:
| 缩放级别 | 2^z | 示例城市横向瓦片数 | 示例城市纵向瓦片数 | 总瓦片数(约) |
|---|---|---|---|---|
| 15 | 32768 | 137 | 218 | 2.9万 |
| 16 | 65536 | 273 | 437 | 11.9万 |
| 17 | 131072 | 546 | 874 | 47.7万 |
| 18 | 262144 | 1092 | 1748 | 190.9万 |
47万张256x256的PNG,按平均15KB算,大约7GB,这个量级在单机磁盘上可以接受。但如果盲目把18级也加进来,量级立刻变成一百多万张,存储和时间成本都高一个量级。先算账,再动手,能省掉很多沟通成本。
3.2 用边界框换算出瓦片行列号集合
有了边界框,就可以按级别生成行列号列表。这里有个新手容易搞反的地方:Web Mercator中纬度越大的地方y值越小,所以北边界的y数比南边界小。生成列表时,需要对y方向取最小到最大,不然范围会乱。
def tile_range(min_lon, min_lat, max_lon, max_lat, z): x0, y0 = lonlat_to_tile(min_lon, max_lat, z) # 左上角 x1, y1 = lonlat_to_tile(max_lon, min_lat, z) # 右下角 for x in range(x0, x1 + 1): for y in range(min(y0, y1), max(y0, y1) + 1): yield x, y这个生成器给出某一级别下的行列号集合。再套一层级别循环,就能得到完整任务列表。任务列表可以提前序列化到本地留档,方便后面断点续传时做对比,也方便你分级别分批次执行下载。
3.3 编写下载函数:单线程先跑通,把坑都踩清楚
我习惯先用单线程把一条链路跑通。这个版本不用考虑效率和优雅,重点是确定目录结构、请求头、文件命名这些基础约定。
import os import requests def download_tile(z, x, y, url_template, save_root): save_dir = os.path.join(save_root, str(z), str(x)) os.makedirs(save_dir, exist_ok=True) save_path = os.path.join(save_dir, f"{y}.png") url = url_template.format(z=z, x=x, y=y) resp = requests.get(url, timeout=10, headers={"User-Agent": "Mozilla/5.0"}) if resp.status_code == 200: with open(save_path, "wb") as f: f.write(resp.content)目录结构是save_root/{z}/{x}/{y}.png,这个结构正好对应Nginx静态服务的URL路径,前端模板直接拼{z}/{x}/{y}.png就能用。单线程跑通的好处是,能提前发现偶发超时、403、空文件这类问题,这些问题在并发场景里会被无限放大,后面排查起来更痛苦。
3.4 并发下载与失败重试
单线程跑太慢,几万张瓦片能跑到地老天荒。我一般用ThreadPoolExecutor开8个线程,再配合重试机制和403/429退避逻辑。高德瓦片服务器对单IP的请求频率有限制,8个线程实测比较安全,再往上就容易触发风控。
import time import requests from concurrent.futures import ThreadPoolExecutor, as_completed def download_tile_safe(z, x, y, url_template, save_root, retries=3): save_dir = os.path.join(save_root, str(z), str(x)) os.makedirs(save_dir, exist_ok=True) save_path = os.path.join(save_dir, f"{y}.png") if os.path.exists(save_path) and os.path.getsize(save_path) > 0: return True url = url_template.format(z=z, x=x, y=y) for attempt in range(retries): try: resp = requests.get(url, timeout=10, headers={ "User-Agent": "Mozilla/5.0", "Referer": "https://www.amap.com/" }) if resp.status_code == 200: with open(save_path, "wb") as f: f.write(resp.content) return True elif resp.status_code in (403, 429): time.sleep(2 * (attempt + 1)) else: time.sleep(0.5) except Exception: time.sleep(1) return False def run_download(tasks, url_template, save_root, workers=8): with ThreadPoolExecutor(max_workers=workers) as pool: futures = [pool.submit(download_tile_safe, z, x, y, url_template, save_root) for z, x, y in tasks] for future in as_completed(futures): future.result()有个细节务必注意:Referer头一定要带。瓦片服务器会校验请求来源,没有Referer的请求很容易直接403。退避间隔从2秒开始成倍增长,如果重试三次都失败,就把这条任务记到日志文件里,最后统一补下。
3.5 断点续传:中断了不用从头再来
几十万张瓦片的下载过程中,网络抖动、服务器断开都可能导致任务中断。断点续传的实现非常简单:下载前判断目标文件是否存在,存在且大小大于0就直接跳过。上面代码里第一段判断就是在做这件事。
这个设计带来的好处是:任务中断后重新跑一遍脚本,已完成的瓦片全部跳过,只补剩余部分。我还会把失败任务单独记录,全部跑完后统一用同样的脚本重跑一次,基本能把失败率压到极低。实测下来,50万张瓦片的一次性下载成功率大概在99.5%左右,剩下0.5%靠续传补上,整个流程很可靠。
4. 内网瓦片服务搭建:Nginx托管与前端接入
4.1 目录结构:让文件路径与URL一一对应
下载完的瓦片目录天然就是静态文件结构:
/data/map_tiles/ ├── 8/ │ ├── 226/ │ │ ├── 94.png │ │ └── 95.png │ └── 227/ │ ├── 94.png │ └── 95.png ├── 9/ │ └── ... └── 17/ └── ...HTTP请求/tiles/8/227/95.png可以直接映射到/data/map_tiles/8/227/95.png。只要Nginx配置里把URL前缀和磁盘路径对应好,不需要任何路由改写,前端模板地址就能直接命中物理文件。这个目录结构在下载器里就定好了,所以下载和托管之间不会出现路径断层的麻烦。
4.2 Nginx静态托管配置
我习惯单独开一个server块专门服务瓦片,不跟业务接口混在一起,避免日志刷屏和路径冲突。
server { listen 8080; server_name _; location /tiles/ { alias /data/map_tiles/; autoindex off; expires 30d; add_header Cache-Control "public, max-age=2592000"; try_files $uri =404; } }这里要特别注意alias和root的区别:用了alias /data/map_tiles/,URL里的/tiles/前缀会被去掉,剩下的路径直接对应到/data/map_tiles/下。如果误写成root /data/map_tiles/,Nginx会去找/data/map_tiles/tiles/8/227/95.png,路径多了一层tiles,立刻404。expires 30d让浏览器把瓦片缓存一个月,内网环境下第二次拖动地图会特别流畅。
4.3 Leaflet接入本地瓦片
内网项目如果不需要高德JS API的复杂交互能力,用Leaflet加载本地瓦片是最省事的路线。L.tileLayer原生支持URL模板,{z}、{x}、{y}三个变量会自动替换成请求时的行列号。
var map = L.map('map', { center: [39.909, 116.397], zoom: 15, minZoom: 8, maxZoom: 17 }); L.tileLayer('http://192.168.1.100:8080/tiles/{z}/{x}/{y}.png', { maxZoom: 17, minZoom: 8, attribution: 'Map data © AutoNavi' }).addTo(map);这里有个坐标基准问题:高德瓦片是GCJ-02体系,Leaflet本身并不关心坐标系,它只是把瓦片按位置贴到地图上。中心点必须用GCJ-02坐标才能和瓦片对齐,如果直接用GPS采集的WGS-84坐标,地图中心位置会出现偏移。在这个底图上叠加WGS-84的GPS轨迹或POI数据,同样要先把数据坐标转成GCJ-02再叠加,否则会整面偏移。
4.4 高德JS API的内网加载策略
有些项目必须保留高德JS API的交互体验,比如缩放控件、标记动画、轨迹播放能力。这种情况下,可以把高德JS API的JavaScript文件下载到内网,用内网地址引用,再通过自定义瓦片图层把底图URL指到内网。
高德JS API支持自定义AMap.TileLayer,核心是重写getTileUrl方法:
var localLayer = new AMap.TileLayer({ getTileUrl: function(x, y, z) { return 'http://192.168.1.100:8080/tiles/' + z + '/' + x + '/' + y + '.png'; }, zIndex: 100 }); var map = new AMap.Map('container', { center: [116.397, 39.909], zoom: 15, layers: [localLayer] });这样页面看起来还是高德地图,底图却完全走内网。需要提醒的是,高德JS API内网化要注意安全密钥和域名白名单的配置,这块取决于项目申请API Key时的授权范围。另外,JS API里依赖在线服务的功能,比如实时路况、搜索建议、路线规划,在内网环境依然不可用,需要用自建服务或者离线算法兜底。
5. 实际项目中的坑:偏移、白边、限流与范围规划
5.1 瓦片对不上号:坐标偏移的排查思路
瓦片下载完成后,最常遇到的症状是底图出来了,但业务数据整体偏了几百米。遇到这种情况,先别急着调前端,按顺序排查三件事:第一,确认业务数据到底是什么坐标系;第二,确认瓦片行列号是基于什么坐标系计算的;第三,确认前端中心点和叠加数据用的坐标基准是否一致。
高德体系内默认是GCJ-02,GPS手持机、第三方矢量数据默认是WGS-84。两者在经纬度数值上相差一点,投影到地图上就是几百米的平移。我的解决习惯是项目一开始就定好规范:所有入库坐标统一转成GCJ-02,所有瓦片行列号用GCJ-02计算,前端中心点直接用GCJ-02。统一基准之后,这个坑基本就不会再出现了。
5.2 瓦片拼接处出现白线
内网加载本地瓦片后,偶尔会在瓦片拼接缝看到一条细细的白线,尤其在地图缩放动画过程中最明显。这不是瓦片没下全,而是浏览器在缩放或位移时对瓦片做了采样,相邻瓦片之间露出背景色,看起来就是一条半透明的缝隙。
处理办法有几个层次:最简单的是把地图容器背景色设成和地图主色调接近的颜色,比如浅灰或浅绿,白线视觉上就不明显了。如果追求更干净的拼接,可以给瓦片图层加一点负边距,让相邻瓦片稍微重叠。另一个容易被忽略的原因是没有统一scale参数,混用256和512的瓦片会导致物理尺寸不一致,拼接处自然对不齐。下载参数保持一致是底线。
5.3 并发过高被限流:403与429的处理
下载脚本跑了一阵突然成片失败,日志里全是403或429,说明瓦片服务器已经识别到请求频率异常,临时把请求拒掉了。处理手段有三层:一是把并发线程数降下来,单IP 8个线程是相对安全的值,超过10就容易被盯上;二是给每个请求带Referer: https://www.amap.com/,模拟浏览器来源;三是做好重试退避,遇到403/429先等几秒再重试,不要反复快速请求。
还有个实战小技巧:任务顺序不要完全固定,可以在任务列表里随机打乱一下。太规律的并发模式容易被识别成批量程序,稍微加点随机性,被限流的概率会低很多。我早期做下载器时没注意这点,连续两次被限流,后来加了个随机shuffle,再没遇到过。
5.4 瓦片数量估算与范围规划
下载前先算瓦片数量,这个习惯能救你很多次。我遇到过一个需求方张口就要全市所有级别的瓦片,一算数据量几十TB,显然不现实。后来把方案改成分级分区域下载:主城区15到17级,远郊区16到17级,核心重点区域单独补18级。这样既满足业务要求,磁盘和带宽也可控。
判断经验一句话:局域网内网静态服务加载瓦片非常快,瓶颈从来不是带宽,而是瓦片准备得够不够全、够不够细。宁可把高一级的瓦片范围缩小,也不要为了省事只下低级别,等业务真的需要缩放时再补,返工成本更高。
5.5 合规边界与瓦片更新:从一个方案评审细节说起
最后说一个原则性问题。瓦片数据是高德投入成本生产的地图数据,受版权和服务条款保护。下载和内网本地化使用,适用于企业内部系统、隔离网络下的技术方案、已经获得授权的项目等场景。不要把下载下来的瓦片二次分发、转售,或者包装成商业服务对外提供。动手之前先确认你的使用场景在授权范围内,技术手段本身是中性的,但使用边界要自己把握清楚。
我的个人习惯是在方案评审阶段就把“瓦片来源、授权情况、更新机制”写进技术文档,让所有参与方心里有数。内网地图的核心问题从来不只是怎么下载,而是你有没有一套可维护的瓦片更新和校验流程。先把本次范围下好,跑通链路,后续需要增量更新时,只要把边界框和级别参数改一改,重新跑一遍下载器就行。整套流程越简单,越不容易出错。这套方法在后来几个内网大屏项目里反复用,每次都是最省心的那一块。