☰
Qt QPainter绘图全面解析:从Basic Drawing示例到实战技巧
2026/10/13 1:11:30 网站建设 项目流程

最近在帮一位刚转 Qt 的同事梳理绘图相关的入门资料,翻来翻去还是觉得官方那份 Basic Drawing 示例最有嚼头。它表面上只是演示了几种基础的绘制方式,实际上把 QPainter 的坐标系统、状态机制、画笔画刷模型、渐变渲染这些核心概念全串了一遍。许多人在这个 demo 上跑通了就扔到一边,后面自己写自定义控件时又回来补课,原因就在于没有真正吃透它。

这篇文章我打算把这个 demo 拆开揉碎,不讲没用的理论,直接把每个绘制类的作用、触发机制、坐标换算、常见坑点都过一遍,并结合实际开发中会遇到的问题做延展。

1. Basic Drawing 到底在演示什么,为什么值得细看

这个 demo 从名字就能看出来,Qt 官方把它定位成绘图入门的第一课。启动之后左侧是几个绘制区域,右侧对应不同的绘制子类,顶部有一排按钮可以切换画笔颜色、线帽样式、线宽、画刷样式这些参数。每一个绘制区域都继承自 QWidget 并重写了 paintEvent,在事件回调里用 QPainter 完成具体的图形输出。

很多初学者容易忽略一个关键点:Basic Drawing 并不是在教你“画一个圆”或“画一条线”这种零散动作,而是在系统展示 QPainter 的完整渲染管线。你看到的是六个彼此独立的绘制窗口,实际上它们分别覆盖了基础图元、画笔属性、画刷属性、抗锯齿差异、渐变填充、坐标变换这六个大的知识点。

我在实际工作中发现,几乎所有需要自定义控件的场景都绕不开这套体系。比如做一个仪表盘、画一个波形图、设计一个自定义进度条、实现一个图表组件,底层靠的都是 QPainter 那套 API。而 Basic Drawing 恰恰把这些 API 按照最容易理解的方式组织在了一起,每个绘制窗口七八行代码就能跑通,非常适合作为起点。

从学习路径来说,我建议不要跳过这个 demo 直接去啃 QPainter 的完整文档。文档是按类组织的,信息很全但缺乏场景感。而 Basic Drawing 是按“画什么、怎么画、画出来的效果如何控制”来组织的,跟着代码走一遍,你对 paintEvent 触发时机、painter 的保存与恢复、坐标变换的叠加顺序这些核心机制会有一个远比读文档更扎实的理解。

还有一个许多人没注意到的价值:这个 demo 的代码结构本身就是一个很好的模块化范例。每个绘制效果被封装成独立的类,互不干扰,想加一个绘制效果就新增一个类并重写 paintEvent。这种组织方式在项目规模变大之后非常受用,因为它让绘制逻辑可以有效隔离,不会把十几个绘图功能塞进一个巨型 paintEvent 里。

2. 六个绘制 Widget 各在练什么,逐个吃透

demo 左侧那六个绘制窗口,对应的类名和绘制内容从简单到复杂排列。第二节我把它们逐个捋一遍,顺带说清楚每个窗口背后的知识点和它的设计意图。

2.1 最基础的“形状绘制”窗口:图元 API 的骨架

第一个窗口叫 Shape Widget,它用一个 paintEvent 画出了六种基础形状:矩形、圆角矩形、椭圆、扇形、弦和饼图。代码不长,但把 QPainter 里最常碰到的 drawRect、drawRoundedRect、drawEllipse、drawPie、drawChord、drawArc 全用上了。

这里有个值得注意的细节:扇形、弦、饼图三个 API 的参数结构是一样的,都是drawPie(rect, startAngle, spanAngle)这样的形式,区别只在于闭合方式。扇形是半径线加圆弧围成的封闭区域,弦是圆弧终点和起点连成的直线加弧围成的区域,饼图则是从圆心出发的两条半径线的夹角区域。

新手最容易懵的是角度单位不是度数而是 1/16 度。也就是说你画一个 90 度的扇形,startAngle 和 spanAngle 都得乘以 16。这是 QPainter 为了在整数运算里保留精度做的设计,写代码时忘了乘 16,画出来的就是非常怪异的一个小扇形。

另外还要留意 rect 参数传的是 QRectF,传给 drawPie 的是整个矩形区域,真正截取的是从 startAngle 开始扫过 spanAngle 的那一段弧。很多人在项目里做饼图,把 drawPie 的起止角度算错,一条数据就消失或者多占了半圈。实际排查时我一般先确认 spanAngle 是正值还是负值,正值表示逆时针扫过,负值表示顺时针。

