首发于《58技术》公众号
背景
对于分类信息流形态的产品,用户通过左右滑动切换分类,通过不断上滑来浏览更多的信息。
用标签页(Tabs)实现切换分类,用无限列表(List)实现上滑浏览
手势上滑,页面向上滚动,展示更多列表项(List Item)
手势左滑,页面向左滚动,展示右边的列表(蓝色)
因为 React Native(RN) 可以用较低的成本,同时满足用户体验、快速迭代,和跨App 开发上线的要求。所以,对于分类信息流形态的产品技术选型使用的是 RN。在使用 RN 开发首页的过程中,我们填过很多坑,希望这些填坑经验,对读者有借鉴意义。
第一,RN 官方提供的无限列表(ListView/FlatList)性能太差,一直被业内吐槽。通过实践对比,我们选择了内存管理效率更优的第三方组件——RecyclerListView。
第二,RecyclerListView 需要知道每个列表项的高度,才能正确渲染。如果,列表项高度不确定,怎么处理?
第三,标签页和无限列表组合使用时,会遇到一些问题。首先,标签页中有多个无限列表,怎样有效管理内存?其次,标签页可以左右滑动,无限列表中也有左右滚动的内容组件,二者手势区域重叠时,如何指定组件优先处理?
列表的技术选型
1、ListView
在实践开发分类信息流形态的产品过程中,我们开始尝试过使用 RN,版本是 0.28。当时,无限列表用的是官方提供的 ListView 。ListView 的列表项始终不会被销毁,这会导致内存不断增加,导致卡顿。前 100 条信息滚动非常流畅,200 条时就开始卡顿,到 1000 条时就基本就滑不动了。当时,也没有特别好的解决方案,只能在产品上进行妥协,将无限列表降级为有限列表。
2、FlatList
FlatList 是在 RN 0.43 版本新增的,拥有内存回收的功能,可以用来实现无限列表。我们第一时间就跟进了,把 RN 版本进行升级。虽然, FlatList 可以实现无限列表,但体验上总归还是有所欠缺的。FlatList 在 iOS 表现很流畅,但在 Android 某些机型上会有略有卡顿。
3、RecyclerListView
在实践开发中,我们技术选型还尝试采用了 RecyclerListView。 RecyclerListView 实现了内存的复用,性能也是更好。无论是 iOS 还是 Android 都表现的很流畅。
流畅度对比
衡量流畅度的关键指标是帧率,帧率越高越流畅,越低越卡顿。我们用 RecyclerListView 和 FlatList 分别实现了相同功能的无限列表,在 Android 手机中进行了测试,滚动帧率如下。
滚动帧率对比(以 Android OPPO R9 为例)
Android
FlatList
RecyclerListView
< 20 帧占比
16%
3%
主观体验
一般卡
流畅
实现原理对比
ListView 、FlatList、RecyclerListView 都是 RN 的列表组件,为什么它们之间性能差距这么大? 我们对其实现原理进行了一些研究。
1. ListView 的实现思路比较简单,当用户上滑加载新的列表内容时,会不断地新增列表项。每次新增,都会导致内存增加,增加到一定程度后,可使用的内存空间不足,页面就会出现卡顿。
2. FlatList 取了个巧,既然用户只能看到手机屏幕里的内容,那么只用将用户看到的(可视区域)和即将看到的(靠近可视区域)部分渲染出来就行了。而用户看不到的地方(远离可视区域),就删掉,用空白元素占位就行。这样,空白区域的内存就得到了释放。
要实现无限加载,必须要考虑如何高效利用内存。FlatList “删除一个,新增一个” 是一个思路。 RecyclerListView “结构类似,改改再用” 是另一个思路。
3. RecyclerListView 假设列表项的种类可枚举的。所有列表项可以分为若干类,比如,一张图片的图文布局是一类,两张图片的图文布局是一类,只要布局相似就是同一类列表项。开发者,需要对类型进行事先的声明。
const types = {
ONE_IMAGE: 'ONE_IMAGE', // 一张图片的图文布局
TWO_IMAGE: 'TWO_IMAGE' // 两张图片的图文布局
}
如果,用户即将看见的列表项,和用户看不见的列表项,类型一样。就把用户看不见的列表,修改成用户即将看到的列表项。修改不涉及到组件的整体结构,只涉及组件的属性参数,通常包括,文本、图片地址,还有展示的位置。
{/* 把用户看不见的列表项 */}
<View style={{position: 'absolute', top: disappeared}}>
<Text>一行文本</Text>
<Image source={{uri: '1.png'}}/>
<View>
{/* 修改成用户即将看见的列表项 */}
<View style={{position: 'absolute', top: visible}}>
<Text>一行文本~~</Text>
<Image source={{uri: '2.png'}}/>
<View></View>
从三者原理上对比,我们可以发现,在内存使用效率方面,内存复用的 RecyclerListView 比内存回收的 FlatList 更好,FlatList 又比内存不回收的ListView 更好。
原理对比
手势上滑,页面向上滚动,加载更多列表项(深绿色)
RecyclerListView 的实践
RecyclerListView 复用列表项的位置是需要经常变化的,因此用的是绝对定位 position: absolute 布局,而不是从上往下的 flex 布局。使用了绝对定位,就需要知道列表项的位置(top)。为了使用者的方便,RecyclerListView 让开发者传入所有列表项的高度(height),内部自动推断出其位置(top)。
高度确定的列表项
在最简单例子中,所有列表项的高度都是已知的。只需将将高度、类型数据,和 Server 的数据进行合并,就可以得到 RecyclerListView 的状态(state)。
const types = {
ONE_IMAGE: 'ONE_IMAGE', // 一张图片的图文布局
TWO_IMAGE: 'TWO_IMAGE' // 两张图片的图文布局
}
// server data
const serverData = [
{ img: ['xx'], text: '' },
{ img: ['xx', 'xx'], text: '' },
{ img: ['xx', 'xx'], text: '' },
{ img: ['xx'], text: '' },
]
// RecyclerListView state
const list = serverData.map(item => {
switch (item.img.length) {
case 1:
// 高度确定,为 100px
return { ...item, height: 100, type: types.ONE_IMAGE, }
case 2:
return { ...item, height: 100, type: types.TWO_IMAGE, }
default:
return null
}
})
高度不确定的列表项
并不是所有列表项的高度,都是确定的。比如,上文下图的列表项,虽然图片高度是确定的,但是文本高度是由 Server 传过来的文本长度决定的。文字可能一行,可能两行,可能多行,文字有几行是不确定的,因此列表项的高度也不确定。那么,应该如何使用 RecyclerListView 组件呢?
1、Native 异步获取高度
Native 端,实际上有提前计算文本高度的 API —— fontMetrics。将 Native fontMetrics API 暴露给 JS,JS 不就具有了提前计算高度的能力了。此时,RecyclerListView 需要的 state 计算方法如下,其值是 promise 类型。
const list = serverData.map(async item => {
switch (item.img.length) {
case 1:
return { ...item, height: await fontMetrics(item.text), type: types.ONE_IMAGE, }
case 2:
return { ...item, height: await fontMetrics(item.text), type: types.TWO_IMAGE, }
default:
return null
}
})
每次调用 fontMetrics,都需要 oc/java 与 js 进行一次异步通讯。而异步通讯是非常耗时的,该方案会明显增加渲染耗时。此外,新增 fontMetrics 接口的方案,依赖 Native 发版,只能在新版本中使用,老版本用不了。因此,我们没有采用。
2、位置修正
开启 RecyclerListView 的forceNonDeterministicRendering=true 属性后,会自动进行布局位置纠正。其原理是,开发者事先估算出列表项的高度, RecyclerListView 先按估算高度把视图渲染出来。当视图渲染出来后,通过onLayout 获取列表项真正的高度,再通过动画将视图位移到正确的位置。
位置修正
该方案,在估计高度偏差小的场景下很适用,但在估算偏差大的场景下,会明观察到明显的重叠和位移的现象。那么,有没有一种估算偏差小,耗时又短的方法呢?
3、JS 估算高度
大部分情况下,列表项高度不确定都是由文本长度的不确定导致的。因此,只要能大致估算文本的高度就行。
1 个 17px 字号 20px 行高的汉字,渲染出来的宽度为 17px,高度为 20px。如果,容器宽度足够宽,文字不折行, 30 个的汉字,渲染出来的宽度为 30 * 17px = 510px,高度依旧为 20px。如果,容器宽度只有 414px,那么显然会折成 2 行,此时文字高度为 2 * 20px = 40px。其通用公式为:
行数 = Math.ceil( 文字不折行宽度 / 容器宽度 )
文字高度 = 行数 * 行高
实际上,字符类型不仅有汉字,还有小写字母、大写字母、数字、空格等,此外,渲染字号也各有不同。因此,最终的文本行数算法也更为复杂。我们通过多种真机测试,得出了 17px 下的各类字符类型的平局渲染宽度,比如大写字母 11px,小写字母 8.6px 等等,算法摘要如下:
/**
* @param str 字符串文本
* @param fontSize 字号
* @returns 不折行宽度
*/
function getStrWidth(str, fontSize) {
const scale = fontSize / 17;
const capitalWidth = 11 * scale; // 大写字母
const lowerWidth = 8.6 * scale;// 小写字母
const spaceWidth = 4 * scale; // 空格
const numberWidth = 9.9 * scale; // 数字
const chineseWidth = 17.3 * scale; // 中文和其他
const width = Array.from(str).reduce(
(sum, char) =>
sum +
getCharWidth(char, {
capitalWidth,
lowerWidth,
spaceWidth,
numberWidth,
chineseWidth,
}),
0,
);
return Math.floor(width / fontSize) * fontSize;
}
/**
* @param string 字符串文本
* @param fontSize 字体大小
* @param width 渲染容器宽度
* @returns 行数
*/
function getNumberOfLines(string, fontSize, width) {
return Math.ceil(getStrWidth(string, fontSize) / width);
}
上述纯 js 估算文字行数的算法,实测的准确率在 90% 左右,估算耗时为毫秒级别,能够很好的满足我们需求。
4、JS 估算高度 + 位置修正
因此,我们的最终方案为,通过 JS 估算出文本行数,并得出文本高度,再进一步地推断出列表项的布局高度。并开启 forceNonDeterministicRendering=true,在估算有偏差时,自动动画修正列表项的位置。
方法
优点
缺点
Native 异步获取高度
获取正确高度,视图位置无偏差
异步获取,耗时长,渲染慢
位置修正
视图位置有偏差时,动画位移进行修正
前偏差大时,用户体验差
JS 估算高度
估算高度准确在 90% 左右
有 10% 左右的视图,渲染不正确
JS 估算高度 +位置修正
估算高度准确在 90% 左右。有大约 10% 偏差可以进行自动修正。
增加了 js 估算高度的步骤
标签页中的无限列表
对于分类信息流形态的产品,有的会包含多样化标签,每个标签都有特定的内容,其中大部分标签页是无限列表。如果,所有标签页的内容都同时存在,内存得不释放,也会导致性能问题。
内存回收
沿用上面的处理列表内存的思路,我们可以选择内存回收,或内存复用思路。内存复用的前提是,复用内容的结构相同,只有数据有变化。实际业务中,产品已经将相似内容进行了分类,每个标签页各有各的特点,很难复用。因此,对于标签页而言,内存回收是更好的选择。
整体思路是,可视区域内的标签页肯定要显示出来。最近在可视区域的显示过的内容,根据情况进行保留。远离可视区的内容,需要销毁。
销毁远离可视区的标签页
手势重叠的处理
标签页 TabView 1.0 使用的是 RN 自带的手势系统,单独的左右滑动切换的标签页,自带的手势系统运行良好。如果可视区中,既有可以左右滑动切换的标签页,又有可以左右滚动的内容区域。用户向左滚动手势重叠区域时,是标签页响应滚动,还是内容区域响应,还是同时响应呢?
手势重叠区域,向左滚动,谁响应?
由于 RN 的手势识别,是同时在 oc/java 渲染主线程和 js 线程中同时进行的,这种奇怪的处理方式,使得手势很难得到精准的处理。这导致 TabView 1.0 不能很好的处理手势重叠的业务场景。
在 TabView 2.0 中,集成了新的手势系统 React Native Gesture Handler。新的手势系统,是声明式的,由纯 oc/java 渲染主线程处理的手势系统。我们可以在 JS 代码中,对手势的响应方式进行提前声明,让标签页等待(waitFor) 内容区域的手势响应。也就是说,重叠的区域手势,只作用于内容区域。
总结
本文介绍了,我们在使用 RN 开发分类信息流形态的产品的无限列表中,遇到的一些常见问题,以及如何进行技术考量、优化和选择的。希望能对大家有借鉴意义。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。