Skip to content

RN 应用篇:性能优化实践

一前言

性能优化是一个老生常谈的问题, 相比于传统的 web 来说, React Native 应用对于性能的要求也是非常重要的,构建高性能的 React Native 应用迫在眉睫。

那么本章节,就来一起学习一下 React Native 的优化手段。在正式进入今天的主题之前,先来看一个问题,那就是 React Native 的性能瓶颈到底在哪里?

首先在前面的章节中讲到,对于 Native 应用来说,React Native 的产物就是一个 JS 文件,称之为 JSBundle, Native 应用不能运行 JS 文件,所以需要创建一个 JS 引擎来运行 JSBundle ,初始化 JS 引擎和加载 bundle 是比较耗时的,这两点直接影响了白屏时常和首屏加载时长,所以开发者有必要在 JS 引擎和 bundle 加载上下功夫。

构建的 React Native 应用,一般都是移动端应用,对于移动端应用,秒开率是非常重要的,秒开率影响着用户体验。再来盘点一下页面加载流程,基本上都是加载 bundle -> 初始化请求 -> 页面渲染,其中不难看出在初始化请求和渲染过程中,也有很多能够优化的地方。

之前的章节讲到过,在运行时的 React Native 应用中,如果触发一次 setState ,会触发 diff 对比,虚拟 DOM 构建,通信绘制指令, Native 处理渲染指令,重新渲染页面,这一系列流程, 可见setState 带来后续复杂的更新链路,也是影响 RN 应用性能的原因。

在 RN 应用中,长列表和图像是非常常见的场景。那么 RN 是如何做长列表优化和图像处理的呢?这也是我们今天值得讨论的问题。

还有就是在 Native 应用中,内存也是一个非常重要的指标,内存的暴涨很容器造成设备的崩溃,所以在 RN 应用中内存也非常值得关注。

针对如上情况,本章节将从以下几个方向入手,解决 RN 应用的性能瓶颈。

  • 引擎和 bundle 加载;
  • 初始化加载;
  • React 渲染优化和任务调优;
  • 长列表和图像处理;
  • 内存调优;

二 JS 引擎和 bundle 加载

来回顾一下 RN 应用的启动流程,以安卓侧为例子,RN 应用的启动流程如下:

  • 创建 JS 引擎,注册 Native 和 C++ ,C++ 和 JS 层的通信桥,同时会创建 JS 和 Native UI 线程队列。
  • 异步加载 JS Bundle,这一部分是 JS 交给 JS 引擎去处理,会对 JS 文件进行加载和解析,当然解析的时长受到 JS 文件大小的影响。
  • 当 JS 解析完毕之后,接下来就要启动 RN 应用了,包括运行 RN 提供的 AppRegistry 入口。
  • 构建组件树,包括执行 React 运行时代码,渲染组件,接下来通过 Native 提供的 UIManager,把虚拟 DOM 树在 Native 应用中渲染出来,视图也就正常呈现了。

再来看一下 RN 在初始化阶段耗时:

JS 引擎的构建,解析并运行 JS Bundle,准备 JS 上下文是最占用时间的一部分,为了解决这个问题,可以采用引擎预加载和引擎复用技术。

2.1 引擎预加载

引擎预加载,本质上很简单,就是在 Native 页面还没有进入到 RN 页面的时候,先预加载 JSBundle 。这样如果 Native 进入 RN 页面的时候,就避免了初始化占用大量的时间。

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

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

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

2.2 引擎复用

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

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

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

引擎复用有一个弊端需要开发者注意,因为引擎的存在,会让 JS 中的一些全局变量(比如 Redux 中的状态)无法被垃圾回收,在下一次复用的时候,会影响到新的 RN 应用的数据状态,一个靠谱的方案就是,在 RN 应用在初始化的时候清除数据。

举一个例子,比如我们全局有一个状态 Store

js
/* bundle 缓存状态 */
class Store {
    state = {}
    /* 设置状态 */
    setStore(name,value){
        this.state[name] = value
    }
    /* 获取状态 */
    getStore(name){}
    /* 重制状态 */
    resetStore(){
        this.state = {}
    }
}
export default new Store()

