
简介这是一份基于 Qt 的电池进度条自定义控件实现面向需要学习 Qt 界面绘制的开发者重点演示 paintEvent 事件处理、QPainter 基础绘图、电量状态颜色切换以及 QPropertyAnimation 属性动画的配合使用。压缩包共包含 8 个文件体积约 7KB其中 C 源文件实现控件与主窗口逻辑头文件声明类结构Qt 工程文件用于管理编译界面文件提供可视化布局参考结构清晰非常适合初学者对照源码逐步理解。目前已有 498 人学习/下载常作为 Qt 自定义控件课程作业或入门练习。从代码中可以学到如何利用 paintEvent 绘制电池外壳和电量填充如何根据百分比设置正常色与警示色并结合动画让数值变化更加平滑。整个项目不依赖额外第三方库可直接在 Qt 环境中编译运行整体代码精简是一份覆盖绘图、动画与状态反馈三大要点的典型小项目。 前阵子做一个设备管理工具时界面上需要显示设备剩余电量。产品给了一张参考图圆角外壳、右侧带电极、内部是绿色液面充电时液面上还有流动的斜纹。第一反应是找现成控件翻了一圈QProgressBar套样式表只能改改颜色和圆角做不出电极和流动动画用图片做帧动画倒是能实现但换尺寸就糊高分屏下更是没法看。最后还是决定老老实实用QPainter在paintEvent里画一个电池进度条。这个方案做完之后稳定跑了好几个项目今天把绘制思路、坐标计算、动画实现和踩过的坑完整记录下来。1. 为什么自绘电池现成方案的根本缺陷1.1 QProgressBar与图片方案的痛点很多刚接触Qt的开发者遇到进度条需求时第一反应就是用QProgressBar。这个控件确实能用比如设置好范围、用样式表改一下QProgressBar::chunk的背景色和圆角勉强能模拟一个扁平的电池。但真正做进去就会发现几个问题。第一个痛点是样式表对内部结构的控制力太弱。电池不是一个简单的矩形进度条它需要有外壳描边、右侧电极凸起、内部液面与外壳之间留出间距、充电时的动态斜纹。这些结构在QProgressBar里要么嵌套多个子控件要么就完全做不到。嵌套子控件又会引入布局和透明背景的麻烦代码写得很别扭。第二个痛点是图片方案在高分屏下的表现。用QSS加背景图、或者用QLabel贴图逻辑上简单但一旦窗口尺寸变化电池会被拉伸得不成比例在Retina这类高分屏上如果只提供一倍图边缘会明显发虚。做一套2x、3x图又不是不行但维护成本摆在那里改个颜色就要重新出图。我的结论是进度类指示控件只要形态稍微特殊一点自绘永远是最省事、最可控的方案。paintEvent绘制不依赖任何外部资源代码全部是矢量绘制缩放不失真换主题改颜色也只是改几个常量的事。1.2 paintEvent绘制控件的适用边界当然不是所有控件都适合自绘。我的判断标准是如果这个控件的主要表现力来自自定义外观而不是复杂交互那就适合用paintEvent。电池进度条正好在这个范围内——它只需要响应数值变化没有什么键盘输入、拖拽、焦点管理这些复杂交互。另一个需要考虑的点是绘制频率。paintEvent会在窗口移动、缩放、遮挡恢复、调用update()时触发如果绘制逻辑很重比如加载大图、做复杂计算就会拖慢界面刷新。电池控件的绘制只是几十个矩形、几条渐变线单次绘制耗时微秒级完全不用担心性能。清楚了这些边界之后就可以放心动手了。下面从最基础的坐标计算开始拆解。2. 绘制前的准备坐标系统、画笔与画刷2.1 控件坐标系统与保留边距写自绘控件的第一步不是画图而是把坐标系统理清楚。paintEvent里拿到的event-rect()默认是控件本地坐标单位是逻辑像素。Qt在绘制时会自动处理设备像素比所以代码里直接使用逻辑坐标即可不要在内部手动乘devicePixelRatio这个后文单独说。电池控件建议预留一个统一的边距避免外壳线条贴死在控件边缘。我用一个常量定义const int kMargin 4; QRectF bodyRect rect().adjusted(kMargin, kMargin, -kMargin, -kMargin);bodyRect就是电池外壳的占位区域。注意用adjusted而不是手动加减因为它同时处理左右和上下边距表达更简洁。接下来要在这个bodyRect里拆出外壳和电极两个部分。电极是电池右侧那个小的凸起模拟的是正极金属帽。我习惯把电极宽度设为电池高度的25%~30%并且垂直居中。这个比例在视觉上最接近手机电池图标。qreal electrodeWidth bodyRect.height() * 0.28; QRectF electrodeRect(bodyRect.right(), bodyRect.center().y() - bodyRect.height() * 0.18, electrodeWidth, bodyRect.height() * 0.36);电极的高度取电池高度的36%上下各留出电池高度的64%的空白这样电极不会显得太粗笨。外壳部分则是QRectF shellRect bodyRect.adjusted(0, 0, -electrodeWidth, 0);也就是把bodyRect右侧让出电极的位置剩下的就是电池外壳的矩形区域。2.2 QPainter的初始化与状态管理绘制函数的第一行一定是创建QPainter并绑定到this然后立刻开启抗锯齿。很多新手画出来的电池边缘有毛刺就是因为忘了这一步。void BatteryWidget::paintEvent(QPaintEvent *event) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); painter.setRenderHint(QPainter::TextAntialiasing, true); ... }Antialiasing负责几何图形的边缘平滑TextAntialiasing负责文字边缘平滑。性能开销在绘制这么简单的图形时可以忽略。这里要强调一个习惯如果绘制过程中需要临时修改画笔、画刷尽量使用QPainter::save()和restore()保存和恢复状态。否则前面设置的画笔可能会泄漏到后续绘制步骤导致颜色、线宽错乱。我的做法是外壳、液面、动画条纹、文字各自独立成块每块开始前存一次状态结束后恢复。这样排查问题的时候每块代码都是自洽的不会互相影响。画笔的设置也有讲究。外壳的描边宽度一般取2个逻辑像素太细则低分辨率下看不清太粗则会挤压内部空间QPen shellPen(QColor(0x33, 0x33, 0x33), 2); painter.setPen(shellPen); painter.setBrush(Qt::NoBrush); painter.drawRoundedRect(shellRect, kRadius, kRadius);注意setBrush(Qt::NoBrush)是必要的否则矩形的填充色会使用上一次遗留的画刷。3. 三层结构画出一个逼真电池外壳、液面、高光3.1 外壳与电极的坐标推导电池外观可以被拆成三层外壳层、液面层、高光装饰层。先看外壳层。外壳用一个带圆角的矩形表示。圆角半径kRadius不能太大我一般取电池宽度的8%左右或者干脆固定为4~6px。太大的圆角会让电池看起来像个胶囊失去电池的形态感。qreal kRadius 6; painter.drawRoundedRect(shellRect, kRadius, kRadius);电极部分则是一个不带圆角或者带极小圆角的矩形。因为电极本来就是从外壳右侧伸出去的一截金属圆角大了反而不像。我用painter.drawRoundedRect(electrodeRect, 2, 2);这里有一个细节外壳矩形和电极矩形在视觉上应该连成一体——电极贴着外壳右侧凸出两者之间不能有缝隙。我的做法是把电极的left和shellRect的right重叠1px避免抗锯齿在交界处露出一条白缝。QRectF electrodeRect(shellRect.right() - 1, shellRect.center().y() - shellRect.height() * 0.18, electrodeWidth, shellRect.height() * 0.36);3.2 电量液面的填充逻辑与颜色分级外壳画好之后核心部分就是液面——也就是真正的进度条。液面的坐标要比外壳内缩几个像素目的是让外壳的描边和液面之间留出电池内壁的间距看起来更有层次感。我定义一个内缩间距取3~4pxQRectF fillRect shellRect.adjusted(3, 3, -3, -3);液面的高度按电量百分比计算。这里要注意方向电池的电量是从下往上涨的所以百分比对应的其实是fillRect的底部往上的一段区域。qreal fillHeight fillRect.height() * m_value / 100.0; QRectF valueRect(fillRect.left(), fillRect.bottom() - fillHeight, fillRect.width(), fillHeight);m_value是当前电量百分比范围0~100。当电量是0%时valueRect的高度为0此时不应该画出液面。但我建议在valueRect高度小于一定值比如4px时仍然画一个2px高的细条避免电池完全空掉时看起来像没有填充区域。液面的颜色要按电量分级。这是手机电池图标的通行逻辑绿色表示充足橙色表示偏低红色表示危急。我按这个规则处理QColor fillColor; if (m_value 80) fillColor QColor(0x2E, 0xCC, 0x40); else if (m_value 40) fillColor QColor(0xF3, 0x9C, 0x12); else fillColor QColor(0xE7, 0x4C, 0x3C); if (m_charging) fillColor QColor(0x2E, 0xCC, 0x40);充电状态下统一使用绿色这个选择是考虑到用户习惯充电时看到绿色本能反应就是正在补充能量。而放电状态下用颜色区分剩余量则能快速传达危险信号。绘制液面时圆角只需保留底部两个角。如果四个角都画圆角外壳圆角和液面圆角叠在一起会出现不自然的双线。正确做法是只对底部两个角启用圆角。QPainterPath可以很好地解决这个问题QPainterPath fillPath; qreal cornerRadius 4; fillPath.moveTo(valueRect.left(), valueRect.top()); fillPath.lineTo(valueRect.right(), valueRect.top()); fillPath.lineTo(valueRect.right(), valueRect.bottom() - cornerRadius); fillPath.arcTo(valueRect.right() - cornerRadius * 2, valueRect.bottom() - cornerRadius * 2, cornerRadius * 2, cornerRadius * 2, 0, 90); fillPath.lineTo(valueRect.left() cornerRadius, valueRect.bottom()); fillPath.arcTo(valueRect.left(), valueRect.bottom() - cornerRadius * 2, cornerRadius * 2, cornerRadius * 2, 90, 90); fillPath.closeSubpath(); painter.setBrush(fillColor); painter.setPen(Qt::NoPen); painter.drawPath(fillPath);3.3 高光与渐变让平面图形有立体感纯色液面虽然不出错但视觉上比较平。真正好看的电池控件内部一般都有一个从左上到右下的细微渐变模拟光线打在液面上的效果。这个用QLinearGradient实现非常简单QLinearGradient gradient(valueRect.topLeft(), valueRect.bottomLeft()); gradient.setColorAt(0, fillColor.lighter(115)); gradient.setColorAt(1, fillColor.darker(110)); painter.setBrush(gradient);lighter和darker的参数要克制15%左右的变化就够了调太大会显得颜色很脏。如果想让效果更精致可以在液面顶部加一条2px高的半透明白线模拟液面的高光反射。方法是在液面上方画一条细线painter.setPen(QPen(QColor(255, 255, 255, 80), 2)); painter.drawLine(valueRect.left() 2, valueRect.top() 1, valueRect.right() - 2, valueRect.top() 1);这一条线的成本极低但对观感的提升非常明显。类似的技巧我在画仪表盘、波形图时也经常用——一个高光条就能让控件从技术原型变成成品UI。4. 充电动画状态管理、定时器与流动条纹4.1 动画数据与QTimer的配合充电状态的电池液面上应该有流动的斜纹。这个动画的实现思路是用QTimer每隔一段时间更新一个偏移量然后调用update()触发重绘在paintEvent里根据偏移量画出条纹。我定义如下成员变量class BatteryWidget : public QWidget { Q_OBJECT public: void setValue(int value); void setCharging(bool charging); protected: void paintEvent(QPaintEvent *event) override; private: int m_value 0; bool m_charging false; int m_animOffset 0; QTimer m_animTimer; };定时器在构造函数里初始化m_animTimer.setInterval(80); connect(m_animTimer, QTimer::timeout, this, [this]() { m_animOffset (m_animOffset 2) % 20; update(); });每80ms偏移2像素视觉上就是一秒移动25像素左右的速度。这个速度经过实测比较舒适太慢会显得堵太快则显得慌乱。20是条纹的周期取这个数是为了让条纹首尾衔接时不会跳变。定时器不能一直在跑。只有在充电状态下才启动停止充电时立刻停掉这是最基本的性能意识。4.2 流动条纹的绘制算法条纹绘制要先设置裁剪区域把条纹限制在液面范围内否则会画到外壳外面去。QPainter::setClipPath或者setClipRect都行painter.save(); painter.setClipPath(fillPath);裁剪完成后用for循环画一组斜线。斜线从液面左下角开始向右上方延伸每根线之间的距离由偏移量控制painter.setPen(QPen(QColor(255, 255, 255, 70), 2)); for (int x -fillRect.height(); x fillRect.width() fillRect.height(); x 20) { int xOffset x m_animOffset; painter.drawLine(xOffset, fillRect.bottom(), xOffset fillRect.height(), fillRect.top()); }这里循环的起始范围从负的fillRect.height()开始结束到fillRect.width() fillRect.height()是为了确保斜纹能覆盖整个裁剪区域避免在边缘出现空档。斜纹的角度由线段两端的x方向偏移量决定x方向偏移等于高度其实就是45度斜角这也是充电条纹最常见的角度。一个值得注意的细节裁剪区域最好用fillPath而不是fillRect。因为液面的底部是有圆角的如果用矩形裁剪充电条纹会在液面底部圆角之外露出来很突兀。这里直接复用上一节构建的fillPath一举两得。4.3 状态切换的接口与重绘触发对外接口要设计得干净。setValue和setCharging是仅有的两个操作入口它们内部只需做三件事更新成员变量、处理定时器启停、触发重绘。void BatteryWidget::setValue(int value) { m_value qBound(0, value, 100); update(); } void BatteryWidget::setCharging(bool charging) { if (m_charging charging) return; m_charging charging; if (m_charging) { m_animOffset 0; m_animTimer.start(); } else { m_animTimer.stop(); } update(); }m_value做一次qBound夹紧是必须的防止外部传入负数或者超过100的值。setCharging里先判断状态是否变化避免重复启停定时器包括stop之后又start这种无效操作。m_animOffset在启动充电时重置为0确保每次进入充电状态条纹从初始位置开始流动而不是接着上一次的位置继续。到这里整个电池控件已经可以正常工作了。但实际项目中使用时还会有一些环境相关的问题下面把我的踩坑记录整理出来。5. 实测中绕不开的坑高分屏、重绘性能与尺寸边界5.1 高分屏下控件模糊与缩放异常最早在Windows上测试时我把控件放在普通屏幕上一切正常一旦拖到高分屏电池边缘就发虚液面颜色也有轻微渗透。排查后发现两个原因。第一个原因是程序没有开启高分屏支持。在Qt5中需要在main()函数顶部加上QApplication::setAttribute(Qt::AA_EnableHighDpiScaling); QApplication::setAttribute(Qt::AA_UseHighDpiPixmaps);不加这两行系统会把控件当作低分辨率内容进行拉伸自然就模糊了。Qt6默认开启高分屏支持不需要手动设置但如果你在维护Qt5项目这个问题依然存在。第二个原因是绘制坐标没有控制在逻辑坐标系内。自绘控件的代码里不要手动去乘devicePixelRatio交给Qt内部的绘制引擎统一缩放就行。手动乘了反而会让绘制内容在高分屏上比预期大导致显示不全。5.2 update()和repaint()的选择很多教程里会把update()和repaint()混着用实测下来两者在频繁动画场景下表现差异明显。update()是异步的它会立刻把需要重绘的事件加入事件队列然后立即返回Qt会在下一次事件循环到达时统一处理repaint()则是同步立即重绘。充电动画每80ms触发一次重绘如果用repaint()一旦同一时间内有多个事件到达界面会明显卡顿。用update()则会把多次重绘请求合并成一次性能好很多。我的建议是自绘控件一律使用update()除非你有必须在某一帧同步完成的绘制需求。另一个相关的问题是绘制函数内部不要创建临时QTimer或者其他对象。paintEvent可能一秒钟被触发多次每次都动态分配内存会造成不必要的开销。我的习惯是所有用到的画笔、画刷、路径要么定义为成员变量要么在函数内使用栈对象重复利用。5.3 尺寸变化时的边界条件处理电池控件的宽高比如果不对画出来会非常奇怪。太扁的时候电极会比外壳还高太窄的时候圆角半径会大于宽度的一半导致drawRoundedRect出现异常形状。我在resizeEvent里加了保护逻辑void BatteryWidget::resizeEvent(QResizeEvent *event) { QWidget::resizeEvent(event); int minHeight height() 40 ? 40 : height(); int minWidth minHeight * 2; setMinimumSize(minWidth, minHeight); }这个保护有点简单粗暴但能保证极端情况下控件不会缩到不可用的程度。实际项目中如果外部布局允许也可以把最小尺寸写在构造函数里用setMinimumSize一次设置。再补一个细节在paintEvent开头应该判断一下控件尺寸是否合法尺寸过小直接return。尤其是电极宽度计算依赖电池高度如果height()为0会产生除以0的风险。现在的代码里虽然用乘法避免了除法但防御式写法还是有必要的if (bodyRect.width() 80 || bodyRect.height() 20) return;小于这个尺寸时画出来的电池细节已经无法辨认不如不画。最后分享一个我后来加进去的小优化电量百分比文字默认不显示可以通过setShowText(true)打开。文字绘制在液面区域中央用白色粗体字如果电量小于20%文字颜色改为白色背景加深色保证低电量时依旧可读。这一点的实现逻辑并不复杂就是多绘制一行drawText但实际项目里需求方经常会提出能不能把数字直接显示在图标上提前做好这个配置项能省不少沟通成本。本文还有配套的精品资源点击获取