ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

高德地图内网离线化:从瓦片下载到Leaflet部署实战

2026/10/3 5:09:07 拓冰建站 浏览量
高德地图内网离线化:从瓦片下载到Leaflet部署实战 前阵子公司一个项目要部署到内网环境业务方指着大屏说地图这块必须保留而且不能断网。我盯着需求书看了半天脑子里蹦出来的思路其实已经很清晰了——高德地图离线化。这里说的离线化不是把手机高德APP的离线包下载下来完事而是指在完全隔离的网络里把一套可交互的地图应用完整跑起来。整体方案拆开来看就一句话先把高德瓦片数据搬到内网再用开源GIS引擎渲染这些瓦片实现标记、弹窗、轨迹、聚合这些常用交互功能。听起来不算复杂但真正动手会发现里面全是细节。从瓦片下载脚本到Nginx发布从坐标纠偏到前端引擎选型每一步都可能踩坑。这篇文章把我从零到一做完的经验完整写出来适合在内网、政务网、园区专网或涉密程度不高的企业内网里做地图展示的开发者参考。1. 为什么做高德地图内网离线化1.1 内网场景的硬约束内网环境最直接的特点就是与外网物理隔离或逻辑隔离。业务系统部署在这样的网络里意味着前端页面无法访问在线地图SDK也无法请求任何外网站点。很多做惯了在线地图开发的同学第一个反应还是引JSAPI、配Key、调接口到了内网全被卡住。除了网络隔离还有几个现实问题在线版高德JS API的JS文件放在CDN上内网访问不了。即使手动把JS文件下载下来脚本内部还会动态请求验证、定位、路况等接口全部依赖外网。内网环境多数也不允许随意开放外网访问权限域名白名单、秘钥管理都是问题。在线API有配额和计费规则调用量大时成本压不住。所以内网地图项目不能按在线开发的思路来做。你想保留高德地图的视觉效果和交互体验就得把地图数据整体“搬”进来同时把渲染和交互能力也全部本地化。1.2 技术选型瓦片方案、离线SDK还是自绘当时我对比了三条路线高德离线SDK、自绘地图、瓦片方案。高德官方提供的离线SDK主要面向Android、iOS原生应用可以在端侧下载城市离线包。但我们的项目是Web大屏官方JS API并没有正式离线版。有人试过把JS API的脚本抓到本地再通过代理把运行时请求转发到内网模拟但高德JS内部还依赖很多在线服务接口一旦某个请求失败地图渲染就会出问题。维护成本太高容易翻车。自绘地图这条路最可控但需要自己准备底图数据、道路数据、POI数据还要做风格渲染项目周期根本不允许。而且没有专业美工调出来的地图风格很难看业务方大概率不会接受。瓦片方案是目前最务实的做法把高德地图已经渲染好的图片瓦片按层级预先下载到本地然后通过Nginx发布成静态文件服务前端用开源引擎加载这些瓦片。优点很明显视觉风格和高德在线保持一致地图数据量可控渲染性能高交互能力用开源轮子补齐。1.3 整体链路设计整个链路分四段确认需要的区域范围和最大缩放级别。用脚本把该范围内每个级别的高德瓦片批量下载到本地。在内网部署Nginx或其他静态文件服务把瓦片目录发布出去。前端页面用Leaflet等开源GIS引擎加载内网瓦片服务叠加业务图层和交互功能。这套链路的关键点在于第2步的瓦片坐标换算、第3步的发布代理以及第4步的坐标体系一致性问题。下面逐个展开。2. 动手前先搞懂瓦片规则与坐标体系2.1 XYZ瓦片到底怎么编号很多同学第一次接触瓦片时会被一堆数字搞得头晕。其实原理很简单地图服务商把全球地图按金字塔模型切成很多张正方形小图片每张就是一个瓦片。第0级通常只有1张或2张瓦片越往下放大级别越高瓦片数量越多。这里用OpenStreetMap推广开的XYZ规则这是目前WebGIS最通用的瓦片寻址方式z表示缩放级别。x表示瓦片所在列从西到东从0开始。y表示瓦片所在行从北到南从0开始。高德地图瓦片在Web端也是按这个规则组织的只是不同数据源的代号和URL模板有所不同。你拿到瓦片服务的地图URL后通常只需要把{z}、{x}、{y}三个参数替换成实际数值就能直接请求到图片。比如某一级某个位置的瓦片请求URL模板大概是{你的数据源地址}/mapstyle/{z}/{x}/{y}.png?param...具体域名和参数每个项目不一样核心是记住替换z/x/y。建议在实际写脚本前先在浏览器地址栏里手改几组数字确认这套URL模板能正常返回图片再动手写批量下载程序。2.2 经纬度换算瓦片编号公式与代码判断一个地理坐标落在哪个瓦片里需要做一次坐标换算。公式看起来有一点数学味道但写起来很固定可以直接抄n 2^z x int((lng 180.0) / 360.0 * n) y int((1.0 - ln(tan(lat_rad) 1 / cos(lat_rad)) / pi) / 2.0 * n)其中lat_rad是纬度的弧度值。这个y公式就是Web Mercator投影的反算不用理解太深知道它是把地球球面坐标映射到平面瓦片网格上就行。代码实现如下import math def lon_lat_to_tile(lon, lat, zoom): lat_rad math.radians(lat) n 2 ** zoom x int((lon 180.0) / 360.0 * n) y int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n) return x, y注意这里用math.asinh是为了代码简洁。这个函数等价于上文公式里的ln(tan(φ) sec(φ))只是Math库里的反双曲正弦刚好有这个性质写起来更紧凑。实际下载某个矩形区域的瓦片时很多人会写出“先算左上角瓦片再算右下角瓦片然后双重循环遍历”的逻辑。这里有一个必须注意的细节瓦片y方向是自北向南递增的所以左上角的纬度更大右上角经度更大。也就是说取范围时要按下面这种方式算def collect_tiles(min_lon, min_lat, max_lon, max_lat, zoom): x1, y1 lon_lat_to_tile(min_lon, max_lat, zoom) # 注意纬度用最大纬度 x2, y2 lon_lat_to_tile(max_lon, min_lat, zoom) # 这里用最小纬度 tiles [] for x in range(min(x1, x2), max(x1, x2) 1): for y in range(min(y1, y2), max(y1, y2) 1): tiles.append((zoom, x, y)) return tiles第一次写这个逻辑时很容易把左上角和右下角的坐标算反导致下载回来的瓦片错位或缺一大片。把min和max都做一遍比较再用range遍历是最稳妥的写法。2.3 GCJ-02、WGS-84和Web Mercator的纠葛这是离线地图开发里最大的坑也是很多人做得好好的项目突然出现几百米偏移的根本原因。简单说国内商用地图服务商包括高德在对外提供地图数据时坐标并不是GPS原生的WGS-84坐标而是经过一次非线性偏移的GCJ-02坐标。高德瓦片本身已经按GCJ-02做了纠偏所以在高德自己的前端引擎里加载用户感觉不到问题。但如果换成Leaflet这类开源引擎直接加载高德瓦片底图本身是GCJ-02的而你的业务数据如果是从GPS设备、第三方传感器拿到的WGS-84坐标直接打到地图上就会偏移。偏移量不是固定值在北京市区通常几百米方向也不完全一致无法通过简单加减修正。解决办法有两种一是业务设备出数时直接做一次坐标转换把WGS-84转成GCJ-02再入库二是在前端渲染前统一转换。前者更好因为数据入库后是干净的。后面的章节我会给出一段可用的转换代码。这里先记住结论数据坐标体系必须和高德瓦片保持一致统一用GCJ-02偏差问题就消失。3. 核心实操编写瓦片下载脚本3.1 确定下载范围、级别与体量在下脚本之前先想清楚要下载哪几级瓦片。内网项目常见的地图展示场景是城市级或园区级。整体覆盖可以考虑10到12级看全局业务聚焦区域下载到15级甚至17级。但级别越高瓦片数量呈4倍增长不是线性的。以北京六环内区域为例我做过一个粗略估算缩放级别覆盖场景北京六环范围瓦片数预计体积10全市概览约20~60张1~5MB12区县主干路约500~1000张30~80MB15街道建筑约4000~6000张300~800MB17楼宇与园区细节约1.5万~2.5万张1~3GB以上是经验估值具体体积取决于瓦片内容复杂度和图片压缩率。地图内容密集的区域单张瓦片可能到80KB而郊区纯色瓦片可能只有10KB。下载前先按这个量级估算好硬盘空间。不建议一上来就全级别全范围下载。先下载低级别观察效果再逐步增加范围和级别。否则脚本写错了或者范围框大了带宽和时间成本都很浪费。3.2 Python脚本落地完整实现与踩坑下载脚本我建议用Python写生态成熟requests加concurrent.futures就能搞定并发。贴一段我实际用下来的结构import math import os import time import requests from concurrent.futures import ThreadPoolExecutor, as_completed HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36, Referer: https://your.site/, # 按数据源要求设置 } # 瓦片URL模板具体按项目数据源填充 URL_TEMPLATE https://your-tile-host/{z}/{x}/{y}.png def lon_lat_to_tile(lon, lat, zoom): lat_rad math.radians(lat) n 2 ** zoom x int((lon 180.0) / 360.0 * n) y int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n) return x, y def download_one(z, x, y, save_dir): save_path os.path.join(save_dir, str(z), str(x), 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(3): try: resp requests.get(url, headersHEADERS, timeout10) if resp.status_code 200: os.makedirs(os.path.dirname(save_path), exist_okTrue) with open(save_path, wb) as f: f.write(resp.content) return True elif resp.status_code 429: time.sleep(2 * (attempt 1)) except Exception: time.sleep(1) return False def download_region(min_lon, min_lat, max_lon, max_lat, zoom, save_dir): x1, y1 lon_lat_to_tile(min_lon, max_lat, zoom) x2, y2 lon_lat_to_tile(max_lon, min_lat, zoom) tasks [] for x in range(min(x1, x2), max(x1, x2) 1): for y in range(min(y1, y2), max(y1, y2) 1): tasks.append((zoom, x, y)) failed [] with ThreadPoolExecutor(max_workers8) as pool: futures {pool.submit(download_one, *task): task for task in tasks} for future in as_completed(futures): task futures[future] try: if not future.result(): failed.append(task) except Exception: failed.append(task) if failed: print(f失败数量: {len(failed)}) with open(failed_tiles.txt, w) as f: for z, x, y in failed: f.write(f{z}/{x}/{y}\n) else: print(全部下载完成)这段代码里有几个当时踩坑后加进去的点值得讲解。第一是重试机制。有些瓦片服务对高频请求会返回429限流重试时加退避时间否则连续下载几千张瓦片时很容易被临时封禁。第二是断点续传。脚本判断文件存在且大小不为0就跳过避免中途挂了要全部重新下载。第三是失败列表落盘。下载完成后检查failed_tiles.txt再针对失败项单独补拉比在终端里翻日志高效得多。并发数建议控制在8到16之间。开太高容易被数据源限流开太低下载几万张瓦片会等很久。带宽和内网部署阶段通常8线程是平衡点。3.3 内网Python环境的准备与依赖离线安装下载脚本理论上可以在有网环境跑完再把瓦片摆渡进内网但实际操作中内网服务器往往也需要跑数据处理程序比如瓦片重命名、目录整理、格式清洗。这时候内网机器的Python环境就要提前准备好。内网Linux机器常见的坑是Python版本过旧。很多国产化Linux服务器自带的还是Python 2.7或者3.6跑现代脚本会报语法错误。优先用源码编译安装新版Python步骤不复杂# 先确认编译工具链 yum install -y gcc gcc-c make openssl-devel zlib-devel bzip2-devel readline-devel sqlite-devel libffi-devel # 下载Python源码后编译 ./configure --prefix/usr/local/python3.11 --enable-optimizations make make install ln -s /usr/local/python3.11/bin/python3.11 /usr/local/bin/python3 ln -s /usr/local/python3.11/bin/pip3.11 /usr/local/bin/pip3编译前务必装上openssl-devel和libffi-devel否则pip会报ssl相关错误新版Python也装不上requests这些包。依赖包离线安装也很简单在外网机器上执行pip download -d /tmp/pkgs requests然后把整个pkgs目录拷进内网内网执行pip install --no-index --find-links/tmp/pkgs requests这里的--no-index参数很关键表示不访问PyPI只从本地目录查找安装包。把requests、urllib3、charset-normalizer这些常见包全部下载好后续脚本就不会再缺依赖。4. 用Nginx发布本地瓦片服务4.1 目录结构与命名校验瓦片下载完成后要检查目录是否按标准路径组织。Nginx静态服务的目录结构需要和瓦片URL对齐。一个规范的目录结构如下/data/map_tiles/ └── 15/ ├── 26000/ │ ├── 13000.png │ ├── 13001.png │ └── ... ├── 26001/ │ └── ... └── ...也就是z目录下面套x目录再往下是y.png文件。Leaflet等前端引擎发请求时会自动替换URL里的{z}/{x}/{y}所以目录结构必须严格匹配多一层少一层都会404。有时候下载脚本会以不同规则保存比如把z/x/y写反了或者文件名缺少扩展名。建议下载完成后写一个小脚本随机抽几十个路径检查文件是否存在以及大小是否正常避免部署到Nginx才发现一堆404。4.2 Nginx配置与缓存策略内网访问量通常不会非常大但大屏项目往往有多个终端同时拉图Nginx配置得当可以省很多后端压力。核心配置如下server { listen 8000; server_name map.internal; location /tiles/ { alias /data/map_tiles/; access_log off; expires 30d; add_header Cache-Control public, max-age2592000; try_files $uri 404; } location / { root /data/map_web; index index.html; try_files $uri $uri/ /index.html; } }瓦片是静态不变的文件开启expires让浏览器缓存一个月可以极大减少重复请求。access_log建议关掉否则几天下来日志文件就能积累几GB全是瓦片请求记录。还要注意location的alias与root区别。alias会把location匹配到的路径替换成指定目录所以/tiles/15/1/2.png会映射到/data/map_tiles/15/1/2.png。如果用rootNginx会把完整URI拼在root目录后面变成/data/map_tiles/tiles/15/1/2.png目录结构就错了。这是配置静态瓦片服务时最容易犯的错。如果前端应用部署在另一台机器需要在瓦片服务的server里加跨域头add_header Access-Control-Allow-Origin *;否则浏览器会拦截跨域图片请求地图白屏。5. 前端交互功能集成Leaflet版离线地图实战5.1 引擎选择的思路前端引擎我首选Leaflet。相比OpenLayersLeaflet更轻配置简单开发效率高相比MapLibre GLLeaflet对标准瓦片服务的兼容性更好学习成本也更低。项目只做2D地图场景Leaflet足够用。既然瓦片数据是高德的为什么不尝试强行加载高德JS API前面提到过高德JS API离线后内部很多接口不可用。即使勉强让底图渲染出来了标记点、气泡弹窗这些能力还是依赖它的DOM和事件系统出问题很难排查。用Leaflet替换后渲染和交互全部本地化只把高德当作纯瓦片数据源这个解耦让项目稳定很多。5.2 初始化和基础交互初始化地图加载本地Nginx上的瓦片服务!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title内网离线地图/title link relstylesheet hrefassets/leaflet/leaflet.css / style html, body, #map { width: 100%; height: 100%; margin: 0; } /style /head body div idmap/div script srcassets/leaflet/leaflet.js/script script const map L.map(map, { center: [39.9042, 116.4074], zoom: 12, minZoom: 3, maxZoom: 17 }); L.tileLayer(http://map.internal:8000/tiles/{z}/{x}/{y}.png, { maxZoom: 17, attribution: Internal Map Service, keepBuffer: 4, updateWhenIdle: false }).addTo(map); /script /body /html这里有两个参数值得说明。keepBuffer: 4表示地图周围多加载4圈瓦片让拖动时不出现白边闪烁。updateWhenIdle: false则是让地图在缩放或平移过程中就持续加载新瓦片而不是等动画完全停下来才加载。内网带宽低时这两项对体验提升很明显。Leaflet的缩放、拖拽、双击放大、滚轮缩放这些交互默认就带不需要额外配置。如果不希望用户拖动到没有瓦片的区域可以设置minZoom/maxZoom超出范围的级别前端直接不允许浏览。5.3 业务场景标记、弹窗、轨迹与聚合内网地图项目最常见的需求是打点展示设备位置。用Marker加Popup就能实现const marker L.marker([39.9042, 116.4074]).addTo(map); marker.bindPopup( b设备编号BJ-JK-0001/bbr 状态正常运行br 最近上报2025-06-12 10:33 );如果点位数量几十上百建议用layerGroup统一管理const markerLayer L.layerGroup().addTo(map); function renderDevices(devices) { markerLayer.clearLayers(); devices.forEach(d { L.marker([d.lat, d.lng]) .bindPopup(d.name) .addTo(markerLayer); }); }上千个点位时直接打Marker会把页面卡死。这时候用Leaflet.markercluster插件做聚合。把插件的js和css下载到本地通过相对路径引入const clusterLayer L.markerClusterGroup({ maxClusterRadius: 40 }); devices.forEach(d { clusterLayer.addLayer(L.marker([d.lat, d.lng])); }); map.addLayer(clusterLayer);聚合插件的效果是地图缩放级别低时把相近的点聚合成一个数字气泡放大后气泡拆开交互体验接近在线地图。轨迹展示是另一个高频需求。用L.polyline画线const trackPoints [ [39.9010, 116.4030], [39.9020, 116.4050], [39.9042, 116.4074], [39.9060, 116.4110] ]; const trackLine L.polyline(trackPoints, { color: #ff6600, weight: 3, opacity: 0.9 }).addTo(map); map.fitBounds(trackLine.getBounds());配合L.circleMarker标注轨迹上的关键节点能做出很直观的车辆或人员轨迹回放页面。5.4 坐标纠偏与数据兼容前面说过GPS原始坐标是WGS-84高德瓦片是GCJ-02。如果业务数据存的是WGS-84必须在前端或后端做一次转换。我贴一份前端JavaScript的通用转换函数来自开源社区实测过国内主流城市可用function transformLat(x, y) { let ret -100.0 2.0 * x 3.0 * y 0.2 * y * y; ret 0.1 * x * y 0.2 * Math.sqrt(Math.abs(x)); ret (20.0 * Math.sin(6.0 * x * Math.PI) 20.0 * Math.sin(2.0 * x * Math.PI)) * 2.0 / 3.0; ret (20.0 * Math.sin(y * Math.PI) 40.0 * Math.sin(y / 3.0 * Math.PI)) * 2.0 / 3.0; ret (160.0 * Math.sin(y / 12.0 * Math.PI) 320 * Math.sin(y * Math.PI / 30.0)) * 2.0 / 3.0; return ret; } function transformLng(x, y) { let ret 300.0 x 2.0 * y 0.1 * x * x; ret 0.1 * x * y 0.1 * Math.sqrt(Math.abs(x)); ret (20.0 * Math.sin(6.0 * x * Math.PI) 20.0 * Math.sin(2.0 * x * Math.PI)) * 2.0 / 3.0; ret (20.0 * Math.sin(x * Math.PI) 40.0 * Math.sin(x / 3.0 * Math.PI)) * 2.0 / 3.0; ret (150.0 * Math.sin(x / 12.0 * Math.PI) 300.0 * Math.sin(x / 30.0 * Math.PI)) * 2.0 / 3.0; return ret; } function wgs84ToGcj02(lng, lat) { const a 6378245.0; const ee 0.006693421622965943; let dLat transformLat(lng - 105.0, lat - 35.0); let dLng transformLng(lng - 105.0, lat - 35.0); const radLat lat / 180.0 * Math.PI; let magic Math.sin(radLat); magic 1 - ee * magic * magic; const sqrtMagic Math.sqrt(magic); dLat (dLat * 180.0) / ((a * (1 - ee)) / (magic * sqrtMagic) * Math.PI); dLng (dLng * 180.0) / (a / sqrtMagic * Math.cos(radLat) * Math.PI); return [lng dLng, lat dLat]; }调用方式很简单把GPS采集的原始经纬度传给wgs84ToGcj02返回的坐标就是能和底图对齐的点。手机端定位上报的数据以及在苹果设备上偶发的位置偏差问题很多都是因为拿了WGS-84坐标没转换就上图导致点位看着偏到别人家楼顶。这里要注意纬度值范围内不能把海域计算跳过否则转换精度下降。实际使用中只要坐标在国土地理范围内统一走这个函数即可。6. 常见问题排查与性能调优实录6.1 高频问题速查表做离线地图项目过程中我遇到最多的一批问题整理成表格方便快速对照故障现象可能原因解决思路页面白屏瓦片不显示Nginx alias/root配置错误导致404检查浏览器Network确认请求URL是否映射到正确目录部分瓦片灰色或花屏下载时某个级别瓦片缺失或文件损坏用failed_tiles.txt补拉删除0KB文件重新下载地图偏移几百米业务数据用WGS-84坐标叠加GCJ-02底图数据入库前统一调用wgs84ToGcj02转换点位出现但标记模糊前端没有引入正确的CSS文件确认Leaflet.css和markercluster.css放本地并正确引用了拖动地图频繁白边瓦片加载慢keepBuffer设置过小前端设置keepBuffer: 4或更大并提前预取周边瓦片地图上POI图标叠成一坨点位太多渲染卡顿改用markercluster聚合或者做Canvas图层浏览器报跨域错误前端和瓦片服务不在同一域名端口在Nginx瓦片服务里加Access-Control-Allow-Origin头瓦片404是最常见的拿到路径后先手动在浏览器打开瓦片URL确认Nginx返回的是图片而不是错误页。如果返回403大概率是没有配置跨域头或路径权限有问题。6.2 性能调优三板斧第一板斧是瓦片体积控制。从在线数据源下载的图片已经比较优化但部分瓦片仍然有压缩空间。批量做一次pngquant压缩地图底图的瓦片体积能降20%到40%。Nginx端再开启gzip传输体积进一步下降。压缩前先备份对比压缩后有无肉眼可见的清晰度损失有些底图文字细节会被压糊。第二板斧是前端缓存策略。Leaflet对瓦片有内存缓存配合浏览器HTTP缓存第二次打开页面时大部分瓦片直接从本地缓存读取请求量会少很多。如果项目里地图切换频率很高可以做一个内存中的瓦片缓存池简单做法是维护一个Map对象key用z/x/y拼接value存Image对象避免重复创建Image导致内存上涨。第三板斧是分层加载。内网项目如果地图资源有几百GB不可能全部发布到Nginx后每个终端都去拉全量数据。常见做法是分级别存储常用级别放本地不常用级别放内网NAS或对象存储Nginx通过内网回源缓存。但因为内网一般没有外网回源条件更现实的方案是按业务区域裁切范围区域外不下载区域内尽量覆盖到需要的最高级别。还有一个容易忽略的坑如果内网机器配置不高渲染大屏时不要同时开太多Leaflet实例。多个页签或者多个大屏都初始化地图内存占用会叠加。切屏前主动调map.remove()销毁地图实例而不是简单隐藏div。这个排查起来很费劲最好在代码里就养成清理的习惯。结尾这套内网高德地图离线化方案做完之后我的直观感受是真正花时间的不是技术实现本身而是把瓦片数据、坐标体系和前端工程之间那层说不清道不明的关联彻底搞清楚。尤其是坐标转换内网项目里数据来源五花八门有GPS设备上报的有标定过的有从第三方平台导出的只要有一个环节忘了统一到GCJ-02地图上就必然出现肉眼可见的偏移。最后再分享一个小技巧瓦片数据下载完成后别急着清理有网环境的脚本和中间文件。项目上线后业务范围大概率会调整今天只要北京城区明天可能就要扩展到天津。把下载脚本、范围配置、失败列表都纳入版本管理下次扩展时重新跑一遍脚本只增量下载就能快速补齐数据不用从头再来。