Qt图形视图框架实战:从QWidget到QGraphics性能优化
1. 从QWidget到QGraphics为什么我最终选择了图形视图框架刚接触Qt那会儿我做过一个节点编辑器的小工具。第一版直接用QWidget加paintEvent硬画几十个节点的时候还行拖拽、连线都凑合能用。等到节点数量破百问题就全冒出来了拖动一个节点整个窗口重绘帧率掉得肉眼可见想做个框选得自己算每个节点的包围盒更别提缩放和平移了坐标换算写了一大堆改一处崩三处。那段时间我几乎把QWidget的坐标系统翻了个底朝天最后得出的结论很直接——当图元数量超过一定规模、需要频繁交互和变换时QWidget这套体系就不该硬扛了。Qt提供的图形视图框架Graphics View Framework核心就是三个类QGraphicsScene、QGraphicsView和QGraphicsItem。它把数据和显示彻底分开了。场景负责管理所有图元、维护它们的空间索引、分发事件视图负责把场景渲染到屏幕上处理滚动、缩放、坐标映射图元则是场景里一个个独立的对象自己管自己的绘制和交互。这套MVC式的结构让一个场景可以同时被多个视图观察也让成千上万个图元的碰撞检测、选中、拖拽变得有章可循。这篇文章适合谁看如果你正在做流程图、拓扑图、电路仿真、地图标注、数据可视化看板这类需要大量图形元素交互的项目或者你已经被QWidget的自定义绘制折磨过一轮那这篇内容应该能帮你少走不少弯路。我会从场景搭建、图元定制、坐标系统、性能优化这几个维度把我在实际项目里踩过的坑和总结出来的做法尽量讲透。内容会持续更新因为图形视图框架的细节实在太多一次讲不完。2. 场景、视图、图元三件套的职责边界与初始化套路2.1 三个类到底谁管什么很多人刚上手时容易把逻辑写乱比如把图元的选中状态存在视图里或者让场景去处理鼠标滚轮缩放。我一开始也这么干过结果就是代码耦合得没法看。后来我强迫自己记住一条原则场景管数据和空间关系视图管呈现和输入映射图元管自己的样子和行为。QGraphicsScene是容器它维护一个图元列表提供addItem、removeItem、items这些接口。它还负责事件分发——鼠标点在某個坐标上场景会通过itemAt找到对应的图元把事件传下去。场景还有一个很重要的能力是空间索引默认用BSP树图元多了之后items查询依然很快。QGraphicsView是窗口部件它继承自QAbstractScrollArea所以自带滚动条。视图持有一个场景指针通过setScene绑定。视图的transform决定了场景坐标到视图坐标的映射缩放、旋转都是改这个变换矩阵。视图还负责把鼠标事件从视图坐标转成场景坐标再交给场景。QGraphicsItem是所有图元的基类。它有一堆虚函数等着你重写boundingRect返回图元的外包围矩形paint负责绘制shape返回精确的碰撞形状。图元有自己的坐标系原点是它的局部原点所有绘制都基于这个原点。图元还可以有父子关系子图元的坐标是相对于父图元的。2.2 一个最小可运行骨架我习惯在项目里封装一个自己的视图类把常用的缩放、平移逻辑放进去。下面这个骨架是我每个图形项目都会用的起点class DiagramScene : public QGraphicsScene { Q_OBJECT public: explicit DiagramScene(QObject *parent nullptr) : QGraphicsScene(parent) { setSceneRect(0, 0, 2000, 2000); setBackgroundBrush(QColor(245, 245, 245)); } }; class DiagramView : public QGraphicsView { Q_OBJECT public: explicit DiagramView(QWidget *parent nullptr) : QGraphicsView(parent) { setRenderHint(QPainter::Antialiasing); setRenderHint(QPainter::SmoothPixmapTransform); setDragMode(QGraphicsView::RubberBandDrag); setViewportUpdateMode(QGraphicsView::SmartViewportUpdate); setTransformationAnchor(QGraphicsView::AnchorUnderMouse); setResizeAnchor(QGraphicsView::AnchorViewCenter); } protected: void wheelEvent(QWheelEvent *event) override { const double factor event-angleDelta().y() 0 ? 1.15 : 1.0 / 1.15; scale(factor, factor); } };这里有几个点值得展开说。setSceneRect一定要设不然场景会随着图元移动不断变大滚动条会跳来跳去。setRenderHint里的抗锯齿在图形项目里基本是必开的不然线条锯齿很难看。setDragMode设成RubberBandDrag之后在空白处拖拽就能框选省得自己写。setViewportUpdateMode我一般用SmartViewportUpdate它会让Qt自己决定重绘哪块区域比全量刷新省资源。setTransformationAnchor和setResizeAnchor这两个锚点设置很关键。AnchorUnderMouse让缩放时鼠标位置保持不动体验很自然AnchorViewCenter让窗口大小变化时中心点不动。这两个如果不设缩放和调整窗口时画面会乱跑用户会骂人。2.3 场景坐标、视图坐标、图元坐标的换算坐标系统是图形视图框架里最容易出错的地方。我画过一张图来帮自己记忆视图坐标是窗口部件的像素坐标原点在视图左上角场景坐标是逻辑坐标原点由sceneRect决定图元坐标是相对于图元父级的局部坐标。换算关系是这样的视图坐标通过mapToScene变成场景坐标场景坐标通过mapFromScene变成视图坐标。图元坐标通过mapToScene变成场景坐标通过mapToParent变成父图元坐标。反过来就是mapFromScene和mapFromParent。我踩过的一个坑是在paint函数里拿到的事件坐标是图元局部坐标但我想判断鼠标是否在某个子图元上就得先转成场景坐标再转成子图元的局部坐标。当时没搞清这套换算判断逻辑一直不对。后来我养成了一个习惯在需要跨图元判断的时候统一先转到场景坐标再从场景坐标转到目标图元坐标这样不容易乱。3. 自定义图元boundingRect和paint的配合是性能命门3.1 boundingRect必须准确且尽量小boundingRect返回的是图元的外包围矩形Qt用它来做碰撞检测、重绘区域计算、场景索引。这个函数返回的矩形如果不准会出现各种诡异问题图元拖到某个位置就消失或者选中框比图元大一圈或者重绘时留下残影。我见过有人图省事boundingRect直接返回一个巨大的矩形比如QRectF(-1000, -1000, 2000, 2000)。这样确实不会出现绘制不全的问题但性能会急剧下降。因为Qt认为这个图元占了很大一块区域任何和这块区域相交的更新都会触发它的重绘场景索引也会变得很低效。正确的做法是boundingRect返回刚好包住图元绘制内容的最小矩形再加上画笔宽度的余量。比如你画一个半径为10的圆画笔宽度为2那boundingRect应该是QRectF(-11, -11, 22, 22)。如果图元有选中状态会画额外的边框那boundingRect也要把边框算进去。QRectF NodeItem::boundingRect() const { const qreal penWidth 2.0; const qreal margin penWidth / 2.0 1.0; return QRectF(-m_radius - margin, -m_radius - margin, (m_radius margin) * 2, (m_radius margin) * 2); }3.2 paint里只画该画的东西paint函数会被频繁调用任何不必要的操作都会拖慢整个界面。我总结了几条原则第一不要在paint里做耗时的计算比如遍历大量数据、加载图片、创建字体第二根据option-levelOfDetailFromTransform判断当前缩放级别缩得很小的时候可以跳过细节绘制第三利用option-exposedRect只绘制暴露区域。void NodeItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) { Q_UNUSED(widget); const qreal lod option-levelOfDetailFromTransform( painter-worldTransform()); if (lod 0.3) { painter-setBrush(m_color); painter-drawRect(boundingRect()); return; } painter-setRenderHint(QPainter::Antialiasing, true); painter-setPen(QPen(m_borderColor, 2.0)); painter-setBrush(m_color); painter-drawEllipse(QPointF(0, 0), m_radius, m_radius); if (lod 0.8) { painter-setPen(Qt::black); painter-drawText(QRectF(-m_radius, -m_radius, m_radius * 2, m_radius * 2), Qt::AlignCenter, m_label); } }这段代码里levelOfDetailFromTransform返回的是当前变换的缩放因子。当图元缩得很小时只画一个简单的矩形代替圆形和文字能省下大量绘制开销。这个技巧在节点数量上千的时候效果非常明显。3.3 shape决定点击判定别偷懒用boundingRectshape返回的是图元的精确轮廓用于碰撞检测和鼠标点击判定。默认实现返回boundingRect对于矩形图元没问题但对于圆形、多边形、带透明区域的图元就不准了。比如一个圆形节点如果shape返回外接矩形那点击四个角也会被判定为选中体验很怪。QPainterPath NodeItem::shape() const { QPainterPath path; path.addEllipse(QPointF(0, 0), m_radius, m_radius); return path; }shape的构建也有性能考量。如果图元形状复杂每次调用都重新构建QPainterPath会很慢。我一般会在图元状态变化时缓存这个路径shape直接返回缓存。4. 交互设计选中、拖拽、连线的完整实现链路4.1 让图元可选中、可移动图元默认是不可选中、不可移动的需要设置标志位setFlag(QGraphicsItem::ItemIsSelectable, true); setFlag(QGraphicsItem::ItemIsMovable, true); setFlag(QGraphicsItem::ItemSendsGeometryChanges, true);ItemIsSelectable让图元能被选中选中后isSelected返回truepaint里可以根据这个状态画高亮边框。ItemIsMovable让图元能被鼠标拖动。ItemSendsGeometryChanges这个标志很关键它让图元在位置变化时收到itemChange通知连线更新就靠它。QVariant NodeItem::itemChange(GraphicsItemChange change, const QVariant value) { if (change ItemPositionHasChanged) { for (EdgeItem *edge : qAsConst(m_edges)) { edge-updatePosition(); } } return QGraphicsItem::itemChange(change, value); }这段代码的意思是当节点位置变化后遍历所有和它相连的边让边重新计算路径。如果没有ItemSendsGeometryChangesitemChange根本不会被调用连线就会和节点脱节。4.2 连线图元的绘制与更新连线图元比节点图元要复杂一些因为它要跟随两个端点移动。我的做法是让边持有起点和终点节点的指针在paint里实时计算路径。void EdgeItem::updatePosition() { prepareGeometryChange(); m_path QPainterPath(m_source-pos()); m_path.lineTo(m_target-pos()); update(); }prepareGeometryChange必须在boundingRect要变化之前调用它通知场景这个图元的包围盒要变了场景会更新索引并安排重绘。如果不调用移动节点后边的旧位置会留下残影。boundingRect对于边来说要返回包含整条线的矩形并且留出画笔宽度的余量QRectF EdgeItem::boundingRect() const { return m_path.boundingRect().adjusted(-3, -3, 3, 3); }4.3 框选与多选的处理QGraphicsView的RubberBandDrag模式已经帮我们实现了框选但有时候我们需要自定义框选行为比如只选中完全在框内的图元或者按住Ctrl加选。这些可以通过重写视图的鼠标事件来实现。我遇到过一个需求框选时只选中节点的中心点在框内的节点。默认的框选是只要图元和框有交集就选中。为了实现这个我重写了mouseReleaseEvent在框选结束后遍历场景中的图元判断中心点是否在框内然后手动设置选中状态。void DiagramView::mouseReleaseEvent(QMouseEvent *event) { if (event-button() Qt::LeftButton m_rubberBandActive) { const QRectF sceneRect mapToScene(m_rubberBandRect).boundingRect(); for (QGraphicsItem *item : scene()-items()) { NodeItem *node dynamic_castNodeItem *(item); if (node sceneRect.contains(node-pos())) { node-setSelected(true); } } m_rubberBandActive false; event-accept(); return; } QGraphicsView::mouseReleaseEvent(event); }这里用dynamic_cast来判断图元类型虽然有一点运行时开销但在框选这种低频操作里完全可以接受。如果图元类型很多可以考虑用type()函数加自定义类型ID来替代。5. 性能调优从几百个图元到上万个图元的实战经验5.1 视图更新模式的选择QGraphicsView有几种视口更新模式FullViewportUpdate、MinimalViewportUpdate、SmartViewportUpdate、BoundingRectViewportUpdate、NoViewportUpdate。默认是MinimalViewportUpdate它会尽量只重绘变化区域。SmartViewportUpdate会让Qt自己判断通常比Minimal更聪明一些。FullViewportUpdate每次全量重绘性能最差除非有渲染问题否则不要用。我在一个项目里把模式从Minimal改成Smart帧率提升了大概百分之二十。但要注意如果你的图元boundingRect不准Smart模式可能会漏绘这时候反而要退回Full模式来排查问题。5.2 图元缓存策略QGraphicsItem有一个setCacheMode方法可以把图元绘制结果缓存成QPixmap。对于绘制复杂但很少变化的图元开启缓存能大幅提升性能。setCacheMode(QGraphicsItem::DeviceCoordinateCache);DeviceCoordinateCache会把图元按设备坐标缓存缩放时缓存会失效重建。ItemCoordinateCache按图元坐标缓存缩放时直接缩放缓存图像速度快但会模糊。我一般对静态的、绘制复杂的图元用DeviceCoordinateCache对需要频繁缩放的图元用ItemCoordinateCache。但缓存不是万能的。如果图元经常变化缓存会频繁失效重建反而更慢。我一般只对背景网格、静态装饰这类图元开缓存。5.3 空间索引与图元查找场景默认用BSP树做空间索引items查询效率很高。但如果你的图元分布很不均匀或者经常增删图元BSP树可能会退化。Qt提供了setItemIndexMethod来切换索引方式NoIndex表示不建索引BspTreeIndex是默认值。我遇到过一次性能问题场景里有上万个图元每次鼠标移动都调用items查询鼠标下的图元卡得不行。后来发现是BSP树在频繁增删图元后变得不平衡。解决办法是批量增删图元时先调用setItemIndexMethod(NoIndex)操作完再设回BspTreeIndex让树重建一次。scene-setItemIndexMethod(QGraphicsScene::NoIndex); // 批量添加或删除图元 scene-setItemIndexMethod(QGraphicsScene::BspTreeIndex);5.4 避免不必要的重绘图元位置变化时如果只是移动不需要调用prepareGeometryChange因为包围盒大小没变。只有包围盒会变化时才需要调用。这个区别很多人搞混导致移动图元时性能很差。另外update()可以指定重绘区域只更新变化的部分update(QRectF(-10, -10, 20, 20));如果图元只有一小块区域变化传一个小的矩形比不传参数全量更新要快得多。6. 那些年我踩过的坑与排查思路6.1 图元拖动时残影不消失这个问题我遇到过好几次每次原因都不一样。第一次是因为boundingRect返回的矩形太小图元绘制超出了包围盒移动时超出部分没被擦除。第二次是因为在paint里画了包围盒之外的东西比如阴影效果。第三次是因为ItemSendsGeometryChanges没设itemChange没触发连线没更新。排查这类问题的思路是先确认boundingRect是否覆盖了所有绘制内容可以在paint里临时用红色画笔把boundingRect画出来看看。然后确认prepareGeometryChange是否在包围盒变化前调用了。最后检查是否有图元之间的依赖关系没更新。6.2 缩放后鼠标点击位置偏移这个问题的根源通常是坐标换算没做对。视图的transform改变了但鼠标事件里的坐标还是视图坐标需要先mapToScene再判断。如果图元有父子关系还要继续mapFromScene到图元坐标。我一般的做法是在视图的鼠标事件里统一转成场景坐标然后交给场景处理。场景的itemAt接受场景坐标返回图元指针。如果需要图元局部坐标再用item-mapFromScene转换。6.3 大量图元时界面卡顿除了前面说的缓存和索引优化还有一个容易被忽略的点是信号槽的连接。如果每个图元都连接了场景的changed信号图元一多信号发射和槽调用就会成为瓶颈。我一般只在必要的时候连接信号或者用blockSignals临时阻塞。另外QGraphicsScene::changed信号会传递一个QListQRectF表示变化的区域。如果槽函数里做了耗时操作会拖慢整个场景。我通常把这个信号用来更新缩略图或者状态栏不做重活。6.4 图元层级与Z值图元的绘制顺序由Z值决定Z值大的画在上面。默认Z值是0按添加顺序绘制。如果图元有重叠需要手动设置Z值。我一般给不同类型的图元分配不同的Z值范围比如背景图元Z值为-100连线Z值为0节点Z值为100选中高亮Z值为200。这样层级关系清晰不会出现连线盖住节点的情况。setZValue(100);选中时临时提高Z值取消选中时恢复这样选中的图元会浮到最上面视觉反馈更明显。7. 从场景到视图多视图与打印输出的扩展玩法7.1 一个场景多个视图图形视图框架的一个强大之处是支持多个视图观察同一个场景。我做过一个项目左边是编辑视图右边是预览视图两个视图共享同一个场景。编辑视图可以缩放平移预览视图固定比例显示整体效果。实现起来很简单创建两个QGraphicsView都setScene到同一个场景即可。但要注意多个视图的transform是独立的一个视图缩放不影响另一个。如果希望两个视图同步缩放需要手动连接信号。另外多个视图同时渲染同一个场景时场景的changed信号会发给所有视图性能上会有一些开销图元特别多的时候要留意。7.2 场景的打印与导出QGraphicsScene提供了render函数可以把场景渲染到任意QPainter上包括打印机和图片。导出图片的代码大概是这样QImage image(scene-sceneRect().size().toSize(), QImage::Format_ARGB32); image.fill(Qt::white); QPainter painter(image); painter.setRenderHint(QPainter::Antialiasing); scene-render(painter); painter.end(); image.save(output.png);打印的话把QPainter换成打印机的QPainter就行。但要注意打印时场景的sceneRect可能很大需要根据纸张大小调整缩放比例。我一般会计算一个合适的缩放因子让内容完整落在纸张的可打印区域内。7.3 与模型数据分离图形视图框架本身不涉及数据模型但实际项目里图元往往对应着业务数据。我的做法是让图元持有一个数据对象的指针或者ID图元的绘制和交互只负责表现数据的增删改查由独立的模型层处理。这样图元可以随时重建数据不会丢。比如节点图元持有一个NodeData的ID场景里维护一个QHashint, NodeData。节点移动时更新NodeData里的坐标节点删除时从哈希表里移除。这样即使场景被清空重建数据依然完整。8. 持续更新图形视图框架里还有哪些值得深挖的方向图形视图框架的内容远不止这些。我接下来打算继续整理几个方向一是图元的动画效果用QPropertyAnimation配合QGraphicsItem的自定义属性实现平滑的移动和缩放二是自定义图元的编辑控件比如双击节点弹出输入框修改文字这涉及到QGraphicsProxyWidget的使用三是大规模图元的渲染优化比如用QOpenGLWidget作为视图的视口把绘制交给GPU四是场景的序列化与反序列化把整个场景保存成文件再读回来。这些内容每一个都够写一篇长文我会在实际项目中继续验证和总结。图形视图框架的学习曲线确实有点陡但一旦理解了场景、视图、图元三者的关系和坐标系统后面的事情就顺了。我个人的体会是不要一上来就追求大而全的架构先用最小可运行的骨架把功能跑通遇到性能问题再针对性优化这样学起来最扎实。