☰
Qt QPainter Basic Drawing 示例深度拆解:从绘制调用到工程实践
2026/10/13 1:37:34 网站建设 项目流程

Qt 官方的 examples 目录里,painting 分类下的 Basic Drawing 是个看着不起眼、信息量却极大的 demo。我第一次认真看它,还是刚开始学 QPainter 的时候,当时只觉得它不过是几个下拉框在不停切换图形。后来真正在项目里写自绘控件、做图表、做截图标注工具,被 QPainter 的各种细节反复折腾之后,才意识到这个 demo 几乎把 QPainter 的底牌全部亮了出来:直线、折线、多边形、贝塞尔曲线、弧、弦、扇形、路径、文本、位图,再加上渐变、坐标变换、混合模式、抗锯齿。

这篇文章我打算把 Basic Drawing 从界面结构到绘制逻辑完整拆一遍,不是简单地翻译官方注释,而是把每个模块“为什么这么写”“换到实际项目中会遇到什么”讲清楚。如果你正在学 Qt 绘图,或者手头有自绘控件的需求,这篇内容可以帮你省下很多试错时间。

1. 这个 demo 的整体思路与架构边界

1.1 一个界面看懂 QPainter 的全部绘制能力

Basic Drawing 的界面分成两块:左边是一个自定义的绘制区域,右边是一个控制面板。控制面板里排列了一堆控件——形状下拉框、画笔颜色、画笔粗细、画刷颜色、画刷填充图案、渐变类型、是否启用坐标变换、旋转角度、缩放系数、混合模式、抗锯齿开关。每切换一个选项,左边的绘制区域立刻重绘,效果实时反映出来。

这个设计本质上就是一个“QPainter 功能的可视化调试台”。你想验证哪个函数的行为,选中它,再配合不同的画笔、画刷、变换参数去看效果。大多数官方的示例程序都喜欢把参数故意做得很花哨,但 Basic Drawing 不一样,它所有控件都贴在 QPainter 的真实 API 上,几乎没有多余的包装。

以形状下拉框为例,里面列出了 13 个选项:Line、Points、Polyline、Polygon、Rect、RoundedRect、Ellipse、Arc、Chord、Pie、Path、Text、Pixmap。这些恰好对应 QPainter 最常用的 13 类绘制调用。一个下拉框走一遍,你就把 QPainter 能画什么全都摸清了。

1.2 RenderArea 与 Window:绘制和控制的职责切割

这个 demo 的核心是两个类:

  • RenderArea,继承自 QWidget,负责一切和绘制相关的事情。
  • Window,负责构建整个右侧控制面板,把所有用户操作通过 Qt 的信号槽机制连到 RenderArea 上。

这种拆分是典型的“视图与逻辑分离”。RenderArea 只关心自己怎么被画出来,Window 只关心用户操作怎么传递过去。两者通过公共槽函数通信,没有互相持有对方的界面细节。

RenderArea 里所有 set 开头的槽函数,做的事情都很简单:把传入的参数存到成员变量,然后调用 update(),仅此而已。真正画图的逻辑全在 paintEvent 里,根据当前成员变量的状态决定画什么、怎么画。

void RenderArea::setShape(Shape shape) { this->shape = shape; update(); } void RenderArea::setPenColor(Color color) { this->penColor = color; update(); }

这里有个细节很多人没意识到:update() 并不是立刻触发重绘,它只是向事件系统提交了一个重绘请求,Qt 会在下一次事件循环里统一处理。连续多次调用 update() 会被合并,最终只重绘一次。这是 Qt 推荐的做法,因为绘制本身可能是昂贵的操作,避免频繁重绘能明显减少 CPU 开销。

1.3 这个 demo 里最容易被忽略的设计细节

Basic Drawing 里有一个做法让我印象很深:RenderArea 把自己的形状、颜色、填充图案、渐变类型全部定义成了枚举,而且这些枚举的取值直接映射到 Qt 内部类型。

比如颜色枚举:

