做节点编辑器最怕的不是功能不够多,而是第三方库“不好商量”。上一轮迭代结束之后,我们内部又跑了一轮真实流程测试,结论很一致:撤回重做、分组折叠、节点删除稳定性、循环连接防护这些能力,已经是生产环境的硬需求了。与其继续给第三方库打补丁,不如把这些能力直接做进自己的编辑器里。
本文是“配合 AI 写 C++ 节点编辑器”系列的第十弹,内容聚焦这轮的两个重大功能更新(撤销/重做体系、分组与折叠)和一批陈年 bug 修复。全文会给出设计思路、完整核心代码、测试方案和实际踩坑记录,适合正在做图编辑器、节点编辑器、可视化流程工具的 C++ 开发者参考。
1. 项目背景:为什么自研节点编辑器,而不是继续用第三方库
市面上常见的 C++ 节点编辑器方案并不少,比如基于 ImGui 生态的 ImNodes,还有 Qt 社区的 NodeEditor/QtNodeEditor 这类开源项目。我对它们的评价是:演示够用,生产难改。具体痛点集中在以下几点:
- 界面定制成本高:第三方库通常有自己的绘制循环和交互逻辑,想改节点颜色、圆角、端口形状、右键菜单、动画效果,往往需要通读源码再覆写内部对象,改动面很大。
- 序列化方案要么没有,要么侵入性太强:节点编辑器最重要的能力就是把图保存下来再重新加载。很多第三方库把序列化逻辑做死在自己内部,或者要求你的业务节点继承它的特定基类,导致业务代码和界面代码耦合严重。
- 事件系统不透明:拖拽、连线、删除、框选这套交互看着简单,实际涉及到鼠标事件、场景坐标转换、悬停检测、命中测试等一堆细节。第三方库一旦在某层处理掉了事件,你就很难再插入自定义逻辑。
- 维护状态不稳定:部分开源节点库更新频率低,Issue 堆积严重,遇到 bug 只能自己啃源码。
正是因为这些原因,我们从第一弹开始就决定自研一套基于 Qt 的节点编辑器。经过十轮迭代,现在它已经支持节点拖拽、连线、属性面板联动、整体缩放、多选操作、快捷键管理等功能。这一轮的更新重点,是把它从“能画图”推进到“能日常使用”。
2. 环境与项目结构
先说明本文示例的环境,读者可以按自己实际情况调整:
- 语言标准:C++17
- GUI 框架:Qt 6.x(Widgets 模块)
- 构建工具:CMake 3.20+
- 编译器:MSVC 2022 / GCC 11+ 均可
- 测试框架:Qt Test(QtTest 模块)
项目使用典型的 CMake 结构:
node_editor/ ├── CMakeLists.txt ├── include/ │ ├── core/ │ │ ├── Node.h │ │ ├── Port.h │ │ └── NodeGraph.h │ ├── items/ │ │ ├── NodeItem.h │ │ ├── ConnectionItem.h │ │ └── GroupRectItem.h │ └── commands/ │ ├── AddNodeCommand.h │ ├── AddConnectionCommand.h │ └── DeleteItemsCommand.h ├── src/ │ ├── main.cpp │ ├── EditorMainWindow.cpp │ └── ... └── tests/ ├── TestUndoStack.cpp ├── TestCycleDetector.cpp └── TestSerialization.cpp这个结构把核心数据(Node、Port、NodeGraph)和界面表现(NodeItem、ConnectionItem、GroupRectItem)分离开。这样做的直接收益是:撤销/重做、序列化、循环检测这些逻辑可以在不启动界面的情况下做单元测试,后续功能迭代也更容易。
3. 核心架构回顾:数据与表现分离
在展开新功能之前,先快速回顾一下这套编辑器的基础架构,方便后面代码能直接对上。
数据层核心类:
Node:保存节点 ID(QUuid)、名称、输入端口列表、输出端口列表、业务数据。Port:端口 ID、端口名称、数据类型,以及所属节点指针。Connection:连接关系,保存输入端口和输出端口,同时缓存两端端口所属节点。NodeGraph:持有所有节点、所有连接,并提供增删节点、增删连接、拓扑排序、环检测等能力。
表现层核心类:
NodeItem:继承QGraphicsObject,负责节点的绘制、拖拽、选中、右键菜单。ConnectionItem:继承QGraphicsPathItem,根据两端端口位置绘制贝塞尔曲线。EditorScene:继承QGraphicsScene,统一管理节点和连接的事件分发。
数据层不感知 Qt 图形对象,这是整个项目能稳定扩展的核心前提。第三个季度之前我们踩过不少坑,后来发现凡是把业务数据直接塞进 QGraphicsItem 的地方,后续维护都特别痛苦。
4. 本轮重大功能更新(一):撤销/重做体系
撤销/重做是这次迭代里最重的一块。之前我们一直没做,是因为觉得“节点编辑器删错了重新拖就行”。但真实使用中,一次误删可能丢掉十几条连线,手工恢复成本非常高,所以这轮必须补齐。
4.1 为什么选择 Qt 自带的 QUndoStack
自己写撤销栈其实不难,难的是和场景的信号、快捷键、按钮状态同步。Qt 自带的QUndoStack和QUndoCommand已经解决了核心问题:
- 自动管理命令栈的 push、undo、redo;
- 支持命令合并(比如连续拖拽同一个节点可以合并成一步);
- 提供
canUndo()/canRedo()信号,方便界面同步按钮状态; - 自带
QUndoView,可以白嫖一个操作历史面板。
所以我们直接基于QUndoCommand实现命令类,而不是重复造轮子。
4.2 命令类设计
先看一个最简单的命令:添加节点。
// 文件路径:include/commands/AddNodeCommand.h #pragma once #include <QUndoCommand> class NodeGraph; class Node; class AddNodeCommand : public QUndoCommand { public: AddNodeCommand(NodeGraph* graph, Node* node, QUndoCommand* parent = nullptr) : QUndoCommand(QStringLiteral("添加节点"), parent) , m_graph(graph) , m_node(node) { } void undo() override { m_graph->removeNode(m_node->id()); } void redo() override { m_graph->addNode(m_node); } private: NodeGraph* m_graph; Node* m_node; };这里有一个很容易犯的错误:redo()里面不能重新new一个节点,否则 undo 之后再 redo 会生成两个不同指针,命令类里的m_node就悬空了。正确的做法是:节点对象在创建命令时就创建好,命令只负责把它加进图里或从图里移除。
同理,删除一组节点也是一条命令:
// 文件路径:include/commands/DeleteItemsCommand.h #pragma once #include <QUndoCommand> #include <QList> #include <QUuid> class NodeGraph; class DeleteItemsCommand : public QUndoCommand { public: DeleteItemsCommand(NodeGraph* graph, const QList<QUuid>& nodeIds, QUndoCommand* parent = nullptr) : QUndoCommand(QStringLiteral("删除节点"), parent) , m_graph(graph) , m_nodeIds(nodeIds) { } void undo() override { for (const QUuid& id : m_nodeIds) { m_graph->restoreNode(id); } } void redo() override { for (const QUuid& id : m_nodeIds) { m_graph->removeNode(id); } } private: NodeGraph* m_graph; QList<QUuid> m_nodeIds; };这里restoreNode是这轮给NodeGraph新增的能力:删除节点时不直接释放内存,而是把节点和它关联的连接信息打包放进“回收站”哈希表,这样 undo 时可以原样恢复,包括节点坐标和属性值。考虑到编辑器内存占用不算极端,这个“延迟销毁”策略比每次全量快照简单可靠得多。
4.3 在界面层接入 QUndoStack
命令只是操作的描述,真正驱动它的是栈对象。在主窗口中声明一个QUndoStack,然后把所有用户操作统一改成:
// 文件路径:src/EditorMainWindow.cpp(核心片段) void EditorMainWindow::onAddNodeButtonClicked() { auto* node = m_graph->createNode("MathNode", QPointF(0, 0)); m_undoStack->push(new AddNodeCommand(m_graph, node)); } void EditorMainWindow::onDeleteSelectedItems() { if (m_scene->selectedItems().isEmpty()) return; QList<QUuid> nodeIds; for (auto* item : m_scene->selectedItems()) { if (auto* nodeItem = qgraphicsitem_cast<NodeItem*>(item)) { nodeIds.append(nodeItem->nodeId()); } } m_undoStack->push(new DeleteItemsCommand(m_graph, nodeIds)); }这里需要特别注意:所有修改图结构的操作都必须通过命令对象进入栈里,不能一边 push 命令一边又手动调用m_graph->removeNode(),否则 undo 时会重复执行操作,状态就错乱了。我们在审查代码时用了一个很笨但有效的办法:grep 所有直接调用removeNode/addNode/addConnection的地方,除了命令类和测试代码,其他地方一律不允许出现。
连线的命令同理,AddConnectionCommand的 undo 会移除这条连接,redo 会重新建立。这里不展开贴全部代码,核心思想完全一致。
5. 本轮重大功能更新(二):分组与折叠
分组功能的需求很直接:当图里的节点超过几十个,屏幕会乱成一团。用户希望可以把一组语义相关的节点框起来,然后整体拖动、整体复制,甚至折叠成一个“子图节点”。
5.1 分组的数据结构
分组不是简单画一个矩形框,因为分组必须能被序列化保存,还要在撤销/重做后保持一致。我们新增了一个NodeGroup数据类:
// 文件路径:include/core/NodeGroup.h #pragma once #include <QString> #include <QUuid> #include <QRectF> #include <QSet> class NodeGroup { public: QUuid id() const { return m_id; } QString name() const { return m_name; } void setName(const QString& name) { m_name = name; } QRectF rect() const { return m_rect; } void setRect(const QRectF& rect) { m_rect = rect; } QSet<QUuid> memberIds() const { return m_memberIds; } void addMember(const QUuid& nodeId) { m_memberIds.insert(nodeId); } void removeMember(const QUuid& nodeId) { m_memberIds.remove(nodeId); } private: QUuid m_id = QUuid::createUuid(); QString m_name; QRectF m_rect; QSet<QUuid> m_memberIds; };这里的关键决策是:分组只保存成员节点的 ID,不持有节点指针。这么设计是为了让分组能稳稳地配合撤销/重做。假设分组 A 引用了 NodeItem 指针,删除命令把节点对象销毁后重新恢复,指针地址可能已经变了,分组就悬空了。用 ID 关联,每次使用时通过NodeGraph::findNode(id)查找,就不会出现悬垂引用。
5.2 分组框 Item 的实现
界面层的GroupRectItem继承自QGraphicsRectItem,负责绘制半透明背景、编辑名称、处理框内节点的联动移动:
// 文件路径:src/items/GroupRectItem.cpp(核心片段) void GroupRectItem::mouseMoveEvent(QGraphicsSceneMouseEvent* event) { QGraphicsRectItem::mouseMoveEvent(event); // 拖动分组框时,把框内的成员节点一起移动 const QPointF delta = event->pos() - event->lastPos(); for (const QUuid& memberId : m_group.memberIds()) { if (NodeItem* item = m_scene->findNodeItem(memberId)) { item->moveBy(delta.x(), delta.y()); } } }这里有一个性能细节:不要把findNodeItem写成QGraphicsScene::items()的线性扫描。我们在EditorScene里维护了一个QHash<QUuid, NodeItem*> m_nodeItems映射表,查找复杂度是 O(1),对几百个节点的大图也扛得住。
5.3 序列化的兼容处理
分组引入后,存档格式必须扩展。我们的 JSON 结构大概是这样的:
{ "version": 3, "nodes": [ { "id": "c1f8b26e-...", "type": "MathNode", "x": 120.0, "y": 80.0 } ], "connections": [ { "fromNode": "c1f8b26e-...", "fromPort": "out1", "toNode": "a2d9cc11-...", "toPort": "in0" } ], "groups": [ { "id": "group-001", "name": "光照计算", "rect": { "x": 80, "y": 60, "w": 400, "h": 240 }, "members": ["c1f8b26e-...", "a2d9cc11-..."] } ] }这里version字段是这轮加上的。之前每一次改存档结构都会导致旧文件无法打开,这次我们约定:加载时先读版本号,如果版本低于当前版本,走迁移函数;如果高于当前版本,直接拒绝打开并提示用户升级编辑器。迁移函数按版本号逐级升级,比如 v2 到 v3 只需要给每个分组生成默认 ID,逻辑量不大,但以后再改结构就不会炸老存档了。
6. Bug 修复专题:这类编辑器最容易踩的那些坑
功能更新只是这轮工作的一半,剩下时间都在和 bug 搏斗。下面整理的几个问题都来自真实使用中的崩溃报告,每一项都附带根因分析和修复方案。
6.1 删除节点后,连接线悬空和野指针
现象:选中一个节点 Delete 后,和它相连的曲线没有删除,继续点击曲线甚至导致崩溃。
根因:ConnectionItem内部保存了两个端点的Port*指针。节点删除时,Port 对象随 Node 一并析构,但ConnectionItem不知道指针已经失效,再次访问就成了悬垂指针。
修复方案:在EditorScene::deleteNode()中,先遍历该节点的所有连接,显式移除对应ConnectionItem,再删除节点:
void EditorScene::deleteNode(const QUuid& nodeId) { Node* node = m_graph->findNode(nodeId); if (!node) return; // 第一步:收集该节点参与的所有连接 const QList<QUuid> connIds = m_graph->connectionsForNode(nodeId); // 第二步:先移除画面上的 ConnectionItem for (const QUuid& connId : connIds) { if (ConnectionItem* item = m_connectionItems.take(connId)) { removeItem(item); delete item; } } // 第三步:再从数据层删除节点 m_graph->removeNode(nodeId); // 第四步:同步维护节点映射表 NodeItem* item = m_nodeItems.take(nodeId); if (item) { removeItem(item); delete item; } }这里最关键的纪律是:界面上所有对象都从EditorScene的两个哈希表里找到,删除时也必须同步从哈希表里移除。只要“新增时注册、删除时注销”这条规则不被破坏,大部分悬垂指针问题都能提前避免。
顺带说一句,使用 Qt 智能指针类QPointer<ConnectionItem>也能帮忙,但QPointer只能检测对象是否销毁,不能解决“连接数据已经从图里移除但画面还留着”的状态错乱,所以治本还是要严格走统一删除流程。
6.2 缩放后新增节点,位置明显偏移
现象:把画布缩放到了 150%,再通过按钮新增节点,节点出现的位置总是不在鼠标可见区域附近,甚至跑到视野外。
根因:点击按钮时拿到的坐标是按钮所在窗口的坐标,直接当成场景坐标用了。窗口坐标需要经过QGraphicsView::mapToScene()转换,这个转换会把当前缩放比例和滚动位置计算进去。
修复方案:创建节点前,先根据视图当前的视口中心点换算场景坐标:
QPointF EditorMainWindow::currentSceneCenter() { QPoint viewportCenter = m_view->viewport()->rect().center(); return m_view->mapToScene(viewportCenter); }新增节点时使用这个坐标,同时让视图平滑滚动到新节点附近:
void EditorMainWindow::onAddNodeButtonClicked() { const QPointF center = currentSceneCenter(); auto* node = m_graph->createNode("MathNode", center); m_undoStack->push(new AddNodeCommand(m_graph, node)); if (auto* item = m_scene->findNodeItem(node->id())) { m_view->centerOn(item); } }这类问题最适合归进回归测试:每次改完缩放逻辑,都要在 50%、100%、200% 三档缩放级别下重复测试新增、粘贴、居中节点。手工测试很容易漏,所以我们在测试用例里直接模拟了缩放后创建节点的场景坐标断言。
6.3 环形连接导致递归爆栈
现象:用户把节点 A 的输出连到了节点 B 的输入,接着又想把节点 B 的输出连到节点 A 的输入,程序在处理求值顺序时直接崩溃,或者界面卡死。
根因:图数据本身允许有环,但求值器用的是递归拓扑排序。有环的情况下递归无法终止,最终栈溢出。
修复方案:在建连接之前做环检测。如果新连接会让图产生环,就直接拒绝并弹出提示:
// 文件路径:src/core/NodeGraph.cpp(核心片段) bool NodeGraph::wouldCreateCycle(const QUuid& sourceNode, const QUuid& targetNode) const { if (sourceNode == targetNode) return true; QSet<QUuid> visited; QList<QUuid> queue; queue.append(targetNode); // BFS:从 target 出发,看能不能走到 source while (!queue.isEmpty()) { QUuid current = queue.takeFirst(); if (current == sourceNode) return true; if (visited.contains(current)) continue; visited.insert(current); for (const QUuid& next : outputNodeIds(current)) { if (!visited.contains(next)) queue.append(next); } } return false; }调用点在addConnection内部,这样无论 UI 拖拽、脚本导入还是命令行调用,都会被这层检查拦下来。安全边界尽量放在数据层,不要只放在 UI 层,这是这轮最大的经验之一。
6.4 多线程求值时 UI 崩溃
现象:节点图里有耗时节点(例如某个计算节点需要跑几百毫秒),我们把求值放到子线程,结果子线程里访问了节点对象,导致 Qt 报出“QObject 只能从创建它的线程访问”的崩溃。
根因:节点对象本质上关联了NodeItem,而NodeItem属于 QObject 子系统,子线程直接修改节点状态会破坏 Qt 的线程亲和性。
修复方案:把求值结果做成只读数据,通过信号从子线程发射回主线程:
// 求值线程结束时的代码(子线程中) Q_EMIT evaluationFinished(nodeId, resultData); // resultData 是纯值,不含 QObject // 主线程连接槽 connect(&m_evaluator, &Evaluator::evaluationFinished, this, &EditorMainWindow::onEvaluationFinished, Qt::QueuedConnection);连接方式明确写成Qt::QueuedConnection,保证槽函数一定切回主线程执行,然后再去更新NodeItem的显示文本、颜色、端口数值。这条规则简单粗暴:业务计算可以多线程,凡是碰 QGraphicsItem 的更新一律回主线程。
7. AI 在开发中的角色:这轮更新是怎么“配合 AI”完成的
标题里提到“配合 AI 写节点编辑器”,这轮迭代中 AI 承担的主要角色不是自动生成全部代码,而是下面几类具体工作:
- 生成命令类样板代码:
AddNodeCommand、DeleteItemsCommand、AddConnectionCommand的结构非常相似,AI 可以按照第一个示例批量生成剩下几个,省去大量复制粘贴时间。 - 写边界测试:环检测、序列化迁移、撤销栈连续操作这些逻辑,AI 很适合生成并发测试场景,比如连续 push 50 条命令再一次性撤销,验证栈状态是否一致。
- 辅助分析崩溃栈:遇到崩溃时,把调用栈和关键源码片段丢给 AI,它能快速指出最可疑的悬垂指针位置,节省了不少排查时间。
但这里也要说句公道话:AI 生成的代码仍然需要人工审查。这轮我们就发现过 AI 生成的“删除节点”函数里没有同步清理m_connectionItems映射,单独看逻辑没问题,回归测试一跑就露馅。所以我们的流程是:AI 产出初稿 → 人工审查数据结构一致性 → 跑 Qt Test 单测 → 再进手工功能验收。
8. 常见问题与排查清单
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 撤销后节点位置没有恢复 | 节点移动走的是直接setPos,没有生成命令 | 节点移动结束事件里 push MoveNodeCommand,记录起始和结束位置 |
| 删除节点时崩溃 | ConnectionItem仍持有已析构的Port* | 先删连接 Item,再删节点;统一走EditorScene删除流程 |
| 打开旧存档缺少分组信息 | 旧文件没有groups字段 | 加载时检查版本号,进行字段迁移 |
| 新节点出现在错误位置 | 窗口坐标未转换为场景坐标 | 使用mapToScene()转换后再创建节点 |
| 连线操作卡死 | 未做环检测,形成了循环依赖 | 在addConnection内部调用wouldCreateCycle |
| 子线程更新界面崩溃 | 子线程直接访问QGraphicsItem | 结果通过信号带回主线程,使用 QueuedConnection |
| 撤销栈和界面不同步 | 手动调用了数据层修改函数,绕过了命令栈 | 所有图结构修改统一通过QUndoStack::push进入 |
建议大家在项目里固化一份排查清单,每次遇到同类问题直接按表格顺序检查,比现场重新推演快得多。
9. 最佳实践与工程建议
经过这轮迭代,有几条经验很值得记录下来:
- 数据层和表现层要保持单向依赖。数据层不依赖 Qt 图形类,表现层可以依赖数据层。这样撤销命令、序列化、环检测都能脱离 UI 测试,运行效率和安全边界都会好很多。
- 所有图结构变更走命令栈,不到万不得已不要旁路。这条看起来简单,但实际操作中经常因为“这个删除太简单了直接调一下”而破坏规则。代码审查时要盯这一点。
- 资源删除必须有统一的注销点。凡是
new出来并注册进场景的对象,删除时必须从关联的哈希表中移除,并且建议在删除函数的注释里写明“几步走”的顺序。 - 存档格式从第一天就带版本号。这在资源管理领域也该当默认规则,但很多编辑器项目直到用户抱怨“旧档打不开”才补。
- 安全边界放到数据层。环检测、非法连接校验应该由
NodeGraph本身保证,而不是依赖 UI 层判断。否则任何绕过 UI 的代码路径都会留下隐患。 - 配合 AI 开发时,让 AI 写测试比写功能更划算。功能代码还要人工理解、审查,而边界测试用例量大且重复,非常适合 AI 批量生成,自己只负责补充几个极端场景。
10. 总结与下一步
这轮迭代完成了两件大事:撤销/重做体系和分组折叠能力,同时修复了删除悬垂指针、缩放坐标偏移、循环连接死循环、多线程 UI 崩溃四个高危问题。现在这套编辑器已经能支撑日常的节点图编辑工作,连续操作几十步后再回退,节点坐标、连接关系、分组信息都能保持一致。
如果接下来继续迭代,我会优先做两件事:一是把分组折叠后生成“子图节点”的渲染做出来,让大图真正能缩成一棵树;二是给撤销栈加上命令合并,把连续拖动同一个节点合并成一个撤销步骤,减少用户按键次数。
工程上这类项目的深度往往不在某一个功能本身,而在各种边界条件——删除顺序、线程切换、存档兼容。把这些问题一个个按规则解决掉,编辑器才会从“能演示”变成“能交付”。如果你也在做类似的节点编辑器,欢迎对照本文的排查清单和架构思路检查一下自己的项目,很多坑提前堵上比事后修复要省太多时间。