React Native 中 ScrollView 性能探究

1 基本使用

ScrollView 是
React Native(后面简称:RN) 中最常见的组件之一。理解 ScrollView 的原理,有利于写出高性能的 RN 应用。

ScrollView 的基本使用也非常简单,如下:

  

  ...
  

它和 View 组件一样,可以包含一个或者多个子组件。对子组件的布局可以是垂直或者水平的,通过属性 horizontal=true/false 来控制。甚至还默认支持“下拉”刷新操作。另外还有一个特别赞的特性,超出屏幕的 View 会自动被移除,从而节省资源和提高绘制效率。我们来看如下一个例子:


class ScrollViewTest extends Component {

  render() {
    let children = [];

    for (var i = 0; i < 20; i++) {
      children.push(

          {"T" + i}
        );
    }
    return (

          {children}

    );
  }
}

在 Android 上的效果如下:如图,我们在 ScrollView 中添加了 20 个子组件,但是我们的屏幕任意时刻最多只能显示 5 个子项目。
下面我们来看实际对应的 Native 控件的情况。RN 中的 ScrollView 对应到 Native 的 RCTScrollView,自动把子组件包含在一个 ViewGroup 中(因为Android 的 ScrollView 只能有一个直接子控件),如下图中的红色框内:

注意到,我们在 JS 中添加了 20 个子组件,但是在 RCTViewGroup 中只有在屏幕上显示的 5 个子控件,在屏幕外的组件,也会自动添加到 View 树中,这与 Native 的 ScrollView 表现一致。
其实,RN 中的 ScrollView 有一个 removeClippedSubviews 属性,表示如果子 View 超出可视区域,是否自动移除,虽然默认是 true。但是也需要子 View 的 overflow: ‘hidden‘属性配合。所以,给子组件的 style 添加如下属性即可。

  {"T" + i}
;

const styles = StyleSheet.create({
  child: {
    ...
    overflow: ‘hidden‘,
  },
});

得到的效果是,在使用上完全没有区别,而我们来看一下界面的 Tree View,如下图:
可见,屏幕外的子 View,就被自动从 View 树中移除了。
同时,我们来看一下 iOS 平台上的表现,与 Android 上类似:

这印证了我们前面的结论,RN 自动优化了 Native 平台 ScrollView,在这个层面,我们可以说 RN 比 Native 的性能还要高。

2 性能研究

通过上面的实例,我们可以看到,ScrollView 应该是非常高效的,它使用简单,并且还能按需构建 View 树,高效渲染,有点类似 Native 平台上的 ListView 了,是我心目完美 ScrollView 该有的样子。
但是,之前看到腾讯的 TAT.ronnie 一篇文章 探索 react native 首屏渲染最佳实践,文中提到的优化方法,主要就是针对 ScrollView 的。作者认为,在 ScrollView 中,即使不可见(例如,超出屏幕)的组件还是会绘制的。为了优化 ScrollView 的绘制性能,不可见的组件,应该在 JS 中避免添加到 ScrollView 中。
显然,这与我们前面观察到的结论是矛盾的。但是,作者的通过那样处理,确实优化了显示性能,这是怎么回事呢?为了验证,我们也和文中一样,使用 componentDidMount() 和 componentWillMount() 的时间差衡量显示速度。在 Android 上,测试 ScrollView 的子组件数量分别为 10,100,1000 的时候,显示的时间,以及 APP 所占用的内存:
子组件数量 加载时间(ms) 占用内存(MB) 绘制时间*(ms)
10 309 19.7 14.666
100 1170 21.9 15.016
1000 9461 26.5 15.025

* 注,这里的绘制时间,是在 Tree View 中获得的 Draw 时间。

从加载时间看,时间随着子组件的数量线性增加,占用内存也有类似趋势,说明 TAT.ronnie 的改进方法确实是有效的。另外我们也注意到,随着子组件的数量增加,Draw 的时间并没有明显的变化,其实 Measure 和 Layout 时间也没有明显的变化。
说明 ScrollView 虽然有 removeClippedSubviews 属性,也确实在 View Hierarchy 中去掉了不可见的 View。但是组件的加载时间消耗资源还是随着子组件的数量成正比。

3 原因分析

