最近在鸿蒙上折腾 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 串行动画的完整实操物流进度 Demo3.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 会从当前值继续跑而不是从初始值开始。所以正确的循环姿势是先把所有值重置到初始状态再创建新的动画数组去 loopconst 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 内部有 parallelparallel 里的所有 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或者换真机测试观感立刻不一样。做动画调试我强烈建议用真机模拟器能用来验证功能但别拿来判断动画流畅度。