☰
Qt绘图三剑客:QPainter、QPen与QBrush的实战指南与踩坑总结
2026/10/17 1:18:49 网站建设 项目流程

想到写这个标题,是因为太多人第一次接触 Qt 绘图时,会被 QPainter、QBrush、QPen 这三个类搞得晕头转向。它们确实是一套组合拳:QPainter 是执行绘制的“手”,QPen 是控制线条的“笔尖”,QBrush 是决定填充的“颜料”。我最早在做一个设备状态面板时,自己画仪表盘和波形,就花了不少时间才把这三者的边界、默认行为和渲染细节摸清楚。这篇文章不打算逐条抄文档,而是从实际使用场景出发,把分工、参数、踩坑和性能问题完整串一遍,适合刚接触自定义控件的开发者,也适合已经写了几个paintEvent但经常被奇怪效果困扰的人。

1. 先搞清楚三者的分工:QPainter、QPen、QBrush 各管什么

1.1 为什么总把这三个类放在一起说

Qt 的绘图模型是典型的“画家模式”:QPainter 站在画布前,握着笔、蘸着颜料,按你的指令一笔一笔往画布上画。画布可以是 QWidget、QPixmap、QImage,甚至 QPrinter。QPainter 本身不存储颜色和线条样式,它只是状态的容器和执行者。线条长什么样,由 QPen 决定;填充区域长什么样,由 QBrush 决定。所以你会看到这样的代码:先setPen,再setBrush,然后调用drawRect、drawEllipse之类的函数。Pen 管外框,Brush 管内部,两者互不干扰,又必须同时准备。

这个设计最大的好处是职责分离。画矩形、画圆形、画路径时,你不需要为每种图形单独传一堆样式参数,只要把 Pen 和 Brush 设好,后面所有draw开头的函数都会自动按当前状态执行。缺点是初学者容易误以为setPen会影响填充,或者setBrush会影响线条,实际上完全不是这么回事。后面我会把每个属性单独拆开讲。

1.2 一个最小的绘制流程,理解整体调用关系

先看一个最简单的自定义控件:

class MyWidget : public QWidget { protected: void paintEvent(QPaintEvent *event) override { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); QPen pen(Qt::darkBlue); pen.setWidth(2); pen.setStyle(Qt::SolidLine); painter.setPen(pen); QBrush brush(Qt::yellow); painter.setBrush(brush); painter.drawRect(20, 20, 160, 120); } };

这段代码涵盖了整个基础流程:构造 QPainter 并绑定绘制设备、设置渲染选项、配置 QPen、配置 QBrush、执行绘制。注意 QPainter 是“一次性”使用的,通常应该作为局部变量放在paintEvent里。它析构时会自动调用end()结束绘制,不需要手动 delete,也不应该长期保存一个全局的 QPainter 实例。重要的一点是,QPainter 构造之后可以用isActive()检查是否成功绑定设备,尤其是对 QImage 或 QPrinter 这类设备时,失败的情况并不少见。

2. QPen 的隐藏细节:不止是“画一条线”

2.1 线宽、线型与 Pen 的默认行为

QPen 最基本的作用是定义线条。常用属性有颜色、宽度、线型、线帽、拐角连接方式、虚线样式和是否使用“装饰笔”。

QPen pen; pen.setColor(QColor(30, 144, 255)); pen.setWidth(2); pen.setStyle(Qt::DashLine); painter.setPen(pen);

很多人在这一步就满足了,但真正影响视觉效果的坑在后面。

线宽这里有个非常容易混淆的点:setWidth(0)和setWidth(1)不一样。width()为 0 时,Qt 会把它当成“装饰笔”,这个画笔在任何坐标变换下都保持刚好 1 像素宽,不会因为scale()放大而变粗。width()大于 0 时,线宽会受到 painter 的坐标变换影响,画布放大了,线也跟着变宽。需要固定 1 像素的网格线或边框时,我一般直接用setWidth(0),或者调用setCosmetic(true),这样旋转、缩放后线条保持细而清晰,不会糊成一团。

