简介:这份压缩包是一份基于Qt框架开发的流程图编辑器课程设计,面向需要完成图形界面大作业的C++学习者。资源共51个文件,核心源码包括6个cpp、5个h头文件、1个ui界面文件,配合37个png图片素材与qrc资源管理配置,整体仅149KB,结构紧凑便于阅读。项目中通过QPainter完成节点和连线的绘制,使用QGraphicsScene管理场景,并借助鼠标事件实现图形元素的选中、移动、删除,还包含图片资源加载、线条颜色调整和文本标注等功能。读者可以从中看到自定义图元Item、箭头连线随动更新、界面布局与资源文件组织方式,也可以直接对照源码梳理绘图事件、信号槽连接和序列化存储思路。已有1949人学习浏览该资源,适合作为理解Qt图形视图框架、流程图交互逻辑及工程目录组织的参考实现。
1. 这套 Qt 流程图编辑器大作业:从画布到序列化,给 C++ 课设一份能直接跑的底稿
如果你正在做 Qt 流程图编辑器课设,最怕的不是画几个框连几根线,而是交上去的代码经不起老师问“节点数据怎么存”“撤销怎么做”“缩放之后连线为什么错位”。这套基于 Qt 5.15.2 + C++ 的流程图编辑器工程,把基本流程图功能都补齐了:节点拖拽、连线、选中删除、缩放平移、属性编辑、XML 工程文件保存与读取。我自己拆完源码后发现,它最大的价值不是某个炫技功能,而是给你一条完整的主线——从 QGraphicsScene 坐标系到自定义序列化协议,每一步都能在代码里找到落点,适合拿来改造成自己的课设框架,而不是推倒重写。新手可以直接照着跑通全流程,老手也能在工程结构、编码习惯上省不少时间。
2. 编辑器核心架构:为什么选 QGraphicsView 体系而不是自己画控件
拿到这套工程,第一件事不是点运行,而是先看它的顶层结构。流程图编辑器难在“用鼠标操作图形”和“数据要持久化”这两件事是互相拉扯的:屏幕上一个矩形框只是视觉元素,但它背后的数据模型要能被保存、复制、重排,甚至未来做自动布局。这套工程用的是 Qt 官方推荐的 QGraphicsScene + QGraphicsView + QGraphicsItem 三件套架构,而不是自己用 QWidget 堆矩形和 QPainter 画连线。
2.1 三件套的分工与坐标系换算逻辑
QGraphicsScene 是数据容器,QGraphicsView 是视口,QGraphicsItem 是图元。我拆代码时最在意的是一点:这个工程把“场景坐标”和“视图坐标”分得清楚。拖拽节点时,鼠标事件从 view 进入,换算成 scene 坐标,再作用到 item 上;缩放的时候只动 view 的 transform,不动 scene 里的坐标数据。
// 鼠标拖拽节点到新位置的逻辑(简化自工程内实现) void FlowChartView::mouseMoveEvent(QMouseEvent *event) { if (!m_pressingItem) return; // 把视图坐标换算到场景坐标,这一步不能省 QPointF scenePos = mapToScene(event->pos()); // 用差值移动节点,而不是直接把节点设置为 scenePos QPointF delta = scenePos - m_lastScenePos; m_pressingItem->moveBy(delta.x(), delta.y()); m_lastScenePos = scenePos; // 连线随节点移动刷新,否则会出现“线头脱离节点”的经典翻车 if (m_currentLine) { m_currentLine->updatePath(); } }这里有一个很多新手会写错的点:直接用item->setPos(scenePos)会把节点“吸”到鼠标位置,导致拖拽时节点中心突然跳到鼠标点,手感全无。工程里用的是moveBy(delta),每次只增加位移差,这样不管鼠标点在节点的哪个角落,相对位置都不变。m_lastScenePos要放在mousePressEvent里初始化,并且第一次按下时就应该记录场景坐标而不是视图坐标。
2.2 节点与连线的图元继承设计
这套工程里所有节点都是QGraphicsItem的直接子类,但连线单独拆了一个LinkItem。看代码你会发现连线的boundingRect()返回的是整条路径的包围盒,而不是单个点。这个细节决定了“点击选中连线”的命中范围。如果直接把 boundingRect 写成线宽,用户极难用鼠标点中它,尤其是对角线。
// 连线图元的关键参数,摘取自 LinkItem 的 paint 与 shape QRectF LinkItem::boundingRect() const { // 必须加上线宽和边距,否则抗锯齿绘制会溢出边界 return m_path.boundingRect().adjusted(-4, -4, 4, 4); } QPainterPath LinkItem::shape() const { // 用描边路径生成一个加宽的命中区域,点线面积从 1px 扩大到 12px QPainterPathStroker stroker; stroker.setWidth(12); return stroker.createStroke(m_path); }shape()这个重写是识别度的关键。默认情况下 QGraphicsItem 的命中检测是基于boundingRect()的,但它是个矩形,你点到线的“空白角落”也会触发选中。工程里用QPainterPathStroker把路径加粗生成真实命中形状,这样只有点到线附近 6px 范围内才算选中。这个技巧在你自己加连线类型时同样适用,比如加一条虚线或贝塞尔曲线,也要重新实现shape(),否则看着是曲线,点起来却是矩形框,很别扭。另外这套工程里的连线是独立的LinkItem,没有挂在某个节点内部,所以移走节点时两条连线不会跟着动,工程在updatePath()里做了重算,而不是靠 item 的父子关系联动。
2.3 工程文件结构与编译前置条件
工程文件用的是 qmake 的.pro组织方式,不是 CMake。如果你导师要求 CMake,你需要手动重写构建脚本。解开压缩包后,第一件事看main.cpp和mainwindow.cpp的包含关系——它依赖 Qt Widgets 模块和 Qt Xml 模块(如果你要用 XML 序列化)。在.pro里必须有:
QT += core gui widgets xml CONFIG += c++11 TARGET = FlowChartEditor TEMPLATE = app SOURCES += main.cpp mainwindow.cpp scene.cpp view.cpp nodeitem.cpp linkitem.cpp HEADERS += mainwindow.h scene.h view.h nodeitem.h linkitem.h用 Qt 5.15.2 + MSVC2019 打开.pro直接能跑。如果打开后编译报“无法打开 QtWidgets/QApplication”,先检查 Kit 选择的是不是 MSVC2019_64,再看QT += widgets是否被注释掉。这套工程没有引入第三方库,全部是 Qt 官方模块,编译成功率高的原因就在这——凡是纯 Qt 模块的大作业,最怕作者贪方便直接移植网上整合包,带了一堆乱七八糟的依赖。
3. 交互功能这样实现:拖拽、缩放、对齐与连线的关键 API 参数
流程图编辑器体验好坏,不在于是不是能画图,而在于是不是画得顺手。这套工程实现了完整的鼠标交互链:按住左键拖出连线、右键菜单删除、Ctrl+滚轮缩放、框选多节点后对齐。这里把每个交互背后的 API 参数说透,你答辩时被问到“缩放怎么实现的”就能直接答出来。
3.1 视图缩放:如何设置锚点保证鼠标中心不跑偏
Zoom 功能是流程图编辑器最容易出现“缩放后画面跑到看不见的地方”的翻车环节。工程里的实现是用QGraphicsView::setTransform叠加缩放系数,并且在缩放前把视图中心设为鼠标所在位置,这样缩放时鼠标指着的那个图形不会飘走。
void FlowChartView::wheelEvent(QWheelEvent *event) { // 设置缩放锚点为鼠标当前坐标,否则缩放时以视图中心为锚点,画面会跳到另一边 setTransformationAnchor(QGraphicsView::AnchorUnderMouse); qreal scaleFactor = 1.15; if (event->angleDelta().y() > 0) { scale(scaleFactor, scaleFactor); } else { scale(1.0 / scaleFactor, 1.0 / scaleFactor); } }AnchorUnderMouse是个枚举值,默认是AnchorViewCenter。很多程设模板没设置这个值,导致滚轮一滚图形全飘走。另外还要注意缩放上下限,否则用户无限制放大下去,transform里的 m11 值会溢出。比较稳妥的做法是维护一个m_scaleValue,每次缩放后判断是否在 0.2 到 3.0 之间,超出范围直接 return。这套工程里没有做上限,老师如果故意疯狂滚轮,可能会出现图形抖动,建议你自己补上这段防御代码。
3.2 连线创建的模式切换与命中检测参数
连线的交互逻辑是:鼠标悬停在节点边缘的插座上时,光标变成十字形;按住左键拖出,到另一个节点上松开,则创建一条连接;如果松开在空白区域,则取消连线。这里有个关键技术点叫“进入连接模式”,鼠标按下时就要进入该模式,而不是先移动再判断。
void NodeItem::mousePressEvent(QGraphicsSceneMouseEvent *event) { // 判断是否命中边缘 8px 热区,进入连线模式 QRectF edgeRect = boundingRect().adjusted(-8, -8, 8, 8); if (edgeRect.contains(event->pos()) && !boundingRect().contains(event->pos())) { m_scene->startLinking(this, mapToScene(event->pos())); event->accept(); } else { // 普通拖拽逻辑 QGraphicsItem::mousePressEvent(event); } }注意mapToScene这步转换,如果漏掉它,连线的起点就是节点局部坐标,画出来直接偏掉。工程里还有一个细节值得学习:连线目标的判断放在scene层,而不是view层。在mouseMoveEvent里通过scene->items(scenePos)拿到鼠标下的所有 item,再用qgraphicsitem_cast判断它是不是NodeItem。这里有一个性能相关的隐性坑:如果场景里 item 数量极大(超过几百个),每次都调用items()做全局命中检测会有明显卡顿。常见的优化是维护一个“插座点列表”,只对每个节点的插座点做距离判断,避免全场景碰撞检测。工程里图元数量少,直接用默认方案没问题,但答辩时如果能说出这个优化思路,印象分差别很大。
3.3 框选与对齐:Qt 内置 rubber band 还是自己画虚线框
这套工程支持 Ctrl+拖拽框选,选中的节点周围出现高亮边框。我看源码时注意到,框选用的是QGraphicsView::setDragMode(QGraphicsView::RubberBandDrag),没有自己画虚线框,这是最省事也最稳的方案。但是 RubberBandDrag 有个局限:它只能用来选中,不能做框选后的批量操作。
// 在 MainWindow 中绑定对齐快捷键 connect(shortcutAlignLeft, &QShortcut::activated, this, [this]() { QList<QGraphicsItem*> selected = m_scene->selectedItems(); if (selected.size() < 2) return; // 按所有选中节点中最小的 x 坐标作为对齐线 qreal minX = std::numeric_limits<qreal>::max(); for (QGraphicsItem *item : selected) { minX = qMin(minX, item->sceneBoundingRect().left()); } for (QGraphicsItem *item : selected) { item->setPos(minX, item->sceneBoundingRect().top()); } });这里有一个逻辑陷阱:setPos是设置图元本地坐标原点,而不是它的包围盒左上角。如果你直接item->setPos(minX, item->pos().y()),对齐之后的视觉效果是每个节点的左上角 x 相同,但节点图标内部如果有边距,看起来就没对齐。更精细的做法是用sceneBoundingRect()而不是boundingRect(),因为前者已经包含了transform缩放和旋转的影响。流程图编辑器里节点的缩放一般不做,但如果你允许用户缩放节点,就要全换sceneBoundingRect,否则连线就会错位。
4. 序列化与撤销重做:这两个模块直接决定课设答辩的深度分
流程图编辑器如果只能画不能存,那等于白做。这套工程用 XML 格式保存工程文件,节点坐标、尺寸、颜色、文本内容、连线两端节点 ID 全部写进 XML。这一个模块覆盖了 Qt 的QXmlStreamWriter和QXmlStreamReader两个类,也是你答辩时最能展开讲的部分。
4.1 XML 序列化的数据模型设计
看工程里saveToXml的实现,会发现它给每个节点分配了一个nodeId,连线不直接存坐标,而是存startNodeId和endNodeId,坐标在加载时重新计算。这个设计非常好——如果直接存连线两个端点的像素坐标,加载后节点一移动,连线就留在原地,变成断线。
// 节点保存的核心代码 void Scene::saveToXml(const QString &fileName) { QFile file(fileName); if (!file.open(QIODevice::WriteOnly)) return; QXmlStreamWriter xml(&file); xml.setAutoFormatting(true); xml.writeStartDocument(); xml.writeStartElement("FlowChart"); xml.writeAttribute("version", "1.0"); foreach (QGraphicsItem *item, items()) { NodeItem *node = qgraphicsitem_cast<NodeItem *>(item); if (!node) continue; xml.writeStartElement("Node"); xml.writeAttribute("id", QString::number(node->nodeId())); xml.writeAttribute("x", QString::number(node->pos().x())); xml.writeAttribute("y", QString::number(node->pos().y())); xml.writeAttribute("width", QString::number(node->nodeWidth())); xml.writeAttribute("height", QString::number(node->nodeHeight())); xml.writeTextElement("Text", node->nodeText()); xml.writeEndElement(); } // 连线遍历类似,用 startId/endId 写两端引用 xml.writeEndElement(); xml.writeEndDocument(); file.close(); }这里的xml.writeAttribute和xml.writeTextElement的区别要能讲清楚:属性适合存数字和短字符串,子元素适合存可能有换行或特殊字符的多行文本。节点文本里如果有&或<,写入属性中 Qt 会自动转义,读取时也会自动反转义,不会破坏 XML 结构。你答辩时如果能主动说一句“XML 的转义由 QXmlStreamWriter 自动处理,不需要手动替换特殊字符”,老师会觉得你真的跑过一遍而不是纯背代码。
4.2 加载时为什么必须先建节点再连连线
加载 XML 的反序列化逻辑有个顺序讲究:第一遍读所有节点、创建图元并加入 scene,第二遍读连线,通过节点 ID 查找到对应图元。如果指望 XML 里连线的startNodeId恰好小于endNodeId,或者按文件顺序先遇到连线再遇到节点,就会出现空指针崩溃。
// 恢复连线的核心逻辑,注意要按 ID 映射到图元指针 void Scene::loadFromXml(const QXmlStreamReader &xml) { QMap<int, NodeItem *> nodeMap; while (!xml.atEnd()) { xml.readNext(); if (xml.isStartElement() && xml.name() == "Node") { int id = xml.attributes().value("id").toInt(); NodeItem *node = createNode( xml.attributes().value("x").toDouble(), xml.attributes().value("y").toDouble() ); node->setNodeId(id); node->setNodeText(xml.readElementText()); addItem(node); nodeMap.insert(id, node); } } // 第二次遍历连线,此时 nodeMap 已完整 while (!xml.atEnd()) { xml.readNext(); if (xml.isStartElement() && xml.name() == "Link") { int startId = xml.attributes().value("startId").toInt(); int endId = xml.attributes().value("endId").toInt(); if (nodeMap.contains(startId) && nodeMap.contains(endId)) { createLink(nodeMap[startId], nodeMap[endId]); } } } }加载时另一个常见问题是readElementText()的调用位置:如果你在一个 startElement 里先取了属性,然后调readNext()继续往下读,再调用readElementText()返回的是空字符串或错误文本。readElementText()必须紧跟在读完 startElement 之后调用,它会把当前元素内的所有字符拼起来。工程代码里刻意先保存了x、y属性,最后才调用readElementText(),这个顺序就是为兼容这个 API 的限制。
4.3 撤销重做:用命令模式还是简单快照
这套工程里的撤销重做做的是“快照式”——每次操作后把 scene 里的所有节点坐标、文本、连线关系存到一个QByteArray里,撤销时直接恢复上一个快照。代码量最小,但对大场景不友好,快照多了内存会炸。
// 快照式撤销的核心实现:序列化到内存而不是文件 void Scene::saveSnapshot() { QBuffer buffer(&m_snapshotBuffer); buffer.open(QIODevice::WriteOnly); QDataStream out(&buffer); // 把所有节点数据写到流中 foreach (QGraphicsItem *item, items()) { NodeItem *node = qgraphicsitem_cast<NodeItem *>(item); if (!node) continue; out << node->nodeId() << node->pos() << node->nodeText(); } m_undoStack.push(m_snapshotBuffer); }快照式撤销存在两个问题。第一是内存膨胀,每操作一次就全量存一份场景,下限 100 个节点、上限几百个历史记录时,内存占用很可观。第二是连线关系没有序列化进快照,撤销节点移动后连线会飘,因为连线坐标是实时更新的,但快照恢复时没有重算连线路径。如果你要用这套工程答辩,最好把快照改成命令模式,为“移动节点”和“删除节点”各写一个QUndoCommand子类。如果没时间改,至少保证快照只保存节点数据,撤销后对 scene 里残留的 LinkItem 做一次updatePath()刷新。
5. 避坑实录:Qt 流程图编辑器最常见的四个崩溃级错误
qt 崩溃是个高频检索词,这里把我在复现这套工程时踩过的坑集中写出来。这些坑不是玄学,每一个都可以从 Qt 文档里找到解释,但先踩了再看文档印象会深得多。
5.1 现象:关闭程序时崩溃,报错 QGraphicsScene destroyed while items are still in it
原因:窗口关闭时 QGraphicsScene 先被销毁,但 scene 里的 QGraphicsItem 还在,item 析构时尝试访问 scene 已经不可用。解决:在MainWindow的析构函数里先调用scene->clear(),把 item 都删干净,再让 scene 自己析构。很多人不是没写clear(),而是写了但顺序在delete scene之后,等于白写。
MainWindow::~MainWindow() { // 先清空场景中的所有图元,再销毁 scene 对象 if (m_scene) { m_scene->clear(); // 这里会 delete 所有 item delete m_scene; // 此时 scene 内没有剩余 item,才安全 } }5.2 现象:按下删除键后节点消失了,但连线还留在画布上
原因:删除节点时只调用了scene->removeItem(node),但removeItem只把 item 从场景中摘除,不会 delete,也不处理与之关联的连线。解决:手动遍历scene->items()找到所有LinkItem,判断其 start 或 end 是否指向被删节点,先删连线再删节点。注意delete node之后不能再访问该指针,所以要在删节点前收集所有相关连线。
void Scene::deleteSelectedItems() { QList<QGraphicsItem *> toDelete; foreach (QGraphicsItem *item, selectedItems()) { if (NodeItem *node = qgraphicsitem_cast<NodeItem *>(item)) { // 找出所有连接到该节点的连线 foreach (QGraphicsItem *other, items()) { if (LinkItem *link = qgraphicsitem_cast<LinkItem *>(other)) { if (link->startNode() == node || link->endNode() == node) { toDelete << link; } } } toDelete << node; } } qDeleteAll(toDelete); }5.3 现象:加载工程文件后节点位置全对,但连线跑到了左上角原点
原因:加载时节点和连线都加到了 scene,但连线路径是通过startNode->pos()计算的。如果加载流程是先加连线再加节点,那么连线计算时节点坐标还是默认的 (0,0)。解决:严格按“先节点后连线”的顺序加载,或在创建连线后手动调一次updatePath()。这题就是 4.2 节说的加载顺序问题,在这里再次强调,因为它确实高频。
5.4 现象:在高 DPI 屏幕上运行,鼠标点选节点总偏差 2-3 像素
原因:Qt 5.15 对高分屏缩放的支持还在磨合期,如果你没有在main.cpp里设置QApplication::setAttribute(Qt::AA_EnableHighDpiScaling),在 125%/150% 缩放的屏幕上,view 的坐标映射会有偏差。解决:在创建QApplication之后立即加一行:
QApplication::setAttribute(Qt::AA_EnableHighDpiScaling); QApplication app(argc, argv);这行必须在QApplication构造之前调用,否则不生效。Qt 6 里这个属性默认开启,但如果你的课设环境是 Qt 5.15,不加就是会有偏差。
6. 发布与演示进阶:如何让流程图编辑器在别的机器上跑起来,以及动态属性扩展
收尾聊一个最实际的话题:课设要交 Demo,怎么把工程打包成能在导师电脑上双击运行的 exe。很多人把 debug 目录下的 exe 直接拷过去,到别人机器上双击没反应,或者报qt.qpa.plugin: could not find the Qt platform plugin "windows"。这个问题你搜一下就知道,原因很简单:缺少 platform 插件目录。
6.1 打包发布:windeployqt 是唯一正规手段
在你的 Qt 安装目录下找到windeployqt.exe,路径一般是C:\Qt\5.15.2\msvc2019_64\bin\windeployqt.exe。把编译生成的FlowChartEditor.exe放到一个单独的文件夹,然后在该文件夹打开命令行,执行:
C:\Qt\5.15.2\msvc2019_64\bin\windeployqt.exe FlowChartEditor.exe它会自动把需要的 Qt5Core.dll、Qt5Gui.dll、Qt5Widgets.dll 以及 platforms 目录下的 qwindows.dll 复制到 exe 旁边。执行完之后,文件夹里会多出几十个文件,整个文件夹拷走就能在别的 Windows 机器上运行。如果你用了 Qt Xml 模块,记得检查有没有 Qt5Xml.dll 被拷进来,windeployqt有时候会漏掉非默认模块。最简单的验证办法:把文件夹拷到一台没装 Qt 的虚拟机里跑一遍。
# 验证发布文件夹是否完整,dll 依赖一键检查 dumpbin /dependents FlowChartEditor.exe如果缺失依赖,输出里会列出找不到的 dll 名字。dumpbin是 VS 自带工具,需要在 VS 开发人员命令行里执行。工程里如果引用了 Qt5Xml,而发布文件夹里没有,程序会在启动时静默崩溃而不报错,这是最难受的坑。
6.2 扩展一个动态属性面板:用 Q_PROPERTY 代替硬编码字段
答辩时老师大概率会问“如果我想给节点加一个颜色属性,怎么改”。如果代码里写死了nodeText和nodeId,扩展一个属性就要改五六个文件。工程的分层其实已经为你预留了扩展空间,节点类继承自 QGraphicsObject(而非 QGraphicsItem),说明它已经支持 Qt 元对象系统。给节点增加属性最优雅的方式是往 XML 里加一个自定义子元素,同时注册一个Q_PROPERTY:
// 在 NodeItem 头文件中声明颜色属性 class NodeItem : public QGraphicsObject { Q_OBJECT Q_PROPERTY(QColor fillColor READ fillColor WRITE setFillColor) public: QColor fillColor() const { return m_fillColor; } void setFillColor(const QColor &color) { m_fillColor = color; update(); // 触发重绘,颜色立即生效 } private: QColor m_fillColor = Qt::white; };有了Q_PROPERTY后,属性浏览器或样式表就能直接读写这个属性,不需要专门写 setter 的调用逻辑。保存到 XML 时加一行xml.writeAttribute("fillColor", m_fillColor.name()),加载时用QColor(xml.attributes().value("fillColor").toString())读回来。整个过程不碰 Scene 和 MainWindow 的代码,新属性从添加到底层到 UI 完全解耦。
6.3 验证自己的工程:把整套流程手动跑一遍
你拿到这张工程后,我建议按下面路径先完整验证一遍,确认没漏件再开始改:新建工程文件并拖入两个节点、一条连线,保存为.flow.xml;关闭程序重新打开,加载刚才的文件,确认节点坐标、文本和连线完全还原;框选一个节点并尝试删除,确认连线随节点一并被清除;按住 Ctrl 滚轮缩放,确认鼠标指向的图元没有飘走;把发布文件夹拷到另一台电脑,双击 exe 确认能正常启动。五个步骤如果全部通过,这套工程在你机器上已经是稳妥状态,剩下的才是你自己的扩展功课。
说句实在话,流程图编辑器这类课设之所以叫大作业,就是因为“能跑”和“能演示完整”之间隔着一条交互细节的鸿沟。这套工程的价值在于帮你跨过那道鸿沟——你不用花三天把画布底层磨平,而是拿到一个已经能跑通的骨架,把省下来的时间全部投入到答辩亮点上。希望这篇笔记能帮你少踩几个坑,把那台电脑上的流程图编辑器稳稳跑起来。
本文还有配套的精品资源,点击获取