React Native 动画的真实成本:对每种方案做基准测试

一个 React Native 动画库每帧到底要花多少开销?在真实的 iOS 和 Android 设备上对 Ease、Reanimated 和 RN Animated 做的基准测试。

中文
复制
The real cost of React Native animations: Benchmarking every approach

本文是 Janic Duplessis 的客座文章——他是 App&Flow 的咨询业务负责人,也是 React Native 的长期贡献者。

想象一个登录界面,背景缓缓漂移,那种让产品显得精致而非拼凑出来的细微动效。听起来很简单。我们用 Reanimated 实现并上线了。但偶尔会掉一帧,刚好够让你觉得有点不对劲——一旦察觉,就再也无法忽视。

根本原因在于 Reanimated 每一帧都要在 UI 线程上运行。当应用在这一帧里做了大量工作(触发重渲染、列表滚动、输入更新),留给动画的预算就被压缩,那个缓慢的背景位移就变成了缓慢的背景卡顿。

App & Flow,我们为注重细节的产品团队构建 React Native 应用和工具。流畅、有原生质感的 UI 是其中的重要一环。所以与其绕开这个问题,我们决定寻找更好的方案。

iOS 上的 Core Animation 把动画交给系统渲染服务器,之后完全不占用你的线程。一旦你给它一个 CAAnimation,系统就会接管,你的应用彻底退出这个循环。我们想在 React Native 里也做到这一点。react-native-ease 由此诞生——一个声明式动画库,一切都通过平台 API 驱动(iOS 上是 Core Animation,Android 上是 ObjectAnimator),没有 JS 循环,没有 worklet,也没有每帧的 shadow tree 提交。

但构建它的过程中,浮现出一个我们想诚实回答的问题:动画库的选择到底有多大影响?

于是我们做了测量。覆盖四种方案、两个平台、高端和中端设备,追踪每帧的 UI 线程开销。这篇文章分享我们的发现,并尝试回答真正重要的问题:帧开销有多大?在什么样的应用里它才重要?选择动画库时,你应该优先考虑什么?

💡 Reanimated 一侧的掉帧是模拟出来的。我们人为注入了 UI 线程压力,以复现繁忙应用中的情况。真实世界的卡顿取决于你的工作负载、设备,以及同一时间 UI 线程上还有多少其他事情在跑。

测试的四款 React Native 动画库

所有基准测试均于 2026 年 4 月进行,环境为 Expo SDK 55、React Native 0.83、Reanimated 4.3.0 和 react-native-ease 0.7.0。

我们对比了四种动画方案:

  • Ease: 即 react-native-ease,直接调用平台 API。动画在 JS 侧以 props 的形式描述,由原生驱动,每帧不涉及 JS。

  • Reanimated(Shared Values): 标准的基于 worklet 的方案。数值通过 C++ worklet 运行时在 UI 线程上驱动,但每一帧仍要经由 shadow tree 更新 props。

  • Reanimated(CSS Animations): Reanimated 较新的 CSS 动画 API。和 Ease 一样是声明式的,但底层仍是 Reanimated 的动画引擎。

  • RN Animated: React Native 内置的 Animated API,配合 useNativeDriver: true 使用。数值由原生驱动,但具体实现因平台而异。

我们还测试了开启静态 feature flags 的 Reanimated,具体是 ANDROID_SYNCHRONOUSLY_UPDATE_UI_PROPSIOS_SYNCHRONOUSLY_UPDATE_UI_PROPS,它们让 Reanimated 在只更新非布局类 props(如 transformopacity)时跳过 shadow tree 提交。这是一项值得单独拿出来说的实质性优化。

注意: RN 0.85 引入了新的 Shared Animation Backend,最终会让这些 feature flags 变得多余。Reanimated 的适配正在进行,但尚未发布。

基准测试如何测量每帧开销

我们在示例应用中内置了一个基准测试页面,循环同时动画 N 个视图(translateX,2 秒,线性,重复)。我们用一个自定义 Expo 原生模块来测量每帧开销:

  • iOS: 我们 swizzle 了 CADisplayLink 的工厂方法,拦截所有框架注册的 display link 回调,然后按帧时间戳汇总,测量每个回调的墙上时钟耗时。

  • Android: 我们使用 Window.OnFrameMetricsAvailableListener,它从平台的帧指标系统上报 ANIMATION_DURATIONLAYOUT_MEASURE_DURATIONDRAW_DURATION

每个测试跑 5 秒的采集窗口,并覆盖多种配置,以同时展示 Reanimated 的最差和最佳表现。

基准测试结果:iOS 和 Android 上的每帧 UI 线程开销

Android(Moto G8 Plus)

Android 是各库之间最公平的对比。所有方案都跑在 UI 线程上,所以你看到的就是每个动画引擎每帧额外增加多少工作量的直接度量。没有花招,没有捷径。

构建配置有多大影响?(50 个视图,平均毫秒)

Reanimated 性能最大的变量不是选哪个动画 API,而是你在 debug 还是 release 构建下测试。

对比图表
配置EaseReanimated SVReanimated CSSRN Animated
Debug,无 FF6.1828.6227.419.38
Release,无 FF3.1411.8711.208.78
Release,全部 FF3.4310.579.068.82

红线是 60fps 下 16.67ms 的帧预算。在 debug 模式下,仅仅 50 个视图,Reanimated SV 和 CSS 就双双超标,实际在丢帧。同样的动画在 release 构建下只要 11ms。Debug 构建会骗人。如果你在开发过程中发现动画卡顿,先在 release 构建下复现,再决定要不要慌。

特性开关通过绕过非布局属性的 shadow tree commit,再额外带来 11–19% 的提升。它们在某些应用里会导致视觉 bug,所以是选择性开启,但如果你看到额外开销,值得一试。

