☰
React Native FlatList 在 OpenHarmony 上的虚拟化性能优化实践
2026/9/26 23:38:00 网站建设 项目流程

1. 先聊聊这个优化任务的背景

React Native 在 OpenHarmony 上跑起来,第一个要面对的问题就是列表。我试过直接把一套电商首页的 FlatList 代码从安卓端搬过来,数据量大概几百条带图片的卡片,结果在 OpenHarmony 真机上滑动直接掉帧,快速滚到底部的时候,新行出现有明显的白块和跳动。不是网络问题,也不是图片加载问题,纯粹是列表渲染扛不住。

这个项目的核心就是解决这个场景:React Native 的 FlatList 在 OpenHarmony 上怎么把虚拟化真正落地。RN 的 FlatList 本身就是基于虚拟化思路设计的,但你在安卓上能流畅跑,不代表在 OpenHarmony 上也能流畅跑,因为底层宿主环境不一样,桥接机制、UI 刷新方式、内存管理都存在差异。如果你正准备把 RN 应用移植到 OpenHarmony,或者已经在做但列表性能不达标,这篇文章提到的思路和排查方向应该能帮你省很多弯路。

我会从两个层面来讲:一是 FlatList 虚拟化的底层逻辑,为什么它需要虚拟化、虚拟化到底优化了什么;二是这份优化实践里真正有参考价值的改动点,包括 render 函数治理、组件层级瘦身、图片加载时机、OpenHarmony 特有问题适配等。最后会附上我踩过的几个坑和排查思路。

2. 虚拟化列表的核心思路拆解

2.1 FlatList 的懒渲染机制到底做了什么

FlatList 的虚拟化不是"只渲染屏幕内的行"这么简单。它的核心逻辑是把一维数据列表映射成有限个行的渲染任务,屏幕可视区加上一定的缓冲区(默认是屏幕高度的若干倍)之外的行,直接不创建原生视图。

这里有个关键点:FlatList 复用的是 ScrollView 的滚动容器,但它通过 VirtualizedList 这个基础组件维护了一个状态机。当滚动发生时,它计算当前偏移量,结合 onLayout 反馈的容器尺寸,算出哪些行在可视范围内,然后调度渲染、卸载或者回收。这种机制保证的是:哪怕你有十万条数据,真正在原生层存在的视图节点可能只有三四十个。

不过在 OpenHarmony 上,这个"视图节点数量可控"的假设需要重新验证。我打点看过,同一个列表在安卓端原生视图数量稳定在 25 个左右,在 OpenHarmony 端有时候会冲到 60 个以上。原因后面会细说,但如果你只在 JS 层看逻辑,永远发现不了这个问题。

2.2 props 变化和渲染函数的稳定性

FlatList 优化时最容易忽略的,是 renderItem 函数本身的状态。我在项目里做了一次改动:把 renderItem 从内联箭头函数改成 useCallback 包裹的稳定函数,同时把列表项组件用 memo 缓存。

听起来是 React 优化的老套路,但在这个场景下效果非常明显。原因是 FlatList 在滚动过程中频繁调用 renderItem,如果这个函数每次都是新引用,列表项组件即使 props 没变化也会重新渲染。JS 层的重新渲染会通过 bridge 同步到原生层,在 OpenHarmony 上这一来一回的开销比安卓更大。

改完以后,我对比了 JS 线程的耗时:滚动过程中,renderItem 的调用频率没有变,但单个 item 的 render 耗时从平均 18ms 降到了 6ms 左右。这还是在没有做任何原生层改动的前提下,纯 JS 层面的优化就能把渲染耗时降到三分之一。

2.3 为什么虚拟化必须在"窗口"上做文章

FlatList 有个参数叫 windowSize,默认值是 21,单位是可视区的倍数。也就是说默认大约会保留可视区上方 10 个屏幕高度、下方 10 个屏幕高度的行。在快速滚动的时候,这个缓冲窗口是必要的,不然用户会看到白屏。

