基于Qt的故障树分析工具开发:画图与计算实战
2026/9/2 3:19:25 网站建设 项目流程

简介:这是一份基于 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 Analysis,FTA)一直是个绕不开的需求。核工业、电力、轨道交通、航空航天这些高风险行业做系统安全分析时,几乎都要用到故障树。它的基本逻辑不复杂:把系统里最不想发生的那个事件当作顶事件,比如“电机启动失败”“安全回路断线”,然后一层一层往下拆,看这个顶事件是由哪些中间事件引起的,中间事件又是由哪些底事件触发的,中间用与门、或门把父子关系串起来。拆完之后,再用数学方法算出哪些组合能导致顶事件发生,这就是最小割集;再算每个底事件的重要度,用来指导维修策略和设计改进。

这类工具在商用软件里不少,但要么价格不便宜,要么格式封闭、没法嵌入自家业务流程。所以很多团队选择自己开发一个故障树工具。而这个项目标题里那个“带画图功能”才是真正的重点:用户不是要一个纯计算器,而是要能像画原理图一样,用鼠标拖出一个个事件节点、用连线把逻辑门串起来,所见即所得地建出故障树,然后一键算出结果。

这也是需求拆解时要特别小心的点。很多人一接到需求就先去想算法,把最小割集算出来了,结果界面是命令行级别的,用户根本没法用。反过来,只想着把图画漂亮,忽略了树结构和逻辑门语义,那画出来的也只是一张静态示意图,和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列表。整个故障树就是一张节点表加边表,底层用QHash<QString, FaultTreeNodeData>和边列表存起来,跟画布完全无关。图元层只持有对应数据节点的指针或ID,通过信号槽同步:用户在画布上拖了节点,就更新数据层里那个节点的坐标字段;算法跑完了,就把结果写回数据层,再触发图元重绘。

这样分层的好处非常实际:第一,算法模块可以脱离界面做单元测试,直接构造一棵树算最小割集;第二,将来如果要做自动布局、批量导入导出,不用碰任何图元代码;第三,数据层可以做undo/redo,QUndoStack只记录数据变更,图形只是数据的投影。

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; };

两个函数必须实现好:boundingRectpaint。boundingRect返回的是整个图元的外包矩形,QGraphics框架用这个矩形做碰撞检测和局部重绘的裁剪,范围给大了影响性能,给小了绘制时会被截断。我的经验是留出8到10像素的余量,因为选中边框、阴影效果都在这个范围里画。paint就是所有绘制逻辑的入口,里面根据节点类型,画矩形、画连接线、画门符号,再用drawText绘制文本。

这里有一个很容易被忽略的细节:节点被拖动时,itemChange回调里能拿到坐标变化,但如果你在paint里直接读取节点的scenePos来绘制连线,会产生电泳效应,也就是连线追着节点跑但总是慢半拍。正确做法是重写itemChangeItemPositionHasChanged分支,在坐标变化后立刻发射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()。同时要注意,视图缩放到很小时,文字会挤成一团看不清。我的做法是设置一个最小缩放比例,低于这个值就只画节点色块,不画文字,缩放上去了再恢复文字绘制。这算一个比较取巧的优化方案,但实际效果非常好。

第三是自动连线。拖拽一个节点到另一个节点附近时,如果能自动吸附并建立父子关系,可以省去大量手工连线。这里要维护一个简单的“吸附规则”:拖拽节点时,如果它的中心点落在某个已有点的顶部/底部附近,就显示一个高亮的吸附提示,松开鼠标时自动创建边,并同步更新数据层。这个功能不复杂,但要对dropEventsnapline做一点处理,适合在基础画图功能稳定之后再上。

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和M2;M1是与门,下方是底事件X1、X2;M2是与门,下方是M3和X3;M3是或门,下方是X2、X4。下行法展开过程大致是:第一步得到M1、M2两列;M1是与门,拆成X1X2;M2拆成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 name="MotorStartFailure" version="1.0"> <Node id="T1" name="电机启动失败" type="top" gate="or" x="400" y="50"/> <Node id="M1" name="电源回路异常" type="intermediate" gate="and" x="300" y="180"/> <Node id="X1" name="总电源跳闸" type="base" gate="none" x="200" y="320"/> <Edge parent="T1" child="M1"/> <Edge parent="M1" child="X1"/> </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框架现成的能力,真正的难度在于把图形交互、业务数据和领域算法串成一条完整的链路。只要你把数据层想清楚,别让图元代码污染算法,后面加任何功能都不慌。

另外再分享一个小经验:别急着把所有按钮都做出来,先把“从空白画布拖出一个节点、连上一条线、保存文件、重新打开还原”这四个动作跑通,这个工具就已经可以交给第一批用户用了。剩下的交互优化和算法增强,都可以根据真实反馈慢慢迭代——实际项目里,这种“先立骨架后长肉”的节奏,往往比闷头开发半年再交付要稳妥得多。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询