使用React Native替代基于WebView的框架来开发App的一个强有力的理由,就是为了使App可以达到每秒60帧(足够流畅),并且能有类似原生App的外观和手感。因此我们也尽可能地优化React Native去实现这一目标,使开发者能集中精力处理App的业务逻辑,而不用费心考虑性能。但是,总还是有一些地方有所欠缺,以及在某些场合React Native还不能够替你决定如何进行优化(用原生代码写也无法避免),因此人工的干预依然是必要的。 本文的目的是教给你一些基本的知识,来帮你排查性能方面的问题,以及探讨这些问题产生的原因和推荐的解决方法。

关于“帧”你所需要知道的

老一辈人常常把电影称为“移动的画”,是因为视频中逼真的动态效果其实是一种幻觉,这种幻觉是由一组静态的图片以一个稳定的速度快速变化所产生的。我们把这组图片中的每一张图片叫做一帧,而每秒钟显示的帧数直接的影响了视频(或者说用户界面)的流畅度和真实感。iOS设备提供了每秒60的帧率,这就留给了开发者和UI系统大约16.67ms来完成生成一张静态图片(帧)所需要的所有工作。如果在这分派的16.67ms之内没有能够完成这些工作,就会引发‘丢帧’的后果,使界面表现的不够流畅。

下面要讲的事情可能更为复杂:请先调出你应用的开发菜单,打开Show FPS Monitor. 你会注意到有两个不同的帧率.

JavaScript 帧率

对大多数React Native应用来说,业务逻辑是运行在JavaScript线程上的。这是React应用所在的线程,也是发生API调用,以及处理触摸事件等操作的线程。更新数据到原生支持的视图是批量进行的,并且在事件循环每进行一次的时候被发送到原生端,这一步通常会在一帧时间结束之前处理完(如果一切顺利的话)。如果JavaScript线程有一帧没有及时响应,就被认为发生了一次丢帧。 例如,你在一个复杂应用的根组件上调用了this.setState,从而导致一次开销很大的子组件树的重绘,可想而知,这可能会花费200ms也就是整整12帧的丢失。此时,任何由JavaScript控制的动画都会卡住。只要卡顿超过100ms,用户就会明显的感觉到。

这种情况经常发生在Navigator的切换过程中:当你push一个新的路由时,JavaScript需要绘制新场景所需的所有组件,以发送正确的命令给原生端去创建视图。由于切换是由JavaScript线程所控制,因此经常会占用若干帧的时间,引起一些卡顿。有的时候,组件会在componentDidMount函数中做一些额外的事情,这甚至可能会导致页面切换过程中多达一秒的卡顿。

另一个例子是触摸事件的响应:如果你正在JavaScript线程处理一个跨越多个帧的工作,你可能会注意到TouchableOpacity的响应被延迟了。这是因为JavaScript线程太忙了,不能够处理主线程发送过来的原始触摸事件。结果TouchableOpacity就不能及时响应这些事件并命令主线程的页面去调整透明度了。

主线程 (也即UI线程) 帧率

