Qt绘制电池进度条:基于paintEvent的自绘控件完整解析 简介面向Qt初学者的自定义控件示例工程演示了重写重绘事件处理函数并结合绘图工具绘制带电量显示的电池进度条同时加入颜色状态判断与属性动画效果。工程包含完整可编译的源码与界面文件共八个文件涵盖三个源程序文件、两个头文件以及工程文件和界面设计文件压缩包仅有七KB结构精巧适合直接导入集成开发环境进行学习和调试。绘制逻辑细致拆解了电池外壳与电量填充的实现思路颜色判断覆盖正常、警示与过渡区间动画触发更新则展示了属性动画与重绘机制的配合方式。通过阅读源码可快速掌握基础绘图、事件重绘流程以及动画驱动界面的典型写法为后续开发更复杂的自定义控件打下基础。已有四百九十八人学习下载对于想理解绘图原理和自定义控件开发的读者是一份简洁清晰的参考范例。1. Qt绘制电池进度条paintEvent自绘的完整实现路径某次接手一个设备上位机界面需求方要求把电量做成电池样子绿条跟着百分比走低于20%还要变红。当时第一反应是拖个 QProgressBar 改样式结果折腾了两个小时怎么看都像个“加载条”——电池该有的外壳、电极帽、低电量警示靠 QSS 根本捏不出来。后来干脆用 Qt 的 paintEvent 亲手画一百多行代码把所有问题解决了。这篇经验就是围绕“Qt绘制电池进度条paintEvent”展开的完整拆解从电池外壳圆角矩形、电量填充比例计算到百分比文本绘制与 update 刷新机制最后聊聊自绘控件最容易踩的坑。适合正在做自定义控件的 Qt 界面开发也适合想彻底搞懂 QPainter 绘制链路的同学。2. 方案选型与控件框架为什么 QProgressBar 替代不了电池2.1 先拆解电池控件到底要画哪几块在打开 Qt Designer 之前我习惯先把视觉元素拆成一个个独立的绘制单元。电池进度条不是一张图而是四层内容叠加电池外壳一个圆角矩形深灰色边框中间不填充电极帽外壳右侧凸起的小矩形模拟电池正极电量填充外壳内部的填充矩形宽度根据电量值动态变化百分比文本显示在电量区域正中间的数值比如“67%”这四层如果拆明白了paintEvent 里就只是按顺序画四个东西。顺序也很关键先画电极帽和外壳再画填充区最后画文字。这个顺序决定了文字不会被填充色盖住也不会因为先画文字后画边框而导致文字被压在边框下面。之所以强调“拆层”是因为很多人写自绘控件时喜欢一把梭把所有绘制代码塞进一个 paintEvent画完外壳画填充结果发现边框和填充的坐标纠缠不清改一个数值牵一发动全身。先拆层再编码后面每次加效果都只需要动对应那一段。2.2 QSS 和 QProgressBar 各自差在哪QProgressBar 默认样式是凹槽加进度块做“电池”这种带明显轮廓的形状QSS 只能靠 border-image 切图来凑。第一次调出来觉得很像放大窗口到 200% 就露馅了圆角变形、电极帽错位纯粹是拿像素在硬拼。图片方案更糟。给它一张 PNG 电池底图普通 DPI 下还凑合换到高分屏或者用户手动缩放窗口位图就发虚。而且电量从 67% 变成 13% 时填充部分如果也要贴图得准备多少张图所以对一个需要动态数值、动态颜色的控件来说paintEvent 自绘几乎是唯一能同时满足清晰度、缩放和状态变化的方案。自绘并不意味着要写很多代码。QPainter 的 drawRoundedRect、drawRect、drawText 三个接口就够用了剩下的工作其实是坐标计算和状态管理。我一般把这类控件封装成一个继承 QWidget 的独立类外部只暴露 setValue 和 setState界面层完全不需要知道绘制细节。2.3 BatteryWidget 类骨架与 paintEvent 入口先搭一个最小可用的类骨架下面的所有绘制代码都围绕这个骨架展开#include QWidget #include QColor class BatteryWidget : public QWidget { Q_OBJECT public: enum State { Normal, Charging, Warning }; explicit BatteryWidget(QWidget *parent nullptr); void setValue(int value); void setState(State state); int value() const; protected: void paintEvent(QPaintEvent *event) override; private: QColor fillColor() const; int m_value 100; State m_state Normal; };这里的关键设计是BatteryWidget 继承 QWidget而不是 QProgressBar 或 QAbstractButton。继承 QWidget 能绕开一堆用不到的交互逻辑让这个类只关心“画电池”这一件事。外部调用方只需要 setValue(67)内部就会自动触发重绘界面层不需要手动调用 repaint。构造函数里建议设置一个最小尺寸防止在布局中被压缩得看不出电池形状。下一节会看到所有绘制坐标都基于 rect() 动态计算窗口任意缩放都不会变形。3. 核心绘制拆解paintEvent 里的四段绘制代码3.1 第一步计算外壳区域与电极帽位置绘制从坐标计算开始。为了让电池在窗口里留出呼吸空间我给整个绘制区域设置了 4px 的外边距外壳右侧额外留出 16px 给电极帽void BatteryWidget::paintEvent(QPaintEvent *event) { Q_UNUSED(event); QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); // 电池外壳区域右侧留出电极帽的空间 const QRectF bodyRect(4, 8, width() - 24.0, height() - 16.0); // 电极帽外壳右侧的小矩形上下各缩进 3px看起来更协调 const QRectF capRect(width() - 16.0, 11.0, 8.0, height() - 22.0); }bodyRect 的四个参数分别是 x、y、width、height。x 从 4 开始y 从 8 开始高度是 height() - 16这样上下对称宽度是 width() - 24因为右边要给电极帽留 16px、左边留 4px再加上外壳自身的左右边距。capRect 的宽度固定 8px高度比外壳略短看起来像真实的电池正极凸起。这里最容易犯的错误是写死数字比如 bodyRect(0, 0, 120, 40)。一旦窗口 resize电池要么变形要么跑偏。后面第 5 章的避坑部分会专门讲这个问题。3.2 第二步画外壳圆角矩形外壳用 drawRoundedRect注意画笔和画刷要分开设置// 外壳只有边框中间不填充 QPen borderPen(QColor(90, 90, 90), 2.0); painter.setPen(borderPen); painter.setBrush(Qt::NoBrush); painter.drawRoundedRect(bodyRect, 6.0, 6.0); // 电极帽实心灰色小矩形 painter.setPen(Qt::NoPen); painter.setBrush(QColor(120, 120, 120)); painter.drawRoundedRect(capRect, 2.0, 2.0);drawRoundedRect 的第三、四个参数是圆角半径取 6px 在 60px 高的控件上看着刚好不会太圆也不会太方。关键点是外壳的画刷必须设为 Qt::NoBrush如果用了默认画刷外壳中央会被填成一片灰色后面的电量填充就全被盖住了。电极帽绘制时把画笔关掉、画刷设为灰色避免画笔的灰色边框让电极帽显得臃肿。这步虽然简单但画笔和画刷的切换顺序直接影响最终观感很多人画出来的电池轮廓粗糙就是因为画笔没关或者画刷没设对。3.3 第三步按百分比计算电量填充区域填充区域是核心。外壳内部不能直接整个填满那样电量 100% 时会把边框内部的 2px 也盖住看起来像“满得溢出来”。所以填充区要在外壳基础上向内缩 3px// 填充区外壳向内缩 3px避免与边框重叠 const QRectF innerRect bodyRect.adjusted(3.0, 3.0, -3.0, -3.0); double fillWidth innerRect.width() * m_value / 100.0; // 最低电量保护1%~2% 时也至少要有一个可见细条 if (m_value 0 fillWidth 2.0) { fillWidth 2.0; } const QRectF fillRect(innerRect.left(), innerRect.top(), fillWidth, innerRect.height()); painter.setPen(Qt::NoPen); painter.setBrush(fillColor()); painter.drawRect(fillRect);fillWidth 的计算就是简单的比例换算电量 67%就把填充宽度设成 innerRect 宽度的 67%。m_value 已在 setValue 中被限制在 0~100 之间所以这里不需要再做边界判断。最低电量保护那两行是血泪经验电量低到 1% 时 fillWidth 只有 0.6px抗锯齿一渲染绿条根本看不见用户会以为电池坏了。fillColor() 是根据 m_state 返回对应颜色的私有方法下一章会详细展开。drawRect 填充时同样要把画笔关掉否则填充矩形周围会多一圈细线和外壳边框叠在一起特别脏。3.4 第四步画百分比文本并保证层级正确文本绘制放在填充之后这样文字永远叠在填充色上面// 百分比文本在整个外壳区域居中不被填充区限制 painter.setPen(QColor(40, 40, 40)); QFont font painter.font(); font.setPixelSize(11); font.setBold(true); painter.setFont(font); painter.drawText(bodyRect, Qt::AlignCenter, QStringLiteral(%1%).arg(m_value));drawText 的矩形参数用 bodyRect 而不是 fillRect是为了让文字始终在电池正中间显示。如果用了 fillRect电量降到 20% 时文字会被挤到左侧视觉上非常奇怪。字体的大小和粗细可以根据实际界面风格调这里给的是 11px 粗体在大部分分辨率下都清晰。到这里一个静态电池控件已经能画出来了。setValue(67) 之后 update()paintEvent 重新执行四段代码依次跑一遍界面上的电池就跟着变了。4. 刷新联动与工程运行从 setValue 到 paintEvent 的闭环4.1 触发链路setValue 内部做了什么光有 paintEvent 还不够外部改数值时怎么通知控件重绘答案是 setValue 里调 update()void BatteryWidget::setValue(int value) { int clamped qBound(0, value, 100); if (clamped ! m_value) { m_value clamped; update(); } }qBound(0, value, 100) 是 Qt 提供的边界限定函数等价于先和 0 比较再和 100 比较返回值永远在 [0, 100] 区间内。之所以先比较 clamped ! m_value 再决定是否 update是为了避免频繁调用 setValue 时产生无意义的重复绘制。update() 是异步重绘请求不会立刻执行 paintEvent而是等 Qt 回到事件循环后再统一处理。这意味着在同一帧里连续调用 setValue(10)、setValue(20)、setValue(30)最终只会有一次 repaint最终的 m_value 是 30。这个机制对性能很友好但也是一个坑如果外部代码在 setValue 之后立刻读取界面渲染结果是读不到的必须等事件循环跑完。4.2 状态切换Normal / Charging / Warning 的颜色映射电池在不同状态下要有不同颜色。充电动画、低电量警示、正常放电都需要一个可切换的状态入口void BatteryWidget::setState(State state) { if (m_state ! state) { m_state state; update(); } } QColor BatteryWidget::fillColor() const { switch (m_state) { case Charging: return QColor(76, 175, 80); // 充电绿 case Warning: return QColor(244, 67, 54); // 告警红 case Normal: default: return (m_value 20) ? QColor(244, 67, 54) : QColor(33, 150, 243); } }state 是外部显式控制的插上充电器时 setState(Charging)电量告警时 setState(Warning)。Normal 状态下还有一层隐式逻辑电量≤20% 时也显示红色这个阈值正好对应需求里的“低电量警示”。setState 和 setValue 都走的是同一个模式数据变更 - 判重 - update()。界面层只管调方法不需要理解绘制细节这也是自绘控件应有的封装粒度。如果某个状态不需要变色直接把 switch 里的对应分支省略即可不影响整体结构。4.3 构建与运行qmake 和 CMake 两套工程这个控件跑起来只需要三个文件batterywidget.h、batterywidget.cpp、main.cpp。qmake 工程文件长这样QT widgets SOURCES main.cpp batterywidget.cpp HEADERS batterywidget.h TARGET battery_demo CONFIG c11CMake 版本对应这样cmake_minimum_required(VERSION 3.16) project(battery_demo LANGUAGES CXX) find_package(Qt6 COMPONENTS Widgets REQUIRED) qt_add_executable(battery_demo main.cpp batterywidget.cpp batterywidget.h ) target_link_libraries(battery_demo PRIVATE Qt6::Widgets)Qt 5 的用户把 find_package 和 target_link_libraries 里的 Qt6 改成 Qt5 即可API 层面没有差异。main.cpp 里加一个模拟放电的定时器就能看到电池条反复变化#include QApplication #include QTimer #include batterywidget.h int main(int argc, char *argv[]) { QApplication app(argc, argv); BatteryWidget battery; battery.resize(140, 60); battery.show(); QTimer timer; QObject::connect(timer, QTimer::timeout, [battery]() { static int v 100; v (v 6 100) ? 0 : v 6; battery.setValue(v); }); timer.start(300); return app.exec(); }这段代码每 300ms 把电量从 0 加到 100 再归零模拟一次快速充放电循环。QTimer 在这里扮演业务数据源角色真实项目里替换成串口、网络或设备信号都可以。5. 避坑指南自绘电池控件的五个翻车现场5.1 电量 1% 时填充条消失了现象setValue(1) 之后电池外壳里看不到任何填充色或者只在左侧有一个被抗锯齿抹掉的残影。原因fillWidth innerRect.width() * 1 / 100假设 innerRect 宽 100pxfillWidth 就是 1px。QPaint 在 1px 的矩形上开启抗锯齿渲染结果会被羽化到几乎透明视觉上等于没有。解决在计算 fillWidth 后加最小宽度保护电量大于 0 时强制至少 2pxif (m_value 0 fillWidth 2.0) { fillWidth 2.0; }5.2 电量 98% 看起来像满电现象电量 98% 和 100% 在界面上几乎无法区分用户反馈“电还没用就显示满了”。原因填充区没有向内部缩进。fillRect 的底部和右侧直接贴着 bodyRect 的内边而 bodyRect 的边框线宽 2px 有一部分也画在矩形内部。填充区盖住了边框的内半侧98% 的填充宽度加上边框视觉宽度就“溢满”了。解决填充矩形统一用bodyRect.adjusted(3.0, 3.0, -3.0, -3.0)内缩给边框留出至少 2px 的呼吸空间。这个内缩值也可以提成成员变量方便不同视觉风格调整。5.3 窗口一放大电池位置就飞了现象窗口 resize 后电池不再居中电极帽悬空或者外壳左侧留白巨大。原因paintEvent 里写死了坐标比如 bodyRect(10, 10, 120, 40)、capRect(130, 15, 6, 30)。窗口一拉大这些固定坐标不会跟随窗口尺寸变化电极帽还在 130px 处外壳宽度还是 120px整体看起来就歪了。解决所有的绘制坐标都必须基于 rect()、width()、height() 动态计算。外壳右侧预留 16px 电极空间电极帽坐标用 width() - 16 推导高度用 height() 推导。这样无论窗口怎么缩放电池比例都能跟随适应。5.4 百分比文字被填充色盖住一半现象改电量值时数字断断续续像是被什么东西啃了一口。原因绘制顺序错了。如果先 drawText 再 drawRect 画填充区电量更新时填充矩形会把之前画好的文字覆盖掉。我在第 3 章反复强调顺序就是因为它真的会出这种肉眼可见的 bug。解决严格遵守绘制顺序外壳 - 电极帽 - 填充区 - 文字。文字永远是最后一个绘制的内容层级上天然在最上层。5.5 快速刷新时窗口闪烁严重现象QTimer 定时 30ms 更新一次电量窗口出现明显闪烁拖动窗口时更严重。原因QWidget 在 update() 后台会先擦除整个背景再执行 paintEvent。频繁刷新时“擦除 - 绘制”反复交替屏幕上就会看到闪烁。这是 Qt 默认行为不处理几乎必现。解决构造函数里加setAttribute(Qt::WA_OpaquePaintEvent)关闭自动擦除同时在 paintEvent 开头用背景色把整个 widget 填充一遍保证没有残影painter.fillRect(rect(), QColor(245, 245, 245));注意开启 WA_OpaquePaintEvent 后如果你没在 paintEvent 里把整个矩形填满残留画面会越积越脏。所以只建议在电池控件需要高频刷新时开启并确认绘制代码覆盖了所有区域。6. 二次开发让电池控件支持平滑动画与业务联动6.1 用 QPropertyAnimation 做平滑电量过渡setValue 是瞬间跳变的真实设备电量是连续变化的直接跳变在界面上特别生硬。给 BatteryWidget 增加一个 value 属性声明就能直接用 QPropertyAnimation 做插值动画BatteryWidget *battery new BatteryWidget; QPropertyAnimation *anim new QPropertyAnimation(battery, value, battery); anim-setDuration(800); anim-setStartValue(battery-value()); anim-setEndValue(65); anim-start();前提是在类声明里加 Q_PROPERTY 宏Q_PROPERTY(int value READ value WRITE setValue)动画每一帧都会调用 setValue而 setValue 内部通过 update() 触发重绘。800ms 内电量从当前值平滑爬到 65%视觉上非常接近真实设备的充电效果。给动画配一个 QEasingCurve::OutCubic 曲线会更有真实感。6.2 把颜色和圆角半径开放成属性如果每个界面的电池风格不一样可以在类里增加 m_borderColor、m_insideMargin、m_radius 几个私有变量配上对应的 setter 方法。用 Q_PROPERTY 注册属性后即使在 Qt Designer 里也能直接调整圆角半径不需要改代码重新编译。6.3 接真实业务数据的思路项目里接入时我习惯在电池控件外面套一层业务适配器专门负责把设备的电量信号转成控件的 setValue 调用。比如电量传感器每 2 秒上报一次 0~100 的浮点值适配器里四舍五入后传给控件充电状态到来时调用 setState(Charging)断开时切回 Normal。核心原则是 BatteryWidget 不关心数据从哪来它只负责“接到数值 - 重绘”双向解耦对后续维护会省很多事。从那以后我每次自绘控件都会强制先列一个绘制顺序清单再写代码——外壳、填充、文本、边框这个固定顺序不知道救了我多少次。希望帮到你。本文还有配套的精品资源点击获取