但窗口越大,同时存在的行越多,内存和渲染压力都更大。在 OpenHarmony 上,我把 windowSize 从 21 调到了 10 甚至 7,配合 initialNumToRender 的调整,发现滑动体验并没有明显变差,但内存占用下降了约 30%。

这里要强调:windowSize 不是越小越好。如果设置过小,快速滚动时行来不及渲染,会出现"滚动到目标位置但内容空白"的情况。我测试下来,7 到 10 是一个比较稳妥的范围,具体要看列表项的复杂度和图片数量。

3. OpenHarmony 上 FlatList 的适配难点

3.1 桥接层带来的额外开销

RN 在安卓上走的是 JSI 或者旧的 Bridge 机制,到了 OpenHarmony 上,RN 框架的适配层通常是借助 OpenHarmony 的 ArkUI 组件来承载。也就是说,RN 的视图最终要映射到 ArkUI 的组件树上,这个中间多了一层转换。

带来的直接问题就是:每一次视图的创建、更新、删除,都要经过 JavaScript 运行时到 ArkUI 的双向通信。列表滚动时,行视图的频繁挂载和卸载会让这层通信变得非常繁忙。我在日志里统计过,快速滑动一秒,视图操作的消息量能达到几百条,没做优化前,这些消息的处理时长占了滚动总时长的 40% 以上。

针对这个问题,可行的优化方向有两个:一是减少视图操作次数,通过复用和回收而不是销毁重建来降低消息量;二是合并消息,把多次连续的视图操作批量提交。后者在 FlatList 的默认实现里没有直接暴露,但你可以通过控制窗口大小和预渲染策略来间接实现。

3.2 行组件层级对渲染性能的影响

列表项组件层级越深,在 ArkUI 上渲染的开销越大。这不是 OpenHarmony 独有的问题,但它的影响比安卓更明显。

我做了一次对比实验:一个列表项包含三层嵌套 View,另一个把嵌套压平为两层,同时去掉不必要的父容器。在同样的数据量和滚动速度下,压平后的列表滚动时的帧率从 38fps 提升到了 50fps 左右。这个提升非常可观。

所以优化 FlatList 时,不要去抠 item 内部某个样式属性的开销,先看看组件树能不能瘦身。一个常见的做法是:能用单一 View 配合 flex 布局解决的,就不要包三层。特别是那些只做"占位"的容器,删除掉对界面没有影响,但渲染开销实打实减少了。

3.3 图片加载对滚动的隐性影响

列表性能杀手排名第一的,永远不是文字,而是图片。FlatList 虚拟化只能控制视图数量,但控制不了图片解码和内存占用。在 OpenHarmony 上,图片加载的链路跟安卓不同,它最终走的是 ArkUI 的 Image 组件,而 RN 的 Image 在映射到 ArkUI 时,加载策略需要重新适配。

我遇到的问题是:列表快速滑动时,图片加载请求大量并发,导致磁盘 I/O 和内存峰值非常高,滚动出现明显卡顿。后来我做了三件事:第一,在列表滚动时暂停加载非可视区域图片;第二,给图片加缓存策略,重复出现的图直接命中缓存;第三,图片降采样,原本 2000px 宽的图,在列表场景只加载 400px 宽的版本。

这三步做完,滚动帧率稳定在了 55fps 以上,内存峰值下降了约 25%。如果你在 OpenHarmony 上做列表优化,图片这块一定要单独处理,而且越早越好。

4. 渲染管线层面的优化方向

4.1 从"同步渲染"到"分批渲染"的调整

FlatList 在快速滚动时,新进入可视区的行会立即触发渲染。在安卓上,这个渲染过程是异步的,UI 线程和 JS 线程并行,所以感知不明显。但在 OpenHarmony 的 RN 适配层,视图提交的过程更接近同步操作,JS 线程的一次 render 会直接阻塞后续的滚动事件处理。