来看一下 RN 中 ScrollView 的相关的源码,主要分析 Android 平台的代码,iOS 类似,就不赘述了。
// ScrollView.js
var AndroidScrollView = requireNativeComponent(‘RCTScrollView‘, ScrollView, nativeOnlyProps);
var AndroidHorizontalScrollView = requireNativeComponent(
  ‘AndroidHorizontalScrollView‘,
  ScrollView,
  nativeOnlyProps
);

var ScrollView = React.createClass({
  render: function() {
    var contentContainer =

         {this.props.children}
       ;

     var ScrollViewClass;
     if (Platform.OS === ‘ios‘) {
       ...
     } else if (Platform.OS === ‘android‘) {
       if (this.props.horizontal) {
         ScrollViewClass = AndroidHorizontalScrollView;
       } else {
         ScrollViewClass = AndroidScrollView;
       }
     }

     // 为了简单,忽略有下拉刷新的情况
     return (

        {contentContainer}

    );
  }
});

JS 部分的代码逻辑很简单。首先把 ScrollView 所有子组件包装在一个 View contentContainer 中,并继承设置了 removeClippedSubviews 属性。根据 ScrollView 是否是水平方向,决定是用 RCTScrollView 或者 AndroidHorizontalScrollView Native 组件来包含 contentContainer。
所以,我们先来看 RCTScrollView 本地组件对应的代码(AndroidHorizontalScrollView 原理也类似)。JS 中的 RCTScrollView 组件由 com.facebook.react.views.scroll.ReactScrollViewManager 提供,具体的 View 的实现是 com.facebook.react.views.scroll.ReactScrollView。
其中 ReactScrollViewManager 是最基础的 ViewManager 的实现,导出了一些属性和事件。ReactScrollView 则继承于 android.widget.ScrollView,并实现了 ReactClippingViewGroup 接口。关于 Scroll 事件相关的代码我们先忽略,我主要关心 View 绘制相关的代码。主要在下面这段代码:
@Override
public void updateClippingRect() {
  if (!mRemoveClippedSubviews) {
    return;
  }
  ...
  View contentView = getChildAt(0);
  if (contentView instanceof ReactClippingViewGroup) {
    ((ReactClippingViewGroup) contentView).updateClippingRect();
  }
}

可见,如果不开启 mRemoveClippedSubviews,它就和普通的 ScrollView 一样,否者,它就会调用了它的第一个(也是唯一的一个)子 View 的 updateClippingRect() 方法。从上面的 JS 中我们可以看到,它的第一个子元素应该就是一个 View 组件,对应的 Native 的控件就是 ReactViewGroup。 ReactViewGroup 是 RN for Android 中最基础的控件,它直接继承于 android.view.ViewGroup
public class ReactViewGroup extends ViewGroup implements
    ReactInterceptingViewGroup, ReactClippingViewGroup, ReactPointerEventsView, ReactHitSlopView {
  private boolean mRemoveClippedSubviews = false;
  // 用来保存所有子 View 的数组,包括可见和不可见的
  private @Nullable View[] mAllChildren = null;
  private int mAllChildrenCount;
  // 当前 ReactViewGroup 于父 View 相交矩阵,
  // 也就是它自己在父 View 中可见区域
  private @Nullable Rect mClippingRect;
  ...
}

在 ReactViewGroup 中实现 removeClippedSubviews 的功能也非常直接,需要更新界面 Layout 的时候,遍历所有的子 View,看子 View 是否在 mClippingRect 区域内,如果在,就通过 addViewInLayout() 方法添加此 View,否者就通过 removeViewsInLayout() 方法移除它。
到这了,我们就可以解释前面的矛盾了。虽然在 ScrollView 的 View Hierarchy 中,会自动移除不显示的 View,但是实际上还是创建了所有的子 View,所以所占内存和加载时间会线性增加。
关于创建所有子 View,我这里可以多分析一下。我们知道在 Android 中,创建 View 的代价是很大的。特别是在 ScrollView 中,所有的子 View 都是同时创建的。如果 ScrollView 中子 View 的数量很多,这样的代价累加起来,对 APP 造成的延迟和卡顿是相当可观的。例如前面的测试中有 1000 个子组件,加载时间竟然长达 9.5 秒。我们用Method Tracing 看一下创建一个子 View 所花的时间,如下图:
这里只是简单的创建一个 TextView 就消耗了大约 25ms 的时间。当然 Tracing 过程本身会拖慢 APP 运行,但是不影响我们的结论。所以 Android 中列表类的控件,都内部支持对 View 的复用,尽量避免创建 View。
通过前面的分析,我们可以得到的结论是:RN 中的 ScrollView 并不像我们想象的那样高性能。

