1

简介

本文针对应用开发中相当常见的瀑布流页面场景,基于按需渲染、组件复用等技术,使用RN框架实现了高性能瀑布流页面。本文提供详细的开发步骤,帮助开发人员高效实现高性能瀑布流页面。

基本概念

瀑布流:瀑布流布局在应用开发中非常常见。它利用容器的布局规则,将元素项目从上到下排列,形成多列参差不齐、不断加载的效果,使内容像瀑布般倾泻而下。由于其特点,瀑布流常用于展示图片资讯、购物商品和直播视频等多种数据形式。在上下滑动时,由于具备无限加载的特性,瀑布流可以展示大量内容,然而大小不一的子元素会增加测量和绘制的性能消耗。

RN:React Native(RN)是一个跨平台移动应用的开源框架,具有高性能和丰富组件库。许多主流应用使用RN开发瀑布流页面,如携程、华为商城等,这些应用利用RN的灵活性和高效性能,实现了图片和视频等内容的不规则布局展示。

典型场景

相较于传统的格子式布局,瀑布流页面看起来更加紧凑和丰满。逐块填充的方式,让布局比较灵活、动态,因而能够提升信息的密集度和丰富度。通过内容的巧妙编排,瀑布流布局可以让视觉焦点自觉且持续下移,促使用户自然而然低滚动查看更多内容,这种布局形式现在广泛应用于社交媒体、电商、影音娱乐等APP。

开发瀑布流页面时,通常会面临数据量不固定、页面卡片高度不一、页面操作阻塞UI绘制线程、卡片布局复杂冗余、状态管理不合理等问题。

数据量不固定的问题通常采用批量请求数据的方式从服务器中获取数据的方式来解决。如果每批请求的数据量少了,则会增加服务器负担,如果每批请求的数据量多了,一次性加载渲染这些数据,应用侧又会花费大量的时间来处理这些数据,导致加载缓慢甚至加载不出来的情况。而按需渲染则不会出现这种问题,因为使用按需渲染技术时,应用加载渲染数据只会加载屏幕显示区域和缓冲区的组件数,这就大大减少了需要加载渲染的组件数量,提高应用性能,提升用户体验。

瀑布流页面上的卡片一般都是结构相同的几种卡片,如果能固定每种卡片的高度,则理论上卡片布局时就不需要额外的计算卡片在瀑布流上的位置,从而减少计算时间提升性能。另外如果每次卡片组件下树就完全销毁组件,上树时又重新创建则会浪费这些卡片相同结构的设计,而使用组件复用技术就能解决这个问题。使用组件复用时,组件下树时会保留组件的结构放到缓存池里,组件上树时从缓存池里拿到对应组件的结构,然后就只需要根据状态渲染就行了,从而减少了创建组件结构的时间。

卡片布局复杂冗余需要开发者仔细检查代码中卡片的布局情况,按下述文档调整,一般而言布局的最大嵌套层级控制在5-8层左右即可。

关键技术

为了实现一个高性能的瀑布流页面,尤其是在承载大量数据和复杂布局的场景下,需要采用一些关键技术来优化性能。以下是一些优化瀑布流布局的关键技术。

技术名称适用场景技术要点
按需渲染数据量大、页面需要流畅滚动的场景仅渲染可视区域内的项目,使用虚拟化技术减少内存占用,提升性能。
组件复用应用中存在大量结构相同的组件频繁创建与销毁的场景而造成性能瓶颈问题的场景。为不同类型的组件创建所对应的组件复用池。
布局优化错误的布局方式可能会导致组件树和嵌套层数过多,在创建和布局绘制阶段产生较大的性能开销,所以可以通过布局优化提升性能。1. 移除冗余的节点。2. 使用扁平化布局减少节点数。3. 利用布局边界减少布局计算。
固定宽高比适用于所有瀑布流页面开发,因瀑布流中每一个列表项的高度未知,瀑布流组件在布局阶段会参与整体的测算,会带来一定的性能消耗。根据已知的宽度计算出列表项的高度,通过固定宽高比,可以在 UI 绘制时直接指定组件的宽高属性。
使用FastImage包含大量图片需要优化加载的场景使用 FastImage 替代 Image 组件,提升图片加载和显示性能,支持缓存和并行加载。

