文章

RN动画实现方式深度解析

对比 Animated API、LayoutAnimation 和手势动画三种方案,拆解 `useNativeDriver` 的底层原理与 react-native-reanimated 的未来方向。

RN动画实现方式深度解析

一句话概括

RN 有三种动画方案——Animated API(声明式驱动,灵活但需要 JS 参与)、LayoutAnimation(布局变化自动过渡,零 JS 开销但灵活性差)、手势动画(交互式,配合 reanimated 可达 60fps)——选对的方案、开对 useNativeDriver,是 RN 动画不掉帧的唯二法门。

核心知识点

1. Animated API:三种动画类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// ① timing:时间驱动的渐变
Animated.timing(value, {
  toValue: 1,
  duration: 500,
  easing: Easing.bezier(0.25, 0.1, 0.25, 1), // 自定义贝塞尔曲线
  useNativeDriver: true,  // 💡 关键!
}).start(({ finished }) => {
  if (finished) console.log('完成')
})

// ② spring:物理弹簧模型
Animated.spring(value, {
  toValue: 200,
  friction: 7,   // 摩擦力越大弹跳越少(默认 7)
  tension: 40,   // 张力越大速度越快(默认 40)
  useNativeDriver: true,
}).start()

// ③ decay:惯性减速(配合手势)
Animated.decay(value, {
  velocity: 1.5,           // 初始速度(手势松手时的速度)
  deceleration: 0.997,     // 减速率(默认 0.997)
  useNativeDriver: true,
}).start()

// 组合:串行 / 并行 / 交错
Animated.sequence([anim1, anim2, anim3])      // 一个接一个
Animated.parallel([anim1, anim2, anim3])       // 同时跑
Animated.stagger(100, [anim1, anim2, anim3])   // 间隔 100ms 依次启动

2. useNativeDriver 的底层原理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
开启 useNativeDriver: true 后:

JS 线程(仅启动瞬间参与)           UI 线程(接管后续所有帧)
│                                    │
│ .start()                           │
│ → 通过 Bridge/JSI 发送动画配置     │
│   {toValue, duration, easing}      │
│                                    │ → 创建原生驱动器
│                                    │ → CADisplayLink(iOS)/
│                                    │   Choreographer(Android)
│                                    │   每帧回调:
│                                    │   - 计算当前 progress
│                                    │   - easing 插值
│                                    │   - 直接更新 View 属性
│                                    │   - 零 JS 参与!
│ JS 之后可以去做别的事              │

限制:useNativeDriver 只能驱动 transform(translateX/Y、scale、rotate)和 opacity。不能驱动 width、height、left、top、backgroundColor 等布局属性——因为这些需要触发原生侧的 requestLayout(),原生驱动没有这个能力。

变通方案:用 translateY 替代 top,用 scaleX/scaleY 模拟宽高动画,用 opacity + 两个叠放的 View 模拟颜色渐变。

3. LayoutAnimation:零代码的布局过渡

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 安卓需要手动开启
if (Platform.OS === 'android') {
  UIManager.setLayoutAnimationEnabledExperimental(true)
}

// 一行代码触发,所有布局变化自动动画化
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setExpanded(!expanded)
// 之后 expanded 控制的 View 出现/消失/移动 全自动过渡

// 精细控制:create(出现) / update(移动) / delete(消失) 分别配置
LayoutAnimation.configureNext({
  duration: 400,
  create: { type: 'easeInEaseOut', property: 'opacity' },
  update: { type: 'spring', springDamping: 0.7 },
  delete: { type: 'easeInEaseOut', property: 'scaleXY' },
})

LayoutAnimation 比 Animated + useNativeDriver 更彻底——JS 只调一次 configureNext,后续完全原生自驱动,连动画配置的传输都免了。代价是灵活性极差:只能对 setState 触发的布局变化做过渡,无法做持续变化的交互动画。

4. 手势动画:从 PanResponder 到 reanimated

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// ❌ PanResponder:事件从原生 → JS 线程处理 → 更新 Animated.Value
// JS 线程一忙就掉帧,"拖拽不跟手"的罪魁祸首
const panResponder = PanResponder.create({
  onPanResponderMove: (_, { dx, dy }) => {
    pan.setValue({ x: dx, y: dy }) // 在 JS 线程!
  },
})