4 ListView

在这里提到 ListView,是因为 RN 中的 ListView 就是基于 ScrollView 的,但是有一些优化。这里简要介绍一些 ListView 的原理。
ListView 其实是对 ScrollView 的一个封装,对应到 Native 平台,和 ScrollView 的表现一模一样。但是 ListView 在显示列表内容的时候,会根据滑动距离,逐步向 ScrollView 中添加子组件(通过调用 renderRow() 方法)。注意到 ListView 有 initialListSize 属性,表示第一次加载的时候添加多少个子项,默认是 10,还有 pageSize 属性,表示每次需要添加的时候,增加多少个子项,默认是 1。
通过上面的分析我们可以看到,ListView 在第一次加载的时候,不论你的列表有多大,默认最多加载 initialListSize 个子项,所以能保证启动速度,如果还没有充满,或者在向下滑动过程中,再组件添加子项。这样的操作似乎比较合理,但是注意到,整个操作中,会逐渐向 ListView 中添加子项,新出现的子项,都是通过创建新的 View,而完全没有复用的过程。所以,如果在应用中,ListView 中的子项数量特别多,ListView 往下滑动的过程中,内存会逐渐上涨的。
值得一提的是,ListView 提供了 renderScrollComponent,可以使用其他 Scroll 组件来替换 ScrollView,并且 RecyclerViewBackedScrollView 组件来作为备选。看到这个名字我很欣喜,说明它支持子项的回收复用(Recycler)。首先,看到 iOS 的实现 RecyclerViewBackedScrollView.ios.js,其实它就是 ScrollView,并没有实现所谓的复用,失望了一半。继续看 Android 的实现,它实际上是对应 Native 的 com.facebook.react.views.recyclerview.AndroidRecyclerViewBackedScrollView,它继承与 Android 的 RecyclerView。看到这里,如果使用这种方法,我直观感觉 RN 的 ListView 性能在 Android 上表现应该会比 iOS 好。
我们继续来看它是怎么实现回收复用的,AndroidRecyclerViewBackedScrollView 内部实现了一个 RecyclerView.Adapter,如下:
static class ReactListAdapter extends Adapter {

  private final List mViews = new ArrayList<>();

  public void addView(View child, int index) {
    mViews.add(index, child);
    ...
  }

  public void removeViewAt(int index) {
    View child = mViews.get(index);
    if (child != null) {
      mViews.remove(index);
      ...
    }
  }

  @Override
  public ConcreteViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
    return new ConcreteViewHolder(new RecyclableWrapperViewGroup(parent.getContext()));
  }

  @Override
  public void onBindViewHolder(ConcreteViewHolder holder, int position) {
    RecyclableWrapperViewGroup vg = (RecyclableWrapperViewGroup) holder.itemView;
    View row = mViews.get(position);
    if (row.getParent() != vg) {
      vg.addView(row, 0);
    }
  }

  @Override
  public void onViewRecycled(ConcreteViewHolder holder) {
    super.onViewRecycled(holder);
    ((RecyclableWrapperViewGroup) holder.itemView).removeAllViews();
  }
}

注意到这里有一个 mViews,用来保存所有的子 View,绑定 View 的时候只是简单用一个空的 View(RecyclableWrapperViewGroup)包了一下。这样一来,RecyclerView 完全没有什么起到复用的作用呀!测试一下,确实也是这样,性能问题还是很严重。
这里我们也可以得到一个结论:RN 中的 ListView 也不是我们想象的 ListView 该有的性能。

5 改进方案

通过前面的分析,我们已经知道了 RN 中的 ScrollView 或者 ListView 的性能瓶颈了,同时也有了改进的思路。下面针对各种情况分析:
  1. 如果要优化首次加载速度,也就是启动速度:可以参考 TAT.ronnie 的文章中的方法,根据实际情况,最小化 ScrollView 或者 ListView 初始子项数量;
  2. 优化内存:因为 ScrollView/ListView 会保存所有子 View 在内存中,因为我们没法删掉子项,但是我们可以尽量减少每个子项所占的内存。例如这个项目 react-native-sglistview,它在子项不可见的时候,就把它退化成一个最基本的
    View;
  3. 终极解决方案:要真正达到高性能,就需要尽量少的创建 View,要想办法真正重复利用已经创建的子项。目前只有一些想法,待我实现了,再来更新。