如上,因为存在引擎复用,会导致缓存的状态,在下一次页面初始化的时候,还继续保存,从而造成了数据混乱。这个时候,只需要重新执行如上的 resetStore 方法就可以了。

2.3 JSbundle 分包和代码优化

当然 JSbundle 的大小也是影响初始化性能的因素。在前面的章节中,讲到了 RN 应用可以用单 bundle 和多 bundle 方式。

单 bundle 就是多个页面打包在一个 bundle 中。如果一个 bundle 承载太多页面,会导致一个包体积过于庞大,这样就导致加载的时间过长。

为了解决这个问题,可以将 bundle 进行拆分,首先就是页面层级的拆分,把多个页面按照包体积大小,拆分成多个 bundle; 除此之外,也可以将 bundle 按照组件级别拆分,比如一个弹窗组件,就是一个独立的 bundle 。

除此之外,可以用组件化和基建来规范化代码,组件化可以将公共组件进行抽离,形成独立的组件库,形成一个组件库独立包,这样提高了代码复用率,减少了应用的体积。对于公共的基建方法 api ,工具类函数也可以独立形成 common 包。在应用启动的时候,优先加载组件包和 common 包,这样一定程度上减少了业务 bundle 的体积。

三 初始化加载

讲完引擎和 JS bundle 层面上的优化,我们再来看看初始化加载的优化手段,初始化加载直接影响了秒开率。对于初始化加载,我们也是从多个角度去分析。

3.1 预请求技术

预请求是提高秒开率很重要手段,正常情况下打开一个 RN 页面之后,RN 应用完成初始化的时候,才能执行用于请求接口的生命周期(componentDidMount)或者是 React Hooks(useEffect), 在 RN 中请求本质上底层也是调用 Native 的 api, 这样一来一回浪费了大量的时间。

既然 RN 请求这么浪费时间,那把请求交给 Native 不就可以了吗,具体流程在 RN 页面的前置页面中当用户点击 RN 页面的入口时,可以通过路由的方式,获取RN页面的相关配置参数,然后通过 RN 模块的bundleName 绑定 RN 页面接口的 functionId,然后根据 functionId 和解析出来的接口请求参数提前进行网络请求。这样大幅度提前了发起请求的时间节点,然后请求的数据,Native 会缓存到内存中,等到 RN 页面的代码开始执行的时候优先通过 jsBridge 获取该 RN 模块的 bundleName 对应的缓存中读取网络接口的数据,这个时候同时在 RN 页面上也发起真实的请求,然后通过 Promise.race 判断是 Native 预请求快,还是本地请求快,请求到数据之后,那么就可以直接渲染视图,正常情况下,预请求的速度都会快于本地请求的。

预请求和真实请求的区别。

伪代码如下所示:

js
class Home extends React.Component{
    componentDidMount(){
        const preReq = this.preRequest()
        const initReq = this.initRequest()
        Promise.race([preReq,initReq]).then(res=>{
            /* 渲染数据 */
        })
    }
    /* 获取预请求数据 */
    preRequest(payload){
       /* 通过桥的方式,向 Native 拉去数据 */ 
       return RNBridgeRequest()
    },
    /* RN 发起网络请求拉数据 */
    initRequest(){
        return getInitData()
    }

}

如上,在初始化的时候,分别用 preRequest 和 initRequest 请求数据,然后通过 Promise.race 竞速,那么数据返回快,就用哪个渲染。

3.2 初始化渲染分片

运行 RN 的宿主环境,基本都是移动端,在移动端,有内存大的高端手机,也有内存小的低端手机,在内存小的低端手机上,如果在初始化阶段一次性加载大量的模块,比如初始化加载大量的图片模块组件,就会让内存端时间内暴涨,低端的手机本来内存就小,就会达到内存的阀值,就会造成 App 崩溃。RN 应用本身就比较耗内存,即便有 LRU 算法,可以处理长时间内的增量内存,但是内存的处理,还是需要时间去消化,那么短时间内内存暴涨依旧是一个非常头疼的问题。

还有一点就是上面说到,渲染本身也耗性能,如果短时间内加载大量的模块,就会让加载时间过长,从而让用户等到响应的时间变长。

