
1. 从一个被忽略的官方示例说起很多人装完 Qt 5.14.2打开 Qt Creator 的 Welcome 页面看到 Examples 列表里躺着一个叫 Analog Clock Window 的小项目点进去扫两眼觉得不过是个画时钟的玩具随手就关了。我一开始也是这么想的。直到有一次我需要在一个工业上位机项目里做一个带指针的仪表盘控件翻遍了第三方库都觉得太重才重新想起这个被冷落的官方示例。仔细读完它的源码之后我发现这个看似简单的时钟窗口实际上把 Qt 自定义绘制、定时器驱动、窗口无边框拖拽、坐标系变换这几个核心知识点串成了一条完整的线。它不是一个玩具它是一份浓缩了 Qt 图形绘制精髓的教学样本。这篇文章就是围绕 Qt 5.14.2 自带的 Analog Clock Window 示例展开的。我会把这个示例的每一层代码拆开来讲清楚告诉你它为什么这样写、每一步在做什么、背后的 Qt 机制是什么以及在实际项目中怎么把这些技术点用起来。不管你是刚接触 Qt 的新手还是已经写过一些自定义控件的开发者相信都能从中挖到有用的东西。尤其是那些想搞明白 QPainter 绘制流程、QTimer 刷新机制、以及无边框窗口交互的朋友这个示例值得你花时间认真读一遍。2. Analog Clock Window 到底包含哪些文件与类结构2.1 项目文件清单与构建方式在 Qt 5.14.2 的安装目录下这个示例位于Examples/Qt-5.14.2/widgets/widgets/analogclock路径中。整个项目非常精简核心文件只有三个main.cpp、analogclock.h、analogclock.cpp外加一个.pro文件。没有.ui文件没有资源文件没有多余的依赖。这种极简的文件组织本身就传递了一个信号这个示例的重点在于纯代码实现而不是依赖设计器拖拽。.pro文件里只有两行有效内容QT widgets和TARGET analogclock。这意味着它只依赖 QtWidgets 模块不需要 QtGui 之外的任何额外模块。构建方式也很直接用 qmake 生成 Makefile 然后 make或者在 Qt Creator 里直接点运行。我建议你第一次跑的时候用 qmake 命令行方式编译一遍这样能看清楚编译链接的完整过程对理解 Qt 项目的构建流程有帮助。2.2 AnalogClock 类的继承关系与职责划分AnalogClock类继承自QWidget这是一个非常经典的选择。它没有继承QFrame或QAbstractButton因为时钟窗口不需要边框样式也不需要按钮的点击语义。继承QWidget意味着它拿到了所有基础窗口能力接收绘制事件、接收鼠标事件、接收定时器事件、管理窗口标志位。这个类的职责非常清晰维护一个QTimer对象来驱动刷新重写paintEvent来完成所有绘制逻辑重写mousePressEvent和mouseMoveEvent来实现窗口拖拽。没有多余的方法没有过度设计。这种一个类只做一件事的风格在 Qt 官方示例里很常见也值得我们自己在写自定义控件时借鉴。头文件里只有三个成员一个QTimer指针或者值对象取决于版本一个QPoint用来记录拖拽起始位置。等等实际上在 Qt 5.14.2 的版本里QTimer是作为值成员存在的不是指针。这个细节后面讲定时器的时候会展开说。2.3 main 函数里做了什么main.cpp的内容短到可以背下来创建QApplication创建AnalogClock实例调用show()然后进入事件循环。但这里有一个容易被忽略的细节——窗口标志位的设置。在 Qt 5.14.2 的示例中main函数里并没有显式设置Qt::FramelessWindowHint但窗口确实是无边框的。这是怎么回事实际上在部分平台的默认行为下QWidget作为顶层窗口时会自带系统边框。但 Analog Clock Window 示例在不同平台上的表现略有差异。如果你在 Windows 上跑可能会看到标题栏如果你想要完全无边框的效果需要在构造函数里加上setWindowFlags(Qt::FramelessWindowHint)。这个点我在实际使用中踩过坑后面会专门讲。3. paintEvent 里的绘制逻辑从坐标原点到指针角度3.1 坐标系平移为什么第一步是 translate打开analogclock.cpppaintEvent函数的第一段有效代码是计算窗口的最小边长然后调用painter.translate(width() / 2, height() / 2)。这一步是整个绘制逻辑的基石。默认情况下QPainter 的坐标原点在控件的左上角x 轴向右y 轴向下。但时钟的圆心应该在控件正中央所有指针的角度计算都应该以圆心为原点。所以第一步必须把坐标系原点搬到控件中心。这里有一个细节值得注意代码里用的是width() / 2和height() / 2这是整数除法。如果控件宽高是奇数比如 201 像素那201 / 2 100原点会偏左一像素。对于时钟这种对称图形来说一像素的偏移肉眼几乎看不出来但如果你在做精密仪表盘建议用qreal版本的计算或者用QRectF来获取精确的中心点。我自己在做工业仪表控件时就因为这个问题导致指针在特定尺寸下看起来有点歪排查了半天才发现是整数除法惹的祸。平移之后代码计算了一个side变量取宽高中较小的那个值然后用它来确定时钟的半径。这个逻辑保证了无论窗口是横向拉长还是纵向拉长时钟始终是一个正圆不会变成椭圆。这个思路在 responsive 设计中非常实用。3.2 抗锯齿与画笔设置绘制质量的关键接下来几行代码设置了QPainter::Antialiasing渲染提示然后创建了三个QPen对象分别用于时针、分针和秒针。抗锯齿这一行看似不起眼但它是决定绘制质量的关键。不开抗锯齿的话圆和斜线会有明显的锯齿感看起来非常粗糙。开了之后边缘会平滑很多。代价是渲染性能会略微下降但对于一个时钟控件来说这点开销完全可以忽略。三个画笔的设置各有讲究。时针的画笔宽度是side / 20分针是side / 30秒针是side / 40。这个比例关系不是随便定的。时针最粗因为它代表小时视觉权重最高分针次之秒针最细因为它变化最快太粗的话会让整个表盘看起来很闹腾。这种视觉层次的设计思路在做任何仪表盘或数据可视化控件时都适用。秒针的颜色被单独设置成了红色或者某个高对比度颜色这是为了让它从时针和分针中脱颖而出。在实际项目中你可以根据品牌色或者主题色来调整这个颜色但原则是一样的变化频率最高的元素应该用最醒目的颜色但面积要小。3.3 表盘刻度的绘制循环与旋转的配合绘制完指针之后代码进入表盘刻度的绘制。这里用了一个for循环从 0 到 12每次循环画一条刻度线。关键操作是painter.rotate(30)因为 360 度除以 12 等于 30 度每画完一条刻度就旋转 30 度这样 12 条刻度就均匀分布了。但这里有一个容易搞混的地方rotate是累积的不是设置绝对角度。也就是说第一次循环时坐标系旋转了 30 度第二次循环时又旋转了 30 度累计 60 度以此类推。所以循环结束时坐标系已经旋转了 360 度回到了初始状态。这个特性在绘制对称图形时非常方便但如果你在循环中间插入其他绘制操作要记得坐标系已经被旋转过了。刻度的长度和位置也有讲究。代码里画的是从(side / 2 - 4, 0)到(side / 2, 0)的短线也就是从靠近边缘的位置向内画一小段。这个长度和位置是经过视觉调校的太长会显得拥挤太短又看不清。如果你要改成分钟刻度60 条需要把循环次数改成 60旋转角度改成 6 度同时把刻度线画得更短更细否则表盘会糊成一团。3.4 指针角度计算三角函数与时间的关系指针的角度计算是这个示例里最核心的数学部分。以时针为例代码先获取当前时间的小时数然后计算角度。公式是(hour minute / 60.0) * 30。这里为什么要加上minute / 60.0因为时针不是整点跳变的它在每小时之间是连续移动的。比如 3 点 30 分的时候时针应该指向 3 和 4 的正中间也就是 105 度而不是死死地指向 3 点位置的 90 度。这个细节决定了时钟看起来是活的还是死的。分针的角度计算类似(minute second / 60.0) * 6。秒针则是second * 6因为秒针是每秒跳一下的不需要连续插值。但如果你想要更平滑的秒针效果可以把定时器间隔设成 100 毫秒甚至更短然后用毫秒数来计算秒针角度这样秒针就会像高端机械表一样扫动而不是一格一格地跳。角度算出来之后通过painter.rotate()旋转坐标系然后画一条从原点出发的线。这里用到了QPainter::drawLine的重载版本传入四个整数坐标。线的终点坐标是(0, -length)负号是因为 Qt 的 y 轴向下负值表示向上。这个坐标系方向的问题初学者很容易搞反记住y 轴向下为正就不会错了。4. 定时器驱动刷新QTimer 的用法与性能考量4.1 定时器间隔为什么是 1000 毫秒在AnalogClock的构造函数里创建了一个QTimer设置间隔为 1000 毫秒然后连接timeout信号到update()槽最后调用start()。1000 毫秒就是 1 秒意味着时钟每秒刷新一次。这个间隔对于秒针跳动的时钟来说刚刚好既不会太频繁导致 CPU 占用过高也不会太慢导致秒针看起来卡顿。但这里有一个隐藏的问题如果定时器恰好在秒数变化的前几毫秒触发那秒针可能会延迟将近一秒才更新。比如定时器在 12:00:00.999 触发下一次触发在 12:00:01.999但秒针在 12:00:01.000 就应该指向 1 秒的位置了实际上却要等到 12:00:01.999 才更新。这个误差最大可以达到接近 1 秒。对于普通时钟来说无所谓但如果你在做计时器或者需要精确同步的应用就不能用这种简单的固定间隔定时器。解决方案是用QTimer::singleShot配合动态计算的间隔每次刷新后计算距离下一秒还有多少毫秒然后设置下一次定时器的间隔。这样可以把误差控制在几毫秒以内。我在做一个比赛计时系统时就用过这个方案效果很好。4.2 update() 与 repaint() 的区别代码里连接的是update()而不是repaint()。这两个方法的区别值得说清楚。update()是异步的它只是向 Qt 的事件循环投递一个绘制请求实际绘制会在事件循环空闲时进行。如果短时间内多次调用update()Qt 会合并这些请求只触发一次paintEvent。repaint()是同步的调用后会立即触发paintEvent不管事件循环在干什么。对于时钟这种每秒刷新一次的控件用update()完全够用而且更安全。用repaint()的话如果在paintEvent里不小心又触发了repaint()就会造成无限递归程序直接卡死。这个坑我见过不止一个新手踩过。4.3 定时器对象的生命周期管理在 Qt 5.14.2 的示例中QTimer是作为AnalogClock的值成员存在的不是指针。这意味着它随着AnalogClock对象的构造而构造随着析构而析构不需要手动delete。这是 Qt 里推荐的做法能用值成员就用值成员避免不必要的堆分配和内存管理负担。如果你用指针来创建QTimer记得把AnalogClock作为父对象传进去这样 Qt 的父子对象机制会自动管理内存。但即便如此值成员仍然是更简洁的选择。我在 review 别人的代码时经常看到QTimer *timer new QTimer(this)这种写法虽然没错但如果能用值成员代码会更干净。5. 无边框窗口的拖拽实现mousePressEvent 与 mouseMoveEvent5.1 记录拖拽起始位置的正确姿势mousePressEvent里做的事情很简单如果按的是左键就把当前鼠标位置相对于窗口存到m_dragPosition成员变量里。这里用的是event-pos()返回的是相对于控件左上角的坐标。这个坐标在后面计算窗口新位置时会用到。但这里有一个容易忽略的点event-pos()在窗口移动之后会变化。因为窗口移动了鼠标相对于窗口的位置就变了。所以必须在按下时记录移动时用记录的值来计算偏移量。这个逻辑和拖拽文件图标是一样的按下时记住鼠标在图标上的哪个位置移动时保持这个相对位置不变。5.2 计算窗口新位置的公式推导mouseMoveEvent里的计算稍微绕一点。代码先判断是否按下了左键然后计算event-globalPos()减去m_dragPosition得到新的窗口左上角位置最后调用move()移动窗口。为什么是globalPos减去dragPosition我们来推导一下。假设按下时鼠标在窗口内的位置是(dx, dy)此时窗口左上角在屏幕上的位置是(wx, wy)那么鼠标在屏幕上的位置就是(wx dx, wy dy)。移动鼠标后鼠标在屏幕上的新位置是(gx, gy)。我们希望窗口移动后鼠标相对于窗口的位置仍然是(dx, dy)所以新的窗口左上角位置应该是(gx - dx, gy - dy)。这就是公式的来源。这个公式看起来简单但如果你把globalPos和pos搞混了窗口就会乱跳。我见过有人用event-pos()来计算结果窗口移动的速度是鼠标的两倍完全没法用。5.3 跨平台差异与高 DPI 屏幕的适配在 Qt 5.14.2 中globalPos()返回的是逻辑坐标不是物理像素。在高 DPI 屏幕上逻辑坐标和物理像素之间有一个缩放系数。如果你在 4K 屏幕上跑这个示例可能会发现拖拽的手感有点不对劲窗口移动的距离和鼠标移动的距离不匹配。这是因为 Qt 的高 DPI 缩放机制在起作用。解决方法是确保QApplication的高 DPI 缩放属性设置正确。在main函数里加上QApplication::setAttribute(Qt::AA_EnableHighDpiScaling)可以启用自动缩放。但要注意这个属性必须在创建QApplication对象之前设置否则不生效。这个坑我在一个跨平台项目里踩过Windows 上看起来正常到了高 DPI 的 Linux 桌面上就出问题了排查了很久才发现是缩放属性没设对。6. 把这个示例用在实际项目里改造思路与扩展方向6.1 从时钟到通用仪表盘控件Analog Clock Window 的绘制框架可以直接改造成通用的仪表盘控件。核心改动点有三个第一把时间驱动的角度计算换成数据驱动的角度计算比如传入一个 0 到 100 的数值映射到 0 到 270 度的指针角度第二把表盘刻度从 12 条改成你需要的刻度数和标签第三增加一个setValue方法在数值变化时调用update()触发重绘。这个改造我在一个环境监测项目里做过用来显示温度、湿度、PM2.5 等指标。每个指标一个仪表盘共用同一个基类只是刻度和颜色不同。代码量不大但效果比用第三方图表库好很多因为完全可控想怎么调就怎么调。6.2 性能优化局部刷新与缓存当时钟窗口很小的时候每秒全量重绘一次没什么问题。但如果你的仪表盘控件很大或者屏幕上有几十个这样的控件同时刷新全量重绘就会成为性能瓶颈。优化的思路是用QPainter的裁剪区域只重绘指针扫过的区域而不是整个控件。另一个思路是把静态部分表盘、刻度、标签缓存成QPixmap每次paintEvent只需要绘制这个缓存图片然后再画指针。这样可以把复杂的刻度绘制逻辑从每帧的绘制流程中剥离出来只在控件尺寸变化时重新生成缓存。这个技巧在绘制复杂背景时特别有效我在一个数据大屏项目里用这个方案把 CPU 占用从 30% 降到了 5% 以下。6.3 样式定制颜色、字体与主题切换官方示例的配色比较朴素实际项目中通常需要根据 UI 设计稿来定制。时针、分针、秒针的颜色和宽度都可以做成可配置的属性通过Q_PROPERTY宏暴露出来这样就能在 Qt Designer 里直接编辑或者用 QSS 来设置。表盘上的数字标签也可以用QPainter::drawText来绘制配合QFont设置字体和大小。如果你需要支持深色模式和浅色模式切换可以重写changeEvent方法监听QEvent::PaletteChange事件然后根据当前调色板来调整绘制颜色。这个做法比硬编码颜色要灵活得多。7. 几个我实际踩过的坑和对应的解决方案7.1 窗口闪烁问题在某些 Windows 环境下无边框窗口在拖拽时会出现闪烁。这是因为窗口移动和重绘不同步导致的。解决方法是在构造函数里设置setAttribute(Qt::WA_OpaquePaintEvent)和setAttribute(Qt::WA_NoSystemBackground)告诉 Qt 这个窗口是不透明的不需要绘制背景。这样可以减少一层背景绘制闪烁问题基本就消失了。另一个可能的原因是paintEvent里没有清空背景。如果你重写了paintEvent但没有调用QPainter::fillRect或者eraseRect上一帧的内容可能会残留。不过对于时钟来说因为每次都是全量重绘这个问题不明显。但如果你改成了局部刷新就一定要注意先擦除旧内容。7.2 高刷新率屏幕上的定时器漂移在高刷新率显示器上比如 144HzQTimer 的精度可能会受到影响。虽然 1000 毫秒的间隔看起来很长不应该有问题但实际上如果系统负载高定时器可能会延迟触发。我遇到过在虚拟机里跑这个示例秒针偶尔会跳两格的情况。解决方法是用QElapsedTimer来测量实际经过的时间然后根据实际时间来计算指针角度而不是假设定时器一定准时。这样即使定时器有延迟指针的位置仍然是准确的。7.3 多屏幕环境下的坐标问题当窗口从一个屏幕拖到另一个屏幕时globalPos()返回的坐标是相对于整个虚拟桌面的不是相对于单个屏幕的。这在大多数情况下没问题但如果两个屏幕的 DPI 缩放比例不同窗口在跨越屏幕边界时可能会突然变大或变小。Qt 5.14.2 对多屏幕 DPI 的处理还不够完善这个问题在 Qt 6 里才得到比较好的解决。如果你必须在 Qt 5 里处理这个场景建议监听QWindow::screenChanged信号在屏幕变化时手动调整窗口大小和位置。7.4 内存泄漏的隐蔽来源虽然这个示例本身没有内存泄漏但如果你在改造过程中动态创建了QPainter对象或者QPixmap对象记得及时释放。特别是QPixmap它占用的显存资源不会自动回收必须手动delete或者用智能指针管理。我在一个项目里因为忘记释放缓存的QPixmap导致程序运行几个小时后内存暴涨最后用 Valgrind 才定位到问题。8. 从这个小示例延伸出去的学习路径Analog Clock Window 虽然简单但它涉及的知识点可以往很多方向延伸。如果你对自定义绘制感兴趣可以接着看 Qt 的另一个示例Affine Transformations那个示例把坐标变换玩得更透彻。如果你对定时器和动画感兴趣可以看Animated Tiles示例它展示了如何用QPropertyAnimation做平滑动画。如果你对无边框窗口的交互感兴趣可以研究Frameless Window示例它实现了窗口的拖拽、缩放、阴影等完整功能。我个人的建议是不要只读代码一定要动手改。把时钟改成倒计时把指针改成进度条把表盘改成圆形仪表每改一次你都会对 Qt 的绘制机制有新的理解。这个示例的价值不在于它本身有多复杂而在于它提供了一个最小可用的框架让你可以在这个框架上自由实验。我在带新人的时候通常会让他们先把这个示例跑起来然后提三个需求改成 24 小时制、加上数字时间显示、支持鼠标滚轮调整时间。能独立完成这三个需求基本就掌握了 Qt 自定义控件的基本套路。