时间: 2024-10-12 22:40:08

React Native 中 ScrollView 性能探究的相关文章

React Native 中 component 生命周期

React Native 中 component 生命周期 转自 csdn 子墨博客  http://blog.csdn.net/ElinaVampire/article/details/51813677 (非原创) React Native中的component跟Android中的activity,fragment等一样,存在生命周期,下面先给出component的生命周期图 getDefaultProps object getDefaultProps() 执行过一次后,被创建的类会有缓存,映

Facebook React Native 中文教程一:介绍

React Native 中文版 Facebook 在 [React.js Conf 2015](http://conf.reactjs.com/) 大会上推出了基于 JavaScript 的开源框架 [React Native](http://facebook.github.io/react-native/),本中文教程翻译自 [React Native 官方文档](http://facebook.github.io/react-native/docs/getting-started.html

React Native中的网络请求

React Native中的网络请求fetch使用方法最为简单,但却可以实现大多数的网络请求,需要了解更多的可以访问: https://segmentfault.com/a/1190000003810652 /** * Sample React Native App * https://github.com/facebook/react-native * 周少停 2016-09-28 * fetch请求数据 header 参数 response转json 请求方式 */ import React

react native中如何往服务器上传网络图片

1 let common_url = 'http://192.168.1.1:8080/'; //服务器地址 2 let token = ''; //用户登陆后返回的token 3 /** 4 * 使用fetch实现图片上传 5 * @param {string} url 接口地址 6 * @param {JSON} params body的请求参数 7 * @return 返回Promise 8 */ 9 function uploadImage(url,params){ 10 return

react native中Unable to load script from assets &#39;index.android.bundle&#39;解决方案

刚刚朋友问我,说是创建好一个项目,运行后报错:Unable to load script from assets 'index.android.bundle',以前好好的没出现这种现象,于是我找到一个解决方案,分享一下. 解决这个问题的方案是: 进入你该项目的根目录下的 android目录下的app目录下的src文件下的mian文件,(可能说的有点绕),在main件夹下,创建一个assets文件,这个文件是rn的资源文件夹! 之后用dos进入你的项目根目录,执行一下命令: react-nativ

[转] 在React Native中使用ART

http://bbs.reactnative.cn/topic/306/%E5%9C%A8react-native%E4%B8%AD%E4%BD%BF%E7%94%A8art 前半个月捣腾了一下React Native ART 现在手上闲下来了和大家分享一下React Native中的ART使用心得 React Native ART 究竟是什么? 所谓ART,是一个在React中绘制矢量图形的JS类库.这个类库抽象了一系统的通用接口,统一了SVG,canvas,VML这类矢量图形在 React中

在 React Native 中使用 Redux 架构

前言 Redux 架构是 Flux 架构的一个变形,相对于 Flux,Redux 的复杂性相对较低,而且最为巧妙的是 React 应用可以看成由一个根组件连接着许多大大小小的组件的应用,Redux 也只有一个 Store,而且只用一个 state 树来管理组件的状态.随着应用逐渐变得复杂,React 将组件看成状态机的优势仿佛变成了自身的绊脚石.因为要管理的状态将会越来越多,直到你搞不清楚某个状态在不知道什么时候,由于什么原因,发生了什么变化.Redux 试图让状态的变化变得可预测.Redux

react native中一次错误排查 Error:Error: Duplicate resources

最近一直在使用react native中,遇到了很多的坑,同时也学习到了一些移动端的开发经验. 今天在做一个打包的测试时,遇到了一个问题,打包过程中报错“Error:Error: Duplicate resources”,什么意思呢,就是打包资源有重复,后来查看了一下,发现打包到android/app/src目录下的静态文件重名了. 重现步骤: 1:通过vscode打开项目,运行打包命令 react-native ram-bundle --entry-file index.js --platfo

在 React Native 中使用 moment.js 無法載入語系檔案

moment.js 是很常見的日期時間 library,友善的 API 與極佳的執行效率是它的兩大賣點.例如 (new Date()).getFullYear(),如果使用 moment.js 我可以只寫 moment().get('year'),可讀性增強許多. 問題 React Native 0.29.x 預設使用 ES6,並支援 import 語法.問題出在如果遵照官方網站的說明去載入語系檔,會發生找不到模組 (cannot find module) 的錯誤.推測可能是 moment.js