ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Qt绘图软件核心开发:架构、交互与性能优化指南

Qt绘图软件核心开发:架构、交互与性能优化指南

简介:一套基于Qt 2D绘图功能的教程与配套源码,适合希望掌握QPainter绘图及图形交互的C++/Qt初学者。包内含完整的paintExample示例工程,覆盖主窗口构建、绘图区域定制、鼠标按下/移动/释放事件处理、颜色与画笔工具选择、绘图保存与加载等关键模块;通过源码可直观理解QPainter丰富的绘图方法、QPaintEvent重绘机制、QGraphicsView/QGraphicsScene高级图形交互,以及Qt Creator中项目的组织与调试方式。通过阅读与调试,可掌握QPen/QBrush属性调节、图像格式保存等技巧,并快速搭建可交互的绘图界面。压缩包共计114个文件,包括15个cpp、18个h、7个ui、52个png以及1个pdf文档,分别对应工程逻辑、界面设计、运行截图和辅导说明,整体大小3.54MB,便于快速下载并在Qt Creator中直接打开运行。该资源由yafeilinux整理,已有704人学习浏览,内容精炼且贴近实战,适合作为Qt绘图应用开发的入门指引与项目参考。

1. Qt 编写绘图软件:从一条线到完整工具链

接到"做一个绘图软件"的需求时,很多人第一反应是找个现成的画图控件凑合,可一旦涉及自定义画笔、图层、撤销重做,Qt 编写绘图软件就变成了一个真正需要认真设计的工程方向。这篇文章要拆的,不是用 QPainter 画一张静态图的 Demo,而是从架构到源码,把画布交互、图元存储、命令回退这些核心模块逐层讲透。你会发现一个反直觉的事实:QPainter 本身是没有状态的,但配合事件系统和命令模式,它能支撑起一个不像玩具的绘图工具。适合正在做白板、流程图编辑器、简易 CAD 或者任何需要自绘控件的开发者,也适合想弄明白"如何把一条线变成一个软件"的人。

2. 动手前先定架构:用 QWidget 自绘还是 Graphics View 框架

2.1 三种常见绘图实现路线与选型对照

Qt 里做绘图软件,绕不开三条路线。第一条是 QLabel 加 QPixmap 涂鸦,鼠标移动时直接在像素图上画线,实现最简单,但图元无法独立选中和编辑,连画个矩形要擦掉重画。第二条是 QWidget 子类重写 paintEvent 和鼠标事件,用 QPainter 直接绘制,这是最灵活也最可控的方式,适合中小型画布,代码可以按业务逻辑分层。第三条是 QGraphicsView/QGraphicsScene 框架,它内置了图元 item、碰撞检测、框选和视图缩放,适合大型场景,但学习曲线明显更陡,很多概念初次接触容易绕进去。

我一般会按这个表格快速选型:

路线核心机制适用场景性能上限复杂度
QLabel + QPixmap直接改像素随手涂鸦、批注很低,图元不可管理低
QWidget 自绘paintEvent + QPainter通用绘图软件、白板、简单编辑器几千个图元以内流畅中
QGraphicsView 框架scene + item大型流程图、CAD、拖拽频繁高,适合海量图元高

如果目标是做一个通用绘图软件,比如能画线、矩形、文字,能拖动、能撤销,我会首选 QWidget 自绘。原因很直接:逻辑可控,序列化简单,不会因为框架自带的行为和业务冲突。只有当图元数量可能超过几千个,或者需要频繁做命中测试和框选时,再切换到 Graphics View。

2.2 建立工程目录:把源码、资源和文档分开

选型定了,工程结构也要跟上。很多刚入门的人会把所有类都塞进 main.cpp,画布逻辑、工具逻辑、主窗口全揉在一起,改需求时痛不欲生。常见做法是按下述结构组织源码,特别是当你想把"教程和源码"真正落到团队协作时,工程目录就是地基。

