☰
鸿蒙React Native串行动画实战:Animated.sequence实现物流进度
2026/10/21 3:08:40 网站建设 项目流程

最近在鸿蒙上折腾 React Native 跨平台开发,发现不少做客户端的老同学都在问同一个问题:串行动画怎么写?网上的教程大多是做一个动画从头到尾跑一遍,但实际项目里最常见的需求反而是“先把A做完,再去做B,然后接着做C”,比如登录按钮的加载转圈、表单校验的逐项提示、电商物流节点的进度推进。这种需求在 React Native 里有一个专门的组合器——AnimatedSequence,也就是 Animated.sequence。这篇文章就围绕这个 API 来写,用一个小白也能直接抄作业的物流进度动画做例子,把串行动画的原理、鸿蒙适配上的坑、以及排查技巧一次讲透。

如果你是刚接触 React Native 鸿蒙开发,或者已经能写基础页面但一碰到动画就头皮发麻,这篇文章适合你。文章里的代码都是可以直接复制到项目里跑的,环境是鸿蒙系统的 React Native 跨平台脚手架,所有组件和 API 都是跨端通用的,iOS 和 Android 同样适用。

1. 动手前的准备:先把 React Native 鸿蒙开发环境跑通

1.1 我使用的环境版本

做鸿蒙上的 React Native 开发,本质上还是走“JavaScript 写业务、原生宿主渲染”这条路。但鸿蒙不像 iOS 和 Android 那样开箱即用,需要额外安装社区适配的鸿蒙运行时和原生工程模板。

我这边实测下来最稳的版本组合是:

  • Node.js 18 LTS 以上
  • React Native 0.72 及以上版本
  • HarmonyOS SDK API 9 及以上
  • DevEco Studio 4.0 及以上
  • 鸿蒙适配层使用社区维护的最新版

不少同学在这一步就放弃了,因为安装鸿蒙运行时总报各种版本不匹配。我的经验是:先装 DevEco Studio 并创建空工程确认 SDK 下载完成,再回来做 React Native 的脚手架初始化,顺序不能反。先跑 RN 再装鸿蒙 SDK 的话,原生工程编译时经常找不到 HarmonyOS 的 API 定义。

1.2 项目初始化的几个坑

初始化的标准流程是先用脚手架生成跨平台基础工程,然后在鸿蒙原生目录里执行关联命令。有几个容易踩的坑值得单独拿出来说:

第一,npm 依赖安装时不要开全局镜像加速,至少鸿蒙相关的依赖包不要走国内公共镜像。有的镜像同步不及时,拉下来的鸿蒙运行时版本比官方旧的,导致运行时崩溃。我某次因为镜像问题折腾了整整一天,最后关掉镜像重新安装就好了。

第二,鸿蒙原生目录的包名和 RN 应用的 appName 必须严格对应。初始化时如果你把应用名写成了带横杠或者中文的名字,生成原生工程后要把包名手改成合法的格式,否则跑起来白屏。

第三,模拟器上开发动画要记得关闭“动画缩放”设置里的窗口动画缩放。鸿蒙模拟器的窗口动画缩放在开启动效时会明显吃掉一部分帧率,影响调试判断。开发阶段用真机更接近真实表现,但模拟器便宜方便,建议至少准备一台真机做最终验证。

2. Animated 核心概念:为什么串行动画能“串联”

2.1 动画的本质是数值驱动

React Native 里的 Animated 动画不是像视频那样一帧一帧画出来的,而是通过一个数值对象从初始值逐渐变化到目标值,驱动 UI 的某些样式属性跟着变。你可以把 Animated.Value 理解成一个会自己“呼吸”的数字,动画的作用就是给它施加一个频率和速度的变化规律。

举个例子,按钮按下去之后,透明度从 1 变到 0.5,宽高从 40 变到 36。这些样式的变化都来源于同一个 Animated.Value。你不需要手动去管理每一帧的数字,只需要告诉 Animated 这个值从哪里开始、到哪里结束、花多长时间、用什么样的节奏。

鸿蒙的 RN 适配层在处理这个逻辑时,本质上是把数值变化的“心跳”从 JavaScript 侧定时发送给鸿蒙原生层,原生层再去更新对应的 UI。所以数值动画的稳定性直接决定了动画的顺滑程度。

2.2 串行、并行、错峰:动画组合器的分工