我实测的现象是:快速滚动时,偶尔出现"卡住半秒然后跳一大截"的情况,就是 JS 线程被同步渲染卡住了。解决思路是让 FlatList 的更新不要全部挤在一帧内完成,而是分批处理。具体做法是给 renderItem 的调用加一个队列,每次处理固定数量的 item,剩下的放到下一帧。

这个改动在 JS 层就能做,不依赖原生代码。具体实现我放到了后面实操环节,这里先记住一个结论:滚动流畅度的瓶颈往往不在渲染本身,而在渲染是否阻塞了滚动的响应循环。

4.2 getItemLayout 带来的收益

FlatList 官方文档一直强调,如果你的列表项高度是固定的,一定要提供 getItemLayout。这个函数可以让滚动定位不做动态测量,直接通过索引计算偏移量,省掉大量的测量交互。

在 OpenHarmony 上,这个建议从"性能优化"变成了"必做项"。因为动态测量要等每个 item 渲染完成后才能拿到高度,这个过程在 ArkUI 上比较慢,而且频繁测量会带来额外的通信开销。

我的列表项高度是固定的,所以我写了 getItemLayout,一行代码的改动,滚动定位的耗时下降了 60% 以上。如果你用的是流式布局或者高度可变的卡片,可以考虑把 item 的高度固定下来,或者用最小高度约束来做近似测量,收益同样明显。

4.3 原生视图回收对内存稳定性的帮助

FlatList 默认的行为是:行离开可视窗口后,卸载对应的原生视图。这个"卸载"在安卓上意味着资源释放,但在 OpenHarmony 上,频繁的卸载和重建会让内存产生碎片,而且对象创建的开销不小。

我尝试了一种"回收复用"的思路:离开可视区的行不销毁,而是回收到一个对象池里,进入可视区时直接从池中取出复用。实现上,需要重写 FlatList 的 cellRenderer 相关逻辑,或者直接改造 VirtualizedList 的回收策略。

这个改动的收益比较明显:内存占用的波动从原来的不断攀升变成了一条平稳的曲线。GC 触发的频率也显著下降,滚动过程中几乎没有因为 GC 导致的卡顿。

5. 实操过程与关键配置

5.1 基础参数调优清单

如果你只想做最基础的优化,不要动代码,先从这几个参数开始调。这些参数都是 FlatList 直接暴露的 props,改起来零风险。

<FlatList data={data} renderItem={renderItem} keyExtractor={keyExtractor} initialNumToRender={10} maxToRenderPerBatch={8} updateCellsBatchingPeriod={50} windowSize={10} removeClippedSubviews={true} getItemLayout={(data, index) => ({ length: ITEM_HEIGHT, offset: ITEM_HEIGHT * index, index, })} onEndReachedThreshold={0.5} onEndReached={loadMore} />

这几个参数的含义我解释一下:

  • initialNumToRender:首屏渲染的 item 数量。设得太大会拉长首屏时间,太小会导致首屏下方空白。我一般取可视区能容纳的数量再加 2。
  • maxToRenderPerBatch:每批渲染的最大数量。这个值控制 JS 线程单帧的工作量,设小一点更流畅。
  • updateCellsBatchingPeriod:批处理间隔,单位是毫秒。间隔太短会让 JS 线程连续工作,间隔太长会让滚动看起来"跟手度"下降。
  • removeClippedSubviews:删除被裁剪的视图。在 OpenHarmony 上这个属性需要用真机验证,因为它依赖原生层正确报告裁剪区域。

5.2 renderItem 稳定化的标准写法

前面提到 renderItem 必须稳定,这里给出一个标准的写法模板。核心是用 useCallback 保证函数引用不变,再用 memo 包裹列表项组件。