2.2 画笔属性窗口:QPen 的每一项都影响什么

第二个窗口是 Pen Widget,核心演示 QPen 的属性和这些属性如何影响绘制结果。窗口顶部可以调整线宽、线帽样式、线连接方式、线型,同时也能切换画笔颜色。

QPen 的几个属性里,最容易混淆的是线帽(CapStyle)和线连接(JoinStyle)。线帽影响的是每条独立线条两端的收口形状,有平头、圆头、方头三种。线连接影响的是折线相交处的连接样式,有斜接、圆角、斜角三种。前者管线段末尾,后者管折线拐弯处,概念完全不同。

实际绘制中用得最频繁的组合是:折线图用圆帽或方帽,配合圆角连接,避免数据线拐弯处出现刺眼的尖刺;标注框的一圈边框用 FlatCap 加 MiterJoin,让边角锐利;波形图这种需要曲线柔顺的场景,PaintCap 和 RoundJoin 是默认选择。

这个窗口还演示了画笔颜色写死的写法。不过在实际项目中,画笔颜色通常不写死,而是从控件的调色板、样式表或属性系统取。在 demo 里用固定颜色是为了让参数调节的反馈足够直观:你选一个颜色,右侧立刻能看到画笔描边的变化。

2.3 画刷属性窗口:填充背后的渐变与纹理

第三个窗口是 Brush Widget,它演示 QPainter 里画刷(QBrush)的用法。画刷负责填充封闭图形的内部区域,它的样式决定图形填充出来是实心、斜线网格、点阵、无填充还是渐变填充。

通过按钮切换,可以直观看到 NoBrush、SolidPattern、DensePattern、HorPattern、VerPattern、CrossPattern 这些填充样式的差异。它们在图形界面开发里也有实际用途:比如在图表里区分不同组的数据,可以用不同密度的斜线填充而不用改变颜色;在编辑器里做选中区域标识,常用浅色画刷配合透明边框。

画刷样式里还有一个很多人不知道的细节:绝大多数基础 Pattern(比如斜线、交叉线、点阵)在绘制时是有固定的逻辑坐标尺寸的,缩放变换一下整个填充纹理都会跟着改变。如果你想让填充纹理不随图形缩放而缩放,就得用 QBrush 的 transform 属性做反向补偿。

如果说这个窗口有什么遗憾,那就是它没有演示 QBrush 的纹理图填充(TexturePattern)。纹理图填充在实际项目里非常常见,比如给自定义控件铺一张噪点背景、给地图区域填一个地形纹理。方法其实也不复杂,构造 QBrush 时传入一张 QPixmap,pattern 设为 TexturePattern 即可。

2.4 抗锯齿对比窗口:性能与视觉的权衡

第四个窗口叫 Anti-Aliasing Widget,它把一个圆分别用开启和关闭抗锯齿的方式画出来。开启抗锯齿的那半边边缘平滑过渡,关闭的那半边能看到明显的锯齿阶梯。

原理层面,抗锯齿的本质是让像素透明度根据图形边缘的覆盖率做梯度过渡,这样边缘就不再是非黑即白的切换。QPainter 的setRenderHint(QPainter::Antialiasing)会开启 QPainter 内部渲染管线的边缘平滑处理。但代价是额外的计算开销,绘制路径越复杂,这个开销越明显。

在项目里到底开不开抗锯齿,我的经验是:静态图形、图表、图标这些视觉导向的绘制,能开就开,视觉收益远大于性能损耗。但如果是高频动画场景,比如每帧都要重绘大量复杂路径,可以先关闭抗锯齿做基准测试,用固定整数坐标避免锯齿太明显。

另外有个很容易被忽略的实践细节:抗锯齿开启后,图形的边缘会向外扩展出半透明的过渡像素,这意味着一个画在 x=100 的竖线,视觉上它的边缘会覆盖 99.5 到 100.5 这个范围。如果你的图形需要和其他元素精确定位对齐,比如绘制的边框要和背景区域边缘完全重合,那就要根据线宽和抗锯齿情况对坐标做 0.5 像素的偏移调整,否则你看到的往往是一条模糊的半透明边线。

2.5 渐变填充窗口:渐变坐标才是灵魂

第五个窗口是 Gradient Widget,它展示了线性渐变(LinearGradient)和辐射渐变(RadialGradient)在矩形、圆角矩形、椭圆上的填充效果。渐变的核心不只有渐变的颜色,还有渐变的几何坐标。