Animated 这套体系最强大的地方不是单段动画,而是能把多段动画组合起来。它提供了四个基础组合方式:

  • Animated.sequence:按顺序一个一个执行,前一个结束才轮到下一个
  • Animated.parallel:同时启动所有动画
  • Animated.stagger:错峰启动,每段之间有固定间隔
  • Animated.delay:单纯等待一段时间,什么都不做

很多第一次写动画的人容易把 sequence 和 stagger 搞混。我的记忆方法是:sequence 关心的是“完成顺序”,stagger 关心的是“开始时间”。sequence 里第二段动画一定是等第一段完全结束后才开始,适合那种有强依赖关系的步骤,比如表单逐项校验提示、视频播放器的弹幕逐条列表、物流节点逐个点亮。

stagger 则是所有动画都从同一起跑线出发,只是发令枪响了之后,每个运动员起跑的时间错开一片,适合列表交错露出的效果。判断标准很简单:后一个动作是否依赖前一个动作的结果?依赖就是 sequence。

2.3 串行动画在鸿蒙上为什么特别合适

鸿蒙的 RN 适配层目前对 Animated.parallel 和 Animated.stagger 的支持已经比较成熟,但 sequence 这种强依赖串行的场景反而更稳。原因在于 JS 侧和原生侧的通信是异步的,如果多个动画同时跑,任一帧的通信延迟都会让各动画的进度产生肉眼可见的偏差。而 sequence 天然避开这个问题,因为它每一时刻只跑一条动画链路,渲染压力小,通信时序也简单,不容易出现丢帧或者卡顿。

实际测试下来,同一个物流进度动画在鸿蒙模拟器上用 sequence 实现,帧率稳定在 55 以上;改成 parallel 强行把四个节点的动画揉在一起,某些低端模拟器会出现肉眼可见的锯齿感。所以为鸿蒙做动画,我的首要原则是:能串行就不要强行并行。

3. AnimatedSequence 串行动画的完整实操:物流进度 Demo

3.1 场景设计与动画脚本

我准备做一个电商物流进度动画,效果是 4 个物流节点依次点亮,顶部有一个小圆点从第一个节点水平移动到最后一个节点。如下图所示:

  • 初始状态:4 个节点都是灰色圆点,小圆点停留在第一个节点
  • 动画阶段一:第一个节点高亮,小圆点移动到第二个节点
  • 动画阶段二:第二个节点高亮,小圆点移动到第三个节点
  • 动画阶段三:第三个节点高亮,小圆点移动到第四个节点
  • 动画阶段四:第四个节点高亮,全部节点保持点亮

每段动画持续 600 毫秒,采用缓入缓出的节奏。为什么选这个节奏?因为在物流场景里,节点从暗到亮需要一个“逐渐显现”的过程,用线性节奏会显得生硬,缓入缓出更接近自然光线的感觉。

动画脚本可以归纳成下表:

阶段动画内容时长节奏
一节点1 高亮,圆点移到节点2500mseasing 缓出
二节点2 高亮,圆点移到节点3600mseasing 缓出
三节点3 高亮,圆点移到节点4700mseasing 缓出
四节点4 高亮400mseasing 缓入

第二段开始我故意把时长逐步加长,因为越靠近目标,用户对动作细节的关注度越高,动画稍微慢一点反而更有“推进感”。

3.2 核心代码实现

下面是核心代码,入口组件用了函数组件加 Hooks 的写法,这是目前社区最主流的风格。

