
简介这是一份基于 Qt 框架实现故障树分析FTA工具的完整工程源码面向需要开发图形化系统安全分析工具、或希望学习 Qt Graphics View 架构的 C 开发者。工程通过自定义 QGraphicsItem 图元构建事件节点、逻辑门与连接箭头包含鼠标拖拽、画布缩放、节点增删改与逻辑关系绘制等交互并提供布局调整、概率计算与 XML/JSON 数据存储功能可直接支撑小型 FTA 工具二次开发或课堂教学。资源包共 14 个文件以 5 个 cpp 和 4 个 h 源码文件为主配合 2 个 ui 界面设计文件和 pro 工程文件整体仅 25KB轻量清晰适合直接打开工程阅读和编译。目前已有 796 人学习下载。读者可从中掌握 QGraphicsScene/View 组合实现 2D 编辑器、自定义图元绘制、故障树逻辑门递归计算等关键实现同时可参考其界面交互与数据结构设计用于扩展更完整的故障树分析平台。1. 项目定位与需求拆解画图只是表面分析才是主线1.1 故障树工具要解决什么问题做工业软件这几年故障树分析Fault Tree AnalysisFTA一直是个绕不开的需求。核工业、电力、轨道交通、航空航天这些高风险行业做系统安全分析时几乎都要用到故障树。它的基本逻辑不复杂把系统里最不想发生的那个事件当作顶事件比如“电机启动失败”“安全回路断线”然后一层一层往下拆看这个顶事件是由哪些中间事件引起的中间事件又是由哪些底事件触发的中间用与门、或门把父子关系串起来。拆完之后再用数学方法算出哪些组合能导致顶事件发生这就是最小割集再算每个底事件的重要度用来指导维修策略和设计改进。这类工具在商用软件里不少但要么价格不便宜要么格式封闭、没法嵌入自家业务流程。所以很多团队选择自己开发一个故障树工具。而这个项目标题里那个“带画图功能”才是真正的重点用户不是要一个纯计算器而是要能像画原理图一样用鼠标拖出一个个事件节点、用连线把逻辑门串起来所见即所得地建出故障树然后一键算出结果。这也是需求拆解时要特别小心的点。很多人一接到需求就先去想算法把最小割集算出来了结果界面是命令行级别的用户根本没法用。反过来只想着把图画漂亮忽略了树结构和逻辑门语义那画出来的也只是一张静态示意图和CAD画个框图没有区别。正确思路是画图是交互入口数据模型是骨架分析算法是灵魂三者必须同时设计任何一环落后都会让项目变成半成品。1.2 为什么选Qt而不是Web技术选Qt做这种工具最直接的原因就是桌面端性能和原生体验。故障树分析往往要处理上百个节点的树图顶事件在最上面下面一层层展开底事件可能几十上百个操作频率又高拖拽、缩放、连线、选中、框选这些都要求毫秒级响应。Qt的Graphics View框架走的是C这条线绘制性能远不是Electron那类Web方案能比的而且不会出现动不动几百MB内存的情况。另外这类安全分析工具很多时候要部署在离线环境、内网环境甚至产线上可能还需要和已有的SCADA、仿真平台集成Qt本身是C库嵌入现有系统非常自然。Qt的跨平台能力也在那里摆着一套代码在Windows上做设计端在Linux工控机上做运行时都不需要重写。当然如果是纯Web展示需求比如领导要在线看分析报告那另说核心编辑工具用Qt做Web端做只读展示反而简单可靠。2. 架构设计图元、数据、算法三层各管各的2.1 视图层选了QGraphicsView而不是自绘Qt里做画图功能绕不开两条路一是用QPainter直接自绘把所有坐标计算、命中检测、重绘逻辑全自己写二是用QGraphicsScene和QGraphicsView这套框架。实际项目里只要图元数量超过几十个就不用考虑自绘了。QGraphicsView框架帮你做好了近乎所有脏活累活图元碰撞检测、鼠标事件分发、视图坐标变换、框选、动画、缩放都是现成的。相当于拿到了一个迷你版图形引擎你要做的只是定义“图元长什么样”和“图元之间怎么关联”。这里有个架构上的关键选择用标准Item还是自定义Item。QGraphicsRectItem、QGraphicsEllipseItem这些标准类能用但故障树的节点不只是一个矩形每个节点要显示事件名、事件编号、逻辑门类型符号还要支持不同状态下的不同绘制样式。所以我强烈建议基于QGraphicsObject或QGraphicsItem去做自定义Item子类把绘制逻辑封装进去。这样做的好处是后续加需求时比如要增加一个“未分析”的状态标记、要按重要度大小给节点染色你只需要改paint函数不用动调用方的代码。2.2 图元模型与故障树数据模型解耦新手最容易犯的一个错是把图元类直接当成数据模型来用。鼠标拖了节点就把坐标变化写到“节点对象”里要存文件了遍历所有Item来保存。这样写demo没问题但项目一复杂就崩了——算法模块要频繁读取树结构它不该关心某个节点在屏幕上的坐标而图元模块需要响应坐标变化重绘也不该去理解“或门”的割集算法。我的做法是单独建立一个数据层核心是一个FaultTreeNodeData结构体存节点的唯一ID、事件名称、事件类型顶事件/中间事件/底事件、门类型与门/或门/无、描述、故障率等业务字段再加上父节点ID和子节点ID列表。整个故障树就是一张节点表加边表底层用QHashQString, FaultTreeNodeData和边列表存起来跟画布完全无关。图元层只持有对应数据节点的指针或ID通过信号槽同步用户在画布上拖了节点就更新数据层里那个节点的坐标字段算法跑完了就把结果写回数据层再触发图元重绘。这样分层的好处非常实际第一算法模块可以脱离界面做单元测试直接构造一棵树算最小割集第二将来如果要做自动布局、批量导入导出不用碰任何图元代码第三数据层可以做undo/redoQUndoStack只记录数据变更图形只是数据的投影。3. 画图功能核心实现从能显示到好用3.1 自定义图元事件节点与逻辑门的绘制故障树图元和流程图的矩形有本质区别。一个标准故障树节点顶部是事件编号中间是事件名称如果是事件底部还可能要显示发生概率如果是逻辑门要画成倒梯形或带弧边的形状门内写AND或OR。好在QGraphics框架允许你在paint函数里想怎么画就怎么画我用的都是QPainterPath拼形状。下面是我项目里比较核心的自定义节点类的一个简写版本class FaultTreeNodeItem : public QGraphicsObject { Q_OBJECT public: enum NodeType { TopEvent, IntermediateEvent, BaseEvent }; enum GateType { NoGate, AndGate, OrGate }; FaultTreeNodeItem(const QString id, QGraphicsItem *parent nullptr); void setNodeType(NodeType type); void setGateType(GateType gate); void setEventName(const QString name); void setProbText(const QString prob); QRectF boundingRect() const override; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; signals: void nodeMoved(const QString id, QPointF newPos); void nodeSelected(const QString id); protected: QVariant itemChange(GraphicsItemChange change, const QVariant value) override; void contextMenuEvent(QGraphicsSceneContextMenuEvent *event) override; private: QRectF m_rect; QString m_id; NodeType m_nodeType; GateType m_gateType; QString m_eventName; QString m_probText; };两个函数必须实现好boundingRect和paint。boundingRect返回的是整个图元的外包矩形QGraphics框架用这个矩形做碰撞检测和局部重绘的裁剪范围给大了影响性能给小了绘制时会被截断。我的经验是留出8到10像素的余量因为选中边框、阴影效果都在这个范围里画。paint就是所有绘制逻辑的入口里面根据节点类型画矩形、画连接线、画门符号再用drawText绘制文本。这里有一个很容易被忽略的细节节点被拖动时itemChange回调里能拿到坐标变化但如果你在paint里直接读取节点的scenePos来绘制连线会产生电泳效应也就是连线追着节点跑但总是慢半拍。正确做法是重写itemChange里ItemPositionHasChanged分支在坐标变化后立刻发射nodeMoved信号让关联的连线重新计算路径而不是等下一次paint再刷新。3.2 连线逻辑父子节点怎么连故障树连线和一般绘图软件的连线有个区别它不仅是图形上的连接线还代表了逻辑上的父子关系。所以连线类要绑定两个节点的ID绘制时根据两端的锚点位置画出一条折线或者一个直角弯线。逻辑上连线还要知道它连的是“输出”还是“输入”父节点的底部是输出子节点的顶部是输入。连线类我通常这样设计class FaultTreeEdgeItem : public QGraphicsItem { public: FaultTreeEdgeItem(const QString parentId, const QString childId, FaultTreeNodeItem *parentItem, FaultTreeNodeItem *childItem); void updatePath(); QRectF boundingRect() const override; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; private: QString m_parentId; QString m_childId; QPainterPath m_path; };关键方法是updatePath。每次任一端的节点移动时都调用它重新计算路径取父节点的底部中心作为起点取子节点的顶部中心作为终点然后用QPainterPath画一条折线——先向下走一段再水平走到子节点上方最后垂直落下。直角折线在故障树里最常用因为读图习惯是从上往下折线比斜线更清爽。还有一个细节是线的选中态。我给FaultTreeEdgeItem增加了setSelected状态在paint里根据状态换颜色加粗。连线的命中检测也被经常忽略默认shape()返回的就是那条细线鼠标很难点到。我重写了shape()把路径往外扩了6像素宽度这样用户不必精确瞄准也能选中连线体验会好非常多。3.3 交互设计拖拽、缩放、框选、自动连线画图功能做得“能用”容易做得“好用”难。工程实践里下面几个交互点必须处理好。第一是拖拽。QGraphicsItem里只要setFlag(QGraphicsItem::ItemIsMovable)就能拖了。但父子关系处理很关键图元移动后要通知数据层更新坐标同时把所有关联连线刷新一遍。这块我走了弯路一开始在mouseMoveEvent里写刷新逻辑结果每个格子都要做一堆坐标计算后来直接用itemChange的信号触发连线的updatePath代码简洁性能也够用。第二是缩放和滚轮。故障树大了以后滚轮缩放是刚需。QGraphicsView默认没有滚轮缩放你得重写wheelEvent。我一般这样处理根据滚轮方向乘以一个缩放因子比如1.15然后调用scale()。同时要注意视图缩放到很小时文字会挤成一团看不清。我的做法是设置一个最小缩放比例低于这个值就只画节点色块不画文字缩放上去了再恢复文字绘制。这算一个比较取巧的优化方案但实际效果非常好。第三是自动连线。拖拽一个节点到另一个节点附近时如果能自动吸附并建立父子关系可以省去大量手工连线。这里要维护一个简单的“吸附规则”拖拽节点时如果它的中心点落在某个已有点的顶部/底部附近就显示一个高亮的吸附提示松开鼠标时自动创建边并同步更新数据层。这个功能不复杂但要对dropEvent和snapline做一点处理适合在基础画图功能稳定之后再上。void FaultTreeView::wheelEvent(QWheelEvent *event) { const qreal factor event-angleDelta().y() 0 ? 1.15 : 1.0 / 1.15; scale(factor, factor); }4. 分析功能落地画出来的树要能算4.1 最小割集计算下行法画图功能完善之后下一步就是把树转化成实际的分析结果。故障树最核心的分析是求最小割集。割集就是一组底事件集合这些底事件同时发生时顶事件必然发生。最小割集就是去掉任何一个底事件顶事件就不一定发生的割集。求最小割集常用“下行法”也叫Fussell算法思路非常直观从顶事件出发按门的类型往下展开。遇到与门就把门的输入做成一行横向展开遇到或门就把输入变成多列纵向展开最后每一列就是一个割集再做一个“吸收”操作去掉真超集。举个例子。假设顶事件T下面是一个或门连接M1和M2M1是与门下方是底事件X1、X2M2是与门下方是M3和X3M3是或门下方是X2、X4。下行法展开过程大致是第一步得到M1、M2两列M1是与门拆成X1X2M2拆成M3X3再把M3拆成X2、X4展开后就有{X1,X2}、{X2,X3}、{X3,X4}三组。这三组里没有哪一组是另一组的子集所以它们都是最小割集。如果出现{X1}和{X1,X2}并存那{X1,X2}就得去掉因为{X1}已经能导致顶事件发生了。Qt里实现这个算法不需要很复杂的代码按照树的深度优先遍历用QSet或者QStringList来存路径就行。我建议在实现时把数据层抽出来喂给算法算法本身完全不依赖图形界面。这样以后如果要做定量分析、重要度计算都能在这一层继续扩展。4.2 保存与导入XML序列化故障树要保存成文件最直观的格式是XML。Qt自带的QXmlStreamWriter/QXmlStreamReader非常轻量非常适合做这类数据的序列化。我在项目里把故障树存成下面这种结构FaultTree nameMotorStartFailure version1.0 Node idT1 name电机启动失败 typetop gateor x400 y50/ Node idM1 name电源回路异常 typeintermediate gateand x300 y180/ Node idX1 name总电源跳闸 typebase gatenone x200 y320/ Edge parentT1 childM1/ Edge parentM1 childX1/ /FaultTree这里有个容易踩的坑节点坐标一定不能只存相对坐标否则打开文件时如果场景尺寸变了整棵树会偏到看不见的地方。我从一开始就保存绝对坐标并且保存当前画布的视图范围打开文件后按视图范围自动居中显示。另外XML解析时不要假设节点一定是按顺序出现的有些导出工具可能把子节点放在父节点前面。所以我读取时先遍历所有Node存到哈希表里再遍历Edge建立父子关系最后再交给布局模块处理。顺序不一致的问题就自然解决了。5. 实测踩坑与优化建议5.1 高频踩坑点第一个高频坑是坐标原点理解不对。QGraphicsScene的坐标系和视图的坐标系是两回事Item的pos是相对于场景坐标系的而鼠标事件的pos是视图坐标。如果不做转换直接拿视图坐标赋值给Item的pos会出现点击位置和实际放置位置错位的问题。解决办法是使用view-mapToScene(event-pos())得到正确的场景坐标。第二个坑是缩放后线宽和字体跟着变。视图scale()以后Item的绘制也会被等比例放大导致本来1像素的线变粗字体变大整棵树看起来非常“糊”。补救办法是在paint时把画笔宽度除以transform().m11()也就是视图的缩放系数让线宽始终保持屏幕像素级。这个技巧在故障树的细节展示上特别管用。第三个坑是QGraphicsScene的setSceneRect如果不设置随着Item往四周拖场景范围和滚动条会自动扩展但有时会出现“拖出视野回不来”的现象。我的做法是在Item拖拽结束后检查Item的边界是否超出当前sceneRect如果是则扩展sceneRect这样滚动条不会被拖动操作反复触发更新。第四个坑也是我印象最深的大量连线在移动节点时的刷新风暴。一开始每个节点移动时我遍历所有关联边更新路径Node一多拖起来就直接卡顿。后来我改成只更新与被移动节点直接相连的一级边不更新整棵树的边再配合视图的ViewportUpdateMode改成BoundingRectViewportUpdate性能立刻上来了。这是个典型的过度刷新问题QGraphicsView默认是刷新所有脏区域但图元多的时候及时减少刷新范围比什么都管用。5.2 大图性能优化故障树节点超过100个之后绘制性能和交互流畅度就会明显下滑。除了上面说的减少刷新范围还有几个经验一启用图元缓存。对于大多数很少变动的节点在paint里用setCacheMode(QGraphicsItem::DeviceCoordinateCache)把绘制结果缓存成位图拖动时的重绘开销会小很多。但要注意如果节点本身会频繁改变颜色或文本缓存反而会降低效率所以只对静态节点开缓存。二分层管理。把节点、连线、背景网格放到不同的QGraphicsItemGroup里。这样改变背景的时候不会触发节点重绘修改连线样式也不用重绘所有节点。三算法层面做优化。最小割集计算在节点数多时指数爆炸要加裁减分支的逻辑如果当前路径已经能构成割集就不再往下展开。这个优化能解决很大一部分性能问题比任何图形层面的优化都明显。四在拖动大量节点时可以考虑暂时把视图的renderHints里的抗锯齿关掉比如把Antialiasing去掉等松手再恢复。视觉上会有轻微锯齿但拖动帧率提升非常明显。很多大图编辑软件都在用这个策略。6. 收尾一点个人经验做到这儿一个Qt故障树工具的核心功能就齐了图形化编辑、数据持久化、最小割集分析。回看整个项目我最深的体会是画图功能本身几乎都是Qt框架现成的能力真正的难度在于把图形交互、业务数据和领域算法串成一条完整的链路。只要你把数据层想清楚别让图元代码污染算法后面加任何功能都不慌。另外再分享一个小经验别急着把所有按钮都做出来先把“从空白画布拖出一个节点、连上一条线、保存文件、重新打开还原”这四个动作跑通这个工具就已经可以交给第一批用户用了。剩下的交互优化和算法增强都可以根据真实反馈慢慢迭代——实际项目里这种“先立骨架后长肉”的节奏往往比闷头开发半年再交付要稳妥得多。本文还有配套的精品资源点击获取