说到 React Native,你肯定不陌生,React Native 应用是一个实实在在由原生组件去渲染的移动端应用,而且,在降低移动端开发门槛的同时还能保有 60FPS 的渲染能力。
但是在实际使用起来的时候,我们仍然可以发现,它在一些场景中还是会出现可感知的页面掉帧现象,比如滑动页面时"不跟手"、动画不连贯等。那么,作为开发者,在 React Native 中我们该如何避免掉帧呢?
"掉帧"是怎么产生的?
首先我们要知道"掉帧"是怎么产生的。
目前市面上大部分移动设备是每秒 60 帧,也就是 UI 主线程会在 16.67ms 内进行一次刷新更新屏幕。如果应用当前执行的任务不能在 16.67ms 内完成,就会阻塞 UI 主线程的刷新,导致丢帧;而当这些丢帧的时长总和超过 100ms 时,用户就能明显地感觉到当前页面掉帧了。
那么在这 16.67ms 中,React Native 到底做了什么?

首先需要完成渲染。React Native 的渲染流程分为上下两部分,上半部分表示的是 JavaScript 端处理的逻辑,下半部分代表的是原生端处理的逻辑。
在 JavaScript 端的逻辑中,当我们的视图组件第一次创建或者更新视图属性/发起渲染 请求时,React Native 内部会对 props 和 的浅比较/来判断是否需要渲染:
- 如果这次和上次渲染时的 props 和 state 都没有发生任何变化,那么流程就到此结束了,不会触发渲染;
- 而如果属性发生了变更,就会调用组件的 render 函数,最后通过 ReactNativeRenderer 模块通知原生端去更新视图。
原生端的 initView() 函数接收到 JavaScript 端想要进行渲染的视图信息后,根据信息内容创建对应的原生视图和虚拟 DOM,并把原生视图加入到待渲染队列中,同时由虚拟 DOM 来对接收到的 JavaScript 样式和布局信息做转换;转换完成之后,虚拟 DOM 也会主动去更新待渲染队列中与这个虚拟 DOM 相对应的实际视图。
这里我们要知道,在这个 initView() 函数中只是创建了实际视图的实例对象并更新了视图样式,并没有添加到父视图上进行渲染,添加和渲染操作还是需要在 JavaScript 端主动发起。
当 JavaScript 端通过 setChild() 发起实际渲染请求后,原生端会根据 JavaScript 端传递的视图 tags 去待渲染队列里面查找实际视图,并把视图批量添加到对应的父视图上让 GPU 去绘制渲染。但如果在待渲染队列中没有找到某个 tag 对应的视图,就表示这个视图可能处于创建中或样式转化中,这时候,原生端就会等待下次触发渲染请求的时候,再对这个视图进行渲染。
用一句话来说就是:initView() 函数负责创建视图,setChild() 函数负责添加视图到界面上绘制。
这个有点枯燥的运行过程就是 React Native 在 16.67ms 里做的事情。如果这个渲染过程可以在 16.67 毫秒内处理完成,那就可以保持理论上的 60FPS 的帧率,用户的使用过程才会是流畅的;如果在 16.67 毫秒内没有处理完成,那当前页面就会发生掉帧现象,影响用户的使用体验。
所以,掉帧现象就是在这 16.67ms 里面产生的。那么我们怎么去解决掉帧问题呢?
如何解决"掉帧"问题?
通过干预组件渲染流程规避"掉帧"
在 React Native 的渲染流程图中,我们不难发现整个流程只存在一个判断节点,就是有没有触发原生渲染。也就是说,如果我们能把无需更新的视图以最小颗粒度识别出来,那就可以降低触发渲染的频次,从而达到减少渲染任务的效果。
而这个判断过程完全是在 JavaScript 端处理的,并且遵循了 React 的渲染机制,所以,我们只要了解 React 视图树的渲染机制,就能找到让我们干预和优化的节点。

假设这是我们的视图树,如果根视图通过修改自身持有的 state 方式去驱动最末端的绿色节点 A,去进行渲染。在默认情况下,React 会重新渲染整个视图树,包括其他的红色节点,但是这样一定会造成一定的冗余开销。其实,只需要渲染 A 节点和它的关联父节点,就能达到更新视图的目的。
这么说可能不容易理解,我们结合 React 的生命周期来看就明白了。

