做鸿蒙应用开发的人,多半会有个感觉:HarmonyOS 6 的 ArkUI 动画上手容易,写起来也快,但真正想把动画做“顺”,做“稳”,做“像系统原生该有的样子”,还是有不少门道。我最近在一款信息流产品里集中做了一批动效优化,从按钮状态反馈、列表项进出场,到页面级转场都过了一遍。这篇文章就把我在 HarmonyOS 6 上踩过的坑、验证过的思路和可以直接抄的写法统一整理出来。
这篇文章适合两类人看:一是刚接触 ArkUI 不久、只会照着 demo 敲 .animation() 和 animateTo 的新手,想搞清楚动画到底是怎么跑起来的;二是已经在做鸿蒙应用、想优化动效手感或者排查动画卡顿问题的开发者。我会尽量把底层逻辑、设计取舍和工程习惯都讲透,最后附一份排查速查表,希望能帮你少走一点弯路。
1. 先把动画这件事想清楚:它到底在动什么?
1.1 动画的本质就是属性插值
很多同学做动画时第一反应是“我要让这个组件飞过去”“我要让它转起来”。但落到 ArkUI 这种声明式框架里,动画的本质不是“组件动”,而是“属性在单位时间内连续变化”。
举个例子:一个按钮按下去要变大再回弹,你在代码里真正改变的其实是 scale 这个属性值,从 1 变到 1.2,再从 1.2 变回 1。如果这个过程是瞬间完成的,用户只会觉得“点了一下没反应”;如果框架在这个值的变化过程中,把每一帧的中间值都计算出来并渲染出来,用户看到的才是“平滑放大再回弹”的动画。
这个中间值的计算过程,在计算机图形学里叫插值(interpolation)。ArkUI 的动画引擎本质上就是在做这件事:你告诉它起点、终点、持续时间和速度曲线,它在每一帧渲染前自动算出一个“当前应该显示的值”,然后交给渲染层去画。理解这一点很重要,因为后面所有参数的调优,本质上都是在调整插值过程中的某些维度。
1.2 动画发生的大环境:UI 刷新不是“每帧都跑”
还有一层背景需要先铺垫。HarmonyOS 的设备屏幕刷新率普遍是 60Hz,部分旗舰机型是 90Hz 或 120Hz。ArkUI 的动画引擎会尽量跟着屏幕的垂直同步信号走,在每一帧刷新前完成属性值的计算和布局更新。
但在实际项目里,动画不流畅的根因往往不是动画引擎本身,而是“每一帧里除了动画,还干了太多别的事”。比如组件层级过深、动画中触发了大量重绘、或者状态变量把无关组件也带进刷新流程。我在后面的性能章节会专门展开,这里先埋个伏笔。
1.3 ArkUI 动画的两大体系
在 HarmonyOS 6 的 ArkUI 中,动画分布在几个层次,我习惯把它们分成“通用属性动画”“转场动画”和“其他高级动画”三类。
通用属性动画是平时用得最多的:绑定在组件属性上的 .animation(),以及通过 animateTo 触发的显式动画。它们负责处理组件的尺寸、位置、透明度、旋转、背景色等属性的平滑变化。
转场动画负责处理“组件出现”和“组件消失”时的效果,比如页面压栈返回、弹窗出现、列表项的插入删除。转场动画里还包含一个看起来很高级的共享元素转场,能够把两个页面中同一个元素的“身份”延续下来,实现跨页面无缝移动的效果。
第三类包括粒子动画、关键帧动画、物理弹簧动画等,针对的是更复杂的场景。理解这些分类之后,遇到需求时你能快速判断走哪条路,而不是所有动画都堆在 .animation() 上,最终搞出一堆莫名的问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 显式动画和隐式动画:同一个动画的两种心态
2.1 隐式动画:给属性挂上“动画说明书”
.animation() 是很多人接触到的第一个 ArkUI 动画 API。它属于隐式动画:你不需要说“现在开始播放”,只需要在组件上声明“当某些属性变化时,用这套动画参数去过渡”。
typescript复制@State expand: boolean = false;
build() {
Column() {
Image($r('app.media.cover'))
.width(this.expand ? 320 : 120)
.height(this.expand ? 200 : 80)
.animation({ duration: 300, curve: Curve.EaseOut })
}
.onClick(() => {
this.expand = !this.expand;
})
}
上面这段代码里,点击之后 expand 状态翻转,图片宽度高度直接变成目标值,但因为绑定了 .animation(),ArkUI 会把这几个属性值的变化包装成动画。它的优点非常明显:代码量小,局部属性一目了然。
但用多了你会发现一个问题,隐式动画是“无差别作用”的。只要这个组件上绑定了 .animation(),所有被影响到的属性变化都会按同样参数播放,哪怕其中某些属性你根本没打算加动画。单个组件还好,一旦组件上多个属性在业务中被高频、不同节奏地修改,隐式动画的灵活性就不够了。
2.2 显式动画:一个 animateTo,驱动一批状态
显式动画的做法是在修改状态变量的同时,把这次修改包到 animateTo 闭包里。闭包内所有状态变化引起的 UI 更新,都会被当作一次动画来处理。
typescript复制@State scale: number = 1;
@State opacity: number = 1;
@State rotating: number = 0;
private toggleState() {
animateTo({ duration: 400, curve: Curve.FastOutSlowIn }, () => {
this.scale = this.scale > 1 ? 1 : 1.1;
this.opacity = this.opacity < 1 ? 1 : 0.6;
this.rotating = this.rotating + 45;
});
}
这里的重点在于:动画不再绑定在某个组件的某个属性上,而是绑定在“状态变更”这个动作上。animateTo 执行的那一刻,所有受闭包内状态变化的组件,都会以统一的时间曲线去更新。
我经常把这两种方式类比成“定时器”和“事件驱动”:隐式动画像设定好的定时器,条件一到就跑;显式动画更像是按下快门的那一瞬间,由你明确告诉系统“现在开始动”。
2.3 实战中我为什么更偏向显式动画
从工程维护角度看,我个人的习惯是优先考虑 animateTo。
第一个原因是它更容易表达动效组合。一个卡片从列表底部“升上来”的过程,通常包含位移、透明度和阴影三个属性同时变化。用显式动画在一个闭包里搞定,后续维护的人一眼就能看出这三个动画是同一组意图。
第二个原因是显式动画不容易误伤。隐式动画一旦加了 .animation(),这个组件的所有后续属性变化都会去匹配动画参数,如果某个变化想“瞬变”,你还得额外处理。而显式动画的生效范围完全由闭包控制,不会出现“我的组件为什么自己动了一下”这种诡异问题。
第三个原因是显式动画和用户交互动线的配合更自然。比如点击按钮弹出菜单,菜单的淡入淡出往往跟一个开关状态绑定。你可以在开关翻转时直接用 animateTo 控制,而不是去倒腾组件上 .animation() 的各种条件。
当然,如果是纯展示场景、只改一个属性,比如切页时图标透明度变化,用 .animation() 也确实能少写好几行。怎么选没有绝对对错,关键在于你要清楚每种方式的生效边界。
3. 调动画参数,其实是在调手感
3.1 曲线决定了动画的“性格”
动画曲线(curve)是影响动效观感最重要的因素。同样是 300ms 内从 A 移到 B,线性匀速、先快后慢、先慢后快,用户感受到的完全是三种东西。
ArkUI 内置了一系列曲线常量,平时用得最多的是这几档:
| 曲线 | 特点 | 适合场景 |
|---|---|---|
| Curve.Linear | 匀速,没有加速减速 | 进度条、加载指示器、循环位移动画 |
| Curve.EaseOut | 快速开始,缓慢结束 | 界面元素进场、弹出菜单、大小变化 |
| Curve.EaseIn | 缓慢开始,快速结束 | 元素离场、消失动画 |
| Curve.FastOutSlowIn | 中间快,两头慢 | 页面级转场、卡片翻转,Material 风格里很常用 |
| Curve.EaseInOut | 两头慢,中间也平稳 | 需要在两端都有停留感的效果 |
内置曲线之外,还有 Curves.cubicBezierCurve(x1, y1, x2, y2) 可以自定义贝塞尔曲线。我通常用它在系统曲线不能满足需求时做微调。比如下拉刷新回弹那个效果,如果只是 EaseOut 会觉得“太黏”,实际比较接近一个先快速回位再轻微振荡的曲线,这时候贝塞尔曲线或者弹簧动画更能还原物理感。
3.2 时长、延迟与重复次数不是随便填的
动画时长的设置,我见过太多人一上来就写 500、1000,然后跑起来觉得慢就改 200,没有任何依据。实际上,时长的确定应该围绕“交互反馈的实时性”来定。
按钮按压的反馈动画,如果超过 300ms,用户会明显感到界面反应迟钝;页面转场如果太短又容易让人觉得“跳变”,没来得及建立空间感。我自己在项目里的默认经验值大概是:
- 按钮按压反馈:120ms ~ 200ms,要快到几乎察觉不到,但又有一点“软”的反馈;
- 组件进出场:200ms ~ 300ms,适合绝大多数列表、卡片场景;
- 弹窗、底部面板:250ms ~ 350ms 入场,配合 EaseOut,出场可以略微快一点;
- 页面转场:300ms 左右,具体要看转场路径的长度;
- 大范围移动/复杂的形变:400ms 左右封顶,再长用户就会觉得拖沓。
延迟时间 delay 往往用在“串行”动画里。比如一个列表的多个项目依次进场,每项之间错开 40ms,形成波纹感。这是很常见的做法,但延迟时间不宜过长,否则后面的元素等待感就会很明显。
重复方面,ArkUI 的 iterations 参数可以直接指定循环次数,还有 PlayMode 控制播放方向。常见的 loading 动画其实就是把 iterations 设为 -1(无限循环),再把 PlayMode 设为 PlayMode.Alternate,让它正着放一遍、反着放一遍,看起来就像来回弹动。
typescript复制animateTo({
duration: 800,
curve: Curve.EaseInOut,
iterations: -1,
playMode: PlayMode.Alternate
}, () => {
this.loadingScale = 0.6;
});
3.3 动画编排:多个动画如何有序播放
动画数量一多,时序就会变成难题。这和前端用 CSS 写多个动画、Android 里用 AnimatorSet 做队列是一个道理。鸿蒙里的动画回调没有刻意做得像 Promise 链那么顺手,但利用 onFinish 回调去做编排也能写出很可靠的效果。
一个典型的场景是“先让遮罩淡入,再让面板从底部弹起”。如果两个动画同时播放,观感就是一堆东西同时出现,没有层次。正确做法是把它们拆成两个阶段:
typescript复制private async showPanel() {
animateTo({ duration: 200, curve: Curve.EaseOut }, () => {
this.maskOpacity = 0.6;
});
// 延迟到遮罩动画接近结束时再启动面板
setTimeout(() => {
animateTo({ duration: 300, curve: Curve.EaseOut }, () => {
this.panelTranslateY = 0;
});
}, 160);
}
实际操作中,我会更倾向用 await 封装一套简单的“顺序播放”工具,或者用 promise 包装 onFinish,反正思路就是别让动画在代码里到处穿插,把时序交给一个可控的队列。尤其要注意别在动画回调里直接改状态去触发下一个动画,那样很容易造成“动画抖一下又回来”或者干脆不触发的问题。
3.4 动画中断与取消
还有一个容易被忽略的点:动画进行中,如果外部状态又变了,会发生什么?ArkUI 默认的做法是“打断当前动画,从当前值开始走新目标”。这个特性大多数时候是好事,比如用户快速点击按钮,反馈动画不用等上一轮播完再响应下一轮。
但如果在动画进行中把整个状态重置,就可能出现视觉上的闪烁。我在处理“快速上滑列表时收起展开的卡片”这种场景时踩过坑:卡片展开动画还没播完,列表就被滑走了,结果卡片以半展开状态卡在屏幕上,点击也没反应。后来我在关闭卡片前先把动画时间缩短,并且手动让状态回到初始值,问题才解决。动画取消不是简单地“把 iterations 设为 0”就行,而是要从业务状态层面保证动画结束后逻辑是收敛的。
4. 从单个组件到整页转场:让切换不再生硬
4.1 页面级转场:用 pageTransition 定制压栈返回效果
HarmonyOS 里页面跳转有两种主流方式:老的 router 和面向新架构的 Navigation。不管用哪种,页面级的转场动画都可以通过 pageTransition 去控制。
如果你想让新页面从右边滑入、当前页面往左滑出,可以在页面组件里这样声明:
typescript复制pageTransition() {
PageTransitionEnter({ duration: 300, curve: Curve.FastOutSlowIn })
.slide(TransitionEffect.SLIDE_LEFT)
PageTransitionExit({ duration: 250, curve: Curve.EaseIn })
.opacity(0)
}
这里 PageTransitionEnter 定义的是“这个页面进场时的效果”,PageTransitionExit 定义的是“这个页面退场时的效果”。.slide(TransitionEffect.SLIDE_LEFT) 代表从左侧滑入,也可以换成 .translate、.opacity 或者 .scale 组合。
我做页面转场时通常会关掉默认的“整页平移”,因为默认的返回手势和左右滑动在部分场景下会跟页面里的横向滚动组件冲突。自定义之后,我习惯让新页面带一点轻微的缩放和透明度变化,这样视觉上更像“层级推入”,而不是生硬的整页替换。
4.2 组件转场:Tab 切换和列表增删的刚需
页面级转场解决的是页面与页面之间的问题,但很多产品里更频繁发生的是“组件切换”。比如一个没有用路由的底部 Tab 切换,或者列表里某个条目被展开成详情卡片。
组件转场和页面转场的核心差异在于:组件转场作用在单个组件挂载/卸载的瞬间,通过 .transition() 来声明进场和离场效果。
typescript复制@State showDetail: boolean = false;
build() {
Column() {
if (this.showDetail) {
Text('详情面板')
.transition({ type: TransitionType.Insert, opacity: 0, translate: { y: 20 } })
}
}
}
这里是说:当 showDetail 变成 true 导致 Text 插入时,它从透明度 0、向下偏移 20 的状态过渡到正常状态。反过来,当 showDetail 变成 false 需要卸载它的时候,你也可以用 TransitionType.Delete 声明一个反向动画。
有一个很容易踩的坑:组件转场必须配合条件渲染(if)才能生效。如果你用 visibility 属性来切换显隐,.transition() 不会触发生效。曾经有个同事在列表筛选功能里用 .visibility(Visibility.None) 控制空状态文案出现,死活调不出动画,后来改成 if/else 结构就正常了。
4.3 共享元素转场:让跨页面跳转更有“连续性”
共享元素转场是我个人很喜欢的动画类型,特别适合“从列表封面点击进入详情页大图”这种场景。它可以让同一个视觉元素在页面跳转过程中不消失,而是从列表小图的位置和尺寸,平滑过渡到详情页大图的位置和尺寸。
在 ArkUI 中实现共享元素转场,核心思路是给源页面和目标页面中的相同元素设立关联关系。给元素加上共享转场标记,通常是通过类似 sharedTransition 的配置来完成,两边使用同一个 id 来互相匹配。
做共享元素转场时要注意一个问题:共享元素的 id 必须在整个页面栈里唯一。如果列表页里有多张封面图片,不能用同一个静态字符串当 id,而是要动态拼上数据索引,比如 cover_${item.id}。另外,共享元素的布局环境如果相差太大,比如一个在 Scroll 列表里、一个在嵌套的 Column 里,动画过程容易产生裁剪问题,做之前要确认两边的父容器没有 overflow: hidden 之类的裁剪限制。
4.4 业务里最常用的加载与空状态动效
很多人在做 loading 动画时喜欢直接丢一个旋转的菊花图片,或者找设计师要一张 JSON 动效,但在使用 ArkUI 原生的 LoadingProgress 组件时就满足了。如果产品想要一点定制感,用属性动画完全可以实现一个干净的呼吸型 loading。
比如把一个小圆点通过 animateTo 在 0.4 和 1.0 之间来回缩放,加上透明度变化,循环播放。这种方式没有额外引入资源文件,包体积更小,而且天然适配深浅色模式。
空状态动画也值得花心思。很多产品在列表为空时只显示一行文案,用户会误以为是自己网络出了问题。加一个简单的淡入上移动画,让空状态“浮出来”,配合精致的插画,体验会提升不少。不需要复杂,一个 opacity 从 0 到 1、translateY 从 16 到 0 的过程,200 多毫秒,就能让页面显得相当精致。
5. 动画卡顿?先别怪设备,多半是这几类原因
5.1 在动画里动了“不该动”的属性
这是性能问题里最常见的一类。做 Web 前端的人应该都知道,CSS 动画里 transform 和 opacity 是最推荐动画的属性,因为它们能走 GPU 合成,而 width、height、top 这些属性的动画会触发布局和绘制,成本要高一个量级。
ArkUI 的思路也是类似的。如果你的动画是反复改变 width 和 height,每次属性变化都可能触发一次布局过程;在页面层级复杂、节点又多的情况下,这个成本很容易吃满一帧的时间,动画自然就掉帧了。
我的做法是:优先用 scale 来替代宽高变化。比如卡片从 120 放大到 320,直接用 .scale() 改变视觉大小,而不是真的去改 width 和 height。这样渲染层可以在合成阶段完成,性能会好很多。
5.2 状态变量变化范围太大,把无关组件也带进来了
ArkUI 的 @State 变量更新时会触发组件的重新渲染。如果你的某个页面级状态被多个子组件引用,哪怕动画只影响其中一个小组件,整个页面的其他组件也可能被连带刷新。
我见过一个真实案例:一个宫格入口页的图标点击后应该只让被点击的图标动一下,但代码里把 @State selected 放在了页面顶层,所有宫格子组件都读取了它。结果每次动画都导致整页所有图标全部重新渲染,即使另外几个图标没有任何变化。从视觉上看就是动画播放时,其他图标也跟着闪或抖。
解决方案是尽可能把状态放到真正需要动画的组件内部,或者用 @ObjectLink / @Observed 让状态粒度更细。做复杂页面时,这个原则带来的性能收益比很多花哨的调优技巧都大。
5.3 动画参数与屏幕刷新率不匹配
如果你的动画时长设置得很碎,比如 100ms、350ms 这种不是 60 或 120 的整数分之一的话,系统在插值时偶尔会出现“某一帧变化量特别大”的感觉。
我一般不会去精确计算刷新率的整数倍,因为设备太多了,算不过来。但我在调参时会留一个习惯:在真机上开启“显示屏幕刷新率”和“GPU 渲染分析”这两个调试工具,观察动画过程中有没有明显掉帧,如果掉帧就优先检查动画里有没有布局和重绘操作。
真机调优有一个很重要的点:模拟器跑动画通常会比真机“感觉更慢”或者“更顺滑”,但不代表真机表现一致。结论一定要在真机上验证,尤其是中低端机型。
6. 常见问题排查速查与一套“不折腾”的默认配置
6.1 动画问题排查速查表
| 现象 | 大概率原因 | 处理思路 |
|---|---|---|
| 动画完全没有发生,属性直接跳变 | 没写 animateTo,或者 .animation() 没绑定到实际属性上 |
检查状态修改是否在 animateTo 闭包内,检查动画修饰符是否落在正确组件上 |
| 只出场有效,离场无动画 | 条件渲染没处理好,if 结构导致组件卸载时来不及播动画 |
用 TransitionType.Delete 配置离场效果,且组件要被 if 控制 |
| 使用独立状态控制显隐时无动画 | 把 transition 配在了 visibility 上 |
改成条件渲染配合 .transition() |
| 动画快结束时卡一下 | 动画执行期间有其他高耗时任务抢占主线程 | 减少动画期间的状态刷新范围,把耗时操作延后 |
| 动画过程中其他组件闪烁 | 页面级状态变化波及太多节点 | 状态下沉到子组件,缩小刷新范围 |
| 动画队列前一个还没播完,后一个就开始了 | 依赖 setTimeout 不准 |
用动画 onFinish 回调或封装的 Promise 队列来控制顺序 |
| 真机上动画效果与预期差异大 | 中低端机性能不足 | 降低动画中对布局属性的依赖,改用 scale / opacity,必要时缩短动画时长 |
| 列表滚动时项内动画掉帧 | 列表项内动画与滚动同时触发 | 尽量在滚动结束后再触发条目内部动画,或降低动画复杂度 |
6.2 我默认使用的“低脑力负担”参数组合
调了很多动画之后,我总结了一套很少翻车的默认组合。遇到没有特别设计稿要求的普通业务动画,我直接套用,再按反馈微调:
- 点击按压反馈:
animateTo({ duration: 150, curve: Curve.EaseOut }) - 列表项进场:
animateTo({ duration: 240, curve: Curve.EaseOut }) - 弹窗面板入场:
animateTo({ duration: 300, curve: Curve.FastOutSlowIn }) - 离场动画:时长一律比入场短 20% 左右,用
Curve.EaseIn - 页面转场:300ms,滑入距离控制在一屏以内,避免过长距离导致晕眩
这套组合我认为是兼顾“跟手”和“轻量感”的折中值。如果你发现自己产品的动效总被人说“慢半拍”或者“太生硬”,先从这些基础参数上去调,大概率就能解决大半问题。
6.3 复盘:动画不只是“加上去就行”
把动画当成“加上去就行”的功能,早晚会在后期付出维护成本。页面里动效一多,必须能说清楚每个动画的触发条件、作用范围、持续时间和结束状态。我通常会把产品里的动效做成一张简单的表格,列清“页面/场景、触发时机、动画类型、生命周期”,这样开发和设计沟通时不会各说各话,测试也能按表格逐条验证回归。
我个人在实际操作中的一个体会是,动效做得好的应用,通常不是动画写得最炫的,而是每个动画都让人觉得“就该是这样”。要做到这一点,除了掌握 ArkUI 的 API,更重要的是理解动画在交互链路里扮演的角色:它负责建立反馈、引导注意力、维持空间连续感,做过了反而会成为干扰。
动效优化是一条需要反复对照设计稿和真机验证的活儿,不同项目、不同用户群体会催生出完全不同的“正确”参数。希望这篇文章能帮你把基础链路理顺,真正动手时少踩几个我踩过的坑。
