前言
在【58部落】的业务场景下,存在较多的列表页面。整个产品的“门面”——入口页面,常驻在58APP下方的“发现”tab,所以要求有较高的用户体验。作为一个初中期的社区产品,很多功能还不够完善和稳定,因此要求能较快的功能迭代。兼具体验和快速迭代的要求,在58APP中,我们的选择是以 React Native 来进行页面的开发。
图1 - 门面页面(我们称为部落一级页,此处为广告 :-) )
该页面是由多个Tab组成,每个tab基本上都是无限下拉的列表。在 React Native 中,可以用作列表的组件,常见的有:
ListView
SectionList
当然还有官方支持的高性能的简单列表组件:
FlatList
但即使是 React Native 官方支持的性能最好FlatList
组件,在Android的一些机型上的表现也差强人意,特别是使用超过两年的Android手机,基本上就是到非常卡的状态了。
所以,今天介绍下在Android上表现更好的、性能更优的 React Native 列表组件:
-
RecyclerListView
。
RecyclerListView 是什么
RecyclerListView 是一个高性能的列表(listview)组件,同时支持 React Native 和 Web ,并且可用于复杂的列表。RecyclerListView 组件的实现灵感,来自于 Android RecyclerView
原生组件及iOS UICollectionView
原生组件。
为什么需要RecyclerListView
我们知道,React Native 的其他列表组件如ListView
,会一次性创建所有的列表单元格——cell
。如果列表数据比较多,则会创建很多的视图对象,而视图对象是非常消耗内存的。所以,ListView
组件,对于我们业务中的这种无限列表,基本上是不可以用的。
对于React Native 官方提供的高性能的列表组件FlatList
, 前文提到,在Android设备上的表现,并不是十分友好。它的实现原理,是将列表中不在可视区域内的视图,进行回收,然后根据页面的滚动,不断的渲染出现在可视区域内的视图。这里需要注意的是,FlatList
是将不可见的视图回收,从内存中清除了,下次需要的时候,再重新创建。这就要求设备在滚动的时候,能快速的创建出需要的视图,才能让列表流畅的展现在用户面前。而问题也就出现在这里,Android设备因为老化等原因,计算力等跟不上,加之React Native 本身 JS 层与 Native 层之间交互的一些问题(这里不做深入追究),导致创建视图的速度达不到使列表流畅滚动的要求。
那怎样来解决这样的问题呢?
RecyclerListView 受到 Android RecyclerView
和 iOS UICollectionView
的启发,进行两方面的优化:
- 仅创建可见区域的视图,这步与
FlatList
是一致的。 -
cell recycling
,重用单元格,这个做法是FlatList
缺乏的。
对于程序来说,视图对象的创建是非常昂贵的,并且伴随着内存的消耗。意味着如果不断的创建视图,在列表滚动的过程中,内存占用量会不断增加。FlatList
中将不可见的视图从内存中移除,这是一个比较好的优化手段,但同时也会导致大量的视图重新创建以及垃圾回收。
RecyclerListView 通过对不可见视图对象进行缓存及重复利用,一方面不会创建大量的视图对象,另一方面也不需要频繁的创建视图对象和垃圾回收。
基于这样的理论,所以RecyclerListView的性能是会优于FlatList的,实际结果会从下面的实践中得知。
RecyclerListView怎么使用
RecyclerListView 的使用比较简单,相对于 FlatList 通过getItemLayout
来优化布局需要提供offset
——相对于FlatList组件对顶部的一个偏移值来说,RecyclerListView 只需要知道对应cell
的高度值即可。对于复杂列表来说,RecyclerListView 的这种方式,大大优于FlatList使用方式。
一个 RecyclerListView 组件必要的 props 有 :
- dataProvider
- layoutProvider
- rowRenderer
一个最简单的示例
/***
Use this component inside your React Native Application.
A scrollable list with different item type
*/
import React, { Component } from "react";
import { View, Text, Dimensions } from "react-native";
import { RecyclerListView, DataProvider, LayoutProvider } from "recyclerlistview";
const ViewTypes = {
FULL: 0,
HALF_LEFT: 1,
HALF_RIGHT: 2
};
let containerCount = 0;
class CellContainer extends React.Component {
constructor(args) {
super(args);
this._containerId = containerCount++;
}
render() {
return (
<View {...this.props}>
{this.props.children}
<Text>Cell Id: {this._containerId}</Text>
</View>
);
}
}
/***
* To test out just copy this component and render in you root component
*/
export default class RecycleTestComponent extends React.Component {
constructor(args) {
super(args);
let { width } = Dimensions.get("window");
//Create the data provider and provide method which takes in two rows of data and return if those two are different or not.
//THIS IS VERY IMPORTANT, FORGET PERFORMANCE IF THIS IS MESSED UP
let dataProvider = new DataProvider((r1, r2) => {
return r1 !== r2;
});
//Create the layout provider
//First method: Given an index return the type of item e.g ListItemType1, ListItemType2 in case you have variety of items in your list/grid
//Second: Given a type and object set the exact height and width for that type on given object, if you're using non deterministic rendering provide close estimates
//If you need data based check you can access your data provider here
//You'll need data in most cases, we don't provide it by default to enable things like data virtualization in the future
//NOTE: For complex lists LayoutProvider will also be complex it would then make sense to move it to a different file
this._layoutProvider = new LayoutProvider(
index => {
if (index % 3 === 0) {
return ViewTypes.FULL;
} else if (index % 3 === 1) {
return ViewTypes.HALF_LEFT;
} else {
return ViewTypes.HALF_RIGHT;
}
},
(type, dim) => {
switch (type) {
case ViewTypes.HALF_LEFT:
dim.width = width / 2;
dim.height = 160;
break;
case ViewTypes.HALF_RIGHT:
dim.width = width / 2;
dim.height = 160;
break;
case ViewTypes.FULL:
dim.width = width;
dim.height = 140;
break;
default:
dim.width = 0;
dim.height = 0;
}
}
);
this._rowRenderer = this._rowRenderer.bind(this);
//Since component should always render once data has changed, make data provider part of the state
this.state = {
dataProvider: dataProvider.cloneWithRows(this._generateArray(300))
};
}
_generateArray(n) {
let arr = new Array(n);
for (let i = 0; i < n; i++) {
arr[i] = i;
}
return arr;
}
//Given type and data return the view component
_rowRenderer(type, data) {
//You can return any view here, CellContainer has no special significance
switch (type) {
case ViewTypes.HALF_LEFT:
return (
<CellContainer style={styles.containerGridLeft}>
<Text>Data: {data}</Text>
</CellContainer>
);
case ViewTypes.HALF_RIGHT:
return (
<CellContainer style={styles.containerGridRight}>
<Text>Data: {data}</Text>
</CellContainer>
);
case ViewTypes.FULL:
return (
<CellContainer style={styles.container}>
<Text>Data: {data}</Text>
</CellContainer>
);
default:
return null;
}
}
render() {
return (
<RecyclerListView
layoutProvider={this._layoutProvider}
dataProvider={this.state.dataProvider}
rowRenderer={this._rowRenderer}
/>
)
}
}
const styles = {
container: {
justifyContent: "space-around",
alignItems: "center",
flex: 1,
backgroundColor: "#00a1f1"
},
containerGridLeft: {
justifyContent: "space-around",
alignItems: "center",
flex: 1,
backgroundColor: "#ffbb00"
},
containerGridRight: {
justifyContent: "space-around",
alignItems: "center",
flex: 1,
backgroundColor: "#7cbb00"
}
};
为了进行cell-recycling
,RecyclerListView要求对每个cell
(通常也叫Item)定义一个type
,根据type
设置cell
的dim.width
和dim.height
:
this._layoutProvider = new LayoutProvider(
index => {
if (index % 3 === 0) {
return ViewTypes.FULL;
}
...
},
(type, dim) => {
switch (type) {
case ViewTypes.HALF_LEFT:
dim.width = width / 2;
dim.height = 160;
break;
...
}
}
);
rowRenderer
负责渲染一个cell
,同样是根据type
来进行渲染:
_rowRenderer(type, data) {
switch (type) {
case ViewTypes.HALF_LEFT:
return (
<CellContainer style={styles.containerGridLeft}>
<Text>Data: {data}</Text>
</CellContainer>
);
...
}
}
当然在我们的实际业务场景中不可能这么简单,页面滚动需要进行一些处理啊,滚动到最底部需要加载下一页等等都是最常见的业务场景,RecyclerListView这些也都支持得比较好,以下是一些常见的 props:
- onScroll: 列表滚动时触发;
- onEndReached: 列表触底时触发;
- onEndReachedThreshold: 列表距离底部多大距离时触发,这里是具体到底部的像素值,与FlatList几屏的数值是有区别的;
- onVisibleIndexesChanged: 可见元素,滚动时实时触发;
- renderFooter: 渲染列表footer。
实际业务怎么处理
在我们的业务场景中,在列表中包含5类cell
:
- 普通帖子
- 置顶banner
- 推荐部落
- 推荐话题
- 通知公告
后期应该还会增加其他的类型。后4类基本从dim.height
上来讲,是不会根据内容变化的,所以还比较简单,定义固定的type
即可。
对于“普通帖子”这个类型来讲,就相对来说比较复杂了,示例其中一种情况如下图:
图2 - 普通帖子的常见样式
其中有两部分是固定有的:
- header:发帖者信息等
- footer: 帖子回复,点赞等数据
其他部分就是根据帖子内容,有,无或者几种形态变化了,如帖子内容可展示为一行或者两行,帖子中的图片分为一图、二图、三图模式等等。
所以这里就出现了一个上述demo中没法解决的问题,“普通帖子”这种类型,我们单单定义一个type
,不进行其他处理,会存在一些问题。解决这个问题,在我们的业务中,测试了两种方式:
-
1.仅定义为一个
type
,记为RecyclerListView#1
。
通过其内容,计算出每个cell
的高度,并存储到原始数据中,在layoutProvider
中获取。this._layoutProvider = new LayoutProvider( index => { ... }, (type, dim, index) => { // 注意这里的第三个参数 // 比如原始数据存在 this.data 中 if(type==='card'){ dim.height = this.data[index].height ; } ... })
-
2.将“普通帖子”,拆分成多个组成部分,记为
RecyclerListView#2
。// 如一条帖子的数据是这样的 const data = { title:'标题', context:'内容', pics:['https://pic1.58cdn.com.cn/1.png'] ,// 图片 user:{} ,// 用户信息 replynum:300 // 回复信息 hotAnswers:[] ... }
根据展示规则,把用户信息等拆成一条,作为
header
这种type
,把title
拆成一条,作为title
这种type
,一个图片拆成一种type
,两个图片的又拆成另一种type
......,这样,每个type
就基本上比较单纯,type
的高度值也基本能固定了。
从理论上来讲,第二种方式心梗应该是会优于第一种方式(具体回顾RecyclerListView的实现方式及原理)。
性能对比
以下是用OPPO R9测试的帧率结果:
图3 - FlatList 滚动帧率
图4 - RecyclerListView#1 滚动帧率
图5 - RecyclerListView#2 滚动帧率
图6 - 帧率对比
通过帧率对比可以看出,RecyclerListView的滚动帧率是远大于FlatList的。FlatList在滚动时帧率波动比较严重,上手体验会发现比较卡顿且较多白屏现象。相对来说,RecyclerListView 的帧率变化相对稳定,基本都能维持到 35fps 以上,平均值在46fps 左右。
RecyclerListView#1 和 RecyclerListView#2, 整体帧率差距不是很明显,在该机型上得不出很正确的结论,就目前的情况来看,这种结果倒是我们作为开发者希望看到的结果。因为相对应的,对数据进行拆分不仅为增加数据量,并且从开发体验上来说,也会增加较大成本,开发体验并不好。
RecyclerListView#1 和 RecyclerListView#2 的比对,还需要更多的设备去验证。
5. 开发建议和场景限制
- 列表能简单,尽量简单
- 数据项能不拆,尽量不拆;拆是个大坑
- 因为
cell recycling
, 所以cell
内部不能保留状态,如果需要数据变化,一定要在外部进行存储,如用redux等 - 列表项(
cell
)删除会存在一定问题,特别是对于数据需要进行拆分的列表
其他开发建议参见 RecyclerListView Performance: https://github.com/Flipkart/r...
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。