按需渲染

问题场景

普通滚动容器ScrollView会从列表数据源一次性加载并渲染全量数据,当数据量较大时首次渲染时间长,并且会占用大量内存,成为性能瓶颈。

image.png

优化手段

按需渲染的技术解决了一次性加载并渲染全量数据的问题,在首次渲染时,只会渲染首屏内容和用户即将看到的内容,减少了页面首次启动时一次性加载数据的时间消耗,减少了内存峰值,所以它的首次渲染速度很快。在滚动渲染时,只会按需渲染屏幕内的和屏幕附近像素的内容,在保证性能的同时,又有一部分数据作为缓冲,不至于一滑动就看到白块。

image.png

性能分析

本地模拟了10、500、1000条数据,分别使用ScrollView(全量加载)、VirtualizedList(按需加载),来测试关闭和开启按需加载情况下的完全显示所用时间、独占内存。最终使用DevEco Studio的Profiler工具和React的Profiler组件检测下述指标,得到的数据如下所示:

ScrollView对比指标10条数据500条数据1000条数据
完全显示所用时间/ms6513512754
独占内存(滑动完成后)/MB168.0338.7510.4
VirtualizedList对比指标10条数据500条数据1000条数据
完全显示所用时间/ms117120122
独占内存(滑动完成后)/MB184.2234.0236.4

组件复用

问题场景

在列表等场景下,每次滚动或渲染列表时,组件实例都会被创建或销毁,可能会消耗大量的内存和处理能力。特别是在列表项内容复杂的情况下,这种开销会更明显,导致应用的响应变慢,甚至出现卡顿现象。

优化手段

列表同一类型的子组件具有相同的组件布局结构,列表更新时仅有状态变量等数据差异,通过组件复用可以提高列表页面的加载速度和响应速度。可复用组件从组件树上移除时,会进入到一个组件复用池,后续创建新组件节点时,会复用组件复用池的节点,节约组件重新创建以及销毁的时间。

image.png

布局优化

问题场景

瀑布流布局与其他布局形式不同,因为它包含了大量的列表项,如果布局方式不当,可能会导致组件树过于复杂和嵌套层级过多,从而在创建和绘制阶段带来显著的性能开销,导致界面出现卡顿。

优化手段

对每一个列表项的布局优化至关重要,通过合理的布局设计和减少嵌套层数,可以有效提高布局的效率。一般而言布局的最大嵌套层级控制在5-8层左右即可,过度的优化布局会导致代码开发难度加大,代码不易于阅读理解,增加后续的维护成本,不利于多设备的适配,且也不会带来特别显著的性能提升。

固定宽高比

问题场景

瀑布流中每一个列表项的高度未知,瀑布流组件在布局阶段会参与整体的测算,会带来一定的性能消耗。

优化手段

在获取新数据后,服务器返回动态内容的宽高,这样可以根据已知的宽度计算出列表项的高度。通过固定宽高比,可以在 UI 绘制时直接指定组件的宽高属性。组件的宽高是固定的,那么在布局阶段,组件不需要再次调整列表项的位置,因为它的节点中已经保存了对应的大小、位置信息。当瀑布流布局中包含大量内容时,避免了瀑布流组件整体的测量过程,这将显著提升性能。

在获取新数据后,服务器返回动态内容的宽高,这样可以根据已知的宽度计算出列表项的高度。通过固定宽高比,可以在 UI 绘制时直接指定组件的宽高属性。组件的宽高是固定的,那么在布局阶段,组件不需要再次调整列表项的位置,因为它的节点中已经保存了对应的大小、位置信息。当瀑布流布局中包含大量内容时,避免了瀑布流组件整体的测量过程,这将显著提升性能。

使用FastImage

问题场景

