2

导语
本文介绍了在使用 React Native 开发过程中,如何对无限列表组件进行技术选型,如何使用RecyclerListView组件对无限列表进行性能优化,如何解决无限列表与标签页搭配使用时的内存优化与手势重叠的问题,希望对大家有所启发。

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

WeChata137821f0492646a08d5fd92e99479bf.png

用标签页(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 条时就基本就滑不动了。当时,也没有特别好的解决方案,只能在产品上进行妥协,将无限列表降级为有限列表。

  1. FlatList
    FlatList 是在 RN 0.43 版本新增的,拥有内存回收的功能,可以用来实现无限列表。我们第一时间就跟进了,把 RN 版本进行升级。虽然,FlatList 可以实现无限列表,但体验上总归还是有所欠缺的。FlatList 在 iOS 表现很流畅,但在 Android 某些机型上会有略有卡顿。
  2. RecyclerListView
    在实践开发中,我们技术选型还尝试采用了 RecyclerListView。RecyclerListView 实现了内存的复用,性能也是更好。无论是iOS 还是 Android 都表现的很流畅。

流畅度对比
衡量流畅度的关键指标是帧率,帧率越高越流畅,越低越卡顿。我们用 RecyclerListView 和 FlatList 分别实现了相同功能的无限列表,在Android 手机中进行了测试,滚动帧率如下。

WeChat647a25498ff0426b6f4a4229fad9b67b.png

滚动帧率对比(以Android OPPO R9 为例)
WeChat5e918078079759f4856cb3cca5d94973.png

实现原理对比
ListView 、FlatList、RecyclerListView 都是 RN 的列表组件,为什么它们之间性能差距这么大?我们对其实现原理进行了一些研究。

  1. ListView
    ListView的实现思路比较简单,当用户上滑加载新的列表内容时,会不断地新增列表项。每次新增,都会导致内存增加,增加到一定程度后,可使用的内存空间不足,页面就会出现卡顿。
  2. FlatList
    FlatList取了个巧,既然用户只能看到手机屏幕里的内容,那么只用将用户看到的(可视区域)和即将看到的(靠近可视区域)部分渲染出来就行了。而用户看不到的地方(远离可视区域),就删掉,用空白元素占位就行。这样,空白区域的内存就得到了释放。
    要实现无限加载,必须要考虑如何高效利用内存。FlatList “删除一个,新增一个” 是一个思路。RecyclerListView “结构类似,改改再用” 是另一个思路。
  3. RecyclerListView
    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)。

  1. 高度确定的列表项
    在最简单例子中,所有列表项的高度都是已知的。只需将将高度、类型数据,和 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
}

})

  1. 高度不确定的列表项
    并不是所有列表项的高度,都是确定的。比如,上文下图的列表项,虽然图片高度是确定的,但是文本高度是由 Server 传过来的文本长度决定的。文字可能一行,可能两行,可能多行,文字有几行是不确定的,因此列表项的高度也不确定。那么,应该如何使用RecyclerListView 组件呢?

WeChat046c9273fdb883dc918b2929dbd06fe0.png

2.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.2 位置修正
开启 RecyclerListView 的 forceNonDeterministicRendering=true 属性后,会自动进行布局位置纠正。其原理是,开发者事先估算出列表项的高度,RecyclerListView 先按估算高度把视图渲染出来。当视图渲染出来后,通过 onLayout 获取列表项真正的高度,再通过动画将视图位移到正确的位置。

WeChatc77458f6a6135fbe449d8f1d680f9d78.png

位置修正
该方案,在估计高度偏差小的场景下很适用,但在估算偏差大的场景下,会明观察到明显的重叠和位移的现象。那么,有没有一种估算偏差小,耗时又短的方法呢?
2.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% 左右,估算耗时为毫秒级别,能够很好的满足我们需求。
    2.4 JS 估算高度 + 位置修正
    因此,我们的最终方案为,通过 JS 估算出文本行数,并得出文本高度,再进一步地推断出列表项的布局高度。并开启forceNonDeterministicRendering=true,在估算有偏差时,自动动画修正列表项的位置。
    WeChat6e4003adc60f86aa2ca9ecc5bf076c0b.png
    标签页中的无限列表
    对于分类信息流形态的产品,有的会包含多样化标签,每个标签都有特定的内容,其中大部分标签页是无限列表。如果,所有标签页的内容都同时存在,内存得不释放,也会导致性能问题。
  1. 内存回收
    沿用上面的处理列表内存的思路,我们可以选择内存回收,或内存复用思路。内存复用的前提是,复用内容的结构相同,只有数据有变化。实际业务中,产品已经将相似内容进行了分类,每个标签页各有各的特点,很难复用。因此,对于标签页而言,内存回收是更好的选择。
    整体思路是,可视区域内的标签页肯定要显示出来。最近在可视区域的显示过的内容,根据情况进行保留。远离可视区的内容,需要销毁。

WeChate45fe1445de2151ad2b376d8568dfeac.png

销毁远离可视区的标签页

  1. 手势重叠的处理
    标签页 TabView 1.0 使用的是 RN 自带的手势系统,单独的左右滑动切换的标签页,自带的手势系统运行良好。如果可视区中,既有可以左右滑动切换的标签页,又有可以左右滚动的内容区域。用户向左滚动手势重叠区域时,是标签页响应滚动,还是内容区域响应,还是同时响应呢?
    WeChatab2910f96f775ad392926a3410839f39.png

手势重叠区域,向左滚动,谁响应?
由于 RN 的手势识别,是同时在 oc/java 渲染主线程和 js 线程中同时进行的,这种奇怪的处理方式,使得手势很难得到精准的处理。这导致TabView 1.0 不能很好的处理手势重叠的业务场景。
在 TabView 2.0 中,集成了新的手势系统 React Native Gesture Handler。新的手势系统,是声明式的,由纯oc/java 渲染主线程处理的手势系统。我们可以在 JS 代码中,对手势的响应方式进行提前声明,让标签页等待(waitFor) 内容区域的手势响应。也就是说,重叠的区域手势,只作用于内容区域。

总结
本文介绍了我们在使用 RN 开发分类信息流形态的产品的无限列表中,遇到的一些常见问题,以及如何进行技术考量、优化和选择的。希望能对大家有借鉴意义。


fitfish
1.6k 声望950 粉丝

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