长列表或者无限下拉列表是最常见的应用场景之一。RN 提供的 ListView 组件,在长列表这种数据量大的场景下,性能堪忧。而在最新的 0.43 版本中,提供了 FlatList 组件,或许就是你需要的高性能长列表解决方案。它足以应对大多数的长列表场景。

测试数据

FlatList 到底行不行,光说不行,先动手测试一下吧。

性能瓶颈主要体现在 Android 这边,所以就用魅族 MX5 测试机,测试无限下拉列表,列表为常见的左文右图的形式。

测试数据如下:

1000条时内存350M180M
2000条时内存/230M
js-fps4~6 fps8~20 fps

js-pfs 类似于游戏的画面渲染的帧率,60 为最高。它用于判断 js 线程的繁忙程度,数值越大说明 js 线程运行状态越好,数值越小说明 js 线程运行状态越差。在快速滑动测试 ListView 的时候, js-pfs 的值一直在 4~6 范围波动,即使停止滑动,js-pfs 的值也不能很快恢复正常。而 FlatList 在快速滚动后停止,js-pfs 能够很快的恢复到正常。

内存方面,ListView 滑动到 1000 条时,已经涨到 350M。这时机器已经卡的不行了,所以没法滑到 2000 条并给出相关数据。而 FlatList 滑到 2000 条时的内存,也比 ListView 1000 条时的内存少不少。说明,FlatList 对内存的控制是很优秀的。

主观体验方面:FlatList 快速滑动至 2000 条的过程中全程体验流畅,没有出现卡顿或肉眼可见的掉帧现象。而ListView 滑动到 200 条开始卡顿,页面滑动变得不顺畅,到 500 条渲染极其缓慢,到 1000 条时已经滑不动了。

通过以上的简单的测试,可以看出,FlatList 已经能够应对简单的无限列表的场景。

使用方法

FlatList 有三个核心属性 data renderItem getItemLayout。它继承自 ScrollView 组件,所以拥有 ScrollView 的属性和方法。

data

和 ListView 不同,它没有特殊的 DataSource 数据类型作为传入参数。它接收的仅仅只是一个 Array<object> 作为参数。
参数数组中的每一项,需要包含 key 值作为唯一标示。数据结构如下:

[{title: 'Title Text', key: 'item1'}]

renderItem

和 ListView 的 renderRow 类似,它接收一个函数作为参数,该函数返回一个 ReactElement。函数的第一个参数的 itemdata属性中的每个列表的数据( Array<object> 中的 object) 。这样就将列表元素和数据结合在一起,生成了列表。

 _renderItem = ({item}) => (
   <TouchableOpacity onPress={() => this._onPress(item)}>
     <Text>{item.title}}</Text>
   <TouchableOpacity/>
 );
 ...
 <FlatList data={[{title: 'Title Text', key: 'item1'}]} renderItem={this._renderItem} />

getItemLayout

可选优化项。但是实际测试中,如果不做该项优化,性能会差很多。所以强烈建议做此项优化!
如果不做该项优化,每个列表都需要事先渲染一次,动态地取得其渲染尺寸,然后再真正地渲染到页面中。

如果预先知道列表中的每一项的高度(ITEM_HEIGHT)和其在父组件中的偏移量(offset)和位置(index),就能减少一次渲染。这是很关键的性能优化点。

 getItemLayout={(data, index) => (
   {length: ITEM_HEIGHT, offset: ITEM_HEIGHT * index, index}
 )}

完整代码如下:

// 这里使用 getData 获取假数据
// 数据结构类似于 [{title: 'Title Text', key: 'item1'}]
import getData from './getData';
import TopicRow from './TopicRow';
// 引入 FlatList
import FlatList from 'react-native/Libraries/CustomComponents/Lists/FlatList';

export default class Wuba extends Component {

  constructor(props) {
    super(props);
    this.state = {
      listData: getData(),
    };
  }

  renderItem({item,index}) {
    return <TopicRow  {...item} id={item.key} />;
  }

  render() {
    return (
      <FlatList
        data = {this.state.listData}
        renderItem={this.renderItem}
        onEndReached={()=>{
          // 到达底部,加载更多列表项
          this.setState({
            listData: this.state.listData.concat(getData())
          });
        }}
        getItemLayout={(data, index) => (
          // 120 是被渲染 item 的高度 ITEM_HEIGHT。
          {length: 120, offset: 120 * index, index}
        )}
      />
    )
  }
}

源码分析

FlatList 之所以节约内存、渲染快,是因为它只将用户看到的(和即将看到的)部分真正渲染出来了。而用户看不到的地方,渲染的只是空白元素。渲染空白元素相比渲染真正的列表元素需要内存和计算量会大大减少,这就是性能好的原因。

