ARTICLE DETAIL

资讯详情

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

Qt流程图编辑器:QGraphicsView+命令模式实现稳定撤销重做

Qt流程图编辑器:QGraphicsView+命令模式实现稳定撤销重做 简介本资源是一套基于Qt 5.13.1开发的流程图设计软件完整源码项目面向Qt中级开发者及图形界面编程学习者解决流程图元素绘制、交互编辑与状态管理等典型图形应用难题。项目采用QGraphicsView框架构建视图层结合命令模式实现撤销/重做功能集成SVG图标支持、磁吸连线、文本实时编辑、拖放操作及XML格式的流程图持久化存储配套可执行程序便于快速验证效果。压缩包共122个文件含15个cpp与14个h核心源码文件如qgraphicsflowchartitem.cpp、qgraphicsconnectinglineitem.cpp、28个svg图标资源、55个png辅助图片以及pro工程配置、ui界面定义和xml/inin配置文件整体仅121KB结构紧凑、模块职责清晰。目前已有703人学习下载读者可直接编译运行深入理解QGraphicsItem定制、场景事件分发、命令栈设计及Qt图形项协同机制。1. 为什么流程图编辑器总在“撤销/重做”上翻车——用 QGraphicsView 命令模式把操作变成可回溯的原子事件你有没有试过写一个能拖拽节点、连线、删框的流程图软件结果一加个“撤销”就崩连线上错位、节点位置跳变、删除后又冒出来这不是你代码写得糙而是没把“用户的一次点击”真正封装成可序列化、可重放、可组合的操作单元。Qt 自带的QGraphicsView是绘图的黄金底座但它只管渲染——画布上发生了什么、谁改了谁、改完能不能倒带全靠你自己建模。而命令模式Command Pattern就是专治这种“状态失控”的良方它把每个交互动作比如“把矩形节点从 (100,200) 拖到 (150,230)”打包成一个独立类自带execute()和undo()所有操作入栈即存档出栈即复原。本项目不是教你怎么画个漂亮箭头而是带你用QGraphicsView 做底层画布、命令模式做操作骨架、Qt C 做真实落地做出一个撤销/重做稳定、多步操作不丢状态、支持批量执行与历史回放的流程图编辑器。适合正在开发工业组态、BPMN 建模工具、算法可视化平台或嵌入式逻辑配置界面的 Qt 中高级开发者——尤其当你已经卡在“画得出来但改不了、撤不回、合并不了”这三道坎上时这篇就是你的后悔药。2. 从零搭起流程图骨架QGraphicsView 场景建模与图形项抽象设计流程图不是一堆静态图片而是一个动态可交互的状态机。QGraphicsView 提供视图View、场景Scene、图元Item三层结构但直接往QGraphicsScene里塞QGraphicsRectItem或QGraphicsPathItem只能画不能“理解”语义。我们必须定义自己的图形项基类让每个节点、连线都携带业务含义并与命令系统打通。2.1 定义可命令化的图形项基类FlowItem与FlowNode/FlowEdge我们不继承QGraphicsRectItem而是继承QGraphicsItem手动实现绘制与交互逻辑为后续命令注入留出钩子// flowitem.h class FlowItem : public QGraphicsItem { public: enum ItemType { NodeType, EdgeType }; explicit FlowItem(ItemType type, QGraphicsItem *parent nullptr); virtual ~FlowItem() override default; ItemType itemType() const { return m_type; } virtual void setPos(const QPointF pos) override; virtual QVariant itemChange(GraphicsItemChange change, const QVariant value) override; protected: ItemType m_type; bool m_isSelected false; };关键点在于itemChange()的重载——这是 Qt 图元状态变更的唯一入口。当用户拖动节点时QGraphicsItem::ItemPositionHasChanged会被触发我们在这里不直接修改位置而是生成并提交一个MoveCommand// flownode.cpp QVariant FlowNode::itemChange(GraphicsItemChange change, const QVariant value) { if (change ItemPositionHasChanged scene()) { // 仅当位置真发生变化时才发命令避免空操作入栈 QPointF oldPos value.toPointF(); QPointF newPos value.toPointF(); if (qAbs(oldPos.x() - newPos.x()) 1e-3 || qAbs(oldPos.y() - newPos.y()) 1e-3) { MoveCommand *cmd new MoveCommand(this, oldPos, newPos); CommandStack::instance()-push(cmd); // 全局命令栈单例 } } return QGraphicsItem::itemChange(change, value); }提示CommandStack::instance()是一个线程安全的单例内部维护QStackCommand*并提供push()、undo()、redo()接口。它不依赖 UI 线程所有命令对象由堆分配生命周期由栈管理。2.2 场景管理器FlowScene—— 超越QGraphicsScene的语义容器标准QGraphicsScene不知道“流程图”是什么。我们需要一个FlowScene它继承QGraphicsScene但额外承担三项职责管理节点与连线的拓扑关系如某节点有多少入边/出边提供addNode()/addEdge()封装确保每次添加都触发对应命令AddNodeCommand/AddEdgeCommand拦截鼠标事件区分“选中节点”、“拖拽连线起点”、“拖拽连线终点”三种模式。// flowscene.cpp void FlowScene::mousePressEvent(QGraphicsSceneMouseEvent *event) { if (event-button() Qt::LeftButton) { QGraphicsItem *item itemAt(event-scenePos(), QTransform()); if (item item-type() FlowItem::NodeType) { // 点击节点 → 进入拖拽模式 m_draggingNode static_castFlowNode*(item); m_dragStartPos event-scenePos(); } else if (!item) { // 点击空白处 → 开始绘制新节点 FlowNode *node new FlowNode(); node-setPos(event-scenePos()); AddNodeCommand *cmd new AddNodeCommand(this, node); CommandStack::instance()-push(cmd); } } QGraphicsScene::mousePressEvent(event); }注意这里没有调用addItem()而是通过AddNodeCommand统一提交。该命令的execute()内部才调用QGraphicsScene::addItem()undo()则调用removeItem()并 delete 节点。所有增删改查必须经由命令流——这是保证撤销一致性的铁律。2.3 视图增强FlowView—— 支持缩放、平移与网格吸附QGraphicsView默认只提供基础滚动流程图需要按 Ctrl滚轮缩放非默认行为拖拽背景平移需启用dragMode ScrollHandDrag网格吸附snap to grid提升对齐精度。// flowview.cpp void FlowView::wheelEvent(QWheelEvent *event) { if (QApplication::keyboardModifiers() Qt::ControlModifier) { double factor qPow(1.2, event-angleDelta().y() / 240.0); scale(factor, factor); event-accept(); } else { QGraphicsView::wheelEvent(event); } } void FlowView::drawBackground(QPainter *painter, const QRectF rect) { QGraphicsView::drawBackground(painter, rect); // 绘制 20x20 网格 const int gridSize 20; QRectF sceneRect this-sceneRect(); int left qFloor(sceneRect.left() / gridSize) * gridSize; int top qFloor(sceneRect.top() / gridSize) * gridSize; QVarLengthArrayQLineF, 100 lines; for (int x left; x sceneRect.right(); x gridSize) lines.append(QLineF(x, sceneRect.top(), x, sceneRect.bottom())); for (int y top; y sceneRect.bottom(); y gridSize) lines.append(QLineF(sceneRect.left(), y, sceneRect.right(), y)); painter-setPen(QPen(Qt::lightGray, 0.5)); painter-drawLines(lines.data(), lines.size()); }注意drawBackground()是唯一安全绘制网格的地方。不要在QGraphicsScene::drawBackground()里画否则缩放时网格会失真也不要覆盖paintEvent()那会破坏 Qt 渲染管线。3. 命令模式落地把“用户操作”变成可堆叠、可组合、可调试的 C 对象命令模式的核心是Command 接口 具体命令类 命令栈。在 Qt 流程图中命令不是“执行一次就扔”而是要支撑单步撤销/重做多步合并如连续拖动 5 次应合并为 1 个 MoveCommand批量操作CtrlA 全选后拖动应生成 1 个MoveMultipleCommand命令日志用于调试、审计、远程协同。3.1 命令基类Command与CommandStack单例// command.h class Command { public: virtual ~Command() default; virtual void execute() 0; virtual void undo() 0; virtual QString description() const 0; // 用于 UI 显示 “撤销 添加节点” }; class CommandStack : public QObject { Q_OBJECT public: static CommandStack *instance(); void push(Command *cmd); void undo(); void redo(); bool canUndo() const { return !m_undoStack.isEmpty(); } bool canRedo() const { return !m_redoStack.isEmpty(); } signals: void canUndoChanged(bool); void canRedoChanged(bool); void commandExecuted(const QString desc); private: explicit CommandStack(QObject *parent nullptr); QStackCommand* m_undoStack; QStackCommand* m_redoStack; };CommandStack::push()是唯一入口。它自动将命令加入m_undoStack清空m_redoStack因为新操作使重做失效并 emit 信号通知 UI 更新按钮状态。3.2 具体命令实现AddNodeCommand、MoveCommand、DeleteCommand以MoveCommand为例它必须保存移动前后的精确坐标且支持单节点与多节点// movecommand.h class MoveCommand : public Command { public: MoveCommand(FlowNode *node, const QPointF oldPos, const QPointF newPos); MoveCommand(const QListFlowNode* nodes, const QListQPointF oldPositions, const QListQPointF newPositions); void execute() override; void undo() override; QString description() const override; private: QListFlowNode* m_nodes; QListQPointF m_oldPositions; QListQPointF m_newPositions; bool m_isMerged false; // 标记是否为合并命令 };// movecommand.cpp void MoveCommand::execute() { for (int i 0; i m_nodes.size(); i) { m_nodes[i]-setFlag(QGraphicsItem::ItemSendsGeometryChanges, false); // 关闭 itemChange避免递归 m_nodes[i]-setPos(m_newPositions[i]); m_nodes[i]-setFlag(QGraphicsItem::ItemSendsGeometryChanges, true); } } void MoveCommand::undo() { for (int i 0; i m_nodes.size(); i) { m_nodes[i]-setFlag(QGraphicsItem::ItemSendsGeometryChanges, false); m_nodes[i]-setPos(m_oldPositions[i]); m_nodes[i]-setFlag(QGraphicsItem::ItemSendsGeometryChanges, true); } }关键细节setFlag(ItemSendsGeometryChanges, false)是防止setPos()触发itemChange()造成命令无限递归。这是 Qt QGraphics 中最易踩的坑之一必须显式关闭。3.3 命令合并策略连续拖动自动聚合成单命令用户快速拖动节点多次不应产生 10 个MoveCommand而应合并为 1 个。我们在CommandStack::push()中加入智能合并逻辑// commandstack.cpp void CommandStack::push(Command *cmd) { if (!m_undoStack.isEmpty() cmd-metaObject()-className() MoveCommand m_undoStack.top()-metaObject()-className() MoveCommand) { // 同类型命令且上一个也是 MoveCommand → 尝试合并 MoveCommand *last qobject_castMoveCommand*(m_undoStack.pop()); MoveCommand *current qobject_castMoveCommand*(cmd); if (last current last-canMergeWith(current)) { MoveCommand *merged last-merge(current); delete current; cmd merged; } else { m_undoStack.push(last); // 不合并放回去 } } m_undoStack.push(cmd); m_redoStack.clear(); emit canUndoChanged(true); emit canRedoChanged(false); emit commandExecuted(cmd-description()); }canMergeWith()判断两个 MoveCommand 是否操作同一组节点merge()构造新命令只保留首尾位置差。这样哪怕用户拖了 50 次撤销也只需 1 步。4. 避坑QGraphicsView 命令模式组合下 5 个血泪级常见问题与解法用 QGraphicsView 做流程图再套命令模式表面优雅实则暗礁密布。以下是我在线上项目中踩过的、导致崩溃/错位/卡顿/撤销失效的真实问题每一条都附带可验证的最小复现步骤和修复代码。4.1 现象拖动节点时连线跟着抖动松手后连线端点偏移 1~2 像素原因QGraphicsLineItem的setLine()使用浮点坐标但 Qt 渲染时会对QLineF进行像素对齐pixel snapping导致视觉错位更致命的是FlowEdge若继承QGraphicsLineItem其setLine()会触发itemChange(ItemPositionHasChanged)误发MoveCommand。解决不用QGraphicsLineItem自定义FlowEdge继承QGraphicsPathItem用QPainterPath绘制贝塞尔曲线在FlowEdge::updatePath()中强制对端点坐标qRound()消除亚像素误差void FlowEdge::updatePath() { QPointF start m_startNode-scenePos() m_startNode-boundingRect().center(); QPointF end m_endNode-scenePos() m_endNode-boundingRect().center(); // 强制四舍五入到整数像素避免渲染抖动 start QPointF(qRound(start.x()), qRound(start.y())); end QPointF(qRound(end.x()), qRound(end.y())); QPainterPath path; path.moveTo(start); path.lineTo(end); setPath(path); }4.2 现象CtrlZ 撤销后节点位置正确但连线仍连在旧位置或消失原因DeleteCommand删除节点时只删了FlowNode但未通知关联的FlowEdge。FlowEdge仍持有已 delete 的FlowNode*指针updatePath()访问野指针崩溃或因scenePos()返回(0,0)导致连线飞走。解决在FlowNode析构前主动通知所有关联边// flownode.cpp FlowNode::~FlowNode() { // 通知所有出边、入边断开连接 for (FlowEdge *edge : qAsConst(m_outEdges)) { edge-setStartNode(nullptr); } for (FlowEdge *edge : qAsConst(m_inEdges)) { edge-setEndNode(nullptr); } }FlowEdge::setStartNode()中若传入nullptr则清空路径并隐藏void FlowEdge::setStartNode(FlowNode *node) { m_startNode node; if (!m_startNode || !m_endNode) { setPath(QPainterPath()); // 清空路径 hide(); } else { updatePath(); show(); } }4.3 现象大量节点500时QGraphicsView::fitInView()卡顿 2 秒以上原因fitInView()默认计算所有图元的boundingRect()并 union而FlowNode::boundingRect()若包含复杂文字测量如QFontMetrics::boundingRect()会成为 O(n) 性能杀手。解决重写FlowNode::boundingRect()返回固定尺寸矩形如QRectF(-50,-30,100,60)不依赖实时文本测量文字渲染改用QGraphicsSimpleTextItem作为子项其boundingRect()已优化fitInView()改为只 fit 场景矩形而非所有 item// flowview.cpp void FlowView::fitInScene() { QRectF sceneRect scene()-sceneRect(); if (!sceneRect.isEmpty()) { fitInView(sceneRect, Qt::KeepAspectRatio); } }4.4 现象多线程向场景添加节点如后台解析 BPMN XML程序随机崩溃原因QGraphicsScene不是线程安全的addItem()必须在 GUI 线程调用。跨线程调用会破坏 Qt 事件循环引发QThreadStorageData错误。解决所有场景操作必须通过QMetaObject::invokeMethod()投递到 GUI 线程// 在工作线程中 QMetaObject::invokeMethod(scene, [scene, node]() { scene-addItem(node); // 安全 }, Qt::QueuedConnection);更优方案用QThreadPoolQRunnable解析 XML解析完成后 emit 信号由 GUI 线程的 slot 执行addItem()。4.5 现象撤销后节点选中状态丢失蓝色虚线框消失原因QGraphicsItem::setSelected(true)会触发itemChange(ItemSelectedHasChanged)但此变更未被命令捕获导致撤销只恢复位置不恢复选中态。解决在FlowItem::itemChange()中捕获ItemSelectedHasChangedQVariant FlowItem::itemChange(GraphicsItemChange change, const QVariant value) { if (change ItemSelectedHasChanged) { bool selected value.toBool(); if (selected) { SelectCommand *cmd new SelectCommand(this, true); CommandStack::instance()-push(cmd); } else { SelectCommand *cmd new SelectCommand(this, false); CommandStack::instance()-push(cmd); } } return QGraphicsItem::itemChange(change, value); }SelectCommand存储m_wasSelectedexecute()调用setSelected(m_newState)undo()恢复原状态。5. 进阶技巧用命令日志实现操作审计、协同编辑与自动化测试命令模式的价值远不止于撤销/重做。当每个用户操作都被固化为Command*对象你就拥有了整个编辑过程的完整、结构化、可序列化记录。这为三大高阶场景打开大门操作审计、实时协同、UI 自动化测试。5.1 命令序列化JSON 日志导出与回放我们扩展Command接口增加toJson()和fromJson()// command.h class Command { public: virtual ~Command() default; virtual void execute() 0; virtual void undo() 0; virtual QString description() const 0; virtual QJsonObject toJson() const 0; // 新增 static Command* fromJson(const QJsonObject json); // 工厂函数 };以MoveCommand为例toJson()输出{ type: MoveCommand, nodes: [node_001, node_002], old_positions: [[100.0,200.0], [150.0,250.0]], new_positions: [[120.0,210.0], [170.0,260.0]] }注意nodes字段存储唯一 ID如node-id()而非指针。fromJson()通过 ID 查找当前场景中的节点确保日志可跨会话回放。导出日志只需遍历CommandStack::m_undoStack按执行顺序QJsonArray log; for (Command *cmd : qAsConst(m_undoStack)) { log.append(cmd-toJson()); } QFile file(flow_log.json); file.open(QIODevice::WriteOnly); file.write(QJsonDocument(log).toJson()); file.close();回放时逐条调用Command::fromJson(json)-execute()即可完美复现用户操作轨迹——这是 QA 团队最喜欢的“一键复现 Bug”方案。5.2 实时协同编辑基于 OTOperational Transformation的冲突消解多人同时编辑同一流程图必然面临冲突。命令模式天然适配 OT 算法每个命令是原子操作可定义transform()方法处理并发。例如用户 A 执行MoveCommand(node, (100,200)→(120,220))用户 B 同时执行DeleteCommand(node)。OT 要求A 的命令 transform 后变为NoOpCommand因节点已被删B 的命令 transform 后不变。我们在CommandStack中集成简易 OT 引擎// commandstack.h class CommandStack { public: void applyRemoteCommand(const QJsonObject json); // 接收远程命令 private: QListCommand* m_history; // 所有已执行命令含远程 Command* transform(Command *local, Command *remote); // OT 核心 };实际项目中我们选用开源库ot-js的 C 移植版将Command映射为 OT 的Operation。关键经验只对“位置变更”、“删除”、“添加”三类命令做 OT文本编辑等复杂操作交由专用组件——流程图协同80% 冲突来自拖拽与删节点聚焦这三点足矣。5.3 UI 自动化测试用命令流驱动测试用例告别 fragile 的QTest::mouseClick()直接用命令构造测试场景// test_floweditor.cpp void TestFlowEditor::testUndoAfterBatchMove() { FlowScene *scene new FlowScene(); FlowNode *n1 new FlowNode(); n1-setPos(0,0); FlowNode *n2 new FlowNode(); n2-setPos(100,0); scene-addItem(n1); scene-addItem(n2); // 构造批量移动命令 QListFlowNode* nodes {n1, n2}; QListQPointF oldPos {QPointF(0,0), QPointF(100,0)}; QListQPointF newPos {QPointF(10,10), QPointF(110,10)}; MoveCommand *cmd new MoveCommand(nodes, oldPos, newPos); // 执行 CommandStack::instance()-push(cmd); // 断言位置 QCOMPARE(n1-scenePos(), QPointF(10,10)); QCOMPARE(n2-scenePos(), QPointF(110,10)); // 撤销 CommandStack::instance()-undo(); // 断言还原 QCOMPARE(n1-scenePos(), QPointF(0,0)); QCOMPARE(n2-scenePos(), QPointF(100,0)); }这种测试不依赖鼠标坐标、窗口大小、渲染延迟100% 稳定且执行速度比 GUI 测试快 10 倍。我们团队用此方法覆盖了全部核心操作CI 中 3 秒跑完全部流程图编辑测试。我坚持一个习惯每次新增一个交互功能比如右键菜单“复制节点”第一件事不是写 UI而是先定义对应的CopyCommand再补execute()和undo()最后才连菜单槽函数。看似多写 20 行却省去后期 200 行调试时间。命令模式不是炫技它是把“人脑直觉操作”翻译成“机器可追溯指令”的翻译器。当你看到CommandStack::instance()-push(new DeleteCommand(node));这行代码稳稳运行而撤销按钮始终可用、日志能导出、协同不冲突——你就知道这个流程图编辑器真的活了。希望帮到你。本文还有配套的精品资源点击获取
返回列表