RN框架的Image组件会像浏览器一样处理图片的缓存,缓存未命中、闪烁和加载缓存效率低时有发生,RN框架对图片默认缓存处理并不是最优的方案。

优化手段

针对图片的缓存处理,社区中提供了更优方案FastImage,在HarmonyOS侧FastImage基于C-API调用HarmonyOS的图片能力,在iOS基于SDWebImage,在Android基于Glide实现。

实践案例

案例简介

本案例针对瀑布流页面场景,基于@react-native-oh-tpl/flash-list、axios等框架,实现了一个高性能的瀑布流页面,该案例提供了关键的开发步骤,旨在帮助开发者高效开发出高性能的瀑布流页面。详情请查看@react-native-oh-tpl/react-native-fast-image

本案例版本约束如下:

名称版本号
react-native-harmony0.72.27
react-native-harmony-cli0.0.26
rnoh5.0.0.302及以上
sample_package5.0.0.302及以上

页面:
页面渲染最大树≤300
页面组件嵌套层数≤30

效果展示

RN效果.gif

性能测试数据

下图采用DevEco Testing场景化性能测试工具,测量连续滑动过程中帧率的变化情况。首先选择测试应用,然后在监控项配置帧率FPS,最后点击创建任务。

image.png

按照上图所示步骤创建测试任务,在手机端连续滑动多次后点击停止任务,生成如下测试报告,整体表现十分平稳,未见明显丢帧的情况。
image.png

以下表格为其它性能指标测试数据:

测试项抛滑(速度大于300mm/s )拖滑(速度小于100mm/s)
时延(时间起点:手指滑动;时间终点:界面发生变化)低于80ms低于60ms
滑动过程最大连续丢帧数滑动过程卡顿率
帧率(滑动速度:大于300ms/s)00

组件选型

在介绍各类瀑布流组件之前,首先让我们来看一下 React Native 中的原生组件 FlatList。FlatList 是 RN官方提供的列表组件,适用于展示大量数据。然而,如果我们希望使用 FlatList 来实现瀑布流布局,就需要进行额外的适配。即便如此,经过适配后的 FlatList 在性能上依然可能无法达到最佳效果。要理解这一点,我们需要先了解 FlatList 的工作原理。

FlatList 基于 VirtualizedList 构建,并通过三个关键步骤实现按需渲染:

第一步,确定按需渲染区域。 每次滚动页面时,都会触发 ScrollView 组件的 onScroll 事件。在该事件中,可以获取当前的滚动偏移量(offset)。FlatList 会基于这个偏移量向上和向下各扩展 10 个屏幕的高度,总计 21 个屏幕的内容被定义为按需渲染区域,而其他区域则无需立即渲染。

第二步,计算按需渲染的列表项索引。 一旦确定了按需渲染区域,就需要计算该区域内的列表项索引。FlatList 通过 setState 改变按需渲染区域内第一个和最后一个列表项的索引,从而触发渲染。如果列表项的高度是确定的,开发者可以通过 getItemLayout 属性将高度预先告知 FlatList,从而快速准确地计算出按需渲染的索引。

然而,若列表项的高度不确定,则需要动态测量。FlatList 会使用 onLayout 回调来测量每个列表项的高度,并根据这些数据计算索引。不过,在实际开发中,若已知列表项高度,建议总是使用 getItemLayout 来优化性能,否则 onLayout 的频繁计算可能导致滑动卡顿。

第三步,渲染按需列表项。 一旦计算出索引,FlatList 便会开始渲染这些列表项。假设一个屏幕的内容包含 10 个列表项,首次渲染时,索引范围为 0 到 109,FlatList 会渲染 11 个屏幕高度的内容。当用户滑动到第 11 屏时,索引范围扩大到 0 到 209。随后以这个大小确定按需渲染的区域,并将按需渲染区域外的列表项使用空白视图代替。