线型常见的枚举值是Qt::SolidLine、Qt::DashLine、Qt::DotLine、Qt::DashDotLine、Qt::DashDotDotLine和Qt::NoPen。NoPen 表示完全不画外框,只保留填充。比如画一个只有填充色、没有边框的圆,就可以设QPen(Qt::NoPen)。实际项目里我经常用 NoPen 做热点区域的高亮,只填色不加框。注意不要用pen.setStyle(Qt::NoPen)之后再调用drawLine,那样线条是看不见的,这不是 bug,是状态设置生效了。

2.2 线帽与拐角连接:决定折线是否“带刺”的细节

QPen 有两个容易被忽略的属性:setCapStyle和setJoinStyle。它们对线段端点、折线拐角的影响非常直观。

线帽有三种:

  • Qt::FlatCap:笔迹正好在端点处结束,没有多余延伸,适合精确坐标。
  • Qt::SquareCap:笔迹在端点处向外延伸半个线宽,实际长度会比坐标范围长一点。
  • Qt::RoundCap:端点处是半圆凸起,画平滑曲线或粗线段时比较自然。

画波形图或者折线图时,如果线宽比较粗,又用默认的Qt::FlatCap,折线端点会显得方方正正;改成Qt::RoundCap之后,视觉上会柔和很多。拐角连接setJoinStyle则决定折线段转折处的样子:Qt::BevelJoin是切平,Qt::MiterJoin是尖角延伸,Qt::RoundJoin是圆滑过渡。绘制折线图时,我一般用RoundJoin,避免尖角在密集采样点处扎眼。

2.3 自定义虚线:注意 DashPattern 的单位

有些界面不能用自带线型,比如故障区间的“短横-大空-点”组合线。这时用setDashPattern:

QPen pen(Qt::red); pen.setWidth(2); QVector<qreal> dashPattern; dashPattern << 4.0 << 3.0 << 1.0 << 3.0; pen.setDashPattern(dashPattern); painter.setPen(pen);

这个向量的含义是“实线长度、空白长度、实线长度、空白长度”如此循环。在 Qt 里,虚线模式的单位并不是绝对像素,而是“笔宽的倍数”。所以如果笔宽是 2,4.0对应 8 像素的实线段;如果笔宽变成 5,同样的dashPattern画出来会明显变长。想要像素级可控,就把笔宽保持为 0 或 1,并配合setCosmetic(true)来使用。这个坑很隐蔽,我见过有同事把笔宽从 1 调到 3,虚线整体变“胖”了好多,还以为是渲染问题。

3. QBrush 的填充体系:从纯色到渐变到纹理

3.1 QBrush 的预定义样式:别只盯着纯色

QBrush 管填充,它有颜色、样式、纹理和渐变四类信息来源。最朴素的用法是QBrush(Qt::green),这就是纯色填充。如果你不想填充,就用Qt::NoBrush。

除了纯色,Qt 还提供一组预置纹理样式,比如Qt::BDiagPattern、Qt::FDiagPattern、Qt::CrossPattern、Qt::DiagCrossPattern、Qt::Dense1Pattern到Dense7Pattern。这些样式适合画工程示意图里的剖面线、填充区域标记,或者用来区分不同数据块。由于它们是固定位图,不会跟着画刷尺寸自适应;想要更灵活的网格或斜纹,可以用多个细长矩形叠加绘制,或者使用自定义纹理。

QBrush brush(Qt::gray, Qt::BDiagPattern); painter.setBrush(brush); painter.drawRect(0, 0, 200, 100);

这是画剖面线最快的方式。需要注意,当 Painter 的坐标变换包含缩放时,预置纹理的密度也会跟着变化,因为纹理是画在逻辑坐标里的。

3.2 三种渐变:线性、径向和锥形

渐变是 QBrush 的高阶玩法。Qt 里的渐变类有三个:QLinearGradient、QRadialGradient、QConicalGradient,它们都继承自QGradient,可以被 QBrush 直接使用。

