基于Qt5 QGraphicsView的无人机地面站地图内核实践
简介:面向无人机地面站与 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/| 模块 | 对外接口 | 不该做的事 |
|---|---|---|
MapView | setCenter(lon,lat)、zoomToFit(rect)、screenToGeo() | 不碰串口,不认识 FlightState |
TileLoader | request(TileKey)、tileReady信号 | 不关心瓦片画在哪个 z 层 |
WaypointItem | geoPos()、moved()信号 | 不直接发协议帧,只发通知 |
LinkWorker | frameParsed(FlightState) | 不操作任何 QWidget 或图元 |
TelemetryPanel | bindState(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; QCache<TileKey, QPixmap> m_cache; QSet<TileKey> 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 量级。如果目标机器内存紧张,把这个数砍到 200~300,副作用只是回滚视野时重新拉图,肉眼几乎看不出。
离线场景不用改逻辑,把buildTileUrl换成拼本地路径、QNetworkRequest换成QUrl::fromLocalFile就行,TileLoader的缓存和去重逻辑一样适用。
3.2 经纬度和世界像素的互转
场景坐标系如果直接用经纬度,会遇到一个致命问题:纬度方向的度数长度和经度方向不一样,地图会被拉扁。所以标准做法是先投影到「世界像素」坐标系——在 z=0 时把整个世界铺成一张 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是经度,范围 -180~180;lat是纬度;zoom是当前层级,范围一般 1~19。返回的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 飞机图元:位置、航向、姿态三件事分开算
飞机图元和航点图元最大的不同是它更新频率高,可能 5~10Hz。每一次更新要动三样东西:位置、航向角、滚转角。三者要分开处理,混在一起写会导致「飞机抖动」。
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收结果;界面刷新做节流,用一个 30~60Hz 的定时器合并刷新;绘图时严格区分update()(只标脏自己)和scene()->update()(标脏全部)。另外检查一下有没有在paint()里创建对象——QPainterPath、QPen、字体如果每帧 new 一次,性能会掉得莫名其妙。
提示:排查卡顿先看帧率和 CPU 占用是不是同步上涨。如果 CPU 不高但画面卡,大概率是事件循环被阻塞;如果 CPU 打满,才去看重绘和绘制路径。
6. 二次开发:从演示壳子到能交付的地面站
这套结构真正值钱的地方在于每一层都能单独换掉。要改成能交付的版本,我一般按下面三条路径动手。
第一条是离线化。TileLoader里把 URL 拼装抽成虚函数或者策略类,联网版走 HTTP,离线版走QUrl::fromLocalFile,瓦片按z/x/y存成目录;打包时整个瓦片目录放进resources,用QDir递归拷贝到安装目录。要注意离线包体积,1~10 级全国范围差不多几百 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()一眼能分开。希望帮到你。
本文还有配套的精品资源,点击获取