enum Color { White = Qt::white, Black = Qt::black, Red = Qt::red, Green = Qt::green, Blue = Qt::blue, Cyan = Qt::cyan, Magenta = Qt::magenta, Yellow = Qt::yellow, Gray = Qt::gray };

填充图案枚举也是同理,直接映射 Qt::BrushStyle:

enum FillPattern { Solid = Qt::SolidPattern, Dense1 = Qt::Dense1Pattern, Dense2 = Qt::Dense2Pattern, Dense3 = Qt::Dense3Pattern, Dense4 = Qt::Dense4Pattern, Dense5 = Qt::Dense5Pattern, Dense6 = Qt::Dense6Pattern, Dense7 = Qt::Dense7Pattern, Horizontal = Qt::HorPattern, Vertical = Qt::VerPattern, Cross = Qt::CrossPattern, BDiag = Qt::BDiagPattern, FDiag = Qt::FDiagPattern, DiagCross = Qt::DiagCrossPattern };

这样做的直接好处是:下拉框的下标天然对应枚举值,组合框当前下标能直接转换成枚举,省掉了一堆 if-else 或者 switch 映射代码。写控制面板的时候,只需要把枚举字符串列表填进 QComboBox,再把当前下标索引强转成枚举值传给 RenderArea 即可。

这种写法在小型工具类项目里非常实用。枚举成员按顺序排列,界面列表顺序保持一致,两边永远同步,不会出现列表项加了一个却忘了加映射代码的情况。

2. 核心绘制逻辑逐行拆解:QPainter 的常规与不常规用法

2.1 paintEvent 里 QPainter 的标准书写方式

整个 demo 的核心就在 RenderArea::paintEvent 里。我把核心脉络整理成了下面这段,去掉控制面板联动以后更清晰:

void RenderArea::paintEvent(QPaintEvent *event) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, antialiased); // 画一个浅灰色背景 painter.fillRect(event->rect(), QColor(240, 240, 240)); // 画一个灰色边框 painter.setPen(QPen(Qt::gray, 1)); painter.drawRect(QRect(0, 0, width() - 1, height() - 1)); // 按当前状态设置画刷 if (gradient != NoGradient) { painter.setBrush(gradientBrush()); } else { painter.setBrush(QBrush(fillColor, static_cast<Qt::BrushStyle>(fillPattern))); } // 按当前状态设置画笔 painter.setPen(QPen(penColor, penWidth, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); // 坐标变换:平移到中心,旋转,缩放 if (transformed) { painter.translate(width() / 2.0, height() / 2.0); painter.rotate(rotation); painter.scale(scaleFactor, scaleFactor); } // 混合模式 painter.setCompositionMode(compositionMode); // 按当前选中的形状分支绘制 drawShape(painter); }

写 QPainter 代码的第一个习惯:把 QPainter 对象直接创建在栈上,传入需要绘制的设备指针,函数结束自动析构。析构时 QPainter 会自动调用 end(),把状态归还给系统。不要到处手动 new QPainter,也不要忘记 delete,栈上对象是 C++ 里最省心的资源管理方式。

第二个习惯:paintEvent 里拿到的 QPaintEvent 参数带有实际的脏矩形区域,用 event->rect() 作为背景填充范围比直接 fillRect(rect()) 更精准。实际项目中如果只需要刷新局部区域,配合 update(QRect) 就能做到局部绘制,性能会好很多。

2.2 从 Line 到 Pixmap:十三种 draw 调用的行为差异

shape 分支的绘制逻辑是这个 demo 最值得对照学习的地方。下面这组行为说明覆盖了全部 13 个分支:

分支核心调用行为要点
Linepainter.drawLine(p1, p2)两点确定一条直线,只看 QPen,不受画刷影响
Pointspainter.drawPoints(points)逐点描点,不连线,点的形状由 QPen 的 cap 风格决定
Polylinepainter.drawPolyline(points)把点依次连起来,但首尾不闭合
Polygonpainter.drawPolygon(points)自动闭合首尾,并且会按当前画刷填充内部
Rectpainter.drawRect(rect)先用画刷填充矩形内部,再沿边界画线
RoundedRectpainter.drawRoundedRect(rect, 25, 25)矩形带圆角,两个半径参数控制圆角大小
Ellipsepainter.drawEllipse(rect)在给定的矩形范围内内切椭圆
Arcpainter.drawArc(rect, 30 * 16, 120 * 16)只画椭圆弧,角度单位是 1/16 度,0 度在三点钟方向
Chordpainter.drawChord(rect, start, span)弧线加一条弦,形成闭合区域,可被画刷填充
Piepainter.drawPie(rect, start, span)从圆心连两条半径到弧两端,形成扇形,饼图就靠它
Pathpainter.drawPath(path)使用 QPainterPath 自由绘制任意曲线组合
Textpainter.drawText(rect, Qt::AlignCenter, str)在指定矩形内绘制文本
Pixmappainter.drawPixmap(rect, pixmap)把图片绘制到目标矩形,默认会拉伸填满

Arc 的角度逻辑是刚开始最容易踩坑的点。Qt 的 drawArc 不接受直接的“角度”数值,它接受的是 1/16 度单位。如果你要画 90 度弧,传入的应该是 90 * 16 = 1440。并且 0 度位于时钟的三点钟方向,角度正向是逆时针方向。配合窗口坐标系 y 轴向下这个前提,实际看到的效果可能会和你高中数学课上画的角度相反。

case Arc: painter.drawArc(rect(), 30 * 16, 120 * 16); break;

这个调用画的是:从 30 度位置开始,逆时针扫过 120 度。如果你想让弧线从 90 度(正下方)开始,传 90 * 16 即可。

2.3 画笔与画刷:线条和填充的配合逻辑

QPainter 里有两个对象决定图形的最终外观:QPen 控制轮廓线,QBrush 控制内部填充。很多人初学时会混淆一个概念:drawRect 是不只是“画一个矩形框”,它会先用画刷填充满整个矩形,再用画笔沿着矩形边界描线。也就是说,一次 drawRect 调用,同时涉及画刷和画笔。

这个 demo 在绘制前同时设置了画笔和画刷:

painter.setBrush(QBrush(fillColor, static_cast<Qt::BrushStyle>(fillPattern))); painter.setPen(QPen(penColor, penWidth, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));

QPen 这里除了颜色和宽度,还额外指定了线帽和连接风格。RoundCap 让线段端点呈圆形,RoundJoin 让折线在拐角处也有圆滑过渡。实际项目中如果画的是折线图、曲线图,这两个参数能让线条看起来舒服很多。如果画的是像素对齐的网格线,反而要换回 FlatCap 和 BevelJoin,避免出现模糊的半透明边缘。

填充图案这块,Qt 提供了非常多的预置笔刷样式:实心、不同密度的点状、横线、竖线、交叉线、斜线等等。Basic Drawing 的下拉框把这些全部暴露了出来,配合不同颜色,你可以直观看到每种图案的实际效果。实际写绘制代码时,我很少直接用这些图案,但它们在调试阶段很管用——比如用斜线填充来对比两个重叠区域的边界。

2.4 QPainterPath 与文本绘制

Path 分支使用的是 QPainterPath,这是 Qt 绘制任意曲线的基础设施。官方示例构造了一条由直线和贝塞尔曲线组成的闭合路径:

QPainterPath path; path.moveTo(20, 80); path.lineTo(20, 30); path.cubicTo(80, 0, 50, 50, 80, 75); path.closeSubpath();

moveTo 相当于把画笔悬空移动到起点,lineTo 画一条直线到下一个点,cubicTo 利用两个控制点绘制贝塞尔曲线,最后 closeSubpath 把终点连回起点,形成一个闭合区域。路径一旦闭合,就可以被画刷填充,这给了你巨大的想象空间:任意形状的气泡框、流程图节点、不规则的热力图区域,本质上都是 QPainterPath 的组合运用。

Text 分支虽然只是 drawText(rect(), Qt::AlignCenter, tr("Hello Qt")),但它背后的东西远不止这几个参数。Qt 的文本绘制支持对齐方式、换行模式、字体缩放、富文本渲染等。实际项目里如果要做表格控件里的文字居中、仪表盘上的数值标签,都要用 drawText 配合 QFontMetrics 去计算文本的实际像素尺寸,否则文字画出来不是偏了就是溢出。