const renderItem = useCallback(({ item, index }) => { return <ListItem item={item} index={index} />; }, []); const ListItem = memo(({ item, index }) => { // 你的 item 渲染逻辑 return ( <View style={styles.item}> <FastImage source={{ uri: item.thumbnail }} /> <Text>{item.title}</Text> <Text>{item.subtitle}</Text> </View> ); });

这里要特别提醒:ListItem 内部的子组件如果用了内联对象或者内联函数,memo 依然会失效。比如 style 如果是每次渲染新创建的对象,那 memo 比较时发现 props 变了,还是会重新渲染。解决方法是把静态样式提到组件外部,用 StyleSheet.create 创建,函数用 useCallback 包裹。

我踩过一次坑:ListItem 里有一个按钮,onPress 直接写成了内联箭头函数,memo 完全没用,每个 item 在滚动时都会重新渲染。改成 useCallback 之后,重新渲染的问题才解决。

5.3 滚动暂停图片加载的实现

图片加载对滚动性能的影响前面说过了,这里给出一段可以抄的代码。核心思路是:监听 FlatList 的 onScroll,滚动开始时暂停非可视区图片加载,滚动停止后再恢复。

const [isScrolling, setIsScrolling] = useState(false); const onScroll = useCallback((e) => { setIsScrolling(true); if (this.scrollTimer) clearTimeout(this.scrollTimer); this.scrollTimer = setTimeout(() => setIsScrolling(false), 100); }, []); <FlatList onScroll={onScroll} scrollEventThrottle={16} // ... />

配合上面的 isScrolling 状态,你可以把需要渲染的图片源打一个"延迟加载"标记。比如用 react-native-fast-image 的话,可以动态切换 source;如果用的是普通 Image 组件,可以先把 uri 设为空,等滚动停止再赋值,这样可以抑制滚动过程中的图片解码。

需要注意:这里的 isScrolling 状态变化会引起整个列表重新渲染,所以不要直接把它放在上下文里传递,而是用 ref 或者事件总线,避免产生额外的渲染开销。

5.4 分批渲染的具体写法

分批渲染的目标是避免一帧内处理过多任务。我封装了一个简单的批处理队列:

function useBatchedRender(items, batchSize = 8) { const [renderedCount, setRenderedCount] = useState(Math.min(batchSize, items.length)); useEffect(() => { let disposed = false; let timer; if (renderedCount < items.length) { timer = setTimeout(() => { if (!disposed) { setRenderedCount((prev) => Math.min(prev + batchSize, items.length)); } }, 50); } return () => { disposed = true; clearTimeout(timer); }; }, [renderedCount, items.length, batchSize]); return useMemo(() => items.slice(0, renderedCount), [items, renderedCount]); }

这个 hook 配合 FlatList 使用的方式是:先用 useBatchedRender 截取数据长度,再交给 FlatList 渲染。每次增加一批 item,给 JS 线程留出空闲时间处理滚动事件。我测试下来,这种"慢一点点填充"的方式反而比一次性渲染所有可见 item 更流畅,因为滚动的首帧响应没有被长任务阻塞。

5.5 如何验证优化效果

优化不能凭感觉,要打点验证。我用的工具组合是:chrome devtools 的 Performance 面板看 JS 线程;OpenHarmony 系统自带的进程内存统计看内存;react-native 的 perf 接口看帧率。

最有效的一个指标是 "time to interactive after scroll"。具体做法是:从列表顶快速滑到底部,记录最后一次滚动事件到画面完全稳定的时间。优化前这个时间基本在 1 秒以上,优化后能压到 300ms 左右。

如果测试时发现帧率还是不稳定,优先看列表项的组件树深度和图片数量。这两个因素在 OpenHarmony 上的影响权重远大于 JS 逻辑本身的耗时。

6. 常见问题与排查思路

6.1 首屏出现白屏,但数据已经返回

这种情况下,数据加载正常,但 FlatList 首屏是空的。常见原因是 initialNumToRender 设置过小,首屏可视区的行数超过了初始渲染数量,导致部分行没有渲染。

排查方法:先看渲染的行数是否等于 initialNumToRender,如果小于可视区行数,加大这个值。另外检查 FlatList 的父容器是否有固定高度。如果没有固定高度,FlatList 不知道自己的可视区域,虚拟化会失效,表现为所有行一次性渲染,或者首屏空白。

这个坑在 OpenHarmony 上更容易出现,因为 RN 接入到页面里时,外层有时会用 flex:1 撑开,但父组件的测量时机和安卓不一致,导致 FlatList 初始化时拿不到高度。

6.2 快速滚动到底部出现短暂空白

这个问题的根源是虚拟化窗口不够大。快速滚动时,手指已经滑到目标位置,但新行还在渲染队列里,没有及时跟上。

解决思路:适当调大 windowSize 或者 initialNumToRender,同时减少单个 item 的渲染耗时。如果你已经做了分批渲染,可以把批处理间隔从 50ms 降到 30ms,让新行的渲染更快。

有一种情况是 getItemLayout 写错了 offset 计算,导致滚动位置估算错误,FlatList 认为目标行不在可视区,也就不会调度渲染。检查方法是在不同位置暂停滚动,打印当前可视区的起始索引,对比实际界面内容,误差超过一个 item 就说明计算有误。

6.3 内存持续上涨,滑动时间越长越卡

这个问题通常不是 FlatList 本身导致的,而是列表项的图片或子组件没有被正确释放。在 OpenHarmony 上,RN 的 Native 组件销毁逻辑可能不会立即触发 ArkUI 的资源回收,需要手动处理。

我的做法是在 item 组件的 componentWillUnmount 里显式清空图片,释放事件监听器。另外,尽量不要在列表项里创建全局使用的动画或者定时器,这些对象如果不手动清理,会一直持有引用。

如果你用的是 removeClippedSubviews,要注意确认视图裁剪后的真实卸载情况。有时候行视图还在原生层保留着,只是不可见,这种情况下内存上涨是必然的,考虑配合对象池方案来解决。

6.4 某些 item 渲染时有闪帧或者跳动

闪帧和跳动的原因通常是行高度没有严格一致。即使你的业务数据高度相同,但如果 item 组件内部有图片加载导致的高度变化,FlatList 在计算偏移量时会出错。

解决方法:给列表项设置一个固定的高度,不要让内容撑开。比如图片加载前占位高度和加载后的高度保持一致。如果做不到严格固定,至少给每个 item 外层 View 设置一个 minHeight,避免加载过程出现布局抖动。

另一种情况是 useCallback 的依赖问题。如果 renderItem 的 useCallback 依赖了外部状态(比如一个计数器的值),那这个函数引用仍然会变化,导致所有 item 重新渲染。排查时把外部状态从组件中提出来,放到全局 store,或者用 ref 代替。

7. 一些额外的心得和实测数据

根据我个人经验,FlatList 优化在 OpenHarmony 上的收益,并不完全等同于在安卓上的收益。有些在安卓上可有可无的优化项,在 OpenHarmony 上是决定性的。比如 getItemLayout,在安卓上不做只是慢一点,但在 OpenHarmony 上不做可能会出现明显的白屏感。

实测下来,一组综合优化的数据供参考:优化前列表快速滚动帧率约 30fps,内存峰值约 480MB;优化后帧率提升到 56fps,内存峰值降到 340MB。首屏渲染时间也从 1.8 秒缩短到了 1.1 秒。注意这些数据是特定机型和特定数据量下的结果,不同场景会有差异,但优化的方向是明确的。

最后分享一个扩展思路:如果你的列表项内部还有嵌套的滚动容器或者复杂的交互,建议把这一类 item 从 FlatList 中拆出来,用原生组件或者独立的页面承载。FlatList 的虚拟化机制擅长处理大量"轻量且结构相似"的行,对于个别"重量级"的 item,让它们脱离虚拟化列表反而更稳定。这个取舍,在 OpenHarmony 上尤其值得考虑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询