paint_app/ ├── CMakeLists.txt ├── src/ │ ├── canvas/ # 画布控件、绘制引擎封装 │ ├── items/ # 图元类(线条、矩形、椭圆、路径) │ ├── commands/ # 撤销重做命令 │ ├── tools/ # 工具状态管理 │ └── mainwindow.cpp ├── resources/ │ └── icons/ # 工具栏图标 └── docs/ # 接口说明、决策记录

每个目录的职责要清晰:canvas 里只放与绘制显示相关的类,items 里是图形数据对象,commands 放 QUndoCommand 子类。资源文件单独放,避免后续打包时资源路径丢失;docs 里记录关键选型理由,比如"为什么选 QWidget 自绘"。工程构建推荐用 CMake 而不是 qmake,Qt Creator 新建项目时选择 CMake,后续跨平台和接入 CI 都更方便。一个最简 CMakeLists 可以这样写:

cmake_minimum_required(VERSION 3.16) project(PaintApp VERSION 0.1 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(Qt6 COMPONENTS Widgets REQUIRED) add_executable(paint_app src/main.cpp src/canvas/drawing_canvas.cpp src/items/draw_item.cpp src/commands/add_item_command.cpp ) target_link_libraries(paint_app PRIVATE Qt6::Widgets)

这里把 Qt6 作为默认依赖,如果你还在用 Qt5,把 find_package 改成 Qt5 即可。源码目录和资源目录的分开,核心目的是让"加一个新图元"这个操作只改 items 目录,而不碰画布和主窗口。

2.3 绘制引擎的核心抽象:画布、图层、命令

很多自绘项目失败,是因为把所有状态都堆在 paintEvent 里:一个 QList 存点,一堆临时变量存颜色,结果一旦要撤销或保存,无从下手。正确的抽象是把"数据"和"渲染"分开。绘图软件的核心数据结构是一份文档,它保存的是图元列表,而不是已经画好的像素。

// draw_item.h 图元基类 class DrawItem { public: virtual ~DrawItem() = default; virtual void draw(QPainter& painter) const = 0; virtual QJsonObject serialize() const = 0; virtual void setPenColor(const QColor& color) { m_penColor = color; } QColor penColor() const { return m_penColor; } protected: QColor m_penColor{Qt::black}; qreal m_penWidth{1.0}; };
// canvas_document.h 文档模型 class CanvasDocument : public QObject { Q_OBJECT public: void addItem(std::shared_ptr<DrawItem> item); void removeItem(const std::shared_ptr<DrawItem>& item); int itemCount() const { return m_items.size(); } signals: void contentChanged(); private: std::vector<std::shared_ptr<DrawItem>> m_items; };

这里有几个关键设计:用 shared_ptr 管理图元生命周期,不需要手写 delete,而且分开保存和渲染很安全;DrawItem 是纯虚基类,所有具体图元都实现 draw() 和 serialize(),这样新增矩形、椭圆时,不需要改动文档结构。CanvasDocument 通过 contentChanged 信号通知画布刷新,画布只负责遍历图元并调用 draw()。

这样做的代价是多写一层接口,但收益非常大:撤销重做命令可以以"添加/删除图元"为操作粒度,保存文件时只需遍历序列化,加载文件时按类型反序列化。后续要加图层,只需要在文档里增加图层列表,每个图层持有自己的图元列表。可以说,绘图软件的功能扩展,全靠这个数据模型撑着。

3. 跑通最小绘图程序:画布、鼠标事件和一条线

3.1 最小可用代码:用 QPainter 实现自由绘制

架构再漂亮,也要先从一条线跑通。下面这份代码是一个最小可用的自由绘制画布,它继承 QWidget,用 QPixmap 作为临时缓冲区,鼠标按下、移动、抬起时把线画到缓冲区上,再通过 update() 触发重绘。你可以直接把这两段代码放到 Qt 工程里编译。

// drawing_canvas.h class DrawingCanvas : public QWidget { Q_OBJECT public: explicit DrawingCanvas(QWidget* parent = nullptr); protected: void paintEvent(QPaintEvent* event) override; void mousePressEvent(QMouseEvent* event) override; void mouseMoveEvent(QMouseEvent* event) override; void mouseReleaseEvent(QMouseEvent* event) override; void resizeEvent(QResizeEvent* event) override; private: QPixmap m_pixmap; QPoint m_lastPoint; QColor m_penColor{Qt::black}; qreal m_penWidth{2.0}; bool m_drawing{false}; };
// drawing_canvas.cpp #include "drawing_canvas.h" #include <QPainter> #include <QMouseEvent> DrawingCanvas::DrawingCanvas(QWidget* parent) : QWidget(parent) { setAttribute(Qt::WA_OpaquePaintEvent, true); setMouseTracking(true); } void DrawingCanvas::resizeEvent(QResizeEvent* event) { QPixmap newPixmap(event->size()); newPixmap.fill(Qt::white); QPainter painter(&newPixmap); painter.drawPixmap(0, 0, m_pixmap); m_pixmap = newPixmap; QWidget::resizeEvent(event); } void DrawingCanvas::mousePressEvent(QMouseEvent* event) { if (event->button() == Qt::LeftButton) { m_lastPoint = event->pos(); m_drawing = true; event->accept(); } } void DrawingCanvas::mouseMoveEvent(QMouseEvent* event) { if (m_drawing && (event->buttons() & Qt::LeftButton)) { QPainter painter(&m_pixmap); painter.setRenderHint(QPainter::Antialiasing, true); QPen pen(m_penColor, m_penWidth, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin); painter.setPen(pen); painter.drawLine(m_lastPoint, event->pos()); m_lastPoint = event->pos(); update(); } } void DrawingCanvas::mouseReleaseEvent(QMouseEvent* event) { if (event->button() == Qt::LeftButton) { m_drawing = false; event->accept(); } } void DrawingCanvas::paintEvent(QPaintEvent*) { QPainter painter(this); painter.drawPixmap(0, 0, m_pixmap); }

这段代码的逻辑很直白:按下左键时记录起始点并进入绘制状态;移动时判断是否处于绘制状态且左键仍按住,然后从上一个点画线到当前点;抬起左键时结束绘制。注意 mouseMoveEvent 里用的是event->buttons() & Qt::LeftButton,而不是m_drawing一个条件,这样能避免悬停时误画线。paintEvent 只负责把缓冲区像素整体贴到窗口,不做任何额外计算,所以重绘性能很高。

这里有几个参数要说清楚:m_penWidth我选 qreal 类型,因为 QPen::setWidthF 支持浮点线宽,比如 0.5、2.5,可以实现更细腻的笔触;QPen 构造第三个参数Qt::SolidLine是线型,改成 Qt::DashLine 就能画出虚线;后面两个参数分别是笔帽样式和连接样式,设为 RoundCap 和 RoundJoin 是为了让线条端点和拐角平滑,不会出现刺眼的直角。

3.2 鼠标事件的三个关键点:按下、移动、抬起

鼠标事件是绘图交互的核心,新手最常踩坑的是"为什么鼠标动的时候没反应"。第一个关键点是:除了重新实现 mousePressEvent,如果你要在不按下按钮时也收到移动事件,必须调用setMouseTracking(true)。第二个关键点是区分event->pos()和event->globalPos():pos() 返回的是相对于当前窗口的坐标,适合画布绘制;globalPos() 返回屏幕坐标,适合实现跨画布工具。第三个关键点:按下事件需要 accept,但大多数情况下 QWidget 对鼠标事件默认是 accept 的,真正会吞事件的是父控件或覆盖在画布上的其他 QWidget 子控件。

当画布上还有其他控件时,比如属性面板叠加在画布上,需要用事件过滤器来拦截。常见做法是在画布外层的容器里安装 eventFilter,但这会让你陷入"到底谁在处理事件"的泥潭。我的经验是:绘图画布一定要保持在窗口层级的最上层,或者独占地使用整个区域,这样三个鼠标事件方法就能简单可靠地工作。

3.3 用 QPen 控制线宽、颜色和抗锯齿的参数说明

QPen 是绘图软件里最常用的画笔对象,它决定了每根线条的视觉表现。除了线宽、颜色,还需要理解它的样式和渲染参数。

QPen 参数常用取值作用
colorQColor线条颜色,支持透明度
widthF1.0 ~ 20.0浮点线宽,避免整数缩放锯齿
styleSolidLine / DashLine / NoPen线型,NoPen 是不绘制边框
capStyleRoundCap / FlatCap / SquareCap线端形状,RoundCap 适合手绘
joinStyleRoundJoin / BevelJoin / MiterJoin折线拐角形状

代码里这样设置一支画笔:

QPen pen; pen.setColor(QColor(30, 80, 160, 200)); pen.setWidthF(2.5); pen.setStyle(Qt::DashLine); pen.setCapStyle(Qt::RoundCap); pen.setJoinStyle(Qt::RoundJoin); painter.setPen(pen);

需要特别注意的是,抗锯齿QPainter::Antialiasing必须在绘制开始前通过 setRenderHint 开启,它影响后续所有绘制操作。缺点是抗锯齿会引入轻微模糊,如果画的是要求像素级精确的缩略图,可以临时关闭。还有一个容易被忽略的细节:QPen 默认样式是 SolidLine,但如果你 setWidthF(0.0),QPainter 会绘制一条极细的 cosmetic 线,宽度始终是 1 物理像素,适合做光标十字线。这个特性在实现对齐辅助线时非常好用。

4. 把功能做全:形状绘制、属性面板、撤销与重做

4.1 用 QPainterPath 画矩形、椭圆和箭头:坐标计算

自由绘制只是起点,真正的绘图软件要支持规则形状。用 QPainterPath 可以把矩形、椭圆、多边形统一成一条可绘制的路径,并且方便存储到 DrawItem 里。常见做法是让用户拖拽时,实时构造一个新图元,并用 QPainterPath 保存它。

QRectF rect = QRectF(startPoint, currentPoint).normalized(); QPainterPath path; path.addRect(rect); path.addEllipse(rect);

这里normalized()很关键,它保证无论用户从左上拖到右下,还是右下拖到左上,得到的矩形都是正的。如果不调用 normalized,负宽度会让部分绘制逻辑出错。椭圆和矩形共享同一个 QRectF,所以拖拽的预览逻辑可以复用。

箭头是另一个高频需求,画箭头不能只是画一条线,需要在线的终点处画出箭头三角。核心是用 atan2 算出直线的角度,然后按角度偏移生成箭头的两个侧翼点。

QPointF start{10, 10}, end{200, 120}; double angle = std::atan2(end.y() - start.y(), end.x() - start.x()); double arrowLen = 12.0; double arrowAngle = M_PI / 6; QPointF arrowP1 = end - QPointF(arrowLen * std::cos(angle - arrowAngle), arrowLen * std::sin(angle - arrowAngle)); QPointF arrowP2 = end - QPointF(arrowLen * std::cos(angle + arrowAngle), arrowLen * std::sin(angle + arrowAngle)); QPainterPath arrowPath; arrowPath.moveTo(end); arrowPath.lineTo(arrowP1); arrowPath.moveTo(end); arrowPath.lineTo(arrowP2);

参数说明:arrowLen 是箭头侧翼长度,12 像素在屏幕上是比较合适的值;arrowAngle 是侧翼与主线的夹角,30 度看起比较协调,角度太大显得笨重,太小看不清楚。箭头的填充方式通常是把 arrowP1 和 arrowP2 连起来形成一个闭合三角形,再用painter.setBrush(Qt::white)填充,这样箭头中间不会被线条覆盖。

4.2 用命令模式实现撤销/重做:QUndoStack 的使用

绘图软件的撤销/重做,用 Qt 自带的 QUndoStack 比自己维护历史列表稳妥得多。QUndoStack 基于命令模式,每个操作封装成一个 QUndoCommand 子类,push 进栈时执行 redo,撤销时执行 undo。下面给一个添加图元的命令:

class AddItemCommand : public QUndoCommand { public: AddItemCommand(CanvasDocument* doc, std::shared_ptr<DrawItem> item, QUndoCommand* parent = nullptr) : QUndoCommand(parent), m_doc(doc), m_item(item) { setText(QStringLiteral("Add %1").arg(item->typeName())); } void undo() override { m_doc->removeItem(m_item); } void redo() override { m_doc->addItem(m_item); } private: CanvasDocument* m_doc; std::shared_ptr<DrawItem> m_item; };

使用方式是在画布完成一次图元创建后:

QUndoStack* undoStack = new QUndoStack(this); undoStack->push(new AddItemCommand(document, item));

这里有个关键点:push 之后不能再手动调用 document->addItem,否则会重复添加,因为 push 内部已经执行了一次 redo。很多初学者在这里吃了亏,在 push 外面又调了一遍 addItem,导致图元出现两个副本。QUndoStack 还有几个实用设置:setUndoLimit(1000)限制历史步数,防止内存随操作无限膨胀;setActive(true)放在窗口获取焦点时,确保 Ctrl+Z 生效。命令对象里保存的是 shared_ptr,而不是裸指针或索引,这样即使文档里的图元被删除,命令中持有的对象仍然存活,不会出现悬空指针。

4.3 属性面板与画布联动:信号槽传递链

有了命令系统和文档模型,属性面板就变简单了。核心链路是:画布发射"当前选中图元变化"信号,主窗口把这个变化传递给属性面板;属性面板修改颜色、线宽后,再直接修改图元对象并通知画布刷新,整个过程通过信号槽连接。

// 画布中 class DrawingCanvas : public QWidget { Q_OBJECT signals: void itemSelected(std::shared_ptr<DrawItem> item); }; // 主窗口 connect(canvas, &DrawingCanvas::itemSelected, this, &MainWindow::onItemSelected);
void MainWindow::onItemSelected(std::shared_ptr<DrawItem> item) { propertyPanel->loadItem(item); } // 属性面板 void PropertyPanel::onColorChanged(const QColor& color) { if (m_item) { m_item->setPenColor(color); emit itemModified(); } }

属性面板里不需要知道画布的任何细节,它只操作 DrawItem 的 setter,然后通过 itemModified 信号让画布 update。如果图元的属性修改涉及命令历史,比如颜色修改也要能撤销,那就不应该直接 set,而是创建 UpdatePropertyCommand。我的做法是,高频操作(画线、添加形状)走命令栈,低频操作(选中后改颜色)先用标签页的"应用"按钮提交一次命令,这样既能撤销,又不会每个颜色微调都堆积历史。

4.4 文件保存与读取:QJsonDocument 序列化图纸

图纸保存是绘图软件的基本功能。用 QJsonDocument 比自定义二进制格式更稳健,也方便调试。每个 DrawItem 子类实现 serialize() 和静态的 deserialize(),文档负责汇总。

QJsonObject LineItem::serialize() const { QJsonObject obj; obj["type"] = "line"; obj["start"] = QJsonArray{ m_start.x(), m_start.y() }; obj["end"] = QJsonArray{ m_end.x(), m_end.y() }; obj["color"] = m_color.name(QColor::HexArgb); obj["width"] = m_width; return obj; }
std::shared_ptr<DrawItem> DrawItem::fromJson(const QJsonObject& obj) { const QString type = obj["type"].toString(); if (type == "line") { auto item = std::make_shared<LineItem>(); auto start = obj["start"].toArray(); item->setStart(QPointF(start[0].toDouble(), start[1].toDouble())); item->setPenColor(QColor(obj["color"].toString())); item->setPenWidth(obj["width"].toDouble()); return item; } // 其他类型类似 return nullptr; }

序列化时一定要保存版本号字段,比如obj["version"] = 1;,以后格式升级时可以兼容旧文件。颜色字段我建议用QColor::HexArgb格式,也就是#AARRGGBB,这样透明度信息不丢;如果用color.name(),透明度会被丢掉,导出的文件再打开颜色就变了。保存整个文档时,遍历所有图元的 serialize 放进一个 QJsonArray,再包一层文档元数据。这种实现的好处是,你可以在没有任何 Qt 界面参与的情况下,单元测试序列化结果,也方便以后做自动备份。

5. Qt 绘图软件避坑指南:这几个坑我踩了不止一次

5.1 鼠标事件失效:到底该重写 mousePressEvent 还是 eventFilter

现象:重写了 mousePressEvent,但点击画布完全没有反应,或者事件被无端吞掉。

原因:最常见的是画布上叠了其他透明子控件,比如一个覆盖全屏的 QRubberBand 或空标签导致事件被拦截;另一个常见原因是你在非 QWidget 类上想通过 eventFilter 处理鼠标,但过滤对象或安装位置不对。还有一部分情况是画布设置了Qt::WA_TransparentForMouseEvents属性,整个控件对鼠标透明。

解决:先确认你的画布是 QWidget 的直接子类,并且没有其他子控件覆盖。如果只是想在主窗口级别统一拦截,再考虑 eventFilter。给画布设置setFocusPolicy(Qt::StrongFocus)能避免鼠标点击后焦点跑掉,导致键盘快捷键失效。最可靠的做法是,把画布放在窗口中心并raise()到最顶层,让三个 mouseEvent 方法成为事件处理的唯一入口。

5.2 绘制闪烁与重影:开启 QPainter 双缓冲的正确姿势

现象:快速拖动鼠标画线时,窗口闪烁,或者出现红色的重影残像。

原因:Qt 的 QWidget 默认有后台缓冲,但如果你在 paintEvent 里没有完全覆盖整个画布区域,旧画面就会暴露出来;另外,很多人习惯直接在 paintEvent 里绘制所有历史轨迹,意味着每次鼠标移动都要重放几百条线段,性能不够就闪烁。

解决:把绘制结果缓存在 QPixmap 中,paintEvent 里只做一次painter.drawPixmap(0, 0, m_pixmap)。鼠标移动时只把新线段画进 QPixmap,然后update()。同时设置setAttribute(Qt::WA_OpaquePaintEvent, true),告诉 Qt"我的绘制会覆盖整个窗口",这样 Qt 就不必先填充背景,能减少闪烁。

void DrawingCanvas::paintEvent(QPaintEvent* event) { QPainter painter(this); painter.drawPixmap(0, 0, m_pixmap); }

注意:当窗口 resize 时,QPixmap 要重新创建并把旧内容拷贝过去,否则窗口拉大后新区域是黑的。这段逻辑我在 3.1 的 resizeEvent 中已经给出,它是双缓冲方案里必不可少的一部分。

5.3 高分辨率屏幕下坐标错位:devicePixelRatio 处理

现象:在 2K/4K 屏幕或 Windows 150% 缩放下,画出的线比鼠标位置偏移,导出图片尺寸不对。

原因:Qt 5.6 以后默认启用高 DPI 缩放,逻辑坐标和物理坐标不再一一对应。QPixmap 默认的 devicePixelRatio 是 1,而窗口可能是 1.5 或 2.0。如果不做处理,鼠标的 pos() 是逻辑坐标,而画到 pixmap 上却按物理像素位置,两者错位。

解决:创建缓冲区 QPixmap 时,把尺寸乘以当前窗口的 devicePixelRatio 值,并为 pixmap 设置相同的 ratio,然后绘制时继续使用逻辑坐标。

qreal ratio = devicePixelRatio(); QPixmap newPixmap(size() * ratio); newPixmap.setDevicePixelRatio(ratio); newPixmap.fill(Qt::white); QPainter painter(&newPixmap);

这样 QPainter 内部会自动把逻辑坐标转换到物理像素。如果你的导出功能要输出固定尺寸的图片,也要显式设置目标 QImage 的分辨率,不要直接凭感觉缩放。这个问题的隐蔽性在于低分屏上永远测不出来,只有换到高分屏才暴露。

5.4 源码编译不过:Qt 5 与 Qt 6 的 API 差异

现象:同一份源码,在 Qt 5.15 上编译通过,换到 Qt 6.4 就报错,常见的报错是QString::SkipEmptyParts is not a member或QMouseEvent::pos() is deprecated。

原因:Qt 6 移除了大量过时 API,字符串枚举改为作用域枚举Qt::SkipEmptyParts,鼠标事件的坐标访问也改为position()返回 QPointF。另外qAsConst改成了std::as_const。

解决:在新代码里直接用 Qt 6 写法;需要兼容两个大版本时,用宏做版本屏蔽。例如:

#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) const QPoint pos = event->position().toPoint(); #else const QPoint pos = event->pos(); #endif

我自己的习惯是,新建工程直接锁定 Qt 6,不再兼容 Qt 5 的过时写法。如果你维护的是老工程,至少要保证所有 API 都在 Qt 5 和 Qt 6 的公共子集内。编译错误是这类问题最友好的提示,真正麻烦的是那些在 Qt 5.15 标记 deprecated、在 Qt 6 才删除的接口,最好在工程里开启QT_DEPRECATED_WARNINGS,提前解决。

6. 让绘图软件更专业:性能优化和自定义图元

6.1 用 QGraphicsScene 重写:大规模图形变流畅

当图元数量上升到几千个,QWidget 自绘的逐个遍历会开始掉帧。这时候值得迁移到 QGraphicsScene/QGraphicsView。核心是把 DrawItem 改成继承 QGraphicsItem 的类,重写boundingRect()和paint(),然后把 item 加入 scene,view 负责显示。迁移时第一件事是定义准确的 boundingRect,它决定 Qt 的绘制裁剪区域,太大浪费性能,太小会出现图形画不完整的现象,还会和 item 的选中高亮框打架。

QRectF MyItem::boundingRect() const { return QRectF(-10, -10, 20, 20); // 以 item 本地坐标原点为中心 }

性能优化还要注意:不要在 item::paint() 里做复杂计算,painter 的样式设置也尽量缓存;如果 item 数量多且形状简单,可以开启 view 的setViewportUpdateMode(QGraphicsView::BoundingRectViewportUpdate),减少重绘面积。

6.2 把图纸导出成图片或 PDF:两个必须设置的参数

导出功能是最容易被验收的部分,却常常在最后时刻翻车。常见做法是用 QPainter 把文档渲染到 QImage 或 QPdfWriter。下面这一步经常被忽视:输出图片前一定要填充背景色,否则有透明通道的图元会在导出后变成黑底或透明底。

QImage image(targetSize, QImage::Format_ARGB32); image.fill(Qt::white); // 必须,否则导出透明 image.setDevicePixelRatio(ratio); // 保证缩放正确 QPainter painter(&image); document->render(painter); // 按文档模型绘制

另一个必须设置的参数是 PDF 分辨率:QPrinter或QPdfWriter的 setResolution() 不设置,默认值会导出一个画面模糊的文件。我一般设为 300 DPI,打印清晰,文件大小也可接受。导出的尺寸计算要统一基于逻辑坐标,不要在渲染过程中混用物理像素和逻辑像素。

这么多年下来,我最大的感悟是绘图软件的复杂度不在某个单点,而在数据模型、交互事件、渲染三者的解耦。早期我把所有逻辑堆在 paintEvent 里,每加一个功能都要把整个绘制流程翻一遍。后来坚持用文档、图元、命令三层结构,加新工具只需要写一个新 DrawItem 子类加一个新命令,画布几乎不动。希望你也能从最小可运行版本开始,一步一步把架构搭稳,踩过的坑都变成代码里的防御逻辑。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进