1. 从一个被忽略的官方示例说起
如果你装过 Qt 5.14.2,打开 Qt Creator 的欢迎界面,点进 Examples 搜索框敲下 "Analog",第一个跳出来的大概率就是Analog Clock Window。很多人第一次看到它,心里想的都是"哦,一个画时钟的 Demo,跳过"。我当年也是这么想的,直到有一次需要在一个工业上位机里做实时状态表盘,翻遍了第三方控件库都不满意,回头再啃这个官方例子,才发现它其实是一份被严重低估的QPainter 绘图教学样本。
这个示例做的事情很纯粹:用 QWidget 作为画布,通过 QPainter 的旋转、坐标变换、抗锯齿绘制,把当前系统时间渲染成一个带时针、分针、秒针的模拟表盘,并且用 QTimer 每秒刷新一次。它不依赖 Qt Quick,不依赖任何第三方库,纯 Widgets 体系,代码量不到两百行,却把坐标系统、变换矩阵、定时器驱动重绘、paintEvent 生命周期这几个 Qt 绘图的核心概念全部串了一遍。
它适合谁?如果你正在做仪表盘、时钟控件、自定义进度环、雷达扫描图这类需要手工绘制的界面,这个例子就是最好的起点。哪怕你只是想搞懂painter.translate()和painter.rotate()到底怎么配合,它也值回票价。下面我就按自己实际改造它的思路,把这个例子从头到尾拆一遍,顺带把官方代码里没写透的地方补上。
2. 整体设计与思路拆解
2.1 为什么用 QWidget 而不是 QQuickPaintedItem
Qt 里画自定义图形有两条路:Widgets 体系的paintEvent+QPainter,和 Quick 体系的QQuickPaintedItem或者Canvas。这个官方示例选了前者,原因很实在——Analog Clock Window 的目标是演示 QPainter 的坐标变换,而不是演示渲染性能。QWidget 的绘制是同步的、命令式的,你调用painter.drawLine()它立刻就在当前坐标系里画出来,学习曲线平缓,调试直观。
反过来说,如果你的表盘需要 60fps 的平滑动画、需要 GPU 加速、需要和粒子效果混合,那 Widgets 就吃力了。我实测过,纯 QWidget 每秒重绘一次毫无压力,但如果把刷新率提到 30fps 以上并且表盘尺寸超过 800x800,CPU 占用会明显上升,因为 Widgets 的绘制走的是光栅化后端。所以选型逻辑很清楚:低频刷新、静态或准静态的自定义绘制,用 QWidget;高频、复杂、需要硬件加速的,转 Quick。这个例子属于前者,选型没毛病。
2.2 定时器驱动 vs 事件驱动
时钟的核心需求是"时间变了就重画"。有两种实现思路:一是监听系统时间变化事件,二是用定时器主动轮询。Qt 没有提供跨平台的"系统时间改变"信号,所以只能走定时器。示例里用的是QTimer,间隔 1000ms,每次 timeout 就调用update()触发重绘。
这里有个细节值得说:为什么是 1000ms 而不是更短?因为秒针一秒跳一格,1000ms 刚好对齐。但如果你想要秒针平滑扫动(像机械表那样连续走),就得把间隔降到 50ms 甚至更短,然后在绘制时用毫秒数计算秒针的精确角度。我在一个项目里就这么干过,把QTimer设成 50ms,秒针角度用QTime::currentTime().msec()参与计算,视觉效果立刻从"电子跳秒"变成"机械扫秒"。代价是 CPU 占用翻了几倍,但现代机器完全扛得住。
2.3 坐标系变换的取舍
画表盘最笨的办法是手工算每个刻度、每根指针的端点坐标,用三角函数sin/cos一个个算。示例没有这么干,它用的是"平移原点 + 旋转坐标系 + 画固定长度线段"的思路。具体来说,先把坐标原点从左上角移到表盘中心,然后根据当前时间旋转坐标系,最后在旋转后的坐标系里画一根朝上的固定线段——这根线段自动就指向了正确方向。
这个思路的优雅之处在于:你不需要关心角度和坐标的换算,Qt 的变换矩阵帮你做了。指针的长度、粗细都是常量,只有旋转角度是变量。代码可读性和可维护性都高出一截。代价是每次绘制都要做几次矩阵运算,但这个开销在每秒一次的频率下可以忽略不计。
3. 核心细节解析与实操要点
3.1 paintEvent 里的坐标原点平移
官方代码里最关键的一行是painter.translate(width() / 2, height() / 2)。这一行的作用是把绘图坐标系的原点从控件左上角挪到正中心。为什么必须这么做?因为旋转是绕原点进行的。如果不平移,painter.rotate()会让整个图形绕左上角转,指针会飞出屏幕。
平移之后,所有后续的绘制都以中心为基准。比如画一个半径为side/2的圆,圆心就在(0, 0),而不是(width/2, height/2)。这个思维转换是新手最容易卡住的地方——一旦平移了原点,你脑子里就要切换到"以中心为原点"的坐标系,所有坐标都变成相对于中心的有符号数。
注意:
translate()是累积的,不是覆盖的。如果你在 paintEvent 里连续调用两次translate(),原点会移动两次的距离。所以每次 paintEvent 开始时坐标系都是全新的(Qt 会在绘制前重置),你不用担心上一次的变换残留。
3.2 抗锯齿与渲染提示
示例里有一行painter.setRenderHint(QPainter::Antialiasing)。这行代码决定了表盘边缘是锯齿状还是平滑的。不开抗锯齿,圆和斜线会有明显的阶梯感;开了之后,边缘像素会做混合,视觉上顺滑很多。
但抗锯齿是有代价的。我做过对比测试:在 400x400 的表盘上,开抗锯齿单帧绘制大约多花 0.3ms,肉眼无感;但在 2000x2000 的大表盘上,差距会拉到几毫秒。如果你的表盘需要高频刷新,可以考虑只在指针和刻度上开抗锯齿,表盘外圈用普通绘制。不过对于这个每秒刷新一次的示例,全局开抗锯齿是最省心的选择。
另外还有一个容易被忽略的 hint:QPainter::TextAntialiasing。如果你在表盘上画数字刻度,不开这个文字会有毛边。示例里没画数字,但你自己扩展时记得加上。
3.3 时针、分针、秒针的角度计算
这是整个示例的数学核心。Qt 的QPainter::rotate()接受的是度数,顺时针为正。表盘上 12 点方向是 0 度,3 点方向是 90 度,以此类推。
- 秒针:每秒走 6 度(360/60)。示例用
6.0 * time.second()计算。 - 分针:每分钟走 6 度,但为了平滑,应该加上秒的贡献:
6.0 * (time.minute() + time.second() / 60.0)。 - 时针:每小时走 30 度(360/12),加上分钟的贡献:
30.0 * (time.hour() + time.minute() / 60.0)。
官方示例里时针和分针的计算其实做了简化,没有把下级单位的贡献算进去,所以时针在整点之间是跳变的。如果你想要更真实的机械表效果,一定要把minute()/60.0和second()/60.0加进去。这个改动很小,但视觉真实度提升明显。
3.4 绘制顺序与图层叠加
绘制顺序决定了谁盖住谁。正确的顺序应该是:表盘背景 → 刻度 → 时针 → 分针 → 秒针 → 中心圆点。指针要盖在刻度上,秒针要盖在时针分针上,中心圆点要盖住所有指针的根部,让它们看起来是从同一个轴心伸出来的。
示例里的顺序基本符合这个逻辑。但有个小坑:如果你先画了中心圆点再画指针,指针的根部会盖住圆点,看起来就像指针插在圆点前面,很别扭。所以圆点必须最后画。这个细节在官方代码里是对的,但很多人改造时容易搞反。
4. 实操过程与核心环节实现
4.1 最小可运行版本的搭建
假设你从零开始,在 Qt Creator 里新建一个 Widgets Application,把主窗口类改造成时钟控件。核心代码结构如下:
class ClockWidget : public QWidget { Q_OBJECT public: explicit ClockWidget(QWidget *parent = nullptr) : QWidget(parent) { QTimer *timer = new QTimer(this); connect(timer, &QTimer::timeout, this, QOverload<>::of(&QWidget::update)); timer->start(1000); setWindowTitle("Analog Clock"); resize(400, 400); } protected: void paintEvent(QPaintEvent *) override { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); int side = qMin(width(), height()); painter.translate(width() / 2, height() / 2); painter.scale(side / 200.0, side / 200.0); // 后续绘制... } };这里我加了一个官方示例里没有明确强调的点:painter.scale(side / 200.0, side / 200.0)。这行代码的作用是把绘制逻辑和控件实际尺寸解耦。你可以在一个 200x200 的逻辑坐标系里设计表盘,然后让 scale 自动适配任意大小的窗口。这样窗口拉伸时表盘会等比缩放,而不是被裁切或留白。官方示例用的是固定尺寸假设,实际项目里强烈建议加上 scale。
4.2 表盘刻度的绘制
刻度分两种:12 个粗刻度(整点)和 60 个细刻度(分钟)。绘制思路是循环 60 次,每次旋转 6 度,根据是否是 5 的倍数决定画长线还是短线。
for (int i = 0; i < 60; ++i) { if (i % 5 == 0) { painter.setPen(Qt::black); painter.drawLine(88, 0, 96, 0); // 粗刻度 } else { painter.setPen(Qt::gray); painter.drawLine(92, 0, 96, 0); // 细刻度 } painter.rotate(6.0); }注意这里的坐标:drawLine(88, 0, 96, 0)画的是从(88,0)到(96,0)的水平线段。因为此时坐标系已经旋转过了,这根水平线在视觉上就是沿着半径方向的刻度线。96是表盘外圈半径,88是粗刻度内端,92是细刻度内端。这些数字都是基于 200x200 逻辑坐标系的,配合前面的 scale 就能自适应。
实操心得:刻度的粗细不要用
setPen的宽度来调,而是用线段的长度和颜色来区分。因为在高分屏上,1px 的线宽经过缩放可能变成 0.5px 或者 2px,视觉不一致。用长度区分更稳妥。
4.3 三根指针的绘制
指针的绘制是示例的精华所在。以时针为例:
painter.setPen(Qt::NoPen); painter.setBrush(Qt::black); painter.save(); painter.rotate(30.0 * ((time.hour() + time.minute() / 60.0))); painter.drawConvexPolygon(hourHand, 3); painter.restore();这里用了一个QPolygon来定义时针的形状——一个细长的三角形。drawConvexPolygon比drawLine好在可以画出有宽度的指针,而且能填充颜色。save()和restore()成对出现,保证旋转只影响当前指针,不会污染后续绘制。
分针和秒针同理,只是角度公式和颜色不同。秒针通常用红色,细一点,视觉上更醒目。我个人的习惯是给秒针加一个尾部配重(往反方向延伸一小段),看起来更像真实钟表。
4.4 平滑扫秒的实现
前面提到过,想要秒针平滑扫动,需要把定时器间隔调小,并且用毫秒参与角度计算:
QTime time = QTime::currentTime(); qreal seconds = time.second() + time.msec() / 1000.0; painter.rotate(6.0 * seconds);同时把timer->start(1000)改成timer->start(50)。这样秒针每 50ms 重绘一次,角度连续变化,视觉上就是平滑扫动。实测在普通办公电脑上,50ms 间隔的 CPU 占用大约在 1% 到 2% 之间,完全可以接受。
但要注意:update()是异步的,它只是标记控件需要重绘,实际绘制发生在事件循环的下一次迭代。如果定时器间隔太短(比如 10ms),可能会出现重绘请求堆积的情况。Qt 对此有优化,会合并重复的 update 请求,所以实际不会堆积,但也没必要设得太短。
5. 常见问题与排查技巧实录
5.1 表盘变形问题
现象:窗口拉成宽扁形时,表盘变成椭圆。
原因:绘制时用了width()和height()分别作为横纵半径,没有取最小值。
解决:用int side = qMin(width(), height())取短边,然后painter.scale(side / 200.0, side / 200.0)等比缩放。这样无论窗口什么比例,表盘都是正圆,多余的空间留白。
5.2 指针抖动或错位
现象:秒针偶尔跳两格,或者指针指向和实际时间对不上。
原因:通常是角度计算公式里漏了进位。比如分针只用了minute()没加second()/60.0,导致分针在整分钟内不动,到下一分钟突然跳 6 度。
解决:检查三个角度公式,确保下级单位都参与了计算。另外确认QTime::currentTime()是在 paintEvent 里调用的,而不是在构造函数里调用一次就存起来——那样时间永远不会更新。
5.3 高分屏下模糊
现象:在 4K 屏或者缩放 150% 的屏幕上,表盘边缘发虚。
原因:Qt 的高 DPI 缩放策略和绘制逻辑冲突。
解决:在main()里设置QApplication::setAttribute(Qt::AA_EnableHighDpiScaling),并且确保绘制时用的是逻辑坐标而不是物理像素。如果还是模糊,可以在 paintEvent 里用devicePixelRatioF()手动调整绘制精度。Qt 5.14 对高 DPI 的支持已经比较完善,大部分情况下开个属性就够了。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 表盘不显示 | paintEvent 未触发 | 检查 update() 是否被调用,定时器是否启动 |
| 指针全部指向 12 点 | 角度计算返回 0 | 检查 QTime 取值和乘法因子 |
| 绘制内容偏移 | translate 参数错误 | 确认平移量是 width/2 和 height/2 |
| 旋转中心不对 | 未先 translate 就 rotate | 旋转必须发生在平移之后 |
| 内存持续增长 | 定时器重复创建 | 确保 QTimer 只 new 一次,parent 设为 this |
| 窗口缩放卡顿 | 每帧重算复杂路径 | 把静态部分缓存为 QPixmap |
6. 从示例到生产:我的改造经验
官方示例是教学性质的,直接拿去用会碰到几个问题。我在实际项目里改造时,主要做了这几件事。
第一是把绘制参数抽成配置。表盘半径、刻度长度、指针宽度、颜色方案,全部做成成员变量或者配置文件。这样同一个控件类可以复用到不同的界面风格里,不用改代码。
第二是增加时区支持。QTime::currentTime()拿的是本地时间,如果要做世界时钟,得用QDateTime::currentDateTime().toTimeZone()转换。Qt 5.14 的QTimeZone类已经很好用了,配合QDateTime可以轻松做出多时区表盘。
第三是缓存静态图层。表盘背景和刻度是不变的,只有指针在动。可以把背景和刻度预先画到一个QPixmap里,每次 paintEvent 只画指针,然后drawPixmap贴背景。这样重绘开销能降低一半以上。我实测过,在 800x800 的表盘上,缓存后单帧绘制时间从 1.2ms 降到 0.4ms。
第四是处理窗口最小化时的定时器。窗口最小化时其实不需要刷新,可以监听QEvent::WindowStateChange,在最小化时暂停定时器,恢复时重启。这个优化在长时间运行的监控软件里很有意义,能省不少电。
最后分享一个小技巧:如果你想让表盘看起来更有质感,可以在外圈加一个径向渐变(
QRadialGradient),从中心到边缘颜色略微变深,模拟金属表壳的反光效果。再在指针下面加一层淡淡的阴影(用半透明黑色偏移绘制一次),立体感立刻就出来了。这些都不需要额外库,QPainter 原生支持。
这个示例后续还可以这样扩展:把表盘换成仪表盘(刻度范围 0-100 而不是 0-12),把指针换成进度条,把定时器换成数据源驱动。本质上,你学到的是一套用坐标变换简化角度绘制的通用方法,时钟只是它的一个载体。