Qt Widgets环形进度条自绘:QPainter与动画实践
简介:这份 Qt 环形进度条示例面向已有一定 C++ 与 Qt 基础、希望在页面中补全自定义控件能力的开发者,针对 Qt 自带控件仅有垂直和水平进度条、缺少环形展示形式的问题,给出可参考的自定义实现,适用于文件下载进度、任务完成度等需要圆形可视化的场景。压缩包共 7 个文件,以 3 个 cpp 源文件和 2 个 h 头文件为主体,另含 1 个 ui 界面文件与 1 个 pro 工程配置,整体约 8KB,规模轻量,便于快速阅读和移植。目前已有 493 人学习下载。读者可从中了解环形进度条的绘制思路、进度值更新方式以及界面集成方法,并据此替换颜色、线宽和尺寸,较快搭建符合项目风格的圆形进度展示,也能为 Qt 自定义绘制类控件积累可复用的实践参考。
1. 一排横向进度条占满的界面:环形进度条真正要解决什么
某公司的设备监控上位机,一块 10 寸工控屏上要同时显示六路通道的实时负载。第一版用 QProgressBar 横向排开,占满整行宽度不说,数值和条子还互相挤,操作员离屏幕一米多就看不清楚。改版思路很直接:同样面积换成六个圆形仪表盘,进度用一圈弧线表示,数字摆在圆心。麻烦在于 Qt Widgets 自带的控件库里没有这个形状,QML 那边的圆形仪表盘又意味着整个工程得迁到 Quick 技术栈,代价太大。落到实处的做法是在 QWidget 上用 QPainter 自绘一个环形进度条(圆形进度条),对外接口和 QProgressBar 保持同一套用法,代码量两百多行。适合正在做 qt 上位机、设备面板、课设毕设里需要把进度展示做得像样一点的人,也适合想补上自绘控件这块短板的开发者。
2. 视觉规格与接口设计:先把参数表和状态机定死
自绘控件最容易犯的错,是边写边调颜色和宽度,写到一半发现「换个尺寸全乱了」,再回头重构。我一般会先花半小时,把视觉参数和对外接口用文字和表格固化下来,再动键盘。
2.1 一张参数表锁死所有可变项
环形进度条看起来简单,实际上可调项比想象的多。下面这张表是工控类项目里比较通用的一套默认值,可以直接抄,也可以按产品规范改:
| 参数 | 类型 | 默认值 | 建议范围 | 说明 |
|---|---|---|---|---|
| ringWidthRatio | qreal | 0.10 | 0.06 ~ 0.18 | 环宽占控件短边的比例,不是绝对像素 |
| startAngle | int | 90 | 固定 90 | 起始角度,90 度 = 时钟 12 点方向 |
| sweepAngle | int | 360 | 固定 360 | 整圈跨度,半圆仪表盘可改 180 |
| direction | enum | Clockwise | 顺/逆时针 | 影响 spanAngle 的正负 |
| baseColor | QColor | #2A2F3A | 任意 | 底环颜色,通常比背景亮一档 |
| startColor | QColor | #3AA0FF | 任意 | 进度起始色 |
| endColor | QColor | #7B61FF | 任意 | 进度结束色 |
| capStyle | Qt::PenCapStyle | RoundCap | RoundCap/FlatCap | 圆角端点更柔和,但会外溢半个环宽 |
| textVisible | bool | true | - | 圆心是否显示百分比 |
| animationDuration | int | 400 | 200 ~ 800 | 数值变化时的过渡时长,单位毫秒 |
关键的一条是 ringWidthRatio 用比例而不是像素。工控屏可能是 1024×600,笔记本可能是 1920×1080,同一套界面代码要跑在两种分辨率上,写死setRingWidth(12)的做法在高分屏上细得像根头发。用比例存,绘制时再乘以短边,尺寸自适应这件事就自动成立了。
圆角端点这一项要单独提醒:RoundCap 会让弧线两端各多出半个环宽的圆头,如果你按控件矩形边缘去算绘制区域,圆头会被裁掉,看起来像被切了一刀。这一点在设计阶段就要预留余量,别等到验收时才发现。
2.2 对外接口:暴露语义,不暴露绘制细节
一个控件好不好用,看头文件就知道。我给这个环形进度条定的原则是:外部只关心「当前值、范围、颜色、要不要动画」,不关心你用的是 drawArc 还是 QPainterPath。
// ringprogressbar.h class RingProgressBar : public QWidget { Q_OBJECT Q_PROPERTY(qreal currentValue READ currentValue WRITE setCurrentValue) public: explicit RingProgressBar(QWidget *parent = nullptr); // 数值相关:与 QProgressBar 保持一致的语义 void setRange(qreal min, qreal max); void setValue(qreal value); // 外部入口,内部走动画 qreal value() const; // 视觉相关 void setRingWidthRatio(qreal ratio); void setColors(const QColor &start, const QColor &end, const QColor &base); void setShowText(bool show); void setAnimationDuration(int ms); signals: void valueChanged(qreal value); protected: void paintEvent(QPaintEvent *event) override; private slots: void onAnimationTick(const QVariant &v); private: void setCurrentValue(qreal v); // 动画属性写入点,走属性系统 qreal currentValue() const; qreal m_min = 0.0; qreal m_max = 100.0; qreal m_currentValue = 0.0; // 绘制用的插值,不是目标值 qreal m_targetValue = 0.0; // 外部设定的目标值 qreal m_ringWidthRatio = 0.10; QColor m_baseColor{0x2A, 0x2F, 0x3A}; QColor m_startColor{0x3A, 0xA0, 0xFF}; QColor m_endColor{0x7B, 0x61, 0xFF}; bool m_showText = true; int m_duration = 400; QPropertyAnimation *m_anim = nullptr; };这里有个刻意的设计:m_currentValue和m_targetValue是两个变量。外部调用 setValue(80) 只改目标值并启动动画,真正被 paintEvent 读取的是插值出来的当前值。很多人图省事只留一个成员变量,结果动画一跑起来画面就跳帧——因为动画每一帧都在改同一个变量,而外部如果按定时器高频推送数据,两边打架,值会来回抽搐。分开放之后,谁改目标、谁改插值,职责是分得清的。
Q_PROPERTY那一行也别省。它的作用是让 QPropertyAnimation 能通过 Qt 的元对象系统直接驱动这个属性,不用手写 connect 去接每一帧。这一点在第 4 章会展开。
2.3 尺寸基准:所有几何量从短边推导
绘制之前的几何计算,决定了控件在任意尺寸下是否都长得对。我的做法是只认短边:
// 在 paintEvent 里,先算三个基准量 const qreal side = qMin(width(), height()); // 短边 const qreal ringWidth = side * m_ringWidthRatio; // 环宽 const qreal margin = ringWidth / 2.0 + 1.0; // 端点与抗锯齿余量 QRectF arcRect(margin, margin, side - margin * 2, side - margin * 2); arcRect.moveCenter(QRectF(rect()).center()); // 非正方形时在控件里居中之所以要用moveCenter,是因为控件不一定是正方形。放在布局里时,QLayout 给的可能是 120×80 的长方形,如果直接拿rect()当绘制区域,画出来是个椭圆。用短边算出一个正方形,再把它挪到控件中心,无论外部怎么拉伸,圆环始终是正圆,并且居中。
margin里那个+ 1.0不是玄学,是给抗锯齿像素留的余量。抗锯齿会在边缘画出半透明的过渡像素,如果绘制区域紧贴控件边界,这一圈过渡像素就落在控件外面被裁掉,圆环边缘看起来会有一圈硬边。多留一个逻辑像素,问题就没了。
还有一个容易忽略的点:尺寸极小时,side - margin * 2可能变成负数,QRectF 宽高为负会导致绘制结果不可预测,有些平台直接什么都不画。这属于第 5 章的排查范围,但设计阶段就应该用qBound兜住。
3. 绘制内核:paintEvent 里三段弧线加一个锥形渐变
绘制部分是这个控件的核心,代码不长,但每一行的顺序和参数都有讲究。拆开看,paintEvent 里干的事就四件:算几何、画底环、画进度环、写文字。
3.1 绘制顺序决定视觉层次
底环、进度环、中心文字,这个顺序不能乱。先画底环是因为它永远在最下层;进度环盖在底环上,但只覆盖实际角度那一段;文字最后画,保证不会被任何东西遮挡。如果反过来先画进度环再画底环,进度就被整圈灰环吃掉了,看起来像根本不动。
void RingProgressBar::paintEvent(QPaintEvent *) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing, true); // 抗锯齿必须开,否则弧线是锯齿状 p.setRenderHint(QPainter::TextAntialiasing, true); const qreal side = qMin(width(), height()); const qreal ringWidth = side * m_ringWidthRatio; const qreal margin = ringWidth / 2.0 + 1.0; QRectF arcRect(margin, margin, side - margin * 2, side - margin * 2); arcRect.moveCenter(QRectF(rect()).center()); if (arcRect.width() <= 0 || ringWidth <= 0) // 极小尺寸保护,别让它算出负数 return; // ---- 第一层:底环,整圈 ---- QPen basePen(m_baseColor, ringWidth, Qt::SolidLine, Qt::RoundCap); p.setPen(basePen); p.drawArc(arcRect, 0, 360 * 16); // ---- 第二层:进度环,按比例 ---- const qreal span = (m_max - m_min) > 0 ? (m_currentValue - m_min) / (m_max - m_min) : 0.0; if (span > 0.0) { const int startAngle = 90 * 16; // 12 点方向 const int spanAngle = -qRound(span * 360.0 * 16); // 负号 = 顺时针 QConicalGradient grad(arcRect.center(), 90.0); // 渐变起点也在 12 点 grad.setColorAt(1.0, m_startColor); // 12 点处是起始色 grad.setColorAt(0.0, m_endColor); // 绕回 12 点时是结束色 QPen progPen(QBrush(grad), ringWidth, Qt::SolidLine, Qt::RoundCap); p.setPen(progPen); p.drawArc(arcRect, startAngle, spanAngle); } // ---- 第三层:圆心文字 ---- if (m_showText) { // 见 3.4 } }参数说明集中在几个地方。90 * 16里的 16 是 Qt 的角度单位约定,drawArc 的角度以 1/16 度为单位,90 度要写 1440。spanAngle取负值表示从起始角顺时针方向扫过,正角度在屏幕上表现为逆时针。这两个约定如果混了,进度条会从 12 点开始倒着往回走,看起来极其别扭。
span > 0.0这个判断不能省。spanAngle 为 0 时,RoundCap 的笔帽会让它在 12 点位置画出一个圆点,进度为 0 的控件上莫名其妙多一颗珠子。用判断直接跳过绘制,干净。
3.2 角度换算:1/16 度单位和方向是最大陷阱
Qt 的 drawArc 有两个反直觉的地方,几乎每个第一次写环形进度条的人都会踩。
第一个是角度单位。Qt 不用弧度也不用度,用 1/16 度,这是历史遗留(早期为了用整数表示角度)。360 * 16 = 5760才是一整圈。很多人在网上抄代码时看到drawArc(rect, 90, -360 * 16)能跑,自己改成drawArc(rect, 90, -360)就画不出东西,原因就在这里。
第二个是方向。Qt 的坐标系 y 轴向下,但 drawArc 的角度正方向在屏幕上是逆时针的。所以要从 12 点顺时针画,起始角写 90 度(12 点位置),跨度写负数。反过来写startAngle = 90, spanAngle = +也能画出弧,但方向就是逆时针,跟多数仪表的读数习惯相反。
第三个隐式陷阱是百分比到角度的映射。如果范围是 30 到 90(比如温度),当前值 60,直接拿 60/360 去算角度是错的,必须先归一化:(value - min) / (max - min)。这个除法还要防除零,max == min时直接返回 0。
实际项目里我还会处理一种情况:数值超界。外部推送的数据偶尔会因为协议解析出错跑出范围,比如负载率变成 103%。这时span会大于 1,画出来的弧会超过一整圈并盖住起点,视觉上像环变粗了一截。所以取比例之后要qBound(0.0, span, 1.0)夹一下,脏数据不至于把界面搞花。
3.3 锥形渐变:为什么不用 QLinearGradient
进度环要好看,颜色就得沿圆弧方向渐变,而不是从左到右。QLinearGradient 是线性渐变,在一个斜向矩形上铺色,画到圆环上会出现明显的方向性断裂——同一段弧上颜色不均匀,看起来像脏了。正确的工具是 QConicalGradient,它以某个中心点为轴,沿角度方向铺色,天然贴合圆环。
// 指定渐变中心为圆心,起始角度 90 表示从 12 点方向开始铺色 QConicalGradient grad(arcRect.center(), 90.0); grad.setColorAt(1.0, m_startColor); // t=1 与 t=0 都在 12 点,这里放进度起点色 grad.setColorAt(0.0, m_endColor); // 绕一整圈回到 12 点时是结束色为什么起始色放在 1.0 而不是 0.0?因为 QConicalGradient 是沿逆时针方向铺色的,t 从 0 增加到 1 表示角度逆时针转一圈;而进度是顺时针增长的,正好反着。把起始色放在 t=1.0、结束色放在 t=0.0,进度从 12 点顺时针走过 25%(到 3 点位置)时,实际落在渐变的 t≈0.75 处,颜色刚好走了四分之一。视觉上就是「头亮尾暗」的渐变,符合直觉。
代价是 t=0 和 t=1 在 12 点位置重合但颜色不同,会形成一条硬边。不过那条边正好在进度的起点,被 RoundCap 的圆头盖住,实际看不出来。这是这个方案能用两点色搞定渐变的原因。
如果产品要求「进度头部永远是同一个亮色,不管进度多少」,那就不能靠固定渐变,得在每次绘制时按当前比例重新计算 setColorAt 的位置,把渐变随进度旋转。这种动态渐变计算量略大,但对单控件来说完全不是瓶颈。
3.4 圆心文字:字号随尺寸走,别写死
圆心放百分比是环形进度条的标配,但文字大小写死成QFont font; font.setPointSize(24);就是给未来的自己挖坑。控件从 200×200 缩到 80×80 时,24 号字会撑破圆环。
正确做法是按短边比例算字号,再用 QFontMetrics 做精确居中:
QFont f = font(); f.setPixelSize(qMax(8, int(side * 0.22))); // 字号 = 短边的 22% f.setBold(true); p.setFont(f); const QString text = QString::number(qRound(m_currentValue)); QFontMetrics fm(f); QRect textRect = rect(); textRect.moveTop(arcRect.center().y() - fm.height() / 2); p.setPen(QColor("#E8ECF3")); p.drawText(textRect, Qt::AlignHCenter | Qt::AlignVCenter, text);0.22 这个系数不是随便定的。环形进度条的圆心区域,可用直径大约是side - ringWidth * 2,取环宽为短边的 10%,可用直径就是 0.8×side。三位数字要横下放,字号超过短边的 25% 就会顶到内圈。0.22 留了一点呼吸空间,视觉上也更稳。
moveTop那一步是为了修正垂直居中。QFontMetrics 的 height 包含了字体的上下行距(ascent + descent + leading),直接把文字矩形对齐到圆心,视觉上会偏下几个像素。把矩形顶部挪到「圆心 y 减去字高一半」,文字才是真的在中间。这个几像素的偏差,人在看单个控件时未必察觉,但六个环形并排放在一起,错位就很明显。
数值取整用qRound而不是直接强转 int。强转是截断,99.6 会显示成 99,用户看到「明明已经满了怎么还是 99」会疑惑。四舍五入到 100 更符合直觉。
4. 动画驱动:把 value 做成属性,交给 QPropertyAnimation
进度条只画静态弧线也能交差,但真正让界面「活」起来的是数值变化时那段过渡。用户看到指针从 40% 平滑走到 65%,会下意识觉得数据是连续的;瞬间跳过去,反而像出了故障。
4.1 用 Q_PROPERTY 让动画引擎直接驱动绘制
Qt 的动画框架有两种用法。一种是 QVariantAnimation + connect,自己处理每一帧;另一种是 QPropertyAnimation,直接绑定到 Q_PROPERTY 上。后者的代码量少一半,而且自带状态管理(start/stop/pause),我更倾向这种。
RingProgressBar::RingProgressBar(QWidget *parent) : QWidget(parent) { setAttribute(Qt::WA_OpaquePaintEvent, false); // 允许背景透出,便于放在卡片控件里 m_anim = new QPropertyAnimation(this, "currentValue", this); m_anim->setDuration(m_duration); m_anim->setEasingCurve(QEasingCurve::OutCubic); } // 外部调用入口:只设目标值,不直接改绘制值 void RingProgressBar::setValue(qreal v) { v = qBound(m_min, v, m_max); if (qFuzzyCompare(v + 1.0, m_targetValue + 1.0)) return; // 目标没变就别重启动画,否则会一直抖 m_targetValue = v; m_anim->stop(); m_anim->setStartValue(m_currentValue); // 从当前已画到的位置接着走 m_anim->setEndValue(m_targetValue); m_anim->start(); } // 属性写入点:动画引擎每帧调一次 void RingProgressBar::setCurrentValue(qreal v) { if (qFuzzyCompare(v + 1.0, m_currentValue + 1.0)) return; m_currentValue = v; update(); // 只触发重绘,别在这里写业务逻辑 }setStartValue(m_currentValue)这一行是平滑的关键。如果不设,动画每次都从 0 开始,第一个数值从 0 涨到 40 是好看了,第二个数值从 0 涨到 65 就会出现「指针先回零再往上冲」的鬼畜画面。用当前插值当起点,无论外部怎么高频改目标,视觉上永远是连续地朝新目标走。
qFuzzyCompare前面加个 1.0,是因为这个函数对 0 附近的值会误判(内部做除法)。这是 Qt 的老坑,浮点数比较写成qFuzzyCompare(a + 1.0, b + 1.0)就能规避。
4.2 缓动曲线:不同业务场景选不同手感
QEasingCurve 有几十种曲线,环形进度条常用的就那么几个:
| 曲线 | 手感 | 适用场景 |
|---|---|---|
| Linear | 匀速,机械感 | 持续计时、下载进度这类线性增长的数据 |
| OutCubic | 起步快、收尾缓 | 绝大多数仪表盘默认选择 |
| OutQuint | 更快起步、更长收尾 | 数值跳变幅度大(如从 10 到 90)时更顺 |
| InOutQuad | 两头慢中间快 | 双向变化(涨落都频繁)的场景 |
| OutBack | 末尾轻微过冲再回弹 | 展示型大屏,娱乐化界面,工业场景慎用 |
OutCubic 是最保险的默认值。它在 70% 的时间里走完 90% 的路程,最后 30% 慢慢收尾,人的眼睛会自动把这段收尾读成「稳定下来了」,跟仪表指针停住的感觉高度吻合。
OutBack 那种回弹效果要慎用。在一个显示电机转速的工控界面上,指针冲过头再弹回来,操作员第一反应是「设备出问题了」。缓动曲线是情绪表达,工业场景里情绪要稳。
时长也不是越长越好。400ms 是一个经验值:低于 200ms 人眼几乎感知不到过渡,等于白做;超过 800ms,连续刷新的数据会让动画永远追不上目标值,最终显示的是个逐渐落后的滞后值。数据刷新频率高的时候,时长要跟着压到 250ms 以内。
4.3 数据源解耦:外部推得比动画快怎么办
真实项目里,环形进度条的值往往来自串口、Modbus 或者 WebSocket,推送频率可能 10Hz 甚至更高。而动画的刷新率跟着屏幕走,通常 60Hz。两边频率不一致,直接调 setValue 会出现两种问题:一是动画被反复 stop/start,永远走不完第一段;二是每帧都在 update,CPU 占用飙升。
我的处理分两层。第一层在控件内部做节流:距离上次 setValue 不足 16ms 的变化,只更新 m_targetValue 不重启动画,等下一帧再统一处理。
void RingProgressBar::setValueThrottled(qreal v) { m_pendingValue = qBound(m_min, v, m_max); if (!m_throttleTimer->isActive()) { applyPendingValue(); // 立即响应第一次变化 m_throttleTimer->start(16); // 16ms 内的后续变化合并 } } void RingProgressBar::applyPendingValue() { setValue(m_pendingValue); // 走 4.1 的动画入口 }第二层在调用方:如果数据源本身是 1Hz 的传感器读数,那就别做 400ms 的动画了,直接 setCurrentValue 瞬间到位,反而更真实。环形进度条是表现层,动画时长应该跟数据的时间尺度匹配,不是所有场景都需要过渡。
还有一种极端情况:数据源推送的值本身就是抖动的(比如负载在 58% 到 62% 之间高频跳动)。这种时候平滑动画会把抖动放大成「呼吸灯」效果,看着心烦。可以用一个简单的滑动平均,把最近 5 次的均值作为目标值,视觉上稳定很多。这个逻辑不该塞进绘制控件里,放在数据接收层更合适。
5. 避坑与排查:环形进度条上线前必须过的五道关
前面几章的代码跑起来能出画面,但从「能看」到「能交付」中间隔着一堆细节。下面这五条是我在不同项目里真实撞过的,按现象、原因、解决拆开写。
5.1 开了抗锯齿边缘还是有毛边
现象:setRenderHint(QPainter::Antialiasing, true)明明设了,圆环外沿还是能看到一圈生硬的锯齿,尤其是斜向的弧线段。
原因不在抗锯齿本身,而在绘制区域被裁。前面算 arcRect 时,如果 margin 只留了环宽的 1/2,RoundCap 圆头加上抗锯齿产生的半透明像素,正好落在控件矩形边界外,被 QWidget 的裁剪区切掉,剩下的就是一条硬直边。另外,如果控件本身放在了带圆角或者透明背景的父控件里,父级裁剪也会造成同样效果。
解决办法是把余量从ringWidth / 2加到ringWidth / 2 + 1,并且确认控件没有设置WA_TranslucentBackground之外的异常裁剪属性。还有一种可能:控件尺寸是奇数,中心点落在半像素上,抗锯齿会把它均匀分成两个半透明像素,视觉上反而发虚。遇到这种情况,可以在 resizeEvent 里把控件尺寸向上取偶,或者干脆接受这点差异——在 1080p 屏上肉眼几乎看不出。
5.2 进度从 12 点开始逆时针走,越走越回去
现象:数值增大时,弧线不是顺时针变长,而是从 12 点往 9 点方向长,最终跟仪表盘的习惯完全相反。
原因是 spanAngle 的符号写反了。Qt 的角度正方向在屏幕上是逆时针,要从 12 点顺时针扫,跨度必须取负。很多人按数学直觉写正数,结果就是反向。把-qRound(span * 360.0 * 16)的负号补上就好。
还有一种是渐变和弧线的方向对不上:进度顺时针走,但QConicalGradient是逆时针铺色,如果 setColorAt 的位置没按第 3.3 节那样对调,会出现「弧线头部的颜色是渐变的尾部色」这种别扭效果。判断方法是把进度设成 50%,看 3 点位置和 9 点位置的颜色是不是跟设计的起止色对应。不对就调换 setColorAt 的 t 值。
5.3 界面卡顿、CPU 占用异常高
现象:一个页面上放了六个环形进度条,刷新时风扇开始转,拖动窗口有明显的掉帧。
先用排查工具定位:Qt Creator 的 Analyzer 里看 CPU 火焰图,或者简单粗暴地在 paintEvent 里打点计时。多数时候罪魁祸首是绘制频率。如果外部数据源是 50Hz 推送,每次都走完整动画 + update,那绘制频率就跟着上去了,六个控件加起来每秒三百次重绘,扛不住很正常。
解法按优先级排:第一,控件的 setValue 加节流(第 4.3 节的 16ms 合并);第二,在不可见时停止动画,重写 showEvent/hideEvent,隐藏状态下m_anim->stop(),避免后台空转;第三,检查有没有在 paintEvent 里构造 QFont、QPen、QColor 这类对象,它们应该做成成员变量,每帧新建对象会造成不必要的分配;第四,如果只是数字在变,设置Qt::WA_OpaquePaintEvent减少背景重绘。
还有一条容易被忽略:QPainter 的 Antialiasing 在高频绘制下开销不小。如果控件尺寸很小(比如 60×60 的状态指示),可以关掉抗锯齿换成 2 倍超采样绘制再缩放,性能反而更好。
5.4 尺寸变成 0 或者极小时控件崩了
现象:把环形进度条放进 QSplitter 或者可折叠面板,拖到最小的时候程序崩溃,或者画面上出现一团乱七八糟的色块。
原因是几何计算出负数。side - margin * 2在 side 小于 2 倍 margin 时变负,QRectF 宽高为负,drawArc 的行为是未定义的,某些平台上直接触发断言。同理,ringWidth 算出 0 时,QPen 的宽度为 0 是「cosmetic pen」,会画成 1 像素宽的细线,跟预期完全不符。
修复就是在 paintEvent 开头加保护:if (arcRect.width() <= 0 || ringWidth <= 0) return;。更稳妥的做法是用 qMax 兜底,把 ringWidth 限制在[1.0, side * 0.3]之间,让极小尺寸下控件退化成一个小圆点而不是崩溃。这个保护必须写,别指望布局系统永远不给零尺寸。
5.5 setStyleSheet 一加,自绘效果全没了
现象:为了统一界面风格,给父窗口设了全局样式表,结果环形进度条的颜色不生效了,或者背景变成一块白底。
原因是 QWidget 的样式表机制和自绘会互相干扰。一旦控件被 QStyleSheetStyle 接管,paintEvent 的调用时机和背景擦除逻辑都会变。具体表现为:控件默认的autoFillBackground被样式表打开,paintEvent 之前先刷了一层背景,你的绘制被盖住;或者样式表里的color属性影响了p()->setPen之外的绘制路径。
解决办法有两个方向。一是彻底隔离:给控件设setAttribute(Qt::WA_StyledBackground, false),并在 paintEvent 里显式p.fillRect(rect(), Qt::transparent)或者按需填充背景色,把绘制权完全拿回来。二是顺应样式表:把颜色配置成 Q_PROPERTY,在qproperty-xxx里从样式表读,这样主题切换时能跟着走。工控项目里我一般选第一种,简单可控;多主题的消费级产品选第二种,可维护性更好。
判断到底是哪种冲突,最快的办法是临时把全局样式表注释掉,看控件是否恢复正常。恢复了就是样式表的问题,没恢复就回去查绘制代码。
6. 验收与答辩:一套能当场自证的检查表
环形进度条这种控件,验收时最怕遇到「看着挺好」四个字。评审的人说不出具体哪里不行,但你自己心里也没底。我的做法是准备一张能当场跑、当场看结果的检查表,把主观判断变成可复现的验证动作。
| 验收项 | 验证方法 | 通过标准 |
|---|---|---|
| 尺寸自适应 | 把窗口从 400×400 拖到 80×80 再拉满 | 圆环始终正圆、居中、文字不溢出、不崩溃 |
| 方向与起点 | 设 value 为 25%,截图对照时钟 | 弧线从 12 点出发顺时针到 3 点 |
| 范围映射 | 设 range(30, 90),value 为 60 | 显示半圈,文字为 60 |
| 超界保护 | 直接 setValue(150) | 画满整圈后停止,不越界不重叠 |
| 动画连续性 | 1 秒内连续调 5 次 setValue | 指针连续前进,无回零、无抽搐 |
| 数值精度 | value 设 99.6 与 99.4 | 分别显示 100 与 99 |
| 主题颜色 | 改 startColor/endColor 后立即生效 | 无需重启,下次重绘即变 |
| 隐藏状态 | 把控件 hide 后持续推送数据 | CPU 占用回落,无后台动画 |
答辩环节,评委最常问的三个问题基本可以预判。第一个是「为什么不用 QML 现成的组件」,回答要落在技术栈代价上:现有工程是 Widgets,引入 Quick 需要额外维护一套渲染后端和打包依赖,收益不足以覆盖迁移成本。第二个是「性能怎么样」,直接给出量化数据比形容词有说服力——六个控件同时以 20Hz 刷新,CPU 占用多少,有没有做节流。第三个是「怎么保证不同分辨率下都对」,把短边比例那套几何推导讲清楚,比说「测过了」可信得多。
我自己在这上面栽过一次。第一版交付时只测了开发机的 1920×1080,到现场工控屏 1024×600 上一跑,圆环压扁成椭圆,文字顶到了环外面。原因就一句话:几何计算里有一处写死了像素值。从那以后我每次做自绘控件,都会强制在 80、200、1080 三档尺寸下各截一张图,摆在一起对比着看。这十秒钟的动作,省掉的是往返现场的时间。
这套环形进度条我已经整理成一份可直接编译的 Qt Widgets 工程,包含完整的头文件、源文件、一个演示窗口和上面那张检查表对应的测试用例。需要的话按标题找过去就行,希望能帮到你。
本文还有配套的精品资源,点击获取