
简介这是一份面向Qt初学者与界面开发者的自定义控件实践资源围绕paintEvent绘制电池进度条展开帮助读者理解QPainter绘图、电量比例填充、颜色状态判断与QPropertyAnimation动画更新等核心机制适合作为图形界面编程的入门练手项目。压缩包共8个文件约7KB以3个cpp与2个h源文件为主体配合1个pro工程文件、1个ui界面文件及1个user配置结构精简便于直接编译运行与二次修改。已有501人学习下载说明其作为小型示例具有一定参考价值。资源将绘制逻辑与动画逻辑分离把颜色判断封装为独立方法读者可从中掌握低电量警示色、正常色与过渡色的切换思路并了解避免在paintEvent中重复计算、缓存结果以优化性能的做法为后续开发更复杂的自绘控件打下基础。1. 电池进度条不是画个矩形从 paintEvent 说起很多同学接到「Qt 绘制电池进度条」这个题目第一反应是拖一个 QProgressBar 改改样式表就交差。真到答辩现场导师一句「电量低于 20% 怎么变色」「充电时那个闪电怎么闪」「圆角怎么保证不糊」就能把人问住。这套方案要解决的就是这类问题不依赖任何第三方控件纯靠 QWidget 加 paintEvent 手绘一个可复用、可配置、能应对答辩追问的电池电量组件。适合正在做 Qt 课程设计、毕设界面模块或者需要在嵌入式 HMI 上做轻量自绘控件的同学。核心关键词就一个qt 自绘。下面从需求、架构、实现到验收把这块电池完整拆开。2. 需求拆解与技术选型为什么不用 QProgressBar2.1 先把需求钉死别急着写 paintEvent自绘控件最容易翻车的地方不是画不出来而是画之前没想清楚要画成什么样。我一般会先列一张需求清单把「必须做」和「可以不做」分开避免写到一半发现架构撑不住。需求项具体要求优先级电量显示0~100 整数支持小数电量如 87.5%必须颜色分级正常绿、低电黄、危险红三档可配阈值必须充电状态充电时显示闪电图标并做呼吸动画必须圆角与边框电池外壳圆角、内部填充圆角缩放不糊必须尺寸自适应任意宽高比下比例协调不拉伸变形必须主题切换深色/浅色两套配色可选数值动画电量变化时平滑过渡而非跳变可选这张表看着朴素但它决定了后面类的接口设计。比如「颜色分级阈值可配」意味着构造函数或 setter 里要暴露 lowThreshold、criticalThreshold 两个参数「充电呼吸动画」意味着类里得有一个 QTimer 和一个 phase 变量paintEvent 里根据 phase 算透明度。需求没钉死接口就会反复改这是血泪经验。2.2 为什么放弃 QProgressBar 和 QStyleQProgressBar 加 QSS 确实能做出「像电池」的效果但它的本质是一条进度槽圆角、内边距、充电图标这些都得靠 border-image 拼图一旦控件尺寸变化九宫格拉伸就容易糊。更关键的是QSS 对「电池头部那个小凸起」这种非矩形轮廓几乎无解只能贴图。QStyle 自绘是另一条路重写 drawControl 能接管绘制但 QStyle 的定位是「给所有同类控件提供统一外观」你为了一个电池去动全局样式耦合太重答辩时被问「你这个改动影响其他控件吗」就不好答。所以选型结论很明确继承 QWidget重写 paintEvent用 QPainter 的路径绘制QPainterPath画外壳和填充用 QPropertyAnimation 或 QTimer 驱动动画。这样做的好处是绘制逻辑完全可控、无外部资源依赖、任意尺寸矢量清晰缺点是所有细节都得自己算——但这恰恰是答辩时能讲出东西的地方。2.3 类结构设计一个控件类加一个配置结构我一般把电池控件拆成两部分BatteryConfig 结构体存所有可配参数BatteryWidget 负责绘制和状态管理。这样配置和逻辑分离换主题、调阈值不用动绘制代码。// batteryconfig.h struct BatteryConfig { int lowThreshold 30; // 低于此值显示黄色 int criticalThreshold 15; // 低于此值显示红色 QColor normalColor QColor(76, 175, 80); // 正常绿 QColor lowColor QColor(255, 193, 7); // 低电黄 QColor criticalColor QColor(244, 67, 54); // 危险红 QColor shellColor QColor(120, 120, 120); // 外壳描边 QColor emptyColor QColor(230, 230, 230); // 空槽底色 int shellRadius 6; // 外壳圆角半径 int fillMargin 3; // 填充与外壳的内边距 bool showPercent true; // 是否在内部显示百分比文字 };这个结构体的设计要点在于所有颜色和阈值都有默认值调用方可以只改关心的字段。shellRadius 和 fillMargin 分开是因为外壳圆角和内部填充圆角需要联动计算——填充圆角通常等于外壳圆角减去内边距否则内填充会顶到外壳圆角外面视觉上「漏出来」。这个细节不写代码时想不到画出来一眼就丑。3. 绘制核心实现paintEvent 里的几何计算与分层3.1 坐标系与几何分解把电池拆成四个矩形paintEvent 里第一件事不是 setPen而是算几何。电池的视觉结构可以拆成外壳矩形、电池头部凸起、内部空槽、电量填充。四个区域的坐标都基于控件当前 rect 推导这样任意尺寸都能自适应。void BatteryWidget::paintEvent(QPaintEvent *event) { Q_UNUSED(event); QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); // 抗锯齿圆角不糊的关键 const QRectF full rect(); const qreal headW full.width() * 0.06; // 头部凸起宽度占 6% const qreal headH full.height() * 0.36; // 头部高度占 36% const qreal gap 2.0; // 外壳与头部之间的间隙 // 外壳区域右侧留出头部宽度和间隙 QRectF shell(full.left(), full.top(), full.width() - headW - gap, full.height()); // 头部凸起垂直居中 QRectF head(shell.right() gap, full.top() (full.height() - headH) / 2.0, headW, headH); drawShell(painter, shell, head); drawFill(painter, shell); if (m_charging) drawBolt(painter, shell); if (m_config.showPercent) drawPercentText(painter, shell); }这段代码的关键决策有三个。第一头部宽度和高度用百分比而不是固定像素保证控件放大缩小时比例不变第二外壳矩形主动减去头部宽度和间隙避免头部和外壳重叠第三绘制顺序是外壳→填充→闪电→文字后画的盖住先画的文字永远在最上层。参数 headW 取 6% 是试出来的经验值太小像天线太大像尾巴5%~8% 之间视觉最舒服。3.2 外壳与填充QPainterPath 画圆角裁剪保证不溢出外壳用 QPainterPath 分别对外壳矩形和头部矩形加圆角然后合并成一个路径描边。填充部分则用「裁剪 矩形」的方式保证电量条永远不超出外壳内壁。void BatteryWidget::drawShell(QPainter p, const QRectF shell, const QRectF head) { QPainterPath path; path.addRoundedRect(shell, m_config.shellRadius, m_config.shellRadius); path.addRoundedRect(head, 2, 2); // 头部小圆角 p.setPen(QPen(m_config.shellColor, 2)); // 2px 描边 p.setBrush(Qt::NoBrush); p.drawPath(path); } void BatteryWidget::drawFill(QPainter p, const QRectF shell) { const qreal m m_config.fillMargin; QRectF inner shell.adjusted(m, m, -m, -m); // 内缩留出内边距 if (inner.width() 0 || inner.height() 0) return; // 按电量比例计算填充宽度 qreal ratio qBound(0.0, m_value / 100.0, 1.0); QRectF fillRect(inner.left(), inner.top(), inner.width() * ratio, inner.height()); QPainterPath clip; qreal r qMax(0.0, m_config.shellRadius - m); // 内圆角 外圆角 - 内边距 clip.addRoundedRect(inner, r, r); p.save(); p.setClipPath(clip); // 裁剪填充绝不溢出内壁 p.setPen(Qt::NoPen); p.setBrush(currentColor()); // 根据电量返回绿/黄/红 p.drawRect(fillRect); p.restore(); }这里有两个容易踩的点。其一内圆角半径必须用 shellRadius 减 fillMargin如果直接沿用外壳半径填充的圆角会比外壳大视觉上「鼓出来」其二setClipPath 之后一定要 restore否则后续绘制闪电和文字都会被裁掉。currentColor() 是个小函数按 value 与两个阈值比较返回对应颜色逻辑简单但别写反比较方向——我见过把value critical写成critical value的结果满电显示红色答辩现场很尴尬。3.3 充电闪电与呼吸动画QTimer 驱动 phase别用 QPropertyAnimation 硬套充电图标用 QPainterPath 画一个多边形闪电呼吸效果靠一个 0~1 循环的 phase 变量控制透明度。这里我选 QTimer 而不是 QPropertyAnimation原因是呼吸动画是无限循环且只改一个浮点值QTimer 每 40ms 触发一次 update() 更直接QPropertyAnimation 反而要额外定义一个属性、写 getter/setter绕。// 构造函数里 m_animTimer new QTimer(this); m_animTimer-setInterval(40); // 约 25fps够用且省 CPU connect(m_animTimer, QTimer::timeout, this, [this]() { m_phase 0.05; if (m_phase 1.0) m_phase 0.0; update(); // 只重绘不重算布局 }); void BatteryWidget::setCharging(bool on) { m_charging on; if (on) m_animTimer-start(); else { m_animTimer-stop(); m_phase 0.0; } update(); } void BatteryWidget::drawBolt(QPainter p, const QRectF shell) { // 闪电多边形坐标相对外壳中心归一化后缩放 QPolygonF bolt; bolt QPointF(0.55, 0.15) QPointF(0.35, 0.52) QPointF(0.48, 0.52) QPointF(0.42, 0.85) QPointF(0.65, 0.45) QPointF(0.52, 0.45); // 映射到外壳区域 QTransform t; t.translate(shell.center().x(), shell.center().y()); t.scale(shell.width(), shell.height()); t.translate(-0.5, -0.5); QPolygonF mapped t.map(bolt); QColor c Qt::white; c.setAlphaF(0.55 0.45 * qAbs(qSin(m_phase * M_PI))); // 呼吸透明度 p.setPen(Qt::NoPen); p.setBrush(c); p.drawPolygon(mapped); }闪电坐标用 0~1 归一化再映射好处是换任何尺寸的电池都不用改坐标。呼吸透明度用qAbs(qSin(phase * PI))而不是线性增减是因为正弦曲线两端变化慢、中间快视觉上更像「呼吸」而不是「闪烁」。定时器间隔 40ms 是权衡20ms 更顺滑但 CPU 占用翻倍60ms 开始有顿挫感40ms 在嵌入式板子上也扛得住。3.4 百分比文字与高 DPI字体度量与 devicePixelRatio文字绘制看似简单但高 DPI 屏上容易糊或者偏。正确做法是用 QFontMetricsF 算文字包围盒居中绘制并且依赖 Qt 的自动高 DPI 缩放Qt5.6 之后默认开启不要手动乘 devicePixelRatio否则会二次放大。void BatteryWidget::drawPercentText(QPainter p, const QRectF shell) { QFont f font(); f.setPixelSize(qMax(8, int(shell.height() * 0.42))); // 字号随高度缩放 f.setBold(true); p.setFont(f); QString txt QString::number(qRound(m_value)) %; QFontMetricsF fm(f); QRectF tb fm.boundingRect(txt); tb.moveCenter(shell.center()); // 文字颜色根据填充是否覆盖文字位置决定保证对比度 p.setPen(isTextOnFill(tb, shell) ? Qt::white : Qt::black); p.drawText(tb, Qt::AlignCenter, txt); }字号取外壳高度的 42% 是试出来的太小看不清太大顶到边框。isTextOnFill 判断文字中心是否落在填充区域内落在里面用白字落在空槽用黑字避免「绿底黑字看不清」这种低级问题。高 DPI 这块只要在 main 里设置QCoreApplication::setAttribute(Qt::AA_EnableHighDpiScaling)Qt5或直接用 Qt6 默认行为paintEvent 里按逻辑坐标画就行别自己乘缩放因子。4. 状态管理与对外接口让控件能被别人用起来4.1 属性、信号与槽value 变化要发信号一个自绘控件如果只能自己玩价值就有限。对外至少暴露 setValue、value、setCharging、setConfig 四个接口value 变化时发 valueChanged 信号方便外部联动比如低电时弹提示。class BatteryWidget : public QWidget { Q_OBJECT public: explicit BatteryWidget(QWidget *parent nullptr); void setValue(double v); // 0~100内部 clamp double value() const { return m_value; } void setCharging(bool on); void setConfig(const BatteryConfig cfg); QSize sizeHint() const override; // 给布局一个合理默认尺寸 signals: void valueChanged(double v); void lowPowerTriggered(); // 进入低电阈值时发一次 private: double m_value 0.0; bool m_charging false; double m_phase 0.0; BatteryConfig m_config; QTimer *m_animTimer nullptr; bool m_lowFired false; // 防止低电信号重复发 };setValue 里要做三件事clamp 到 0~100、判断是否跨过低电阈值决定发不发 lowPowerTriggered、调用 update()。m_lowFired 这个标志位是防抖用的——电量在阈值附近抖动时不加标志会连续发信号外部弹窗能弹一串。sizeHint 返回比如 QSize(120, 50)让布局系统有个参考不然控件可能被压成一条线。4.2 主题切换与配置热更新setConfig 里改完参数必须调用 update()否则界面不刷新。如果配置里颜色变了但没触发重绘用户会以为「设置没生效」这是自绘控件最常见的「假 bug」。另外建议在 setConfig 里做一次参数合法性校验比如 criticalThreshold 必须小于 lowThreshold否则颜色分级逻辑会错乱。void BatteryWidget::setConfig(const BatteryConfig cfg) { m_config cfg; if (m_config.criticalThreshold m_config.lowThreshold) { std::swap(m_config.criticalThreshold, m_config.lowThreshold); // 兜底纠正 } update(); // 必须否则配置不生效 }深色主题就是把 shellColor、emptyColor 换成深灰系normalColor 稍微提亮其余逻辑不变。因为颜色全在配置结构里切主题只是换一个 BatteryConfig 实例绘制代码一行不用动——这就是前面把配置抽出来的回报。5. 避坑与排查自绘控件最容易翻车的五个点5.1 圆角发虚、边缘有锯齿现象圆角处能看到毛边放大后尤其明显。原因没开抗锯齿或者开了抗锯齿但画笔宽度是奇数且坐标落在半像素上。解决paintEvent 开头setRenderHint(QPainter::Antialiasing, true)描边宽度用偶数2px矩形坐标尽量对齐到 0.5 的整数倍避免半像素模糊。5.2 控件缩放后比例失调现象拉宽控件电池头部变得又扁又长。原因头部宽高用了固定像素或者只按宽度算没按高度约束。解决所有几何量都用相对比例百分比推导并在 resizeEvent 里不做额外计算全部放到 paintEvent 里按当前 rect 现算保证每次重绘都基于最新尺寸。5.3 动画卡顿或 CPU 飙高现象充电呼吸动画一顿一顿或者任务管理器里 CPU 占用明显。原因定时器间隔太短如 10ms且每帧都触发完整重绘或者忘了在停止充电时 stop 定时器。解决间隔取 40~50ms动画只改 phase 变量后 update()不重算布局setCharging(false) 时务必 stop否则后台一直空转。5.4 填充溢出外壳或文字被裁现象电量 100% 时填充条顶出圆角或者百分比文字显示不全。原因填充没做 clipPath 裁剪或者文字包围盒没留内边距。解决填充前 setClipPath 内缩后的圆角路径绘制后 restore文字用 QFontMetricsF 算实际包围盒再居中别用固定矩形硬套。5.5 高 DPI 屏上整体偏小或模糊现象在 4K 屏或缩放 150% 的环境下控件看起来比设计小一圈或者边缘糊。原因手动乘了 devicePixelRatio 导致二次缩放或者没启用高 DPI 支持。解决Qt5 在 main 里设 AA_EnableHighDpiScalingQt6 默认即可paintEvent 里一律用逻辑坐标不手动乘缩放因子图片资源才需要考虑 DPR纯矢量绘制不用。6. 验收与答辩怎么证明这块电池是「对的」验收自绘控件光看截图不够得有可复现的检查项。我一般准备一张验收表逐项过一遍答辩时直接拿这张表讲比空口说「我实现了」有说服力。验收项操作方法合格标准电量边界setValue(-5)、setValue(105)分别显示 0% 和 100%不崩溃颜色分级依次设 50、25、10绿、黄、红三色正确切换充电动画setCharging(true) 观察 10 秒闪电呼吸平滑无闪烁跳变尺寸自适应拖拽窗口从 80x30 到 400x150比例协调圆角不糊文字不溢出高 DPI系统缩放调至 150% 重启清晰无模糊尺寸符合预期主题切换切换深色配置颜色即时生效无需重启答辩时最容易被追问的三个问题提前准备好答案一是「为什么不用 QProgressBar」答自绘可控性、无贴图依赖、任意尺寸矢量清晰二是「动画为什么用 QTimer 不用 QPropertyAnimation」答单值循环动画用定时器更直接省去属性定义三是「高 DPI 怎么处理的」答依赖框架自动缩放加逻辑坐标绘制。把这三个答顺基本就稳了。还有一个实操技巧答辩演示前把电量从 100 拖到 0 走一遍完整过程让评委看到颜色过渡和低电信号触发比静态截图有说服力得多。演示环境的分辨率和缩放比例提前调成和开发机一致避免现场「怎么和你说得不一样」的翻车。从那以后我每次做自绘控件都强制先写需求表和验收表再动手画完对着表逐项打勾不靠「看着差不多」过关。希望帮到你。本文还有配套的精品资源点击获取