通过这种按需渲染机制,FlatList 能够在展示大量数据时保持较高的性能,若此时仍然存在性能问题,开发者需要关注项目中使用的rnoh版本是否较新,或者通过Profiler工具排查业务侧代码问题,但在实现复杂的瀑布流布局时,由于没有组件复用的机制,仍然可能不足以达到最佳效果。在第三方库中,已经有现成的瀑布流组件,其中不少在性能上超越了 FlatList。

以下是RN中瀑布流组件的各项对比:

三方库react-native-masonry-list(2.16.1)react-native-waterfall-layout-list(1.0.1)react-native-waterfall-flow(1.1.5)@react-native-oh-tpl/flash-list(1.7.0)
动态加载
懒加载
自适应列宽
动态项目高度
平滑滚动
性能优化中等中等
简洁API
高扩展性中等中等
多种数据源支持
增量加载
点击和长按事件
调试工具
使用复杂度中等
文档和社区支持中等
依赖原生能力

综合性能、使用复杂度等因素考虑,我们最终选用@react-native-oh-tpl/flash-list 。

@react-native-oh-tpl/flash-list底层使用了recyclerlistview的按需加载、组件复用等优秀机制,并且提供了瀑布流组件MasonryFlashList,我们无需再重复造轮子,就能实现流畅的应用瀑布流首页。

实现说明

布局说明

页面整体由三部分组成,SearchBar+MasonryFlashList+Tab.Navigator。功能区Swiper由MasonryFlashList的ListHeaderComponent承载,瀑布流内容由图文卡片、视频卡片、直播卡片构成,每个列表项中标题文本和用户信息结构是相同的,相同UI结构可以复用,避免无用的层级嵌套。

image.png

瀑布流是本案例重点,下面我们将介绍使用@react-native-oh-tpl/flash-list的MasonryFlashList组件实现瀑布流的关键步骤及其细节。

使用@react-native-oh-tpl/flash-list实现瀑布流

安装&导入

安装参考@react-native-oh-tpl/flash-list

import { MasonryFlashList } from '@shopify/flash-list';

使用MasonryFlashList实现瀑布流

<MasonryFlashList
  ref={(ref) => (this.listRef = ref)}   // 获取组件引用,实现类似一键回到顶部的功能
  estimatedItemSize={200}               // 估算列表项的高度,按需加载相关
  disableAutoLayout={false}       
  ListHeaderComponent={
    data.length === 0 ? <BlankView /> : <SwiperView />
  }  // 功能区
  onEndReachedThreshold={0.5}
  ListFooterComponent={this.ListFooterComponent}   // 列表footer,上拉加载相关
  data={data}   // 数据源
  numColumns={deviceRange === 'SM' ? 2 : 3}   // 列数
  refreshControl={<RefreshControl refreshing={refreshing} onRefresh={this.onRefresh} />}   // 下拉刷新
  onEndReached={this.onLoadMore}   // 触底回调
  optimizeItemArrangement={true}   // 通过修改列表项顺序减少列高差异,需要实现overrideItemLayout
  overrideItemLayout={this.overrideItemLayout}   // 通过列表项精确大小或列宽度
  getItemType={(item: CardData) => {
    return item ? item.type : 'image';    // 组件复用类型
  }}
  renderItem={({ item, index, columnIndex }) => {
    return (
      <Card item={item} columnIndex={columnIndex} />   // 渲染列表项
    );
  }}
  onViewableItemsChanged={this.onViewableItemsChanged}    // 列表项可见性变化回调
  viewabilityConfig={{
    minimumViewTime: 800,
    itemVisiblePercentThreshold: 100,
  }}
  showsVerticalScrollIndicator={false}
/>

以上便是MasonryFlashList的简单使用,接下来我们将介绍MasonryFlashList如何实现按需加载、组件复用等关键技术。

MasonryFlashList性能优化方式

正确估算列表项的高度(按需加载)

<MasonryFlashList
  // 估算列表项高度
  estimatedItemSize={250}
  ...
/>