线性渐变需要起点和终点:

QLinearGradient linear(0, 0, 200, 200); linear.setColorAt(0.0, QColor("#FFFFFF")); linear.setColorAt(1.0, QColor("#1E90FF")); QBrush brush(linear); painter.setBrush(brush); painter.drawEllipse(20, 20, 200, 200);

径向渐变适合做圆形光晕,圆心为中心点,半径决定范围,还可以设置焦点偏移。锥形渐变则像表盘指针扫过的角度范围,用于雷达图或角度指示器。

一个容易犯的错是:把渐变对象构造好赋值给 QBrush 之后,又去修改渐变的坐标或颜色停靠点,想让已经赋出去的画刷“动起来”。QBrush 保存的是赋值那一刻的状态,后面改原渐变对象通常不会影响已经创建好的画刷。我建议在赋给 QBrush 之前把所有颜色停靠点、坐标都核对清楚。

渐变默认使用逻辑坐标模式,也就是说它会跟着 painter 的translate、scale一起变化。如果只想让渐变均匀地覆盖某个矩形区域,可以设置坐标模式为QGradient::ObjectBoundingMode,搭配绘制目标矩形使用,这样渐变范围会跟着目标矩形走。比如按钮悬停效果里的光泽渐变,用这个模式就非常合适。

3.3 纹理画刷与图片填充:最灵活但也最容易忽略坐标系

QBrush 还可以直接拿一张 QPixmap 当作填充内容:

QPixmap texture(":/images/pattern.png"); QBrush brush(texture); painter.setBrush(brush); painter.drawRect(0, 0, width(), height());

这样会以纹理图片平铺的方式填满整个矩形。这里有个小坑:如果你希望纹理在缩放后显得更密或更疏,不能指望 QBrush 自动缩放图片,而是要先对 QPixmap 做scaled(),再赋给画刷。另外,纹理填充会受到 painter 变换影响,这意味着旋转画布时纹理会跟着旋转。对于铺满大面积底纹的场景,我习惯把纹理图片预先准备好,避免在paintEvent里频繁缩放,否则每一帧重绘都会拖慢界面。

4. QPainter 操作细节:路径、变换与抗锯齿

4.1 用 QPainterPath 把复杂图形“封装”起来

当图形由多段直线、曲线、圆角矩形组合而成时,不要一段一段drawLine/drawRect地画,而是把它们组合进一个QPainterPath。QPainterPath 记录了一整条矢量路径,可以反复用同一个 painter 绘制,也能被QPainter::drawPath()一次性输出。

QPainterPath path; path.moveTo(50, 50); path.lineTo(150, 50); path.quadTo(200, 50, 200, 100); path.cubicTo(200, 150, 150, 150, 100, 120); path.addRoundedRect(QRectF(20, 20, 40, 40), 8, 8); painter.setPen(pen); painter.setBrush(brush); painter.drawPath(path);

路径的好处不仅仅是结构清晰,还体现在复杂区域填充上。比如画一个“环形扇区”,或者把几个图形叠加成带洞的形状,你可以用setFillRule(Qt::OddEvenFill)来让相交区域变成镂空。我在画仪表盘的刻度盘时,就把外圈底色和内部镂空区域通过两条圆弧路径合并,一次drawPath完成,比先画圆再手动遮罩高效得多。

4.2 抗锯齿渲染:开启前后是两套视觉效果

QPainter::Antialiasing是最常见的渲染开关。开启后,图形边缘会通过灰度过渡来减少锯齿,视觉上更平滑。代价是性能下降,特别是在嵌入式平台或频繁全量重绘的场景下。

painter.setRenderHint(QPainter::Antialiasing, true);

开与不开的差别在圆角矩形、椭圆、斜线这种非水平非垂直边缘上特别明显。椭圆不开抗锯齿时边缘像楼梯一样;开了之后虽然还是有一些模糊,但整体干净很多。

