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 而不是 QQuickPaintedItemQt 里画自定义图形有两条路Widgets 体系的paintEventQPainter和 Quick 体系的QQuickPaintedItem或者Canvas。这个官方示例选了前者原因很实在——Analog Clock Window 的目标是演示 QPainter 的坐标变换而不是演示渲染性能。QWidget 的绘制是同步的、命令式的你调用painter.drawLine()它立刻就在当前坐标系里画出来学习曲线平缓调试直观。反过来说如果你的表盘需要 60fps 的平滑动画、需要 GPU 加速、需要和粒子效果混合那 Widgets 就吃力了。我实测过纯 QWidget 每秒重绘一次毫无压力但如果把刷新率提到 30fps 以上并且表盘尺寸超过 800x800CPU 占用会明显上升因为 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 的大表盘上差距会拉到几毫秒。如果你的表盘需要高频刷新可以考虑只在指针和刻度上开抗锯齿表盘外圈用普通绘制。不过对于这个每秒刷新一次的示例全局开抗锯齿是最省心的选择。另外还有一个容易被忽略的 hintQPainter::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窗口缩放卡顿每帧重算复杂路径把静态部分缓存为 QPixmap6. 从示例到生产我的改造经验官方示例是教学性质的直接拿去用会碰到几个问题。我在实际项目里改造时主要做了这几件事。第一是把绘制参数抽成配置。表盘半径、刻度长度、指针宽度、颜色方案全部做成成员变量或者配置文件。这样同一个控件类可以复用到不同的界面风格里不用改代码。第二是增加时区支持。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把指针换成进度条把定时器换成数据源驱动。本质上你学到的是一套用坐标变换简化角度绘制的通用方法时钟只是它的一个载体。
阅读完成 · 觉得有帮助?