3. 渐变、坐标变换与混合模式

3.1 三种渐变的定位与设置方式

Basic Drawing 的渐变下拉框里有三种类型:线性渐变、辐射渐变、锥形渐变。它们继承自同一个 QGradient 基类,最终都可以作为 QBrush 传给 QPainter。

线性渐变(QLinearGradient)由两个坐标点定义,颜色从一个点向另一个点直线过渡。你可以把两个点想象成一根颜色丝带的两个端点,中间的颜色由 setColorAt 设置的位置决定:

QLinearGradient linearGradient(0, 0, width(), height()); linearGradient.setColorAt(0.0, Qt::white); linearGradient.setColorAt(1.0, Qt::black);

辐射渐变(QRadialGradient)由一个圆心和一个半径定义,颜色从圆心向外扩散。适合模拟光源效果、球面高光、雷达扫描的氛围感。锥形渐变(QConicalGradient)则是绕圆心旋转一圈的色带,起点由角度控制,适合仪表盘的彩色弧段。

我遇到很多人在项目里用渐变时,会忽略一个问题:渐变的方向和范围是基于当前坐标系计算的。如果设置了坐标变换,渐变坐标也跟着旋转和缩放,最后呈现的渐变方向和直观感受可能完全不同。这在 Basic Drawing 里展示得很明显,勾选 Transformed 以后,所有渐变都会随图形一起变形。

3.2 坐标变换的顺序是很多人绕不过去的坎

Basic Drawing 里的坐标变换是三个调用的组合:

painter.translate(width() / 2.0, height() / 2.0); painter.rotate(rotation); painter.scale(scaleFactor, scaleFactor);

理解这段代码的关键在于搞清楚 transform 调用的叠加顺序。很多人以为 translate 之后 rotate,图形的效果是“先移动再旋转”,但 QPainter 的变换是作用于坐标系的,你在纸上画图时,每一笔都是在一个已经被改变过的坐标系里落下的。

更直观的理解方式是:想象一张透明坐标纸。translate 是把这个坐标纸的原点从左上角挪到控件的中心点;rotate 是让这张坐标纸绕新原点旋转;scale 是再把这张纸放大缩小。所以最终图形看起来是“绕中心旋转并且缩放”,而不是“绕左上角变换”。

实际项目中最常用的技巧就是“相对固定点变换”:先 translate 到某个目标点,接着旋转、缩放,然后以原点为参考开始绘制。所有图形都围绕这个锚点转动,省去手工计算每个点旋转后坐标的麻烦。比如仪表盘的指针,就是先平移到表盘中心,再根据数值旋转,最后画一条从原点出发的直线。

这段代码还有一个附加效果:由于绘制区域本身是控件的中心,而 shape 绘制时用的矩形坐标是控件整体范围,所以切换 Transformed 开关后,图形会整体缩放到中心区域,边界不容易被剪切掉。这也是官方在演示变换之前故意把所有图形画在控件完整范围内的原因。

3.3 混合模式:不是简单的图层透明度

混合模式(CompositionMode)是 Basic Drawing 里看起来最酷、理解起来最微妙的功能。它控制的是新绘制的像素和已经存在的像素如何进行数学运算,而不是简单叠加一层半透明颜色。

官方示例在绘制 shape 之前,先给整个区域填充了一个半透明的背景色,然后把混合模式切换到选中的模式,再绘制图形,这样混合效果才能被肉眼感知。如果你直接在白纸上画不透明图形,绝大多数混合模式看着都和普通绘制没什么区别。

实际项目中我用到最多的是 QPainter::Composition_Xor。它能让重叠区域的像素颜色变成互补关系,非常适合做选区、橡皮擦类的交互工具。比如截图标注里你要擦掉某一段笔迹,可以把画笔模式临时切到 Xor,然后在旧笔迹上画一遍,旧笔迹区域就被清除,同时不影响下方图片内容。

