ARTICLE DETAIL

资讯详情

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

基于Qt与百度瓦片的离线地图控件实现

基于Qt与百度瓦片的离线地图控件实现 上半年做一个设备巡检的小项目需求不复杂值班室工控机上要能查看设备点位、巡检路线地图要能拖动、缩放。麻烦的是值班室在厂区深处没有外网连手机信号都要看运气。用在线地图SDK这条路直接被堵死能选的只剩离线地图。调研了一圈高德和百度都提供瓦片开放接口天地图也有但客户要求界面跟平时用的百度地图越像越好POI要全、路网要清楚最终定了百度瓦片方案。整个实现分两大块先把目标区域瓦片批量下载到本地再用Qt写一个能加载、拖动、缩放的地图控件。这篇文章把从瓦片下载到交互实现的每个环节都摊开讲代码也是可以照着改的。适合正在做Qt地图类项目、急需在无网环境里展示地图的开发者也适合想把“地图加载”这个能力集成到桌面程序里的朋友。下面直接进入正题。1. 为什么偏要选百度坐标系、瓦片规则与方案取舍1.1 三种离线地图方案我最后为什么选了百度做离线地图绕不开的候选方案有三个百度、高德、天地图。它们都能以瓦片图片的形式提供地图数据区别在于投影方式、瓦片编号规则和坐标偏移处理逻辑。方案投影/坐标系瓦片规则优势主要坑点百度地图BD09MC墨卡托BD-09坐标自成一派和谷歌规则不兼容POI全、路网清晰、视觉效果最接近普通用户日常使用坐标系是二次加密GPS坐标不能直接用高德地图Web墨卡托GCJ-02坐标接近谷歌切片规则技术资料多瓦片拼接相对简单国内POI略少部分旧版本瓦片URL已失效天地图CGCS2000标准经纬度切片权威底图国界边界处理规范更新频率低部分乡镇级信息不够细我最终选了百度表面原因是客户要求“和百度地图长得像”实际原因是百度瓦片的道路分级和POI标注密度确实更适合设备巡检这种场景街道名、小区名、楼栋信息都比较全放大到16级左右仍然有足够的参考信息。高德和天地图在某些区域会显得“空”。但选百度是有代价的。它的瓦片编号规则不对外开放网上流传的下载脚本大部分是基于逆推实现的不同区域的投影偏移表现也不一样。这篇文章里的换算代码能覆盖大多数国内城市但如果你用它下载西南山区或者高纬度地区的地图要多留个心眼后面我会专门说怎么校验。1.2 百度的坐标体系和投影逻辑先搞清楚再动手百度地图有两层坐标BD-09是最终使用的经纬度坐标BD09MC是它的投影平面坐标。BD-09是在GCJ-02基础上又做了一次坐标偏移而GCJ-02本身又是对WGS-84坐标的二次加密。用一句话概括GPS拿到的经纬度是WGS-84必须先转GCJ-02再转BD-09才能和百度瓦片对齐。这不是百度“不讲究”而是国内地图行业的一个普遍现象。做离线地图项目时如果你只是把百度瓦片静态贴到界面上不叠加任何业务位置数据那坐标偏移跟你无关但只要你需要在图上画设备点位、标注巡检人员位置、叠加GPS轨迹就必须处理坐标转换否则点位会整体偏移几百米看起来像地图放错了地方。BD09MC投影更是直接决定瓦片行列号的计算方式。百度瓦片的编号规则和谷歌、高德完全不同网上许多现成脚本直接把Web墨卡托公式套到百度瓦片上在小范围一个城市的主城区内视觉上能对上一旦范围扩大就会出现瓦片错位、拼接错乱。所以联网在线的正式项目我一般建议用官方API的离线包自己下载瓦片这套更适合固定区域、固定级别的脱机展示。本文默认以城市级区域、10到18级为例覆盖范围和细节足够日常演示。1.3 这套方案的适用边界与前置知识先泼一盆冷水自己下载瓦片拼地图不适合做全国级、全级别的地图应用。百度瓦片从3级到18级全国数据量是TB级别的普通工程机根本存不下。但这个方案特别适合三种场景固定区域展示比如一个园区、一个校区、一个城市主城区固定业务范围比如巡检、物流、船舶调度活动范围几千公里以内内网隔离环境比如机房值班室、工厂中控台、特殊行业现场。前置知识方面你需要熟悉Qt的基本事件机制鼠标事件、绘图事件会看C代码能看懂Python脚本下载瓦片用不需要懂图形学但需要理解“瓦片坐标”和“屏幕坐标”不是一回事。接下来就从瓦片下载开始。2. 瓦片从哪来解析URL、算行列号并批量下载2.1 百度瓦片URL模板与请求头设置百度地图前端的瓦片加载地址核心是一个固定的URL模板https://online0.map.bdimg.com/onlinelabel/?qttilex{x}y{y}z{z}stylesplscaler1p1参数拆开看参数含义qttile固定参数表示请求瓦片x / y瓦片列号 / 行号z缩放级别百度地图范围一般3到19其中3级是全国18级是街道级别stylespl图层类型pl是普通道路图sl是卫星图scaler1清晰度倍率1表示普通分辨率2会有更高清的瓦片但体积大p1瓦片版本号一般固定直接拿浏览器请求这个地址基本能拿到图但用脚本批量下载时经常遇到403或者返回一张空白占位图。原因是百度瓦片服务会校验请求头。我实际测试下来必须带上User-Agent和RefererReferer固定填https://map.baidu.com/否则下载量一上来就会被服务端拦。HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://map.baidu.com/, }另外online0这个域名可以换成online1、online2、online3四个域名都能用。并发下载时把任务平均分到四个域名上速度更稳也能降低触发限流的概率。2.2 经纬度到瓦片行列号的换算百度瓦片行列号的计算逻辑不公开但根据对瓦片URL的反推可以整理出一条可用的换算路径先把经纬度转成百度墨卡托平面坐标再把平面坐标映射到瓦片坐标。百度的BD09MC投影和标准Web墨卡托很接近但不完全一致。直接用标准Web墨卡托公式反推百度瓦片编号在城市级范围几十公里内误差很小肉眼几乎看不出区别如果范围扩大到省一级边缘瓦片就可能偏出几个像素。这里先给一版“够用”的实现适用于国内绝大多数城市主城区。import math TILE_SIZE 256 def ll2mc(lng, lat): # 近似BD09MC墨卡托投影城市范围可用 x lng * 20037508.34 / 180.0 y math.log(math.tan((90 lat) * math.pi / 360.0)) / (math.pi / 180.0) y y * 20037508.34 / 180.0 return x, y def mc2tile(mx, my, zoom): # 把平面坐标映射到瓦片行列号 res 2.0 * 20037508.34 / (TILE_SIZE * (2 ** zoom)) px (mx 20037508.34) / res / TILE_SIZE py (my 20037508.34) / res / TILE_SIZE return int(px), int(py)这段代码的思路是把全球投影平面看作一个正方形边长约40075016.68米某一级别的瓦片网格把整个平面切分成2^zoom行乘2^zoom列每张瓦片覆盖40075016.68 / (2^zoom)米。给定一个平面坐标先平移到左上角为原点的坐标系里再除以每张瓦片的大小就得到行列号。需要说明的是这不是百度的官方算法它是基于标准Web墨卡托的近似。百度的真实投影还要把GCJ-02到BD-09的偏移考虑进去所以在下载完成后的自检阶段如果发现地图周边瓦片错位优先要查的就是这里。2.3 批量下载脚本Python并发版下面这个脚本可以直接跑。它读入目标区域的左下角和右上角经纬度在指定缩放级别范围内循环下载所有瓦片保存为baidu_tiles/{z}/{x}/{y}.png的目录结构。默认示例是北京主城区级别范围10到16图片量大约在几万张左右首次运行建议先跑10到14看看效果。 百度离线瓦片批量下载脚本 依赖pip install requests 用法python download_baidu_tiles.py import math, os, time, requests from concurrent.futures import ThreadPoolExecutor, as_completed TILE_SIZE 256 ZOOM_MIN 10 ZOOM_MAX 16 # 目标区域左下角、右上角经纬度示例北京主城区 LON_MIN, LAT_MIN 116.25, 39.80 LON_MAX, LAT_MAX 116.55, 40.05 OUTPUT_DIR baidu_tiles HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://map.baidu.com/, } def ll2mc(lng, lat): x lng * 20037508.34 / 180.0 y math.log(math.tan((90 lat) * math.pi / 360.0)) / (math.pi / 180.0) y y * 20037508.34 / 180.0 return x, y def mc2tile(mx, my, zoom): res 2.0 * 20037508.34 / (TILE_SIZE * (2 ** zoom)) px (mx 20037508.34) / res / TILE_SIZE py (my 20037508.34) / res / TILE_SIZE return int(px), int(py) def tile_range(zoom): min_mc ll2mc(LON_MIN, LAT_MIN) max_mc ll2mc(LON_MAX, LAT_MAX) x0, y1 mc2tile(min_mc[0], min_mc[1], zoom) x1, y0 mc2tile(max_mc[0], max_mc[1], zoom) x0, x1 min(x0, x1), max(x0, x1) y0, y1 min(y0, y1), max(y0, y1) return x0, x1, y0, y1 def download_one(z, x, y): path os.path.join(OUTPUT_DIR, str(z), str(x)) os.makedirs(path, exist_okTrue) fname os.path.join(path, f{y}.png) if os.path.exists(fname) and os.path.getsize(fname) 1024: return True, f{z}/{x}/{y}.png url fhttps://online0.map.bdimg.com/onlinelabel/?qttilex{x}y{y}z{z}stylesplscaler1p1 for attempt in range(3): try: r requests.get(url, headersHEADERS, timeout10) if r.status_code 200 and len(r.content) 1024: with open(fname, wb) as f: f.write(r.content) return True, f{z}/{x}/{y}.png time.sleep(0.1) except Exception: time.sleep(0.5) return False, f{z}/{x}/{y}.png def main(): tasks [] for z in range(ZOOM_MIN, ZOOM_MAX 1): x0, x1, y0, y1 tile_range(z) print(fzoom{z}, x:[{x0},{x1}], y:[{y0},{y1}]) for x in range(x0, x1 1): for y in range(y0, y1 1): tasks.append((z, x, y)) total len(tasks) print(ftotal tiles: {total}) failed [] done 0 with ThreadPoolExecutor(max_workers8) as pool: futs {pool.submit(download_one, *t): t for t in tasks} for fut in as_completed(futs): ok, name fut.result() done 1 if not ok: failed.append(name) if done % 500 0: print(fprogress: {done}/{total}, failed{len(failed)}) print(ffinish, success{total-len(failed)}, failed{len(failed)}) for f in failed: print(retry:, f) if __name__ __main__: main()几个技巧说明一下下载前做好幂等检查os.path.exists(fname) and os.path.getsize(fname) 1024已经存在的瓦片直接跳过方便中断后续传返回内容小于1KB的大概率是百度返回的空白占位图或错误提示不能当成正常瓦片保存所以要判断len(r.content) 1024并发数控制在8到16比较合适太多容易触发服务端限流太少下载速度太慢失败列表最后打印出来供第二轮重试使用。2.4 下载完成后怎么自检这一步很多人会跳过但我建议一定要做尤其是第一次跑通流程时。最简单的自检方法是写一个几行的小程序把某个级别相邻的四张瓦片拼成一张大图肉眼检查拼接处和道路连续性。如果道路在瓦片边缘断开了说明行列号计算有偏差或者该区域百度瓦片实际使用的投影和脚本里的近似公式不一致。还有一种快速检查法用QCryptographicHash统计瓦片文件的MD5如果同级别里大量瓦片的MD5都一样说明下载到的很可能全是空白占位图请求头被服务端拦截了需要检查Referer和User-Agent。正常情况下一个城市的10到16级瓦片量大约在几万张左右占用空间几百MB到1GB。下载完之后把baidu_tiles目录拷贝到Qt工程里下一步就是写Qt侧的数据加载层。3. 把瓦片喂给Qt离线包结构、磁盘缓存与坐标处理3.1 离线包目录设计与配置清单下载好的瓦片不能直接丢给Qt程序用最好先整理成一个带配置文件的离线包。我习惯用下面这种目录结构baidu_tiles/ ├── config.json └── tiles/ ├── 10/ │ ├── 123/ │ │ ├── 456.png │ │ └── 457.png │ └── 124/ │ ├── 456.png │ └── 457.png ├── 11/ └── ...config.json记录地图源信息Qt程序启动时读取它来确定缩放范围、中心点和坐标系类型{ mapSource: baidu, coordinateSystem: BD09MC, zoomMin: 10, zoomMax: 16, centerLng: 116.397, centerLat: 39.908, tileSize: 256, baseUrl: https://online0.map.bdimg.com/onlinelabel/ }这个配置文件的好处是将来换地图源比如换成高德时只需要改配置文件不用改代码。比如coordinateSystem改成GCJ02、baseUrl改成高德的瓦片地址控件本身可以复用。3.2 TileManager的设计磁盘优先、内存缓存、网络兜底Qt程序里不能每次绘制都去磁盘读PNG文件那样拖动地图时会卡成PPT。我单独实现了一个TileManager职责有三个内存缓存、磁盘读取、网络兜底下载。class TileManager : public QObject { Q_OBJECT public: TileManager(const QString offlineDir, QObject *parent nullptr); QImage tile(int z, int x, int y); // 同步获取缓存/磁盘 void requestTile(int z, int x, int y); // 异步请求本地没有则走网络 signals: void tileReady(int z, int x, int y, const QImage img); private: QString tileKey(int z, int x, int y) const; QImage loadFromDisk(int z, int x, int y); void saveToDisk(int z, int x, int y, const QImage img); QHashQString, QImage m_cache; QNetworkAccessManager m_network; QString m_offlineDir; };关键实现思路tile()是同步查询接口先查内存缓存缓存没有就查磁盘磁盘也没有就返回空QImage同时触发requestTile()异步加载requestTile()先判断是否已在缓存或磁盘如果本地有就直接发信号如果没有且网络可用就发起网络请求网络请求的回调里先检查HTTP状态码和返回数据大小过滤掉无效图片再写入磁盘和内存缓存最后发tileReady信号通知界面刷新。这个设计实现了“本地优先、网络兜底”在有网环境下跑一遍后续再进入离线状态时已经看过的区域就无需网络了。一个容易踩的坑QNetworkAccessManager是QObject的子类默认在主线程运行。如果直接把网络请求放到工作线程里要么用信号槽把请求发回主线程要么在子线程里单独创建局部QNetworkAccessManager绝对不能跨线程直接调用。对于这种“偶尔联网补图”的场景我建议网络请求统一交给主线程的TileManager处理简单且安全。3.3 内存缓存怎么控制上限瓦片解码成QImage后内存占用很可观。一张256x256的ARGB32格式图片大小是256 x 256 x 4 256KB。1000张就是256MB如果不控制缓存上限程序跑几分钟就能吃掉1GB内存。最简单的控制方式是给缓存加一个上限超过就删掉最久未使用的项。Qt自带的QCacheQString, QImage天然支持这个能力设置maxCost即可。我一般把上限设为2000张大约512MB在多数工控机上都能接受。m_cache.setMaxCost(2000);如果项目里还没有QCache也可以用QHash加QQueue手动实现LRU每次命中缓存就把key移到队尾新增键时如果队列长度超限就从队头移出最旧的键。实际使用中QCache已经够用没必要重复造轮子。3.4 瓦片间隙、白边和GPS坐标偏移的实战处理离线地图加载时最容易见到的视觉问题是瓦片间隙——两张瓦片之间有一条细细的白线。这个问题的根源有三个瓦片尺寸不是严格的256像素、绘制时坐标经过浮点换算产生半像素偏差、PNG边缘存在半透明像素。解决办法按优先级排列绘制时关闭QPainter的抗锯齿和相关平滑选项避免边缘插值所有瓦片坐标一律用int取整不要留浮点抖动绘制瓦片时把矩形外扩1像素也就是画sx - 1, sy - 1, 258, 258用重叠覆盖掉缝隙。p.setRenderHint(QPainter::Antialiasing, false); p.setRenderHint(QPainter::SmoothPixmapTransform, false); p.drawImage(sx - 1, sy - 1, 258, 258, img);这种方式对视觉几乎没有影响但能彻底消灭白缝。GPS坐标偏移的处理我单独说。WGS-84转GCJ-02、GCJ-02转BD-09的算法在网上能找到标准实现这里给一个函数骨架QPointF wgs84ToBd09(double lng, double lat);基层逻辑是WGS-84经纬度先经过GCJ-02加密算法得到火星坐标GCJ-02再经过BD-09偏移算法得到百度经纬度把百度经纬度作为地图控件中心点或者作为点位坐标使用。实际项目里GPS设备采集到的坐标如果不经过这个转换直接画到百度瓦片上点位会偏移约300到600米而且不同城市偏移方向还不一样。所以这个转换不是可选项是必选项。4. 手把手写交互从坐标换算到拖拽、缩放、双击4.1 控件选型为什么用QWidget自绘地图控件可以用两种思路实现QGraphicsView加载瓦片Item或者QWidget重写paintEvent自绘。我选了后者理由很实际瓦片本身是一张位图QPainter直接贴图足够快QGraphicsView的Item管理在这里是多余复杂度地图的拖动/缩放本质是改变“视口中心点坐标”和“缩放级别”两个状态不需要Scene/View的坐标变换体系后续要叠加轨迹、绘制标注点时在paintEvent里统一控制绘制顺序更直观QGraphicsView在大量Item频繁移动时会有性能坑而QWidget自绘只需要控制好绘制范围。自绘控件整体的复杂度低很多遇到问题也好排查。4.2 核心坐标模型与paintEvent实现地图控件里最重要的数据模型是“中心点瓦片坐标”。它表示当前视口中心在哪张瓦片上用浮点数存储这样缩放、拖动时画面才能平滑过渡。class MapWidget : public QWidget { Q_OBJECT public: explicit MapWidget(TileManager *tileMgr, QWidget *parent nullptr); void setCenter(double lng, double lat, int zoom); void setZoomRange(int minZoom, int maxZoom); protected: void paintEvent(QPaintEvent *event) override; void mousePressEvent(QMouseEvent *event) override; void mouseMoveEvent(QMouseEvent *event) override; void mouseReleaseEvent(QMouseEvent *event) override; void wheelEvent(QWheelEvent *event) override; void mouseDoubleClickEvent(QMouseEvent *event) override; private: void zoomAt(int step, const QPoint pos); TileManager *m_tileMgr; int m_zoom 14; int m_minZoom 10; int m_maxZoom 16; double m_centerX 0.0; double m_centerY 0.0; bool m_dragging false; QPoint m_lastPos; double m_lastCenterX 0.0; double m_lastCenterY 0.0; };paintEvent的绘制逻辑void MapWidget::paintEvent(QPaintEvent *) { QPainter p(this); p.fillRect(rect(), QColor(0xF4, 0xF4, 0xF4)); if (!m_tileMgr) return; const int tileSize 256; const int vw width(); const int vh height(); // 根据中心点瓦片坐标计算当前视口可见的瓦片行列号范围 int left std::floor(m_centerX - vw / 2.0 / tileSize); int right std::floor(m_centerX vw / 2.0 / tileSize); int top std::floor(m_centerY - vh / 2.0 / tileSize); int bottom std::floor(m_centerY vh / 2.0 / tileSize); for (int tx left; tx right; tx) { for (int ty top; ty bottom; ty) { int sx qRound((tx - m_centerX) * tileSize vw / 2.0); int sy qRound((ty - m_centerY) * tileSize vh / 2.0); QImage img m_tileMgr-tile(m_zoom, tx, ty); if (!img.isNull()) { p.setRenderHint(QPainter::Antialiasing, false); p.setRenderHint(QPainter::SmoothPixmapTransform, false); p.drawImage(sx - 1, sy - 1, 258, 258, img); } else { p.fillRect(sx, sy, tileSize, tileSize, QColor(0xE0, 0xE0, 0xE0)); m_tileMgr-requestTile(m_zoom, tx, ty); } } } }这里有一段细节值得解释sx qRound((tx - m_centerX) * tileSize vw / 2.0)意思是某张瓦片在屏幕上的位置等于“该瓦片行列号与中心点行列号的差值”乘以每张瓦片的像素大小256再加上视口宽度的一半作为偏移。整套公式直接建立在“瓦片坐标空间”上不需要经过经纬度转换拖拽和缩放时的计算量非常小。4.3 拖拽平移事件处理与一个容易写反的方向拖拽平移的思路很简单鼠标按下时记录当前位置和当时的中心点坐标鼠标移动时用位移差值反推中心点应该移动多少。void MapWidget::mousePressEvent(QMouseEvent *e) { if (e-button() Qt::LeftButton) { m_dragging true; m_lastPos e-pos(); m_lastCenterX m_centerX; m_lastCenterY m_centerY; setCursor(Qt::ClosedHandCursor); } } void MapWidget::mouseMoveEvent(QMouseEvent *e) { if (!m_dragging) return; int dx e-pos().x() - m_lastPos.x(); int dy e-pos().y() - m_lastPos.y(); m_centerX m_lastCenterX - dx / 256.0; m_centerY m_lastCenterY - dy / 256.0; update(); } void MapWidget::mouseReleaseEvent(QMouseEvent *e) { if (e-button() Qt::LeftButton m_dragging) { m_dragging false; unsetCursor(); } }最容易写反的是方向。直觉上是“鼠标往右拖地图跟着往右走”但代码里dx是正的m_centerX却要减小。原因在于鼠标向右拖意味着用户想看“右边”的内容视口向右移动中心点在地图坐标系里其实是在向左移动。想不通的话可以想象用放大镜看一张报纸把放大镜向右移你看到的其实是报纸上偏左的内容。这个方向判断我在初版代码里写过反现象是“地图跟着鼠标走反了”所以单独拎出来提醒一次。4.4 滚轮缩放让鼠标位置不漂移的计算方法地图缩放的难点不在于改变级别而在于缩放前后鼠标指向的地理位置不能“跑掉”。比如鼠标指着某个路口滚动滚轮放大后那个路口应该还停留在鼠标下方。这个效果需要一个锚点计算公式。通用推导逻辑缩放前鼠标点对应的瓦片坐标当前级别是 [ T center \frac{mousePos - viewportCenter}{256} ]将T换算到统一原点下的绝对瓦片坐标除以(2^{zoom})这样切换级别后依然能对应同一个地理点切换缩放级别后用新的缩放倍数反算出中心点坐标。代码可以抽成一个zoomAt函数滚轮和双击都复用它void MapWidget::zoomAt(int step, const QPoint pos) { int newZoom qBound(m_minZoom, m_zoom step, m_maxZoom); if (newZoom m_zoom) return; double scaleOld std::pow(2.0, m_zoom); double absTx (m_centerX (pos.x() - width() / 2.0) / 256.0) / scaleOld; double absTy (m_centerY (pos.y() - height() / 2.0) / 256.0) / scaleOld; m_zoom newZoom; double scaleNew std::pow(2.0, m_zoom); m_centerX absTx * scaleNew - (pos.x() - width() / 2.0) / 256.0; m_centerY absTy * scaleNew - (pos.y() - height() / 2.0) / 256.0; update(); } void MapWidget::wheelEvent(QWheelEvent *e) { int step e-angleDelta().y() 0 ? 1 : -1; zoomAt(step, e-position().toPoint()); } void MapWidget::mouseDoubleClickEvent(QMouseEvent *e) { if (e-button() Qt::LeftButton) { zoomAt(1, e-pos()); } }这个“绝对瓦片坐标”的概念是这个控件的核心它把不同缩放级别下的瓦片编号统一到z0级别让缩放前后能精确对应到同一个地理位置。我最初在缩放时直接做m_centerX * 2这种简单换算结果鼠标锚点总是漂移改成绝对坐标后再也没出过问题。4.5 双击放大与代码复用双击放大直接复用zoomAt(1, pos)不用单独写逻辑。右键可以预留为“回到初始中心点”实现方式是把中心点重置为config.json里记录的中心经纬度对应的瓦片坐标即可。到这里一个支持拖拽、滚轮缩放、双击放大的地图控件已经能跑起来了。接下来要解决的是把它打磨到能交付的程度。5. 上线前要处理的性能、崩溃与打包细节5.1 内存占用估算与缓存淘汰先算一笔账。QImage在Qt 5.15.2里默认用ARGB32_Premultiplied格式存储每像素4字节。一张256x256的瓦片解码后占256KB一个1920x1080的窗口最多显示大约9 x 7 63张瓦片一次性渲染63张瓦片的内存开销只有16MB左右。真正的内存压力来自缓存——如果无限缓存程序用几个小时就会吃掉几个GB。缓存瓦片数估算内存500约128MB1000约256MB2000约512MB所以在TileManager里用QCacheQString, QImage并设置maxCost为2000保证内存始终在可控范围内。另外paintEvent里每帧都在调用m_tileMgr-tile()查询缓存这个函数要保证是O(1)的操作不要在查询路径里做文件IO。文件IO只放在异步加载路径渲染主线程只读内存缓存。5.2 几个我实际踩过的崩溃点第一个是QImage空对象问题。瓦片文件不存在、文件损坏、下载返回的不是有效PNG都会得到空的QImage。绘制前一定要判空否则drawImage在Windows上偶发崩溃。第二个是跨线程访问缓存。如果用了QtConcurrent在线程里加载瓦片加载完成回调里千万不要直接操作QHash缓存要通过信号槽回到主线程再写入缓存。Qt的信号槽跨线程连接是安全的直接操作容器不是。第三个是缩放级别的边界。qBound(m_minZoom, m_zoom step, m_maxZoom)这行不能省否则鼠标滚轮快速滚动时m_zoom可能变成负数或超过20pow(2, zoom)会溢出画面直接花掉甚至崩溃。第四个是release模式下“画面不刷新”的假象。很多人改了m_centerX后会忘记调用update()现象就是松开鼠标后地图没动静。这个严格来说不算崩溃但调试时会浪费很多时间排查顺序可以保持先看有没有调用update再看坐标计算对不对。5.3 发布打包与离线包路径处理打包环节有一个高频问题程序启动后找不到离线瓦片目录。原因大多是把路径写成了相对路径./baidu_tiles而程序从不同工作目录启动时相对路径的基准点不一样。更稳的做法是用QCoreApplication::applicationDirPath()定位可执行文件目录再拼上离线包的相对位置QString offlineDir QCoreApplication::applicationDirPath() /data/maps/baidu_tiles;这个路径不受当前工作目录影响无论从桌面快捷方式、服务管理器还是命令行启动都能找到。Windows下发布用windeployqtwindeployqt.exe --release --no-translations --no-compiler-runtime App.exe如果用了QtConcurrent确认生成的目录里包含Qt5Concurrent.dll和Qt5Core.dll否则程序启动会提示缺少DLL。Linux下打包时注意把离线包目录一并拷贝进安装路径同时确认目标机器上有相应的字体否则中文标注可能出现豆腐块。这套流程我前后调了两个周末最大的感受是地图类项目真正花时间的不是渲染和事件而是坐标系的统一。下载瓦片、控件绘制、GPS轨迹叠加只要坐标系对不上再好看的界面都是错的。做离线地图之前先把坐标系这件事彻底理一遍后面几乎不会再走弯路。
返回列表