likes
comments
collection

构建高性能 React Native 跨端应用—引擎与渲染

作者站长头像
站长
· 阅读数 27

一 前言

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 应用中渲染出来,视图也就正常呈现了。

构建高性能 React Native 跨端应用—引擎与渲染

在上述流程中,JS 引擎的构建,解析并运行 JS Bundle,准备 JS 上下文是最占用时间的一部分。所以对于 JS 引擎的预加载就显得非常重要。

预加载技术

引擎预加载和业务场景息息相关,对于一些上下游的页面会有一定的要求,在加载当前页面的时候,如果下游页面是 RN 页面,那么会进行引擎的预加载,构建初始化的 JS 环境。比如如下的页面栈:

构建高性能 React Native 跨端应用—引擎与渲染

如上一个业务线上存在 A,B,C 三个页面,其中 C 是 RN 页面,那么当从 A 进入到 B 的时候,开始启动预加载,加载 C 页面的 Bundle,这样进入到 C 页面后,就不需要做初始化 JS 运行环境等操作,大幅度提高了页面的秒开率

但是预加载的 JS 引擎不能一直存在,所以可以在 从 B -> A 的时候,回收引擎。还有一点需要注意的是,预加载的引擎需要在内存中保留一段时间后才会被回收,所以在进入一个页面中的时候,不要预加载很多页面,这样就会造成内存瞬间暴涨,容易引起 APP 闪退。

可以说预加载是对下一个页面预处理,那么对于引擎优化层面上,还有一个优化技巧那就是引擎复用。

引擎复用技术

引擎复用,也是一种对页面初始化加载的优化手段,比如 A 进入 RN 的 B 页面,当 B 离开回到 A 的时候,B 的引擎并没有直接回收,而是被保存下来,当 A 再次进入到 B 的时候,直接服用引擎,这样当第二次进入 B 的时候,打开的速度非常快。

构建高性能 React Native 跨端应用—引擎与渲染

引擎复用是一种短时间内对引擎的保活,但是并不意味着引擎就可以一直存在,如果一直存在,还会面临内存吃紧的情况,长此以往就会让应用内存越来越大,导致崩溃。所以需要对引擎进行短时间的保活,一般都会存在几分钟。

引擎复用比较适合从列表页到详情页的场景,比如从商品列表到商品详情,用户可能多次从商品详情返回列表,然后再次进入商品详情。

引擎复用有一个弊端需要开发者注意,因为引擎的存在,会让 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 开发同学一个性能优化上启发。

参考

React Native 渲染原理 React Native 中文网