混合模式是个全局状态,一旦设置,QPainter 后续所有绘制都会受影响。用完以后记得恢复成 QPainter::Composition_SourceOver,否则你会在其他绘制里看到各种诡异效果。改状态这种事,最好用 RAII 或者在函数结束时统一恢复。

4. 实操:把 Basic Drawing 改造成自己的绘图调试器

4.1 工程搭建与文件结构

如果你想亲手把整个工程跑起来,新建一个 Qt Widgets Application,加入三个文件:main.cpp、window.h/.cpp、renderarea.h/.cpp。RenderArea 负责绘制,Window 负责控制面板,main 里把它们组合起来显示。

官方示例的工程结构略重,我自己复刻时习惯精简掉不必要的资源文件和复杂界面,保留每类控件的核心连接方式。这样后续改造成自己的工具时更容易下手。

4.2 设计 RenderArea 的对外接口

RenderArea 的关键是让所有可调参数都暴露成公共槽函数,而不是让外部直接操作成员变量。这样可以保证参数变化时必定触发重绘,也不会出现外部改了变量但界面不刷新的问题。

class RenderArea : public QWidget { Q_OBJECT public: enum Shape { Line, Points, Polyline, Polygon, Rect, RoundedRect, Ellipse, Arc, Chord, Pie, Path, Text, Pixmap }; enum Color { White = Qt::white, Black = Qt::black, Red = Qt::red, Green = Qt::green, Blue = Qt::blue, Cyan = Qt::cyan, Magenta = Qt::magenta, Yellow = Qt::yellow, Gray = Qt::gray }; enum Gradient { NoGradient, LinearGradient, RadialGradient, ConicalGradient }; explicit RenderArea(QWidget *parent = nullptr); QSize sizeHint() const override; public slots: void setShape(Shape shape); void setPenColor(Color color); void setPenWidth(int width); void setFillColor(Color color); void setFillPattern(Qt::BrushStyle pattern); void setGradient(Gradient gradient); void setTransformed(bool enabled); void setRotation(int degrees); protected: void paintEvent(QPaintEvent *event) override; private: QBrush gradientBrush() const; void drawShape(QPainter &painter); Shape shape = Rect; Color penColor = Blue; int penWidth = 2; Color fillColor = Red; Qt::BrushStyle fillPattern = Qt::SolidPattern; Gradient gradient = NoGradient; bool transformed = false; int rotation = 0; };

4.3 控制面板的搭建与信号连接

Window 里用 QComboBox 让用户选择形状、填充图案和渐变类型。组合框的每一项文本顺序要和 RenderArea 里的枚举顺序保持一致,然后通过 currentIndexChanged 信号直接下发:

shapeCombo = new QComboBox; shapeCombo->addItem("Line"); shapeCombo->addItem("Points"); // ... 其他项 connect(shapeCombo, &QComboBox::currentIndexChanged, this, [this](int index) { renderArea->setShape(static_cast<RenderArea::Shape>(index)); });

这里借助了 lambda 表达式做类型转换。核心思路是:界面列表的顺序 = 枚举声明的顺序,所以 currentIndex 可以直接映射为枚举值。这比维护一张字符串到枚举的映射表省事得多,代价是以后调整枚举顺序时必须同步调整列表项顺序。

旋转角度用 QSlider 和 QSpinBox 联动。滑块负责手感,数字框负责精确值,两者通过 valueChanged 信号互相同步,最终都送上旋转角度给 RenderArea。

4.4 运行效果与扩展方向

跑起来以后,你可以拖动滑块看图形旋转、缩放,切换渐变看填充颜色变化,切到混合模式看叠加效果,整个过程基本零延迟。这就是一个属于自己的 QPainter 实验台。

基于这个骨架,我实际做过几个扩展:

  • 添加网格背景绘制,对比图形在不同缩放比例下的像素对齐情况。
  • 把 shape 替换成实时采集的数据曲线,配合缩放旋转验证坐标系变换逻辑。
  • 把单个 RenderArea 换成多个联动实例,验证不同绘制参数在同一个窗口内的对比。
  • 改成 QGraphicsView + QGraphicsScene 架构,用来评估自绘方案和场景图方案的表现差异。