线性渐变通过两个点定义一个方向,颜色在这两点之间沿直线过渡,超出两点范围的颜色行为由 spread 属性决定。辐射渐变通过圆心和半径定义一个圆形渐变区域,颜色从圆心向四周扩散,同样受 spread 控制。

这个窗口里我看到有很多人改完颜色就收工了,压根没试过渐变坐标互换。实际操作里,渐变坐标直接决定填充的方向和形态。比如要做一个金属质感的长条按钮,线性渐变的两个点应该沿着按钮的宽度方向设置,从左上角到右下角,而不是随便给两个点预设颜色带的方向。一个看起来非常奇怪的渐变,往往不是颜色配得不好,而是坐标范围没有覆盖到目标图形。

在 Qt6 里渐变坐标默认是相对于绘制设备的逻辑坐标空间的。如果你想做覆盖整个控件的那种渐变,渐变坐标通常取rect().topLeft()、rect().bottomRight()这种相对于控件本身的点,而不是绝对坐标。这一点在处理多个相同控件时特别重要,写死了绝对坐标,控件一变位置或大小,渐变的方向和感觉就全变了。

2.6 坐标变换窗口:painter 的“世界”如何平移旋转

第六个窗口是 Transformation Widget,演示 QPainter 的 translate、rotate、scale、shear 四个变换对绘制结果的影响。这个窗口通过移动滑块,让一个图形在平移、旋转、缩放、扭曲之间连续变化。

它的核心价值在于让你理解一个概念:painter 的操作是“施加在画笔所在的坐标系”上的,而不是“对已经画好的图像做变换”。你在 translate 之后画的所有图形,坐标原点就变了;在 rotate 之后画的所有图形,坐标系就旋转了。后续绘制命令的结果受当前变换矩阵的影响,而这个变换矩阵是可以叠加的。

很多人写自定义控件时,想让图形围绕自身中心旋转,结果却是围绕画布原点旋转,就是因为对变换的叠加顺序理解不到位。常见的解决方案是:先 translate 到目标中心点,再 rotate,然后再把坐标 translate 回负的中心点(即painter.translate(center); painter.rotate(angle); painter.translate(-center);),这样后续绘制的所有内容都以该中心点为轴旋转。

另一个要点是变换是有状态的。你必须记得在变换前调用painter.save(),变换操作、绘制完成后调用painter.restore()还原,否则后续绘制会带着残留的变换继续渲染。这个窗口的代码里每一步变换都是先 save 再绘制再 restore,这个习惯建议沿用到所有实际的绘制代码中。

3. 坐标系统与 painter 状态管理的底层机制

前两节把六个 widget 的绘制内容都说完了,但这套 demo 能带给你的最大价值,藏在那些没有直接画出来、但每时每刻都在起作用的东西里——坐标系统和状态管理。这一节把它们单独拎出来讲透。

3.1 逻辑坐标、设备坐标和视口的关系

QPainter 的绘制命令里传入的坐标称为逻辑坐标。绘图设备(窗口控件、QImage、QPixmap 等)的物理像素坐标称为设备坐标。两者之间通过 painter 的变换矩阵联系起来。默认情况下两者是一比一的,但一旦你有平移、缩放、旋转或者自定义的变换,逻辑坐标和设备坐标就不再一致。

在 Basic Drawing 这样的基本示例里,逻辑坐标和设备坐标一模一样,所以很多人完全不理解为何 QPainter 要搞这么一层“逻辑坐标”的概念。我举一个实际中的例子你立刻就明白了:假设你要做一个波形控件,数据范围是电压 0 到 5 伏,时间范围是 0 到 60 秒,而控件本身只有几百像素宽高。你不可能把像素坐标直接当数据坐标来用。正确做法是建立一个从数据坐标到控件坐标的映射关系,绘制时先通过 QPainter 的scale把逻辑坐标映射到像素,或者自己写映射函数,将数据点换成控件坐标再传入绘制函数。

涉及视口时更微妙一点。painter 的视口映射是逻辑坐标转换到设备坐标的“中间层”,它负责把 painter 的逻辑坐标映射成设备的物理像素。在某些情况下,开发者会用setViewport和setWindow来重新定义坐标系:比如想让逻辑坐标的 y 轴向上为正、取值为 0 到 100,而窗口实际只有 50 像素高,那就可以设置一个视口实现这个映射。这种做法的好处是绘制代码里写的是业务数值,后续修改控件尺寸或坐标取景范围时,只改动映射参数,不动绘制路径代码。

3.2 paintEvent 的触发时机,为什么不能随意手动调用