MasonryFlashList根据estimatedItemSize指定列表项在渲染之前的大小,然后,MasonryFlashList可以使用此信息来决定在初始加载之前和滚动时需要在屏幕上绘制多少个项目。如果每个列表项高度相同,estimatedItemSize填入列表项高度即可,如果每个列表项高度不同(瀑布流页面场景),estimatedItemSize使用列表项的平均高度或者中值。参考此处如何正确计算estimatedItemSize及其正确估算estimatedItemSize的重要性。

组件复用

<MasonryFlashList
  // 根据不同的列表项类型创建组价复用池
  getItemType={(item: CardData) => {
    return item.type;
  }}
  ...
/>

interface CardData {
  thumbnails: string;
  source: string;
  type: string;     // 列表项类型
  inited: string;
  title: string;
  title_en: string;
  nick_name: string;
  collections_count: number;
  index: number;
  height: number;
}

当前瀑布流内容分为3种列表项:video、image、living,由服务器返回CardData数组。由于不同的类型的列表项组成差异较大,需要创建不同的组件复用池。只需实现MasonryFlashList的getItemType方法,根据item返回所对应的type,即可为video、image、living创建不同的组件复用池。从而加快重新渲染的速度。

固定宽高比

MasonryFlashList可以通过overrideItemLayout提前指定列表项的大小,当MasonryFlashList没有实现overrideItemLayout时,会使用estimatedItemSize的值作为列表项的高,而瀑布流的列表项的高度是不固定的,当列表项越来越多的时候,就会出现列表项布局在同一列的情况,破坏了瀑布流的结果,详情请查看issue。当我们能够精确计算列表项的高度时,实现overrideItemLayout后MasonryFlashList会优先使用我们提供的大小或者列跨度,提供精确的overrideItemLayout不仅能解决以上issue,还能使得scrollToIndex、initialScrollIndex更加精准。

overrideItemLayout = (layout: { span?: number; size?: number }, item: CardData,) => {
  if (!item) {
    return;
  }
  let itemWidth = (Dimensions.get('window').width - (this.state.deviceRange === 'SM' ? 40 : 48)) / (this.state.deviceRange === 'SM' ? 2 : 3);
  layout.size = itemWidth * item.height / item.width + getNumberOfLines(item.title, 14, itemWidth) > 1 ? 2 : 1;
}

下面我们将介绍精确计算列表项高度的细节。

JS计算Text行数

1、列表项高度不确定

在实际需求中,并不是所有列表项的高度都是确定的。如下图的列表项,虽然组件间的间距和图片的高度(通过服务器返回)是确定的,但是文本的高度是由服务器传过来的文本长度确定的。文字可能有一行,可能有两行,可能有多行,文字行数不确定,列表项的高度也不能够确定。

image.png

2、计算文字行数

方案一:Native 端,实际上有提前计算文本高度的 API —— fontMetrics。将 Native fontMetrics API 暴露给JS,JS 就具有了提前计算高度的能力。但是每次调用 fontMetrics,都需要Native 与 js 进行一次异步通讯。而异步通讯是非常耗时的,该方案会明显增加渲染耗时,因此我们没有采用。

方案二:JS估算高度。1 个 17px 字号 20px 行高的汉字,渲染出来的宽度为 17px,高度为 20px。如果,容器宽度足够宽,文字不折行, 30 个的汉字,渲染出来的宽度为30 * 17px = 510px,高度依旧为 20px。如果,容器宽度只有 414px,那么显然会折成 2 行,此时文字高度为 2 * 20px =40px。其通用公式为:

行数 = Math.ceil( 文字不折行宽度 / 容器宽度 )
文字高度 = 行数 * 行高

实际上,字符类型不仅有汉字,还有小写字母、大写字母、数字、空格等,此外,渲染字号也各有不同。因此,最终的文本行数算法也更为复杂。我们通过多种真机测试,得出了17px 下的各类字符类型的平局渲染宽度,比如大写字母 11px,小写字母 8.6px 等等,代码实现如下:

/*
 * RN 中并没有一个 FontMetrics 之类的方法,可以提前获取文字的高度。
 * 通过该 getNumberOfLines 可以简单的计算出行数,但并不完全精确(目测 100 条 < 5 条 算错)。
 * 输入: str 要展示的字符串
 * 输入: fontSize 字体大小
 * 输入: width 展示容器的的宽度
 * 输出: NumberOfLines 字符串自然排列的行数
 * */
export default function getNumberOfLines(
  str: string,
  fontSize: number,
  width: number,
) {
  if (!str) {
    return 0;
  }
  return Math.ceil(getStrWidth(str, fontSize) / width);
}
function getStrWidth(str: string, fontSize: number) {
  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,
  );
  // 0.1 个字算 1 个字
  return Math.ceil(width / fontSize) * fontSize;
}
interface ChartWidthType {
  capitalWidth: number;
  lowerWidth: number;
  spaceWidth: number;
  numberWidth: number;
  chineseWidth: number;
}
export function getCharWidth(
  char: string,
  {
    capitalWidth,
    lowerWidth,
    spaceWidth,
    numberWidth,
    chineseWidth,
  }: ChartWidthType,
) {
  let width;
  // 大写字母
  if (/[A-Z]/.test(char)) {
    width = capitalWidth;
    // 小写字母 和部分常见符号
  } else if (/([a-z]|[\u0021-\u002F])/.test(char)) {
    width = lowerWidth;
    // 数字
  } else if (/\d/.test(char)) {
    width = numberWidth;
    // 空格
  } else if (/\s/.test(char)) {
    width = spaceWidth;
    // 汉字或其他
  } else {
    width = chineseWidth;
  }
  return width;
}

使用axios网络框架处理请求

安装&导入

// 安装
npm install axios
// 导入
import axios from 'axios';

异步请求

axios使用 Promise 处理异步请求,这使得代码更简洁和易于阅读。
export class NetworkUtil {
    static async getCarData(pageNum: number): Promise<CardData[]> {
        const response = await axios.get(baseDisplayURL, {
            params: { pageNum, PAGE_SIZE },
        });
        // 自动转换 JSON
        const newData: CardData[] = response.data.data;
        return newData;
    }

    static async getFunctionData(): Promise<Item[]> {
        const response = await axios.get(functionURL);
        // 自动转换 JSON
        const newData: Item[] = response.data.data;
        return newData;
    }
}

常见问题

组件复用失效

确保列表项及其子组件中没有key,否则@react-native-oh-tpl/flash-list将无法正确进行回收复用,详情请查看Writing performant components:remove-key-prop

嵌套List

本文中的sample不涉及List的嵌套,功能区封装在MasonryFlashList的ListHeaderComponent中,针对ScrollView 嵌套 MasonryFlashList滚动,当MasonryFlashList滚动时,ScrollView禁止滚动,详情开发者可以参考此处

this.state = {
  enableScrollViewScroll: true,
  ...
}
onEnableScroll = value => {
    this.setState({
      enableScrollViewScroll: value,
    });
};

render() {
  return(
     <ScrollView 
       scrollEnabled={this.state.enableScrollViewScroll}
      >
        <MasonryFlashList
            onTouchStart={() => {this.onEnableScroll(false);}}
            onMomentumScrollEnd={() => {this.onEnableScroll(true);}}
            ...
        />
        ...  
     </ScrollView>
  ) 
}

Sample代码

如果读者需要阅读完整代码,详情可参考 oh-scenario-based-solution/Home-Fluency

总结与回顾

本文深入探讨了实现高性能瀑布流页面的关键技术,先介绍了按需加载技术的原理,这种方法通过延迟加载未在可视区内的组件,有效地减少了初始渲染时间和内存占用。其次本文讨论了组件复用的策略,强调通过优化组件的创建和销毁,避免不必要的资源浪费。本文还分享了实现高性能瀑布流的最佳实践,包括如何组件的选型、网络处理等,确保应用能流畅运行。


HarmonyOS码上奇行
6.9k 声望2.8k 粉丝