1. 组合动画到底解决了什么问题1.1 为什么单个动画不够用QML里单个动画确实好写一个NumberAnimation配target和property就能让控件动起来。但真实界面几乎不可能只靠一个动画撑起来卡片弹入要透明度配合位移按钮按压要有缩放和颜色变化列表项增删要淡入淡出加滑动这些需求只要沾上“多个属性一起变”或者“多个阶段依次跑”就得考虑组合动画。我早先写过不少QML界面最深的感受是如果所有动画都用独立对象写在元素外面每个动画各自设置running、duration最大的问题是调度全靠手工要么同时触发导致节奏混乱要么某个动画提前结束、另一个还在跑最后视觉完全失控。组合动画的价值就是把一组动画变成一个可管理的整体统一控制播放、暂停、停止和循环子动画之间要么并行要么串行逻辑上一目了然。在QML里专门干这件事的类型是ParallelAnimation和SequentialAnimation它们都属于AnimationGroup这个体系。名字也直白ParallelAnimation让子动画同时跑SequentialAnimation让子动画按顺序跑。组合动画核心就是靠这两个类型撑起来的日常写动效时基本离不开它们。1.2 串行与并行两种最基本的组合思路串行和并行听起来简单但真正落到动效编排上需要想清楚每个阶段各自负责什么。串行适合“先后关系明确”的场景比如入场时先位移再旋转或者加载时先转圈再淡入文案。并行适合“同一时刻多个属性一起变”的场景比如透明度、缩放、位置同时变化有一种整体涌现的感觉。实际项目里极少只用纯串行或纯并行绝大多数动效是二者嵌套在一起的。比如弹窗出现外层先并行透明度从0到1、缩放从0.8到1这是“出现”阶段紧接着串行播放一个顶部横幅滑入这是“内容进场”阶段。把嵌套关系理清楚组合动画就能做得非常顺。组合动画另一个容易被忽略的好处是“统一生命周期”。一个复杂的进场动效如果拆成七八个独立动画用户点击关闭按钮时得挨个stop漏一个就出现残影。用组合动画后只需stop最外层一个对象所有子动画都会被终止省心太多。2. 两种核心组合类型怎么选怎么用2.1 SequentialAnimation顺序编排没有想象中简单SequentialAnimation是QML里的串行动画容器写法上就是在它内部嵌套其他动画对象。SequentialAnimation { running: true NumberAnimation { target: rect property: opacity from: 0 to: 1 duration: 400 } NumberAnimation { target: rect property: y from: 0 to: 100 duration: 300 } }上面这段会先花400ms把透明度从0变到1等它完全结束后再花300ms把y坐标从0移动到100。子动画之间是严格的前后关系这和“同时触发两个独立动画”有本质区别。实际使用中一个关键点是子动画里如果只写了to没写from动画启动时会自动读取当前值作为from。这在串行动画里容易“串电”。比如第一个动画把透明度变成了0.5第二个动画目标值是1但你又想从0开始不显式写from的话它就会从0.5继续视觉效果很容易和预期不符。我习惯在SequentialAnimation的每一个子动画里都把from、to、duration写全宁可啰嗦也不要靠默认值猜。SequentialAnimation内部可以放任意Animation子类包括PropertyAnimation、ColorAnimation、PauseAnimation、ScriptAction也可以继续嵌套ParallelAnimation或者其他SequentialAnimation。嵌套时一定要记住内部子动画会被当作一个整体外部SequentialAnimation会等内部整体结束后再播放下一个。2.2 ParallelAnimation并发控制的几个细节ParallelAnimation的语义是所有子动画同时开始。它适合那种“一组属性共同表达一个动作”的场合比如一个矩形在位移的同时完成旋转和透明度变化。ParallelAnimation { running: true NumberAnimation { target: rect property: x from: 0 to: 200 duration: 800 } NumberAnimation { target: rect property: rotation from: 0 to: 360 duration: 800 } ColorAnimation { target: rect property: color from: #ff9800 to: #3f51b5 duration: 800 } }三个动画时长都是800ms便于同时结束。如果时长不一致ParallelAnimation不会等最长的一个统一结束而是每个动画按自己的时长跑完先结束的就停在终值上直到所有子动画都结束整个ParallelAnimation才算完成。这个细节在编排“匀变速”动效时很重要想刻意做出“弹到位后颜色还在继续渐变”的效果只需要让ColorAnimation的duration比位移动画长即可。ParallelAnimation里不推荐两个子动画同时操作同一个属性比如一个NumberAnimation改opacity另一个PropertyAnimation也改opacity结果会是后设置的一方覆盖先设置的一方最终行为依赖启动顺序非常难排查。如果确实需要同一属性分不同阶段变化请把它们放进SequentialAnimation而不是ParallelAnimation。2.3 PauseAnimation与ScriptAction组合动画里的调节阀组合动画有了并行和串行还需要两个“工具型”成员PauseAnimation和ScriptAction。PauseAnimation只负责暂停一段时间它没有target也没有property核心属性就是duration单位毫秒。放在SequentialAnimation里可以制造“停顿感”。SequentialAnimation { running: true NumberAnimation { target: rect property: scale from: 0 to: 1 duration: 300 } PauseAnimation { duration: 200 } NumberAnimation { target: rect property: opacity from: 0 to: 1 duration: 300 } }这个动效会先放大停顿200ms再淡入。停顿时间可以理解为动画节拍中的呼吸点能够明显提升节奏感。ScriptAction则是执行一段QML脚本放在串行动画里可以在某个时间点插入逻辑。使用前需要特别注意ScriptAction类型不在QtQuick模块里而在QtQml模块中必须加上import QtQml 2.0才能使用否则QML解析器会直接报“ScriptAction is not a type”。我自己第一次用就踩了这个坑找了好久才反应过来是import没写。import QtQuick 2.15 import QtQml 2.0 SequentialAnimation { running: true ScriptAction { script: { rect.color #e91e63 log.text 动画开始 } } NumberAnimation { target: rect property: x from: 0 to: 300 duration: 500 } ScriptAction { script: { rect.color #00bcd4 log.text 动画结束 } } }ScriptAction适合在串行动画链里处理一些“非动画”操作比如更新数据、播放音效、切换状态。但要注意script里不要写过于复杂的逻辑QML脚本性能一般而且调试起来不方便。能用绑定和Signal解决的就不要去脚本里折腾。3. 一个完整的组合动画实例入场到常驻的小卡片3.1 界面搭建与需求拆解这一节用一个小卡片组件串联上面讲的概念。需求很典型页面加载时卡片从底部弹入弹入后颜色渐变之后卡片进入一种“呼吸”循环每2秒左右完成一次缩放和颜色微调点击按钮可以重新播放整套入场动画。这个需求涵盖了串行、并行、无限循环、PauseAnimation和一个冷门的细节复位问题。先搭基础界面import QtQuick 2.15 import QtQuick.Window 2.15 import QtQml 2.0 Window { id: root width: 480 height: 640 visible: true title: 组合动画 Demo color: #fafafa Rectangle { id: card width: 160 height: 220 radius: 12 color: #ff9800 anchors.horizontalCenter: parent.horizontalCenter anchors.verticalCenter: parent.verticalCenter opacity: 0 scale: 0.6 } Button { id: replayBtn anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: parent.bottom anchors.bottomMargin: 40 text: 重放入场 onClicked: entryAnim.restart() } }注意这里的Button我假设已经存在实际项目里通常会自定制或者使用某个通用按钮组件。卡片初始状态故意设置成opacity: 0、scale: 0.6这样再配合入场动画从可见性和缩放上都做到“从无到有”。3.2 入场编排顺序切换与组内并行入场动效设计成这样先并行完成“淡入恢复缩放一次小回正旋转”停顿一下再并行完成“颜色切换轻微放大”。对应代码SequentialAnimation { id: entryAnim // 阶段一进场 ParallelAnimation { NumberAnimation { target: card property: opacity from: 0 to: 1 duration: 350 easing.type: Easing.OutCubic } NumberAnimation { target: card property: scale from: 0.6 to: 1.0 duration: 350 easing.type: Easing.OutBack } NumberAnimation { target: card property: rotation from: -8 to: 0 duration: 350 easing.type: Easing.OutBack } } // 阶段二停顿 PauseAnimation { duration: 120 } // 阶段三颜色渐变 轻微放大 ParallelAnimation { ColorAnimation { target: card property: color to: #3f51b5 duration: 600 } NumberAnimation { target: card property: scale to: 1.08 duration: 600 easing.type: Easing.InOutQuad } } }这里期初需要注意阶段三的缩放目标值是1.08结束时卡片停留在1.08。如果后续要接一个循环动画这个1.08就成了循环的起点容易让“呼吸”循环的起点不在1.0导致卡顿感。所以在循环动画里需要显式地从某个基准值开始不能省略from。3.3 常驻动画无限循环的呼吸组合呼吸循环放在另一组动画里与入场动画互不干扰。设计为每1.4秒完成一次“缩放比例在0.92和1.08之间来回摆动颜色在深蓝与紫色之间来回渐变”。因为要一直循环最外层用ParallelAnimation并设置loops: Animation.Infinite。ParallelAnimation { id: breatheAnim running: true loops: Animation.Infinite SequentialAnimation { NumberAnimation { target: card property: scale from: 1.0 to: 0.92 duration: 700 easing.type: Easing.InOutSine } NumberAnimation { target: card property: scale from: 0.92 to: 1.0 duration: 700 easing.type: Easing.InOutSine } } SequentialAnimation { ColorAnimation { target: card property: color from: #3f51b5 to: #9c27b0 duration: 700 easing.type: Easing.InOutSine } ColorAnimation { target: card property: color from: #9c27b0 to: #3f51b5 duration: 700 easing.type: Easing.InOutSine } } }最外层ParallelAnimation里的两个子SequentialAnimation同时启动每个内部又是两段往返动画所以整体表现为缩放和颜色同时在“正向”和“反向”之间切换。真正实现“呼吸感”的关键是easing.type统一使用InOutSine这样动画不是匀速直来直去而是有加速减速过程。这里也会遇到一个组合动画经典问题父动画设置了loops: Animation.Infinite子动画又单独设置了loops尤其是子动画也设成Infinite容易导致组合体的结束信号永远不触发。我的建议是无限循环只放在最外层一个动画上内部全部使用默认的loops: 1。3.4 用按钮触发手动播放入场动画通过entryAnim.restart()来触发。注意restart()会强制让动画从头开始无论当前处于什么阶段。如果我们希望“点击时若动画正在播放则不重新开始”需要先判断entryAnim.running再决定是否restart。onClicked: { if (!entryAnim.running) { entryAnim.restart() } else { console.log(入场动画还在播放中) } }另一个细节是入场动画执行完毕后卡片的状态应该是“完全可见且为深蓝色”所以为了让重新播放有意义restart时需要注意初始状态。QML动画里的NumberAnimation如果写了from那每次启动都从from开始如果没写from就会从当前值开始。我们的入场动画opacity明确写了from: 0所以restart时卡片会立刻跳到透明度0这是预期效果。但如果一个子动画只写了to而没有fromrestart时会从此刻的当前值开始不是从头开始这一点在“点击重播”类交互中特别容易翻车。4. 组合动画在Transition里的正确姿势4.1 Transition中的动画编排特性QML里的Transition用于状态切换和视图变化时自动播放动画。Transition最常用的位置是State的transitions列表里也可以用在ListView等视图元素的addTransition、removeTransition等属性上。Transition内部可以放动画也可以直接放ParallelAnimation或SequentialAnimation。与普通动画对象不同Transition内部动画通常不写target因为Qt Quick会自动化将它们应用到发生状态变化的对象上。这个“作用对象自动接管”特性带来一个好处一组动画可以复用在多个状态之间不用每一个控件复制一套。Rectangle { id: root states: [ State { name: normal PropertyChanges { target: card x: 0 color: #ff9800 rotation: 0 } }, State { name: active PropertyChanges { target: card x: 200 color: #3f51b5 rotation: 45 } } ] transitions: [ Transition { from: normal to: active SequentialAnimation { ParallelAnimation { NumberAnimation { property: x duration: 300 } ColorAnimation { property: color duration: 300 } } NumberAnimation { property: rotation duration: 200 } } } ] }这段代码表示从normal切到active时先并行跑位移和颜色变化300ms然后再旋转200ms。如果直接在Transition下并列写多个动画对象它们会并行执行想分组串行就必须用SequentialAnimation包一层。这个区别用错的人不在少数。4.2 给ListView添加增删动画的实战写法组合动画在视图元素里最常见的场景是ListView的添加和删除过渡。用一个简单的例子说明ListView { id: listView anchors.fill: parent model: myModel delegate: Rectangle { id: itemRect width: listView.width height: 60 color: #e0e0e0 Text { anchors.centerIn: parent text: model.name } } addTransition: Transition { ParallelAnimation { NumberAnimation { property: opacity; from: 0; to: 1; duration: 300 } NumberAnimation { property: x; from: -50; to: 0; duration: 300 } } } removeTransition: Transition { SequentialAnimation { NumberAnimation { property: x; to: 50; duration: 200 } NumberAnimation { property: opacity; to: 0; duration: 200 } } } displacedTransition: Transition { NumberAnimation { property: y; duration: 250 } } }这里的addTransition和removeTransition分别定义新增和移除时的过渡效果。新增时用ParallelAnimation让透明度从0到1的同时从左侧滑入移除时先向右滑再淡出用SequentialAnimation保证先后关系。displacedTransition控制其他项重新排列时的动画它只是一个普通NumberAnimation因为只需要y坐标移动。值得强调的是Transition内部动画如果写了target通常会报错或产生不可预期行为。正确写法是只写property、from、to、duration让框架自动把动画应用到所有受影响的对象上。5. 常见的坑和排查思路5.1 动画顺序和总时长怎么算组合动画的总时长不是随便猜的。SequentialAnimation的总时长等于所有子动画时长之和再加上内部PauseAnimation的durationParallelAnimation的总时长等于所有子动画duration的最大值。嵌套之后依然适用只是需要逐层展开。举个容易翻车的例子ParallelAnimation { SequentialAnimation { NumberAnimation { target: rect; property: x; from: 0; to: 100; duration: 500 } NumberAnimation { target: rect; property: y; from: 0; to: 100; duration: 500 } } NumberAnimation { target: rect; property: opacity; from: 0; to: 1; duration: 1000 } }最外层ParallelAnimation总时长是1000ms因为opacity动画要跑1000ms。但内部SequentialAnimation的x和y共用时1000ms所以两者同时结束。如果想让总时长变成1500ms就要保证所有子动画中最长的一个是1500ms而不是把三个500ms的串行动画累加起来。理解这个计算方式才能准确控制动效节奏。5.2 父动画与子动画的loops冲突这是组合动画最经典的问题之一。父级ParallelAnimation设置了loops: Animation.Infinite内部某个子SequentialAnimation也设置了loops: Animation.Infinite结果就是父级永远不会收到“所有子动画完成”的信号或者说内部那个无限循环的子动画会一直占住播放进程后续其他子动画可能迟迟不执行。我建议遵循三条原则无限循环一律放在最外层。内部子动画默认都是loops: 1不要画蛇添足。如果需要整体暂停后再恢复直接操作外层组合动画的paused属性不要分别操作内部动画。这三条能避开大多数循环冲突问题。真遇到“某个子动画单独无限循环但不影响其他子动画”的需求建议拆成两个独立动画对象而不是硬塞进同一个组合里。5.3 属性冲突和状态残留组合动画里的状态残留也一样隐蔽。比如ParallelAnimation里两个子动画同时对scale属性操作或者SequentialAnimation里前一个动画修改了rotation后一个动画以为rotation还是初始值结果就会产生跳变。排查思路很直接先看每个子动画有没有显式写from再看每个动画的结束终值是什么最后看组合动画是否在结束时修改了属性。如果终值都需要保留最好在动画定义里就用to明确目标值而不是依赖当前状态推算。状态残留另一个表现形式是动画被stop后属性卡在中间值。比如拖动进度条做到一半暂停动画属性停在了0.3。这时候如果重新start动画如果没显式from就会从0.3继续而不是从头开始。解决办法要么写from要么在start前手动把属性重置到初始值。我习惯在组合动画的onCompleted里做一次显式复位或者在用户交互的入口处先执行一下属性初始化。5.4 性能与卡顿的排查方向组合动画本身不会导致卡顿真正卡顿多半是内部动画数量过多或者属性更新触发大量布局计算。常见诱因对Text文本频繁做缩放或位置动画导致文本重新布局。对ListView的delegate做逐项动画且每项内部又有多个并行动画。在动画中对大尺寸矩形或模糊效果较多的项操作。排查时先用QML Profiler看动画帧耗时再逐个精简动画。一个经验是能用opacity、scale、rotation、x、y这类GPU友好的属性就尽量避免动画width、height、color这些可能触发重排重绘的属性。颜色动画往往比位置动画更消耗性能特别是大面积矩形时。但项目里偶尔也需要颜色动画这种场景尽量控制动画区域和时长。5.5 注意类型导入作用域ScriptAction需要导入QtQml这个之前说过但组合动画里还有一个相关的坑ParallelAnimation和SequentialAnimation属于QtQuick而PauseAnimation和NumberAnimation等都属于QtQuick正常情况下都不需要额外导入。但如果写了一个ScriptAction却忘记import QtQml运行时错误提示往往不明显只是加载QML报错找不到类型。另一个容易忽视的点是有的项目里为了用QML状态机会额外导入QtQml.StateMachine等模块这不影响组合动画。但模块导入数量增加后要留意类型名冲突比如自己也定义了一个叫SequentialAnimation的组件就可能在局部作用域中覆盖内置类型报错时很难想到是命名冲突。6. 写在最后一点个人经验组合动画用熟了之后最大的变化是写动效从“试”变成“设计”。先想清楚哪些阶段是并行的、哪些是串行的再决定用ParallelAnimation和SequentialAnimation怎么嵌套然后给每个子动画定义明确的from、to、duration和easing最后用最外层一个动画统一控制生命周期。这样写出来的代码容易读也好维护别人接手时不用靠注释猜动效意图。另外我经常做的一件事是调试组合动画时临时把duration调小一点比如全部除以4这样能快速观察整体节奏是否正确。很多顺序或时间重叠问题完整播放时看不清调快后一眼就能发现。更快的方法是在动画对象上加断点或者用console.log打印running状态变化定位“哪个子动画没有按预期结束”这类问题非常有效。组合动画不算高级到需要长篇大论但它是QML动效编排的地基。把SequentialAnimation和ParallelAnimation的配合逻辑吃透再遇到卡片切换、列表增删、页面入场这些场景基本都能手到擒来。希望这篇笔记能帮你少踩几个我当年踩过的坑。