import React, { useEffect, useRef, useState } from 'react'; import { View, Text, Animated, Easing, StyleSheet, TouchableOpacity, } from 'react-native'; const NODE_COUNT = 4; const NODE_STEP = 84; // 每个节点之间的横向间距 export default function LogisticsProgress() { // 当前高亮到第几个节点,用 state 是为了让底部文字联动更新 const [activeNode, setActiveNode] = useState(0); // 驱动小圆点横向移动的动画值 const dotTranslateX = useRef(new Animated.Value(0)).current; // 每个节点的高亮透明度,用数组存 4 个 Animated.Value const nodeOpacity = useRef( Array.from({ length: NODE_COUNT }, () => new Animated.Value(0.2)) ).current; // 每个节点的缩放值,高亮时稍微放大一下 const nodeScale = useRef( Array.from({ length: NODE_COUNT }, () => new Animated.Value(1)) ).current; const startSequence = () => { // 先把所有状态重置到初始位置 setActiveNode(0); dotTranslateX.setValue(0); nodeOpacity.forEach((item) => item.setValue(0.2)); nodeScale.forEach((item) => item.setValue(1)); const animations = []; for (let i = 0; i < NODE_COUNT; i++) { // 高亮当前节点 animations.push( Animated.parallel([ Animated.timing(nodeOpacity[i], { toValue: 1, duration: 300, easing: Easing.out(Easing.quad), useNativeDriver: false, }), Animated.spring(nodeScale[i], { toValue: 1.15, friction: 4, useNativeDriver: false, }), ]) ); // 把圆点平移到下一个节点位置(最后一个节点不需要再移动) if (i < NODE_COUNT - 1) { animations.push( Animated.timing(dotTranslateX, { toValue: NODE_STEP * (i + 1), duration: 400 + i * 100, easing: Easing.inOut(Easing.quad), useNativeDriver: false, }) ); } } // 核心:把所有动画放进 sequence,逐个执行 Animated.sequence(animations).start(() => { // 全部执行完成后,可以把状态置为完成 }); }; useEffect(() => { startSequence(); }, []); const renderNodes = () => { return nodeOpacity.map((item, index) => { const isActive = index <= activeNode; return ( <Animated.View key={index} style={[ styles.node, { opacity: item, transform: [{ scale: nodeScale[index] }], }, ]} > <Text style={styles.nodeText}>{isActive ? '✓' : index + 1}</Text> </Animated.View> ); }); }; return ( <View style={styles.container}> <View style={styles.progressRow}> {/* 底部连接线 */} <View style={styles.line} /> {/* 移动的小圆点 */} <Animated.View style={[ styles.dot, { transform: [{ translateX: dotTranslateX }], }, ]} /> {/* 四个节点 */} {renderNodes()} </View> <Text style={styles.tipText}> {activeNode === NODE_COUNT ? '包裹已送达' : `正在发往第 ${activeNode + 1} 站`} </Text> <TouchableOpacity style={styles.replayBtn} onPress={startSequence}> <Text style={styles.replayText}>重新播放</Text> </TouchableOpacity> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#F5F7FA', alignItems: 'center', justifyContent: 'center', }, progressRow: { width: NODE_STEP * (NODE_COUNT - 1) + 60, height: 68, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', position: 'relative', }, line: { position: 'absolute', left: 30, right: 30, top: 32, height: 3, backgroundColor: '#DCE3EC', borderRadius: 2, }, dot: { position: 'absolute', left: 18, top: 22, width: 14, height: 14, borderRadius: 7, backgroundColor: '#2F80ED', }, node: { width: 36, height: 36, borderRadius: 18, backgroundColor: '#AEB6C2', alignItems: 'center', justifyContent: 'center', zIndex: 2, }, nodeText: { color: '#fff', fontSize: 14, fontWeight: '600', }, tipText: { marginTop: 24, fontSize: 14, color: '#4E5A6B', }, replayBtn: { marginTop: 20, paddingHorizontal: 28, paddingVertical: 10, borderRadius: 20, backgroundColor: '#2F80ED', }, replayText: { color: '#fff', fontSize: 14, }, });

这里有个关键细节:key 设置了 index,节点高亮文字用 state 来控制,但 opacity 和 scale 是 Animated.Value 驱动。动画执行过程中,百分之百的视图变化都走 Animated 体系,而不会因为 setState 触发整个组件重渲染。否则动画跑起来会明显卡顿,因为每次 setState 都会让所有节点重新计算样式。

3.3 节点状态联动:把动画结果变成 UI 样式

上面代码里有一行const isActive = index <= activeNode;可能有人会疑惑:activeNode 到底怎么更新?

我故意在 sequence 动画数组里没写 setActiveNode,而是留到动画全部结束才更新。但在实际项目中,往往需要节点高亮的同时让下方文字同步变化,怎么办?

一个比较实用的小技巧是,在每段动画开始前,用 start 回调去更新 state。改造一下上面的循环:

animations.push( Animated.timing(nodeOpacity[i], { toValue: 1, duration: 300, easing: Easing.out(Easing.quad), useNativeDriver: false, }) ); // 动画开始前就把当前索引同步到 state setActiveNode(i);

