简介:面向Qt图形开发初学者,一套基于VS2017+Qt5.14.2的QGraphicsView架构示例,全面演示了矩形、正方形、圆形、三角形、多线段、曲线等基本图形的绘制与交互操作。压缩包共137个文件,以35个cpp源码和19个h头文件为主体,包含图形工厂、自定义场景与视图等核心类;另含编译产生的obj、pdb、exe等工程文件,以及docx文档、ui界面文件,整体约375MB,便于对照源码与工程配置理解实现思路。代码内置完整的图形项封装与鼠标事件处理逻辑:按下开始绘制、再次按下停止写入,图形支持拖拽、删除、撤销,曲线绘制也能实时反馈路径。结构清晰,适合学习QGraphicsView/Scene的事件分发、图形项设计及撤销栈思想,已有3997人下载学习,是Qt图形编辑入门到进阶的不错参考。
1. 为什么用 QGraphicsView 而不是自己画:这套源码能直接抄的三件事
做 Qt 图形绘制,第一反应往往是重写 paintEvent,在 QWidget 上手动维护图元列表,但图一多、交互一复杂就会失控。QGraphicsView 是 Qt 官方推荐的一套“场景—视图—图元”架构,它帮你把命中检测、坐标变换、图元管理这些脏活全部包掉。这套源码基于 VS2017 + Qt5.14.2 开发,实现矩形、正方形、圆形、三角形、多线段、曲线的鼠标实时绘制,并支持拖拽、删除、撤销。适合两类人:一是被 paintEvent 重绘逻辑折磨过的 Qt 开发者,二是课程设计里需要交付一个“能跑、能交互、代码结构能讲清楚”图形系统的同学。拿到手之后,你会发现真正复杂的不是画一个矩形,而是让“鼠标按下—移动—再按下”变成一次完整的绘制闭环。
2. 场景、视图与图元:拆开这套源码的三层骨架
2.1 源码包里的文件分工:moc 与非 moc 文件先分清
这套源码包的命名很规整,先看文件再谈原理。里面有一批moc_前缀的文件,比如moc_QCustomGraphicsView.cpp、moc_QCustomQGraphicsScene.cpp、moc_QShapeManyLineSegments.cpp,这些是 Qt 的元对象编译器自动生成的,不是手写代码,在 VS 工程里它们归在“Generated Files”目录下。你编译的时候不要动它们,更不要手改——moc 文件一旦和头文件里的 Q_OBJECT 宏不同步,编译会报一堆莫明其妙的链接错误。
真正手写的主力类是这几个:QCustomGraphicsView是自定义视图,负责鼠标事件和视图缩放;QCustomQGraphicsScene是自定义场景,负责承载图元和坐标基准;QShapeDottedBox、QShapeManyLineSegments是两种自定义图元,前者多半是虚线框或选中框,后者是多线段路径;QShapeFactory是图元工厂,用模式来生产矩形、圆、三角形等对象;DrawingMainWindow是主窗口,把工具栏、撤销栈、标尺控件串起来。最容易被忽略的是CustomRulerWidget.cpp,这个标尺组件不是视图自带的功能,但它对“图形落在哪个坐标”帮助极大,后面我会专门讲它。
2.2 QGraphicsScene 记录坐标,QGraphicsView 负责呈现
这套架构的核心关系是:QGraphicsScene 不负责显示,它只维护一组 QGraphicsItem 以及它们的世界坐标;QGraphicsView 负责把场景里的内容渲染到屏幕上,并处理滚动条、缩放、鼠标事件到场景坐标的换算。你在QCustomGraphicsView里拿到的鼠标位置,默认是视图坐标,必须通过mapToScene()转换成场景坐标,否则画出来的图形位置和鼠标点击位置对不上。
初始化时的常见做法是先给场景指定一个固定区域,再让视图读取这个区域。代码里通常长这样:
// 创建场景和视图,设定一个 2000x1500 的工作区 m_scene = new QCustomQGraphicsScene(this); m_scene->setSceneRect(0, 0, 2000, 1500); QCustomGraphicsView* view = new QCustomGraphicsView(this); view->setScene(m_scene); view->setRenderHint(QPainter::Antialiasing); // 开抗锯齿,画圆和曲线更平滑 view->setDragMode(QGraphicsView::RubberBandDrag); view->setTransformationAnchor(QGraphicsView::AnchorUnderMouse);setSceneRect这一步很多人会漏。不设置的话,场景默认矩形会随着图元增减实时扩张,滚动条会一直跳,标尺也跟着乱跑。设置固定区域之后,视图的滚动范围、标尺刻度、以及fitInView的缩放基准才有一个稳定参照。
setRenderHint(QPainter::Antialiasing)不是可选项。画矩形不抗锯齿看不太出来,画圆形和多线段曲线时没开抗锯齿,边缘会出现明显的毛刺,视觉上就像“低分辨率截图”,会直接影响课程设计或交付时的观感。
2.3 自定义图元的三件事:boundingRect、paint、shape
这套源码里自定义图元类数量不少,但 QGraphicsItem 子类真正要覆写的核心就三个方法。boundingRect()返回图元的矩形包围盒,Qt 用它判断哪些区域需要重绘;paint()画图元内容;shape()返回更精确的命中轮廓,Qt 用它做鼠标点击检测。
QRectF QShapeDottedBox::boundingRect() const { // 多给 2 像素余量,防止虚线框被裁剪掉半个像素 return QRectF(-m_width / 2 - 2, -m_height / 2 - 2, m_width + 4, m_height + 4); } void QShapeDottedBox::paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget) { Q_UNUSED(option); Q_UNUSED(widget); // 虚线框通常用于框选或标注,不填充、不参与抗锯齿也成立 QPen pen; pen.setStyle(Qt::DashLine); pen.setColor(QColor(0, 120, 215)); pen.setWidthF(1.5); painter->setPen(pen); painter->drawRect(boundingRect().adjusted(2, 2, -2, -2)); }很多初学者只覆写paint(),漏掉boundingRect()或者把包围盒算得太紧,结果就是图元画出来之后边缘被裁剪,或者拖拽时鼠标明明还压在图形上,Qt 却判定已经移出图元区域。shape()默认返回boundingRect()的矩形区域,对于圆、三角形这类不规则图元,默认行为会导致点击圆形的四个角也能选中它。如果需要精确拾取,就得重写shape(),返回一个QPainterPath,把圆形、三角形的真实轮廓交给 Qt。
QShapeFactory在这里承担了“给不同模式生成不同图元”的职责。常见做法是维护一个枚举或字符串映射,比如createShape(int type, QPointF start, QPointF end),内部按矩形、圆、三角形分支构造对应图元,返回基类指针。这样主窗口和视图只需要依赖工厂接口,新增一种图形时不用改动已有交互代码。
3. 鼠标实时绘制:按下、移动、再按下的完整事件流
3.1 三段式事件处理:按下开始绘制,再按下结束
这套源码的核心交互是“鼠标按下后开始绘制,再次按下后停止绘制”。这个逻辑依赖三个事件的配合:鼠标按下、鼠标移动、再次按下。和 Qt 默认的“按下—拖动—释放”三件套不同,这里是“按下—移动—再按下”才收尾,所以需要自己维护一个绘制状态。
我一般会在自定义视图里维护几个成员变量:当前绘制模式、起始点、当前图元指针、是否正在绘制。核心流程如下:
void QCustomGraphicsView::mousePressEvent(QMouseEvent* event) { QPointF scenePos = mapToScene(event->pos()); // 视图坐标转场景坐标 if (event->button() == Qt::LeftButton) { if (!m_drawing) { // 第一次按下:记录起点,创建图元 m_startPoint = scenePos; m_currentItem = QShapeFactory::createShape(m_currentShapeType); m_currentItem->setStartPoint(m_startPoint); scene()->addItem(m_currentItem); m_drawing = true; } else { // 再次按下:结束本次绘制 m_currentItem->setEndPoint(scenePos); m_currentItem->update(); m_drawing = false; m_currentItem = nullptr; } } QGraphicsView::mousePressEvent(event); } void QCustomGraphicsView::mouseMoveEvent(QMouseEvent* event) { if (m_drawing && m_currentItem) { QPointF scenePos = mapToScene(event->pos()); m_currentItem->setEndPoint(scenePos); // 实时更新终点 m_currentItem->update(); // 触发重绘 } QGraphicsView::mouseMoveEvent(event); }mapToScene()是整个绘制的第一个关键点。视图有滚动、缩放时,直接用event->pos()画出来的图形会整体偏移,滚动越多偏得越离谱。把取到的位置统一转成场景坐标,图形才会钉在鼠标真正指向的位置上。
setEndPoint()之后必须调update(),否则图元不会重绘。这个update()看起来多余,实际是 QGraphicsItem 的标准刷新接口,漏掉它就会出现“图形半天不动,一拖窗口才变形”的滞后感。
3.2 绘制状态机:矩形是拖出来的,曲线是采出来的
矩形和圆形都走“起点—终点”的逻辑,鼠标第二次按下时的位置直接作为对角线终点。三角形稍微特殊一点:一般会做三个顶点的分段确认,或者用等边三角形边界框推导。多线段和曲线则不走“按下结束”的逻辑,而是移动鼠标时持续追加采样点。
多线段图元内部通常维护一个QVector<QPointF>,鼠标每次移动都往路径上追加一个点,并触发重绘:
void QShapeManyLineSegments::addPoint(const QPointF& point) { m_points.append(point); update(); // 路径变了,包围盒也可能变,必须刷新 } QRectF QShapeManyLineSegments::boundingRect() const { return QRectF(m_boundingRect); // 用上一次计算好的包围盒快速返回 } void QShapeManyLineSegments::paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget) { Q_UNUSED(option); Q_UNUSED(widget); QPainterPath path; if (m_points.size() == 1) { path.addPoint(m_points.first()); // 单点,画不出一条线,先兜底 } for (int i = 1; i < m_points.size(); ++i) { path.moveTo(m_points.at(i - 1)); path.lineTo(m_points.at(i)); } painter->setRenderHint(QPainter::Antialiasing, true); painter->drawPath(path); }曲线和多线段在源码里是分开的,关键差异在于曲线用quadTo或cubicTo做平滑插值,而不是直连每个采样点。采集点的时候可以做轻量滤波——相邻两点距离小于 1 像素就丢弃,否则一次快速拖动能攒下几百个重复点,路径越长,重绘越卡。曲线绘制用QPainterPath追加二次贝塞尔段,比手动求插值点简单得多,且重绘效率和拐角平滑度都有保证。
3.3 DrawingMainWindow:工具栏动作、撤销栈与标尺联动
主窗口DrawingMainWindow把上面那些类串成一个可操作的界面。工具栏上的“矩形”“圆形”“三角形”“多线段”“曲线”动作,本质上都是设置视图当前绘制模式。切换模式时,如果上一次绘制还没结束,要先强制结束,否则再点工具栏就会把当前半成品图元留在场景里,视觉上像画了一半被切掉。
撤销操作是这个源码包的一个亮点。Qt 提供QUndoStack标准撤销框架,但QUndoStack在 QGraphicsScene 里做撤销,通常要走“命令对象”模式:每条命令记录图元的创建或删除,然后在undo()里反向执行。对于图形创建这种动作,撤销命令的undo()就是scene()->removeItem(target),redo()就是scene()->addItem(target)。注意删除图元时不要直接delete命令对象持有的指针,要用scene()->removeItem()配合item->deleteLater()或者把所有权转移给命令对象,否则撤销栈里残留悬空指针,多点几下撤销就会闪退。
CustomRulerWidget作为独立控件挂在视图上方和左侧,它读取视图的滚动条位置和缩放比例来计算刻度。这里有一个容易翻车的细节:标尺必须监听视图滚动条的valueChanged信号,否则视图一滚动,标尺刻度还在旧位置,整个坐标参考就失真了。常见做法是把标尺和视图用同一个QScrollArea容器管理,标尺单独重绘刻度线,宽度和视图的horizontalScrollBar()->value()绑定。
4. 常见问题排查:编译失败、乱码、拾取失灵与撤销闪退
4.1 现象:VS2017 编译时中文字符串全部乱码,或直接报 C4819 警告
原因:VS2017 默认按本地代码页(GBK/936)解析源文件,而 Qt 5.14.2 的头文件和源码通常按 UTF-8 保存,中文字符串字面量在两个字符集之间切换会出现乱码,严重时编译警告被当错误处理,直接中断构建。
解决:在 VS2017 工程里加入编译选项/utf-8,同时把源码文件统一保存为 UTF-8 带 BOM 格式。具体路径是“项目属性—C/C++—命令行—其他选项”里追加。优先改项目,而不是逐个文件转码。改完之后重编一次,如果还有个别文件乱码,检查那个文件的编码格式是不是被编辑器自动改掉了。
4.2 现象:Debug 程序能跑,Release 版一创建图元就崩溃
原因:Qt 5.14.2 的 Debug 和 Release 使用的是两套库,如果工程里 Qt 库路径选的混了,Debug 程序链接了 Release 的 Qt5Cored.dll 或者反过来,运行时会出现“内存块类型冲突”导致崩溃。这类崩溃往往不是代码问题,而是库的二进制版本不匹配。
解决:确认 VS2017 的 Qt VS Tools 插件里,Debug 和 Release 分别指向对应的 Qt 编译器路径。最稳的检查方法是打开“项目属性—链接器—输入—附加依赖项”,Debug 配置要看到Qt5Cored.lib带d后缀,Release 配置要看到Qt5Core.lib不带d。这个检查要作为换机器、换环境后的第一件事,别急着怀疑自己的代码。
4.3 现象:图元删除后,点一下撤销就崩溃,且栈内指针“无法读取内存”
原因:删除图元时用了 delete 操作,但撤销命令对象里仍然保存着这个已释放的指针。删除动作本身入了栈,图元却被提前销毁,撤销时去操作一个悬空指针,Qt 直接触发访问冲突。这是 QGraphicsScene 撤销操作里最常见的血泪教训。
解决:删除图元只调用scene()->removeItem(item),不要立刻 delete。把图元指针交给撤销命令对象持有,由命令对象在析构时统一释放。如果非要在外部删除,撤销命令内部要加指针有效性检查——但 QGraphicsScene 没有直接提供“指针是否有效”的查询接口,所以最保险的还是把删除和撤销绑定成同一个命令对象。
4.4 现象:滚动条拖到标尺图元区域,图形跑到坐标区外面看不见
原因:图元的坐标或 boundingRect 超出了 sceneRect 的范围,但这个超范围部分没有被标尺控件感知。标尺显示的是视图可见区对应的场景坐标,而不是所有图元的集合范围,一旦图元落在场景矩形外,滚动条拉不过去,标尺自然也不会显示那一段。
解决:给场景设置一个足够大的sceneRect,或者在每次绘制结束后调用scene()->itemsBoundingRect()与当前 sceneRect 做并集,再把并集setSceneRect回去。做标尺联动时,不要手动计算刻度,直接取view->mapToScene(view->viewport()->rect())的可见矩形来做刻度区间。
4.5 现象:鼠标按下画矩形,画面不停闪烁,或者“画出来又消失”
原因:分两种。闪烁一般是QGraphicsView::drawBackground里做了耗时操作,或者视图开启了setViewportUpdateMode(QGraphicsView::FullViewportUpdate),每次 mouseMove 都重绘全屏。画完消失一般是因为图元创建后没有addItem到 scene,或者图元坐标设置的是视图坐标而不是场景坐标,滚动一下就不再可见。
解决:视图更新模式改成QGraphicsView::BoundingRectViewportUpdate,只更新鼠标移动影响到的包围盒区域。图元消失的问题,在addItem之后立即打印item->pos()和scenePos,确认是否把视图坐标当场景坐标用了。这一步排查对新手来说往往比改代码本身更关键。
5. 再进一步:用命中测试和离屏渲染验证你的绘制质量
图形系统做完基本功能,接下来要验证两件事:一是鼠标拾取是否精确,二是画出来的图元在缩放、滚动后是否仍然正确。第一个用QGraphicsScene::itemAt()配合重写后的shape()验证。默认shape()返回 boundingRect,矩形没问题,但圆形和三角形的四角也会被当成图元区域,点击空白处却选中了图形,这就是拾取失真。可以临时写一个调试函数,打印每次鼠标点击命中的图元类型和 pos:
QGraphicsItem* hitItem = scene()->itemAt(scenePos, QTransform()); if (hitItem) { qDebug() << "hit:" << hitItem->type() << "pos:" << hitItem->pos() << "bounding:" << hitItem->boundingRect(); }如果点击圆形边角命中了,说明shape()没有覆写,返回的仍是矩形包围盒。修正方法是返回QPainterPath的实际轮廓,例如path.addEllipse(boundingRect()),再把shape()的返回值换成这个 path。
第二个验证方式是离屏渲染。不显示窗口,直接把整个 scene 渲染到 QPixmap 上,检查图形输出是否符合预期,这样能在不弹窗的情况下自动化测试。代码思路如下:
QPixmap pixmap(800, 600); pixmap.fill(Qt::white); QPainter painter(&pixmap); painter.setRenderHint(QPainter::Antialiasing, true); scene()->render(&painter); painter.end(); pixmap.save("render_check.png", "PNG");scene()->render()会把场景的当前内容绘制到指定 QPainter 上,和视图渲染走的是同一条绘制栈。保存成 PNG 之后,可以直接检查抗锯齿是否生效、虚线框是否被裁掉、三角形端点是尖角还是圆头。这个验证方式尤其适合多线段和曲线——实时绘制时鼠标轨迹快,肉眼很难判断路径走向,离屏渲染后把图放大看细节,曲线平滑度一目了然。
从那以后,我每次写完 QGraphicsView 项目,都会把这三步强制走一遍:看文件名区分手写类和 moc 类,检查 debug/release 库后缀,再离屏渲染一张图确认绘制结果。这套源码包里的功能点正好能把这三步全部过一遍,直接下载跑通再改,比从零搭框架快得多。希望帮到你。
本文还有配套的精品资源,点击获取