资讯动态

基于Qt5 QGraphicsView的无人机地面站地图内核实践

发布时间:2026/10/26 23:47:59 来源:尧图企业网站定制
简介面向无人机地面站与 Qt 地图应用开发者这套 Qt5 互动地图示例以 Qt 5.15.2 和 opmapcontrol 为基础演示多厂商地图接入、地图缓存与街道图切换并支持拖动缩放、航点增删改与保存加载、航点信息显示、Home 点及安全区域设置、运动轨迹展示等交互。资源包含 179 个文件以 h/cpp 源码、png/svg 图标、ui 界面和 pro 工程配置为主压缩包约 1.01MB便于按地图控件、投影转换与网络请求等模块查阅已有 1180 人学习。读者可据此理解地图图层组织、坐标投影、航点数据管理和基础地面站交互流程作为课程设计、毕业设计或二次开发的原型参考快速搭建地图交互框架并迁移到自己的无人机项目中。1. 被地图 SDK 劝退三次之后我选择了自己画去年做一个无人机地面站的演示壳子我第一反应是找现成地图组件结果授权、离线部署、Qt 版本兼容轮番上阵前后被劝退三次。后来换了个思路用 Qt5 的 QGraphicsView 自己搭一块互动地图瓦片自己做调度飞机图标、航点、航线、遥测框全部做成图元压在地图上跑起来就是一块能拖能缩、能跟着飞机走的地面站底图。这套结构适合两类人手里已经有飞控数据链路、只缺一个干净地图外壳的 C 开发者以及想把图形视图框架真正落到项目里的 Qt 学习者。它不依赖浏览器内核不依赖网络地图服务打包出来就是一个绿色目录。2. 工程骨架地图层和业务层怎么切开动手写代码之前先想清楚一件事这块地图以后是要被复用的。今天它服务于无人机地面站明天可能拿去做车辆调度、做设备巡检唯一不能变的是「地图归地图业务归业务」。所以工程骨架的第一原则不是追求跑得快而是让地图内核对外只暴露坐标转换、图元挂载和视野控制这三件事业务层完全不知道瓦片是怎么来的。2.1 为什么选 QGraphicsView 而不是自己拼 QWidget最朴素的写法是放一个大 QWidget在 paintEvent 里手动算瓦片位置、手动贴图、手动处理鼠标事件。几十行能出效果但只要加上「航点可拖动」「航线跟着动」「飞机图标要旋转」这三条需求就会立刻失控——你得自己维护一套命中测试、自己算 z 序、自己处理图元之间的遮挡。QGraphicsView 恰好把这三件事全包了场景坐标和图元坐标系分离、BSP 索引做碰撞查询、zValue 决定绘制顺序。另一个被排除的方案是嵌一个浏览器控件加载网页地图。它上手最快但代价是打包体积、离网可用性和与 C 数据层的通信成本做演示可以做交付很难受。我一般会把地图内核放在core/map下禁止它 include 任何业务头文件业务层的飞机、航点、航线放在station下只允许单向依赖地图内核。这条约束后面会省掉大量重构工作。2.2 目录结构和模块职责拆完之后的目录大概是这样命名可以按团队习惯改分层不要动mapconsole/ ├── app/ │ ├── main.cpp # 入口高分屏属性、日志、样式 │ └── mainwindow.{h,cpp} # 主窗口菜单、状态栏、面板布局 ├── core/ │ ├── map/ │ │ ├── mapview.{h,cpp} # 视图滚轮缩放、拖拽平移、锚点 │ │ ├── mapscene.{h,cpp} # 场景瓦片层 覆盖层两个分组 │ │ ├── tileloader.{h,cpp} # 瓦片下载/读取 内存缓存 去重 │ │ ├── tilelayeritem.{h,cpp} # 瓦片图元按 key 复用 │ │ └── projection.{h,cpp} # 经纬度 - 世界像素 互转 │ └── geo/ │ └── geoutil.{h,cpp} # 距离、方位角、抽稀 ├── station/ │ ├── aircraftitem.{h,cpp} # 飞机图元位置、航向、姿态 │ ├── waypointitem.{h,cpp} # 航点图元可拖动、可选中 │ ├── routeitem.{h,cpp} # 航线连接航点的折线 │ ├── trackitem.{h,cpp} # 航迹历史点抽稀后追加 │ └── telemetrypanel.{h,cpp} # 遥测面板与地图双向联动 ├── link/ │ ├── linkworker.{h,cpp} # 独立线程里的协议收发 │ └── framecodec.{h,cpp} # 帧头/校验/解包可替换 └── resources/ ├── icons/ # 飞机、航点、返航点图标 └── qss/模块对外接口不该做的事MapViewsetCenter(lon,lat)、zoomToFit(rect)、screenToGeo()不碰串口不认识 FlightStateTileLoaderrequest(TileKey)、tileReady信号不关心瓦片画在哪个 z 层WaypointItemgeoPos()、moved()信号不直接发协议帧只发通知LinkWorkerframeParsed(FlightState)不操作任何 QWidget 或图元TelemetryPanelbindState(FlightState)不自己算投影坐标一律走地图内核2.3 启动链路从 main.cpp 到主窗口入口文件要处理的就是三件小事高分屏、应用元信息、主窗口尺寸。看起来没什么可讲但高分屏这一条漏了后面的图标和文字会一起糊。#include QApplication #include mainwindow.h int main(int argc, char *argv[]) { // 必须在 QApplication 构造前设置,否则不生效 QCoreApplication::setAttribute(Qt::AA_EnableHighDpiScaling); QCoreApplication::setAttribute(Qt::AA_UseHighDpiPixmaps); QApplication app(argc, argv); app.setApplicationName(QStringLiteral(MapConsole)); app.setOrganizationName(QStringLiteral(Demo)); MainWindow w; w.resize(1280, 800); w.show(); return app.exec(); }AA_EnableHighDpiScaling打开的是逻辑像素缩放AA_UseHighDpiPixmaps让QPixmap在绘制时按 devicePixelRatio 取高清源图两个只开一个都会出现「尺寸对了但图糊了」或者「图清楚了但界面被放大两次」。如果团队还在 Qt 5.6 以下Qt::AA_EnableHighDpiScaling这个枚举还没有需要考虑升级别在这个坑里绕。主窗口的构造更简单重点是给地图视图设置好拖拽模式和背景填充色避免拖动时露出脏底。MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { m_mapScene new MapScene(this); // 场景内部自建瓦片层和覆盖层 m_mapView new MapView(m_mapScene, this); m_mapView-setDragMode(QGraphicsView::ScrollHandDrag); m_mapView-setRenderHint(QPainter::Antialiasing, true); m_mapView-setRenderHint(QPainter::SmoothPixmapTransform, true); m_mapView-setBackgroundBrush(QColor(#0e1218)); // 瓦片没到位时的底色 setCentralWidget(m_mapView); }ScrollHandDrag省掉了手写mouseMoveEvent里的中心点计算但要注意它和「拖动航点」会抢事件后面第 4 章的WaypointItem里会给出处理办法图元本身设ItemIsMovable并在按下时临时切换视图的拖拽模式。3. 地图内核拆解瓦片、投影和鼠标锚点有了骨架接下来是真正决定手感的三块内容。这三块写对了地图就「顺」写歪了后面叠再多业务功能都是灾难。血泪经验是先在这三块上写两三天测试代码比先画界面再回来补要省事得多。3.1 瓦片金字塔与加载调度瓦片地图的核心是一个 z/x/y 三元组。z 是缩放层级x、y 是该层级下的行列号每升一级世界被切成 4 倍数量的瓦片。视图只需要算出「当前视野覆盖了哪些 z/x/y」把它们丢给加载器加载器负责缓存、去重、异步取回。struct TileKey { int z 0, x 0, y 0; bool operator(const TileKey o) const { return z o.z x o.x y o.y; } }; inline uint qHash(const TileKey k, uint seed 0) { return qHash(k.z * 73856093 ^ k.x * 19349663 ^ k.y * 83492791, seed); } class TileLoader : public QObject { Q_OBJECT public: explicit TileLoader(QObject *parent nullptr) : QObject(parent) { m_cache.setMaxCost(600); // 上限按「块」计,约 600 * 256*256*4 ≈ 150MB } void request(const TileKey key); signals: void tileReady(const TileKey key, const QPixmap pixmap); private: QNetworkAccessManager m_nam; QCacheTileKey, QPixmap m_cache; QSetTileKey m_pending; // 正在请求中,避免同一块重复发 };request()里判断顺序很关键先查缓存再查在途集合最后才发请求。少一个判断快速拖动地图时同一块瓦片会发出好几次请求本地服务端日志立刻刷屏。void TileLoader::request(const TileKey key) { if (QPixmap *hit m_cache.object(key)) { emit tileReady(key, *hit); // 命中缓存,直接同步回吐 return; } if (m_pending.contains(key)) return; m_pending.insert(key); QNetworkRequest req(QUrl(buildTileUrl(key))); req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); QNetworkReply *reply m_nam.get(req); connect(reply, QNetworkReply::finished, this, [this, reply, key] { m_pending.remove(key); reply-deleteLater(); // 漏掉这一行,内存曲线一定是一路向上 if (reply-error() ! QNetworkReply::NoError) return; // 失败静默,下次滚回来再试 QPixmap pm; if (!pm.loadFromData(reply-readAll())) return; m_cache.insert(key, new QPixmap(pm), 1); emit tileReady(key, pm); }); }setMaxCost(600)里的 600 是块数而不是字节数因为每张瓦片的 cost 都填 1。用块数做单位的好处是心里有数600 张 256×256 的 32 位图差不多 150MB 量级。如果目标机器内存紧张把这个数砍到 200300副作用只是回滚视野时重新拉图肉眼几乎看不出。离线场景不用改逻辑把buildTileUrl换成拼本地路径、QNetworkRequest换成QUrl::fromLocalFile就行TileLoader的缓存和去重逻辑一样适用。3.2 经纬度和世界像素的互转场景坐标系如果直接用经纬度会遇到一个致命问题纬度方向的度数长度和经度方向不一样地图会被拉扁。所以标准做法是先投影到「世界像素」坐标系——在 z0 时把整个世界铺成一张 256×256 的方图z 层每加一级边长翻倍。static constexpr double kTileSize 256.0; static constexpr double kMaxLat 85.05112878; // 墨卡托投影有效纬度上限 QPointF lonLatToWorldPixel(double lon, double lat, int zoom) { const double worldSize kTileSize * std::pow(2.0, zoom); const double x (lon 180.0) / 360.0 * worldSize; // 先夹紧纬度,否则 tan 在极点附近会算出无穷大 const double latRad qDegreesToRadians(qBound(-kMaxLat, lat, kMaxLat)); const double y (1.0 - std::log(std::tan(latRad) 1.0 / std::cos(latRad)) / M_PI) / 2.0 * worldSize; return QPointF(x, y); } QPointF worldPixelToLonLat(const QPointF px, int zoom) { const double worldSize kTileSize * std::pow(2.0, zoom); const double lon px.x() / worldSize * 360.0 - 180.0; const double n M_PI - 2.0 * M_PI * px.y() / worldSize; const double lat qRadiansToDegrees(std::atan(std::sinh(n))); return QPointF(lon, lat); }参数说明lon是经度范围 -180180lat是纬度zoom是当前层级范围一般 119。返回的QPointF单位是像素不是度所以它可以直接当成场景坐标用——把某个 zoom 下的世界像素减去当前视野左上角对应的世界像素就得到该点在场景里的位置。这套换算必须全工程只有一份一旦业务代码里出现第二份手写的投影公式后面找偏移问题会找到崩溃。3.3 缩放时锚住鼠标别让地图跑滚轮缩放的默认行为是以视图中心为锚点结果就是你想放大看某个航点滚两下它跑到屏幕外面去了。体验上这叫「地图在跑」实际是锚点选错了。MapView::MapView(QGraphicsScene *scene, QWidget *parent) : QGraphicsView(scene, parent) { // 必须关掉自动锚点,自己接管,否则两次变换会打架 setTransformationAnchor(QGraphicsView::NoAnchor); setResizeAnchor(QGraphicsView::NoAnchor); setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); } void MapView::wheelEvent(QWheelEvent *event) { const QPoint anchor event-pos(); const QPointF sceneBefore mapToScene(anchor); // 缩放前,光标下的场景坐标 const double step event-angleDelta().y() 0 ? 1.15 : 1.0 / 1.15; const double target qBound(0.2, m_scale * step, 64.0); if (qFuzzyCompare(target, m_scale)) return; // 到边界了就别动,免得抖 const double factor target / m_scale; m_scale target; scale(factor, factor); // 缩放之后同一个屏幕点对应的场景坐标变了,把差值补回去 const QPointF delta mapToScene(anchor) - sceneBefore; translate(delta.x(), delta.y()); emit scaleChanged(m_scale); }这里的关键是setTransformationAnchor(NoAnchor)。用AnchorUnderMouse也能实现类似效果但它内部已经做过一次锚点补偿你如果再手动centerOn或translate就等于补了两次缩小时地图会朝反方向滑出去——这个 bug 我调过大半天才定位到。translate()的参数是场景坐标单位不是像素所以缩放级别越大同样的屏幕位移对应的delta越小这也是为什么这段代码在任何缩放倍率下都不会出现「越缩越飘」的累积误差。4. 地面站业务层把飞机和航点画成图元地图能拖能缩之后业务层才有意义。这一层的设计原则是所有图元都把「经纬度」当作唯一真相场景坐标只是经纬度投影后的副产品。这条原则在拖动、缩放、换层级的时候能救命。4.1 航点图元可拖动、可选中、可回传航点是最容易写歪的一个类。新手常犯的错是把pos()当成位置数据拖动之后拿pos()去算经纬度。一旦缩放层级变了、世界像素原点了pos()全错。class WaypointItem : public QGraphicsObject { Q_OBJECT public: enum { Type UserType 1 }; int type() const override { return Type; } explicit WaypointItem(int index, QGraphicsItem *parent nullptr) : QGraphicsObject(parent), m_index(index) { setFlags(ItemIsMovable | ItemIsSelectable | ItemSendsGeometryChanges); setZValue(20); // 压在瓦片和航线之上 setCacheMode(DeviceCoordinateCache); // 拖动时省一次重绘 } QRectF boundingRect() const override { return {-14, -14, 28, 28}; } // 比图标大一圈便于抓取 void paint(QPainter *p, const QStyleOptionGraphicsItem *, QWidget *) override; QPointF geoPos() const { return m_geo; } void setGeoPos(const QPointF geo) { m_geo geo; } signals: void geoMoved(WaypointItem *self); // 拖动结束再发,拖动中不发 protected: QVariant itemChange(GraphicsItemChange change, const QVariant value) override { if (change ItemPositionChange scene()) { // 地图平移时统一重算,不让单个图元自己算 } return QGraphicsObject::itemChange(change, value); } void mouseReleaseEvent(QGraphicsSceneMouseEvent *e) override { QGraphicsObject::mouseReleaseEvent(e); if (scene() view()) { // 反向投影回经纬度,写进 m_geo,这是唯一的真相 m_geo worldPixelToLonLat(scenePos(), currentZoom()); } emit geoMoved(this); } private: int m_index; QPointF m_geo; };boundingRect故意比图标大一圈是因为 16×16 的图标在高分屏上太难点中扩大热区比放大图标更自然。ItemSendsGeometryChanges打开后itemChange才会被调用否则拖完不通知。真正的位置回写放在mouseReleaseEvent里而不是itemChange里是因为拖动过程中每移动一像素就重算航线脏区会爆炸。航点被拖动后主窗口收到geoMoved重算航线并写回飞控形成闭环connect(m_waypoint, WaypointItem::geoMoved, this, [this](WaypointItem *wp) { m_route-rebuild(); // 折线重新连接所有航点 m_link-uploadWaypoint(wp-index(), wp-geoPos()); // 只上传变化的那一个 m_panel-setWaypointCount(m_route-count()); // 面板同步 });注意uploadWaypoint只上传单个航点不要图省事把整条航线重传一遍。航点数量上到三五十个以后全量上传会让链路上出现明显的卡顿。4.2 飞机图元位置、航向、姿态三件事分开算飞机图元和航点图元最大的不同是它更新频率高可能 510Hz。每一次更新要动三样东西位置、航向角、滚转角。三者要分开处理混在一起写会导致「飞机抖动」。void AircraftItem::updateState(const FlightState s) { // 1) 位置:经纬度 - 世界像素 - 场景坐标(减去当前视野原点) const QPointF world lonLatToWorldPixel(s.lon, s.lat, m_zoom); setPos(world - m_worldOrigin); // 2) 航向:图元自身旋转,机头始终朝向前进方向 setRotation(s.heading); // 3) 姿态:横滚只做视觉倾斜,夹紧到 ±35°,避免看起来在翻跟头 m_roll qBound(-35.0, s.roll, 35.0); // 4) 高度用不透明度和阴影表达,别做 3D setOpacity(qBound(0.55, 1.0 - s.alt / 5000.0, 1.0)); update(); // 只重绘自身,不调 scene()-update() }setRotation和setPos都是轻量操作真正贵的是update()触发的重绘。这里必须只更新自己如果写成scene()-update()地图上所有瓦片图元都会被标记为脏区帧率立刻掉一半。高度用透明度表达是一个取舍。想做 3D 倾斜也可以但要引入透视变换矩阵和后面的地图旋转方案会冲突演示项目里不划算。我一般给飞机下面再加一个椭圆阴影阴影跟着高度变大变小视觉上比透明度更直观。如果要做「地图跟着飞机转」的第一人称视角正确做法是旋转整个视图而不是旋转每个图元// 地图旋转时,图元的 setRotation 会和视图旋转叠加,必须补偿 m_aircraft-setRotation(s.heading - m_mapView-mapRotation());这个补偿量算反了飞机就会原地打转是这类项目里最常见的「玄学」问题之一。4.3 遥测面板与地图的双向联动面板和地图是两个方向的数据流。地图到面板点击某个航点面板高亮对应行面板到地图在表格里改了经纬度地图上的图元要跟着动。这种双向绑定如果直接互相调用很快就会绕成死循环。做法是让面板只做「展示 发出意图」不直接改图元// 地图 - 面板 connect(m_scene, MapScene::waypointSelected, m_panel, TelemetryPanel::highlightRow); // 面板 - 地图(注意:用 QueuedConnection 断开调用栈) connect(m_panel, TelemetryPanel::waypointEdited, this, [this](int index, const QPointF geo) { if (auto *wp m_route-waypointAt(index)) { wp-setGeoPos(geo); wp-setPos(lonLatToWorldPixel(geo.x(), geo.y(), currentZoom()) - worldOrigin()); } }, Qt::QueuedConnection);用QueuedConnection是防死循环的关键面板发出信号后立刻返回等事件循环下一轮再处理即使处理过程中又触发了面板刷新也不会在同一个调用栈里循环。状态数据的刷新要做节流。飞控 10Hz 上传遥测面板上的数字如果每个都实时刷QLabel 的setText调用会占掉不少 CPU。我的习惯是攒在一个FlightState结构里用 30Hz 的QTimer统一刷一次界面链路层的数据一个都不丢只是显示合并。4.4 数据链路把协议解析挪出 UI 线程串口读数据本身不慢慢的是在 UI 线程里做「找帧头 校验 解包」。数据量一上来界面就开始一顿一顿的。class LinkWorker : public QObject { Q_OBJECT public slots: void open(const QString portName, int baud) { m_port.setPortName(portName); m_port.setBaudRate(baud); if (!m_port.open(QIODevice::ReadWrite)) emit linkError(m_port.errorString()); connect(m_port, QSerialPort::readyRead, this, LinkWorker::onReadyRead); } signals: void frameParsed(const FlightState state); // 值传递,跨线程别传指针 void linkError(const QString msg); private: void onReadyRead() { m_buffer.append(m_port.readAll()); while (true) { const int idx m_buffer.indexOf(kFrameHead); // 找帧头 if (idx 0) { m_buffer m_buffer.right(3); break; } // 留一点尾巴防截断 if (m_buffer.size() - idx kFrameLen) break; // 半帧,等下次 const QByteArray frame m_buffer.mid(idx, kFrameLen); m_buffer.remove(0, idx kFrameLen); FlightState st; if (FrameCodec::decode(frame, st)) emit frameParsed(st); } } QSerialPort m_port; QByteArray m_buffer; };frameParsed用值传递FlightState是因为信号跨线程时参数会被拷贝进事件队列传指针的话对象生命周期完全不可控。onReadyRead里那个「留 3 字节尾巴」的细节是为了防止帧头正好被 TCP/串口分片切断丢掉之后整帧都找不到。主窗口这边只做一件事把 worker 挪到线程用QueuedConnection接住结果。m_linkThread new QThread(this); m_link new LinkWorker; m_link-moveToThread(m_linkThread); connect(m_linkThread, QThread::finished, m_link, QObject::deleteLater); connect(m_link, LinkWorker::frameParsed, this, MainWindow::onFlightState, Qt::QueuedConnection); m_linkThread-start();m_linkThread-finished连deleteLater这一行不能省否则关窗口时 worker 还挂在已经销毁的线程上退出时必定崩一次。5. 排查清单白块、跑偏、内存涨和界面卡这套东西跑通不难难的是跑稳。下面五条是我在这个项目里真实踩到的按「现象 → 原因 → 解决」整理遇到同类问题可以照着对。5.1 拖动地图时出现白块和闪烁现象快速拖动或连续滚轮缩放时地图上出现一块块纯色空白松手后零点几秒才补上有时候还会整体闪一下。原因每次视野变化都重建了瓦片图元。有的写法是在scrollContentsBy里清空场景再按新视野重新添加图元创建和 pixmap 加载都在主线程里同步做主线程一卡画面就白了。解决瓦片图元按TileKey缓存复用视野变化时只做「更新已有图元的位置 请求缺失的 key」绝不重建。同时把setViewportUpdateMode设成QGraphicsView::SmartViewportUpdate让它自己算最小脏区。如果还想更稳可以在瓦片未到位时先画上一层的低 z 瓦片放大顶替视觉上只是模糊一点不会有白块。5.2 缩放之后地图整体跑偏现象滚轮放大几次原本在屏幕中央的航点跑到角落去了越缩越偏缩回去又对不上。原因锚点补偿做了两次。setTransformationAnchor(AnchorUnderMouse)已经帮你补过一次代码里又调了centerOn(sceneAnchor)或translate()两次补偿方向相反。解决只用一种。要么完全交给AnchorUnderMouse要么设NoAnchor自己算第 3.3 节给的是后者。检查方法很直接在wheelEvent里打一行日志把缩放前后mapToScene(anchor)的值打出来如果两个值差得很多就是补偿没生效或者补多了。5.3 挂机几小时内存一路涨现象启动时 90MB跑四小时到 800MB关掉地图才降下来。原因通常有三个得逐个排QNetworkReply没调deleteLater瓦片缓存setMaxCost没设或设成字节数但 cost 填的是 1航迹图元的点无限追加QPainterPath越来越长。解决第一每个 reply 的finished回调里第一件事就是deleteLater第二QCache的 cost 单位必须和insert时传的值一致用块数就全程用块数第三航迹按距离抽稀两点间距小于一定阈值就不追加同时限制总点数比如 5000 点超了从头裁。有条件的团队可以直接挂一个定时器每 30 秒打一行scene()-items().size()和进程 RSS涨得不对一眼就能看出来。5.4 航点位置和实际位置差一截现象飞控回传的经纬度贴到地图上落点和实际地形对不上偏几十米到几百米缩放层级越高偏得越明显。原因底图瓦片和飞控数据用了两套不同的坐标基准其中一套带偏移。两个坐标系的经纬度直接混用误差随纬度变化看起来就像「地图不准」。解决全工程只认一套基准我给项目统一成 WGS84所有底图数据在入库前先转换成同一套转换逻辑封装在geoutil里业务层调toDisplayGeo()/toRawGeo()两个函数谁都不许自己加减偏移量。这类问题最忌讳「哪里偏了就在哪里加个常数」加到最后没人知道常数代表什么。5.5 数据一多界面就卡顿现象飞控 10Hz 上报时界面还算流畅一旦接入多条链路或者做轨迹回放整个窗口拖动都变粘。原因信号槽用了DirectConnection解析和界面刷新挤在同一个线程或者每个数据帧都触发一次全场景重绘。解决解析层必须在线程里UI 层只用QueuedConnection收结果界面刷新做节流用一个 3060Hz 的定时器合并刷新绘图时严格区分update()只标脏自己和scene()-update()标脏全部。另外检查一下有没有在paint()里创建对象——QPainterPath、QPen、字体如果每帧 new 一次性能会掉得莫名其妙。提示排查卡顿先看帧率和 CPU 占用是不是同步上涨。如果 CPU 不高但画面卡大概率是事件循环被阻塞如果 CPU 打满才去看重绘和绘制路径。6. 二次开发从演示壳子到能交付的地面站这套结构真正值钱的地方在于每一层都能单独换掉。要改成能交付的版本我一般按下面三条路径动手。第一条是离线化。TileLoader里把 URL 拼装抽成虚函数或者策略类联网版走 HTTP离线版走QUrl::fromLocalFile瓦片按z/x/y存成目录打包时整个瓦片目录放进resources用QDir递归拷贝到安装目录。要注意离线包体积110 级全国范围差不多几百 MB交付前必须按实际作业区域裁剪只留目标省市的层级。第二条是协议适配。FrameCodec定义成纯接口virtual bool decode(const QByteArray, FlightState) 0换飞控只换实现类LinkWorker和上层一行不动。FlightState里字段只放业务语义经纬度、航向、电量、飞行模式不放任何协议相关的字段偏移否则换个协议整个 UI 层都要跟着改。第三条是性能验证。改完别只靠肉眼写一个小工具用QElapsedTimer统计连续 1000 帧updateState的耗时分布用QGraphicsScene::items(Qt::IntersectsItemBoundingRect)统计当前场景图元数量再手动跑一遍「快速拖动 连续缩放 10Hz 数据注入」的压力组合。我常用的一个具体技巧是给瓦片层单独关掉 BSP 索引// 瓦片数量上千,且从不需要被命中测试,关掉索引省下建树和查询开销 m_tileGroup-setHandlesChildEvents(false); m_scene-setItemIndexMethod(QGraphicsScene::BspTreeIndex); // 覆盖层保留索引覆盖层飞机、航点、航线必须留着索引因为拖动和选中都靠它做命中测试瓦片层是纯背景关掉之后拖动流畅度会有肉眼可见的提升。这个开关很容易被忽略但它是绝大多数「地图场景图元一多就卡」的根因。最后一个提醒别把经纬度、世界像素、场景坐标三者混着传。我在早期版本里图省事图元之间直接传QPointF结果一套代码里同时存在三种含义的QPointF调试时打印出来全是数字根本分不清是度还是像素一个偏移 bug 查了整整两天。从那以后我每次加新图元类都强制在命名上带Geo或Pixel后缀geoPos()和scenePos()一眼能分开。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