背景
从入职以来,我一直在参与一个跨端项目的开发,主要技术栈是 React Native。在这期间,也不可避免地接触了一些原生能力,比如陀螺仪、手势、生命周期等。
最近,我们开始调研一个问题:
在 App 中,如何实现"持续长时间的动态渲染"?
这里说的"持续长渲染",并不是指一次性的动画效果,而是类似以下场景:
- Keep 中的跑步轨迹持续绘制
- 导航 App 中路线与视角的实时跟随
- 地图、雷达、3D 角色、桌面宠物等长时间存在、持续运动的画面
这些场景的共同点是:
画面会在很长时间内持续更新,并且帧与帧之间存在状态延续。
一开始我并没有理解这个"状态延续"的原理,只是单纯觉得:
不就是一直画吗?
Skia
既然我们本身是 RN 项目,又不想引入 WebView,那最自然的选择就是找"原生渲染能力"。
在 RN 社区里,第一个跳出来的就是 Skia。
Skia 是一个由 Google 主导的开源图形渲染库,主要用于 高性能 2D 图形绘制,底层使用 C++ 实现,被广泛应用在 Chrome、Flutter、Android 系统组件中。
在 RN 中,我们通常会通过引入 @shopify/react-native-skia 来使用它。
为什么一开始觉得它很合适?
说实话,Skia看起来几乎是"标准答案":
- 原生级性能
- GPU 加速
- 不走 WebView
- API 类似 Canvas
而且它的优化十分到位会把把绘制这件事直接下沉到 C++。

在最开始的设想中,我甚至觉得:
"只要把动画逻辑写好,Skia 负责画,应该就很稳了。"
但真正开始往"长时间动态场景"上靠的时候,问题慢慢暴露了。
问题并不是性能,是匹配度
Skia 本质上是一个以 2D 图形管线为核心设计的渲染引擎 。
它非常擅长一件事:把你给它的东西画出来,而且画得很快。
但它并不负责:
- 场景管理
- 相机系统
- 空间关系
- 世界状态的持续演进
换句话说:
Skia 的核心能力是"绘制",而不是"场景"。

当画面开始需要「持续运动」「围绕轨道旋转」「跟随某个路径」时:
- 每一帧的位置要 JS 算
- 每一帧的变化要 JS 推
- 每一帧的绘制触发,还是 JS
只要 JS 线程被打断------
比如 RN 正在处理手势、列表滚动、setState------
画面就会立刻卡住。
到这里我才意识到:
Skia 并不是慢,它只是不解决我现在遇到的这个问题。
Expo GLView + Three.js
既然 Skia 偏 2D,那自然就会往 3D 方向走。
在 RN 生态里,最常见、也是最"官方"的 3D 组合基本就是:
expo-gl(GLView)expo-threethree.js
这个方案是怎么工作的?
Three.js 在 Web 端已经非常成熟了。但真正梳理它的执行流程时,会发现它在expo的工作其实是这样的:
scss
JS 创建 GLView
⬇️
JS 初始化 Three.js Scene / Camera / Mesh
⬇️
JS 启动 requestAnimationFrame
⬇️
每一帧:
JS 计算动画
→ renderer.render
→ 手动调用 gl.endFrameEXP()
如果只是让 GLB 模型在场景中做位移、绕轨道运动,在刻意降低帧率(比如 10~20fps)的情况下,整体是"能看"的,甚至看起来还算流畅。 这类运动有一个共同点: 每一帧只是"位置在变",模型本身并没有发生结构或尺度上的变化。

但当我尝试对 同一个 GLB 模型直接做缩放(scale)动画 时,情况立刻变了。哪怕动画逻辑非常简单,只是不断放大 / 缩小,画面会出现非常明显的:抖动,掉帧,"一卡一卡"的感觉。而且这种卡顿,并不是偶发的,而是稳定复现的。

