1. 从 Qt 窗口到表盘:这项目的价值比想象中大
很多人在学 Qt 的时候,都有一个感觉:控件库里的 QWidget、QPushButton 摆弄了一堆,窗口能弹出来了,按钮能响应了,但一到"自己画点什么东西"就卡壳。绘制一个简易时钟恰好是把这个断层补上的最佳练习——它不依赖任何现成控件,整个表盘、刻度、时分秒针全得靠你手写绘制代码完成,而最后跑出来的效果又非常直观:窗口上就是一个走着的表,显示的还是系统当前时间。
当初我自己做这个小项目,本来只是想验证一下 QPainter 的基本用法,结果做下来发现水挺深。时钟看着简单,但它把 Qt 绘图里最核心的几个机制全串起来了:
- 绘图事件:窗口需要刷新的时候,系统会触发 paintEvent,所有绘制动作都得在这里面完成;
- 坐标系统:QPainter 的坐标系怎么平移、旋转,怎么把一个窗口坐标映射到任意位置;
- 定时刷新:秒针每秒动一次,意味着你要用一个 QTimer 定时触发重绘,涉及事件循环和信号槽的配合;
- 自适应缩放:窗口从小拖到大,表盘不能糊不能跑偏,这考验你对长宽比和中心点的处理;
- 反走样与渲染质量:同样一条直线,开不开抗锯齿,画出来的观感完全是两回事。
所以我一直觉得,时钟项目是 Qt 入门之后、涉及自定义控件之前,最值得花一个下午认真做一遍的东西。你能在很短的时间内看到一个完整的、可交互的、有实际功能的界面程序诞生,这种正反馈比对着文档看 API 强太多了。
这篇文章适合谁?一是刚学完 Qt 基础、准备做点小项目练手的初学者;二是想搞清楚 QPainter 坐标变换原理的开发者;三是需要快速实现一个"自定义绘制控件"做原型验证的人。我会从架构设计讲起,然后分步骤写代码、解释每一步背后的原因,最后再把我实际调试中踩过的坑列出来。
2. 时钟的最小架构:只有三个角色
动手写代码之前,先把概念理清楚。很多人一上来就写 paintEvent,把绘制逻辑堆了一大坨,最后窗口一缩放就出问题,根本原因就是没有先把"谁负责什么"想明白。
一个显示系统当前时间的时钟程序,逻辑上可以拆成三个角色:
1. 数据源:负责提供当前时间。在 Qt 里就是QTime::currentTime(),这个调用返回一个 QTime 对象,里面有时、分、秒、毫秒。数据源本身不关心你怎么画,它只负责回答一个问题:现在几点了?
2. 定时器:负责驱动刷新。你要让秒针动,就得每秒触发一次重绘。QTimer在这里扮演的就是一个节拍器,每 1000 毫秒发出一个 timeout 信号,你把这个信号连接到update()上,窗口就会安排一次重绘。
3. 绘图器:负责把"现在是 10 点 15 分 30 秒"翻译成屏幕上的一组线条。这部分的代码写在 paintEvent 里,用 QPainter 完成所有绘制。
为什么要拆这么清楚?因为这三个角色的耦合度必须降到最低。数据源是纯函数的调用,你不希望绘制过程中去阻塞它;定时器只负责触发,它不关心绘制内容是什么;绘图器只根据传入的 QTime 把图形画出来,它也不关心时间是从哪里来的。这样设计的直接好处是:以后你想加一个"时钟面板上同时显示日期"的功能,只需要在绘图器里多画几行字,其他两个角色完全不用动。
另外特别提一句,绘制这块一定要把"逻辑坐标"和"物理坐标"分开想。很多人的误区是直接在窗口的左上角开始画表盘,然后通过一堆魔法数字把指针位置算出来。这种写法很僵硬,窗口稍微一拉伸就会乱套。正确做法是:先把坐标原点移动到窗口中心,再在这个相对坐标系里画所有内容。这一步想通了,后面的缩放适配就水到渠成。
3. 关键机制拆解:QPainter 的坐标变换与重绘流程
整个时钟项目能跑起来的核心,就是 QPainter 的坐标变换体系。不理解这套机制,你就是在死记硬背绘制的 API 调用顺序,能画出静态表盘,但永远写不出灵活的绘图代码。
3.1 从窗口到设备:什么是"逻辑坐标"
QPainter 绘图的坐标叫逻辑坐标。默认情况下,逻辑坐标的原点在窗口客户区的左上角,X 轴向右,Y 轴向下,单位是像素。你调用drawLine(0, 0, 100, 100)画出来的就是从左上角到右下角的斜线。
但实际操作中,几乎不会有人真的在左上角坐标系里画表盘。因为表盘是圆的,你需要的是以圆心为中心、向四周发散的坐标系。于是就有了第一步:
painter.translate(width() / 2, height() / 2);这一行把逻辑坐标原点移到了窗口中心。做完这个操作之后,你再画任何东西的时候,(0, 0) 就是窗口的中心点,X 轴向右,Y 轴向下。
细心的读者可能已经发现问题了:如果窗口不是正方形,比如宽 800 高 600,那么按比例画出来的刻度会变成椭圆,指针长短也会不一致。这就要进入第二步:等比缩放。
3.2 等比例缩放:让表盘永远是圆的
要让表盘在任何窗口尺寸下都保持圆形,一个非常简单的做法是取窗口宽和高的最小值,以此为基准算出缩放系数:
int side = qMin(width(), height()); double scale = side / 200.0; // 假设以 200 为基准半径 painter.scale(scale, scale);这样做的效果是:无论窗口怎么变,X 轴和 Y 轴的缩放系数完全相同,画出来的圆永远不会变成椭圆。同时,因为基准值是 200,你在设计绘制逻辑时,半径、刻度长度、指针长度这些数值都以 200 为参考来设定,逻辑非常清晰。
我之前见过不少实现,不用 scale,直接用 width() 和 height() 各自除以 2 来做坐标换算,画出来表盘拉伸变形,指针长短不一。这种问题的根子就在坐标系上,而不是绘制逻辑本身。
3.3 旋转:从圆心画出一条指针
绘制指针的难点在于角度计算。如果用三角函数去算指针末端的坐标,虽然可行,但代码会很啰嗦,而且每个刻度、每个指针都要算一遍。QPainter 提供了一个优雅得多的方案:rotate()。
假设你要画一条从圆心指向 3 点钟方向、长度为 80 的秒针。最简单的办法是画一条drawLine(0, 0, 80, 0)。注意这条线是水平向右的——这正好对应 0 秒(即 12 点整方向是垂直向上,但我们先把基准定义成 3 点方向也行,看你怎么约定)。如果要让这条线转到 15 秒的位置,只需要在绘制前旋转:
painter.save(); painter.rotate(90.0); // 从 0 秒位置旋转到 15 秒位置 painter.drawLine(0, 0, 80, 0); painter.restore();这里save()和restore()的作用是把旋转之前的绘图状态保存下来,绘制完这根指针后恢复原状,避免旋转状态影响到后面的绘制。这是个极其常用的模式,强烈建议养成习惯。
秒针的角度是time.second() * 6,因为一圈 360 度有 60 秒,每秒 6 度。分针是time.minute() * 6 + time.second() * 0.1——要加上秒针带来的微小偏移,这样分针在 59 秒的时候不会突然跳一格,而是平滑过渡。时针是time.hour() % 12 * 30 + time.minute() * 0.5——每小时 30 度,再加分钟带来的偏移。
3.4 为什么必须调用 update() 而不是直接重绘
我见过很多初学者在定时器槽函数里直接调用painter.drawLine(),以为这样就能画出来,结果什么都没发生。原因在于:QPainter 绘图只能在 paintEvent 里进行。你可以在任何地方请求重绘,但真正执行绘制动作的地方只有 paintEvent。
所以正确的做法是:
QTimer *timer = new QTimer(this); connect(timer, &QTimer::timeout, this, QOverload<>::of(&QWidget::update)); timer->start(1000);update()这个方法很聪明,它会合并连续多次的重绘请求。如果你在一秒内调用了 100 次 update(),Qt 可能会只在下一帧统一重绘一次,不会浪费 CPU。这其实是在画布上传递一个标记,告诉 Qt"这块地方脏了,需要重新画一遍"。只有回到事件循环时,Qt 才会真正派发 paintEvent。这也是时钟项目能稳定运行而不卡顿的重要原因。
4. 完整代码实现:一步步画出会走的表盘
架构想清楚了,代码就顺理成章。我直接给出一个完整的可运行版本,关键位置加上注释,然后逐段解释设计思路。
4.1 头文件:最小化的类声明
// ClockWidget.h #ifndef CLOCKWIDGET_H #define CLOCKWIDGET_H #include <QWidget> class ClockWidget : public QWidget { Q_OBJECT public: explicit ClockWidget(QWidget *parent = nullptr); protected: void paintEvent(QPaintEvent *event) override; private: void drawBackground(QPainter &painter, const QTime &time); void drawClockFace(QPainter &painter); void drawHands(QPainter &painter, const QTime &time); }; #endif // CLOCKWIDGET_H这里我把绘制拆成了三个私有方法,每个方法只专注于一件事。drawBackground画外圈和表盘底色,drawClockFace画刻度,drawHands画时分秒针。这样拆的好处是逻辑清晰,而且后面你想加日期显示或者秒针平滑动画,只需要新增一个方法,不用把 paintEvent 改得面目全非。
4.2 构造函数:初始化定时器
// ClockWidget.cpp #include "ClockWidget.h" #include <QTimer> #include <QPainter> #include <QTime> #include <QtMath> ClockWidget::ClockWidget(QWidget *parent) : QWidget(parent) { setWindowTitle(QStringLiteral("简易时钟")); resize(400, 400); QTimer *timer = new QTimer(this); connect(timer, &QTimer::timeout, this, QOverload<>::of(&QWidget::update)); timer->start(1000); }构造函数里的逻辑很直白:设定窗口大小,创建一个定时器并连接 update(),启动定时。有一个小地方值得注意,QOverload<>::of(&QWidget::update)这种写法是为了明确指定连接到 update() 的无参数版本。因为 QWidget 还有带 QRect 参数的 update 重载版本,编译器在 connect 里可能分不清你要哪个,显式指定更稳妥。
4.3 paintEvent:绘制入口
void ClockWidget::paintEvent(QPaintEvent *event) { Q_UNUSED(event); QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); QTime currentTime = QTime::currentTime(); drawBackground(painter, currentTime); drawClockFace(painter); drawHands(painter, currentTime); }paintEvent 只做三件事:创建 painter、开抗锯齿、获取当前时间并调用三个绘制方法。Q_UNUSED(event)是因为我们这个项目完全不需要用到 paintEvent 传入的 QPaintEvent 参数,但为了避免编译警告,做个标记。
开抗锯齿这行我建议永远放在第一行。没有它,你画出来的直线和圆弧边缘会呈现明显的锯齿,非常影响观感,尤其在高分屏上更是明显。代价只是一点点性能开销,对于时钟这种每秒重绘一次的场景完全不是问题。
4.4 绘制表盘背景
void ClockWidget::drawBackground(QPainter &painter, const QTime &time) { painter.save(); int side = qMin(width(), height()); painter.setViewport((width() - side) / 2, (height() - side) / 2, side, side); painter.setWindow(-120, -120, 240, 240); // 表盘外圆 painter.setPen(QPen(QColor(60, 60, 60), 2)); painter.setBrush(QColor(245, 245, 245)); painter.drawEllipse(QPointF(0, 0), 115, 115); // 表盘内圆 painter.setPen(Qt::NoPen); painter.setBrush(QColor(255, 255, 255)); painter.drawEllipse(QPointF(0, 0), 108, 108); // 中心小圆 painter.setPen(Qt::NoPen); painter.setBrush(QColor(60, 60, 60)); painter.drawEllipse(QPointF(0, 0), 5, 5); // 数字 painter.setPen(QColor(60, 60, 60)); QFont font = painter.font(); font.setPointSize(10); font.setBold(true); painter.setFont(font); for (int i = 1; i <= 12; ++i) { painter.save(); painter.rotate(i * 30.0); painter.drawText(QPointF(0, -85), QString::number(i)); painter.restore(); } painter.restore(); }这里我用了setViewport和setWindow的组合设定坐标系。setWindow(-120, -120, 240, 240)表示逻辑坐标系里 x 从 -120 到 120,y 从 -120 到 120,原点在中心,总共 240 个单位。setViewport则把这个逻辑坐标系映射到窗口中央的方形区域上。
这时候你可能想问:为什么不直接用 translate + scale?因为 setWindow + setViewport 的语义更清晰:它明确规定了逻辑坐标和物理坐标的对应关系。以后代码里你想让表盘半径为 115,就直接用 115,不用担心窗口大小变化。窗口被拖大时,viewport 自动变大,表盘跟着等比例放大,但所有绘制逻辑不用改一个数字。
drawText 在旋转坐标系里画出来的数字方向也会跟着旋转,所以数字 6 会倒过来。如果你希望数字始终正立,需要在绘制数字前临时旋转成水平状态,或者改用数学方式计算每个数字的位置。多数实现会接受数字跟着转,因为从视觉上看,有些表盘设计就是让数字朝向圆心,这样反而更统一。我先给出一种常见的实现,后面再给出那款"所有数字正立"的变体写法。
数字的定位思路:每个数字 i 对应角度i * 30度。把坐标系顺着这个角度旋转,数字就落在垂直方向 -85 的位置,也就是半径 85 处。对 12 个数字循环执行"旋转 + 画字",代码就完成了 12 个数字的定位。不需要任何三角函数——rotate 帮你解决了所有坐标换算。
4.5 绘制刻度线
void ClockWidget::drawClockFace(QPainter &painter) { painter.save(); // 12 个大刻度 for (int i = 0; i < 12; ++i) { painter.save(); painter.rotate(i * 30.0); painter.setPen(QPen(QColor(60, 60, 60), 2)); painter.drawLine(QPointF(0, -96), QPointF(0, -88)); painter.restore(); } // 60 个小刻度 for (int i = 0; i < 60; ++i) { if (i % 5 == 0) continue; // 已经画过大刻度 painter.save(); painter.rotate(i * 6.0); painter.setPen(QPen(QColor(120, 120, 120), 1)); painter.drawLine(QPointF(0, -96), QPointF(0, -92)); painter.restore(); } painter.restore(); }大刻度是 12 个,每个代表 30 度,画在半径 88 到 96 的环形区间内。小刻度 60 个,每个 6 度,但跳过整点位置,画在半径 92 到 96 的区间内。这两层刻度叠加起来,表盘就有了一种"精修过"的质感,而不是光秃秃的圆圈加数字。
一个小细节:大刻度笔宽 2,小刻度笔宽 1,视觉层次更分明。刻度线越靠近圆心越短,这是通过起止点的半径差实现的。这个差值是 8 或 4,像素虽小,但视觉上能明显感受到"刻度是立体地压印在表盘上"的效果。
4.6 绘制时分秒针
void ClockWidget::drawHands(QPainter &painter, const QTime &time) { painter.save(); // 秒针 painter.save(); painter.rotate(time.second() * 6.0); painter.setPen(Qt::NoPen); painter.setBrush(QColor(220, 60, 60)); QPolygonF secondHand; secondHand << QPointF(-3, 12) << QPointF(3, 12) << QPointF(2, -90) << QPointF(0, -98) << QPointF(-2, -90); painter.drawPolygon(secondHand); painter.restore(); // 分针 painter.save(); painter.rotate(time.minute() * 6.0 + time.second() * 0.1); painter.setPen(Qt::NoPen); painter.setBrush(QColor(60, 60, 60)); QPolygonF minuteHand; minuteHand << QPointF(-4, 15) << QPointF(4, 15) << QPointF(3, -72) << QPointF(0, -80) << QPointF(-3, -72); painter.drawPolygon(minuteHand); painter.restore(); // 时针 painter.save(); painter.rotate((time.hour() % 12) * 30.0 + time.minute() * 0.5); painter.setPen(Qt::NoPen); painter.setBrush(QColor(60, 60, 60)); QPolygonF hourHand; hourHand << QPointF(-5, 12) << QPointF(5, 12) << QPointF(4, -52) << QPointF(0, -60) << QPointF(-4, -52); painter.drawPolygon(hourHand); painter.restore(); painter.restore(); }秒针用一个多边形来表示,而不是简单的一根线。多边形的顶点设计是这样的:尾部在圆心附近(12 或 15),尖端在 -98 处,左右两侧有 3 像素的宽度,这样秒针看起来是一个有体积的等腰三角形,而不是一条细线。笔尖稍微凸出到 -98,尾部略微探出圆心 12 像素,形成一种"指针穿过圆心的重心感"。
分针和时针也用了同样的多边形手法,只是尺寸递减。时针最短,只有 60 像素,但尾部更宽(5 像素),这样比例上看起来更敦实。三个指针的旋转角度各有不同,这在前面已经解释过计算逻辑了。
画完整之后,窗口中央再叠加一个小圆心。我们这个实现里把小圆心放在 drawBackground 里画了,这样指针的根部看起来是被小圆心盖住的,视觉上更自然。
4.7 让窗口缩放时表盘自动适配
到这一步编译运行,你已经能看到表盘和指针都动起来了。但还差最后一块拼图:窗口缩放时怎么办?
如果你把 paintEvent 里的 setViewport 和 setWindow 写成固定的 240×240,那么窗口拉宽之后,表盘会被拉伸变形。但因为我们用 setWindow 定义了逻辑坐标到物理坐标的映射关系,Qt 会帮我们做等比例缩放的换算。前提是:物理区域的宽高比要始终等于逻辑区域的宽高比。
在resizeEvent里可以加一个保护:
void ClockWidget::resizeEvent(QResizeEvent *event) { Q_UNUSED(event); // 保持宽高比为 1:1,让表盘一直是正圆 int side = qMin(width(), height()); resize(side, side); }不过要注意,直接调用 resize() 有可能会引起重新触发 resizeEvent,形成循环,所以更稳妥的写法是在 paintEvent 里用side = qMin(width(), height())的方式,保证物理区域永远是正方形,这样 setViewport 里的宽永远等于高,表盘必然是正圆。两种方法取其一,我推荐后面这种,因为不用处理递归 resize 的麻烦。
5. 实测过程中踩过的坑与优化细节
理论讲完、代码跑通之后,才是真正拉开水平差距的地方。这个项目看起来简单,实际调试中我踩过的坑有好几个,每一个都值得单独拿出来讲。
5.1 指针旋转中心与窗口中心不重合
第一次写这个项目的时候,我没用 setWindow 做坐标归一化,直接按窗口像素坐标计算圆心和指针末端。结果窗口大小改变时,指针末端位置计算出现了系统性偏差,秒针转到 3 点钟和 9 点钟方向时长明显不一致。
排查过程:我先在 paintEvent 里画一个十字线把圆心标出来,然后把秒针固定在某一个角度观察。发现偏移量和窗口宽度跟高度之差有关系。最终改成"先归一化到以窗口中心为原点、边长为 side 的逻辑正方形坐标系",问题立刻消失。
这个问题的本质是:计算坐标时必须用同一个参照系。如果用物理坐标算圆心、逻辑坐标算指针,两个坐标系混着来,必然出错。很多人写绘制代码都是这里拿一个 width()/2、那里拿一个 height()/2,最后拼出来的图形歪七八扭,其实就是坐标系没有统一。
5.2 秒针跳动的卡顿感与系统时间的取整
QTimer 的 timeout 信号并不是精确到千分之一秒的。默认情况下,它的精度受限于系统的事件循环调度,可能会差几十毫秒。所以当秒针跳到 59 秒末、准备切换到 0 秒时,如果你直接用QTime::currentTime()的秒数,可能会出现"卡一下"的感觉——因为当前时间已经进入下一秒,但重绘还没有触发。
解决方法有两种思路:
- 第一种,定时器周期设成 1000ms,触发后立刻调用 update()。实际体验久了你会发现偶尔有掉帧,尤其系统负载高的时候。
- 第二种,定时器周期设成 200ms,也就是说每秒强制刷新 5 次。每次重绘读取 currentTime,秒针始终显示当前最新秒数,跳动会更跟手。
我实际测试下来,200ms 的刷新周期对 CPU 占用几乎可忽略,但体感确实更顺畅。如果你还想让秒针平滑扫动而不是一格一格跳,可以把绘制角度改成time.second() + time.msec() / 1000.0再乘以 6 度,这样秒针会连续画出一个扫动的效果。做不加这个,少了一种高级玩法,加上之后视觉档次提升明显。
5.3 默认字体在中文环境下的数字显示差异
drawText 画的数字字体跟随窗口字体设置。在某 Linux 发行版上,默认字体下数字 1 显示得特别窄,整个表盘的数字宽度看着很不协调。后来我强制设置了字体族为常见的无衬线字体,数字显示才恢复整齐。
如果你想让所有平台表现一致,可以考虑直接画出刻度线,而不画数字。很多瑞士表的设计风格就是只有刻度没有数字,视觉上更简洁,也从根本上绕开了字体不一致的问题。但作为练手项目,数字显示这个功能还是值得保留的,调整字体族比想象中简单:
QFont font = painter.font(); font.setFamily(QStringLiteral("Arial")); // 或 "DejaVu Sans" font.setPointSize(10); font.setBold(true); painter.setFont(font);5.4 反走样没有生效
有一次我发现所有直线边缘都有锯齿,排查半天才发现是在初始化 painter 之后忘记调用setRenderHint(QPainter::Antialiasing, true)。这行代码在大多数情况下应该放在 paintEvent 的最前面,因为它是全局性的渲染设置,影响整个绘制过程中的所有线条和填充。
另外还有个细节:如果你在 save() 之后再开启抗锯齿,restore() 之后这个设置会被还原。所以最佳实践就是——在创建 painter 之后立刻开启,避免后面所有 save/restore 的干扰。
5.5 时间获取的成本
QTime::currentTime()的调用开销极低,几微秒级别,对于这种每秒刷新一次的场景完全不用优化。但如果你要做的是一个秒表、计时器,每秒要获取几千次时间戳,那时候就要考虑用系统级的高精度时间接口,而不是 QTime。这里顺带提一句,免得以后踩坑。
6. 功能扩展:数字正立、日期显示与平滑秒针
做完一个基础版本之后,总想加点花样。我在这里分享几个我认为扩展成本低、效果却很明显的改进方向,你也完全可以根据自己的审美去改。
让数字始终正立。前面提到旋转画出的数字会跟着旋转,如果你希望所有数字像印刷在表盘上一样正立,可以在画每个数字前把角度旋转回来:
for (int i = 1; i <= 12; ++i) { painter.save(); painter.rotate(i * 30.0); painter.rotate(-(i * 30.0)); // 把之前旋转的角度抵消 // 但是这样画文字时位置又不对了,因为坐标系转回原样 painter.restore(); }这种做法逻辑上绕,其实更漂亮的方法是不旋转坐标系,而是用三角函数算出数字的中心坐标,在未旋转的坐标系里画正立文字:
for (int i = 1; i <= 12; ++i) { double angle = i * 30.0 * M_PI / 180.0; // 角度转弧度 qreal x = 85 * qSin(angle); qreal y = -85 * qCos(angle); painter.drawText(QPointF(x - 8, y - 8), QString::number(i)); }这里 x、y 是数字在表盘上的坐标,减 8 是粗略地把文本中心对齐到目标点。精确对齐要用到 QFontMetrics 的 boundingRect 方法,但对于 "1" 到 "12" 这种短字符串,偏移几个像素看不出差异。三角函数坐标计算法在原理上更通用,它不依赖坐标系旋转,也更适合后续做"数字跟随窗口大小缩放"之类的功能。
加日期显示。日期显示在表盘底部比较常见。可以在 drawBackground 里加一段:
QDate currentDate = QDate::currentDate(); QString dateString = currentDate.toString(QStringLiteral("yyyy-MM-dd")); painter.drawText(QPointF(-28, 50), dateString);记得在头文件里 include QDate。位置 (‐28, 50) 只是示例值,你可以根据表盘半径调整。如果你用了缩放,这里也应该改成相对值,比如半径的某个比例。
平滑扫动的秒针。这个前面提过,把秒针角度从time.second() * 6.0改成:
qreal secondAngle = (time.second() + time.msec() / 1000.0) * 6.0;这样秒针会在一秒内连续走完 6 度,而不是瞬间跳一格。但是要注意,定时器刷新频率必须高于每秒 1 次,至少 20 帧每秒才能看到平滑效果。把定时器周期改成 50ms 甚至 16ms,CPU 占用也才个位数百分比。这种视觉平滑度提升是非常明显的,做完之后你第一时间就会感受到差异。
7. 整理一下:一个时钟牵出了整个 Qt 绘图体系
如果你按前面的代码一步步实现下来,现在应该已经有一个可以正常显示系统当前时间的简易时钟程序了。它虽然小,但五脏俱全:事件循环驱动、定时器触发重绘、画笔坐标变换、逻辑坐标与物理坐标映射、反走样渲染、自定义绘制方法拆分。这些能力几乎都直接复用到 Qt 里任何需要自定义绘制的场合——仪表盘、波形图、进度环、地图标注、游戏画布,没有一个离得开这套底层机制。
我自己的体会是,做这个项目最大的收获不是"我会画表了",而是终于搞明白了 paintEvent 里那套坐标变换的来龙去脉。以前看别人的自定义控件代码,总觉得 save/restore、rotate、translate 这几个操作像是魔法,现在自己能从头写一遍,再去读任何 QPainter 相关代码都会轻松很多。
如果你还想继续往下延伸,我个人比较推荐的两个方向:一是把 ClockWidget 封装成一个真正的自定义控件,加上setTimeZone、setShowSeconds之类的公开接口,供其他界面调用;二是把绘制输出到 QImage 上导出为图片,这样你能得到一张任意大小的静态表盘图,甚至可以拿来做程序启动画面。这些扩展都能在现有代码上平稳演化,不会牵一发动全身。
最后分享一个小技巧:开发这种绘图界面时,我习惯在 paintEvent 里临时加一段帮助调试的网格线绘制代码——画几条半透明的参考线和中心十字线,定位问题的速度会快很多。调完记得删掉就行。这个习惯帮我省了不少查坐标的时间,建议你有空也试试。