Qt 21 自定义控件:速度表绘制 学习笔记
Qt速度表控件以状态驱动重绘和纯绘制函数为核心。
Qt 21 自定义控件:速度表绘制 学习笔记
本章重要 API 函数
| API | 功能 | 参数说明 | 头文件 |
|---|---|---|---|
QWidget::paintEvent() | 重绘回调,控件外观的全部来源 | QPaintEvent *event:event->rect() 可取脏矩形。protected + override | <QWidget> |
QWidget::update() | 请求重绘,是自定义控件”数据变了”的唯一响应方式 | 无参 / QRect。异步、可合并 | <QWidget> |
QPainter::translate() | 平移坐标系原点 | qreal dx, dy 或 QPointF。仪表盘的第一步:把原点搬到控件中心 | <QPainter> |
QPainter::scale() | 缩放坐标系 | qreal sx, sy:把 200×200 逻辑画布映射到实际尺寸,实现尺寸自适应 | <QPainter> |
QPainter::rotate() | 旋转坐标系 | qreal angle:单位是度、顺时针为正(与数学习惯相反) | <QPainter> |
QPainter::save() / restore() | 画家状态压栈 / 弹栈 | 无参。必须严格配对,循环画刻度时是刚需 | <QPainter> |
QPainterPath::addPolygon() | 往路径里加一个多边形 | const QPolygonF &polygon:可用 {QPointF(...), ...} 初始化列表构造 | <QPainterPath> |
QPainterPath::subtracted() | 路径差集(在自己身上挖掉对方) | const QPainterPath &p:返回新路径,不修改原对象 | <QPainterPath> |
QPainter::drawPath() | 绘制路径(按当前 Pen 描边 + Brush 填充) | const QPainterPath &path | <QPainter> |
QPainter::drawText() | 绘制文字 | (const QRect &r, int flags, const QString &text):flags 用 Qt::AlignCenter 等。颜色由 Pen 决定 | <QPainter> |
QFont::setPointSize() | 设置字号(点数) | int pointSize。注意会被 scale() 一起缩放 | <QFont> |
QString::number() | 数字转字符串 | int/double value, int base = 10(double 版是 char format, int precision) | <QString> |
QString::toDouble() | 字符串转 double,带成功标志 | bool *ok = nullptr:必须检查 ok,否则非法输入静默变成 0 | <QString> |
QBoxLayout::setStretch() | 设置布局项的伸缩比例 | int index, int stretch:如 (0,9)/(1,1) 表示 9:1 分配空间 | <QBoxLayout> |
💡 Tips:
painter.rotate()是顺时针为正,而数学上的极坐标是逆时针为正。所以”从左下 150° 开始、跨 240°“在屏幕上是”左下 → 正上 → 右下”的顺时针扫过。算角度时如果方向搞反,指针会朝错误的一侧摆。QFont::setPointSize()会被scale()放大。本工程scale(W/200.0, H/200.0)之后设setPointSize(6),在 400px 宽的控件上实际渲染约等于 12pt。这就是为什么代码里的字号数字看起来”小得不合理”——它们是逻辑坐标下的字号。subtracted()返回新路径、不改原对象。a.subtracted(b)必须接收返回值;写成a.subtracted(b);一行丢弃返回值,编译通过但毫无效果。toDouble(&ok)的ok必须检查。"abc".toDouble(&ok)会让ok = false且返回 0.0。不检查的话,用户输入乱码会让指针”归零”,看起来像 bug 却找不到原因。- Qt5 vs Qt6:
Q_OBJECT宏后面不要加分号(本工程SpeedWidget.h写了Q_OBJECT;,Qt5/Qt6 的 moc 都能容忍,但某些编译器/静态分析工具会报警告,属于不规范写法);Qt6 移除了QPainter::HighQualityAntialiasing;Qt6 高 DPI 默认开启,但本篇的scale归一化写法天然 DPI 无关。
本章目录
点击条目可跳转到对应小节。
章节导航
- 本章重要 API 函数
- 学习前后依赖
- 1. 概述
- 2. 核心概念与原理
- 3. 关键机制:一次 setSpeed 到指针转动的完整链路
- 4. 核心代码解析
- 5. 运行效果与验证
- 容易踩坑(注意点)
- 学习要点总结
- Q&A(常见问题)
- Tips(全局速记)
💡 Tips:本篇的正确读法是对着 qt20 的
SeniorPainter::base04()一起读。qt21 的paintEvent有大半是 base04 的翻版,真正新增的只有两块:setSpeed()接口 + 镂空指针。把”哪些是复用的、哪些是新增的”分清楚,学习效率会高一倍。
学习前后依赖
| 方向 | 内容 |
|---|---|
| 前置基础 | qt20 绘图基础(强依赖):paintEvent/QPainter/QPen/translate-rotate-scale/save-restore/QPainterPath 布尔运算,尤其是 SeniorPainter::base03/base04 的刻度盘代码;qt11 布局管理(QHBoxLayout/QVBoxLayout/setStretch);qt13 信号槽(lambda 连接)。 |
| 本篇核心 | 把绘图代码封装成可复用控件:私有状态 + 公有 setter + update() 的标准范式;角度映射公式;QPainterPath::subtracted 做镂空指针;控件与宿主窗口的职责分离。 |
| 后续承接 | 所有自绘控件(温度计、进度环、频谱、K 线、示波器)都是这个范式的变体;也是接入 qt19 线程数据源、qt22 数据库数据源做可视化的展示层。 |
💡 Tips:为什么必须先会 qt20?因为本篇不再解释任何绘图 API,
translate/rotate/save/restore会被当成已知常识直接使用。如果 qt20 的”变换是累积的、必须 save/restore 隔离”这一点没吃透,读SpeedWidget::paintEvent的循环时会完全看不懂为什么每次算的是绝对角度。而本篇为哪篇打基础?它给出的”私有数据 + setter + update()“三段式,是之后所有自定义控件(包括接数据库/线程数据的实时图表)的通用骨架。
1. 概述
qt20 教会了我们”怎么用 QPainter 画东西”,但那些代码有个共同问题:它们不是控件,只是一段绘制脚本。SeniorPainter::base04() 能画出漂亮的刻度盘,可你没法在别的项目里 new 一个它、给它设个值、把它塞进布局里——因为它没有对外接口,画的内容也是写死的。
本工程 qt21_tst_speedmeter 完成的正是这个”从脚本到控件”的跨越。它把车速表做成一个独立的 SpeedWidget 类:
SpeedWidget *meter = new SpeedWidget();layout->addWidget(meter); // 能进布局meter->setSpeed(120.5); // 能设数据,画面立刻更新这三行代码背后是自定义控件的完整契约:它像 Qt 自带控件一样可用。使用者不需要知道里面有多少个 rotate、指针是怎么镂空的,只需要调 setSpeed()。
工程结构极简,一共三个源文件:
| 文件 | 职责 |
|---|---|
SpeedWidget.h/.cpp | 控件本体:一个 qreal m_speed 状态 + setSpeed() 接口 + 全部绘制逻辑 |
mainwin.h/.cpp | 宿主窗口:左侧放表盘(占 9 份宽),右侧放输入框 + 按钮(占 1 份),用 lambda 把输入值送给表盘 |
main.cpp | 标准入口 |
值得注意的是:SpeedWidget 完全不知道 QLineEdit 的存在,MainWin 也完全不知道表盘里有几根刻度。这种职责分离不是”写得漂亮”的加分项,而是控件能被复用的前提——同一个 SpeedWidget 明天接串口数据、后天接数据库查询结果,都不需要改一行绘制代码。
💡 Tips:概述里最该记住的一句话——自定义控件的价值不在”画得好看”,而在”接口足够小”。
SpeedWidget的全部对外接口只有一个setSpeed(qreal),这才是它能被到处复用的根本原因。反直觉的点是:很多人做自绘控件时急着加setMaxSpeed()、setTickColor()、setNeedleStyle()一堆配置项,结果类膨胀到 500 行还是不够灵活。先做死一个能用的,再按真实需求逐个放开参数,比一开始就设计”万能控件”务实得多。
2. 核心概念与原理
2.1 自定义控件的三要素
一个合格的自绘控件必须有且只需有三样东西:
class SpeedWidget : public QWidget { Q_OBJECTpublic: void setSpeed(qreal speed); // ② 对外接口:改状态 + 触发重绘protected: void paintEvent(QPaintEvent*) override; // ③ 外观:完全由状态决定private: qreal m_speed = 0; // ① 内部状态:唯一的数据源};- ① 内部状态:控件”当前该显示什么”的全部信息。必须
private,且有默认值(这里= 0,保证控件在还没被 setSpeed 前也能正确绘制)。 - ② 对外接口(setter):固定两句——改状态,然后
update()。少了update(),数据变了画面不动;多做别的事(比如直接画图)就破坏了”绘制只在 paintEvent”的规则。 - ③ paintEvent:纯函数式——只读
m_speed和width()/height(),不修改任何状态,不依赖”上次画了什么”。这保证了无论何时被调用(窗口遮挡后恢复、resize、setSpeed 触发)结果都一致。
这个三段式看起来简单到不值一提,但它是 Qt 全部控件(包括 QSlider、QProgressBar)的内部结构。记住它,你就掌握了自定义控件的 80%。
2.2 坐标归一化:按 200×200 设计,适配任意尺寸
自绘控件最棘手的问题是”窗口大小会变”。硬编码像素(drawLine(180, 200, 195, 200))在 400px 的控件上好看,在 800px 上就缩在角落。解决方案是坐标归一化:
int W = width();int H = height();painter.translate(W/2.0, H/2.0); // ① 原点搬到控件中心painter.scale(W/200.0, H/200.0); // ② 逻辑 200×200 → 实际 W×H// 此后所有坐标都按"一张 200×200、原点在中心、四边到边界各 100"的纸来写painter.drawLine(80, 0, 95, 0); // ③ 距中心 80~95 处的一根横线做完这两步,你就在一个固定尺寸的虚拟画布上作画:
- 坐标范围是
x ∈ [-100, 100]、y ∈ [-100, 100]; 80表示”从中心到边界的 80%“处,控件放大缩小它都在同一相对位置;- 所有尺寸、字号、线宽都自动跟着缩放。
这是自绘控件最重要的一个模式,qt20 的 base03/base04 已经预演过,qt21 原封不动地复用。
2.3 镂空图形 = 大路径减小路径
指针如果只是一个实心三角形,视觉上很”廉价”。真实仪表盘的指针往往是中空的箭头轮廓。手写这个轮廓需要 6~8 个点还要处理拐角,但用路径差集只需两行:
QPainterPath bigNeedle; // 外轮廓:底宽 8、长 80 的细长三角bigNeedle.addPolygon(QPolygonF({QPointF(0,4), QPointF(80,0), QPointF(0,-4)}));QPainterPath smallNeedle; // 内轮廓:稍小一圈的同形三角smallNeedle.addPolygon(QPolygonF({QPointF(4,2), QPointF(60,0), QPointF(4,-2)}));QPainterPath finalNeedle = bigNeedle.subtracted(smallNeedle); // 大减小 = 中空结果是一个”只有边框、内部被挖空”的箭头。这个技巧的通用性极强:镂空箭头、环形进度条(大圆减小圆)、月牙、异形按钮,全是同一招。
💡 Tips:原理层面最容易误解的三点:
setSpeed()里的update()不是可选的礼貌,而是必需的。 删掉它,m_speed会被正确改写,但屏幕上的指针一动不动——直到你偶然拖动窗口触发了重绘,指针才”跳”到新位置。这个现象非常迷惑(“有时候能动有时候不能”),根因就是漏了update()。scale()之后,所有数字的含义都变了。drawLine(80, 0, 95, 0)里的 80 不是 80 像素,是”200 单位画布里的 80 单位”。同理setPointSize(6)也不是 6pt。看到 scale 就要切换思维单位,这是读自绘代码的必备开关。scale(W/200.0, H/200.0)用了两个不同的比例,控件非正方形时会变形。 圆盘会被拉成椭圆、文字会被横向拉伸。qt20 的base03/base04用的是side = qMin(W,H)然后scale(side/200.0, side/200.0)(等比缩放),那才是更稳妥的写法。qt21 这里的差异是本工程最值得注意的一处细节,详见 4.2。
3. 关键机制:一次 setSpeed 到指针转动的完整链路
3.1 数据驱动重绘流程图
3.2 角度映射公式推导
指针角度这一行是整个控件的数学核心:
qreal speedAngle = startAngle + (m_speed / max_speed) * rangeAngle;// 150 + (speed / 240) * 240拆解它:
| 部分 | 含义 | 本工程取值 |
|---|---|---|
startAngle | 表盘起始角(0 刻度所在角度) | 150(左下方) |
rangeAngle | 表盘总跨度 | 240(转 2/3 圈,底部留 120° 缺口) |
max_speed | 满量程 | 240 km/h |
m_speed / max_speed | 归一化比例,把速度映射到 [0,1] | 120/240 = 0.5 |
乘 rangeAngle | 比例 → 角度增量 | 0.5 × 240 = 120° |
加 startAngle | 增量 → 绝对角度 | 150 + 120 = 270° |
270° 顺时针(因为 rotate 顺时针为正)从”三点钟方向为 0°“起算,落在正上方——120 km/h 是满量程的一半,指针指正上,完全符合直觉。
这个公式是所有量程类控件的通用模板:温度计、油量表、进度环、音量旋钮,把 startAngle/rangeAngle/max 三个常量换掉就能直接用。
💡 Tips:流程图里最关键的三个节点:
E判断节点(ok检查):这是本工程唯一的输入校验。它的存在保证了”输入 abc 不会让指针乱跳”,但它选择了静默丢弃——用户不知道自己输错了。工程化改进应该是edt_value->setStyleSheet("border:1px solid red")或弹提示。I节点(update()):整个链路的异步分界线。它上面是数据流(同步、立即完成),下面是渲染流(异步、由事件循环调度)。理解这条线,就理解了”为什么 setSpeed 返回后画面还没变”。M → O的顺序:刻度和指针共用同一个归一化坐标系,但刻度循环内部有自己的 save/restore,所以画完刻度后坐标系回到”已归一化、未旋转”的干净状态,指针才能从rotate(speedAngle)正确起算。如果刻度循环漏了 restore,指针的角度会被累积的旋转污染——这是最难排查的一类 bug。
4. 核心代码解析
以下代码全部摘自 D:\QT_place\QT_study_pjt\qt21_tst_speedmeter\。
4.1 SpeedWidget.h:最小对外接口
class SpeedWidget : public QWidget { Q_OBJECT; // ① 多了个分号public: explicit SpeedWidget(QWidget *parent = nullptr); ~SpeedWidget() override = default; // ② 默认析构足够 void setSpeed(qreal speed); // ③ 唯一的对外接口protected: void paintEvent(QPaintEvent *event) override; // ④ protected + overrideprivate: qreal m_speed = 0; // ⑤ 唯一状态,有默认值};- ①
Q_OBJECT;后面这个分号是多余的。Q_OBJECT展开是一大段声明(含public:访问说明符),末尾已经自带分号。多写一个分号会产生一条空声明语句——C++11 起允许类内出现多余分号,所以 MinGW/GCC 编译通过,但 MSVC 的某些警告等级和 clang-tidy 会提示。规范写法是不加分号。 - ②
~SpeedWidget() override = default;——控件没有裸指针成员,默认析构就够。写override是好习惯(基类QWidget的析构是 virtual)。 - ③
setSpeed不是 slot 也不是信号,就是个普通成员函数。这完全够用——MainWin通过 lambda 直接调用它。如果希望它能被connect到别的信号(比如 qt19 的 worker 信号),把它移到public slots:下即可(Qt5 新语法下普通成员函数也能连,但声明为 slot 更明确表达意图)。 - ④
paintEvent必须是protected(Qt 的设计约定,外部不该调用它)且必须override。漏了override而把参数类型写错(如写成QEvent*)会变成”新函数”而不是重写,编译通过但永远不会被调用——控件一片空白。override关键字能在编译期拦住这个错误。 - ⑤
m_speed = 0的默认值很重要。控件在被show()后、setSpeed()被调用前就会收到第一个paintEvent。如果m_speed未初始化,那次绘制会用随机值算角度,指针指向不确定方向(甚至 NaN 导致绘制异常)。自定义控件的所有状态成员都必须有确定的初值。
4.2 paintEvent 第一段:坐标归一化
void SpeedWidget::paintEvent(QPaintEvent *event){ QPainter painter(this); painter.setRenderHints(QPainter::Antialiasing, QPainter::TextAntialiasing); // ① int W = width(); int H = height(); painter.translate(W/2.0, H/2.0); // ② 原点移到中心 painter.scale(W/200.0, H/200.0); // ③ 归一化,但用了两个不同比例-
①
setRenderHints(hintA, hintB)又是那个误用(详见 qt20 笔记)。签名是setRenderHints(RenderHints hints, bool on = true),第二个参数QPainter::TextAntialiasing的枚举值非 0,被隐式转成true。实际效果 = 只开了Antialiasing,文字抗锯齿没开。 本工程画了 13 处文字(12 个刻度数字 + km/h),所以这个 bug 在这里是能看出来的——数字边缘比开了文字抗锯齿时略毛糙。正确写法:painter.setRenderHints(QPainter::Antialiasing | QPainter::TextAntialiasing);这是本工程最值得动手修的一处。修完前后放大截图对比,能直观感受到文字渲染质量的差别。
-
②
translate(W/2.0, H/2.0)用2.0而不是2保证浮点除法——W/2在 W 为奇数时会丢半个像素,导致图形轻微偏移。这个.0是有意义的。 -
③
scale(W/200.0, H/200.0)是本工程最值得注意的一处设计选择:它用了 X、Y 两个独立的缩放比例。后果是——控件尺寸 sx sy 效果 400×400(正方形) 2.0 2.0 ✅ 圆盘是正圆,文字正常 400×200(扁) 2.0 1.0 ❌ 圆盘被压成椭圆,文字被横向拉宽 200×400(高) 1.0 2.0 ❌ 圆盘被拉成竖椭圆 MainWin里resize(400,420)且表盘占 9/10 宽度,所以启动时表盘区域约 360×420 —— 已经不是正方形,仪表盘实际上是轻微竖向拉伸的椭圆。用鼠标把窗口拖成很扁的形状,会看到明显的变形。对比 qt20 的正确做法:
qreal side = qMin(W, H); // 取短边painter.scale(side/200.0, side/200.0); // 等比缩放,永不变形SeniorPainter::base03/base04用的就是这个等比写法。qt21 反而”退化”了——这提醒我们:代码演进不总是单向变好的,抄自己以前的代码时也要盯住关键行。为什么等比更好?因为仪表盘的语义是”圆盘”,圆变椭圆就失真了。少数情况下(如横向拉伸的条形图)非等比缩放才是想要的。
4.3 paintEvent 第二段:循环画刻度与数字
qreal startAngle = 150; qreal rangeAngle = 240; for (int i = 1; i <= 24; i++) // ① 注意从 1 开始 { painter.save(); // ② 压栈,隔离本次变换 qreal cur = startAngle + (i * (rangeAngle/24.0)); // ③ 绝对角度 painter.rotate(cur); if (i % 2 == 0) { painter.setPen(QPen(Qt::darkRed, 2)); // ④ 直接构造 + setPen,正确写法 painter.drawLine(80, 0, 95, 0); // ⑤ 长刻度 int speed_value = i * 10; // ⑥ 刻度值 painter.translate(70, 0); // ⑦ 移到文字位置 painter.rotate(-cur); // ⑧ 反向转回,文字保持水平 QRect textRect(-15, -10, 30, 20); QFont font = painter.font(); font.setPointSize(6); painter.setFont(font); // ⑨ 改完 set 回去,正确 painter.drawText(textRect, Qt::AlignCenter, QString::number(speed_value)); } else { painter.setPen(QPen(Qt::yellow, 1)); // ⑩ 短刻度用黄色细线 painter.drawLine(80, 0, 90, 0); } painter.restore(); // ② 弹栈 }-
①
for (int i = 1; i <= 24; i++)从 1 开始,这是本工程一个隐含的问题。当 i=1 时cur = 150 + 10 = 160°,也就是说 0 km/h 刻度线(应在 150°)根本没画。对比 qt20 的base04用的是for (int i = 0; i <= rangeIndex; ++i)(从 0 开始,画 25 根,含起点)。后果:表盘最左下角少了一根刻度和”0”这个数字,第一根出现的数字是 i=2 时的”20”。而指针在
m_speed=0时会指向 150° 位置——那里空无一物。运行时能明显看到”指针零位没有刻度”。改成for (int i = 0; i <= 24; i++)即可修复。 -
②
save()/restore()包住整个循环体,保证每次进入循环时坐标系都是”已归一化、未旋转”的干净状态。这样 ③ 才能用绝对角度。如果去掉这一对,第 i 次的实际旋转会是前 i 次cur的累加,刻度瞬间乱成一团——这是本篇最不能删的两行。 -
③
cur = 150 + i * (240/24.0) = 150 + i*10。注意240/24.0写成浮点除法,若写240/24是整数除法得 10(这里恰好整除所以没出错,但换成rangeAngle/25就会因整除丢精度)。角度计算永远用浮点。 -
④
painter.setPen(QPen(Qt::darkRed, 2))—— 直接构造新 pen 并设置,避免了 qt20base03里”改painter.pen()副本忘了 set 回”的经典 bug。这个写法值得学:一步到位,不给自己犯错的机会。 -
⑤⑩ 长刻度
drawLine(80,0,95,0)长 15 单位、粗 2;短刻度drawLine(80,0,90,0)长 10 单位、粗 1。注意两者起点都是 80,所以是”从内向外延伸不同长度”。qt20 的 base04 用的是长刻度(80,0,95,0)、短刻度(85,0,95,0)——“外端对齐、内端不同”。两种风格都常见:外端对齐更整齐(像真实仪表),内端对齐更有层次。 -
⑥
speed_value = i * 10,配合 i 步进 2(只有偶数画数字),所以数字序列是 20、40、60 …… 240,间隔 20。加上缺失的 0,正好是标准车速表标注。 -
⑦⑧ “先 translate 再反向 rotate”是仪表盘文字水平的标准技巧(qt20 已详述)。这里
translate(70, 0)把原点移到距中心 70 单位处(刻度线内侧),然后rotate(-cur)把坐标系转回水平。顺序绝对不能反:先反向转再平移,70 就会沿水平方向走,文字全跑到右边去。 -
⑨
QFont font = painter.font(); font.setPointSize(6); painter.setFont(font);—— 三步齐全。注意painter.font()和painter.pen()一样返回值拷贝,这里正确地 set 回去了。另外setPointSize(6)在scale之后意味着实际渲染字号约6 × (W/200),在 360px 宽时约 10.8pt,视觉上正合适。 -
⑩ 短刻度用
Qt::yellow,长刻度用Qt::darkRed。这是纯审美选择,但在白色默认背景上黄色对比度很低,短刻度几乎看不见。改成深色(如Qt::darkGray)或者给控件铺个深色底会好很多——真实仪表盘几乎都是深底亮字。
4.4 paintEvent 第三段:镂空指针
qreal max_speed = 240; // ① 满量程,与刻度上限一致 painter.save(); qreal speedAngle = startAngle + (m_speed/max_speed) * rangeAngle; // ② 核心公式 painter.rotate(speedAngle); // 镂空效果 QPainterPath bigNeedle; bigNeedle.addPolygon(QPolygonF({QPointF(0,4), QPointF(80,0), QPointF(0,-4)})); // ③ QPainterPath smallNeedle; smallNeedle.addPolygon(QPolygonF({QPointF(4,2), QPointF(60,0), QPointF(4,-2)})); // ④ QPainterPath finalNeedle = bigNeedle.subtracted(smallNeedle); // ⑤ 大减小 painter.setBrush(QColor(255,60,60)); // ⑥ 亮红填充 painter.setPen(QPen(Qt::darkRed, 1)); // ⑦ 深红描边 painter.drawPath(finalNeedle); painter.restore(); // 画单位 painter.save(); QRect textRect(-20, 60, 40, 20); // ⑧ 中心下方 QFont font = painter.font(); font.setPointSize(12); painter.setFont(font); painter.drawText(textRect, Qt::AlignCenter, "km/h"); painter.restore();}-
①
max_speed = 240和刻度的24 × 10 = 240必须一致,否则指针指向和刻度标注对不上。当前是硬编码的两处”240”(一处在循环的i*10,一处在这里),改量程时要同时改两处,很容易漏。工程化改法是提成一个成员常量:static constexpr qreal kMaxSpeed = 240.0;static constexpr int kTickCount = 24;// 刻度值 = i * (kMaxSpeed / kTickCount)这样量程只有一个真相来源。“同一个魔法数字在代码里出现两次”永远是重构信号。
-
② 角度映射公式(推导见 3.2)。注意
m_speed/max_speed没有做钳位:输入 500 会算出150 + 2.08*240 = 650°,指针转过一圈半指到奇怪的位置;输入 -100 会指到 150° 左侧。健壮写法应该m_speed = qBound(0.0, speed, kMaxSpeed);在 setter 里就钳住。 -
③④ 两个三角形的点集设计:
- 大三角
(0,4) → (80,0) → (0,-4):底边在原点处宽 8 单位,尖端伸到 80 单位处。这是一个细长的等腰三角形。 - 小三角
(4,2) → (60,0) → (4,-2):底边在 x=4 处宽 4,尖端到 60。整体比大三角小一圈且缩在里面。 - 关键是小三角必须完全包含在大三角内部,否则
subtracted会切出奇怪的形状。这里 (4,2) 在大三角内、(60,0) 在大三角轴线上且 60<80,满足条件。
- 大三角
-
⑤
subtracted()返回新路径。写成bigNeedle.subtracted(smallNeedle);丢弃返回值,编译通过但指针会是实心的——一个”看起来正常只是不镂空”的静默失败。 -
⑥⑦ 填充亮红
QColor(255,60,60)+ 描边深红。因为路径是镂空的,填充只作用于”大三角减小三角”的环状区域,中间的空洞会透出背景色。这就是镂空效果的视觉来源。如果把setBrush去掉,就只剩内外两圈轮廓线——也是一种风格。 -
⑧
QRect textRect(-20, 60, 40, 20)在归一化坐标里,即”中心下方 60~80 单位处、宽 40 单位”的区域。因为此时坐标系已经restore回未旋转状态,所以文字是水平的、位置固定在表盘下半部。setPointSize(12)在 scale 后约合 21pt,是刻度数字的两倍,视觉层次合理。 -
一个可优化点:
bigNeedle/smallNeedle/finalNeedle每帧都重新构造和做布尔运算。指针形状其实永远不变,只有角度在变,完全可以把finalNeedle缓存成成员变量,构造时算一次。当前实现在 30 FPS 下不会有问题(路径只有 3 个点,运算是微秒级),但如果指针形状复杂(几十个控制点 + 贝塞尔),这就是必须做的优化。
4.5 mainwin.cpp:本工程最值得注意的几处写法
class MainWin : public QWidget // ① 没有 Q_OBJECT 宏{public: explicit MainWin(QWidget *parent = nullptr); ~MainWin() override = default;private: SpeedWidget *m_speed_meter;};MainWin::MainWin(QWidget *parent) // ② parent 参数被接收但没传给基类!{ auto main_layout = new QHBoxLayout(this); auto ctl_layout = new QVBoxLayout(); auto ctl_Widget = new QWidget(this); ctl_Widget->setLayout(ctl_layout); // ③ 手动 setLayout auto btn_start = new QPushButton("速度设置"); // ④ 无 parent,靠 addWidget 收养 auto edt_value = new QLineEdit(); ctl_layout->addWidget(edt_value); ctl_layout->addWidget(btn_start);
m_speed_meter = new SpeedWidget(); // ④ 同上 main_layout->addWidget(m_speed_meter); main_layout->addWidget(ctl_Widget); main_layout->setStretch(0, 9); // ⑤ 表盘 : 控制区 = 9 : 1 main_layout->setStretch(1, 1); resize(400, 420); connect(btn_start, &QPushButton::clicked, this, [this, edt_value]() { // ⑥ bool ok = false; double v = edt_value->text().toDouble(&ok); // ⑦ 检查 ok,好习惯 if (ok) { m_speed_meter->setSpeed(v); // ⑧ 唯一的数据入口 } }); // ⑨ 非法输入静默忽略}这个文件虽然只有 30 行,但藏着本篇最多的”注意点”,逐条说明:
-
①
MainWin缺少Q_OBJECT宏。 后果:- 不能定义自己的信号和槽(moc 不为它生成元对象);
qobject_cast<MainWin*>失效;tr()翻译、属性系统、className()都拿不到正确信息。
那为什么第 ⑥ 行的
connect还能工作? 因为 Qt5 新语法的connect(sender, &Class::signal, context, functor)只要求 sender 有元对象(QPushButton当然有),第三个参数this仅作为”上下文对象”用于生命周期管理,不需要MainWin自己有元对象。所以这里侥幸能跑。但只要哪天想给MainWin加一个signals: void speedChanged(qreal);,就会得到链接错误。任何 QObject 派生类都应该加Q_OBJECT,没有例外。 -
②
MainWin::MainWin(QWidget *parent)接收了parent却没有传给基类——应该写成MainWin::MainWin(QWidget *parent) : QWidget(parent)。当前写法等价于调用QWidget()默认构造,parent参数被完全丢弃。后果:如果有人写
new MainWin(someParent),这个窗口不会成为 someParent 的子控件,不会随父窗口销毁 → 内存泄漏,也不会跟随父窗口显示/隐藏。本工程main.cpp里是MainWin w;(栈对象、无 parent),所以问题没暴露。这是一个”当前用法下无害、但一旦复用就出错”的定时炮弹,而且编译器不会给任何警告(未使用参数的警告通常被关掉)。凡是构造函数签名里有
parent,初始化列表里就必须有Base(parent)——这是 Qt 类的肌肉记忆。 -
③
ctl_Widget->setLayout(ctl_layout):ctl_layout创建时没传 parent(new QVBoxLayout()),所以需要手动setLayout建立关联。对比main_layout = new QHBoxLayout(this)直接在构造时指定了宿主。两种写法都对,但混用容易搞混”这个 layout 到底装在哪”。建议统一用构造传参:new QVBoxLayout(ctl_Widget)。 -
④
new QPushButton("速度设置")、new QLineEdit()、new SpeedWidget()都没有传 parent。这不是 bug——layout->addWidget(w)会自动把 w reparent 到 layout 所属的 widget 上。所以它们最终都挂在对象树上,不会泄漏。但要注意这和 qt19 的
moveToThread场景形成有趣对比:那里”不能有 parent”是硬要求,这里”暂时没有 parent”只是依赖addWidget后续收养。如果你new了一个 widget 却忘了addWidget,它就是真的泄漏了(没 parent 也没 layout 管它)。 -
⑤
setStretch(0, 9)/setStretch(1, 1)让表盘和控制区按 9:1 分配水平空间。在 400px 宽下约 360px 给表盘、40px 给控制区。40px 宽的控制区能放下”速度设置”四个字的按钮吗? 实际上放不下——按钮有最小尺寸约束(
sizeHint),布局会优先满足最小宽度,所以实际比例会偏离 9:1。这也解释了为什么运行时控制区看起来比 1/10 宽。setStretch分配的是”剩余空间”,不是”绝对比例”,这是布局系统一个高频误解点。 -
⑥ lambda 捕获了
this和edt_value两个指针。this用来访问m_speed_meter,edt_value是局部变量所以必须显式捕获(值捕获指针本身)。这里有个潜在的生命周期风险:lambda 持有
edt_value的裸指针,如果edt_value先于btn_start被销毁,点击按钮就是访问野指针。本例中两者都挂在同一对象树上、生命周期一致,所以安全。但通用做法是把edt_value提升为成员变量,或者用第三参数this保证”MainWin 销毁时自动断开连接”(这里已经这么做了,是正确的)。注意
connect的第三个参数this不是可选装饰:如果写成connect(btn_start, &QPushButton::clicked, [this, edt_value](){...})(省略 context),那么MainWin销毁后连接不会自动断开,若btn_start存活更久就会调用已销毁对象的成员 → 崩溃。带 lambda 的 connect 一定要给 context 对象。 -
⑦
toDouble(&ok)检查了ok,这是本文件写得最好的一处。QString::toDouble()在转换失败时返回 0.0,如果不检查,用户输入 “abc” 会让指针归零——一个看起来像 bug 却查不出原因的现象。 -
⑧
setSpeed(v)是MainWin和SpeedWidget之间唯一的耦合点。MainWin不知道表盘里有几根刻度、指针怎么画;SpeedWidget不知道数据来自输入框还是传感器。这种”窄接口”是控件可复用的根本。想接 qt19 的子线程数据,只需connect(worker, &Worker::speedReady, meter, &SpeedWidget::setSpeed);(把setSpeed挪到public slots:即可),一行代码换数据源。 -
⑨ 非法输入被静默忽略——
if(ok)为假时什么都不做,用户看不到任何反馈,会以为”程序卡了”。改进方案:if (ok) {m_speed_meter->setSpeed(qBound(0.0, v, 240.0)); // 顺手钳位edt_value->setStyleSheet("");} else {edt_value->setStyleSheet("border: 1px solid red"); // 视觉反馈}或者直接用
QDoubleValidator从源头限制输入:edt_value->setValidator(new QDoubleValidator(0, 240, 1, edt_value));“校验通过才用、校验失败给反馈”是完整的输入处理,只做前一半是半成品。
💡 Tips:这几段代码里藏着的关键细节,一句话一条:
update()触发重绘——setSpeed里少了它,指针就只在窗口被动重绘时才跳动。save()/restore()包住刻度循环,删掉后所有刻度的旋转会累积叠加,表盘瞬间报废。for i = 1少画了 0 刻度,指针零位处空无一物;改成i = 0修复。scale(W/200.0, H/200.0)非等比,控件非方形时圆盘变椭圆;qt20 的qMin(W,H)等比写法更稳。setRenderHints(A, B)第二参是 bool,文字抗锯齿实际没开;改用A | B。MainWin缺Q_OBJECT、构造函数丢了parent——当前能跑,但都是复用时的定时炮弹。toDouble(&ok)检查了 ok(好),但失败后无反馈(半成品)。- 量程 240 硬编码了两处,改量程容易漏改一处导致指针和刻度对不上。
5. 运行效果与验证
编译运行后应看到:
- 一个 400×420 的窗口。左侧大部分区域是白底的速度表:一圈从左下经正上到右下的刻度(深红长刻度 + 黄色短刻度),长刻度旁标着 20、40、60 …… 240 的数字,表盘下方有 “km/h”。
- 中心有一根镂空的红色箭头指针,初始
m_speed = 0时指向左下 150° 方向。 - 右侧窄条区域有一个输入框和”速度设置”按钮。
- 在输入框输入
120、点按钮 → 指针立即跳到正上方(120 是满量程 240 的一半,150° + 120° = 270°,正上)。 - 输入
240→ 指针跳到右下(150 + 240 = 390° ≡ 30°)。 - 输入
0→ 指针回到左下起点。
逐项验证清单:
| 验证项 | 操作 | 预期 | 说明 |
|---|---|---|---|
| 零刻度缺失 | 观察左下 150° 位置 | 没有刻度线也没有”0” | 因为 for i=1 起。改成 i=0 后会出现 |
| 指针角度对不对 | 输入 120 | 指针正上方 | 验证角度公式;若指向别处说明公式或起始角理解错了 |
| 非等比缩放变形 | 把窗口拖得很扁(如 700×250) | 圆盘明显压成椭圆,文字被横向拉宽 | 验证 scale(W/200,H/200) 的副作用。改成 qMin 等比后不再变形 |
| 尺寸自适应 | 等比放大窗口(400×420 → 800×840) | 所有元素按比例放大,相对位置不变 | 验证归一化设计有效 |
| 非法输入 | 输入 abc 点按钮 | 指针不动,无任何提示 | 验证 if(ok) 生效;同时暴露”无反馈”的体验问题 |
| 超量程 | 输入 500 | 指针转过一圈半,指向奇怪位置 | 验证缺少 qBound 钳位 |
| 负值 | 输入 -50 | 指针指到 150° 左侧(表盘外) | 同上 |
| 空输入 | 直接点按钮 | 指针不动("" 转换失败) | if(ok) 正确拦住了 |
| 镂空效果 | 放大窗口后细看指针 | 箭头内部透出背景色,只有一圈红色轮廓 | 验证 subtracted 生效;若是实心说明丢了返回值 |
| 文字抗锯齿 | 放大到 800px 看数字边缘 | 边缘略有锯齿 | 因 setRenderHints(A,B) 误用。改成 A|B 后明显平滑 |
推荐的对比实验(最有价值的三个):
- 注释掉
setSpeed里的update():输入 120 点按钮,指针不动;然后用鼠标拖动窗口边缘,指针突然跳到 120 位置。这个”延迟生效”的现象能让你永久记住update()的作用。 - 删掉刻度循环里的
save()/restore():表盘变成一团乱麻——因为每次rotate(cur)都叠加在上一次之上,第 24 次的实际角度是所有cur之和(约 3000°)。 - 把
scale(W/200.0, H/200.0)改成qreal s = qMin(W,H)/200.0; painter.scale(s,s);然后拖成扁窗口对比。你会发现等比版本永远是正圆(只是可能没填满控件),非等比版本会变形。这是”要不要填满 vs 要不要保形”的经典权衡。
💡 Tips:验证时最容易误判”成功”的三种假象:
- “指针会动,所以角度公式对了”——指针会动只说明
setSpeed + update通了。公式对不对要用特殊值验证:0 应指起点、满量程应指终点、半量程应指正上。只测一个中间值(比如 87)根本看不出偏差。- “窗口默认大小下表盘是圆的,所以缩放没问题”——
resize(400,420)加上 9:1 布局,表盘区域约 360×420,已经在轻微变形了,只是幅度小看不出。必须主动拖成极端比例才能暴露。这是”默认配置掩盖 bug”的典型。- “输入 abc 指针没乱跳,所以输入校验做好了”——校验只做了一半。没有反馈的校验对用户来说等同于”程序坏了”。判断标准应该是”用户能否知道自己输错了”,而不是”程序有没有崩”。
容易踩坑(注意点)
-
坑 1:
setSpeed()里忘了update()。 现象:数据改了但画面不动;拖动窗口后指针突然跳到新位置,表现为”有时候能动有时候不能”。 原因:m_speed只是内存里的数,Qt 不知道控件需要重绘。 规避:所有自定义控件的 setter 结尾都必须update()。进阶优化:if (qFuzzyCompare(m_speed, speed)) return;先判重,值没变就不重绘。 -
坑 2:循环画多个旋转元素时漏了
save()/restore()。 现象:刻度越画越歪,后面的元素飞到控件外,整个表盘乱成一团。 原因:rotate/translate/scale是累积的,不 restore 就会叠加。 规避:循环体首尾严格配对save()/restore(),像括号一样对齐缩进。复杂场景可写个 RAII 守卫类保证异常安全。 -
坑 3:非等比
scale(W/200, H/200)导致圆盘变形。 现象:窗口不是正方形时,圆盘变椭圆、文字被拉伸。 原因:X、Y 用了不同缩放比例。 规避:保形优先用qreal s = qMin(W,H)/200.0; scale(s,s);(qt20 的做法);若确实要填满非方形区域,再考虑sizeHint()+heightForWidth()强制控件保持方形。 -
坑 4:
for (int i = 1; ...)漏画起始刻度。 现象:指针零位处没有刻度线和”0”标注,表盘左下角有个缺口。 原因:循环从 1 开始,跳过了i=0对应的 150° 位置。 规避:画刻度的循环边界要用”特殊值验证法”检查:手算 i 的首末值对应的角度,确认覆盖了[startAngle, startAngle+rangeAngle]整个区间。这里应是for (int i = 0; i <= 24; i++)。 -
坑 5:指针角度不钳位,超量程/负值时指到表盘外。 现象:输入 500 指针转一圈半,输入 -50 指到刻度区外。 原因:
m_speed/max_speed可以是任意值。 规避:在 setter 里就钳住m_speed = qBound(0.0, speed, kMaxSpeed);。输入校验放在数据入口,而不是绘制阶段——绘制代码应该假设数据已经合法。 -
坑 6:
painter.pen()/font()/brush()改了副本没 set 回去。 现象:明明改了字号/线宽,画面毫无变化。 原因:这些 getter 返回值拷贝。 规避:三步齐全QFont f = painter.font(); f.setPointSize(6); painter.setFont(f);;或直接painter.setPen(QPen(Qt::darkRed, 2))一步到位(本工程刻度部分用的正是这种更安全的写法)。 -
坑 7:
QObject派生类忘了Q_OBJECT宏。 现象:当前能跑(因为只用了 lambda connect),但一旦想加自己的 signal 就链接错误;qobject_cast返回 nullptr。 原因:moc 不为它生成元对象。 规避:任何直接或间接继承 QObject 的类,第一行就写Q_OBJECT(不加分号),不管当前是否用得上。 -
坑 8:构造函数接收了
parent却没传给基类。 现象:new MainWin(parent)创建的窗口不受 parent 管理 → 泄漏、不随父窗口显示隐藏。 原因:MainWin::MainWin(QWidget *parent)后面漏了: QWidget(parent)。 规避:Qt 类的构造函数只要有parent参数,初始化列表就必须有Base(parent)。这条没有例外。 -
坑 9:
setRenderHints(hintA, hintB)当成开两个 hint。 现象:文字边缘发毛,以为是字体问题。 原因:第二参是bool on,第二个 hint 被转成true。 规避:用|组合setRenderHints(A | B)。 -
坑 10:量程等魔法数字在多处硬编码。 现象:改量程时只改了一处,指针指向和刻度标注对不上(比如刻度标到 240 但指针按 300 算)。 原因:
i*10隐含的 240 和max_speed = 240是两个独立的真相。 规避:提成static constexpr常量,所有派生值都从它算出来。
学习要点总结
核心知识点
- 自定义控件三要素:
private状态(有默认值)+publicsetter(改状态 +update())+paintEvent(纯函数式,只依赖状态)。这是 Qt 所有控件的内部结构。 - 坐标归一化模式:
translate到中心 +scale到固定逻辑尺寸(200×200)→ 按虚拟画布设计,自动适配任意控件大小。等比缩放(qMin(W,H))保形,非等比会变形。 - 量程 → 角度的映射公式:
angle = startAngle + (value / maxValue) * rangeAngle。温度计、油量表、进度环、旋钮全都用它,只需换三个常量。 - 循环画重复元素必须
save()/restore(),这样每次可以用绝对角度而不是累积增量,既易读又无累积误差。 - “先 translate 再反向 rotate”让旋转坐标系里的文字保持水平,这是仪表盘绘制最精髓的技巧,顺序不可颠倒。
QPainterPath::subtracted()做镂空:大形状减小形状。通用于镂空箭头、环形进度、月牙、异形按钮。- 窄接口是复用的前提:
SpeedWidget对外只有setSpeed(qreal),所以换任何数据源(输入框/串口/线程/数据库)都不用改绘制代码。
关键 API
| API | 功能 |
|---|---|
paintEvent(QPaintEvent*) | 控件外观的唯一来源 |
update() | setter 里必须调用,请求重绘 |
translate/scale/rotate | 坐标归一化与元素定位 |
save()/restore() | 状态压栈,循环绘制的刚需 |
QPainterPath::addPolygon/subtracted | 构建路径 / 做镂空 |
drawPath/drawLine/drawText | 绘制路径 / 刻度 / 数字 |
QFont::setPointSize | 字号(会被 scale 一起缩放) |
QString::toDouble(bool*) | 输入解析,必须检查 ok |
QBoxLayout::setStretch | 按比例分配剩余空间 |
qBound | 数值钳位,防止超量程 |
下一步学习
- qt22 SQLite 数据库:给仪表盘配上数据持久化——把每次采集的速度存进数据库,再查历史记录回放。
QSqlQuery取出的数据经setSpeed()送进本篇的控件,就是一个完整的”存储 + 可视化”小系统。 - 之后可延伸:
QPropertyAnimation让指针平滑过渡而不是瞬间跳变(配合Q_PROPERTY(qreal speed ...));离屏缓存把静态刻度盘预渲染成QPixmap优化性能;QChart/QCustomPlot做趋势曲线。
💡 Tips:本篇与 qt22 的挂钩点——本篇解决了”数据怎么显示”,qt22 解决”数据从哪来、存到哪去”。两篇合起来就是一个最小可用的数据采集系统:
QSqlQuery查出速度记录 →setSpeed()→update()→ 指针转动。而如果数据源是耗时查询,还要用上 qt19 的 worker 线程避免卡界面。qt19 + qt20 + qt21 + qt22 四篇正好构成一条完整链路:后台采集(线程)→ 数据持久化(SQLite)→ 绘制引擎(QPainter)→ 可复用控件(SpeedWidget)。
Q&A(常见问题)
Q1:setSpeed() 为什么必须调 update()?直接在 setSpeed 里画不行吗?
A1:不行,而且会失败得很彻底。原因有两层:
- 技术上不可能:
QPainter painter(this)只有在paintEvent执行期间才能成功绑定 widget(此时 paint engine 有效)。在setSpeed里构造会得到QPainter::begin: Paint device returned engine == 0,什么都画不出来。 - 逻辑上不该:即使能画,你画上去的内容也会在下一次系统重绘(窗口遮挡恢复、resize)时被完全擦掉——因为系统重绘会调
paintEvent,而它只认m_speed。
正确的心智模型是:paintEvent 是”状态 → 像素”的纯函数,setter 只负责改状态并说一句”我脏了”。
void SpeedWidget::setSpeed(qreal speed) { if (qFuzzyCompare(m_speed, speed)) return; // 可选优化:值没变不重绘 m_speed = speed; // 改状态 update(); // 请求重绘}Q2:save()/restore() 到底在保存什么?不用它行不行?
A2:save() 把 QPainter 的全部当前状态压入栈:变换矩阵(translate/rotate/scale 累积的结果)、Pen、Brush、Font、裁剪区域、渲染提示、opacity 等。restore() 弹出恢复。
不用它行不行?取决于你的循环写法:
// 写法 A:用 save/restore + 绝对角度(本工程,推荐)for (int i = 0; i <= 24; i++) { painter.save(); painter.rotate(150 + i * 10); // 绝对角度,一眼看懂 painter.drawLine(80, 0, 95, 0); painter.restore();}
// 写法 B:不用 save/restore + 增量角度(qt20 base03 的做法)painter.rotate(150); // 先转到起点for (int i = 0; i <= 24; i++) { painter.drawLine(80, 0, 95, 0); painter.rotate(10); // 每次增量 10°}写法 B 也能工作且更省调用,但有两个缺点:① 循环体内如果还有 translate(比如画文字),就会污染下一轮,必须 save/restore;② 浮点累积误差(转 24 次 10.0° 可能不精确等于 240°)。只要循环体内做了多于一次变换,就必须用 save/restore。 本工程偶数分支里有 rotate + translate + rotate,属于必须用的情况。
Q3:QPainterPath::subtracted() 和 intersected()、united() 分别什么时候用?
A3:三个都是路径布尔运算,选择取决于你想要的形状:
| 方法 | 效果 | 典型用途 |
|---|---|---|
a.united(b) | 并集,a 和 b 合并 | 复合轮廓(圆+矩形拼成的图标)、连通区域 |
a.subtracted(b) | 差集,在 a 上挖掉 b | 镂空指针(本篇)、环形进度条(大圆减小圆)、月牙、带孔的形状 |
a.intersected(b) | 交集,只留重叠部分 | 柳叶/透镜形(两圆相交)、把图形裁进某个区域 |
注意三点:
- 都返回新路径,不修改原对象。
a.subtracted(b);丢弃返回值 = 什么都没做。 - 做差集时被减的形状应完全包含在减数内部(或有明确的重叠语义),否则会切出意外形状。
- 有开销(多边形裁剪算法)。形状不变只是角度变时,应该缓存路径:
// 在构造函数里算一次m_needlePath = bigNeedle.subtracted(smallNeedle);// paintEvent 里只做 rotate + drawPathpainter.rotate(speedAngle);painter.drawPath(m_needlePath);Q4:为什么 MainWin 没有 Q_OBJECT 宏也能用 connect?这样有什么隐患?
A4:因为 Qt5 引入的新语法 connect(sender, &Sender::signal, context, functor) 对三个参数的要求不同:
- sender:必须有元对象(
QPushButton有Q_OBJECT,满足); - signal:编译期取成员函数指针,靠 sender 的元对象解析;
- context(第三参
this):只需要是QObject*,用于”context 销毁时自动断开连接”。不需要自己有元对象。
所以 MainWin 缺 Q_OBJECT 在这个特定用法下不会报错。隐患是:
- 无法定义自己的 signals/slots——加一个
signals: void xxx();会得到undefined reference to MainWin::xxx()(moc 没生成实现); qobject_cast<MainWin*>(ptr)永远返回 nullptr;metaObject()->className()返回的是基类名"QWidget"而不是"MainWin",调试和属性系统都受影响;- 无法用
tr()做上下文相关的翻译; - 无法用 Qt 的属性系统(
Q_PROPERTY)和动画框架。
结论:没有任何理由省略 Q_OBJECT。 代价是零(moc 生成的代码量微乎其微),收益是完整的元对象能力。
Q5:怎么让指针平滑转动而不是瞬间跳变? A5:三种方案,从简到繁:
方案一:QPropertyAnimation(最优雅,推荐)
class SpeedWidget : public QWidget { Q_OBJECT Q_PROPERTY(qreal speed READ speed WRITE setSpeed) // 声明为属性public: qreal speed() const { return m_speed; } void setSpeed(qreal s) { m_speed = s; update(); }};
// 使用auto *anim = new QPropertyAnimation(meter, "speed");anim->setDuration(800);anim->setStartValue(meter->speed());anim->setEndValue(120.0);anim->setEasingCurve(QEasingCurve::OutCubic); // 缓出,更像真实仪表anim->start(QAbstractAnimation::DeleteWhenStopped);动画框架会以约 60 FPS 反复调用 setSpeed,每次都 update(),自然形成平滑过渡。注意 Q_PROPERTY 要求类有 Q_OBJECT——这又是一条不能省略它的理由。
方案二:QTimer 手动插值(qt20 Ball 的思路)
// 每 16ms 让 m_speed 向 m_targetSpeed 逼近m_speed += (m_targetSpeed - m_speed) * 0.15; // 指数逼近,天然缓出update();方案三:QVariantAnimation + 自定义插值,适合非线性映射(如指针有超调回摆的物理效果)。
真实汽车仪表盘还会有”回弹”效果(指针略微超过目标再回摆),用 QEasingCurve::OutBack 一行就能模拟。
Q6:Qt5 和 Qt6 在自定义控件上有什么差异需要注意? A6:本工程用 Qt 6.5.3 + MinGW,主要差异:
| 项目 | Qt5 | Qt6 |
|---|---|---|
| 高 DPI 缩放 | 需 QApplication::setAttribute(Qt::AA_EnableHighDpiScaling) | 默认开启,devicePixelRatio() 可能是 1.25/1.5 等小数 |
QPainter::HighQualityAntialiasing | 废弃(等同 Antialiasing) | 移除 |
drawRoundRect() | 废弃 | 移除,用 drawRoundedRect() |
QFontMetrics::width() | 废弃 | 移除,用 horizontalAdvance() |
Qt::MidButton | 废弃别名 | 移除,用 Qt::MiddleButton |
qrand()/qsrand() | 废弃 | 移除,用 QRandomGenerator |
QWidget::enterEvent 参数 | QEvent* | 改为 QEnterEvent* |
对自绘控件影响最大的是高 DPI 默认开启:width() 返回逻辑像素,实际物理像素是 width() * devicePixelRatio()。好消息是本篇的”translate + scale 归一化”写法天然 DPI 无关——因为一切都是相对比例。坏消息是如果你做离屏缓存(画到 QPixmap),必须:
QPixmap pm(size() * devicePixelRatio());pm.setDevicePixelRatio(devicePixelRatio()); // 否则在高 DPI 屏上模糊另外 QEnterEvent 那条会导致 Qt5 代码在 Qt6 下编译通过但 enterEvent 永不被调用(签名不匹配变成了新函数)——这也是为什么 override 关键字必须写。
Tips(全局速记)
最容易忽视的细节
m_speed = 0的默认初值不是可选的。 控件在show()后、setSpeed()前就会收到第一个paintEvent。成员未初始化会用随机值算角度,可能得到 NaN 让整个路径绘制失败(表现为”指针不见了”)。所有控件状态成员都要有确定初值。for (int i = 1; i <= 24; ...)这个1让 0 刻度凭空消失。 循环边界错误在绘图代码里特别隐蔽——因为”少画一根线”不会报错,只会让画面”看起来差点意思”。画刻度的循环写完,一定要手算首末角度对不对。scale(W/200.0, H/200.0)和scale(qMin(W,H)/200.0, ...)的差别在方形窗口下完全看不出来。 这类”默认配置掩盖的 bug”必须靠极端参数测试暴露。养成习惯:自绘控件写完,先把窗口拖成 3:1 和 1:3 各看一次。painter.font()返回值拷贝,和pen()/brush()一样。本工程这里写对了(改完 set 回去),但 qt20 的base03就在pen()上翻了车。Qt 里从对象取出的配置类,改完必须 set 回去。setStretch(0,9)分配的是”剩余空间”而非绝对比例。 子控件的minimumSizeHint优先被满足,所以”速度设置”按钮的最小宽度会挤占表盘空间,实际比例达不到 9:1。想要严格比例得用setFixedWidth或QSizePolicy。connect带 lambda 时第三个参数this是生命周期保险。省略它,MainWin销毁后连接仍存在,若 sender 存活更久就会访问野指针。本工程正确地传了this。
踩坑记录
- “指针有时候能动有时候不能”排查半天,最后是
update()被注释掉了。 现象之所以迷惑,是因为窗口的被动重绘(拖动、遮挡恢复)会掩盖漏掉update()的 bug——你以为程序是间歇性故障,实际是”只有系统重绘时才更新”。遇到”间歇性生效”的绘图问题,第一个怀疑对象永远是update()。 Q_OBJECT;那个多余的分号,MinGW 编过了,换 MSVC 报警告。 这类”某编译器容忍、某编译器不容忍”的写法是跨平台移植的隐形成本。规范写法(不加分号)在所有编译器下都对,没有理由不遵守。MainWin构造函数丢了parent这个 bug,编译器一个字都没说。 因为”未使用的参数”警告默认关闭,而”忘了调基类构造”是合法 C++(会调默认构造)。这类 bug 只能靠 code review 和肌肉记忆防御。 建议在 CODEBUDDY.md / 团队规范里写死一条:“Qt 类构造函数有 parent 参数时,初始化列表必须有 Base(parent)”。- 量程 240 在代码里有两个独立来源(
i*10的隐含 240 和max_speed=240)。 我曾把max_speed改成 300 想扩量程,结果刻度还标到 240、指针却按 300 算,两者错位——查了很久才发现”另一个 240 藏在i*10里”。魔法数字出现第二次,就是提取常量的时候。
与后续章节的衔接
- → qt22 SQLite:本篇的
setSpeed()是可视化入口,qt22 提供数据来源。完整链路是QSqlQuery::next()取出历史速度 →setSpeed()→ 指针转动,就能做”行程回放”。如果查询耗时,还要套上 qt19 的 worker 线程。 - → 回望 qt19 线程:把数据源换成子线程只需一行
connect(worker, &Worker::speedReady, meter, &SpeedWidget::setSpeed);(前提是setSpeed声明为 slot)。跨线程信号自动排队到主线程,update()在主线程调用,完全符合”UI 只能在主线程”的铁律——本篇的窄接口设计让这个切换毫无成本。 - → 回望 qt20 绘图:本篇是 qt20
SeniorPainter::base04()的产品化。对比两者能清楚看到”实验脚本 → 可复用组件”需要补的东西:状态成员、setter、update()、以及把硬编码值参数化。 - → 后续动画与属性系统:给
SpeedWidget加Q_PROPERTY(qreal speed ...)就能接入QPropertyAnimation,指针从”跳变”升级为”平滑转动”。这也是 Qt 属性系统最直观的一个应用场景。 - → 后续性能优化:静态刻度盘每帧重画 24 根线 + 12 段文字是浪费(只有指针在动)。标准优化是把刻度盘预渲染到
QPixmap缓存,paintEvent里drawPixmap+ 画指针。这是所有仪表类控件的标配优化,等你做到 10 个仪表同屏刷新时就必须用上。
设计决策思考
- 为什么把速度表做成独立的
SpeedWidget类,而不是直接在MainWin::paintEvent里画? 因为复用性和可测试性。独立成类后:① 可以在任何窗口、任何布局里放多个实例(做双表盘、四表盘仪表台);② 数据源可以随意替换(输入框/串口/线程/数据库),因为它只认setSpeed;③ 可以单独调试——写个只有一个表盘的main快速迭代外观。如果画在MainWin里,这三点全部丧失。“一个类只画自己”是自绘控件的第一原则。 - 为什么用”归一化到 200×200”而不是直接用像素坐标? 因为它把”设计”和”适配”解耦了。设计时你在一张固定大小的纸上思考(刻度在 80~95、文字在 70、指针长 80),完全不用考虑控件多大;适配交给一行
scale。如果用像素坐标,每个数字都得写成width()*0.4这样的表达式,代码可读性会崩塌,而且改一处比例要改十几个地方。这是”用一次变换换取全局简洁”的经典权衡,代价是必须始终记住”当前数字是逻辑单位不是像素”。 - 为什么指针用
subtracted做镂空,而不是画两个三角形(外面红、里面白)? 因为背景不一定是白色。如果用”里面填白色三角”的假镂空,控件放在深色背景上就会露出一块白斑。真镂空(路径差集)挖出的是透明区域,会正确透出任何背景。这是”看起来一样但本质不同”的两种实现,只有换背景时才暴露差异。做视觉效果时要问一句:“如果周围环境变了,我这个实现还成立吗?” - 为什么
setSpeed不做参数校验而把校验放在MainWin的 lambda 里? 当前工程是这么做的(if(ok)在 MainWin 里),但这个划分其实值得商量。更好的做法是”双层校验”:MainWin负责”字符串能否转成数字”(输入格式校验,属于 UI 职责),SpeedWidget::setSpeed负责”数字是否在量程内”(业务约束校验,属于控件职责,用qBound)。理由是——控件不能假设所有调用者都会先钳位。每个模块都应该对自己的不变式负责,这是防御性编程的基本原则,也是为什么本篇反复强调 setter 里该加qBound。 - 为什么
paintEvent要设计成”每次全量重绘”而不是”增量更新指针”? 增量更新(只擦掉旧指针、画新指针)在理论上更快,但实践中几乎不可行:① 你得精确知道旧指针覆盖了哪些像素(包括抗锯齿的半透明边缘);② 擦除时要还原被覆盖的刻度和背景;③ 一旦有半透明叠加,增量计算就彻底失效。Qt 选择”全量重绘 + 脏区裁剪 + 双缓冲”这条路:你只管重画全部,Qt 用event->rect()裁掉不可见部分、用双缓冲避免闪烁。这是典型的”用简单正确的模型 + 底层优化,替代复杂易错的手工优化”,也是为什么paintEvent必须写成幂等的纯函数。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
评论区
正在加载 GitHub 评论…