一个 React 组件在第一次渲染的时候,会携带初始的 state 和 props 进入 render 的渲染流程;但是当第一次渲染完成后,所有通过 setProps 和 setState 触发的更新请求都会全部交给 shouldComponentUpdate 这个函数来做对比,只有当返回值为 true 的时候才会引起视图的更新。也就是说,我们可以在这个节点对渲染流程进行干预,来避免过度渲染。
通过 InteractionManager 降低"掉帧"风险
但是,还有一个重要的问题,在实际的业务场景中,GPU 和 UI 线程除了要处理基本的渲染外,还承担着响应用户交互的职责,如果用户交互量级过大,也会导致应用在一帧的时间内需要处理的数据量激增。所以,我们把用户的交互行为也添加到整个流程中来,看看还有哪些可以优化的节点。

这次我们从三个线程的角度来理解。
- 首先是 JavaScript 线程,它主要负责执行我们的 JavaScript 逻辑代码,除了交互和渲染之外的逻辑基本都在这个线程执行。
- 然后是 NativeModule 线程,JavaScript 端和原生端的通信都是在这个线程中进行处理的,包括维护视图树和视图样式转换。
- 最后是 UI 线程,负责处理渲染绘制以及用户交互。
明确了这三个线程的分工后,我们看右侧的用户行为,这里简单描述了用户的一次交互行为从触发到响应都经历了哪些环节。
首先用户的行为是在原生端的 UI 线程触发的,然后通过 NativeModule 线程将行为数据传递给 JavaScript 线程,再由其自行分发到对应的视图组件中来响应具体的绑定函数,处理完成之后,NativeModule 线程再去通知 UI 线程,对用户的这次行为做出反馈。
那么,在这个流程中,对于响应用户行为而产生的处理任务,我们又可以做哪些优化呢?
相比 React Native 应用,原生应用的使用体验明显要优秀。因为对于纯原生应用来讲,所有繁重的逻辑都可以通过开辟新的线程来保证用户行为的处理速度,但是对于 React Native 来说,所有逻辑代码都只能在 JavaScript 线程中同步执行,JavaScript 线程的处理能力需要与原生应用所有子线程处理能力相当,才能保证拥有跟原生一样的体验,而这显然是不可能的。
所以,我们考虑可不可以通过其他的手段,来帮助我们把一些不那么紧急、不需要立即响应的逻辑放在用户交互行为响应完成后 -,以保证用户的操作都能得到实时反馈?
当然可以,React Native 中的 InteractionManager 就可以做到。
InteractionManager 可以让一些耗时的任务在交互操作或者动画完成之后执行,这样就满足了我们优先响应用户行为的需求。这里有一个购物车组件,我们需要发送网络请求来获取对应数据,并在请求完成后调用 setState 来更新视图;如果这时用户开始滚动屏幕,原生 UI 线程又在处理批量渲染,那么很有可能会导致用户行为不能在这帧中进行反馈,就产生了列表"不跟手"的现象。

使用 InteractionManager 我们就可以在网络请求完成之后,通过 InteractionManager 中的 runAfterInteractions 函数来监听用户行为是否响应完成,也就确保了在用户行为响应完成之后才开始调用 setState,使视图能在相对空闲的时间里进行渲染,从而降低用户滑动列表时的掉帧风险。

总结延伸
总地来说,React Native 在更新屏幕内容的 16.67ms 里,需要同时处理页面渲染,用户交互和其他业务逻辑,如果没有处理完的话就会出现丢帧。而当这些丢帧的时长总和超过 100ms 时,就会产生很明显的掉帧现象。
要想解决 React Native 的掉帧现象,从原理出发,我们可以通过 shouldComponentUpdate 作对比,直接阻断冗余刷新,干预组件渲染流程避免过度渲染,从而解决过度重绘带来的掉帧问题。另外,对于用户交互行为与我们的重绘发生在同一时间内引起的掉帧,我们可以使用 React Native 中的 InteractionManager 类,让一些耗时的任务在交互操作或者动画完成之后进行执行,降低对用户行为的影响,避免 UI 线程阻塞造成的掉帧。
其实,在用户体验层面,除了解决和预防掉帧之外,与原生应用类似,我们在 React Native 中也可以采用常见的优化手段------预加载来规避一些性能问题进而提升用户体验。具体怎么做呢?下一次会详细讲解 React Native 预加载方案,欢迎你持续关注。