另一个绕不开的问题是 paintEvent 什么时候触发。Basic Drawing 的每个子控件重写它,但demo不会告诉你它到底什么时候会被调用。窗口第一次显示、窗口从覆盖状态恢复、窗口大小改变、调用 update 或 repaint,都会触发系统向控件发送绘制事件,进入 paintEvent。

新手常犯的一个错误是想画新内容时直接调用widget.paintEvent()。这是完全错误的做法,因为绘制工作依赖一个有效的 QPainter,而 QPainter 必须由 paintEvent 系统准备和约束。正确的做法是修改完数据后调用widget.update(),请求系统在下一轮事件循环里安排一次重绘。这也涉及到性能优化:update 会把多个重绘请求合并成一次,避免频繁重绘导致 CPU 占用过高。

我见过一个实际项目,动画里每帧都调用 update,结果动画只有 30 帧的效果,但 CPU 占用比 60 帧的还高,原因就是把重绘请求拆分成了多次事件处理,每次都在做多余的绘制工作。理解了 paintEvent 的触发机制,你才能在动画场景中合理控制重绘频率。

3.3 坐标校验:从“画不出来”到“画出来了但不在这里”

排查“图形画不出来”时,最优先怀疑的永远不是图形本身,而是坐标。如果绘制坐标远远超出绘制区域的可见范围,你就会得到一个“什么都画不出来”的假象;如果坐标恰好落在控件边缘附近,又会出现图形被裁剪成一半的假象。

这类问题在设计自定义控件时几乎必踩。我发现很多刚从示例代码转过来的人,在 demo 里用的坐标都是写死的:比如固定画一个QRectF(10.0, 10.0, 80.0, 80.0)。一旦自己写控件,就忘了坐标应该基于控件的实际尺寸计算,于是控件一拉伸,图形不是偏移出视野就是重叠成一块。

一个稳妥的写法是:在 paintEvent 里先获取当前控件的rect()或contentsRect(),再基于这个矩形计算所有绘制元素的几何位置。控件大小变化时,所有坐标会跟着重建,绘制内容自然能自适应。这比任何一个“固定好数值再手动改尺寸“的做法都省心。

4. 从 demo 延伸出去:反直觉的实战陷阱

Basic Drawing 的代码本身不会踩坑,毕竟官方示例都是精心挑选的干净代码。但你在项目里照着 demo 的思路写绘制代码时,会遇到一些 demo 没有呈现、但几乎必然出现的坑。这一节把我实测踩过的几类典型问题展开讲。

4.1 缩放后的描边宽度问题:画了 2px 却显示成 10px

最常见的坑是painter.scale()之后的线条宽度。很多人认为设置了 QPen 的线宽为 2,就一定能画出一条 2 像素宽的线。但一旦 painter 被设置了 5 倍缩放,这条线的实际像素宽度就是 10 像素。因为线宽也是受变换矩阵影响的,它和图形本体一起被缩放。

这个现象在高分辨率显示、图表缩放、地图绘制场景里尤其突出。如果你希望线宽在缩放时保持不变,解决办法是手动做反向补偿:比如你对 painter 全局缩放 scaleX,但对于想要保持固定视觉宽度的线条,需要把 pen 的 widthF 除以 scaleX。另一种方案是绘制时不用 painter 的 scale,而是自己将坐标映射到目标像素再原样绘制。

这个坑最反直觉的地方在于,你第一次遇到它时几乎不可能通过阅读 demo 代码发现,因为你看到的绘制窗口默认没有缩放。实际的排查过程是靠逐步二分法定位的:先去掉 painter 的 scale,发现线宽突然变正常,然后再检查缩放系数。

4.2 渐变坐标跟随变换导致“渐变方向神秘消失”

有一个和上一个坑同源、但表现完全不同的问题:渐变填充时,渐变坐标也受变换矩阵影响。你定义的线性渐变坐标是逻辑坐标,变换矩阵一旦作用于 painter,渐变方向也会跟着旋转和缩放。

比如你在一个被旋转 45 度的 painter 上做一个水平渐变,出来的渐变方向可能会变成 45 度斜向。很多人第一反应是“颜色写错了”,实际上只需要把渐变的两个点放在未旋转的坐标系中重新计算,或者也用反向变换补偿。

这个问题在设计旋转的进度条、旋转的图形面板时很容易踩。我实际排查过的一个案例是:一个圆环进度条,为了让进度弧从 90 度方向开始旋转,给 painter 加了 rotate 变换,结果圆环上的渐变色带完全跟着旋转,看起来像是颜色在环上滑来滑去。最终修复方式是先把渐变坐标设置在旋转后的坐标系所需的正确方位上,再配合 save/restore 隔离变换的影响。

4.3 save/restore 的洁癖原则:少一次 restore 就多一片污染

