2

首发于《58技术》公众号

背景

对于分类信息流形态的产品,用户通过左右滑动切换分类,通过不断上滑来浏览更多的信息。

用标签页(Tabs)实现切换分类,用无限列表(List)实现上滑浏览
手势上滑,页面向上滚动,展示更多列表项(List Item)

手势左滑,页面向左滚动,展示右边的列表(蓝色)

因为 React Native(RN) 可以用较低的成本,同时满足用户体验、快速迭代,和跨App 开发上线的要求。所以,对于分类信息流形态的产品技术选型使用的是 RN。在使用 RN 开发首页的过程中,我们填过很多坑,希望这些填坑经验,对读者有借鉴意义。

第一,RN 官方提供的无限列表(ListView/FlatList)性能太差,一直被业内吐槽。通过实践对比,我们选择了内存管理效率更优的第三方组件——RecyclerListView。

第二,RecyclerListView 需要知道每个列表项的高度,才能正确渲染。如果,列表项高度不确定,怎么处理?

第三,标签页和无限列表组合使用时,会遇到一些问题。首先,标签页中有多个无限列表,怎样有效管理内存?其次,标签页可以左右滑动,无限列表中也有左右滚动的内容组件,二者手势区域重叠时,如何指定组件优先处理?

列表的技术选型

1ListView

在实践开发分类信息流形态的产品过程中,我们开始尝试过使用 RN,版本是 0.28。当时,无限列表用的是官方提供的 ListView 。ListView 的列表项始终不会被销毁,这会导致内存不断增加,导致卡顿。前 100 条信息滚动非常流畅,200 条时就开始卡顿,到 1000 条时就基本就滑不动了。当时,也没有特别好的解决方案,只能在产品上进行妥协,将无限列表降级为有限列表。

2FlatList

FlatList 是在 RN 0.43 版本新增的,拥有内存回收的功能,可以用来实现无限列表。我们第一时间就跟进了,把 RN 版本进行升级。虽然, FlatList 可以实现无限列表,但体验上总归还是有所欠缺的。FlatList 在 iOS 表现很流畅,但在 Android 某些机型上会有略有卡顿。

3RecyclerListView

在实践开发中,我们技术选型还尝试采用了 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 组件呢?

1Native 异步获取高度

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 获取列表项真正的高度,再通过动画将视图位移到正确的位置。

位置修正

该方案,在估计高度偏差小的场景下很适用,但在估算偏差大的场景下,会明观察到明显的重叠和位移的现象。那么,有没有一种估算偏差小,耗时又短的方法呢?

3JS 估算高度

大部分情况下,列表项高度不确定都是由文本长度的不确定导致的。因此,只要能大致估算文本的高度就行。

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% 左右,估算耗时为毫秒级别,能够很好的满足我们需求。

4JS 估算高度 + 位置修正

因此,我们的最终方案为,通过 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 开发分类信息流形态的产品的无限列表中,遇到的一些常见问题,以及如何进行技术考量、优化和选择的。希望能对大家有借鉴意义。


fitfish
1.6k 声望950 粉丝

前端第七年,写一个 RN 专栏。