简介:面向需要绘制网络拓扑图及节点关系的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 而不是直接 paintEvent
QGraphicsView的核心是三层结构: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 QList<QRectF>&) { 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(); QHash<int, 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的update:
connect(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 QList<QRectF>&) { timer->start(); }); connect(timer, &QTimer::timeout, this, [=]() { scene->setSceneRect(scene->itemsBoundingRect().adjusted(-200,-200,200,200)); });这样多次位置变化只在200ms内合并成一次sceneRect调整,避免滚动条范围频繁抖动。节点数量超过1000时,建议把设备图标换成预渲染QPixmap,在paint里用drawPixmap替代drawEllipse+drawText,文本单独放在QGraphicsSimpleTextItem子项里,这样可以进一步降低图元绘制开销。注意,QGraphicsScene::itemsBoundingRect()在场景为空时会返回一个无效QRectF,需要判断isNull()再设置,否则视图的滚动范围会被重置到一个很怪的位置。
本文还有配套的精品资源,点击获取