简介一份面向Qt初中级开发者的自定义控件实战资源重点演示在paintEvent中重绘电池进度条贯穿QPainter基础绘图、QPropertyAnimation属性动画、电量阈值判断与状态颜色切换适合学习界面绘制或需要为桌面应用补充动态进度指示的读者。压缩包共8个文件包含3个cpp与2个h源码、1个pro工程文件、1个ui界面文件和1个user配置从控件初始化、电量矩形填充到动画触发均有可运行代码可直接导入Qt Creator查看效果。示例中电量低于20%时显示警示色高于80%使用正常色其余区间为过渡色动画触发后每次电量变化都会自动重绘直观还原动态反馈过程。已有498人浏览学习压缩包虽仅7KB但代码结构清晰将绘制逻辑、动画逻辑和颜色判断适当拆分方便初学者对照理解自定义QWidget的常用写法并能在此基础上扩展为音量条、信号强度指示等类似图形组件。1. 用 paintEvent 自绘电池进度条为什么不用现成控件接手过一个模拟项目X的仪表盘界面第一版偷懒用QLabel加QProgressBar拼电池图标结果拉伸窗口时三个控件各跑各的间距错位,圆角对不上深色背景下还有白边。后来改成在QWidget里重写paintEvent一个类搞定所有绘制坐标、圆角、颜色全部统一管理才彻底摆脱这种“凑控件”的尴尬。paintEvent的思路是整块电池外观、电量填充、百分比文字都在一次绘图事件里完成。它不只适合电池信号强度、柱状图、自定义仪表盘这类需要动态反馈的组件都能复用这套写法。适合正在用Qt做桌面客户端、嵌入式HMI界面或者想搞懂QPainter坐标与绘图机制的人。这篇文章会把绘制顺序、比例换算、状态配色、抗锯齿、自适应缩放这些关键点拆开讲最后附上踩过的坑和参数调整心得照着可以直接落地到自己的项目里。2. 理解 paintEvent 与 QPainter先搞清楚绘图顺序和坐标体系2.1 绘制顺序决定了外观层级QWidget的所有绘制都发生在paintEvent里你需要在这个函数里按从下到上的顺序画出背景、外框、内芯和文字。电池的层级关系是最底层是半透明外框或外壳底色然后是内部电量的填充矩形最上面是百分比文字。这个顺序反了填充色就会盖住文字或者外框把内容切掉一块。void BatteryWidget::paintEvent(QPaintEvent *event) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); // 抗锯齿 painter.setRenderHint(QPainter::TextAntialiasing, true); // 文字抗锯齿 // 1. 绘制电池外壳圆角矩形描边 drawBatteryShell(painter); // 2. 绘制电量填充以正极到负极的坐标方向 drawBatteryCore(painter); // 3. 绘制百分比文本 drawBatteryText(painter); }逻辑说明paintEvent是QWidget的受保护虚函数窗口需要重绘时系统会自动调用。在这个函数里创建QPainter对象并设置两个渲染提示——Antialiasing是让电池圆角边缘不再有锯齿TextAntialiasing是让百分比文字更平滑。然后按三个步骤依次绘制每个步骤用独立函数封装便于单独调试和修改。event参数是绘制事件的描述对象在自定义绘制时一般用不到但函数签名必须保留。参数说明setRenderHint的枚举值可以组合比如同时开启两个抗锯齿就写两次调用这是最常见的写法。如果界面缩放比例很大且性能紧张可以关掉Antialiasing圆角边缘会出现肉眼可见的像素锯齿但绘制速度会提升嵌入式设备上经常需要这么取舍。2.2 坐标系与矩形边距计算电池绘图最容易被绕进去的是坐标系。QPainter的(0,0)点在左上角X轴向右延伸Y轴向下延伸和数学里的坐标不一样。绘制时我习惯先设定一个电池内容区contentRect在构造函数或者resizeEvent里计算好后面所有子组件都基于这个矩形做相对偏移。void BatteryWidget::resizeEvent(QResizeEvent *event) { QWidget::resizeEvent(event); int w width(); int h height(); // 电池头正极凸起部分占整个宽度的 8% m_headWidth qRound(w * 0.08); // 外壳圆角半径取高度的 20%高度太小时做下限保护 m_cornerRadius qMax(2, qRound(h * 0.2)); // 绘制内容区右侧减去电池头宽度 m_contentRect QRect(m_headWidth, 0, w - m_headWidth, h); // 内芯填充区比外壳小 4px留出外壳描边空间 int margin 4; m_coreRect m_contentRect.adjusted(margin, margin, -margin, -margin); }逻辑说明resizeEvent在窗口尺寸变化时触发这里把所有比例相关的几何参数都集中计算避免在paintEvent里做除法运算。width()和height()返回当前控件像素尺寸。电池头宽度按整宽8%估算视觉上比较接近真实电池比例。外壳和内芯之间留4像素边距这样外框描边不会和内芯填充重叠缩小到80px高度时这个边距依然清晰可见。参数说明cornerRadius取高度的20%是为了让圆角随控件大小等比缩放但用qMax包了一层下限保护避免高度太小算出0导致直角。margin值4px是按60px高度选的如果你把控件拉伸到200px高度视觉上边距可以适当加大到8px更协调——这个值可以做成可配置属性不用硬编码。2.3 内芯矩形按电量比例动态变化电量填充不是画满整个内芯区域而是按百分比线性截取。这里有两个主流做法一种是从左往右填充矩形另一种是从正极方向往负极方向收缩。多数手机电池图标是正极在右侧电量耗尽时内芯从右侧往左侧缩短所以填充矩形的宽度应该是总宽乘以百分比。void BatteryWidget::drawBatteryCore(QPainter painter) { if (m_percent 0) { return; } double ratio m_percent / 100.0; int fillWidth qRound(m_coreRect.width() * ratio); QRect fillRect QRect(m_coreRect.left(), m_coreRect.top(), fillWidth, m_coreRect.height()); QColor fillColor getLevelColor(m_percent); painter.setPen(Qt::NoPen); painter.setBrush(fillColor); painter.drawRoundedRect(fillRect, m_cornerRadius - 2, m_cornerRadius - 2); }逻辑说明percent是类成员变量调用方通过setPercent接口更新。ratio把百分比转成0到1之间的小数乘以内芯矩形宽度得到实际填充像素宽度。fillRect从内芯矩形左边开始宽度按比例截取。setPen(Qt::NoPen)很重要——如果不设NoPen画笔会用默认颜色给这个圆角矩形描边看起来像填充区域被套了个框。填充颜色getLevelColor按电量区间返回不同颜色后面配表细说。参数说明圆角半径在这里减去2因为内芯矩形比外壳小如果直接用外壳圆角半径视觉上内部圆角会显得比外壳更大减掉2正好和内缩边距匹配。这个数值可以根据margin微调我一般是让内芯圆角半径等于cornerRadius减margin的一半。3. 电池外观细节与状态配色圆角、描边、百分比文本一次成型3.1 外壳描边和电池头绘制电池需要画出金属外壳轮廓和正极凸起。外壳用圆角矩形描边电池头用一个普通矩形加可选的小圆角。两个部分可以用同一种颜色绘制但描边和填充要区分开。void BatteryWidget::drawBatteryShell(QPainter painter) { // 外壳只描边不填充模拟金属边框 QColor borderColor(180, 180, 180, 255); painter.setPen(QPen(borderColor, 3)); painter.setBrush(Qt::NoBrush); painter.drawRoundedRect(m_contentRect, m_cornerRadius, m_cornerRadius); // 电池头与外壳同色填充连接处不需要圆角 QRect headRect QRect(0, m_contentRect.height() / 4, m_headWidth - 2, m_contentRect.height() / 2); painter.setPen(QPen(borderColor, 2)); painter.setBrush(borderColor); painter.drawRect(headRect); }逻辑说明外壳画笔宽度设为3像素这样在小尺寸下描边依然明显。电池头高度取内容区高度的四分之一到四分之三之间也就是中间的二分之一长度左右位置从0开始占满整个headWidth。连接处不画圆角是因为真实电池正极和外壳是焊接关系直角过渡反而更自然。参数说明画笔宽度3是按60px高度设计的。如果控件放到150px高度3像素会显得太细看起来像细线框建议把画笔宽度做成和cornerRadius相关的变量比如qMax(2, h/20)。headRect的left从0开始宽度给headWidth减去2像素是为了和外壳之间留1像素视觉间隙避免两个图形块完全贴死显得闷。3.2 电量区间配色低电量红色、正常绿色、充电黄色电池的电量状态直接反映在填充色上这是UI反馈最直观的一环。我习惯用一个独立的取色函数通过switch或if分支控制区间色值。QColor BatteryWidget::getLevelColor(int percent) { if (percent 20) { return QColor(230, 66, 66); // 危险红色 } else if (percent 50) { return QColor(240, 180, 60); // 警告琥珀色 } else { return QColor(38, 166, 91); // 正常绿色 } }逻辑说明20%、50%是两个阈值低于20%用高饱和红色引起注意20%到50%之间用琥珀色表示电量偏低但还能撑一会50%以上用绿色传达安全状态。这三个颜色都是在RGB空间直接定义的具体值没有用系统调色板保证深浅色背景下都稳定可识别。参数说明阈值和颜色不是写死的我在实际项目里会做成可配置的属性或从QSS读取。有时客户要求低电量用橙色而不是红色或者把警告阈值调整到30%这时候如果颜色写死在函数里就得翻代码改——不划算。建议在类里定义三个QColor成员变量构造函数给默认值外部可以通过setter覆盖。3.3 百分比文字的绘制与居中算法百分比文本画在内芯中间偏右的位置字体大小应该随控件高度变化不然拉伸窗口后文字会和填充矩形重叠或者溢出。文字居中要用QFontMetrics的boundingRect计算实际像素包围盒再偏移到目标位置。void BatteryWidget::drawBatteryText(QPainter painter) { if (m_showText false) { return; } QString text QString::number(m_percent) QString(%); QFont font painter.font(); font.setPixelSize(qMax(10, m_coreRect.height() / 2)); font.setBold(true); painter.setFont(font); QFontMetrics metrics(font); QRect textRect metrics.boundingRect(text); int x m_coreRect.center().x() - textRect.width() / 2; int y m_coreRect.center().y() textRect.height() / 2; painter.setPen(Qt::white); painter.drawText(x, y, text); }逻辑说明字体高度取内芯矩形高度的一半下限10像素防止控件太小时文字不可读。boundingRect返回不带任何缩放的文字包围矩形x坐标用中心点减去文字宽一半实现水平居中y坐标用中心点加文字高一半实现垂直居中——注意drawText的y参数是文字基线而不是顶部所以是加而不是减。画笔设为白色因为填充区在低电量时是深红色白字在任何背景上对比度都足够。参数说明m_showText是控制是否显示文字的总开关有些场景电池图标很小不需要文字只留填充色块。font.setPixelSize用的是像素而不是point因为paintEvent是在像素坐标系中工作point在缩放因子下可能出现模糊。textRect的height()在不同字体下可能包含行距如果追求像素级垂直居中可以用ascent加descent替代。3.4 动态刷新setPercent 接口与 update 触发重绘外部修改电量之后必须触发重绘否则界面不会变化。setPercent里做范围限定然后调用update()。void BatteryWidget::setPercent(int percent) { m_percent qBound(0, percent, 100); // 电量从 20 跨越到 20 时即使数字没变也可能有视觉歧义 if (m_percent ! m_lastPercent) { m_lastPercent m_percent; update(); } }逻辑说明qBound把传入的百分数限定在0到100之间——有些业务逻辑可能传入负数比如估算误差不处理的话ratio会变成负值QRect构造会得到反向矩形绘制结果不可预期。这里做了一个简单的变化检测只有百分比真的改变时才触发update避免同一数值反复重绘消耗CPU。update()是异步的它会把控件标记为“脏区域”等待事件循环进入下一次绘制时统一处理不会立即调用paintEvent——所以连续多次setPercent也只会触发一次或少量重绘这是Qt的合并机制性能上是安全的。参数说明m_lastPercent是新增的成员变量在构造函数里初始化为-1这样第一次setPercent即使传0也会触发重绘。如果你需要强制同步刷新比如动画场景可以调用repaint()但会阻塞当前线程直到绘制完成不建议在频繁调用的地方使用。4. 避坑指南paintEvent 绘制的五个常见问题与排查方法4.1 电池头分叉变成一块黑色方块现象绘制出来的电池正极不是凸起的小矩形而是一个填满左上角的大黑块。原因drawBatteryShell里设置画笔和画刷的代码顺序有坑——有时画笔设置了颜色下一行setBrush又随手设置成黑色或者画笔用了默认颜色叠加在填充外框上。另一个常见原因是headRect的高度或宽度计算使用了负值比如contentRect.height()在窗口未完成布局时返回0除4得到0而drawRect对零高度矩形会直接绘制一条线而非填充。解决每一步设置画笔、画刷前先明确要用哪个对象。我一般是先setPen再setBrush两者都要显式指定不依赖上一次的状态。坐标计算统一用resizeEvent里缓存的成员变量不要在paintEvent里现场计算依赖布局的值——布局完成前width()可能尚未更新。调试时可以在paintEvent里临时加qDebug输出矩形坐标确认是不是负数。4.2 设置样式表后外壳圆角内部出现白色背景现象在外部给BatteryWidget设置了QSS比如background-color: transparent绘制电池时圆角矩形内部有一块白色或灰色底怎么也消不掉。原因QSS的background-color作用于QWidget的paintEvent绘制之前相当于在自绘内容下面画了一层背景。当QSS背景色不是透明时就会盖住你绘制的外壳填充色。解决给这个自绘控件设置一个空样式或者属性选择器排除样式影响。常见做法是在构造函数里setAttribute(Qt::WA_StyledBackground, false)或者直接setStyleSheet(background: transparent;)。更干净的方式是让BatteryWidget继承QWidget时重写paintEvent同时把setAttribute(Qt::WA_OpaquePaintEvent, true)设置上告诉Qt这个控件完全由自己绘制不需要清理背景。4.3 窗口缩放后内芯矩形底部露出一个小缺口现象窗口高度拉大后填充矩形的底部或右侧出现未绘制区域像电池被削了一角。原因paintEvent里直接使用了width()和height()计算坐标而窗口缩放过程中这两个值在resizeEvent和paintEvent之间存在不一致——resizeEvent已经更新了成员变量但paintEvent可能在同一个事件循环里先执行了。另一个原因是用m_coreRect.adjusted加边距时边距值大于矩形自身尺寸导致QRect变成负宽度绘制结果不可预测。解决强制在paintEvent里重新取一次实时尺寸或者用一个标志位让resizeEvent和paintEvent之间保证同步。我一般是在resizeEvent里重新计算所有矩形同时调用update()这样paintEvent拿到的必然是最新几何。调整边距时加一个最小尺寸保护比如margin值不超过coreRect短边的四分之一。如果问题只出现在极限缩放也可以把QRect运算换成QRectF浮点运算在边界处容错性更好。4.4 电量从 100% 变成 99% 时内芯右边出现一条缝隙现象电量100%时内芯填充矩形与外壳描边之间贴合紧密但显示99%时右侧出现约1像素的缝隙肉眼仔细看能看到背景色。原因fillWidth是用double类型乘以矩形宽度后qRound取整。100%时fillWidth等于m_coreRect.width()而99%时乘出的小数被四舍五入到整数可能比完整宽度少1像素这1像素宽度就是缝隙产生的根源。解决在绘制填充矩形时如果百分比大于等于99直接把fillRect的宽度强制设成m_coreRect.width()或者用qCeil代替qRound保证接近满电时不会留缝。更稳妥的做法是把fillRect的right值用qMin限幅到m_coreRect.right()这样即使计算误差超了也不会越界。低电量同理小于1%时直接返回不绘制而不是画一个1像素宽度的细条。4.5 文字在部分显示屏上发虚或带锯齿现象同一段代码在不同分辨率或缩放比例下百分比文字边缘模糊和小字号的系统字体对比明显。原因QFontMetrics计算出的boundingRect和实际绘制时字体渲染存在偏差尤其是字体启用了subpixel antialiasing时会更明显。另一个因素是drawText的坐标计算使用了整数字体在非整数倍缩放时可能落在像素边界上。解决改成QRectF浮点坐标绘制或者用QPainterPath包裹文字路径。我在项目里一般把drawText的x和y全部转成double绘制前统一加0.5做像素偏移校准。如果文字模糊持续存在最直接的方案是换字体等宽字体在小字号下通常比黑体更锐利。还要确认控件的devicePixelRatio设置正确Qt高DPI模式下忽略了devicePixelRatio会导致整个绘制内容的模糊——包括外壳圆角。// 高DPI适配 void BatteryWidget::setDeviceScale(qreal scale) { m_scale scale; m_contentRect QRectF(m_headWidth * scale, 0, (w - m_headWidth) * scale, h * scale); update(); }逻辑说明这段展示了高DPI下用缩放因子统一调整坐标的做法。devicePixelRatio在高分屏下通常为2或更高所有基于像素的几何计算都要乘以scale否则绘制结果只有屏幕的四分之一大小。m_contentRect从QRect改成QRectF后浮点坐标即使在非整数倍缩放下也不会产生累积误差。如果不需要支持高DPI这段可以整个忽略但移动端和Retina屏幕的项目建议提前写好。参数说明scale值由外部传入不应该在控件内部硬编码获取因为有些场景需要手动覆盖系统默认值。比如远程桌面或虚拟机里系统DPI报告和实际显示不匹配手动设置scale能矫正绘制效果。5. 进阶技巧从电池进度条到通用自绘控件5.1 把几何计算抽成独立方法方便其他项目复用BatteryWidget的核心逻辑其实只有三件事计算内容矩形、计算填充矩形、计算文字居中位置。这三件事可以抽成一个独立的布局计算函数不依赖任何Qt控件只做纯数学运算然后任何自绘控件都能调用同样的方法。struct BatteryLayout { QRectF shellRect; QRectF coreRect; QRectF textRect; qreal headWidth; }; BatteryLayout calculateBatteryLayout(qreal w, qreal h, qreal percent, qreal shellMargin, qreal cornerRatio) { BatteryLayout layout; layout.headWidth w * 0.08; qreal radius qMax(2.0, h * cornerRatio); layout.shellRect QRectF(layout.headWidth, 0, w - layout.headWidth, h); layout.coreRect layout.shellRect.adjusted(shellMargin, shellMargin, -shellMargin, -shellMargin); layout.coreRect.setWidth(layout.coreRect.width() * percent / 100.0); return layout; }逻辑说明这个纯函数不依赖QWidget的成员状态任何地方传入宽高和百分比都能得到完整布局结果。这样做的好处是可以在单元测试里直接验证不同输入下矩形是否正确不需要实例化控件跑事件循环。结构体BatteryLayout统一封装所有绘制所需的几何参数绘制函数只需要从这个结构体里取数值职责拆分清楚。参数说明cornerRatio控制圆角高度比例不同风格可以传0.15到0.25的不同值。shellMargin默认4像素但如果高度小于30像素传入的margin会导致计算出的coreRect高度为负——调用方需要在外层做最小尺寸限定或者在函数里再加一层qMax保护。5.2 用单例缓存字体和颜色避免paintEvent里反复创建paintEvent每帧都会执行如果每次绘制都new一个QFont或者QColor在60帧刷新率下会累积大量内存分配和释放。更麻烦的是QFontMetrics每次构造都要做字体度量计算相当耗时。void BatteryWidget::drawBatteryText(QPainter painter) { static QFont s_cacheFont; static QColor s_cacheColor; if (m_fontDirty) { s_cacheFont painter.font(); s_cacheFont.setPixelSize(qMax(10, m_coreRect.height() / 2)); s_cacheFont.setBold(true); } painter.setFont(s_cacheFont); if (m_colorDirty) { s_cacheColor getLevelColor(m_percent); } painter.setPen(s_cacheColor); }逻辑说明静态局部变量在首次调用时初始化后续调用直接复用不会再创建对象。m_fontDirty和m_colorDirty是用成员标志位控制的失效标记——字体相关参数变了才重建字体电量颜色变了才重新计算颜色。这样paintEvent体积变得很小绘制路径里没有昂贵的构造和析构操作。参数说明静态局部变量的生命周期是整个程序如果在多个BatteryWidget实例之间共享状态需要考虑颜色是否会串。这里cacheColor按percent计算不同实例如果百分比不同会互相覆盖。我的做法是把这些对象提升为成员变量标志位操作换成成员变量版本性能和静态变量差不多但多实例间互不干扰。5.3 给电池增加充电动画和边框发光效果电量进度条最常用的扩展效果有两个充电时的呼吸高亮和低电量时的边框红色闪烁。呼吸高亮的本质是让填充色在基准色和亮色之间周期性插值可以用QTimer配合一个透明度变量实现。边框发光则用QPainter的绘制路径阴影或者多层描边叠加完成不需要额外引入QGraphicsDropShadowEffect——那个在paintEvent里不起作用。void BatteryWidget::startChargingAnimation() { m_charging true; m_glowOpacity 0.0; m_animTimer.start(50); // 50ms 刷新一次 }逻辑说明QTimer每50毫秒触发一次在槽函数里让m_glowOpacity在0到1之间来回摆动。绘制时用当前充电状态计算颜色把fillColor的lightness调亮glowOpacity对应的梯度值。这样不需要开线程所有更新都通过update()汇入主线程绘制流程和paintEvent天然兼容。定时器在窗口隐藏时记得停止省电也避免无意义的重绘。参数说明50毫秒对应20FPS的动画刷新率——视觉上已经平滑但CPU占用比30FPS低不少。如果追求更细腻的呼吸效果可以改成30毫秒但要注意低端嵌入式平台上高频率定时器可能导致主线程卡顿。边框红色闪烁用同样的定时器机制在电量低于20%时切换描边颜色比单色常亮更醒目。5.4 用 Q_PROPERTY 暴露可配置属性让 QSS / QML 可以直接控制把电量百分比、边框颜色、警示阈值注册成Qt属性外部用setProperty就能动态修改这比手写一堆setter更灵活。Q_PROPERTY注册后QML和QSS也能通过属性名直接操作不用改C代码就能换皮肤。Q_PROPERTY(int percent READ percent WRITE setPercent NOTIFY percentChanged) Q_PROPERTY(QColor fillColor READ fillColor WRITE setFillColor NOTIFY colorChanged)逻辑说明Q_PROPERTY把C成员变量暴露给Qt元对象系统。percent属性有读函数、写函数和信号外部赋值后自动触发通知信号任何绑定了这个属性的UI组件都会同步更新。我在实际项目里靠这个机制做了主题切换——把填充主色、描边色、低电量阈值全部注册成属性运行时通过配置文件直接改外观不需要重新编译。参数说明NOTIFY信号必须存在才能支持MVVM双向绑定如果只是单向显示信号可选。setPercent里如果值没变化记得不要发信号否则会引发无意义的级联刷新。还有一点容易被忽略——用Q_PROPERTY注册的属性建议使用getter和setter而非直接暴露public成员这样可以在setter里做范围校验、触发update保证进入绘制流程的数据一定是合法的。从那以后我每次做自绘控件都强制走一遍这个流程先理清绘制层级再把几何计算从paintEvent里抽出来最后才考虑配色和动画。抽布局计算这个习惯帮我省掉了大量调坐标的时间现在给同事演示或者改需求都只需要动一个纯函数就能看到效果不用在绘图事件里反复试错。希望这篇笔记能帮你在电池进度条或类似的自绘组件上少踩几个坑快速做出能直接落地的界面。本文还有配套的精品资源点击获取