第一次看到 Qt 官方 examples 里的 Basic Drawing 这个 demo 时我的第一反应是这不就是把 QPainter 的绘制函数挨个列出来吗后来真的在项目里做自定义控件——仪表盘、曲线图、富文本编辑器里的画布——才意识到这个 demo 的分量。它几乎是 Qt 2D 绘图体系的“全息骨架”左边一个形状列表右边一块画布点击不同条目就画出线、矩形、椭圆、扇形、路径、文本。代码量不大但 QPainter 的绘制原语、QPen/QBrush 的属性、三种渐变、抗锯齿设置、paintEvent 的重绘时机全部被串在了同一个例子里。这篇详解会把 demo 里出现的每一条绘制 API、每一个属性设置都拆开讲清楚配合我自己踩过的坑让新手能一遍看懂也让有基础的开发者能对照查漏补缺。1. 项目概述Basic Drawing 到底教了什么1.1 看似简单的界面却是自绘控件的标准模板Basic Drawing 的界面结构非常典型左侧是可供选择的图形列表包括 Line线段、Rect矩形、Rounded Rect圆角矩形、Ellipse椭圆、Arc圆弧、Pie扇形、Chord弦形、Polygon多边形、Polyline折线、Text文本、Path路径右侧是一块继承自 QWidget 的绘制区域。界面上还有一些选项比如是否开启抗锯齿、是否填充图形等。这个结构本身就是开发自绘控件的通用骨架一个继承 QWidget 的自定义类 重写 paintEvent 在 paintEvent 里创建 QPainter 并按状态绘制。你以后写的股票 K 线图、雷达扫描图、签名板本质上都是这三个步骤的变体。所以别看 demo 简单它就是一副可以直接套用的模板。1.2 Qt 2D 绘图的三个主角要真正理解这个 demo得先把 Qt 绘图体系里三个最基本的概念理清楚QPainter、QPaintDevice、QPaintEngine。QPainter负责“怎么画”的画笔操作者。它提供 drawLine、drawRect、drawPath 等一系列高层 API。QPaintDevice负责“画在哪”的画布相当于一块可绘制的表面。QWidget、QPixmap、QImage 都是 QPaintDevice 的子类甚至可以画到打印机上。QPaintEngine负责“具体怎么实现”是连接 QPainter 和底层绘图设备之间的桥。在 99% 的日常开发中我们不会直接操作 QPaintEngine但它解释了为什么同一个 QPainter 既能画在屏幕上也能画在图片上——因为底层引擎会自动切换。用生活里的例子来比QPainter 是画家QPaintDevice 是画纸QPaintEngine 是画家的手。Basic Drawing 里的绘制区就是一个 QWidget它天然就是一张屏幕上的“画纸”。1.3 为什么这个 demo 值得逐行研究市面上讲 Qt 绘图的教程很多但大多只会告诉你某个函数的参数。Basic Drawing 的价值在于它把高频绘制路径集中呈现直线类、形状类、闭合图形类、路径类、文本类几乎涵盖了自定义控件 90% 以上的绘制需求。把 demo 里每个 case 都跑一遍、改一改参数、观察效果变化你对 Qt 绘图的认知会有一个质变从“调用某个函数画个图形”上升到“看到需求就知道该用哪个 API、配什么画笔画刷、设什么坐标变换”。这也是我写这篇详解的核心目标。2. 核心细节解析demo 里的绘制原语2.1 从一条线开始drawLine 与逻辑坐标demo 里点击 Line 时绘制区会画一条对角线。对应的核心调用是painter.drawLine(QPointF(10, 10), QPointF(250, 150));drawLine 有两种常见重载一种是直接给四个坐标值另一种是传两个 QPointF 点对象。需要记住的第一件事是drawLine 只能画直线段没有“无限延伸”的线。你给的是起点和终点它画的就是这两点之间的一条线段。另一个容易被忽略的概念是“逻辑坐标”。QWidget 里的坐标原点在左上角x 轴向右增长y 轴向下增长单位不是物理像素而是逻辑坐标。在支持高 DPI 缩放的屏幕上逻辑坐标和物理像素之间有一个设备像素比的换算关系。这个问题我会在第五章展开讲这里先记住QPainter 操作的是逻辑坐标体系。2.2 形状函数drawRect、drawRoundedRect、drawEllipse、drawArc点击 Rectdemo 会调用 drawRect(QRectF(...))。QRectF 用浮点数保存位置和尺寸好处是缩放、运算时不容易积累精度误差。如果你的图形会参与动画或矩阵变换优先考虑 F 系列类型。drawRoundedRect 的签名是drawRoundedRect(rect, xRadius, yRadius)后面的两个参数是圆角半径。注意它们既可以传像素值也可以传百分比配合Qt::RelativeSize标志来用。demo 里通常传固定数值如 20所以圆角看起来是圆润的如果传 0就是一个普通矩形。drawEllipse 的参数是外接矩形drawEllipse(rect)。这里有个新手容易踩的坑QPainter 没有直接提供“圆心 半径”的 drawCircle 前几代的 API画圆也是调用 drawEllipse把外接矩形传成正方形就行。想画椭圆只需调整矩形宽高比。drawArc 是我特别想强调的因为它的角度单位非常容易搞错。drawArc(rect, startAngle, spanAngle)的前两个角度值单位是“1/16 度”也就是说画一个 90 度的圆弧spanAngle 要写成90 * 16。Basic Drawing 里画圆弧的时候如果你直接把 demo 中的120 * 16改成 120会发现几乎看不见弧线——它变成了不到 8 度的短弧。这种单位设计源自早期绘图系统Qt 一直保留下来目的可能是为了用整数表示更精细的角度。既然改不了就要养成习惯角度参数一律乘以 16。另外要区分 Arc、Pie、Chord 三者Arc只画弧线不加额外连线。Pie弧线两端与圆心连线围成一个扇形面。Chord弧线两端用一条弦相连围成弓形面。三者都是startAngle spanAngle的角度描述方式理解了这个规律看到 demo 里这三个选项就不会犯迷糊。2.3 多边形与折线drawPolygon、drawPolyline多边形用QPolygonF来定义它本质上是QVectorQPointF的集合。写法很直观QPolygonF polygon; polygon QPointF(30, 80) QPointF(150, 70) QPointF(210, 160) QPointF(90, 200) QPointF(40, 140); painter.drawPolygon(polygon);drawPolygon 会自动把最后一个点和第一个点连起来形成闭合区域并且可以填充。而 drawPolyline 只按顺序连接点不闭合也不填充。多边形有一个必须留意的参数填充规则。QPainter 支持Qt::OddEvenFill和Qt::WindingFill两种规则传给drawPolygon/drawPath时会影响自交多边形的填充结果。** OddEven 按“奇偶交叉次数”判断内外Winding 按“绕行方向”判断内外**。画五角星或者复杂轮廓时两种规则会画出截然不同的形状。demo 里的多边形比较简单看不出差别等你做字体描边或者复杂遮罩时就会遇上。2.4 文本绘制drawText 与字体度量Text 选项调用的是drawText(rect, flags, text)。这个重载会在指定的矩形区域内绘制文本并支持对齐标志比如Qt::AlignCenter就是水平和垂直都居中。还有另一个重载drawText(pos, text)它把文本以 pos 作为左上角起点绘制不支持对齐。文本绘制的“坑”在于字体度量的概念。如果你要在一个控件里精确居中一段文字或者把文字宽度算出来用于排版就需要用QFontMetricsQFontMetrics fm(painter.font()); int textWidth fm.horizontalAdvance(Qt Drawing); int textHeight fm.height();这样算出来的宽度才是这个字体下文本真实的像素宽度直接拿 rect.width() 去猜会差很多因为字体有字间距、衬线、内边距这些因素。Basic Drawing 里虽然只演示了最简单的居中但在真实项目中文字与图形混排时 QFontMetrics 几乎都是必用的。2.5 QPainterPath把图形揉成一个复合体Path 是整个 demo 里含金量最高的部分。QPainterPath 描述的是一个“路径对象”它可以包含直线、贝塞尔曲线、矩形、椭圆甚至文本相当于一张草稿纸上所有笔迹的集合。基本操作QPainterPath path; path.moveTo(20, 180); // 抬笔移动到起点 path.cubicTo(60, 20, 140, 220, 220, 120); // 三次贝塞尔曲线 path.lineTo(240, 60); // 画直线到新点 painter.drawPath(path);cubicTo 有“两组控制点”适合画 S 形曲线quadTo 只有“一组控制点”适合画抛物线。如果只是想拼接直线和圆弧moveTo/lineTo 就够了。QPainterPath 还支持 addRect、addEllipse、addText、addRegion这意味着你可以把不同来源的图形组合成一个整体然后统一进行填充、描边和裁剪。实际开发中气泡聊天框、圆角卡片、坐标折线图的光滑曲线几乎都用 QPainterPath 实现。demo 里的 Path 只是画一条简单的贝塞尔但我建议你把 addRoundedRect、addEllipse 再配合path.setFillRule()一起实验一遍这部分玩熟了自定义控件的绘制能力会上一个台阶。3. 画笔、画刷与渐变图形的“皮肤”怎么配3.1 QPen 的五大要素Basic Drawing 的 Pen 选项区会展示不同线宽、线帽和连接样式。QPen 的完整模型包含五个维度线宽width决定线条的粗细。笔刷brush线本身要填充的颜色或纹理。样式style实线还是虚线、点线。线帽capStyle线段端点形状有 FlatCap、SquareCap、RoundCap。连接样式joinStyle两条线段连接处的拐角有 MiterJoin、BevelJoin、RoundJoin。这里有个常被忽略的细节线宽设置为 0 时是特殊值表示宽度为 1 像素的“极细线”并且它在任何缩放变换下都不会变粗。这在画网格、坐标轴这类不希望被放大的辅助线时特别好用。连接样式的理解可以这样想MiterJoin 是尖角延长两条线相交产生一个尖锐的角但如果角度太小尖角会非常长这时候受setMiterLimit限制会自动退化成 BevelJoinBevelJoin 是切掉尖角RoundJoin 是在拐角处补一个圆弧视觉上最柔和。做仪表盘刻度或者做折线图平滑曲线时RoundJoin 常常是首选。3.2 QBrush 的填充魔法QBrush 决定图形内部怎么填充。最常见的是QBrush(QColor)纯色填充还有各类渐变色画刷。demo 里一个反复出现的操作是切换“填充”开关其实就是在画笔仍然存在的情况下把画刷设置为Qt::NoBrushpainter.setBrush(Qt::NoBrush);这样图形就只描边不填充了。在调试模式下这个功能非常有用——你可以关闭填充观察图元的轮廓是否如预期。还有一个概念是填充规则在 QBrush 构造时通常不需要指定但绘制 Polygon 和 Path 时你要确保 painter 设置的是自己想要的填充规则。默认规则是Qt::OddEvenFill而字体轮廓、复杂自交图形会受很大影响。3.3 三种渐变Linear、Radial、ConicalBasic Drawing 里画出来的彩色渐变效果来自 QBrush 搭配 QGradient。Qt 的渐变分三类// 线性渐变两点确定渐变方向 QLinearGradient linear(50, 50, 200, 200); linear.setColorAt(0.0, Qt::red); linear.setColorAt(1.0, Qt::blue); // 辐射渐变由内向外扩散 QRadialGradient radial(QPointF(100, 100), 80); radial.setColorAt(0.0, Qt::yellow); radial.setColorAt(1.0, Qt::green); // 锥形渐变绕中心旋转 QConicalGradient conical(QPointF(150, 100), 30); conical.setColorAt(0.0, Qt::cyan); conical.setColorAt(1.0, Qt::magenta);setColorAt 的第一个参数是 0.0 到 1.0 之间的位置比例0.0 是起点1.0 是终点中间随便插多少颜色节点都行。使用渐变时要注意一个坐标问题渐变默认使用的坐标是设备坐标不是 QPainterPath 的局部坐标。如果你把一个矩形平移到屏幕上别的位置渐变颜色并不会跟着矩形移动它会以初始坐标为中心保持不动。解决办法有两个一是每次变换 painter 时同步调整渐变坐标二是使用setCoordinateMode(QGradient::ObjectBoundingMode)让渐变的坐标相对于被填充对象的边框。我自己的经验是绘制单个控件内的渐变效果时直接用 ObjectBoundingMode 最省心但如果是多个图元各自不同方向的渐变还是老老实实各自设置坐标改动最小。4. 实操过程与核心环节实现4.1 工程搭建与文件结构我建议自己动手把这个 demo 从零搭一遍不用依赖 Qt Creator 自动生成的模板。最简单的工程是 qmake 项目.pro文件里写QT widgets CONFIG c17 SOURCES main.cpp widget.cpp HEADERS widget.h这里不展开整个工程的完整代码重点看核心的 RenderArea 类。它继承自 QWidget持有几个成员当前形状类型 shape、画笔 pen、画刷 brush、是否抗锯齿 antialiased。这些成员可以由外部槽函数修改修改后调用update()触发重绘。这种“状态改变 → 请求重绘 → paintEvent 按状态绘制”的模式是 Qt 自绘控件最典型的交互闭环。4.2 主窗口与列表选择主窗口左侧放一个 QListWidget右侧放 RenderArea。点击列表项时拿到对应项数据用Qt::UserRole保存枚举值然后调用 renderArea 的 setShapevoid MainWindow::onShapeChanged(int row) { RenderArea::Shape shape static_castRenderArea::Shape( listWidget-item(row)-data(Qt::UserRole).toInt()); renderArea-setShape(shape); }setShape 里要判断新值是否与旧值相同避免无意义的重绘void RenderArea::setShape(Shape shape) { if (shape ! this-shape) { this-shape shape; update(); } }这里只调 update 不直接调 repaint原因我后面会讲。4.3 核心绘制流程与关键代码paintEvent 是整个 demo 的神经中枢。它的标准写法是先创建 QPainter明确绘制质量选项再按状态分流void RenderArea::paintEvent(QPaintEvent *) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, antialiased); painter.setPen(pen); painter.setBrush(brush); QRectF rect(30.0, 20.0, 200.0, 150.0); switch (shape) { case Line: painter.drawLine(QPointF(20, 20), QPointF(250, 180)); break; case Rect: painter.drawRect(rect); break; case RoundedRect: painter.drawRoundedRect(rect, 20, 20); break; case Ellipse: painter.drawEllipse(rect); break; case Arc: painter.drawArc(rect.adjusted(-20, -20, 20, 20), 0, 120 * 16); break; case Pie: painter.drawPie(rect.adjusted(-20, -20, 20, 20), 0, 120 * 16); break; case Chord: painter.drawChord(rect.adjusted(-20, -20, 20, 20), 0, 120 * 16); break; case Polygon: { QPolygonF polygon; polygon QPointF(30, 80) QPointF(150, 70) QPointF(210, 160) QPointF(90, 200) QPointF(40, 140); painter.drawPolygon(polygon); break; } case Polyline: { QPolygonF polyline; polyline QPointF(30, 120) QPointF(90, 50) QPointF(140, 100) QPointF(210, 30); painter.drawPolyline(polyline); break; } case Text: painter.drawText(rect, Qt::AlignCenter, tr(Qt Drawing)); break; case Path: { QPainterPath path; path.moveTo(20, 180); path.cubicTo(60, 20, 140, 220, 220, 120); path.lineTo(240, 60); painter.drawPath(path); break; } default: break; } }这段代码里的矩形 rect 我用的是局部变量没有硬编码成与控件大小一致。在真实项目中你通常会根据widget()-width()/height()动态计算绘制区域保证窗口大小变化后图形居中或自适应。qpainter 直接以 this 作为设备构建等价于隐式调用了 begin(this)。如果一个绘制周期里创建了 painter 又提前 return局部 painter 析构时也会安全结束所以这个写法是比较安全的。4.4 抗锯齿图形质量的开关paintEvent 里第一行setRenderHint(QPainter::Antialiasing, antialiased)就是抗锯齿开关。为什么 Basic Drawing 里要专门放一个选项因为抗锯齿对图形观感影响极大。不抗锯齿时斜线边缘会出现明显的“阶梯感”也就是称的像素锯齿抗锯齿开启后图形边缘通过半透明过渡像素来实现平滑效果。代价是明显的性能开销。如果你的控件每帧要绘制几千条线、几千个多边形全屏抗锯齿会让 CPU/GPU 压力成倍增加。我的经验是静态图形、需要精细外观的图形果断开抗锯齿实时刷新、大数据量绘制的场景可以不开或者局部开。Qt 的 RenderHint 还有TextAntialiasing和SmoothPixmapTransform前者控制文字灰度平滑后者控制图片缩放时的平滑度demo 里没体现但实际开发中经常会一起设置。4.5 状态变换save/restore 是保命符demo 的简单绘制不需要坐标变换但一旦你开始写复杂控件painter 的 translate/scale/rotate 几乎是绕不开的。这里有个必须养成的习惯变换前 save变换后 restore。painter.save(); painter.translate(width() / 2.0, height() / 2.0); painter.rotate(45); // 在这里绘制坐标原点是控件中心 painter.restore();如果不做 save/restore每次变换都会在当前坐标系上叠加画完一个图形就“回不到原点”后面的图形位置会越来越偏。我自己调试时最常犯的错误就是忘记 restore结果图形飞出去一大截还找不到原因。写代码时把 save/restore 成对出现能省下大量调试时间。5. 常见问题与排查技巧实录这几个问题是我在带新人、自己改代码时反复遇到的很多毛病和 Basic Drawing 的场景直接相关建议收藏备用。5.1 为什么图形画了却不显示这是新手最高频的问题通常有三个原因绘制代码写在构造函数里而不是 paintEvent 里。窗口创建时还没准备好画的内容会被后续擦掉。重写了 paintEvent 但没有调用基类的 QWidget::paintEvent(event)。如果你把背景绘制和逻辑画图搞混可能会吞掉系统绘制。状态改变后没调用 update()界面永远不会重画。注意 update 和 repaint 的区别update 会合并短时间内多次请求只要事件循环有空就重绘一次repaint 立即强制重绘在需要即时反馈的场景使用。线上经验频繁动画不要用 repaint用 QTimer update 驱动静态修改用 update 足够。如果图形都画了但不更新检查一下是不是外层控件开启了setAttribute(Qt::WA_OpaquePaintEvent)影响了合成。5.2 高 DPI 下线条发虚或位置偏移Qt 5.14 默认开启了高 DPI 缩放窗口都以“逻辑尺寸”显示。如果你在 paintEvent 里用了painter.device()-width()拿到的是物理像素而绘制坐标是逻辑单位两者不匹配就会导致图像错位或模糊。正确做法是绘制路径里的坐标尽量用控件自身的 width()/height()也就是逻辑坐标如果确需物理像素做判断要用devicePixelRatio()换算。qreal dpr devicePixelRatioF(); QSizeF physicalSize QSizeF(width() * dpr, height() * dpr);在 Basic Drawing 的矩形绘制里直接用 rect 逻辑坐标不会出问题但如果你做了setTransform或者手动放大 painter就需要格外留意这些单位差异。5.3 渐变颜色出不来或者位置不对我见过好几个人卡在渐变上明明 setColorAt 设置了颜色画出来却一片漆黑或者渐变只出现在窗口一角。第一个坑是渐变对象被提前释放。QBrush 持有一个渐变副本但如果你传的是一个局部渐变临时对象某些 Qt 版本下行为可能不符合预期。稳妥起见显式构造 QLinearGradient 变量再传给 QBrush。第二个坑就是前面说的坐标模式。渐变坐标默认相对设备坐标如果图形发生了平移渐变不会跟着动。调试时可以先画一个纯色画刷的矩形作为对照确认矩形位置后再对比渐变矩形的位置。需要渐变“贴”着图形走使用QGradient::ObjectBoundingMode最直接。5.4 旋转平移后图形位置完全失控这个和我前面说的 save/restore 相关。常见表现是第一次旋转正常第二次旋转图形飞出去了。原因就是 painter 坐标系已经被上一次变换改变了继续在旧坐标系上做叠加。排查方法很经典在变换代码前后分别打印 painter 的变换矩阵或者干脆把 transform 打出来看qDebug() painter.transform();如果矩阵数值不断累计就说明缺少 restore。还有一种隐蔽情况你在别的地方调用了 painter.begin(this) 后没有 end()接着又创建新的 QPainter导致状态错乱。QPainter 是资源型对象必须保证 begin/end 配对或者依赖局部变量的析构机制。5.5 性能问题一拖动就卡成 PPT绘制性能的主要瓶颈通常不在 QPainter 本身而在“过度绘制”。如果你在 paintEvent 里做了大量重复计算比如每次都重新生成一个巨大的 QPainterPath、每次动态创建字体性能很难好。建议做法把不变的图形先用 QPainterPath 预先缓存好paintEvent 里只调用 drawPath。把背景保存成 QPixmap绘制时直接 drawPixmap省去重复画网格。用 QWidget 的Qt::WA_OpaquePaintEvent提示系统区域不需要背景擦除减少无效绘制。下面把几个高频问题整理成速查表问题现象可能原因解决思路绘制内容不显示画在了构造函数/没调 update移到 paintEvent状态变更后调用 update图形发虚模糊逻辑坐标与物理像素混用使用控件 width/height 逻辑坐标需要时换算 DPR渐变空白或错位渐变坐标模式错误/对象提前释放使用 ObjectBoundingMode确保临时渐变生命周期完整旋转平移偏移坐标变换叠加未恢复save/restore 成对使用滚动、拖动时卡顿paintEvent 内重复大量计算缓存 QPainterPath/QPixmap减少重复构建6. 写在最后的一点个人体会Basic Drawing 这个 demo 我前前后后看过三次每次关注的点都不一样。第一次看是学语法知道 QPainter 能画什么第二次看是在做图表控件的时候意识到它把绘制体系里最关键的“原语”都串起来了笔触、画刷、路径这些概念在实际项目里一个都躲不掉第三次看是在排查高 DPI 问题和性能优化时回头才发现 demo 里那个简单的绘制区域恰好包含了所有需要考虑的基础问题。我自己学 Qt 绘图的体会是不要急着上第三方图表库先用官方 demo 把每个绘制函数练熟再试着改参数、加交互、加变换。把 Basic Drawing 里每一个 case 都亲手跑一遍再自己扩展两个新图形进去比如画一个带刻度的仪表盘、画一段平滑曲线。这个练习做完你去看任何开源的自定义控件源码基本都能看懂七八成。绘图这块没有捷径大量的手写实验往往比看十遍文档都管用。
阅读完成 · 觉得有帮助?