为了解决上面的两点问题,渲染分片就显得格外重要了,可以根据业务场景,渲染模块按需加载,而不是一次性渲染大量的模块,首先就要对模块定义渲染的优先级,重要的模块优先渲染,次要的模块滞后渲染。

就像当用户进入一个商品详情页,最优先展示的应该用是有关该商品的信息,比如图片,价格,生产地等等,而一些不重要的模块,比如推荐其他商品,就不需要优先渲染。

具体场景如下:

在数据返回的时候,可以通过分片的方式,来渲染数据。

如上有很多模块 module ,在视图区域内的为 module1, module2, module3 ,而 module4 和 module5 在视图区域之外,这种情况下,就很容易做渲染分片,把渲染分成两次以上,优先渲染视图区域内,然后在视图之外的分成第二次渲染,这样就解决了一次性渲染大量模块,造成加载时间过长的问题。

当然可以通过 setTimeout 来加一个短暂的延时。这样的操作就像给渲染加了调度,去控制每一个模块的渲染顺序。

js
/* 初始化请求 */
async initRequest(){
    /* 拿出不同的模版 */
    const { module1,module2,module3,module4,module5 } = await getData()
    /* 创建渲染 */
    const renderQueue = [ [module1,module2,module3] , [module4,module5] ]
    /* 开始渲染分片 */
    this.handleRenderQueue(renderQueue)
}
/* 分片渲染 */
handleRenderQueue(renderQueue){
    if(renderQueue.lenght === 0 ) return
    const currentQueue = renderQueue.shift()
    const { renderList } = this.state
    this.setState({
        renderList:[...renderList,...currentQueue]
    },()=>{
        setTimeout(()=>{
           this.handleRenderQueue()
        },200)
    })
}

如上,就是通过 setTimeout 来进行渲染分片。

3.3 业务阻塞处理

在 RN 应用初始化的时候,可能会有很多阻塞任务,比如 http 请求。比如本来可以并行的任务变成了串行,这都直接影响了初始化时间。如下:

js
async init(){
    /* 第一个接口获取页面数据 */
   const data1 = await getDataHead()
   /* 第二个接口获取页面数据 */
   const data2 = await getDataBody()
   this.setState({
       headData:data1,
       footData:data2
   })
}

如上 getDataHead 和 getDataBody 没有依赖关系,但是串行执行了, 可以变成如下的样子:

js
async init(){
    Promise.all([ getDataHead, getDataBody]).then((res)=>{
        const [headData,footData ] = res
        this.setState({({
            headData,
            footData
        })
    })
},

这样就能在一定程度上减少请求串行的时间,提高秒开率

四 React 渲染优化和任务调优

如上讲到了 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 元素并渲染。

所以在 RN 端,页面的渲染成本会更高,这就要求开发者在开发过程中,需要监控一下组件的渲染次数,可以通过 React 层面去减少页面或者组件的 rerender。

4.1 React 渲染优化手段

在 RN 中,减少页面渲染方案和浏览器端是统一的,本质上都是在 React render 阶段的优化手段。

我们来回顾一下 React 控制渲染的策略:

1 缓存React.element对象

第一种是对 React.element 对象的缓存。这是一种父对子的渲染控制方案,缓存了 element 对象。这种方案在 React Native 中同样受用。

js
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 是否相等。

js
import React from "react"
import { View, TouchableOpacity, Text } from "react-native"

class Children extends React.PureComponent{
    //...
}

3 shouldComponentUpdate

有的时候,把控制渲染,性能调优交给 React 组件本身处理显然是靠不住的,React 需要提供给使用者一种更灵活配置的自定义渲染方案,使用者可以自己决定是否更新当前组件,shouldComponentUpdate 就能达到这种效果。

js
 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

js
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 开发者可以通过如上的四种方式减少组件的渲染次数,进而优化性能。

4.2 渲染任务调优

在 React 18 中,有了 transition 的过渡属性,有了过渡属性。可以将更新任务设置不同的优先级,在 React 中低优先级的任务会让高优先级的任务。

比如切换 tab 更新视图的时候,本质上有两个更新任务,第一个任务是 tab 的状态的改变,第二个就是 tab 容器的改变。 tab 切换很简单,但是 tab 容器内容的切换却需要更新很多内容,这个时候,如果两个更新任务一起更新的话,有可能影响 tab 切换的响应速度,因为用户点击 tab 后,半天才响应的话,势必会影响到用户体验。

这个时候,需要将 tab 切换做了一个高优先级的任务,而 tab 内容的更新就作为一个低优先级的更新任务。

但是 React Native 中,并没有 transition 相关的 API ,所以需要我们用 setTimeout 来模拟过渡更新任务。比如如下:

js
/* 更新切换 tab */
const handleChangeTab=(cur)=>{
    /* 优先更新 tab 组件 */
    changeTab()
    setTimeout(()=>{
        /* 过渡更新 tab 容器 */
        changeData()
    },300)
}

如上用 setTimeout 来模拟过渡更新任务,这样优先保障了 tab 切换的响应。

五 高性能列表和图像处理

长列表和图像处理是移动端 RN 应用比较常见的场景,也是容器造成性能问题的关键点。还好 RN 也提供了相应的解决办法。

5.1 高性能列表方案

长列表是移动端应用中,一个比较常见的场景,基本上主流的 App 应用中,都有长列表的影子。 在 Native 中,对于长列表本来就有比较成熟的方案,在 Native 应用中,对于每个列表 item 可以进行复用,在 RN 中,也提供了对应的组件来处理长列表的情况,比如说 FlatListSectionList

来看看 FlatList 对应的使用:

js
<FlatList
    data={DATA}
    renderItem={renderItem}
    keyExtractor={(item) => item.id}
    extraData={selectedId}
/>

再来看一下 SectionList 的基本使用:

js
const DATA = [
  {
    title: "Main dishes",
    data: ["Pizza", "Burger", "Risotto"]
  },
  {
    title: "Sides",
    data: ["French Fries", "Onion Rings", "Fried Shrimps"]
  },
  {
    title: "Drinks",
    data: ["Water", "Coke", "Beer"]
  },
  {
    title: "Desserts",
    data: ["Cheese Cake", "Ice Cream"]
  }
];
 <SectionList
    sections={DATA}
    keyExtractor={(item, index) => item + index}
    renderItem={({ item }) => <Item title={item} />}
    renderSectionHeader={({ section: { title } }) => (
    <Text style={styles.header}>{title}</Text>
    )}
/>

5.2 图像处理

图片合理应用

图片的处理,占 RN 性能优化的大头,在现在的移动端应用中,有很多应用大量图片的场景,加载图片的过程实际是很复杂的,并且图片本身的大小,也不是最后加载到内存中的大小,也就是说最后落实在内存里面的大小,会大于图片本身的大小。

图片的处理在不同平台上表现也不一致,在 iOS 平台上对于图像的加载,加密,到最后的展现,表现还算比较好。但是在安卓平台,就时常会出现幺蛾子。

笔者在开发 RN 应用中,就遇到了这样的场景:我们 RN 只运行在安卓端,一个 RN 页面会加载大量的图片,刚开始我们没有对图片进行任何处理,只是图片的宽度和高度是写死的,那么造成的现象是,所有的图片都展现不出来,并且图片是黑的,接下来就是安卓程序直接崩溃。

后来经过排查我们发现,原来我们给图片的容器特别小,但是图片资源却非常大,由于为了在小容器中呈现大的图片,就比如说一个 100 * 100 图片容器,加载一个 1000 * 1000 图片,安卓底层需要对图片源数据进行算法压缩,此时就会让内存暴涨,帧率直接降为个位数,导致黑屏,闪退的情况。作者还把这种小容器加载大图片的情况,叫做小马拉大车。

那么如何解决这个问题呢? RN 中的 Image 组件有个 resizeMethod 属性,就是解决 Android 图片内存暴涨的问题。当图片实际尺寸和容器样式尺寸不一致时,决定以怎样的策略来调整图片的尺寸。

js
<Image resizeMethod="resize" source={{ uri: imageUrl  }} />

resizeMethod 属性有三个可选的值,默认为 auto .

resize:小容器加载大图的场景就应该用这个属性。原理是在图片解码之前,会用算法对其在内存中的数据进行修改,一般图片大小大概会缩减为原图的 1/8。 scale:不改变图片字节大小,通过缩放来修改图片宽高。因为有硬件加速,所以加载速度会更快一些。

auto:使用启发式算法来在resize和scale中自动决定,,如果是本地图片,就会用 resize,其他的一般都是 scale 属性,由于项目运用的是网络图片,所以就按照 scale 处理逻辑。

实际最佳的方案就是,适当的大小的图片容器,加载适当的图片。但是对于一些图片资源的大小是未知的,我们不能直接通过设置宽和高的方式草率的设置图片容器大小,解决方案就是可以通过 api 的方式获取远程图片的大小。如下:

js
import { Image } from 'react-native'

/* 使用 */
Image.getSize(imageUrl,(width,height)=>{ 
    console.log('宽度:',width,'高度:',height)
})

当然客户端也可以把图片压缩的操作交给服务端去做,目前很多大公司都有自己的内建图床和 CDN 服务,会提供一些自定制图片的功能,在请求图片资源的时候,就把图片的宽和高拼接到 url 中,这样服务器接受到图片请求,会根据路径获取 width 和 height,然后自行的对图片进行压缩。返回给客户端的就已经是处理好的能够适配图片容器大小的图片了。

图片管理优化

上面介绍了图片的合理使用,接下来我们看一下图片的管理优化,在 RN 中有多种多样的类型的图片,比如 png/jpg/base64/gif ,对于 gif 在安卓 build.gradle 中需要添加相关依赖。对于一些动图的处理,比如 svg 和 svga ,RN 也提供了相关的生态去处理这些图像。

对图片的管理可以通过不同的场景,运用更为合理的方案。比如对于一些大量 gif 图片的场景,内存就是一个棘手问题,图片的管理工具就需要均衡好内存缓存和磁盘缓存的策略,一般都会采用三级缓存策略。

对于一些网络加载的图片,在一些网络差或者特殊网络的情况下,可以出现加载慢,丢包的现象,这样就会导致图片一致加载失败。庆幸的是,还有专门的图片管理库来来解决这个问题。那就是 react-native-fast-image

react-native-fast-image 这个库比较受欢迎的,它对图片的加载和内存优化上都有着不错的表现。这个库在 iOS 和安卓平台上,底层用原理也各不相同。

六 内存调优

在移动端 RN 应用中,内存也是一个非常值得关注的指标,在 RN 中,运行 JS 引擎等操作本来就会占用大量的内存,何况还有图片,音视频等资源,所以开发 RN 应用不能像开发 web 应用那样无所顾忌。

6.1 清除资源状态

在 RN 层面的优化,可以从回收资源清除状态两个角度去分析。

回收资源:

比如当 A 页面中有视频播放的模块,而 B 页面是 A 的二级页面,在融合模式下,进入 A 页面之后会开始播放视频流,但是当从 A 页面进入到 B 页面之后,本质上 A 页面并没有被回收,但是这个时候,还在加载着视频资源。那么这样下去,会让内存越来越大。 那么如何解决这个问题呢? 当 A 跳转到 B 页面之后,应该停止 A 页面加载资源,或者清空视频资源,让内存维护一个健康的水平。

对于一些超多 gif 图片的页面,并还有列表加载功能,这样在向下加载数据的过程中,会渲染更多的 gif 组件,这样就会让内存越来越大,并且不容易下来,或者一些低端的机型,根本无法渲染太多的 gif 图片,那么此时应该如何解决呢? 这个时候可以做一个优化,就是只有在视图范围内的元素才渲染真正的 gif 图片,而其他看不见的直接渲染图片或者是占位图。如下所示:

清除状态:

对于一些全局的状态,或者一些定时器,延时器,在页面销毁的时候,应该注意清除。

七 总结

本章节讲了 React Natice 应用性能优化上的解决思路,希望能给开发 RN 的读者一个解决性能瓶颈方向。

前端知识体系 · wcrane