ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Qt+QGraphicsView实现可拖动网络拓扑图:不连接数据库也能无限绘制

Qt+QGraphicsView实现可拖动网络拓扑图:不连接数据库也能无限绘制 简介面向需要绘制网络拓扑图及节点关系的Qt开发者这份代码基于Qt框架通过连接SQL Server数据库获取节点数据利用递归函数解析节点层级关系并计算坐标实现无限节点绘制与节点自由拖动。资源共包含16个文件以5个cpp源文件和4个h头文件为主附带png、jpg格式的节点与背景图片、qrc资源文件以及pro工程配置压缩包仅341KB结构简洁便于直接编译参考。已有2584人学习下载适合学习Qt图形视图框架、递归遍历算法以及数据库与界面联动的开发者。代码中数据库连接模块默认禁用熟悉SQL Server的读者只需放开connectSqlServer()并配置对应数据表即可恢复完整功能同时可参考其节点坐标计算与拖动交互的具体实现快速迁移到自己的网络管理或可视化项目中。1. 从一张可拖动的网络拓补图说起用Qt绘制网络拓补图最麻烦的不是画几个矩形和几条连线而是让图、数据库、交互状态三者始终同步。标题里提到的“连接数据库、无限绘制、可拖动节点”正好对应了这套系统的三个关键点数据要持久化画布要能向任意方向扩展节点要能用手拖。这个场景常见于网络运维平台、机房监控和SDN控制器前端通常由Qt/C实现而QGraphicsView框架是最常被选中的底座。很多初学者会把所有图元直接画在一个固定大小的Widget上结果是节点一多就溢出坐标一变就乱。真正可用的方案是让场景坐标与视图分离用Qt的图形视图框架管理大量图元再通过数据库保存设备与链路的属性。读完这套做法你能搭出一个支持任意扩展、拖动不闪烁、数据能落地的拓补图原型新手可以直接复现熟手也能从最后的性能排错里找到可继续深入的边界。2. Qt绘网络拓补图坐标系与无限绘制的关系很多从QWidget画图转过来的人第一反应是重写paintEvent把设备和线路全画在一个控件上。这种做法在10个节点内还能用一旦节点超过50个滚动、缩放、碰撞检测都要自己写而且每次paint都要重算全部坐标。QGraphicsView框架把场景坐标和视图坐标分开视口只渲染落在可见区域内的图元这正是“无限绘制”的现实前提画布不是真的无限而是在浮点数坐标里足够大并且场景矩形跟随内容扩展。2.1 为什么主选 QGraphicsView 而不是直接 paintEventQGraphicsView的核心是三层结构QGraphicsScene保存所有图形项QGraphicsView是显示窗口QGraphicsItem是单个图元。节点和连线都是Item拖拽时只更新Item的位置重绘由框架按脏矩形合并触发不需要手动update整个窗口。这比在paintEvent里手工管理区域刷新要省力得多也让“无限绘制”变为了一个坐标范围问题而不再是一个绘制性能问题。我一般会这样搭建最小场景QGraphicsScene *scene new QGraphicsScene(this); QGraphicsView *view new QGraphicsView(scene, this); view-setRenderHint(QPainter::Antialiasing); view-setDragMode(QGraphicsView::RubberBandDrag); view-setViewportUpdateMode(QGraphicsView::BoundingRectViewportUpdate); view-setSceneRect(scene-itemsBoundingRect().adjusted(-50, -50, 50, 50));这段代码里最关键的是最后两行。BoundingRectViewportUpdate让视图在拖拽大批图元时不会每帧重绘整个画布而是按图元包围盒合并后的区域刷新setSceneRect则决定了视口能滚动的范围。如果一直沿用初始的sceneRect即使你把节点坐标设到几千滚动条也不会出现画布看起来就像被“卡住”了。2.2 场景坐标、视图坐标与“无限”画布QGraphicsScene的坐标类型是qreal在绝大多数平台上是double也就是说逻辑坐标范围够大。真正影响“无限”的是QGraphicsView默认的sceneRect行为。如果没显式设置视图会根据场景内所有item的包围盒推算可滚动范围。问题在于当所有item聚集在一块小区域时用户无法向空白处继续拖拽或放置新节点。2.2.1 动态扩展 sceneRect 让画布跟随节点常见做法是监听scene的changed信号每次有item位置变化时重新计算包围盒并适当扩边connect(scene, QGraphicsScene::changed, this, [scene, view](const QListQRectF) { QRectF rect scene-itemsBoundingRect(); scene-setSceneRect(rect.adjusted(-200, -200, 200, 200)); });这段逻辑说明只要用户把节点拖到更远位置itemsBoundingRect就会变大场景矩形跟着扩出200像素的边距滚动条自动出现。节点一旦删除或缩回场景矩形也随之收缩。用这种方法实现“无限绘制”并不需要真正把sceneRect设成一个巨大的固定值那样反而会让滚动条范围过大、缩放定位不直观。2.2.2 为什么不用超大固定 sceneRect有人会直接把sceneRect设为(-100000, -100000, 200000, 200000)认为这就是“无限”。但要注意QGraphicsView的滚动条精度受限于int坐标超大场景在缩放时会损失精度而且itemsBoundingRect()返回的QRectF一旦为空视图会重新回到不可控状态。动态扩展的方式更稳也符合拓扑图“内容在哪画布就到哪”的使用直觉。2.3 缩放与拖动带来的坐标换算既然画布能滚动用户必然要缩放。视图的transform()里包含缩放系数把鼠标屏幕坐标转成场景坐标时要用view-mapToScene(pos)而不是直接从event里取坐标。这个细节在拖拽节点时特别容易踩坑如果你在viewport的mouseMoveEvent里直接用event-pos()不经过mapToScene实际命中位置会随缩放级别跑偏。QPointF scenePos view-mapToScene(event-pos()); QGraphicsItem *item scene-itemAt(scenePos, view-transform());参数说明event-pos()是视图坐标view-transform()中的缩放矩阵用于逆变换把视图坐标准确映射到场景坐标。箭头等自定义图元还需要在paint里处理option-levelOfDetailFromTransform(painter-transform())否则缩放后文字和线宽要么糊成一团要么粗得占满屏幕。坐标类型用途换算方法场景坐标节点和连线的逻辑位置直接作为item的pos视图坐标鼠标事件、视口内位置view-mapToScene(mapFromGlobal())设备坐标像素渲染一般不需要手工处理3. 连接数据库把设备表变成可渲染的图元拓补图不是画完就完设备名称、IP、端口、状态这些元数据通常都来自运维数据库。Qt的QSql模块封装了数据库连接、查询和模型映射支持QSQLITE、QMYSQL、QPSQL等驱动。我一般建议先用SQLite做本地原型因为不需要单独启动服务把SQL文件导入后就能验证。真正上线再换成MySQL代码只需要改连接参数。3.1 Qt SQL 连接与驱动选择连接数据库的第一步是检查驱动是否编译进Qt。在pro文件里加QT sql然后运行qDebug() QSqlDatabase::drivers();如果列表里没有QSQLITE或QMYSQL说明Qt安装时没带对应插件需要重新安装或编译插件。常见情况下Windows上使用Qt自带的SQLite驱动无需额外部署MySQL驱动则要libmysql.dll放到可执行目录。建立连接的常用写法QSqlDatabase db QSqlDatabase::addDatabase(QSQLITE); db.setDatabaseName(topology.db); if (!db.open()) { qWarning() database open failed: db.lastError().text(); return false; } QSqlQuery query(db); query.exec(SELECT id, name, ip, x, y FROM device ORDER BY id);参数说明addDatabase的第二个参数可以指定连接名不写则使用默认连接。在多线程场景下每个线程要有独立的连接对象不能在主线程创建连接后直接到工作线程使用。x和y字段是当初保存的节点坐标没有坐标的设备需要随机初始化这部分放到最后一章再说。3.2 两张核心表device 与 link网络拓补图的数据模型至少需要两张表。device表保存节点本身link表保存两个设备之间的网络链路。CREATE TABLE device ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, ip TEXT, status INTEGER DEFAULT 0, x REAL DEFAULT 0, y REAL DEFAULT 0 ); CREATE TABLE link ( id INTEGER PRIMARY KEY AUTOINCREMENT, src_id INTEGER NOT NULL, dst_id INTEGER NOT NULL, bandwidth INTEGER DEFAULT 1000, FOREIGN KEY(src_id) REFERENCES device(id), FOREIGN KEY(dst_id) REFERENCES device(id) );这张表结构里x和y不是必须的但强烈建议保留。没有坐标字段每次打开程序都要重新随机布点有了坐标字段拓补图就能还原用户上次拖动后的布局。状态值0/1可以关联颜色比如断线节点标红。链路表只存“谁连谁”线型、带宽等展示属性可以在读取后计算。3.3 从查询结果生成节点和连线读取设备数据并创建图元典型的实现是QSqlQuery devQuery(db); devQuery.prepare(SELECT id, name, ip, x, y FROM device); devQuery.exec(); QHashint, NodeItem* nodeMap; while (devQuery.next()) { int id devQuery.value(0).toInt(); QString name devQuery.value(1).toString(); QPointF pos(devQuery.value(3).toDouble(), devQuery.value(4).toDouble()); NodeItem *node new NodeItem(id, name); node-setPos(pos); scene-addItem(node); nodeMap.insert(id, node); } QSqlQuery linkQuery(db); linkQuery.prepare(SELECT src_id, dst_id, bandwidth FROM link); linkQuery.exec(); while (linkQuery.next()) { NodeItem *src nodeMap.value(linkQuery.value(0).toInt()); NodeItem *dst nodeMap.value(linkQuery.value(1).toInt()); if (!src || !dst) continue; EdgeItem *edge new EdgeItem(src, dst); edge-setBandwidth(linkQuery.value(2).toInt()); scene-addItem(edge); }这里有几个容易踩的坑。第一必须先用device查询把nodeMap建好再去遍历link否则维表引用会找不到目标。第二场景中Item的添加顺序会影响绘制层级连线最好在节点之后添加。第三如果数据库里存了已被删除的链路需要跳过否则后面算锚点时会出现空指针。4. 可拖动节点重写 Item 之后的坐标与事件处理连接数据库之后拓补图只完成了数据到图元的映射。用户真正会天天碰到的交互是把节点拖动到合适的位置。在QGraphicsItem里做拖动不需要处理复杂的鼠标全局坐标只需要重写mousePressEvent、mouseMoveEvent和mouseReleaseEvent利用setFlag(QGraphicsItem::ItemIsMovable)就能获得基础拖动能力。但如果要拖动时实时更新链路走向、记录落下坐标需要多做几步。4.1 NodeItem 最小实现自定义节点一般既要显示图标或状态颜色又要显示设备名。做法是重写boundingRect确定可绘制范围再在paint里画圆形和文字class NodeItem : public QGraphicsItem { public: NodeItem(int id, const QString name) : QGraphicsItem(), m_id(id), m_name(name) { setFlag(QGraphicsItem::ItemIsMovable, true); setFlag(QGraphicsItem::ItemSendsGeometryChanges, true); } QRectF boundingRect() const override { return QRectF(-25, -25, 50, 50); } void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override { Q_UNUSED(widget); painter-setPen(QPen(Qt::darkBlue, 2)); painter-setBrush(m_selected ? Qt::lightGray : Qt::white); painter-drawEllipse(QPointF(0, 0), 20, 20); painter-drawText(-25, 32, 50, 20, Qt::AlignCenter, m_name); } QVariant itemChange(GraphicsItemChange change, const QVariant value) override { if (change ItemPositionHasChanged) { emit positionChanged(); } return QGraphicsItem::itemChange(change, value); } signals: void positionChanged(); };这里的关键是ItemSendsGeometryChanges标志。没有这个标志itemChange在位置变化时不会被调用你也拿不到“节点已经移动”的通知。拖动结束后要保存坐标也是依赖这个信号。标志作用ItemIsMovable允许鼠标拖动ItemIsSelectable允许选中ItemSendsGeometryChanges在几何变化前后发送通知ItemIsFocusable允许键盘焦点4.2 让连线跟随节点移动直线连线的数据模型最简单的是保存起点和终点item指针重算boundingRect和paint时动态取两者坐标QRectF EdgeItem::boundingRect() const { if (!m_src || !m_dst) return QRectF(); return QRectF(m_src-pos(), m_dst-pos()).normalized().adjusted(-5, -5, 5, 5); } void EdgeItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) { Q_UNUSED(widget); painter-setPen(QPen(Qt::gray, 1.5)); painter-drawLine(m_src-pos(), m_dst-pos()); }注意EdgeItem本身没有位置它的pos()是(0,0)所有绘制都用场景绝对坐标。为保证拖动时连线实时刷新两个NodeItem的positionChanged信号都要连接到EdgeItem的updateconnect(src, NodeItem::positionChanged, edge, EdgeItem::update); connect(dst, NodeItem::positionChanged, edge, EdgeItem::update);这样拖动节点时连线会跟随重绘而不需要手动调用scene-update()。如果链路很多比如某个设备下挂几百条链路每次都update整条边的包围盒仍会拖慢速度后文会提到把EdgeItem的绘制简化成一张预渲染的QGraphicsPixmapItem之外的做法。4.3 拖拽中的命中与防误触ItemIsMovable默认会响应整块boundingRect内的鼠标事件。若节点上有文字区域或状态图标希望这些区域点击时不触发拖动可以单独重写mousePressEvent判断按下位置void NodeItem::mousePressEvent(QGraphicsSceneMouseEvent *event) { if (event-button() Qt::LeftButton) { QPointF p event-pos(); if (p.manhattanLength() 25) { event-ignore(); return; } m_dragOffset event-scenePos() - pos(); } QGraphicsItem::mousePressEvent(event); } void NodeItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) { if (m_dragOffset.isNull()) { QGraphicsItem::mouseMoveEvent(event); return; } setPos(event-scenePos() - m_dragOffset); event-accept(); }m_dragOffset用于记录鼠标按下位置与节点中心的偏移避免拖动时节点跳到鼠标点。很多人在这一步直接用event-scenePos()设置坐标导致节点中心瞬间贴近鼠标视觉上像是“抖了一下”。4.4 页面缩放时拖动坐标的偏差当视图缩放比例不是100%时scenePos和viewPos携带的精度仍够用但setPos后触发itemChange通知。此时如果数据库读取到的坐标是整数格点而用户在缩放下执行了细粒度拖动建议把最终坐标做一次四舍五入void NodeItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) { setPos(qRound(pos().x()), qRound(pos().y())); emit positionChanged(); QGraphicsItem::mouseReleaseEvent(event); }这样写数据库时不会出现一长串浮点尾数也方便后续做坐标对齐。参数表里可以记一下qRound返回最近整数对300.4999这类值会取300避免格式问题。5. 进阶技巧坐标持久化、性能验证与常见坑点最后一章不再重新搭架子而是给上面的代码补齐收尾。坐标写回数据库的时机我通常不放在每次mouseMove因为拖动过程中会触发几十次信号每次执行SQL会让界面卡顿。正确做法是在mouseReleaseEvent里调用落库函数把x和y更新到device表void MainWindow::saveNodePosition(int id, QPointF pos) { QSqlQuery query; query.prepare(UPDATE device SET x :x, y :y WHERE id :id); query.bindValue(:x, pos.x()); query.bindValue(:y, pos.y()); query.bindValue(:id, id); query.exec(); }这个时机选择很重要拖动过程中只更新内存里的Item位置松开鼠标才写数据库既保证交互流畅也不会反复触发磁盘IO。如果要支持撤销重做可以在ItemPositionHasChanged而不是ItemPositionChange里收集旧坐标放入QUndoStack。关于重绘性能最简单有效的验证办法不是看FPS而是看viewport-updateScene传给drawBackground的矩形范围。用一个带边框的调试矩形即可。如果拖动节点时矩形覆盖了整个画布说明场景rect被动态扩展代码改得太频繁可以加一个200ms的定时器合并QTimer *timer new QTimer(this); timer-setInterval(200); connect(scene, QGraphicsScene::changed, this, [](const QListQRectF) { timer-start(); }); connect(timer, QTimer::timeout, this, []() { scene-setSceneRect(scene-itemsBoundingRect().adjusted(-200,-200,200,200)); });这样多次位置变化只在200ms内合并成一次sceneRect调整避免滚动条范围频繁抖动。节点数量超过1000时建议把设备图标换成预渲染QPixmap在paint里用drawPixmap替代drawEllipsedrawText文本单独放在QGraphicsSimpleTextItem子项里这样可以进一步降低图元绘制开销。注意QGraphicsScene::itemsBoundingRect()在场景为空时会返回一个无效QRectF需要判断isNull()再设置否则视图的滚动范围会被重置到一个很怪的位置。本文还有配套的精品资源点击获取
返回列表