Basic Drawing 提供的不是“一个示例”,而是一套结构和思路。你消化它的方法,比单纯跑一遍它重要得多。

5. 我踩过的坑与问题排查记录

5.1 paintEvent 不是你想触发就触发的

很多人写完绘制代码以后发现界面不更新,第一反应是检查绘制逻辑,但问题往往出在“没人通知控件重绘”。QPainter 的绘制代码不会自动执行,它只在以下几种情况被调用:控件首次显示、窗口大小变化、窗口从被遮挡恢复到可见、以及代码里调用了 update() 或 repaint()。

修改了参数以后,必须在 setter 里调用 update()。忘了这一步,所有参数都白改。另外 update() 是异步的,它会合并短时间内多次请求,尽量用 update() 而不是 repaint(),后者会立刻触发绘制,在连续高频更新场景下容易导致界面卡顿。

5.2 变换后的“坐标失踪”问题

坐标变换开启以后,所有绘制坐标都变成了变换坐标系下的坐标。我见过不少人在 translate 之后还想用控件的原始坐标系统去计算图形位置,结果图形画到了完全意想不到的地方。

排查技巧是:在变换之后临时画几条辅助线,从原点出发沿 x、y 方向延伸,看一眼坐标轴到底指向哪里,视觉上确认变换后的坐标系位置。确认以后,所有绘制都以新的原点为参考去计算。

5.3 QPainter 使用中的状态残留

QPainter 会在同一个绘制事件里累积状态。一旦设置了画笔颜色或者混合模式,后续所有绘制的对象都会带上这个状态,直到你主动修改它。如果你在 paintEvent 里先画背景,再画图形,务必确保画背景用的画笔、画刷不会污染后面的图形绘制。

比较稳妥的做法是:画背景时单独用一个临时的 QPen/QBrush,或者画完背景后重新调用 setPen 和 setBrush 覆盖成你想要的样式。不要指望 QPainter 每次绘图前自动重置状态,它不会。

5.4 抗锯齿与性能的取舍

Basic Drawing 里的抗锯齿开关非常直观。开启后,图形的边缘会变平滑,代价是绘制性能下降。如果你的绘图区域很大、图形很多、重绘频率又高,抗锯齿会带来肉眼可感知的卡顿。

优化思路是分层处理:不经常变化的底图关闭抗锯齿,需要精细边缘的前景图形开启抗锯齿;或者只在静态绘制的一次性画布上开启抗锯齿,动态交互时关掉。高 DPI 屏幕下,配合设备像素比设置好坐标缩放,抗锯齿的效果差异会更明显。

5.5 常见问题速查表

现象可能原因解决方法
改了参数界面没反应setter 里没有调用 update()在槽函数末尾调用 update()
图形画到控件外面坐标变换平移到了不合理位置先 translate 到中心,再绘制相对坐标
图形边缘特别粗糙未开启抗锯齿设置 QPainter::Antialiasing 渲染提示
混合模式没效果没有先画背景,或模式选错先填充半透明背景,再绘制叠加图形
旋转中心不对没有先 translate 到目标锚点旋转前先平移坐标系原点
渐变方向和预期相反渐变坐标随变换一起旋转确认 setGradient 的坐标体系
文本位置永远对不齐没有算文本实际尺寸用 QFontMetrics 计算宽度和高度
绘制性能突然下降大面积抗锯齿加频繁重绘分层绘制,或降低重绘频率

最后分享一点个人体会。官方示例平时看着不起眼,但它是 Qt 团队亲手维护的代码,每一行都遵循了 Qt 的推荐用法。我每次在项目里遇到 QPainter 相关的疑难问题,第一反应不是去搜索引擎,而是翻一遍官方示例里有没有对应的场景。Basic Drawing 就是这样一个值得反复精读的起点,你花两个小时把它的每个参数都调一遍,收获比看两小时的教程视频实在得多。

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

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

立即咨询