简介:这是一份基于 Qt 实现的网络拓扑图绘制示例工程,面向具备一定 Qt 基础、希望掌握图形化拓扑展示与数据库联动的开发者。它通过连接 SQL Server 获取全部节点数据,借助递归函数解析节点之间的层级与连接关系,并自动计算每个节点的布局位置,支持无限绘制与节点拖动,可用于网络监控、设备管理或教学演示等场景。压缩包共 16 个文件,约 341KB,包含 5 个 cpp 与 4 个 h 源文件承载核心逻辑,另有 png、jpg 图片资源用于节点图标,以及 qrc、pro 等工程配置,结构紧凑、便于直接编译运行。作者已默认禁用数据库连接部分,熟悉 SQL Server 的读者可放开 connectSqlServer() 并配置数据表即可接入真实数据。目前已有 2586 人学习下载,适合作为 Qt 图形视图与递归布局的实战参考。
1. 从一张“永远画不完”的拓扑图说起
很多做运维监控或工控上位机的同行都遇到过这种需求:设备清单存在数据库里,拓扑图要能无限扩展,节点还得能用鼠标拖着走。第一次接到这个需求时,我下意识觉得“Qt 画个图能有多难”,结果真动手才发现,难点根本不在画线,而在数据驱动 + 无限画布 + 拖拽交互这三件事怎么捏到一起。标题里的“qt绘制网络拓补图(连接数据库,无限绘制,可拖动节点)”,拆开看就是三个技术点:用 Qt 的图形视图框架做渲染底座,从数据库动态拉取节点和链路数据,再通过自定义图元实现可拖拽和无限延伸的画布。这套方案适合做设备拓扑、产线组态、机房布线图这类场景,新手能照着跑通最小闭环,熟手能直接拿去改造成生产工具。下面我按自己踩过的路,把选型、建库、画图、拖拽、避坑一条条讲清楚。
2. 为什么选 QGraphicsView 而不是 QPainter 硬画
2.1 三种 Qt 绘图路线的取舍
Qt 里画拓扑图,常见做法有三条:纯 QWidget + QPainter 自绘、QGraphicsView 图形视图框架、以及嵌入 QML 的 Canvas。我最早用 QPainter 硬画过一版,节点少的时候没问题,一旦节点过百,每次拖动都要重绘整个画布,帧率直接掉到个位数,血泪经验就是“别跟重绘区域较劲”。QGraphicsView 自带场景图(scene graph)和空间索引,拖动单个节点时只重绘受影响区域,而且它天然支持图元级别的鼠标事件、坐标变换和缩放平移,做无限画布几乎是顺手的事。QML 方案渲染性能也好,但和传统 QWidget 数据库程序混用会引入额外的上下文切换成本,团队里如果没人熟 QML,维护起来就是黑匣子。
所以我的选型结论很明确:QGraphicsView + 自定义 QGraphicsItem。它把“画什么”和“怎么交互”解耦了,节点是独立对象,链路是独立对象,数据库来的数据只需要映射成这些对象就行。
2.2 最小可运行骨架:场景、视图、图元三件套
先搭一个能跑起来的最小骨架,不连数据库,手动塞两个节点和一条线,确认渲染和拖拽没问题再往上加数据层。
# main.py —— PyQt5 最小拓扑骨架 import sys from PyQt5.QtWidgets import QApplication, QGraphicsView, QGraphicsScene, QGraphicsEllipseItem from PyQt5.QtCore import Qt class TopoView(QGraphicsView): def __init__(self): super().__init__() self.scene = QGraphicsScene(self) self.setScene(self.scene) self.setRenderHint(self.renderHints()) # 开启抗锯齿 self.setDragMode(QGraphicsView.RubberBandDrag) # 框选模式 self.setSceneRect(-5000, -5000, 10000, 10000) # 无限画布的关键:给一个超大场景矩形 # 手动放两个节点 n1 = QGraphicsEllipseItem(0, 0, 80, 40) n1.setPos(100, 100) n1.setFlag(QGraphicsEllipseItem.ItemIsMovable) # 可拖动 n1.setFlag(QGraphicsEllipseItem.ItemIsSelectable) self.scene.addItem(n1) n2 = QGraphicsEllipseItem(0, 0, 80, 40) n2.setPos(400, 300) n2.setFlag(QGraphicsEllipseItem.ItemIsMovable) n2.setFlag(QGraphicsEllipseItem.ItemIsSelectable) self.scene.addItem(n2) if __name__ == '__main__': app = QApplication(sys.argv) view = TopoView() view.resize(900, 600) view.show() sys.exit(app.exec_())这段代码里有两个参数值得说清楚。setSceneRect给了一个 10000×10000 的场景矩形,这是“无限绘制”的物理基础——场景坐标可以远超屏幕,视图只是开了一个窗口在看它。ItemIsMovable是 Qt 内置的拖拽开关,打开后图元就能被鼠标拖动,但注意它拖的是图元自身坐标,链路不会自动跟着更新,后面要自己处理。RubberBandDrag让空白处拖拽变成框选,和节点拖拽不冲突,这是交互设计上的一个小细节。
2.3 无限画布到底“无限”在哪
很多人以为无限画布是视图能无限滚动,其实核心在场景坐标系。QGraphicsView 的视口是有限的,但场景可以是任意大。你只需要在节点被拖到边缘时动态扩展sceneRect,或者一开始就给一个足够大的范围。我一般会在视图的mouseMoveEvent里判断:如果当前鼠标位置接近 sceneRect 边界,就把 sceneRect 向外扩 2000 像素。这样用户感觉画布永远拖不到头,实际是场景矩形在悄悄长大。注意别一次性给太大,比如 1e9,某些平台下滚动条计算会溢出,20000 到 50000 是比较稳的量级。
3. 把数据库里的设备表变成场景里的节点
3.1 表结构设计:节点表和链路表怎么分
拓扑图的数据模型就两张表:节点表和链路表。节点表存设备唯一标识、名称、类型、坐标;链路表存源节点、目标节点、带宽或状态。坐标一定要存,否则每次打开图都重新布局,用户拖好的位置就丢了,这是后悔药都买不到的设计失误。
-- 节点表 CREATE TABLE topo_node ( node_id VARCHAR(64) PRIMARY KEY, node_name VARCHAR(128) NOT NULL, node_type VARCHAR(32) DEFAULT 'switch', pos_x DOUBLE DEFAULT 0, pos_y DOUBLE DEFAULT 0 ); -- 链路表 CREATE TABLE topo_link ( link_id INTEGER PRIMARY KEY AUTOINCREMENT, src_id VARCHAR(64) NOT NULL, dst_id VARCHAR(64) NOT NULL, link_label VARCHAR(64), FOREIGN KEY (src_id) REFERENCES topo_node(node_id), FOREIGN KEY (dst_id) REFERENCES topo_node(node_id) );这里用 SQLite 举例是因为它零配置,换成 MySQL 或 PostgreSQL 只需要改连接串。pos_x、pos_y用 DOUBLE 而不是整数,因为缩放后坐标会有小数。链路表的外键约束能防止出现指向不存在节点的脏链路,但如果你做的是动态发现拓扑,外键反而会碍事,那种场景建议去掉外键,在应用层做校验。
3.2 用 QSqlTableModel 还是手写查询
Qt 提供了 QSqlTableModel 这种模型类,能直接把表映射成模型,但拓扑图需要的是“节点对象 + 链路对象”的图结构,不是表格视图。我试过用 QSqlTableModel 再转换,多一层映射反而绕。常见做法是手写 QSqlQuery 查询,把结果集组装成字典,再批量创建图元。数据量在几千条以内,一次性加载完全没问题;上万条就要考虑分页或按视口范围懒加载,那是另一个话题。
# db_loader.py —— 从 SQLite 读取节点和链路 import sqlite3 def load_topology(db_path): conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row cur = conn.cursor() nodes = {} for row in cur.execute("SELECT node_id, node_name, node_type, pos_x, pos_y FROM topo_node"): nodes[row["node_id"]] = { "name": row["node_name"], "type": row["node_type"], "x": row["pos_x"], "y": row["pos_y"], } links = [] for row in cur.execute("SELECT src_id, dst_id, link_label FROM topo_link"): if row["src_id"] in nodes and row["dst_id"] in nodes: links.append((row["src_id"], row["dst_id"], row["link_label"])) conn.close() return nodes, linksrow_factory = sqlite3.Row让结果可以按列名访问,比下标可读。链路加载时做了一次存在性过滤,避免脏数据导致画线时找不到端点而崩溃。这个函数返回的nodes字典和links列表,就是下一步创建图元的输入。
3.3 节点图元:继承 QGraphicsItem 还是用现成 Item
Qt 自带的 QGraphicsEllipseItem、QGraphicsRectItem 能直接当节点用,加上ItemIsMovable就能拖。但生产环境通常要自定义外观——比如不同设备类型不同颜色、带图标、带端口标签。这时候继承 QGraphicsItem 重写paint和boundingRect更灵活。我一般会做一个NodeItem(QGraphicsItem),内部根据node_type画不同形状,同时把node_id存成属性,方便链路查找。
# node_item.py —— 自定义节点图元 from PyQt5.QtWidgets import QGraphicsItem from PyQt5.QtCore import QRectF, Qt from PyQt5.QtGui import QBrush, QPen, QColor, QFont TYPE_COLORS = { "switch": QColor(70, 130, 180), "router": QColor(60, 179, 113), "server": QColor(205, 92, 92), } class NodeItem(QGraphicsItem): def __init__(self, node_id, name, ntype, x, y): super().__init__() self.node_id = node_id self.name = name self.ntype = ntype self.setPos(x, y) self.setFlag(QGraphicsItem.ItemIsMovable) self.setFlag(QGraphicsItem.ItemIsSelectable) self.setFlag(QGraphicsItem.ItemSendsGeometryChanges) # 位置变化时通知链路 def boundingRect(self): return QRectF(-50, -20, 100, 40) def paint(self, painter, option, widget=None): color = TYPE_COLORS.get(self.ntype, QColor(150, 150, 150)) painter.setBrush(QBrush(color)) painter.setPen(QPen(Qt.black, 1.5)) painter.drawRoundedRect(-50, -20, 100, 40, 8, 8) painter.setPen(QPen(Qt.white)) painter.setFont(QFont("Microsoft YaHei", 9)) painter.drawText(QRectF(-50, -20, 100, 40), Qt.AlignCenter, self.name)ItemSendsGeometryChanges这个 flag 是关键,打开后图元位置变化会触发itemChange回调,链路图元可以在这里收到通知并重算线条。boundingRect返回的矩形要比实际绘制区域略大一点,否则选中时的虚线框会贴太紧。paint里用drawRoundedRect画圆角矩形,比纯椭圆更像网络设备图标。
4. 链路跟随与拖拽联动的实现细节
4.1 链路图元怎么绑定两个端点
链路不能是静态线,它必须知道两端是谁。我的做法是LinkItem持有src_item和dst_item两个引用,在paint里实时读取它们的pos()计算连线。这样节点一拖,链路下一帧就跟着变,不需要手动触发更新。
# link_item.py —— 跟随节点移动的链路 from PyQt5.QtWidgets import QGraphicsItem from PyQt5.QtCore import QRectF, QLineF from PyQt5.QtGui import QPen, QColor class LinkItem(QGraphicsItem): def __init__(self, src_item, dst_item, label=""): super().__init__() self.src = src_item self.dst = dst_item self.label = label self.setZValue(-1) # 链路画在节点下面 def boundingRect(self): return QRectF(self.src.pos(), self.dst.pos()).normalized().adjusted(-5, -5, 5, 5) def paint(self, painter, option, widget=None): painter.setPen(QPen(QColor(100, 100, 100), 2)) painter.drawLine(QLineF(self.src.pos(), self.dst.pos()))setZValue(-1)让链路层级低于节点,避免线盖住设备框。boundingRect用两端点构造矩形并normalized(),防止坐标反了导致矩形无效。这里没有画箭头和标签,实际项目里可以用drawPolygon在终点加箭头,用drawText在中点画标签,原理一样。
4.2 拖动节点时链路不刷新怎么办
这是新手最容易翻车的地方:节点拖了,线没动。原因通常是链路没有收到节点位置变化的通知。解决办法有两个:一是节点itemChange里主动调用链路的prepareGeometryChange();二是链路在paint里实时读位置,但需要视图重绘。我一般两个都做——节点位置变化时标记链路需要更新,同时调用update()触发重绘。
# 在 NodeItem 中重写 itemChange def itemChange(self, change, value): if change == QGraphicsItem.ItemPositionHasChanged: for link in self.scene().items(): if isinstance(link, LinkItem) and (link.src is self or link.dst is self): link.prepareGeometryChange() link.update() return super().itemChange(change, value)prepareGeometryChange()告诉场景这个图元的包围盒要变了,场景会重新索引。少了这一步,拖动时会出现残影或者线画到旧位置。update()则请求重绘。两个调用配合,链路就能丝滑跟随。
4.3 拖拽后把坐标写回数据库
用户拖完节点,坐标得存回去,否则下次打开又回到原位。我一般在鼠标释放事件里做持久化,而不是拖动过程中每一帧都写库,那样数据库压力大且没必要。
# 在 TopoView 中重写 mouseReleaseEvent def mouseReleaseEvent(self, event): super().mouseReleaseEvent(event) for item in self.scene.selectedItems(): if isinstance(item, NodeItem): pos = item.pos() cur = self.db.cursor() cur.execute( "UPDATE topo_node SET pos_x=?, pos_y=? WHERE node_id=?", (pos.x(), pos.y(), item.node_id) ) self.db.commit()这里用selectedItems()只更新被选中的节点,但拖动后节点通常处于选中状态,所以能覆盖。更严谨的做法是记录拖动开始和结束的节点集合,避免误更新。commit()别忘了,否则数据只在事务里,程序一关就丢。
5. 避坑与常见问题排查
5.1 节点拖出场景边界后消失
现象:把节点拖到很远的地方,滚动条拉到底也找不到。原因:sceneRect没有动态扩展,节点跑到了场景矩形外面,视图不再渲染。解决:在itemChange里检测节点位置,如果超出当前sceneRect,就调用scene().setSceneRect(scene().itemsBoundingRect().adjusted(-2000, -2000, 2000, 2000)),让场景跟着内容长大。
5.2 链路端点对不上节点中心
现象:线连到了节点左上角而不是中心。原因:QGraphicsItem的pos()返回的是图元原点,而boundingRect如果以原点为中心(比如 -50,-20 到 50,20),原点就在中心;如果以左上角为原点,线就连到角上。解决:统一约定boundingRect以原点为中心,或者连线时手动加偏移量src.pos() + QPointF(50, 20)。我习惯前者,图元坐标即中心,省心。
5.3 数据库连接在子线程里报错
现象:把加载数据放到 QThread 里,程序崩溃或提示数据库驱动不支持跨线程。原因:Qt 的 QSqlDatabase 连接不能跨线程共享,每个线程要独立创建连接。解决:如果只是启动时加载一次,直接在主线程做,几千条数据毫秒级完成;如果必须异步,在子线程里用QSqlDatabase.addDatabase新建连接,并指定不同的连接名。
5.4 大量节点时拖动卡顿
现象:节点超过 500 个后,拖动明显掉帧。原因:每次拖动都触发全场景重绘,且链路的boundingRect计算开销大。解决:给视图设置setViewportUpdateMode(QGraphicsView.MinimalViewportUpdate),只重绘脏区域;链路图元开启setCacheMode(QGraphicsItem.DeviceCoordinateCache),把线条缓存成位图。另外,链路查找不要遍历scene().items(),而是在节点里维护一个links列表,直接遍历自己的链路。
5.5 坐标写回后重启程序位置偏移
现象:拖完保存,重启后节点位置和上次不一样。原因:保存的是图元pos(),但图元原点定义变了,或者场景有缩放变换没还原。解决:保存前用mapToScene(pos())转成场景坐标再存,加载时用setPos直接设场景坐标。如果视图有缩放,确保保存的是场景坐标而不是视图坐标。
6. 进阶:让拓扑图自己“长”出来
基础功能跑通后,我一般会加两个进阶能力。第一个是自动布局,当数据库里新增了一批节点且坐标都是 0 时,用简单的力导向或网格布局给它们分配初始位置,避免全部叠在原点。力导向不需要引入重型库,自己写个几十行的迭代就行:节点之间斥力、链路之间引力,迭代 100 次后坐标就散开了。第二个是视口懒加载,节点上万时不要一次性全建图元,而是监听视图的horizontalScrollBar和verticalScrollBar变化,只创建当前视口范围内的节点,滚出去的就销毁。这样内存和帧率都能稳住。
验证这套方案是否靠谱,我的习惯是造一批假数据:用脚本往topo_node里插 2000 个节点、3000 条链路,然后拖动、缩放、框选,看帧率和内存曲线。如果拖动时 CPU 不超过 30%、内存不持续增长,基本就能上生产了。最后说一个我踩过的坑:别在paint里做数据库查询或复杂计算,paint会被高频调用,里面任何耗时操作都会放大成卡顿。所有数据在创建图元时就准备好,paint只负责画。希望帮到你。
本文还有配套的精品资源,点击获取