FlatList 将页面分为 4 部分。初始化部分/上方空白部分/展现部分/下方空白部分。初始化部分,在每次都会渲染;当用户滚动时,根据需求动态的调整(上下)空白部分的高度,并将视窗中的列表元素正确渲染出来。

_usedIndexForKey = false;
const lastInitialIndex = this.props.initialNumToRender - 1;
const {first, last} = this.state;
// 初始化时的 items (10个) ,被正确渲染出来
this._pushCells(cells, 0, lastInitialIndex);
//  first 就是 在视图中(包括要即将在视图)的第一个 item
if (!disableVirtualization && first > lastInitialIndex) {
  const initBlock = this._getFrameMetricsApprox(lastInitialIndex);
  const firstSpace = this._getFrameMetricsApprox(first).offset -
    (initBlock.offset + initBlock.length);
  // 从第 11 个 items (除去初始化的 10个 items) 到 first 渲染空白元素
  cells.push(
    <View key="$lead_spacer" style={{[!horizontal ? 'height' : 'width']: firstSpace}} />
  );
}
// last 是最后一个在视图(包括要即将在视图)中的元素。
// 从 first 到 last ,即用户看到的界面渲染真正的 item
this._pushCells(cells, Math.max(lastInitialIndex + 1, first), last);
if (!this._hasWarned.keys && _usedIndexForKey) {
  console.warn(
    'VirtualizedList: missing keys for items, make sure to specify a key property on each ' +
    'item or provide a custom keyExtractor.'
  );
  this._hasWarned.keys = true;
}
if (!disableVirtualization && last < itemCount - 1) {
  const lastFrame = this._getFrameMetricsApprox(last);
  const end = this.props.getItemLayout ?
    itemCount - 1 :
    Math.min(itemCount - 1, this._highestMeasuredFrameIndex);
  const endFrame = this._getFrameMetricsApprox(end);
  const tailSpacerLength =
    (endFrame.offset + endFrame.length) -
    (lastFrame.offset + lastFrame.length);
   // last 之后的元素,渲染空白
  cells.push(
    <View key="$tail_spacer" style={{[!horizontal ? 'height' : 'width']: tailSpacerLength}} />
  );
}

既然要使用空白元素去代替实际的列表元素,就需要预先知道实际展现元素的高度(或宽度)和相对位置。如果不知道,就需要先渲染出实际展现元素,在获取完展现元素的高度和相对位置后,再用相同(累计)高度空白元素去代替实际的列表元素。_onCellLayout 就是用于动态计算元素高度的方法,如果事先知道元素的高度和位置,就可以使用上面提到的 getItemLayout 方法,就能跳过 _onCellLayout 这一步,获得更好的性能。

return (
    // _onCellLayout 就是这里的 _onLayout
    // 先渲染一次展现元素,通过 onLayout 获取其尺寸等信息
  <View onLayout={this._onLayout}>
    {element}
  </View>
);
...
  _onCellLayout = (e, cellKey, index) => {
    // 展现元素尺寸等相关计算
    const layout = e.nativeEvent.layout;
    const next = {
      offset: this._selectOffset(layout),
      length: this._selectLength(layout),
      index,
      inLayout: true,
    };
    const curr = this._frames[cellKey];
    if (!curr ||
      next.offset !== curr.offset ||
      next.length !== curr.length ||
      index !== curr.index
    ) {
      this._totalCellLength += next.length - (curr ? curr.length : 0);
      this._totalCellsMeasured += (curr ? 0 : 1);
      this._averageCellLength = this._totalCellLength / this._totalCellsMeasured;
      this._frames[cellKey] = next;
      this._highestMeasuredFrameIndex = Math.max(this._highestMeasuredFrameIndex, index);
      // 重新渲染一次。最终会调用一次上面分析的源码
      this._updateCellsToRenderBatcher.schedule();
    }
  };

简单分析 FlatList 的源码后,后发现它并没有和 native 端复用逻辑。而且如果有些机器性能极差,渲染过慢,那些假的列表——空白元素就会被用户看到!

那么为什么要基于 RN 的 ScrollView 组件进行性能优化,而不直接使用 Android 或 iOS 提供的列表组件呢?

最简单回答就是:太难了!

由于本人对 RN 底层原理实现只有简单理解。只能引用 Facebook 大神的解释,起一个抛砖引玉的作用。

以 iOS 的 UITableView 为例,所有即将在视窗中呈现的元素都必须同步渲染。这意味着如果渲染过程超过 16ms,就会掉帧。

但是问题是,从 RN render 到真正调用 native 代码这个过程本身是异步的,过程中消耗的时间也并不能保证在 16ms 以内。

那么解决方案就是,在一些需要高性能的场景下,让 RN 能够同步的调用 native 代码。这个答案或许就是 ListView 性能问题的终极解决方案。

03-05 17:09