开销如何随视图数量扩展?(Release,全部 FF,平均毫秒)

视图数量增加时,开销如何变化?
视图数EaseReanimated SVReanimated CSSRN Animated
102.347.255.565.13
1003.9211.9810.269.93
5006.6339.9423.2921.85

💡 500 个视图是压力测试,不是现实目标。如果你同时给 500 个东西做动画,动画库可能不是你最大的问题。

在 10–100 个视图时,所有方案平均都在帧预算之内,不过 Reanimated 和 RN Animated 在 100 个视图时距离预算只剩 5ms,留给帧内其他工作的余量已经不多。到 500 个视图,只有 Ease 还在预算之内。Reanimated SV 达到 36ms,是帧预算的两倍多;而这还是优化过的配置。

iOS(iPhone 15 Pro)

在 iOS 上,架构差异变得无法忽视。Android 上所有库共用 UI 线程,所以对比是公平的。iOS 上 Ease 则可以“作弊”(而且是最优雅的那种)。Core Animation 运行在独立的操作系统渲染服务进程中,完全在你的应用之外。Ease 注册一个 CAAnimation 之后,系统接管一切,你的线程就可以腾出来做别的事。这就是 Ease 各项数据都在 ~0.01ms 的原因:UI 线程上每帧确实什么都没发生。代价是 Core Animation 动画无法在运行过程中从 JS 读取或打断,所以手势驱动的动画仍然得靠 Reanimated。

每帧 display link 回调耗时,ms(release 构建)

每帧显示链路回调耗时,单位 ms(release 构建)
ViewsEaseReanimated SVReanimated SV (FF)Reanimated CSSReanimated CSS (FF)RN Animated
100.011.331.081.060.630.83
1000.013.723.332.712.483.32
5000.016.846.544.163.704.91

绝对值比 Android 低,因为这里只统计了 UI 线程回调耗时。但结论依然成立:在 iOS 上,无论有多少个视图在动,Ease 都不给 UI 线程增加任何开销,而其他方案每帧都在干活。

为什么 React Native 动画库的每帧开销不同

shadow tree 的税

每一帧,Reanimated 的 worklet 都会算出新值,并通过 shadow tree 提交一次 prop 更新。这次提交会跑 Yoga 布局、prop diff 和视图变更。当你动画的是 transformopacity(对布局毫无影响的属性)时,这些工作全是白费。为了把一个 blob 往左挪三个像素,你付了一整趟布局的代价。Yoga 根本不需要知道。

feature flag(ANDROID/IOS_SYNCHRONOUSLY_UPDATE_UI_PROPS)绕过了这一整套:它把视觉属性的更新直接推到 UI 层,完全跳过布局。在 Moto G8 Plus、50 个视图下,它把 Reanimated SV 从 11.87ms 降到 10.57ms(-11%),CSS 从 11.20ms 降到 9.06ms(-19%)。默认不开,因为某些应用里会导致视觉 bug,但如果你在追开销,这是第一个该试的。

RN Animated

RN Animated 配合 useNativeDriver: true 同样能跳过每帧的 JS 线程,但动画由一个独立的原生动画模块驱动,每个动画节点都带有簿记开销。在中低视图数量下表现尚可,但随着动画视图增多,扩展性不如 Reanimated CSS。部分原因是它缺少那些 feature flag 所启用的 shadow tree 优化。

动画库的选择何时在生产应用中真正重要

对长时间运行或缓慢的动画影响最大:骨架屏加载、缓慢漂移的背景、环境式 UI 效果。一段 5 秒的动画里掉一帧就能被看出来,而与此同时几乎总有别的工作在进行(数据请求、重渲染、用户交互)。列表中的任何内容同样如此,屏幕上很容易同时存在几百个动画项。在低端设备上,每帧的微小开销会迅速累积,用户比你先察觉到。

对于短促的一次性过渡(按钮按下、toast、modal),这点开销可以忽略,用哪个库都行。

值得一提的是:Ease 只覆盖这一特定场景。手势驱动的动画(滚动联动、拖拽、滑动)以及任何改变布局属性(width、height、padding)的动画,仍然需要 Reanimated 或 RN Animated。Ease 是为视觉属性上的声明式、触发式动画专门打造的。

React Native 0.85 与 Shared Animation Backend

React Native 0.85 带来了实验性的 Shared Animation Backend,这是由 Meta 和 Software Mansion 直接构建在渲染器中的统一动画引擎。一旦 Reanimated 的集成落地,SYNCHRONOUSLY_UPDATE_UI_PROPS 就不再必要,因为绕过 shadow tree 会成为默认路径,“默认 Reanimated”和“优化后的 Reanimated”之间的差距实际上就消失了。

不过架构上的差异依然存在。Ease 根本没有逐帧动画引擎。即便后端更快,Reanimated 仍然要每帧计算数值并推送 prop 更新。这部分开销不会消失,只是变小了。集成落地后我们会更新基准测试。

自己动手跑 React Native 动画基准测试

基准测试已经内置在示例应用里。克隆仓库,运行 yarn example iosyarn example android,在演示界面点击 Benchmark 即可。源码在 example/src/demos/BenchmarkDemo.tsx,原生模块在 example/modules/frame-metrics/

有一点要注意:请使用 release 构建。Debug 模式会让 Reanimated 的数据明显偏高。如果你的数字看着吓人,多半就是这个原因。

yarn example ios --configuration Release
yarn example android --variant release

*react-native-easeApp & Flow 开发,这是一家位于蒙特利尔的 React Native 工程工作室,也是 Expo 推荐的团队。*

来源: Expo Blog← 返回首页