基于QT属性动画的连击数字动画组件实现与优化
1. 项目概述这个连击动画组件到底在解决什么问题做游戏客户端或者电竞外设配套软件的朋友应该都懂那种感受——连击数Combo这个东西看着就是个数字但做得好不好看直接影响玩家“手感”和“爽感”。我自己在做的某个QT桌面项目里需要频繁展示玩家连击次数最开始图省事直接丢了个QLabel然后setText结果就是数字干巴巴地跳完全没有那种“越连越high”的氛围。后来痛定思痛花了两天时间把这块拆出来重构成一个独立的动画组件也就是这篇文章的主角一个基于QT C、以属性动画为核心、能随时抛到任何项目里复用的“好看的连击动画组件”。这个组件解决的问题很明确第一让连击数字在变化时具有缩放弹跳、颜色渐变、透明度层次感等视觉效果第二在高频更新数字时不卡顿、不闪烁CPU占用可控第三对外提供简洁接口不用每换一个项目就重写一遍动画逻辑。如果你正在用QT做游戏辅助工具、直播弹幕助手、体育比分板或者音游编辑器之类的界面这个组件可以直接抄作业改改接上如果你是QT初学者想弄懂QPropertyAnimation、QGraphicsOpacityEffect这些类怎么配合着玩视觉效果这篇内容也能给你一个完整的可运行思路。我先把最核心的成果放在这里——组件本身只有两个文件一个头文件一个源文件核心逻辑加起来不到300行不依赖任何第三方库纯用QT自带的Widgets模块。运行效果是数字每次变化时以一个短促的“放大-回弹-稳定”节奏呈现并且颜色会从冷色调往暖色调过渡连击越高色调越“燃”。下面我会从设计思路、核心实现、踩坑记录、优化心得这几个角度完整展开。2. 整体设计与核心思路拆解2.1 为什么选择属性动画而不是手动计算坐标在动手之前其实纠结过一条技术路线用QTimer定时器配合手动计算缩放比例、颜色RGB值然后update()刷新绘制。这条路说白了就是自己模拟动画曲线代码写起来自由度最高但有两个现实问题很快暴露。第一QTimer的刷新频率通常是16ms或32ms跟QT自身的绘制触发天然不同步容易出现动画一卡一卡的第二手动计算每次要维护一堆状态变量比如当前进度、目标进度、加减速曲线代码会越写越拖沓。后来我看了下QT的Animation框架发现QPropertyAnimation这玩意儿简直就是为这种场景量身定做的。核心原理其实不复杂它内部基于帧时钟QAbstractAnimation驱动每帧去修改你指定的属性值并且带有缓动曲线easingCurve控制数值变化节奏。这意味着我不需要自己写动画循环只需要定义“从多少到多少”“花多长时间”“用什么变换曲线”剩下的交给QT的动画框架去跑。代码量直接砍一半而且播放过程中去修改目标值、重新调整时长之类的操作也都有现成接口异常情况处理起来方便得多。所以最终的结构就定成了这样一个QLabel子类负责承载显示文本两个QPropertyAnimation实例一个管缩放一个管透明度一个自绘的绘制函数处理数字居中、颜色插值、阴影效果对外暴露一个setCount(int)接口外部只管传入连击数字不用管动画细节。我还在组件里加了一个可选的“倒计时衰减”模式就是连击数字一段时间不变就自动淡出归零。这个模式在直播弹幕、记分牌场景里特别好用但默认是关闭的避免给不需要的人添乱。2.2 动画节奏与视觉心理为什么“回弹”比纯放大更带感这里想多说一句视觉层面的设计逻辑。很多人做数字动画喜欢让数字从某一帧开始匀速放大到目标大小就结束了但实际跑起来你会发现这个效果很“呆”。真正有“打击感”或者“连击爽感”的数字动画几乎都有一个共同点先快速越过目标值再回落到稳定值。这叫“overshoot”效应和弹簧过冲是同一个原理。QPropertyAnimation的缓动曲线里正好有个叫QEasingCurve::OutBack的曲线就是专门模拟这种“后退-冲过头-回位”的感觉。我实际试过OutCubic、OutElastic、OutBack好几种曲线最终锁定了OutBack因为它的过冲幅度恰到好处——不会像OutElastic那样弹个没完数字都停了还在震又比OutCubic多了一层“duang”一下的肉感。吃鸡游戏里开镜的动画、MOBA游戏里击杀数字跳出来的节奏基本都是这个调调。除了缩放透明度也做了配合。数字在放大过程中透明度从0快速升到80%随后回弹过程中逐步升到100%。换句话说每一次数字跳动都有一闪而过的“残影”这个残影是动画早期半透明状态留下的视觉暂留会让眼睛觉得变化是连续的、有层次的。这个效果在纯白背景下可能不明显但只要背景稍暗一点立刻就能看出区别。3. 核心实现拆解动画、绘制与接口设计3.1 类的骨架最小可用结构这里直接上代码的核心骨架我做了删减保留最关键的部分方便看逻辑。// CombCountLabel.h #pragma once #include QLabel #include QPropertyAnimation #include QGraphicsOpacityEffect class CombCountLabel : public QLabel { Q_OBJECT Q_PROPERTY(qreal scaleFactor READ scaleFactor WRITE setScaleFactor) Q_PROPERTY(int colorStage READ colorStage WRITE setColorStage) public: explicit CombCountLabel(QWidget* parent nullptr); void setCount(int count); void setCountSilently(int count); // 直接用新值渲染不播动画 protected: void paintEvent(QPaintEvent* event) override; private slots: void onScaleFinished(); private: void buildAnimations(); void updateColorStageTarget(); qreal scaleFactor() const; void setScaleFactor(qreal s); int colorStage() const; void setColorStage(int stage); int m_count 0; int m_colorStage 0; qreal m_scaleFactor 1.0; QPropertyAnimation* m_scaleAnim nullptr; QPropertyAnimation* m_opacityAnim nullptr; QGraphicsOpacityEffect* m_opacityEffect nullptr; };眼尖的朋友会发现我不仅加了scaleFactor还加了一个colorStage属性。这个属性不是直接存颜色值而是存一个阶段索引——0到10的数字颜色计算的时候从冷蓝色线性过渡到橙红色。为什么这么做因为如果直接用QColor做动画属性QPropertyAnimation对QColor的插值确实也是支持的但一方面颜色通道的线性插值视觉上并不均匀容易中间出现很脏的灰色另一方面我自己希望颜色变化的节奏跟缩放解耦这样我可以让颜色慢悠悠过渡而缩放快进快出两者互不干扰。用阶段索引外部映射函数灵活性大得多。3.2 动画创建与属性绑定细节这一块是坑最多的地方我把完整代码放出来然后逐个讲为什么这么写。// CombCountLabel.cpp 片段 CombCountLabel::CombCountLabel(QWidget* parent) : QLabel(parent) { setAlignment(Qt::AlignCenter); setAttribute(Qt::WA_TransparentForMouseEvents); m_opacityEffect new QGraphicsOpacityEffect(this); m_opacityEffect-setOpacity(1.0); setGraphicsEffect(m_opacityEffect); m_scaleAnim new QPropertyAnimation(this, scaleFactor, this); m_scaleAnim-setDuration(280); m_scaleAnim-setEasingCurve(QEasingCurve::OutBack); m_scaleAnim-setKeyValueAt(0.0, 1.0); m_scaleAnim-setKeyValueAt(0.5, 1.35); m_scaleAnim-setKeyValueAt(1.0, 1.0); m_opacityAnim new QPropertyAnimation(m_opacityEffect, opacity, this); m_opacityAnim-setDuration(200); m_opacityAnim-setEasingCurve(QEasingCurve::OutCubic); m_opacityAnim-setKeyValueAt(0.0, 0.3); m_opacityAnim-setKeyValueAt(0.7, 1.0); m_opacityAnim-setKeyValueAt(1.0, 1.0); connect(m_scaleAnim, QPropertyAnimation::finished, this, CombCountLabel::onScaleFinished); }几个关键点逐一说明。第一scale动画我用了setKeyValueAt而不是简单的setStartValue/setEndValue。这两个接口的区别在于start/end只能用一段线性或缓动曲线中间只会路过两个值而keyValueAt可以定义多个关键帧配合缓动曲线形成复合节奏。我在这里定义了0%50%100%三个关键帧分别是1.0、1.35、1.0。这意味着动画前半程数字放大后半程缩小回原位。同时OutBack曲线会在关键帧之间做过冲处理反映到最终效果上就是比单纯两个关键点多了一层“顿挫感”。我对比过只设置start1.0、end1.0配OutBack过冲直接从初始值开始开头会有一种忽然缩小的错觉不太舒服所以关键帧必须这样设。第二opacity动画的targetObject我传的是m_opacityEffect这个QGraphicsOpacityEffect对象而scale动画的targetObject是this本身。这是QT动画框架里很考验理解的地方——QPropertyAnimation的模板是“目标对象属性名”它不管属性在哪个对象上只要目标对象的meta-object系统里找得到这个属性就行。QGraphicsOpacityEffect自带opacity属性我直接拿它做动画对象比自己额外维护半透明状态方便太多。很多新手不知道GraphicsEffect本身可以作为动画目标于是自己写一堆透明度计算走了弯路。第三注意setAttribute(Qt::WA_TransparentForMouseEvents)这个设置。连击数字作为一个纯展示组件如果用户正好在它上面拖拽鼠标或点击按钮它不应该拦截任何事件。QLabel默认虽然也不拦截鼠标但设置了更保险尤其在和QStackedWidget、QGraphicsView混搭的场景下有这一句能省掉一些诡异的事件穿透问题。3.3 setCount接口与动画触发策略void CombCountLabel::setCount(int count) { if (count m_count) return; m_count count; // 关键如果上一个动画还在播先把它强拧到终点 m_scaleAnim-stop(); m_opacityAnim-stop(); // 重置起始状态 setScaleFactor(0.7); m_opacityEffect-setOpacity(0.0); // 根据连击数字动态调整目标颜色阶段 updateColorStageTarget(); // 更新文本但注意文本更新放在动画启动之前 QLabel::setText(QString::number(count)); update(); // 启动缩放 m_scaleAnim-start(); // 透明度动画保持同步启动 m_opacityAnim-start(); }这段逻辑背后有一个很重要的设计原则高频连击时动画必须“可打断”。你想连击数字从5跳到6动画刚播到一半用户又打出了7如果我把动画stop掉然后重新从0开始那显示上会有一小段“跳回去再弹上来”的不自然感。但如果不stop而是直接叠加新动画QT的动画框架底层对同一属性的并发支持也不是那么好会互相抢目标值表现为随机跳动。我的解法其实是个折中每次更新先stop掉所有旧动画但把“初始状态”设置为0.7而不是1.0。这样即使数字连跳视觉上也只是“从缩小的位置弹上来”并没有人眼无法接受的跳变。实测下来连打十几连的数字体感全是连续的膨胀收缩不突兀。另外注意到我把文本更新放在了动画启动之前。这样绘制系统会在启动动画那一刻就已经持有新文本动画第一帧的缩放虽然小但文字内容已经是新的了。如果你先启动动画再setText可能有一两帧显示的是旧数字肉眼不一定看得出来但录屏慢放就会穿帮。3.4 颜色阶段的计算逻辑void CombCountLabel::updateColorStageTarget() { int targetStage qBound(0, (m_count - 1) / 5, 10); // 每5连击变一档 // 颜色动画用定时器逐步逼近目标而不是直接跳变 if (targetStage m_colorStage) { m_colorStage; } else if (targetStage m_colorStage) { m_colorStage--; } }这里我设计了一种“每次连击只变化一个颜色档位”的策略。比如从0连击涨到12连击目标颜色档位是2但代码不会直接跳到2而是每触发一次setCount只加减1。这样做有两个好处视觉上颜色是逐级过渡的而不是突然从蓝变橙那种突变在暗色背景里特别刺眼另外这种递进式变化天然自带“越连越燃”的渐进情绪配合缩放动画非常舒服。颜色档位映射函数我写成每5次连击换一档实际项目中你也可以改成线性映射或者先用对数映射压一下后期变化幅度看你的UI调性。具体颜色换算在paintEvent里做从QColor(70, 130, 255)到QColor(255, 120, 50)做一个分段线性插值每一档对应色相环上的一个位置。这个方法不够“高级”但胜在可控想调成紫色主题或者红色主题只需要改两端颜色值其余不用动。3.5 绘制与阴影处理绘制这块比较反直觉我直接说结论连击数字的阴影不能用QGraphicsDropShadowEffect必须自己在paintEvent里画。为什么因为QGraphicsDropShadowEffect是基于场景级模糊渲染的它会把整个QLabel的内容离屏渲染到一张半透明图像上再做高斯模糊。如果QLabel最终显示的控件尺寸频繁变化比如数字从一位数变成两位数离屏图像每次都要重新分配内存再加上模糊计算帧率会有很明显的掉帧。更麻烦的是阴影effect和透明度effect不能同时挂在一个QGraphicsEffect链路上你挂了阴影再挂透明度会发生覆盖结果就是动画播到一半阴影消失。所以我放弃了effect方案改用直接绘制文字阴影void CombCountLabel::paintEvent(QPaintEvent* event) { Q_UNUSED(event); QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); painter.setRenderHint(QPainter::TextAntialiasing); QFont f font(); f.setBold(true); f.setPixelSize(qRound(height() * 0.6)); painter.setFont(f); // 颜色插值 QColor frontColor interpolateColor(); QColor shadowColor(0, 0, 0, m_colorStage * 12); // 阴影随连击数略微加深 QRectF r rect(); // 先画阴影偏移2像素 painter.setPen(Qt::NoPen); painter.setBrush(shadowColor); painter.drawText(r.translated(2, 2), Qt::AlignCenter, text()); // 再画前景 painter.setPen(frontColor); painter.setBrush(frontColor); painter.drawText(r, Qt::AlignCenter, text()); // 手动应用缩放效果 if (m_scaleFactor ! 1.0) { // 这里需要配合transform但实际上上面的绘制已经在paintEvent里 // 所以缩放通过painter.scale实现更合理 } }上面这段代码有个问题就是缩放没有真正应用到绘制上。因为QPropertyAnimation修改的scaleFactor属性只是一个普通成员QLabel的绘制流程不会自动读它。我最终采用的方案是重写paintEvent并手动应用变换矩阵但代码里用了一个小技巧通过painter的translatescale来整体缩放绘制区域。简单说就是painter.save(); painter.translate(rect().center()); painter.scale(m_scaleFactor, m_scaleFactor); painter.translate(-rect().center()); // ... 绘制文字、阴影 ... painter.restore();这么做之后整个文字内容包括阴影都会同步缩放效果非常统一。有的实现会只缩放文字的font大小但那样阴影位置会跟着变画面就不稳了。3.6 对外接口与使用示例组件最终对外只保留三个接口我已经在头文件里标注得很清楚setCount(int)设置连击数自动触发动画setCountSilently(int)设置连击数但不播放动画用于初始值setDecayTimeout(int ms)开启/调整连击衰减定时器传0表示关闭。集成到你自己的界面上只需要这样auto* comboLabel new CombCountLabel(parentWidget); comboLabel-setGeometry(100, 100, 120, 80); comboLabel-setCountSilently(0); // 当游戏/业务逻辑判定连击增加时 comboLabel-setCount(newCount);组件本身不关心你的连击数字从哪来——可能来自键盘连打、传感器信号、网络消息它只负责把一个数字“有灵魂地”展示出来。这种解耦是我刻意保持的也是它能够快速复用到不同项目里的关键。4. 实操中的常见问题与排查实录这部分是重点。写这个组件的过程中我前后踩了大概七八个坑有几个一度让我怀疑是不是QT的bug后来查源码才发现是自己理解不到位。选几个最有代表性的记录下来给后来人省时间。4.1 动画播放时QSS样式表失效第一次跑起来的时候我把字体颜色通过QSS设置成白色QSS和前面的颜色插值逻辑冲突了。现象很随机有时候数字颜色是白色有时候动画一播就变成我代码里的渐变色过一会儿又变回白色。排查之后发现QLabel的QSS文本颜色绘制优先级高于paintEvent里painter.setPen设置的颜色。因为你一旦给控件设了qss哪怕只是border-radius或font-sizeQT内部的样式系统会把文字的绘制接管过去paintEvent里再设置画笔颜色也不会完全生效。解法要么是彻底不用qss一切的padding、color、font只通过代码设置要么是绕开QLabel绘制直接自绘整个控件不继承QLabel的绘制路径。我自己最后选了后者把类继承改成了QWidget而不是QLabel所有显示内容完全自绘。这样自由度最大但也意味着失去了一些QLabel现成的文本布局能力权衡下来还是值得。这个坑其实很隐蔽因为默认情况下QLabel没设置qsspaintEvent里的画笔是生效的但只要你为了圆角、内边距什么的加了样式表问题就来了。所以如果你也打算做类似的自绘文本动画控件建议直接从QWidget起步别和QLabel纠缠。4.2 透明度effect和阴影effect互相打架这个上面已经提到过但值得单独说一遍现象。我最初给组件挂了两个graphicsEffect一个透明度动画用的QGraphicsOpacityEffect一个阴影用的QGraphicsDropShadowEffect结果动画播放到透明度为0.3左右的区间时阴影忽然整个消失透明度恢复后阴影又回来。这个问题的本质是setGraphicsEffect只能挂一个effect且effect对象内部是一个串行链。重复调用setGraphicsEffect会把前一个effect从widget上摘除但没delete只是断开了关联。所以第二次挂阴影时透明度effect实际上已经不生效了动画虽然还在跑但目标对象m_opacityEffect已经没有附着在任何widget上自然看不到透明度变化。而阴影显示正常是因为它是后来挂上去的那个。搞明白这一点之后我彻底放弃了阴影effect改用painter自绘阴影。损失是阴影没有模糊效果只有简单的偏移半透明填充但换来的是性能稳定和效果可控。如果你特别需要模糊阴影另一个思路是把QGraphicsDropShadowEffect挂到一个容器widget上容器内部再放这个Label但这样层级关系会复杂一点按需取舍。4.3 高DPI缩放下文字模糊在Windows高DPI或Linux不同缩放因子的显示器上组件里的文字偶尔会出现边缘发虚。这问题跟动画无关但一旦动画在播放模糊会更显眼所以排查时很容易误判成动画导致的绘制缺陷。原因在于paintEvent里我最初用font().setPixelSize(qRound(m_scaleFactor * 100))这种方式驱动缩放而pixelSize在DPI缩放环境下要和设备的逻辑分辨率换算。QT6.2以后默认开启了高DPI逻辑分辨率到物理分辨率的映射不再永远是1:1此时用pixelSize指定文字大小在缩放时会按四舍五入取整小数部分被截断就产生模糊。我的解决办法不要用pixelSize创建字体始终用pointSize然后让QT自己映射或者更彻底一点直接走painter.scale变换矩阵驱动缩放而不是重新改字体大小。最终实现里我是后者文字字体只在初始化时确定一次后续所有缩放效果都是矩阵变换完成的这样就绕开了字体像素取整的问题在不同DPI下都保持清晰。4.4 动画重叠导致数字跳变这个问题在处理高频连击时非常关键。假设setCount连续触发前一个scale动画还没跑完后一个就来了。我最初的实现里没有stop()直接重新start结果表现是数字偶尔会先变小再弹大像在“撅嘴”很不自然。后来加上了stop()并且在stop之后手动把scaleFactor归位到0.7才算稳定。注意这个“归位到0.7”本身就是一个视觉技巧——如果归位到1.0中间会有一帧显示正常大小的数字然后才缩小弹大眼尖的人会觉得闪了一下归位到0.7以后整个过程是连续的“从缩小态弹起”闪变感就消失了。同样的问题也会出现在透明度动画上所以透明度动画也得stop重置opacity为0.0。两段代码我封装在一个resetAnimationState()函数里确保setCount入口的调用顺序一致。4.5 文本长度变化导致控件跳动连击数字从9变成10的那一瞬间字符串变长了如果控件宽度刚好不够QLabel会自动换行或者截断整体布局就会跳。而这个跳动如果发生在动画播放中会让缩放效果显得非常廉价。我最后采用了“固定宽度动态调整字体”的策略组件内部计算当前数字文本的宽度和自身宽度做比较如果超出就按比例缩小font的scale不是改pixelSize而是改矩阵缩放系数。这样数字始终保持在一行且永远居中。核心逻辑就一行判断QFontMetrics fm(f); int textWidth fm.horizontalAdvance(text()); if (textWidth rect().width()) { // 按比例压缩缩放系数不要直接改字体 }这里面还有一个细节压缩只影响文字显示大小不影响缩放动画本身的scaleFactor。也就是说真正的弹跳动画和因为文本变长导致的“基础字号压缩”是两条独立逻辑前者做动态后者做静态适配。二者相乘得到最终绘制缩放值。很多组件在这个点上图省事直接改字体大小结果弹跳效果也被连带缩放数字一大就显得特别傻。5. 性能优化与扩展思路5.1 降低无效绘制从update到按需刷新我在早期的版本里setCount之后直接update()然后动画全程依靠QPropertyAnimation的内部刷新来触发repaint。这个方案其实有点浪费因为动画的每一帧都会调用update()即使数字文本完全没变只有缩放和透明度的中间帧需要刷新。这个还好因为显示内容确实在动态变化重绘无可避免。真正的浪费点在于透明度动画阶段。透明度其实并不影响文字内容本身它影响的是整个控件的不透明度。而QGraphicsOpacityEffect的底层实现是把控件离屏渲染一次再按透明度合成。也就是说动画播放的每个中间帧都有一次额外的离屏渲染开销。如果背景是纯色、没有复杂的控件叠加这个开销可以忽略但如果组件被套在一个复杂的复合页面里比如仪表盘、多层浮层离屏渲染的代价就会成倍放大。优化方案有两个方向。一是把透明度动画剥离出GraphicsEffect体系改在paintEvent里手绘一个半透明层。具体做法是在自绘文字之前给整个控件填充一个带透明度的黑色矩形文字照常画在它上面。这样透明度效果依然存在但没有离屏渲染开销。另一个方案是直接砍掉透明度动画只保留缩放和颜色变化。实测下来在复杂页面上后者性能提升更明显但视觉层次感略差。我更推荐前者手绘透明层其实就几行代码的事。5.2 复用动画对象避免重复创建QPropertyAnimation这类对象创建开销不算大但在高频点击场景下比如一秒触发几十次连击刷新重复创建和销毁动画对象的开销就会积累甚至可能导致堆内存碎片。更稳妥的做法是在构造函数阶段就把动画对象new好之后每次启动复用同一个对象只是修改duration或target值。我贴的代码就是这种模式——组件内部m_scaleAnim和m_opacityAnim只创建一次后续都是stop/start切换。与此匹配的一个细节是动画对象停止后相关属性值不会自动恢复初始值除非你手动设置。这就是我在resetAnimationState()里做的事情。如果你忘了这一步下一个动画的起点就可能不是0.7而是上一次动画的某个中间值表现就是越来越“飘”。5.3 扩展连击音效联动与数值飘字组件写完之后我又加了一个小小的扩展当连击数达到5、10、20这样的里程碑时向外发送一个信号。外部可以根据这个信号触发音效或者在屏幕上再飘出一个“5连击”“太强了”之类的额外文案飘字。这个扩展成本极低就是在setCount里加一个判断if (m_count % 5 0) { emit milestoneReached(m_count); }信号带上当前连击数值外部自行决定视觉效果。这个设计保持了组件的独立性——组件本身不做音效和飘字只负责告诉你“该庆祝了”。这样它既是一个单纯的显示组件又能很容易地融入到更复杂的游戏反馈体系中。另外如果你需要“连击断裂清零”的动画连击断掉之后数字不是直接变0而是先闪红再淡出清零可以在外部监听业务连击中断事件调用组件的playBrokenEffect()方法把数字颜色闪红一次再归零。这个效果我目前只在demo里实现了还没有完全打磨成熟等稳定了再单独写一篇分享。6. 组件最终效果与自检清单按照上面这套实现最终效果是这样的连击数字跳变时整体弹跳缩放带有OutBack曲线的“过冲-回落”质感数字主色从冷蓝线性过渡到橙红连击越高越“燃”透明度在弹跳前期从0快速浮现视觉上有一层残影感所有动画可打断高频刷新稳定不闪烁不依赖qss/graphics effect从QWidget自绘可自由叠加阴影/透明样式纯QT Widgets模块可一键移植到现有项目。我理解的技术文章不能只展示“成品炫酷”更重要的是让读的人知道为什么这么做、踩过哪些坑。上面的内容基本已经把我从决定动手到现在相对稳定的全过程走了一遍。如果你也正在做类似的连击动画组件可以直接翻到第四章节那些坑大概率你也会遇到少走弯路比什么都强。最后再分享一个从这套实现里沉淀下来的通用心得在QT里做视觉动画组件别迷信“一个效果对应一个Effect对象”这种组装思路GraphicsEffect更适用于静态效果叠加动态的动画效果交给QPropertyAnimation绘制层面的细节交给paintEvent。把握住这三层分工动画逻辑和渲染逻辑都清晰代码维护起来也轻松得多。