// ✅ react-native-reanimated:手势+动画全在 UI 线程
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated'

function DraggableBox() {
  const translateX = useSharedValue(0)
  const translateY = useSharedValue(0)

  const gesture = Gesture.Pan()
    .onUpdate((e) => {
      translateX.value = e.translationX  // UI 线程!
      translateY.value = e.translationY
    })
    .onEnd(() => {
      translateX.value = withSpring(0)   // 弹回动画也在 UI 线程
      translateY.value = withSpring(0)
    })

  const style = useAnimatedStyle(() => ({
    transform: [{ translateX: translateX.value }, { translateY: translateY.value }],
  }))

  return (
    <GestureDetector gesture={gesture}>
      <Animated.View style={[{ width: 100, height: 100 }, style]} />
    </GestureDetector>
  )
}

手势动画选型:PanResponder 够用于低频交互(如按钮拖拽),高频交互(如地图拖拽、图片缩放)必须上 react-native-gesture-handler + react-native-reanimated,全程 UI 线程,JS 线程卡死也不掉帧。

5. 三种方案的选型决策

场景推荐方案原因
按钮按下反馈Pressable style + pressed最简单,原生驱动
页面切换过渡Animated + useNativeDriver精细控制时序
列表展开/折叠LayoutAnimation零代码,自动过渡
启动引导页序列动画Animated.sequence步步控制
拖拽/缩放/旋转reanimated + gesture-handler全 UI 线程
骨架屏 shimmerAnimated.loop + native driver循环动画

其实你每天都在用

  1. Pressable 的 pressed 状态变化就是动画 — style={({ pressed }) => [...]} 中的状态切换自带原生驱动的过渡
  2. react-navigation 的页面切换动画底层就是 Animated API — stack navigator 的 slide、fade 效果都是 Animated.timing
  3. 键盘弹起时 KeyboardAvoidingView 的位移 → 就是 LayoutAnimation 的一次实际应用 — 键盘弹起导致布局变化,自动过渡
  4. RefreshControl 的下拉刷新动画 → Android 原生驱动、iOS 部分走 Animated — 两端实现不同,但表现一致
  5. Lottie 动画文件(JSON)比代码动画更适合复杂动效 — 设计师用 AE 导出 Lottie JSON,lottie-react-native 直接在原生层播放

常见误解(FAQ)

❌ 误区:「开了 useNativeDriver 动画就一定 60fps」 useNativeDriver 只保证动画计算不在 JS 线程——但如果 Props 更新触发 React 重渲染(比如动画驱动的 transform 变化导致父组件 setState),JS 线程依然可能成为瓶颈。reanimated 的 useAnimatedStyle 完全绕过了 React 的 render 周期,才是真正的”全原生驱动”。

❌ 误区:「width/height 动画不能用原生驱动,所以一定卡」 用 scaleX/scaleY + transformOrigin 可以模拟宽高动画,而且能用 useNativeDriver。视觉效果一致,性能天差地别。面试官问”width 从 100 变 200 怎么优化”,答案就是”改成 scaleX 从 1 到 2”。

❌ 误区:「LayoutAnimation 是旧的 API,应该用 Animated 替代」 两者不是替代关系,是互补。LayoutAnimation 适用于”我不知道哪些 View 会变,但我知道它们会变”的场景——比如一个动态表单增删字段,你没法为每个字段都创建 Animated.Value。LayoutAnimation 一句搞定。

❌ 误区:「动画做完了就完事了,不需要关心 cleanup」 Animated.timing().start() 返回的对象不持有 → 组件卸载后动画继续跑 → 尝试更新已卸载组件的 state → 内存泄漏 + warning。正确的做法是用 useRef 持有动画引用,useEffect return 里 .stop()。

一句话总结

RN 动画从”能用”到”丝滑”的分水岭就两个:useNativeDriver: true 把动画计算送离 JS 线程,react-native-reanimated 把整个动画管线搬到 UI 线程——前者是及格线,后者是优秀线。

本文由作者按照 CC BY 4.0 进行授权