什么原因导致这样的
后面我想都是一样的使用 RN + GLView + threeJS,认为交互的问题,或者是GLB文件的特性
位移动画为什么还算流畅?
模型位移时:
- 只是更新
position - 变换矩阵相对简单
- Three.js 内部状态变化较少
- JS 每一帧传递的数据也很有限
在 低帧率 + 匀速运动 的情况下,人眼会帮我们"脑补连续性",
于是看起来还算顺。
scale 动画为什么这么容易卡?
scale 动画在 Three / GLView 这一套里,成本要高得多:
- 每一帧都要重新计算变换矩阵
- scale 会影响子节点、包围盒、裁剪判断
- 渲染管线状态变化更频繁
- JS → GL 的同步压力明显变大
这些变化都只能由 JS 在每一帧明确告诉底层怎么去渲染。只要 JS 线程被 RN 抢走一瞬间:手势 ,setState,Layout,Bridge调度在这一帧就丢失了。
因此这并不是交互本身的问题,也不是 GLB 模型复杂度导致的性能瓶颈。
本质原因在于 在 RN + GLView 架构下,动画状态的更新与帧的推进完全依赖 JS 线程驱动 。
一旦 JS 线程被其他任务占用,渲染帧就无法按时推进,画面便会直接卡顿。
为什么 Web 中的 scale 看起来这么轻松?
回答问题前我们先分别看一下web上的位移动画和Scale动画效果


差距有点大......都是 GL+threeJS的背景,那为什么造成了这个原因呢?
浏览器的优化
上面我们看到,在 RN 的跨端框架中:
渲染是否发生、何时发生、以及下一帧画什么,几乎完全依赖 JS 线程逐帧驱动。
而在 Web 环境中,即使我们使用的是 WebGL + Three.js,渲染帧的推进并不完全依赖 JS 线程。 换句话说:浏览器并不是"每一帧都等 JS 算完再画",而是"只在必要时才叫 JS 介入"。
当一个动画被启动后:
- Three.js 将物体的变换参数(position / scale / rotation)
- 以及对应的矩阵更新逻辑
- 提交给浏览器的渲染管线
只要动画逻辑本身是"连续的" (例如匀速位移、缩放、插值动画),
帧调度与合成始终由浏览器主导,JS 只是参与者,而不是驱动者。
- JS 线程只负责 初始状态与关键参数变更
- 帧调度、vsync 对齐、GPU 提交由浏览器统一完成
因此即便 JS 出现短暂阻塞,画面也不会立刻停住
RAF MDN地址 Rendering pipeline 这里面可以看到web中的RAF是由浏览器去调度的,即使 JS 线程短暂阻塞,浏览器仍可能复用上一帧的状态让 GPU 进行合成。
结尾
在 Web 环境中,即便动画是匀速移动或连续旋转,GPU 并不需要每一帧都等 JS 计算完成才能渲染下一帧。只要 JS 在动画开始时提供了初始状态、速度、旋转轴等信息,浏览器的渲染管线就能利用这些已有状态持续推进动画。换句话说:
- Web / Three.js + WebGL
JS 负责一次性设置参数 → GPU 自行渲染 → 即使 JS 暂时阻塞,动画也不会瞬间停住,而是继续使用已有状态进行渲染。就像导演指挥好演员动作后,摄影机自己拍摄电影。 - RN / Expo GLView + Three.js
GPU 并没有独立推进动画的能力,每一帧都需要 JS 告诉它物体的新位置和旋转。JS 线程一旦阻塞或计算过重,画面就会卡住或掉帧。像导演必须每一帧都手把手指挥演员,任何停顿都会影响拍摄。
核心差异:Web 的 GPU 可以"复用上一帧状态继续播放",而 RN 的 GPU 渲染完全依赖 JS 线程逐帧驱动。
这一点也解释了为什么同样的 GLB 模型、同样的 Three.js 代码,在浏览器里顺滑,但在 RN 里高 FPS 动画很容易卡顿。