这里也有一个“像素级强迫症”问题:如果画的是 1 像素的竖线,坐标正好落在整数位置,开抗锯齿后线条会被分摊到两列像素上,看起来会变成灰色而不是纯黑。想要特别锐利的表格线或网格线,我通常选择关闭抗锯齿,或者把坐标偏移 0.5 到像素中心。这种细节在报表、表格类界面里非常影响观感,值得单独调一次。

4.3 坐标变换与状态栈:防止“画完后状态污染”

QPainter 的translate、scale、rotate是绘制自定义控件的核心能力。比如要画一个雷达表盘,最方便的做法是先把坐标系平移到控件中心,再绘制所有刻度,最后恢复。

painter.save(); painter.translate(rect().center()); painter.rotate(-90); for (int i = 0; i < 12; ++i) { painter.drawLine(QPointF(80, 0), QPointF(100, 0)); painter.rotate(30); } painter.restore();

这里最关键的编程习惯是save()和restore()配对。save 会把当前 Pen、Brush、字体、变换等全部状态压栈,restore 再弹出来。如果不做这件事,在循环里每次rotate都会叠加,后续绘制全被带偏。我在封装绘制函数时,经常在函数开头save(),函数末尾restore(),保证了外部状态不会被函数内部修改。这也让绘制函数像“黑盒”一样,只需要传入数据,调用方不用考虑内部变换。

4.4 常用绘图函数一览表

函数用途常见参数
drawLine画直线两点坐标或 QLineF
drawRect画矩形QRectF 或 左上角+宽高
drawRoundedRect画圆角矩形矩形区域+圆角半径
drawEllipse画椭圆或圆QRectF 作为外接矩形
drawArc画圆弧外接矩形、起始角、跨度角
drawPie画扇形外接矩形、起始角、跨度角
drawChord画弓形外接矩形、起始角、跨度角
drawPolygon画多边形QPolygonF 顶点列表
drawText绘制文字矩形区域+对齐方式+字符串
drawPixmap绘制图片目标矩形+QPixmap
drawImage绘制 QImage坐标系+QImage
drawPath绘制复杂路径QPainterPath

表格里没有列出的fillRect是一个很实用的函数,它只填充矩形区域,不画外框。需要“半透明遮罩”时,我一般用fillRect加一个带 alpha 的 QColor,比先画 QPen 再画 QBrush 要简洁。

5. 实操中反复踩过的坑与排查清单

5.1 绘制内容不出现,先按这个顺序排查

画了半天界面上一片空白,这是最让人头疼的问题。我的排查顺序是这样的:

  1. 确认绘制代码确实在paintEvent里执行。很多新手在构造函数里直接 new 一个 QPainter 绘制到this,效果必然为空。
  2. 确认 QPainter 绑定设备成功。可以加一个if (!painter.isActive()) return;看看是不是设备不可用。
  3. 确认触发过重绘。数据变了以后要调用update(),不是repaint();update()会把重绘请求合并到事件循环里,避免重复绘制。
  4. 确认绘制的坐标落在控件可视范围。比如某控件只有 100x100,画在 (0, 0, 200, 200) 之外的区域当然看不到。
  5. 检查样式表或父控件背景有没有覆盖内容。如果父控件背景不透明且层次不对,子控件的绘图会被背景盖住。

前几条是代码逻辑问题,最后一条往往是布局层级造成的。我自己遇到最多的是第 3 条,数据刷新后忘记update(),看起来像绘制失效,实际上只是没有触发重绘请求。

5.2 QPen/QBrush 设置之后不生效,多半是状态被覆盖

很多“设置无效”的案例,都是因为 QPainter 的状态被后续操作覆盖了。比如:

painter.setPen(QPen(Qt::red)); painter.setPen(QPen(Qt::black)); painter.drawLine(...);

最后画出来的当然是黑色。看起来很蠢,但写复杂绘制逻辑时很常见。更隐蔽的是save/restore不配对导致的回滚,或者两个绘制函数共用同一个 painter,一个函数内部 setPen 之后忘了恢复,把另一个函数的线条颜色改了。