前面我反复提到 save 和 restore,这里想做个系统的总结。Basic Drawing 的变换窗口虽然展示了每次绘制前后 save/restore,但实际的开发习惯需要比示例代码更加严格。

我的原则很简单:任何一次绘制操作,只要修改了 painter 的任何状态(变换、画笔、画刷、渲染提示、透明度、裁剪区域),就必须在操作前 save,操作完成后 restore。这条原则几乎没有例外。

不遵守它的后果是:你在一个绘制函数里改了画刷和画笔颜色,其他绘制函数的 paintEvent 跟着“串色”。最典型的就是同一个控件内有多个绘制步骤,第一步画背景用的是深色画刷,第二步画边框时没改画刷,边框就直接填成了深色块。这类问题的排查往往让人抓狂,因为你盯着第二步的代码看半天也找不出毛病,直到你把状态隔离的检查提上日程。

还有一个很多人忽略的点:restore 之后 painter 的状态完全回到 save 之前,包括裁剪区域。如果你自己设置过setClipRect却忘了在绘制完成后恢复,那么后续在这个 painter 上绘制的所有内容都会被裁剪在一个你看不见的矩形里,出现“为什么图形少了一块”的灵异现象。这也是只有你深入使用 QPainter 超过一定时间之后才体会得到的教训。

4.4 渲染性能:抗锯齿不是唯一开销

最后聊一下性能。很多人以为绘制性能的大头是抗锯齿,实际在复杂场景中,路径的构建、坐标变换、裁剪计算、状态切换带来的开销同样不可忽视。

在 Qt6 里,QPainter 的绘制会尽可能利用底层渲染引擎的加速能力,但如果你每帧都重建巨大的 QPainterPath,或者频繁切换 pen/brush 导致状态缓存失效,性能就会明显退化。一个实际经验是:对于高频重绘的控件,把不变的那部分绘制结果先渲染到 QPixmap 上,在 paintEvent 里直接 drawPixmap,能显著减少绘制路径的计算量。

另外,绘制路径的复杂度直接影响性能。一个由上千个节点构成的路径,和三个节点的路径虽然代码上都只是一次 drawPath,但底层光栅化的成本相差巨大。如果做大数据量的折线图或曲线图,建议先把数据做抽稀,再丢给 painter,而不是让 painter 一次扛几万个点。

5. 把 demo 跑通之后,我建议你这样继续深挖

Basic Drawing 只是个开头,但它是通往 QPainter 更高级主题的一条最好的引路标。跑通它之后,我建议按下面的顺序继续推进自己的知识体系。

第一件事是尝试改造 demo 的某个 widget。比如把渐变窗口的线性渐变改成径向渐变,或者给抗锯齿窗口再加一个多边形。不要急着看文档,先在 demo 的代码上动手改,观察效果。这一阶段最容易培养出对 QPainter 各种 API 的直觉。

第二件事是把坐标变换窗口的滑块逻辑搬到一个自定义控件里。比如做一个可以拖动旋转角度的自定义箭头控件,体会变换顺序和 save/restore 的配合。任何在变换上糊涂的人,做一个小型旋转组件之后基本都能理清。

第三件事是开始理解样式表和 QPainter 之间的关系。Qt 的样式表在底层很多时候也是通过绘制代码实现的,自定义控件往往需要同时考虑 QPainter 绘制的效果和样式表可能带来的叠加影响。你可以试着做一个带背景色的自定义按钮,观察样式表设置背景色与 paintEvent 里画背景的相互作用,这会让你对两者之间的边界有更清楚的认识。

第四件事是学习 QGraphicsView 框架。QPainter 是 QGraphicsView 渲染的基础之一,把 QPainter 这套东西吃透之后,图元管理、视图变换这些概念会变得顺手许多。甚至可以说,QPainter 的经验能帮你省下 QGraphicsView 学习过程中最大的那段弯路。

总的来说,Basic Drawing 是那种“看着简单,越挖越深”的官方示例。你若只是把它当作普通入门 demo,读完这篇文章就已经掌握了它的全部内容;若你能把它当作一个支点,顺着它去撬动坐标系统、变换矩阵、状态管理、渲染性能这些更深的领域,那它带给你的收益会远超过一个示例程序本该有的分量。

最后再说一个个人体会,写绘制代码时无论项目多紧急,都别把 save/restore 省掉。我因为图省事跳过的每一次状态恢复,最后几乎都以更耗时的排查作为代价补了回来。这个习惯如果你从现在开始养成,将来写复杂自定义控件时会少掉大半的烦恼。

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

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

立即咨询