ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

高德地图瓦片离线化实践:从URL规则到内网部署全解析

高德地图瓦片离线化实践:从URL规则到内网部署全解析 去年有个项目让我头疼了整整一周。客户的内网系统里要嵌入一张城市地图页面早就做好地图容器也画出来了但打开之后整张底图全是灰的连一条道路都看不见。我当时第一反应是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}langzh_cnsize1scale1style8浏览器向这个地址发起HTTP请求高德服务器返回对应的PNG图片。整个过程对前端使用者是透明的你只看到地图“画”出来了背后其实是几十张图片在快速加载和拼接。只要中间任何一条链路走不通底图就会空白。内网环境最典型的问题就是域名解析不到外网或者外网请求直接被防火墙拦截。1.3 内网环境下的三种典型需求我实际接触过的内网地图需求大概分三类出发点各不相同但最终都落到同一个技术动作上业务系统地图展示内部管理平台需要在地图上叠加资产点位、人员轨迹、区域边界底图必须放在内网这是最基础的要求。数据可视化大屏大屏项目普遍追求高德风格的街道图或卫星图效果但又不能在演示现场赌外网带宽瓦片本地化成了唯一靠谱方案。应急离线地图一些现场环境网络状态不稳定提前把目标区域瓦片下载到本地现场直接用静态服务撑住整个演示方便又省心。这三类需求落到技术动作上完全一致先把瓦片下载到内网再让前端从内网加载。下面我从瓦片URL规则开始拆解。2. 拆解高德瓦片URL规律z/x/y与坐标系里藏着的门道2.1 从浏览器开发者工具抓一个瓦片请求写下载器之前先得把瓦片URL的规律摸清楚。方法很简单打开一个使用高德地图的网页按F12切到Network面板过滤Image类型请求拖动一下地图就能看到瓦片请求像瀑布一样刷下来。一个典型的高德街道图瓦片请求是这样的https://webrd01.is.autonavi.com/appmaptile?langzh_cnsize1scale1style8x227y95z8其中webrd01是瓦片服务器编号高德做了负载均衡webrd02、webrd03、webrd04也能取到同样的瓦片。z是缩放级别x和y是瓦片行列号style8表示街道图scale1是普通分辨率返回256x256scale2返回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参数直接影响显示效果和存储成本。scale1返回256x256单张大约10-20KBscale2返回512x512单张大约30-60KB。同样的范围高清瓦片占用的存储空间通常翻三到四倍。做普通PC端Web系统scale1完全够用。做数字大屏或者需要截图输出的场景我建议直接用scale2否则画面放大后全是锯齿。关键点是下载时用了什么scale前端URL模板就要保持一致混用会导致瓦片物理尺寸不一致拼接出来非常难看。这个我在第5章的踩坑记录里还会再讲。3. 写一个瓦片下载器从边界框计算到并发断点续传3.1 规划下载范围用经纬度边界框确定瓦片数量下载瓦片的第一步不是写循环而是先明确下载范围。范围一般用经纬度矩形边界框描述也就是最小经度、最小纬度、最大经度、最大纬度。缩放级别范围取决于业务城区大屏项目通常15到17级就够18级以上瓦片数量会暴涨下载时间和磁盘占用都不划算。我习惯先估算瓦片数量再动手。以北京城区大约1.5度经度跨度、1.2度纬度跨度为例在17级下的数量大概是这样的缩放级别2^z示例城市横向瓦片数示例城市纵向瓦片数总瓦片数约15327681372182.9万166553627343711.9万1713107254687447.7万1826214410921748190.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_okTrue) save_path os.path.join(save_dir, f{y}.png) url url_template.format(zz, xx, yy) resp requests.get(url, timeout10, 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, retries3): save_dir os.path.join(save_root, str(z), str(x)) os.makedirs(save_dir, exist_okTrue) 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(zz, xx, yy) for attempt in range(retries): try: resp requests.get(url, timeout10, 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, workers8): with ThreadPoolExecutor(max_workersworkers) 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-age2592000; 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-02GPS手持机、第三方矢量数据默认是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 合规边界与瓦片更新从一个方案评审细节说起最后说一个原则性问题。瓦片数据是高德投入成本生产的地图数据受版权和服务条款保护。下载和内网本地化使用适用于企业内部系统、隔离网络下的技术方案、已经获得授权的项目等场景。不要把下载下来的瓦片二次分发、转售或者包装成商业服务对外提供。动手之前先确认你的使用场景在授权范围内技术手段本身是中性的但使用边界要自己把握清楚。我的个人习惯是在方案评审阶段就把“瓦片来源、授权情况、更新机制”写进技术文档让所有参与方心里有数。内网地图的核心问题从来不只是怎么下载而是你有没有一套可维护的瓦片更新和校验流程。先把本次范围下好跑通链路后续需要增量更新时只要把边界框和级别参数改一改重新跑一遍下载器就行。整套流程越简单越不容易出错。这套方法在后来几个内网大屏项目里反复用每次都是最省心的那一块。
返回列表