另一个容易忽略的是画笔和画刷的组合关系。drawRect同时使用 Pen 和 Brush,外框用 Pen,内部用 Brush;drawLine只用 Pen,不理会 Brush。如果你设置了一堆 Brush 却调用drawLine,当然看不到填充效果,这不是 BUG,是语义如此。想确认当前画笔样式,可以用painter.pen()和painter.brush()打印出来检查,比盯着代码发呆高效得多。

5.3 性能与闪烁:把静态内容缓存成 QPixmap

自定义绘图控件最常见的性能问题是paintEvent过于耗时。每次拖动窗口、遮挡恢复都会触发重绘,如果每帧都现算几百条曲线、渐变和路径,界面就会卡顿。

我常用的做法是“分层缓存”:把不变的静态内容(比如仪表盘底色、刻度、Logo)预先绘制到一个 QPixmap 上,在paintEvent里先drawPixmap画缓存,再动态绘制变化的部分。这样重绘时省去大量重复计算。

QPixmap cache; void MyWidget::renderCache() { cache = QPixmap(size()); cache.fill(Qt::transparent); QPainter p(&cache); // 画静态刻度、背景、网格线 p.end(); } void MyWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.drawPixmap(0, 0, cache); // 再画动态数据 }

另外,如果只是局部数据变化,尽量用update(QRect(...))更新小矩形区域,而不是对整个控件调用update()。Qt 虽然会裁剪重绘区域,但大范围重绘的开销还是实打实的。在嵌入式设备上,这一步优化有时能明显拉高帧率。

5.4 抗锯齿与 1 像素线条的取舍

打开抗锯齿后,凡是整数坐标上的 1 像素线条,都可能出现“边缘变灰、变宽”的现象。原因很简单:抗锯齿会把边缘的覆盖率换算成透明度,1 像素的线条在像素边界上被两列各分了一半,视觉上变成 2 像素宽的浅色线。如果项目要求严格的表格线、边框线,我会分两种场景处理:

  • 静态界面线框:关闭抗锯齿,保证坐标对准整数,线条锐利清晰。
  • 圆形、斜线、曲线:开启抗锯齿,视觉平滑远大于边缘模糊的副作用。

甚至可以在同一个paintEvent里先关闭抗锯齿画一次底层网格,再开启抗锯齿画上层曲线。setRenderHint可以在绘制过程中切换,不会清空已有内容。

5.5 子线程绘制:可以画 QImage,不要直接画 QWidget

很多扇形统计图或者大数据的波形图,计算量很大,放在主线程的paintEvent里会把界面卡死。一个合理的做法是在子线程里生成一张 QImage,再通过信号把结果传回主线程显示。QPainter 可以在子线程中操作 QImage,因为 QImage 不依赖窗口系统资源,这是安全且常见的用法。但不要直接在子线程里创建 QPainter 绘制 QWidget 或 QPixmap,这两个类属于 GUI 资源,只能在主线程访问。大致结构如下:

void Worker::generateChart() { QImage image(800, 600, QImage::Format_ARGB32_Premultiplied); image.fill(Qt::transparent); QPainter painter(&image); // 在子线程里完成复杂绘制 painter.end(); emit chartReady(image); }

主线程收到信号后,再把 QImage 转成 QPixmap 绘制到控件或存入缓存。这种“离屏绘制 + 信号回传”的模式,既避免了界面卡顿,也绕开了线程安全问题。需要注意的是,QImage 在线程之间传递时最好用隐式共享或拷贝,不要在多线程同时修改同一份图像。

我自己在写绘图模块时最大的体会是:QPainter 的 API 看起来简单,但真正稳定的绘制代码,需要把状态管理、坐标变换、性能缓存这三件事理得很顺。尤其是自定义控件多了以后,每个paintEvent都像一个微型渲染器,状态一旦乱掉,画面就会出现各种“莫名其妙”的错乱。建议从今天开始,把你所有绘制函数都写成“save 开头、restore 结尾、参数只进不出”的风格,再结合离屏缓存,大部分绘制问题都能提前规避。

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

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

立即咨询