做节点编辑器最怕的不是功能不够多而是第三方库“不好商量”。上一轮迭代结束之后我们内部又跑了一轮真实流程测试结论很一致撤回重做、分组折叠、节点删除稳定性、循环连接防护这些能力已经是生产环境的硬需求了。与其继续给第三方库打补丁不如把这些能力直接做进自己的编辑器里。本文是“配合 AI 写 C 节点编辑器”系列的第十弹内容聚焦这轮的两个重大功能更新撤销/重做体系、分组与折叠和一批陈年 bug 修复。全文会给出设计思路、完整核心代码、测试方案和实际踩坑记录适合正在做图编辑器、节点编辑器、可视化流程工具的 C 开发者参考。1. 项目背景为什么自研节点编辑器而不是继续用第三方库市面上常见的 C 节点编辑器方案并不少比如基于 ImGui 生态的 ImNodes还有 Qt 社区的 NodeEditor/QtNodeEditor 这类开源项目。我对它们的评价是演示够用生产难改。具体痛点集中在以下几点界面定制成本高第三方库通常有自己的绘制循环和交互逻辑想改节点颜色、圆角、端口形状、右键菜单、动画效果往往需要通读源码再覆写内部对象改动面很大。序列化方案要么没有要么侵入性太强节点编辑器最重要的能力就是把图保存下来再重新加载。很多第三方库把序列化逻辑做死在自己内部或者要求你的业务节点继承它的特定基类导致业务代码和界面代码耦合严重。事件系统不透明拖拽、连线、删除、框选这套交互看着简单实际涉及到鼠标事件、场景坐标转换、悬停检测、命中测试等一堆细节。第三方库一旦在某层处理掉了事件你就很难再插入自定义逻辑。维护状态不稳定部分开源节点库更新频率低Issue 堆积严重遇到 bug 只能自己啃源码。正是因为这些原因我们从第一弹开始就决定自研一套基于 Qt 的节点编辑器。经过十轮迭代现在它已经支持节点拖拽、连线、属性面板联动、整体缩放、多选操作、快捷键管理等功能。这一轮的更新重点是把它从“能画图”推进到“能日常使用”。2. 环境与项目结构先说明本文示例的环境读者可以按自己实际情况调整语言标准C17GUI 框架Qt 6.xWidgets 模块构建工具CMake 3.20编译器MSVC 2022 / GCC 11 均可测试框架Qt TestQtTest 模块项目使用典型的 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保存节点 IDQUuid、名称、输入端口列表、输出端口列表、业务数据。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 QListQUuid 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; QListQUuid 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; QListQUuid nodeIds; for (auto* item : m_scene-selectedItems()) { if (auto* nodeItem qgraphicsitem_castNodeItem*(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; } QSetQUuid 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; QSetQUuid 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里维护了一个QHashQUuid, 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 QListQUuid 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 智能指针类QPointerConnectionItem也能帮忙但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; QSetQUuid visited; QListQUuid 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 崩溃四个高危问题。现在这套编辑器已经能支撑日常的节点图编辑工作连续操作几十步后再回退节点坐标、连接关系、分组信息都能保持一致。如果接下来继续迭代我会优先做两件事一是把分组折叠后生成“子图节点”的渲染做出来让大图真正能缩成一棵树二是给撤销栈加上命令合并把连续拖动同一个节点合并成一个撤销步骤减少用户按键次数。工程上这类项目的深度往往不在某一个功能本身而在各种边界条件——删除顺序、线程切换、存档兼容。把这些问题一个个按规则解决掉编辑器才会从“能演示”变成“能交付”。如果你也在做类似的节点编辑器欢迎对照本文的排查清单和架构思路检查一下自己的项目很多坑提前堵上比事后修复要省太多时间。
阅读完成 · 觉得有帮助?