很多人会注意到,NavigatorIOS的性能要比Navigator好的多。原因就是它的切换动画是完全在主线程上执行的,因此不会被JavaScript线程上的掉帧所影响。(阅读关于为何你仍然需要使用Navigator

同样,当JavaScript线程卡住的时候,你仍然可以欢快的上下滚动ScrollView,因为ScrollView运行在主线程之上(尽管滚动事件会被分发到JS线程,但是接收这些事件对于滚动这个动作来说并不必要)。

性能问题的常见原因

console.log语句

在运行打好了离线包的应用时,控制台打印语句可能会极大地拖累JavaScript线程。注意有些第三方调试库也可能包含控制台打印语句,比如redux-logger,所以在发布应用前请务必仔细检查,确保全部移除。

这里有个小技巧可以在发布时屏蔽掉所有的console.*调用。React Native中有一个全局变量__DEV__用于指示当前运行环境是否是开发环境。我们可以据此在正式环境中替换掉系统原先的console实现。

if (!__DEV__) {
  global.console = {
    info: () => {},
    log: () => {},
    warn: () => {},
    debug: () => {},
    error: () => {},
  };
}

这样在打包发布时,所有的控制台语句就会被自动替换为空函数,而在调试时它们仍然会被正常调用。

还有个babel插件可以帮你移除所有的console.*调用。首先需要使用yarn add --dev babel-plugin-transform-remove-console来安装,然后在项目根目录下编辑(或者是新建)一个名为·.babelrc`的文件,在其中加入:

{
  "env": {
    "production": {
      "plugins": ["transform-remove-console"]
    }
  }
}

这样在打包发布时,所有的控制台语句就会被自动移除,而在调试时它们仍然会被正常调用。

开发模式 (dev=true)

JavaScript线程的性能在开发模式下是很糟糕的。这是不可避免的,因为有许多工作需要在运行的时候去做,譬如使你获得良好的警告和错误信息,又比如验证属性类型(propTypes)以及产生各种其他的警告。

缓慢的导航器(Navigator)切换

如之前说,Navigator的动画是由JavaScript线程所控制的。想象一下“从右边推入”这个场景的切换:每一帧中,新的场景从右向左移动,从屏幕右边缘开始(不妨认为是320单位宽的的x轴偏移),最终移动到x轴偏移为0的屏幕位置。切换过程中的每一帧,JavaScript线程都需要发送一个新的x轴偏移量给主线程。如果JavaScript线程卡住了,它就无法处理这项事情,因而这一帧就无法更新,动画就被卡住了。

长远的解决方法,其中一部分是要允许基于JavaScript的动画从主线程分离。同样是上面的例子,我们可以在切换动画开始的时候计算出一个列表,其中包含所有的新的场景需要的x轴偏移量,然后一次发送到主线程以某种优化的方式执行。由于JavaScript线程已经从更新x轴偏移量给主线程这个职责中解脱了出来,因此JavaScript线程中的掉帧就不是什么大问题了 —— 用户将基本上不会意识到这个问题,因为用户的注意力会被流畅的切换动作所吸引。

新的React Navigation库的一大目标就是为了解决这个问题。React Navigation中的视图是原生组件,同时用到了运行在原生线程上的Animated动画库,因而性能表现十分流畅。

ListView初始化渲染太慢以及列表过长时滚动性能太差

这是一个频繁出现的问题。因为iOS配备了UITableView,通过重用底层的UIViews实现了非常高性能的体验(相比之下ListView的性能没有那么好)。用React Native实现相同效果的工作仍正在进行中,但是在此之前,我们有一些可用的方法来稍加改进性能以满足我们的需求。

initialListSize

这个属性定义了在首次渲染中绘制的行数。如果我们关注于快速的显示出页面,可以设置initialListSize为1,然后我们会发现其他行在接下来的帧中被快速绘制到屏幕上。而每帧所显示的行数由pageSize所决定。

pageSize

在初始渲染也就是initialListSize被使用之后,ListView将利用pageSize来决定每一帧所渲染的行数。默认值为1 —— 但是如果你的页面很小,而且渲染的开销不大的话,你会希望这个值更大一些。稍加调整,你会发现它所起到的作用。

scrollRenderAheadDistance

“在将要进入屏幕区域之前的某个位置,开始绘制一行,距离按像素计算。”

如果我们有一个2000个元素的列表,并且立刻全部渲染出来的话,无论是内存还是计算资源都会显得很匮乏。还很可能导致非常可怕的阻塞。因此scrollRenderAheadDistance允许我们来指定一个超过视野范围之外所需要渲染的行数。

removeClippedSubviews

“当这一选项设置为true的时候,超出屏幕的子视图(同时overflow值为hidden)会从它们原生的父视图中移除。这个属性可以在列表很长的时候提高滚动的性能。默认为false。(0.14版本后默认为true)”

这是一个应用在长列表上极其重要的优化。Android上,overflow值总是hidden的,所以你不必担心没有设置它。而在iOS上,你需要确保在行容器上设置了overflow: hidden

我的组件渲染太慢,我不需要立即显示全部

这在初次浏览ListView时很常见,适当的使用它是获得稳定性能的关键。就像之前所提到的,它可以提供一些手段在不同帧中来分开渲染页面,稍加改进就可以满足你的需求。此外要记住的是,ListView也可以横向滚动。

在重绘一个几乎没有什么变化的页面时,JS帧率严重降低

如果你正在使用一个ListView,你必须提供一个rowHasChanged函数,它通过快速的算出某一行是否需要重绘,来减少很多不必要的工作。如果你使用了不可变的数据结构,这项工作就只需检查其引用是否相等。

同样的,你可以实现shouldComponentUpdate函数来指明在什么样的确切条件下,你希望这个组件得到重绘。如果你编写的是纯粹的组件(返回值完全由props和state所决定),你可以利用PureComponent来为你做这个工作。再强调一次,不可变的数据结构在提速方面非常有用 —— 当你不得不对一个长列表对象做一个深度的比较,它会使重绘你的整个组件更加快速,而且代码量更少。

由于在JavaScript线程中同时做很多事情,导致JS线程掉帧

“导航切换极慢”是该问题的常见表现。在其他情形下,这种问题也可能会出现。使用InteractionManager是一个好的方法,但是如果在动画中,为了用户体验的开销而延迟其他工作并不太能接受,那么你可以考虑一下使用LayoutAnimation

Animated的接口一般会在JavaScript线程中计算出所需要的每一个关键帧,而LayoutAnimation则利用了Core Animation,使动画不会被JS线程和主线程的掉帧所影响。

举一个需要使用这项功能的例子:比如需要给一个模态框做动画(从下往上划动,并在半透明遮罩中淡入),而这个模态框正在初始化,并且可能响应着几个网络请求,渲染着页面的内容,并且还在更新着打开这个模态框的父页面。了解更多有关如何使用LayoutAnimation的信息,请查看动画指南

注意:

  • LayoutAnimation只工作在“一次性”的动画上("静态"动画) -- 如果动画可能会被中途取消,你还是需要使用Animated

在屏幕上移动视图(滚动,切换,旋转)时,UI线程掉帧

当具有透明背景的文本位于一张图片上时,或者在每帧重绘视图时需要用到透明合成的任何其他情况下,这种现象尤为明显。设置shouldRasterizeIOS或者renderToHardwareTextureAndroid属性可以显著改善这一现象。 注意不要过度使用该特性,否则你的内存使用量将会飞涨。在使用时,要评估你的性能和内存使用情况。如果你没有需要移动这个视图的需求,请关闭这一属性。

使用动画改变图片的尺寸时,UI线程掉帧

在iOS上,每次调整Image组件的宽度或者高度,都需要重新裁剪和缩放原始图片。这个操作开销会非常大,尤其是大的图片。比起直接修改尺寸,更好的方案是使用transform: [{scale}]的样式属性来改变尺寸。比如当你点击一个图片,要将它放大到全屏的时候,就可以使用这个属性。

Touchable系列组件不能很好的响应

有些时候,如果我们有一项操作与点击事件所带来的透明度改变或者高亮效果发生在同一帧中,那么有可能在onPress函数结束之前我们都看不到这些效果。比如在onPress执行了一个setState的操作,这个操作需要大量计算工作并且导致了掉帧。对此的一个解决方案是将onPress处理函数中的操作封装到requestAnimationFrame中:

handleOnPress() {
  // 谨记在使用requestAnimationFrame、setTimeout以及setInterval时
  // 要使用TimerMixin(其作用是在组件unmount时,清除所有定时器)
  this.requestAnimationFrame(() => {
    this.doExpensiveAction();
  });
}

分析

你可以利用内置的分析器来同时获取JavaScript线程和主线程中代码执行情况的详细信息。

对于iOS来说,Instruments是一个宝贵的工具库,Android的话,你可以使用systrace,参见调试Android UI性能


书籍推荐