ARTICLE DETAIL

资讯详情

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

基于Qt与QGraphicsView的流程图编辑器开发实战

基于Qt与QGraphicsView的流程图编辑器开发实战 简介一份基于Qt的流程图编辑器完整工程面向具备C与Qt基础、希望系统掌握图形视图框架的开发者演示如何从零构建带自定义节点、拖拽连线、自动吸附与保存加载的流程编辑工具也适合作为课程设计、毕业设计或可视化应用开发的起步模板。压缩包共43个文件含14个cpp与14个h源码、3个ui界面布局、6个png图标资源及工程配置文件整体约68KB目录结构清晰便于按模块对照学习。代码覆盖自定义图形项绘制、基于路径项的连接线自动吸附、信号槽交互、数据流序列化存取及撤销重做机制并附带矩形节点、端口、连接器、网格画布、工具箱等模块从图形绘制到交互逻辑相互贯通可直接编译运行并二次扩展。已有1579人浏览学习适合需要快速上手可视化流程编辑开发、想深入理解Qt图形视图编程的读者参考。1. 流程图编辑器难点从来不在画线拿到“基于Qt的流程图编辑器”这个需求时很多人的第一反应是画布、画节点、画箭头能拖能存就行。真正动手后才发现编辑器的主干不是绘图代码而是对象模型、交互状态机、序列化格式和运行时求值这四件事。图形界面只是表象底层需要一套能描述“节点是什么、连线怎么走、数据从哪来、撤销怎么记”的数据结构。这篇文章面向两类读者一类是用Qt做工具软件开发、想把白板上乱画的原型变成可交付的编辑器产品另一类是刚接触Qt图形视图框架、想找一个能落地的中型示例来掌握QGraphicsView体系的工程师。目标只有一个照着做能跑能扩展。2. 为什么是 QGraphicsView 体系三个类各管一件事2.1 对象模型先行绘图只是视图层的投影流程图编辑器最常见的失败开局是直接在QWidget的paintEvent里画几百个矩形和线段。这么做在小图上没问题一旦图超过一两百个节点就会出现三个绕不过去的坎无法精确判断鼠标点中了哪个图元、移动节点时不会自动触发相邻连线重算、撤销操作没有可依赖的对象边界。QGraphicsView体系的优势在于它把这三个问题拆成了三个类来解决QGraphicsScene管对象和碰撞检测QGraphicsItem管单个图元的绘制与交互QGraphicsView管视口变换和滚动缩放。这意味着你不需要自己写“点在矩形内”的命中测试也不需要手动维护一张图元坐标表场景替你完成了空间索引。对象模型优先的含义是先定义“流程图由哪些数据构成”再考虑怎么画。我一般把数据层拆成三个纯数据结构NodeData保存节点类型、坐标、参数、端口定义EdgeData保存起点节点ID、起点端口、终点节点ID、终点端口PortData保存端口方向、类型、绑定的参数名。UI层的图元只做一件事把NodeData画出来并把用户在界面上的修改写回NodeData。这样序列化、撤销、运行时求值都直接操作数据层UI随时可以从数据层重建。2.2 基类设计NodeItem 和 EdgeItem 的骨架无论是圆形节点、矩形节点还是条件判断的菱形节点QGraphicsItem子类的写法是一致的重写boundingRect、paint、itemChange三个方法。boundingRect返回的是整个图元的矩形范围要包含边框宽度和端口锚点paint负责画形状、文字、端口和选中态itemChange是移动节点后自动通知连线刷新的关键回调。端口建议不要单独做成子Item而是定义成相对图元坐标的锚点列表连线时根据锚点坐标计算起点终点这样端口总是跟随整个节点变换。class NodeItem : public QGraphicsItem { public: enum { Type UserType 1 }; explicit NodeItem(const NodeData data); int type() const override { return Type; } QRectF boundingRect() const override { QRectF r(0, 0, data_.width, data_.height); return r.adjusted(-10, -10, 10, 10); // 预留选中框和端口半径 } QPointF portPos(int portIndex) const { PortData p data_.ports.at(portIndex); qreal y data_.height * p.anchorRatio; return QPointF(p.dir PortData::In ? 0 : data_.width, y); } void paint(QPainter* painter, const QStyleOptionGraphicsItem*, QWidget*) override { painter-setBrush(brush_); painter-setPen(QPen(QColor(#6C7A89), 1.5)); painter-drawRoundedRect(QRectF(0, 0, data_.width, data_.height), 6, 6); painter-drawText(10, 10 data_.height / 2, data_.title); for (auto p : data_.ports) { painter-setBrush(p.type PortData::Flow ? Qt::black : Qt::white); QPointF pt portPos(p - data_.ports[0]); painter-drawEllipse(pt, 4, 4); } } protected: QVariant itemChange(GraphicsItemChange change, const QVariant value) override { if (change ItemPositionHasChanged) onPosChanged(); return QGraphicsItem::itemChange(change, value); } private: NodeData data_; QBrush brush_; };这段代码的核心在portPos函数它把指定端口从相对坐标换算成图元坐标端口的锚点比例存在NodeData里。比如“输入端口在左边1/2高度处”anchorRatio就是0.5dir决定靠左边还是靠右边。boundingRect额外向外扩了10像素为了给选中框和鼠标拾取留出空间否则端口画在边界线上时鼠标很难精准点中。itemChange里监听位置变化后回调节点的坐标同步这个回调会把新坐标写回NodeData并通知关联的EdgeItem重新计算路径。2.3 连线的两种实现画直线还是画正交折线连线用QGraphicsPathItem或者直接从QGraphicsLineItem子类化。小规模编辑器直接用直线加箭头就够了路径计算简单性能也好。一旦节点需要上下左右四个方向任意连接直线会交叉穿过节点看起来非常乱这时候必须换成正交折线。正交路径的本质是计算两个端口坐标后按“先水平后垂直”或“先垂直后水平”的策略生成中间拐点。QPainterPath buildOrthogonalPath(const QPointF start, const QPointF end) { QPainterPath path; path.moveTo(start); qreal dx end.x() - start.x(); if (qAbs(dx) 40) { QPointF mid(start.x() dx / 2, start.y()); path.lineTo(mid); path.lineTo(QPointF(mid.x(), end.y())); } else { qreal dir (end.x() start.x()) ? 20.0 : -20.0; QPointF elbow(start.x() dir, start.y()); path.lineTo(elbow); path.lineTo(QPointF(elbow.x(), end.y())); } path.lineTo(end); return path; }拐弯逻辑不复杂但有一个关键参数值得说明dx阈值40像素。当起点终点水平距离太近时直拐会产生锐角路径折回来压在节点边框上视觉上是“线穿过了端口旁边的空白区域”。加一个最小水平偏移量保证折线从端口伸出一小段后才转向。箭头则用QPolygonF在路径终点附近单独画三角不要用线帽去模拟线帽的可控性太差方向会随角度变化。3. 用鼠标拖出节点和连线交互状态机的三步拆分3.1 左侧工具箱拖放节点到场景流程图编辑器最常见的创建节点方式是从左侧的节点面板拖一个图元到画布中间。QGraphicsView本身只处理场景内的交互从QListWidget拖出节点需要自己监听dragEnterEvent和dropEvent。拖放的数据格式可以直接走Qt的MIME机制也可以用最简单的qobject_cast指针传递。实操时我习惯在列表项的data里存节点类型字符串drop时再根据类型构造NodeData这样列表和图元之间就不需要共享对象指针。void GraphView::dragEnterEvent(QDragEnterEvent* event) { if (event-mimeData()-hasFormat(application/x-node-type)) event-acceptProposedAction(); } void GraphView::dropEvent(QDropEvent* event) { QString nodeType event-mimeData()-data(application/x-node-type); NodeData data NodeFactory::create(nodeType); // 按类型构造默认参数 data.pos mapToScene(event-position().toPoint()); // 视口坐标转场景坐标 NodeItem* item new NodeItem(data); scene_-addItem(item); item-setPos(data.pos); event-acceptProposedAction(); }拖放中最容易踩的坑是坐标转换。event-position()返回的是视图控件上的像素坐标必须通过mapToScene转成场景坐标。如果场景有缩放一定要调用视图的mapToScene接口而不是用scene-addItem后在item里setPos一个视图坐标。我在早期实现里犯过这个问题表现为缩放80%后从侧边栏拖出来的节点总是出现在鼠标位置右下方一大截。3.2 连线交互按下、移动、释放三段式画连线的交互是整个编辑器里最容易写得散架的部分。标准做法是把“画线”拆成三个状态鼠标按下时检查是否命中了某个端口的锚点区域命中则记录起点端口并创建一个临时连线Item鼠标移动时调用临时节点的setEndPoint实时更新线的终点鼠标释放时再检查终点是否落在另一个端口的拾取范围内是则生成正式EdgeItem否则删掉临时线。这里的核心技巧是不要试图在mouseMoveEvent里手动遍历所有NodeItem做碰撞那很慢。void GraphEditor::mousePressEvent(QMouseEvent* event) { QPointF scenePos mapToScene(event-pos()); NodeItem* node dynamic_castNodeItem*(scene_-itemAt(scenePos, QTransform())); if (node) { for (int i 0; i node-portCount(); i) { QPointF portPos node-mapToScene(node-portPos(i)); if (QLineF(portPos, scenePos).length() 12.0) { dragging_ true; tempEdge_ new QGraphicsLineItem(); tempEdge_-setPen(QPen(QColor(#E67E22), 2.5)); scene_-addItem(tempEdge_); startPort_ {node, i}; return; } } } QGraphicsView::mousePressEvent(event); }端口拾取的逻辑依赖两层坐标换算。portPos返回的是节点自身的相对坐标需要先mapToScene变换到场景坐标再和鼠标位置做距离判断。阈值12像素是我试下来比较舒服的值太会比端口半径多出大半圈导致误触太小则鼠标稍微一抖就点不中。tempEdge_必须加在scene里而不是视图里否则缩放时线不会跟着场景变换会出现线在节点上漂移的视觉bug。释放时的命中检查复用同一段距离判断并把起点、终点端口信息写入EdgeData之后把tempEdge_从scene移除换成正式EdgeItem。3.3 缩放、平移和框选的三件套流程图编辑器还有一个隐藏需求画布缩放后拖拽仍然要跟手框选时不能把连线也框进去。缩放的常规做法是在视图上重写wheelEvent里调用scale并给NodeItem设置ItemIgnoresTransformations标志这样节点文字不会随缩放变糊。平移的手势处理则用setDragMode(QGraphicsView::ScrollHandDrag)来开启但要注意开启手型拖拽后左键拖拽事件会被视图截走不能再用来做节点框选。我的处理方式是默认用RubberBandDrag做框选按住空格键时才临时切到ScrollHandDrag空格松开切回来。这样既保留了鼠标画连线的操作空间又能快速平移画布。3.4 撤销与重做把命令对象放进操作栈撤销之所以在流程图编辑器里难写是因为一个操作会同时影响数据层和视图层。比如移动一个节点数据层要更新位置的坐标视图层要更新绑定连线的路径这两者必须放在同一个事务里。常见做法是引入QUndoCommand子类每个操作实现redo和undo两个函数并把对NodeData和QGraphicsItem的更新都写在这两个函数里。移动节点时不要直接改item的pos而是在command的redo里调用item-setPos这样撤销时才能回到旧坐标。4. 用 JSON 做序列化格式保存、加载与升级兼容4.1 为什么选 JSON 而不是 QSettings 或二进制流程图的存储格式选JSON是性价比最高的选择。QSettings适合存程序配置不适合存结构复杂的图数据二进制格式性能好但没法调试。JSON可以明文查看也方便做版本兼容和跨平台。Qt自带的QJsonDocument、QJsonObject、QJsonArray足够用了不需要引入第三方库。JSON布局我采用双层结构顶层拆成meta和graph两个键。meta里放编辑器版本号、画布缩放比例、视图中心点坐标graph里放nodes和edges两个数组。nodes数组的每个元素包含id、type、x、y、width、height、ports、configedges数组包含id、fromNode、fromPort、toNode、toPort、label。id必须显式存下来不能靠数组下标代替因为节点被删除后数组下标会变化而连线的起终点必须指向稳定标识。4.2 保存与加载的对称实现QJsonObject saveGraph(EditorData data) { QJsonObject root; root[version] 1; root[viewScale] view_-transform().m11(); QJsonArray nodeArr; for (const NodeData nd : data.nodes) { QJsonObject obj; obj[id] nd.id; obj[type] nd.type; obj[x] nd.pos.x(); obj[y] nd.pos.y(); obj[width] nd.width; obj[height] nd.height; nodeArr.append(obj); } QJsonArray edgeArr; for (const EdgeData ed : data.edges) { QJsonObject obj; obj[id] ed.id; obj[fromNode] ed.fromNodeId; obj[fromPort] ed.fromPortIndex; obj[toNode] ed.toNodeId; obj[toPort] ed.toPortIndex; edgeArr.append(obj); } root[nodes] nodeArr; root[edges] edgeArr; return root; }加载时要解决一个对新手来说特别绕的问题NodeItem和EdgeItem的生成顺序有依赖关系。连线必须知道起终点NodeItem的指针才能把两个图元关联起来而加载JSON时节点和连线是平级数组先把所有节点都创建好之后才能创建连线。所以加载函数要分成三步走。第一步遍历nodes创建所有NodeItem并setPos同时用一个id到NodeItem的QHash缓存起来第二步再遍历edges从缓存里取出起点和终点NodeItem调用节点上对应端口的映射函数取锚点坐标第三步把所有item交给scene。QHashint, NodeItem* itemById; for (auto nd : data.nodes) { NodeItem* item new NodeItem(nd); itemById[nd.id] item; scene_-addItem(item); item-setPos(nd.pos); } for (auto ed : data.edges) { NodeItem* fromNode itemById.value(ed.fromNodeId); NodeItem* toNode itemById.value(ed.toNodeId); if (!fromNode || !toNode) continue; // 节点已被删除连线失效 EdgeItem* edge new EdgeItem(fromNode, toNode, ed); scene_-addItem(edge); }这段代码隐藏着一个健壮性设计当一条edge引用了不存在的节点id时直接跳过而不是崩溃。常见场景是手改JSON时误删了某个节点但连线还在加载时如果强行引用空指针必然导致访问违规。QHash::value取不到时返回默认构造的nullptr检查一下就好。4.3 版本迁移别让旧文件打开即崩编辑器做到后期一定会遇到文件格式升级。保存时要写version字段加载时检查版本号如果低于当前版本走一遍迁移函数。迁移的逻辑最好写成版本号到处理回调的映射表比如v1到v2的迁移函数负责把旧的index数组拆成新的id数组。新版代码在加载旧文件时先读version如果小于当前版本按版本差依次执行迁移再走正常加载流程。这个套路看着笨但能避免将来用户拿旧文件打开后各种空白和错位。5. Qt 流程图编辑器5 个让人崩溃的坑与排查方法5.1 节点双击修改文字后文本总是超出边框现象双击节点修改标题文本输入完成后文字溢出节点矩形甚至被旁边节点遮挡。原因paint里drawText使用的矩形区域是写死的QRectF(10, 10, width-20, height-20)而文本内容长度是动态的。宽度不够时Qt默认会换行换行后行高超过矩形高度超出的部分仍然被画出来。解决修改文本后根据QFontMetrics重新计算文本实际占用矩形按比例扩NodeData的width和height再调用update通知重绘。如果希望保持节点尺寸稳定就把文字截断策略改为省略号但要注意省略号必须结合QFontMetrics的elidedText计算不能简单按字符数截中文和数字的宽度差很多。5.2 连线重叠在节点上面点击节点时总是选中线现象节点和连线的层次关系混乱连线从节点上方穿过导致鼠标没有点中节点而是选中了连线。原因QGraphicsItem的绘制顺序默认按添加先后顺序排列。先添加节点后添加连线连线会盖在节点上面。要命中测试更靠近用户连线对鼠标拾取应该不可见。解决给EdgeItem重写setFlag(QGraphicsItem::ItemIsSelectable, false)或者在paint调用前用setZValue把节点Z值调得比连线高比如节点Z值为10、连线Z值为1。推荐做法是两条同时做连线不参与鼠标拾取节点始终在上层。这样连线只是视觉元素不会干扰节点的拖动和选中。// 创建连线时直接关闭可选中性 edge-setFlag(QGraphicsItem::ItemIsSelectable, false); edge-setAcceptedMouseButtons(Qt::NoButton);5.3 保存再打开后节点位置全部偏移了现象保存的JSON里x、y明明是对的重新打开后节点整体向右下角移动了一段距离越靠近右下角偏移越大。原因保存时把视图的scrollbar偏移也写进了坐标。常见写法是把scene-items()遍历后取item-pos()但pos返回的是场景坐标。如果保存的是view-mapFromScene(item-pos())或者混用了mapToScene和mapFromScene会导致坐标带上了视口偏移。解决统一只存场景坐标。加载时setPos用的也是场景坐标。二者必须同源。产生偏移的代码通常是保存时用了view的变换加载时又用了一次相反的变换重复应用后造成越来越大的误差。5.4 debug版本运行正常release版本连点几下就闪退现象项目在Debug配置下完整跑通切换Release后连续快速拖拽节点、创建连线程序突然退出。用Qt Creator调试时会报“程序异常结束”。原因最常见的根因是信号和槽绑定的上下文对象生命周期问题。快速拖拽时某个对话框或临时Item被释放后事件循环里的挂起事件仍然持有它的指针释放后指针变成悬垂指针。Debug版由于内存填充模式不同可能没有立即崩溃Release版则直接在访问时触发访问违规。解决检查所有delete操作。凡是用new创建的Item必须由scene统一管理用scene-removeItem后再delete不要在父对象之外手动释放。QGraphicsScene在析构时会删除所有Item所以只要Item是被addItem添加过的就不要在scene析构前手动delete清理工作交给scene。如果是临时连线tempEdge_在鼠标释放时必须先removeItem再delete并置空指针防止双击鼠标时同一事件循环里连续两次释放。5.5 加载大文件时内存飙升拖拽卡顿现象加载200个节点、300条连线的图拖动一次节点要卡几百毫秒继续加载更大规模的图内存占用增长到离谱的水平。原因常见原因是每个节点Item都单独保存了一份QPixmap缓冲或字体对象整个场景有几百份重复资源。Qt的隐式共享机制在QPixmap上有限QPixmap是显式共享的每个Item存一份就是一份真实内存。另外每画一次临时连线就new一个QGraphicsLineItem也会让场景的Item数量膨胀。解决节点绘制时不要预先缓存QPixmap直接用QPainter命令绘制基本形状即可绘制开销远小于内存开销。临时连线用悬浮层绘制不要每次create一个新Item。具体做法是子类化QGraphicsView重写drawForeground画当前拖拽中的临时连线拖拽结束把数据转给正式EdgeItem后清除状态这样场景里不会残留大量临时对象。6. 从编辑器到执行器用拓扑排序把图跑起来流程图编辑器做到能存能读还不够很多场景下用户需要“运行”这张图。比如一个节点是加法运算另一个节点是条件判断连线代表数据流向。这个需求一旦出现就给编辑器加了一个新的层面把NodeData里的运算逻辑提取出来用拓扑排序确定执行顺序然后逐节点求值。实现顺序拆三步走。第一步给每个端口定义类型和方向输入端口接收上游数据输出端口向下游发送结果。第二步根据edges构建邻接表计算每个节点的入度。第三步用队列维护入度为0的节点按顺序pop出来执行执行完后把输出写入下游节点的输入槽再更新入度。如果图中存在环拓扑排序会提前终止此时要定位成环路提示用户修改。QListQString resolveOrder(const QListEdgeData edges, const QListNodeData nodes) { QHashQString, int indegree; QHashQString, QListQString adj; for (auto n : nodes) indegree[n.id] 0; for (auto e : edges) { adj[e.fromNodeId].append(e.toNodeId); indegree[e.toNodeId] 1; } QQueueQString q; for (auto it indegree.begin(); it ! indegree.end(); it) if (it.value() 0) q.enqueue(it.key()); QListQString order; while (!q.isEmpty()) { QString cur q.dequeue(); order.append(cur); for (const QString next : adj[cur]) { indegree[next] - 1; if (indegree[next] 0) q.enqueue(next); } } return order.size() nodes.size() ? order : QListQString(); }这里的实现把节点ID和节点类型的对应关系留给外部传入执行器本身不知道具体运算规则。执行的时候根据节点类型调用注册表里的计算函数输入值从节点实例的输入槽里取。这种设计让编辑器保持通用而执行逻辑可以按业务场景注册。举例来说同样是“加法”节点在数据处理工具里输入的是数值型字段在UI流程里输入的是控件事件执行函数可以各自注册各自的实现但拓扑排序代码完全复用。运行时求值只是进阶方向的第一层。一个有用的扩展是执行时高亮当前节点便于用户观察数据流向。高亮不需要额外设计运行状态只需在求值循环里emit一个信号主界面收到后把对应节点的画刷状态改一下执行完毕后统一恢复。这里要提醒一句运行流程和编辑器交互不要运行在同一个线程里执行器跑在QThread中界面更新通过信号跨线程派发否则一张复杂图在运行时拖动节点界面会卡死。我在做这类编辑器时养成了一个习惯每加一个功能都先确认它是属于“数据层”还是“视图层”。凡是要落盘的写进NodeData凡是要反应给用户的放进paint和itemChange。坚持这个划分后撤销、序列化和运行时都变得好写很多。希望这一套从对象模型到执行器的做法能帮你在QT项目里少走几条弯路。本文还有配套的精品资源点击获取
返回列表