简介面向VS2017Qt5.14.2环境下的Qt开发者这套基于QGraphicsView架构的图形绘制工程完整演示了矩形、正方形、圆形、三角形、多线段与曲线的鼠标实时交互绘制方式解决从视图框架搭建到交互绘制的关键流程问题。压缩包共137个文件大小约375MB核心源码为35个cpp与19个h配以ui界面、qrc资源、sln/vcxproj工程配置以及可执行exe和docx文档其余多为编译中间文件便于完整理解构建流程。该主题已有3997人学习/下载在同类图形交互资源中热度较高。从预览的源码结构看涵盖了派生场景/视图类、图形形状工厂、拖拽框选等关键设计同时提供实时曲线绘制和撤销删除操作既适合初学者按工程逐步调试也适合开发者抽取组件集成到自有项目中。1. QGraphicsView架构图形绘制从「画死」到「画活」的那一步做绘图工具、流程图编辑器或者组态显示的时候我最初也和很多人一样直接在控件的paintEvent里用QPainter画矩形、椭圆、连线。图形少的时候确实爽可一旦图元数量上了几十个、又要支持拖拽和缩放坐标换算、局部重绘、命中检测全搅在一起代码很快就成了一团理不清的麻绳。QGraphicsView架构把这件事从根上拆成了三层场景管数据、视图管呈现、图元管自己。这篇文章就沿着QGraphicsView架构讲清楚基本图形怎么绘制、怎么交互、坑在哪里适合正在做绘图类工具或想从QPainter迁移到图形框架的开发者。2. Scene/View/Item三层与坐标系先拆明白再动手2.1 三个类各管一摊Scene管世界、View管视野、Item管自己把QGraphicsView架构比作一个舞台就很好理解QGraphicsScene是舞台本身它拥有所有演员的名单负责演员的添加、删除、查找和相互碰撞检测QGraphicsView是观众席上的那台摄像机它决定你站在哪个位置、用多大倍率看舞台摄像机怎么转都不会改变舞台上的摆位QGraphicsItem则是站在舞台上的每个演员它自己决定长什么样、占多大地方、要不要回应鼠标和键盘。这种分工最大的好处是职责隔离。过去用QPainter直接绘制时绘制逻辑天然地和窗口尺寸绑定在一起窗口一缩放所有坐标都得手动重算。而在QGraphicsView架构里Item永远住在一个逻辑坐标的世界里View只是负责把这个世界的一个矩形区域投到屏幕上。摄像机拉近拉远演员的站位和尺寸数据完全不动动的是View的变换矩阵。创建基本骨架时我通常会先把场景范围定下来。scene.setSceneRect(-200, -200, 400, 400)这行代码很多人忽略但它决定了滚动条的范围和后续fitInView的基准。如果不设置Scene会动态计算所有Item的边界Item一旦被拖走滚动条就会自己乱跳这是新手最容易遇到的第一类莫名其妙。// 创建场景与视图的最小骨架 QGraphicsScene scene; scene.setSceneRect(-200, -200, 400, 400); QGraphicsView view(scene); view.setRenderHint(QPainter::Antialiasing, true); // 抗锯齿曲线和斜线不发毛 view.resize(800, 600); view.show();这里的逻辑是Scene先划定一个世界边界View再把这块边界的某一局部投射到窗口。setRenderHint告诉View在渲染时统一开抗锯齿这是视觉质量的开关一旦漏了圆和斜线的锯齿感会非常明显。2.2 三套坐标系换算用对mapToScene之前别谈交互Item内部用局部坐标Scene用世界坐标View用视口坐标这是QGraphicsView架构里最核心、也最容易混淆的三套坐标。局部坐标的典型场景是你画一个矩形Itemrect定的是(-50,-30,100,60)这个坐标系以Item自己的原点为基准与它在舞台上的位置无关。Item在舞台上的落点则由setPos决定这个Pos是Scene坐标系下的值。View坐标则是你鼠标在窗口控件里拿到的那一套QPoint。三者换算的方法集中在几个函数里我用得最多的是下面这张表换算需求调用方式说明鼠标点击位置转世界坐标view.mapToScene(event-pos())交互的第一件事永远先做这一步Item局部坐标转世界坐标item-mapToScene(localPos)或item-scenePos()取Item所在位置世界坐标转Item局部坐标item-mapFromScene(scenePos)常用于判断某个点是否落在Item内部世界坐标转View坐标view.mapFromScene(scenePos)需要在视口上画覆盖层时用一套典型的点击判断流程是这样View收到鼠标事件后先把event-pos()通过mapToScene转成世界坐标再用scene.itemAt(scenePos, QTransform())问Scene这个位置站着谁。如果直接把View坐标拿去用View有滚动条或缩放时点选必然偏位这是命中检测翻车的常见来源。值得注意的是View的缩放和滚动只改变变换矩阵不改变任何一个Item的坐标。所以判断一个点是否落在图元内部最稳的写法是item-contains(item-mapFromScene(scenePos))。先在Item内部用局部坐标问它而不是自己手算View和Scene的比例。2.3 什么时候不值得用QGraphicsView一个冷静的判断表QGraphicsView架构不是万能的我在一些项目里也见过为画一个静态仪表盘强行引入整套框架的写法反而绕了远路。判断标准其实很朴素如果没有交互、图元极少、只需要一次性渲染QPainter直接画到QImage或QPixmap上更快更省。场景建议方案原因一张静态图导出PNGQPainter直接画到QImage省掉Scene和View两层开销窗口内只有一两个固定图形重写控件的paintEvent用不上碰撞检测和视图变换图元几十到几百且要拖拽、缩放、编组QGraphicsView现成的图元管理、重绘分区、事件分发图元数量极大动画频繁QGraphicsView 自定义绘制优化但要注意缓存与局部更新策略我的经验是至少要同时满足多图元和有交互两个条件上QGraphicsView才划算。判断不清的时候可以先按我要不要给每个图形一个独立状态来问自己——独立状态多、需要各自响应事件就是该上这套框架的信号。3. 最小可运行示例标准图元与自定义图元的两种画法3.1 骨架代码新建Scene挂上View先让它空转起来先跑通一个空窗口确认环境没有问题再一步步往上加图元。一个最小的可运行程序只需要创建Scene和View、建立关联、设置场景范围即可。我把这段骨架放在一个独立的小工程里编译运行后会出现一个灰色窗口窗口里什么都没有但坐标系统和更新机制已经就绪。#include QApplication #include QGraphicsScene #include QGraphicsView int main(int argc, char *argv[]) { QApplication app(argc, argv); // Scene承载所有图元的场景相当于一个逻辑世界 QGraphicsScene scene; // 世界坐标范围左上(-200,-200)宽400高400。范围影响滚动条与fitInView基准 scene.setSceneRect(-200, -200, 400, 400); // View观察Scene的视口负责把世界坐标投影到屏幕 QGraphicsView view(scene); view.setRenderHint(QPainter::Antialiasing, true); view.setWindowTitle(QGraphicsView基本图形); view.resize(800, 600); view.show(); return app.exec(); }逻辑说明scene.setSceneRect设定了世界的可视边界view.resize设的是窗口大小两者互不相等时View通过滚动条显示超出窗口的部分。参数上setRenderHint开抗锯齿能明显提升直线和曲线的边缘质量建议在创建View时统一设置一次不要在每次paint里反复切换。3.2 用addRect/addEllipse/addPath等标准Item快出一版系统自带了一批现成的标准Item类它们够覆盖绝大多数基本图形的需求矩形、椭圆、线段、路径、文本、图片。最直接的方式是通过Scene的add系列方法创建后返回对应的Item指针接着就能设置位置、颜色、笔宽和各种flag。// 以下所有图元基于同一个scene对象通过add系列接口快速创建 QGraphicsRectItem *rect scene.addRect(-100, -100, 120, 80, QPen(Qt::blue, 2), QBrush(Qt::yellow)); rect-setFlag(QGraphicsItem::ItemIsMovable); // 让矩形可以被鼠标拖动 // 椭圆参数为外接矩形的局部坐标 scene.addEllipse(0, 0, 100, 60, QPen(Qt::darkGreen, 2), QBrush(QColor(200, 255, 200))); // 线段直接指定两个端点的局部坐标 scene.addLine(-100, 20, 100, 20, QPen(Qt::red, 3, Qt::DashLine)); // 路径任意曲线都通过QPainterPath描述比多条LineItem更高效 QPainterPath path; path.moveTo(0, 100); path.cubicTo(80, 40, 120, 160, 200, 100); // 三次贝塞尔曲线 scene.addPath(path, QPen(Qt::magenta, 2)); // 文本QGraphicsSimpleTextItem适合简单的单行文字 QGraphicsSimpleTextItem *text scene.addSimpleText(QGraphicsView); text-setPos(-50, 120); // 文本Item自身没有几何尺寸位置靠setPos控制 text-setBrush(QBrush(Qt::darkGray));逻辑说明QPen的第一个参数是颜色第二个是线宽第三个可选风格如Qt::DashLineQBrush决定填充色。addRect返回的Item指针可以直接调用setFlag等方法。路径Item的addPath用一条QPainterPath承载所有曲线段在绘制复杂连线时一个Item比几十个QGraphicsLineItem在重绘和命中检测上划算得多。不同标准Item的适用差异如下图形对应Item类特点矩形/圆角矩形QGraphicsRectItem通过setRect调整尺寸最常用椭圆/圆QGraphicsEllipseItem构造函数里的矩形即外接矩形线段QGraphicsLineItem结构简单但多条线建议合并成Path任意曲线/折线QGraphicsPathItem灵活碰撞检测基于Shape路径文本QGraphicsSimpleTextItem单行文本字体由场景统一渲染位图QGraphicsPixmapItem适合背景图或图标素材3.3 自定义ItemboundingRect和paint是唯二必须重写的函数标准Item只能改变样式和位置它们封装了固定的绘制逻辑遇到带选中高亮、带缩放手柄、状态随数据变化这类需求就力不从心了。这时候需要自己继承QGraphicsItem写一个子类。任何自定义Item都必须实现两个函数boundingRect告诉框架我占多大地方paint负责把自己画出来。class MyRectItem : public QGraphicsItem { public: QRectF boundingRect() const override { // 关键必须把画笔宽度的一半和抗锯齿余量也算进边界 qreal m penWidth / 2 2; return QRectF(-50 - m, -30 - m, 100 m * 2, 60 m * 2); } void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override { Q_UNUSED(option); Q_UNUSED(widget); painter-setPen(QPen(Qt::black, penWidth)); painter-setBrush(brush); painter-drawRect(-50, -30, 100, 60); } private: qreal penWidth 2.0; QBrush brush QColor(255, 220, 180); };逻辑说明boundingRect返回的是局部坐标下的矩形View靠它决定图元的可见范围、重绘区域和碰撞检测。这里故意把矩形向外扩了penWidth / 2 2因为画笔描边有一半画在矩形轮廓外侧如果不预留空间边缘线会被裁掉一截。paint里的绘制坐标要和boundingRect保持一致都基于Item自己的局部坐标系。参数上有个细节penWidth是逻辑坐标下的宽度它会随View缩放一起被放大。如果希望线宽恒定需要读取View的变换系数做换算这一点在第4章展开。自定义Item的paint函数只会在View认为需要重绘时被调用不需要手动刷新整个窗口这是它比paintEvent方案高效的核心原因。4. 交互三件套选中、拖动、缩放以及它们触发的坑4.1 flags组合让Item能选中、能拖动、能响应键盘默认情况下Item是死的鼠标点上去没有任何反应。要让图元活起来得通过setFlag打开对应的能力。ItemIsSelectable允许图元被选中ItemIsMovable允许鼠标拖动ItemIsFocusable允许它接收键盘焦点。// 让图元支持选中与拖动 rectItem-setFlag(QGraphicsItem::ItemIsSelectable); rectItem-setFlag(QGraphicsItem::ItemIsMovable); // 监听选中状态变化的推荐方式连Scene的信号 connect(scene, QGraphicsScene::selectionChanged, this, []() { QListQGraphicsItem* selected scene-selectedItems(); // 更新侧边栏属性面板或状态栏 updatePropertyPanel(selected); });拖动Item时框架会自动修改Item的pos()也就是它的Scene坐标位置。如果需要跟随位置变化同步数据可以重写itemChange它是Item级的状态回调比定时轮询靠谱得多QVariant MyRectItem::itemChange(GraphicsItemChange change, const QVariant value) { if (change ItemPositionHasChanged) { // 位置变化后向外部数据模型同步 emit positionChanged(value.toPointF()); } return QGraphicsItem::itemChange(change, value); }itemChange在Item发生位置、选中状态、可见性等变化时被调用返回值会被框架采纳为新的状态值。这是把界面和业务数据解绑的关键接口做图形编辑器时几乎必用。4.2 视图缩放fitInView、scale与恒定线宽的写法缩放是View层的操作调用QGraphicsView::scale会改变观察倍率而Item坐标保持原封不动。这带来一个很多人没意识到的结果所有基于逻辑坐标绘制的线宽和文字会被一起放大。// 方式一手动放大1.25倍。注意scale是累乘的 view.scale(1.25, 1.25); // 方式二把某个图元缩放到适合视野的大小 view.fitInView(targetItem, Qt::KeepAspectRatio); // fitInView实际上重置并设置了View的变换矩阵 // 之后想继续手动缩放必须先resetTransform再scale view.resetTransform(); view.scale(2, 2);如果不想让线宽跟着视图一起变就得在Item的paint里读出当前的缩放系数把画笔宽度除以它。稍微稳妥的做法是void MyLineItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) { painter-save(); qreal scaleFactor 1.0; if (QGraphicsView *v qobject_castQGraphicsView *(widget)) { QTransform t v-transform(); // 等比缩放时m11即X方向缩放比不等比时换成对line长度求模 scaleFactor t.m11(); } painter-setPen(QPen(Qt::black, penWidth / scaleFactor)); painter-drawLine(QPointF(0, 0), QPointF(100, 100)); painter-restore(); }文本缩放的思路类似想让字号恒定可以在paint里根据比例动态设置painter-setFont的字号。这个写法的收益是图像内容随缩放变大变小但线条和文字边缘永远保持一个舒适的观感是绘图工具里非常实用的处理。4.3 鼠标hover与事件坐标从Item局部坐标出发的交互自定义Item响应鼠标事件时事件对象里带的位置是Item局部坐标不是Scene坐标更不是View坐标。很多人在这一步踩坑拿着event-pos()直接和Scene坐标比较结果永远判断不中。void MyRectItem::hoverMoveEvent(QGraphicsSceneHoverEvent *event) { QPointF p event-pos(); // 局部坐标 QRectF inner(-50, -30, 100, 60); if (!inner.contains(p)) { // 鼠标靠近边界/角落时切换光标暗示可拖拽 setCursor(Qt::SizeFDiagCursor); } else { unsetCursor(); } QGraphicsItem::hoverMoveEvent(event); }要响应Hover事件构造函数里需要调用setAcceptHoverEvents(true)。如果要判断某个Scene坐标点是否落在自己身上正确的做法是先把点转成局部坐标bool hit contains(mapFromScene(scenePos));。这套局部坐标优先的习惯贯穿整个架构凡是Item内部的状态判断都用局部坐标凡是跨Item的都用Scene坐标数据边界清楚后面做缩放手柄才不会乱。5. QGraphicsView绘制避坑五个翻车现场与化解方式5.1 图元添加了却不显示先查boundingRect和坐标系现象paint里明明写了绘制代码Item也通过addItem或addRect加进了Scene运行后窗口里却什么都看不到。原因一般出在两个地方一是boundingRect返回了空的或负尺寸的矩形View认为图元没有可视范围直接跳过它的paint二是把Scene坐标当成了Item局部坐标来用矩形画到了距离原点很远的地方视口里自然看不见。解决让boundingRect覆盖paint实际会画到的区域并给坐标值一个合理的量级比如(-50,-30,100,60)再把Item长在哪个位置交给setPos两个职责分开不要混在同一个数值里。5.2 缩放后残影、重影增量更新的范围没跟上现象每次缩放或拖动Item后图形边缘留下一层旧图像的残影窗口看起来脏兮兮的。原因在于Item的paint内容超出了boundingRect声明的范围View做局部重绘时只刷新了boundingRect覆盖的像素画出去的部分残留了下来。解决把所有绘制内容严格限制在boundingRect内画笔描边、阴影、外发光效果都要预留余量如果不确定范围可以直接调用item-update()让View重画整个Item代价是多消耗一点性能。排查时可以临时把view-setViewportUpdateMode(QGraphicsView::FullViewportUpdate)打开如果不是性能敏感场景这样能让残影问题立刻暴露。5.3 放大后线宽膨胀、文字糊成一团pen宽度与缩放系数的换算现象视图放大两倍后原本2像素的细线变成了4像素的粗线文字边缘也模糊开裂。原因Item的paint默认在逻辑坐标系里执行View的变换矩阵会把线宽和字号一起乘进去。解决在paint里保存painter-save()读出当前View的缩放系数把QPen宽度除以该系数画完再restore()。缩放系数按view-transform().m11()取如果不是等比缩放用t.map(QLineF(0,0,1,0)).length()算实际比例。这一条算是我带过的项目里翻车率最高的调整多数人只会调大线宽不知道真正要除的是视图比例。5.4 鼠标点选不准View坐标与Scene坐标混用现象点选图元时鼠标越是靠近边缘偏差越明显View一滚动偏差就更离谱。原因QGraphicsView的事件回调里拿到的event-pos()是View坐标直接拿它构造一个QPointF去调用scene.items()当然不命中。解决统一走坐标换算三步走QPointF scenePos view.mapToScene(event-pos());再用scene.itemAt(scenePos, QTransform());取图元。如果是自定义Item内部判断先把Scene坐标转到局部坐标bool hit contains(mapFromScene(scenePos));。这条规则几乎可以无脑套用比肉眼估算偏移量可靠得多。5.5 图元一多就卡缓存不是越多越好现象图元数量到几百个拖动或缩放时帧率明显下降CPU占用飙高。原因每个Item在每帧重绘时都执行完整的paint尤其是带渐变、阴影、复杂路径的图元绘制开销非常大。解决给低频变化的Item开缓存item-setCacheMode(QGraphicsItem::DeviceCoordinateCache);让它把渲染结果缓存成位图缩放和拖动时直接贴位图。但要注意缓存是按设备坐标存储的Item内容一旦发生变化就得重建缓存高频动画Item开缓存反而会拖慢速度数量特别多的小图元也不要开缓存内存的代价比绘制开销更贵。按场景选择的经验是大图元、低频变化开缓存小图元、高频变化不开缓存。6. 进阶一脚把矩形做成带缩放手柄的可调控件6.1 最小实现四个手柄的命中检测与尺寸更新前面画的都是静态图形这一节把它们升级成可交互控件矩形四角各一个小方块手柄拖动手柄改变矩形尺寸。这个能力几乎每个图形编辑器都需要也是检验你对Item坐标、命中检测理解程度的试金石。核心思路是重写shape()让鼠标落在手柄附近时就被判定为命中在mousePressEvent里记住当前拖的是哪个角mouseMoveEvent里按偏移量更新矩形。// 一个带4个角手柄的可调整矩形Item的核心逻辑 QRectF itemRect QRectF(-50, -30, 100, 60); qreal handleR 6.0; // 手柄半径 QRectF handleAt(int index) { QPointF c itemRect.topLeft(); if (index 1) c itemRect.topRight(); if (index 2) c itemRect.bottomRight(); if (index 3) c itemRect.bottomLeft(); return QRectF(c.x() - handleR, c.y() - handleR, handleR * 2, handleR * 2); } QPainterPath shape() const override { QPainterPath p; p.addRect(itemRect); for (int i 0; i 4; i) p.addRect(handleAt(i)); return p; }交互侧的逻辑是在mousePressEvent中遍历4个handleAt判断event-pos()落在哪个手柄内记录该角索引和初始itemRect在mouseMoveEvent里用event-pos() - pressPos计算增量按拖动的角更新itemRect的left/right/top/bottom最后update()让View重绘。这里要特别留意边界约束尺寸不能小于2 * handleR否则矩形会翻面、手柄跑到对侧去。6.2 收尾习惯在坐标换算处留注释写这类Item时我给自己定了个习惯任何涉及局部坐标、Scene坐标、View坐标换算的代码都在旁边留一行注释说明当前这个值是从哪套坐标系来的、要转成哪套。这个习惯救过我很多次尤其是隔了几个月再回头维护一个图形编辑类项目坐标换算的地方只要留过注释恢复上下文的时间会从一下午缩到十分钟。做绘图框架真正的成本不在第一版把图形画出来而在后续反复调整交互细节时坐标系依然清清楚楚。希望这篇笔记能帮你少走几段弯路。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?