一 前言
React Native 目前是一个非常成熟的跨端技术方案, 总体来看 RN 在 Native 端的表现也是非常出色的,即便是这样,在 RN 构建的应用中,性能也是不容忽略的一部分,尤其在移动端应用中,受到内存的影响很大,如果用法不当,很容易造成闪退,崩溃的情况发生。
所以,本章节我们来浅谈一下在 RN 中的性能优化,以及如何构建高性能的 RN 应用。
我这里把性能优化的方向,分成引擎层面,渲染层面,图像层面,内存层面 等四个层面,本文将重点介绍前两个。
二 引擎层面
在 React跨端动态化序章—从JS引擎到RN落地 中,我们讲到 React Native 分为 JS 层,C++ 层和 Native 层三部分构成,在 JS 层,JS 是由 JSC 作为引擎驱动 (目前已经开始主推 Hermes )了。所以想要快速打开一个 RN 应用,创建 JS 引擎是一个关键的环节。
以安卓侧为例子,RN 应用的启动流程如下:
- 创建 JS 引擎,注册 Native 和 C++ ,C++ 和 JS 层的通信桥,同时会创建 JS 和 Native UI 线程队列。
- 异步加载 JS Bundle,这一部分是 JS 交给 JS 引擎去处理,会对 JS 文件进行加载和解析,当然解析的时长受到 JS 文件大小的影响。
- 当 JS 解析完毕之后,接下来就要启动 RN 应用了,包括运行 RN 提供的 AppRegistry 入口。
- 构建组件树,包括执行 React 运行时代码,渲染组件,接下来通过 Native 提供的 UIManager,把虚拟 DOM 树在 Native 应用中渲染出来,视图也就正常呈现了。
在上述流程中,JS 引擎的构建,解析并运行 JS Bundle,准备 JS 上下文是最占用时间的一部分。所以对于 JS 引擎的预加载就显得非常重要。
预加载技术
引擎预加载和业务场景息息相关,对于一些上下游的页面会有一定的要求,在加载当前页面的时候,如果下游页面是 RN 页面,那么会进行引擎的预加载,构建初始化的 JS 环境。比如如下的页面栈:
如上一个业务线上存在 A,B,C 三个页面,其中 C 是 RN 页面,那么当从 A 进入到 B 的时候,开始启动预加载,加载 C 页面的 Bundle,这样进入到 C 页面后,就不需要做初始化 JS 运行环境等操作,大幅度提高了页面的秒开率。
但是预加载的 JS 引擎不能一直存在,所以可以在 从 B -> A 的时候,回收引擎。还有一点需要注意的是,预加载的引擎需要在内存中保留一段时间后才会被回收,所以在进入一个页面中的时候,不要预加载很多页面,这样就会造成内存瞬间暴涨,容易引起 APP 闪退。
可以说预加载是对下一个页面预处理,那么对于引擎优化层面上,还有一个优化技巧那就是引擎复用。
引擎复用技术
引擎复用,也是一种对页面初始化加载的优化手段,比如 A 进入 RN 的 B 页面,当 B 离开回到 A 的时候,B 的引擎并没有直接回收,而是被保存下来,当 A 再次进入到 B 的时候,直接服用引擎,这样当第二次进入 B 的时候,打开的速度非常快。
引擎复用是一种短时间内对引擎的保活,但是并不意味着引擎就可以一直存在,如果一直存在,还会面临内存吃紧的情况,长此以往就会让应用内存越来越大,导致崩溃。所以需要对引擎进行短时间的保活,一般都会存在几分钟。
引擎复用比较适合从列表页到详情页的场景,比如从商品列表到商品详情,用户可能多次从商品详情返回列表,然后再次进入商品详情。
引擎复用有一个弊端需要开发者注意,因为引擎的存在,会让 JS 中的一些全局变量(比如 Redux 中的状态)无法被垃圾回收,在下一次复用的时候,会影响到新的 RN 应用的数据状态,一个靠谱的方案就是,在 RN 应用在初始化的时候清除数据。
三 渲染层面
如上讲到了 React Native 在 JS 引擎方面上的优化,主要的影响就是页面打开的时间,白屏时间,以及秒开率,接下来我们分析一下在 React Native 运行时的优化手段。
组件渲染也是很重要的一部分,因为在 React Native 中,渲染成本比 web 端更大,为什么这么说呢?我们先来看简单分析一下 React web 应用和 React Native 应用的渲染区别。
在 React web 应用中渲染流程是,先由 element 对象转换成虚拟 DOM fiber 对象,再有 fiber 转换成真实 DOM ,最后交给浏览器去绘制。
但是在 RN 中,渲染流程会更加复杂,在构建 fiber 对象后,需要通过桥的方式通知 UI Manage 构建一颗 Shadow Tree,Shadow Tree 可以理解为是 "Virtual DOM" 在 Native 的映射,拥有和 Virtual DOM 相同的树形层级关系。最后 Native 根据 Shadow Tree 映射成 Native 元素并渲染。
React Native 渲染过程中需要三个线程共同完成。
Main Thread:主线程又称 UI thread,主要负责 UI 的渲染以及用户行为的监听等等,是 App 启动时首先创建的线程
JavaScript Thread:通过 JavaScript Core 或者是 Hermes 引擎,JavaScript 代码的解析和执行是由 JS 线程负责的。和浏览器环境不同的是, JS 代码解析和执行在独立 JS 线程,而不是和 UI 渲染共用一个线程。
Shadow Thread:前两个线程都比较好理解,那 Shadow 线程是做什么的呢?要回答这个问题,首先我们需要理解 React 的原理。
所以在 RN 端,页面的渲染成本会更高,这就要求开发者在开发过程中,需要监控一下组件的渲染次数,可以通过 React 层面去减少页面或者组件的 rerender。
减少 rerender 的次数
在 RN 中,减少页面渲染方案和浏览器端是统一的,本质上都是在 React render 阶段的优化手段。
我们来回顾一下 React 控制渲染的策略:
1 缓存React.element对象
第一种是对 React.element 对象的缓存。这是一种父对子的渲染控制方案,缓存了 element 对象。这种方案在 React Native 中同样受用。
import React from "react"
import {
View, TouchableOpacity, Text } from "react-native"
function Children () {
return <View>子组件</View>
}
function App(){
const [ number, setNumber ] = React.useState(0)
/* 这里把 Children 组件对应的 element 元素缓存起来了 */
const children = React.useMemo(()=><Children />,[])
const onPress = () => setNumber(number => number + 1);
return <View >
父组件
<TouchableOpacity onPress={
onPress} >
<View>
<Text>add</Text>
</View>
</TouchableOpacity>
</View>
}
如上当点击 add 按钮的时候,App 会重现渲染,但是由于 Children 组件对应的 element 被缓存起来了,所以并不会跟随着父组件渲染。一定程度上优化了性能。
2 PureComponent
纯组件是一种发自组件本身的渲染优化策略,当开发类组件选择了继承 PureComponent ,就意味这要遵循其渲染规则。规则就是浅比较 state 和 props 是否相等。
import React from "react"
import {
View, TouchableOpacity, Text } from "react-native"
class Children extends React.PureComponent{
//...
}
3 shouldComponentUpdate
有的时候,把控制渲染,性能调优交给 React 组件本身处理显然是靠不住的,React 需要提供给使用者一种更灵活配置的自定义渲染方案,使用者可以自己决定是否更新当前组件,shouldComponentUpdate 就能达到这种效果。
shouldComponentUpdate(newProp,newState,newContext){
if(newProp.propsNumA !== this.props.propsNumA || newState.stateNumA !== this.state.stateNumA ){
return true /* 只有当 props 中 propsNumA 和 state 中 stateNumA 变化时,更新组件 */
}
return false
}
4 React.memo
React.memo(Component,compare)
React.memo 可作为一种容器化的控制渲染方案,可以对比 props 变化,来决定是否渲染组件,首先先来看一下 memo 的基本用法。React.memo 接受两个参数,第一个参数 Component 原始组件本身,第二个参数 compare 是一个函数,可以根据一次更新中 props 是否相同决定原始组件是否重新渲染。
memo的几个特点是:
React.memo: 第二个参数 返回 true 组件不渲染 , 返回 false 组件重新渲染。和 shouldComponentUpdate 相反,shouldComponentUpdate : 返回 true 组件渲染 , 返回 false 组件不渲染。
memo 当二个参数 compare 不存在时,会用浅比较原则处理 props ,相当于仅比较 props 版本的 pureComponent 。
RN 开发者可以通过如上的四种方式减少组件的渲染次数,进而优化性能。
渲染分片
运行 RN 的宿主环境,基本都是移动端,在移动端,有内存大的高端手机,也有内存小的低端手机,在内存小的低端手机上,如果在初始化阶段一次性加载大量的模块,比如初始化加载大量的图片模块组件,就会让内存端时间内暴涨,低端的手机本来内存就小,就会达到内存的阀值,就会造成 App 崩溃。RN 应用本身就比较耗内存,即便有 LRU 算法,可以处理长时间内的增量内存,但是内存的处理,还是需要时间去消化,那么短时间内内存暴涨依旧是一个非常头疼的问题。
还有一点就是上面说到,渲染本身也耗性能,如果短时间内加载大量的模块,就会让加载时间过长,从而让用户等到响应的时间变长。
为了解决上面的两点问题,渲染分片就显得格外重要了,可以根据业务场景,渲染模块按需加载,而不是一次性渲染大量的模块,首先就要对模块定义渲染的优先级,重要的模块优先渲染,次要的模块滞后渲染。
就像当用户进入一个商品详情页,最优先展示的应该用是有关该商品的信息,比如图片,价格,生产地等等,而一些不重要的模块,比如推荐其他商品,就不需要优先渲染。
那么比如有三个组件 A,B,C 我们就可以在渲染 A 之后再渲染 B ,渲染 B 之后再渲染 C。
那么首先有一个问题就是如何知道组件渲染完成了? 还好即便是 RN 也保持了 React 的活性。可以用对应的生命周期或者是 hooks ,来感知组件更新完毕。
在类组件中,可以通过 componentDidMount 知道组件初始化渲染完成,同样在函数组件中,可以通过 useEffect 或者 useLayoutEffect 来达到相同的目的。
比如实现上面 A -> B -> C 的渲染流程,可以在 A 的 componentDidMount / useEffect 来渲染 B ,然后 B 渲染完成以同样的手段再渲染 C。甚至可以通过 setTimeout 来加一个短暂的延时。这样的操作就像给渲染加了调度,去控制每一个模块的渲染顺序,当然渲染调度写在每一个业务代码中,不是很友好,所以为了解决这个问题,可以写一个 HOC 来包裹业务组件,通过 HOC 组件生命周期来控制业务组件的加载时机,达到渲染分片的目的。
具体的代码实现可以参考 「React 进阶」 学好这些 React 设计模式,能让你的 React 项目飞起来 中 HOC 的介绍场景。
长列表优化
长列表是移动端应用中,一个比较常见的场景,基本上主流的 App 应用中,都有长列表的影子。
在 Native 中,对于长列表本来就有比较成熟的方案,在 Native 应用中,对于每个列表 item 可以进行复用,在 RN 中,也提供了对应的组件来处理长列表的情况,比如说 FlatList 和 SectionList。
FlatList 是高性能的简单列表组件,SectionList 是高性能的分组(section)列表组件。官方网站中介绍比较详细,这里就不多说了
四 总结
本文从引擎与渲染两个方面介绍了 RN 优化手段,希望这篇文章的能给 React Native 开发同学一个性能优化上启发。