不过要小心,这里 setActiveNode 在循环里同步调用会导致多次渲染合并,React 内部会批处理,实际表现是每帧只触发一次,不会产生严重的性能问题。

这里还有一个新手常见的误区:直接用 Animated.Value 的值去做逻辑判断,比如if (nodeOpacity[i].__getValue() === 1)。这绝对不可取,因为 Animated.Value 的值是浮点数,动画过程中经过 easing 计算,最终值不一定是精确的 1,可能停在 0.9999。正确做法是维护一个普通的 state 变量来做逻辑判断,Animated 值只负责视觉表现。

3.4 循环播放与停止控制

物流进度动画通常只需要播一遍,但如果你要做的是引导页、轮播图这类需要循环的场景,会遇到一个问题:Animated.sequence 不支持直接原地循环。你把同一个动画数组传给 Animated.loop 之后,第二次执行时所有 Animated.Value 会从当前值继续跑,而不是从初始值开始。

所以正确的循环姿势是先把所有值重置到初始状态,再创建新的动画数组去 loop:

const replaySequence = () => { // 重置所有动画值 dotTranslateX.setValue(0); nodeOpacity.forEach((item) => item.setValue(0.2)); nodeScale.forEach((item) => item.setValue(1)); Animated.loop( Animated.sequence(animations) ).start(); };

注意一个细节:setValue和Animated.timing不能混用在同一帧。在 sequence 里如果第一个动画是 timing,紧接着 setValue 又修改同一个值,会导致动画起始点被覆盖,出现一闪而过或者卡住动不了的问题。我的做法是:所有重置操作放在动画开始之前,不要在动画数组中间混入 setValue。

4. 鸿蒙上跑串行动画,适配层要留意什么

4.1 基础组件的动画支持差异

React Native 官方推荐动画驱动的属性主要是 transform 和 opacity,这两者在鸿蒙适配层支持得最完善。除此之外,某些样式的动画在鸿蒙上的表现和 iOS、Android 并不完全一致。

比如width和height的动画,React Native 官方文档本就标注为不推荐;鸿蒙适配层目前对 width 动画的支持也是“能用但感受不好”,因为它不走原生渲染器的 transform,而是触发布局重算,性能开销大。

另外,left、top这类 position 属性的动画在鸿蒙上会造成滚动区域重排,动画过程中其他元素的坐标也会跟着跳动。我踩过一次坑:用left驱动圆点在节点之间移动,结果动画跑的时候节点文字疯狂抖动。后来改成用transform: [{ translateX }],问题立刻消失。

原则很简单:能改 transform 就不要动布局属性,能改 opacity 就不要去动背景色的色值动画。除非实在没有替代方案,否则尽量避免对 color、borderRadius、width 这类属性做高频 Animated 动画。

4.2 useNativeDriver 在鸿蒙下的实际表现

很多教程会告诉你“一定要把 useNativeDriver 设为 true,否则动画卡”。这句话在 iOS 和 Android 上基本正确,但鸿蒙适配层还在完善中,无脑开 true 可能会翻车。

实测了 opacity 和 transform 两个属性的动画,open 着 useNativeDriver: true 在鸿蒙模拟器和真机上都能跑,但 transform 的 translateX 动画偶尔会出现“提前结束”的现象,动画值已经走完,但视图位置差几个像素没跟上,视觉上就是一个顿挫。在 iOS 上没出现过这个问题。

我的建议是:鸿蒙平台上的串行动画,useNativeDriver 先用 false 跑通功能,再逐个提升为 true 测性能,不要一开始就全量打开。实践中动画段数不多、节点简单时,JS 驱动和 native 驱动的体感差异基本察觉不到,稳是第一位的。

4.3 避免动画过程中的 UI 卡死

鸿蒙的 RN 运行时在 JavaScript 线程和 UI 线程之间有一套消息队列机制,如果动画过程中 JavaScript 线程被大任务阻塞,动画就会卡顿或假死。串行动画因为每一段都在 JS 侧编排,所以特别怕这个。

有两个常见的阻塞来源:

第一个是 setState 的过度调用。如果动画过程中每次 tick 都调用 setState 去更新文本、图标,会让 JS 线程的帧预算被大量消耗。解决方案是:文本和图标用 Animated 的 interpolate 做映射,或者分层渲染,把不动的内容拆成独立的子组件,用 memo 包一下。

第二个是同步读取本地存储、蓝牙、传感器等原生模块的数据。在动画播放期间去做同步原生化调用,会阻塞消息队列。正确的做法是放到 InteractionManager.runAfterInteractions 里执行:

import { InteractionManager } from 'react-native'; InteractionManager.runAfterInteractions(() => { // 读取本地数据,更新页面 });

这个 API 的作用是等当前所有动画和交互结束后再执行任务,能有效避免动画被中途打断。

5. 常见问题排查实录:动画不动、闪跳、叠加失效

5.1 动画完全不动,也没报错

这是串行动画最容易踩的坑:sequence 执行了,但所有节点纹丝不动。排查顺序是这样的:

第一步,断点确认动画数组里的动画确实被创建了。很多新手是在 useEffect 里调用了 startSequence,但 useEffect 的依赖数组写错了,或者组件还没挂载就调用了原生模块,导致动画数组为空。

第二步,检查 Animated.Value 是否绑定到正确的 Animated.View 上。只有 Animated.View 或 Animated.Text 能响应 Animated.Value 的变化,普通的 View 不认这个,写了也白写。

第三步,检查是否使用了useNativeDriver: true来驱动不支持的属性,比如 color。鸿蒙适配层对 color 的原生驱动支持还不稳定,会直接静默失败。把 useNativeDriver 改成 false 再试。

5.2 动画一步到位,中间过程被跳过

遇到的场景是:点击播放后,圆点从第一个节点“瞬移”到最后一个节点,中间过程完全没显示。这通常是因为 sequence 里的每段动画共用同一个 Animated.Value,而 setValue 在动画开始前被调用了多次。

仔细看代码的话,大概率是把重置逻辑写在了循环里,共振最后一段动画的重置覆盖了前面所有段的目标值。解决方法是把重置逻辑从循环里提出来,只在动画开始前执行一次。

还有一个隐蔽情况:同一个 Animated.Value 同时被 parallel 里的两段动画驱动,比如圆点移动和节点高亮都拿 dotTranslateX 做驱动,一段设了toValue: 84,另一段设了toValue: 168,两段抢同一个值,最终结果就是直接跳到后面的目标值。这种问题很容易查,检查每个 Animated.Value 有没有被两个以上的动画引用。

5.3 循环动画卡死或越来越快

loop 场景下动画越来越快的根本原因是动画叠加,不是循环本身的问题。每次循环结束,Animated.Value 的值没有回到初始值,下一轮动画从中间值开始跑,视觉上就像动画被加速了。

解决办法我在 3.4 已经说了:loop 前把所有相关值用 setValue 重置。但还要注意,如果 sequence 内部有 parallel,parallel 里的所有 Animated.Value 都要重置,漏掉任何一个都会导致某一项动画在第二轮“凭空消失”或者从错误的位置开始。

我这边整理了一张排查速查表,适合贴在公司显示屏旁边:

现象排查点解决方案
动画完全不动Animated.Value 是否绑定 Animated 组件换 Animated.View / Animated.Text
动画完全不动是否用 native driver 驱动不支持属性改为 false 或换属性
动画瞬移setValue 与 timing 混用重置逻辑放动画数组外
动画瞬移同一 Value 被多段动画同时驱动检查 parallel / sequence 内部的重复引用
循环加速loop 前未重置所有相关值循环前逐一 setValue 重置
动画卡顿setState 在动画帧内频繁调用用 Animated.interpolate 替代部分 state
节点文字抖动使用了布局属性驱动位置改用 transform: translateX

排查时要善用 Animated.Value 的addListener,把值打印出来看是否按预期变化。我这个物流 Demo 里排查瞬移问题时,就是靠监听发现 dotTranslateX 在动画开始前已经被 setValue 成 168 了,才定位到是重置逻辑写到了循环里。

最后补一个小技巧:串行动画里如果发现某一段动画有肉眼可见的延迟,不是代码逻辑的问题,而是鸿蒙模拟器的帧率限制。模拟器默认帧率可能只有 30,动画再顺滑也会显得一顿一顿。把模拟器帧率调到 60,或者换真机测试,观感立刻不一样。做动画调试,我强烈建议用真机,模拟器能用来验证功能,但别拿来判断动画流畅度。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询