ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

rn_for_openharmony列表性能优化实战:FlatList卡顿排查与调优

rn_for_openharmony列表性能优化实战:FlatList卡顿排查与调优 上个月我把一个购物类App的首页迁到rn_for_openharmony上跑第一个让我失眠的就是 List 列表。原生 React Native 上几十毫秒就能渲染完的 FlatList在 OpenHarmony 上直接给我表演白屏加掉帧1000 条数据滑起来像在拖动一张没压缩的图片。我一度怀疑是网络图片的问题把接口换成本地 mock 数据还是卡。最后静下心把包依赖、组件映射、渲染参数全翻了一遍才算弄明白问题出在哪。这篇博文就是把这段折腾的过程整理出来。围绕 rn_for_openharmony 里的 List 系列组件FlatList、SectionList、VirtualizedList重点讲清楚三件事它和原生 RN 的 FlatList 在底层上有哪些差异实际使用中哪些参数和姿势能真正提升性能以及我踩过的那些看着小、折腾一下午的交互坑。如果你正在做 RN 代码向 OpenHarmony 迁移或者刚开始接触 RNOH 开发这篇内容可以直接帮你绕开我走过的弯路。1. 先搞清楚 rn_for_openharmony 里 List 组件的真实处境1.1 为什么列表组件是最容易出问题的适配层移动应用里最不缺的就是列表。Feed 流、商品列表、订单记录、通讯录全是 List 的天下。React Native 生态里FlatList、SectionList 这些组件是整个 UI 体系的地基几乎每个正经 App 都离不开。所以做 OpenHarmony 适配的时候社区和厂商把大量精力花在列表组件上原因很简单列表的性能直接决定了 App 的使用体感。在 rn_for_openharmony后面统一叫 RNOH里RN 组件并不是凭空渲染的。JS 侧描述组件树跨过桥接层被翻译成 OpenHarmony 的 ArkUI 节点树再交给系统渲染。在这个链路里List 是性能敏感区因为列表天生要面对大量数据、高频滑动、动态追加任何一层偷懒都会在滑动的时候被成倍放大。我当时犯的一个错误是拿普通 RN 的思路直接套到 RNOH 上。普通 RN 里 FlatList 的性能问题多数在 JS 侧比如 renderItem 写了复杂逻辑、keyExtractor 不稳定。但在 RNOH 上问题往往会出现在 JS 线程和 ArkUI 渲染线程的配合上。这么说吧RN 在原生平台上是个翻译官到 OpenHarmony 这里变成了二传手JS 侧先算一遍再跨语言边界到 ArkUI 侧算一遍中间任何一次多余的测量和通信都会让流畅度打折扣。1.2 RN 的虚拟列表机制和 ArkUI 的 List 机制在桥接上发生了什么要理解 List 在 RNOH 上的表现得先知道两边各自的原生机制是什么样。React Native 的 FlatList 本质上是 VirtualizedList 的封装核心思路是不渲染全部数据只渲染可视区域附近的一小部分 item用一个滑动的窗口维护当前应该显示哪些节点。这个机制跑在 JS 线程上由 JS 计算窗口位置再通知原生层创建或销毁视图。这套思路在 Android 和 iOS 上已经打磨了很多年性能损耗相对可控。OpenHarmony 这边ArkUI 也提供了自己的列表方案List 容器配合 ListItem 子组件加上 LazyForEach 做懒加载。它同样只渲染可视区域附近的节点但整个调度逻辑跑在 ArkUI 的渲染引擎里不走 JS 桥。RNOH 的适配思路是JS 侧的 VirtualizedList 逻辑仍然保留组件树翻译到 UI 层时把 FlatList 映射到 ArkUI 的 Scroll 容器或 List 容器上item 由 RN 的 View 和 Text 组件映射成对应的 ArkUI 节点。问题就出在映射这两个字上。比如一个 item 的高度RN 侧一开始并不知道是多少得等 item 渲染出来之后通过 onLayout 回调拿到真实尺寸。在原生 RN 上这个过程是原生层内部的事开销不大在 RNOH 上这个测量结果要跨 JS 桥传回 JS 线程再去更新虚拟窗口的位置。如果列表很长、item 很多这种来回通信会频繁发生卡顿就是这么积累出来的。所以你会看到一个现象同样的代码、同样的数据量RNOH 上的 FlatList 总是比原生 RN 更容易掉帧。这不是 RNOH 水平不行而是桥接架构决定了它需要更多前提信息才能跑得流畅。后面第三节讲的 getItemLayout、windowSize 这些参数本质都是给这层桥接提前喂信息。1.3 开跑前先确认环境版本不匹配会让 List 行为变得不可预测说一个很多人忽略的问题RNOH 对 RN 版本的适配是有明确绑定的不是随便装个 react-native 包就能跑。我最初用了一个老项目里的 RN 0.71 代码直接往 RNOH 工程里搬结果 FlatList 的滚动事件死活不触发排查了半天才发现是核心包版本和 RNOH 的适配层不匹配。在开始写列表之前建议先确认这几项DevEco Studio 和 OpenHarmony SDK 的版本要与 RNOH 的发布说明对应起来。SDK 太新或者太旧都可能出现底层组件行为不一致。工程里同时存在 react-native 核心包和 RNOH 的适配包通常以react-native-oh-tpl/开头两者的版本必须配套。具体版本号要去 RNOH 的发布日志核对。ArkTS 的语法约束比 TS 更严格比如不能直接使用隐式 anyJSON.parse 的返回值不能当作类型确定的对象直接访问属性。列表项的数据结构如果是从接口动态返回的建议先做一次类型收窄否则编译期过不去。这些看起来和 List 没关系但环境不对时List 的各种诡异行为点击不灵敏、滚动位置错乱、item 不刷新都会冒出来而且很难定位。先确认版本再谈其他。2. FlatList 和 SectionList 的基本盘先把列表跑出真东西2.1 FlatList 最简可运行版本以及两个容易被忽略的细节不管你是刚接触 RN 还是老手到了 RNOH 上都建议先写一个最小的 FlatList 跑通整个链路确认当前环境没问题。我通常用 100 条本地数据做验证import React from react; import { FlatList, Text, View, StyleSheet } from react-native; const DATA Array.from({ length: 100 }, (_, i) ({ id: String(i), title: 第 ${i} 个列表项, })); function App() { const renderItem ({ item }) ( View style{styles.item} Text style{styles.title}{item.title}/Text /View ); return ( FlatList data{DATA} keyExtractor{(item) item.id} renderItem{renderItem} style{styles.list} / ); } const styles StyleSheet.create({ list: { flex: 1 }, item: { height: 60, justifyContent: center, paddingHorizontal: 16 }, title: { fontSize: 16, color: #1a1a1a }, });这段代码在 RNOH 上跑起来和普通 RN 的手感基本一致。但有两个细节我建议养成习惯。第一style上一定要给flex: 1或者明确的宽高。RNOH 上如果 FlatList 的父容器没有给高度列表可能直接坍缩成 0内容什么都不显示。这个问题在原生 RN 上也会出现但 RNOH 上更容易踩到因为 ArkUI 侧的布局约束和 Yoga 布局有一些差异父容器尺寸传递不及时的情况更多。第二keyExtractor必须存在且稳定。RNOH 的组件复用机制对 key 的依赖比原生 RN 更重如果 key 不稳定会导致 item 状态错乱典型表现是滚动后复选框选中的项乱跳、文本内容串位。最稳妥的做法是用数据里唯一的业务 ID不要用数组 index。2.2 SectionList 的分组列表和粘性头在 OH 上的表现带分组标题的列表也是高频场景比如订单按日期分组、通讯录按首字母分组。RN 里 SectionList 就是干这个的数据格式是一个 sections 数组每一项里有 data 和可选的 section 头配置const SECTIONS [ { title: 本周订单, data: [{ id: 1, name: 商品A }, { id: 2, name: 商品B }] }, { title: 上周订单, data: [{ id: 3, name: 商品C }, { id: 4, name: 商品D }] }, ]; function OrderList() { return ( SectionList sections{SECTIONS} keyExtractor{(item) item.id} renderItem{({ item }) Text style{styles.itemText}{item.name}/Text} renderSectionHeader{({ section }) ( Text style{styles.sectionHeader}{section.title}/Text )} stickySectionHeadersEnabled{true} / ); }在 RNOH 上跑这个大多数情况下没问题但stickySectionHeadersEnabled这个属性要特别留意。粘性头是分组列表里很常见的需求但在部分 RNOH 适配版本里开启之后滚动时会出现头部吸附位置不准、或者吸附过程中闪烁的问题。我的建议是先关掉粘性头跑一遍确认数据渲染正常再打开粘性头看效果。如果真遇上 bug直接改用 FlatList 手动计算分组头也是一个可行方案代价是多写一些分组逻辑但稳定性可控。2.3 ListEmptyComponent 和 ListFooterComponent 的细节列表的空态和加载更多通常用ListEmptyComponent和ListFooterComponent来实现。这两个属性看着简单在 RNOH 上有一个坑data 从有数据变成空数组时ListEmptyComponent在部分适配版本里不会及时刷新导致空态组件不显示或者显示的是旧数据。解决方式很简单给 FlatList 传一个extraData把可能变化的依赖值塞进去。比如FlatList data{listData} extraData{listData} ListEmptyComponent{EmptyView /} /原理是 VirtualizedList 会根据data和extraData的变化来决定是否重新渲染列表项和空态。RNOH 的桥接层对这类依赖变化但数据引用没变的场景识别得不够积极显式传extraData是最省事的规避手段。另外ListFooterComponent里不要做耗时操作比如同步拉取接口、执行复杂计算。它和普通 item 在渲染优先级上没有区别如果在 footer 里卡住了 JS 线程上拉加载更多的体验会非常糟糕。我之前试过在 footer 里直接调同步方法做数据拼接结果每次加载完成的瞬间页面都要抖一下后来改成在请求回调里先处理好数据再更新 state问题消失。3. 数据一大就卡List 性能问题定位与优化3.1 别急着加参数先确认卡顿是哪一层的责任列表卡顿很多人第一反应是加getItemLayout、调windowSize、上React.memo。但如果卡顿的根因不在那些地方加再多参数也是白费功夫。我现在的习惯是不管什么问题先在 DevEco Studio 里拉一次 Profiler看清楚 JS 线程和渲染线程的 CPU 占用情况。整体思路可以这样梳理现象可能的责任层优先排查方向滚动时 JS 线程 CPU 长期占满JS 侧 diff、renderItem 计算、状态更新频繁组件 memo、回调引用、数据量控制JS 线程不算忙但画面掉帧ArkUI 渲染层布局计算、图片解码item 高度固定、合理 windowSize、图片尺寸白块明显、滚动跳跃虚拟化窗口配置不合理getItemLayout、maxToRenderPerBatch、updateCellsBatchingPeriod具体排查过一次 1000 条商品数据的列表Profiler 显示 JS 线程占用只有 20%但帧率就是上不去。后来发现是每个 item 里的商品图片都是从原图 URL 加载的一张图好几 MBArkUI 侧在解码图片时把渲染线程拖垮了。把图片 URL 改成带尺寸参数的裁剪地址之后帧率立刻稳定。这个案例说明先定位再优化比盲调参数高效得多。3.2 getItemLayout 和 windowSize把虚拟列表的窗口打开到合适大小getItemLayout是我在 RNOH 上最推荐的性能优化手段没有之一。它的作用是告诉 FlatList每个 item 的高度是固定的以及第 n 个 item 距离顶部的偏移量是多少。有了这些信息虚拟列表就不需要等 item 渲染完成再去测量高度可以直接精准计算出当前窗口应该渲染哪些 item。在固定高度列表上例如每个 item 都是 120 高写法是const ITEM_HEIGHT 120; FlatList data{data} getItemLayout{(data, index) ({ length: ITEM_HEIGHT, offset: ITEM_HEIGHT * index, index, })} /为什么这个参数在 RNOH 上更重要因为前面说过RNOH 的尺寸测量要跨桥通信代价比原生 RN 高很多。有了getItemLayout滑动时 ArkUI 侧可以直接按偏移量渲染省掉的通信次数相当可观。需要注意getItemLayout只在每个 item 高度完全一致时才能用。如果列表项高度不固定或者有不同样式的 item 混排千万别硬套否则会出现滚动位置错乱、跳转后定位不准的严重问题。遇到高度不固定的场景我用的是另一个方案在onLayout里统一记录 item 高度并缓存但实现复杂度明显高所以设计稿阶段尽量让列表项高度统一。windowSize的作用是控制可视区域外预渲染的 item 数量。RNOH 上我建议把windowSize调得比原生 RN 默认值小一些比如 3 到 5。默认值偏大预渲染的 item 越多ArkUI 侧创建节点的压力越大。窗口调小之后滑动时需要新渲染 item 的概率略增但配合getItemLayout后整体体验反而更好。3.3 renderItem 的性能边界memo、引用稳定、图像处理getItemLayout解决的是列表框架的渲染效率但每个 item 本身如果写得很重性能照样上不去。我在 RNOH 上对 renderItem 有几条硬性要求第一item 组件一定要用React.memo包裹。FlatList 在滚动时会对 item 做引用比对如果每次 renderItem 返回的是新对象就会触发不必要的重新渲染。React.memo能保证在 props 不变的前提下跳过渲染。const ListItem React.memo(({ item, onPress }) ( TouchableOpacity onPress{onPress} style{styles.item} Image source{{ uri: item.imageUrl }} style{styles.image} / Text style{styles.title}{item.title}/Text /TouchableOpacity ));第二传给 item 的回调函数必须保持引用稳定。不能在渲染里直接写onPress{() handlePress(item.id)}这样每个 item 拿到的 onPress 都是新函数memo 直接失效。正确做法是用useCallback包一层通过参数把数据递进去const handlePress useCallback((id: string) { // 处理点击 }, []);第三不要在 renderItem 里做任何计算。比如不要根据 item 状态现场拼样式数组、不要做日期格式化、不要在渲染时处理图片颜色。这些计算放到数据进 state 之前完成或者放到 item 组件内部配合 memo 做局部缓存。图片是一个特别大的坑。RNOH 上加载大图导致的内存和帧率问题比原生 RN 明显得多。图片 URL 带上裁剪尺寸参数、避免加载几 MB 的原始大图是性价比最高的优化。还有一个建议不要在 item 里同时加载多张高清大图如果有轮播图需求优先考虑只加载当前展示的那一张。4. 点击、刷新、嵌套滚动交互层那些看着小却卡半天的问题4.1 列表项点击不生效从哪开始排查列表项点击事件失效是搞 RNOH 列表时高频遇到的现象。常见表现有两种整个 item 点击都没反应或者只有 item 里某个子区域能点其他区域点了没反馈。我的排查链路是这样的第一确认点击组件没有被遮挡。item 里如果有绝对定位的元素或者 zIndex 设置不对点击事件会被上层组件消费掉。这个在原生 RN 也有但 RNOH 上由于节点映射的层级问题更容易出现。第二确认子组件没有拦截事件。item 里如果嵌套了可滚动组件比如水平滚动的标签栏触摸方向的判定可能会抢走事件。RNOH 上这种情况建议给子滚动组件设置scrollEnabled{false}先确认主点击事件恢复再想怎么处理嵌套滚动。第三检查是不是 Touchable 组件的事件回调本身没触发。可以在onPressIn里打日志判断事件有没有进到组件内部。如果onPressIn有输出但onPress没有多半是触摸结束位置偏移超出了判定阈值把pressRetentionOffset调大一些可以缓解。还有一个 RNOH 上特有的问题当 item 内有 TextInput 或者 ScrollView 这类原生交互组件时ArkUI 侧的手势系统会和 RN 的触摸响应系统产生冲突表现为 Tap 手势偶尔丢失。遇到这种场景我建议先尽量简化 item 内部结构把输入框移出列表项改到单独的详情页去结构简单了问题自然消失。4.2 RefreshControl 和 onEndReached下拉刷新、上拉加载的常见坑下拉刷新在 RN 里通常用RefreshControlRNOH 上也支持。基本用法和原生 RN 一致FlatList data{data} renderItem{renderItem} refreshing{refreshing} onRefresh{handleRefresh} /这里的坑主要在状态管理refreshing必须严格绑定一个 state请求完成后再置回 false。如果请求失败或者中途退出页面容易陷入刷新状态一直转圈的情况。我习惯在handleRefresh里加一个防抖避免用户频繁触发重复请求。上拉加载更多用的是onEndReached这个在 RNOH 上有一个很典型的坑列表首屏还没看到底部onEndReached就触发了。原因是首屏渲染时内容高度测算不准系统误以为已经滚到底部。解决方式有几个配合着用onEndReachedThreshold设置一个合理值比如 0.2表示距底部还剩 20% 可视高度时触发。在onContentSizeChange回调里做一次判断只有 contentSize 大于可视区域高度时才允许触发加载。用hasMore状态做保护没有更多数据时直接返回避免反复触发。代码骨架大概是const handleEndReached useCallback(() { if (!hasMore || loading) return; loadMore(); }, [hasMore, loading]);4.3 横向列表和嵌套滚动冲突横向 List 在 RNOH 上也不少见比如首页顶部的横向分类入口。实现方式就是 FlatList 加一个horizontal属性item 里限制好宽度。横向滚动和竖向滚动的方向不同理论上手势判定不会冲突但 RNOH 的适配早期版本里偶尔会出现横向列表滑动时页面也跟着上下抖动的现象。这个更多是手势响应链的问题目前的建议是横向 FlatList 的父容器不要同时是可滚动的如果必须嵌套给横向列表设置固定的高度。真正麻烦的是竖向页面嵌套 FlatList。最经典的报错是VirtualizedLists should never be nested inside plain ScrollViews with the same orientation意思是同方向的虚拟列表嵌套普通滚动容器会有问题。这个错误在原生 RN 上就有RNOH 上同样存在而且因为桥接层更复杂出现位置错乱和滚动卡顿的概率更高。我的处理原则很简单能不用嵌套就不用。把外层 ScrollView 去掉改用 FlatList 的ListHeaderComponent和ListFooterComponent来承载顶部和底部的内容。这样整个页面就是一个虚拟列表滚动性能和事件处理都干净。如果确实改不了必须保留嵌套那内层 FlatList 有两种补救方式一种是给内层列表固定高度且设置scrollEnabled{false}把它当作一个纯展示区块另一种是用style{{ height: computedHeight }}在 onLayout 里动态计算高度。两个方案都不完美但至少能避免滚动冲突带来的各种诡异问题。5. 一个真实案例商品列表页从卡顿到流畅的完整改造5.1 初版为什么卡我踩过的每个坑都在里面我这边有一个实际的商品列表页数据量不大也就 1000 条左右但初版代码跑起来确实到了PPT 级流畅度。现在回头看问题非常典型每个 item 高度是固定的120但没有写getItemLayout。图片直接用的接口原图 URL单张图 2 到 5 MB。item 组件没有 memorenderItem 里直接写了onPress{() handlePress(item.id)}。initialNumToRender用默认值 10windowSize没有动。列表数据是通过一次接口全部拿到的前端没有做分页。我当时的第一步排查是先用 Profiler 看了一眼JS 线程占用不算高但 ArkUI 侧的图片解码和布局计算明显是热点。问题的层次很清楚图片大、没有高度预设、渲染参数默认三个因素叠加桥接层压力拉满。5.2 一步一步改每次改动都验证效果改造过程我没做一步到位而是一步一验证方便确认每项改动到底起到多大作用。第一步先把图片 URL 改成带缩放参数的地址比如在 URL 后面加?w400h300服务端返回裁剪后的小图。这一项改动之后帧率从几乎不可用提升到了勉强能滑动的水平。第二步加getItemLayout。因为所有商品卡片高度统一是 120直接按固定值算。这一步做完滑动时的白块明显减少滚动位置也更准确了。第三步把 item 组件用React.memo包起来handlePress改成useCallback。这一步解决的是快速滑动时 CPU 峰值过高的问题JS 线程的压力肉眼可见地降了下来。第四步调initialNumToRender到 6windowSize到 3。这样首屏只渲染可视区域附近的少量 itemArkUI 侧创建节点的数量大幅减少首屏加载速度和滚动稳定性都变好了。第五步把单次批量渲染数量maxToRenderPerBatch从默认的 10 调到 5。这一步的效果是让渲染工作更均匀地分摊到每一帧避免某一帧一次性创建太多节点导致掉帧。5.3 改造后可以直接抄走的代码骨架这套改造做完列表页的体验已经接近原生 RN 的感觉了。下面是一个可以直接参考的商品列表组件模板把数据源和样式换成自己的即可import React, { useCallback, useState } from react; import { FlatList, Text, View, Image, TouchableOpacity, StyleSheet } from react-native; const ITEM_HEIGHT 120; const ProductItem React.memo(({ item, onPress }) ( TouchableOpacity style{styles.item} onPress{onPress} Image source{{ uri: item.imageUrl }} style{styles.image} / View style{styles.info} Text style{styles.title} numberOfLines{1}{item.title}/Text Text style{styles.price}¥{item.price}/Text /View /TouchableOpacity )); function ProductList({ data }) { const [refreshing, setRefreshing] useState(false); const [hasMore, setHasMore] useState(true); const [loading, setLoading] useState(false); const handlePress useCallback((id: string) { console.log(点击商品, id); }, []); const renderItem useCallback(({ item }) ( ProductItem item{item} onPress{() handlePress(item.id)} / ), [handlePress]); const handleRefresh useCallback(() { setRefreshing(true); // 请求第一页 setRefreshing(false); }, []); const handleEndReached useCallback(() { if (!hasMore || loading) return; setLoading(true); // 请求下一页 setLoading(false); }, [hasMore, loading]); return ( FlatList data{data} keyExtractor{(item) item.id} renderItem{renderItem} ref{listRef} getItemLayout{(data, index) ({ length: ITEM_HEIGHT, offset: ITEM_HEIGHT * index, index, })} initialNumToRender{6} windowSize{3} maxToRenderPerBatch{5} refreshing{refreshing} onRefresh{handleRefresh} onEndReached{handleEndReached} onEndReachedThreshold{0.2} style{styles.list} / ); } const styles StyleSheet.create({ list: { flex: 1, backgroundColor: #f5f5f5 }, item: { height: ITEM_HEIGHT, flexDirection: row, alignItems: center, backgroundColor: #fff, marginHorizontal: 12, marginTop: 8, borderRadius: 8, paddingHorizontal: 10, }, image: { width: 80, height: 80, borderRadius: 6 }, info: { flex: 1, marginLeft: 10 }, title: { fontSize: 15, color: #222 }, price: { fontSize: 16, color: #e4393c, marginTop: 6 }, });这里有个细节renderItem里我给onPress写的还是内联箭头函数但因为我给ProductItem做了 memo并且handlePress引用稳定ProductItem的 props 里只有 item 在变化所以 memo 依然能生效不会因为内联函数造成多余的重新渲染。这是一种简化的写法严格追求极致性能可以再给 item 传递一个稳定的事件处理函数但实际项目中这个程度的优化已经足够。最后说一个我自己的体会在 RNOH 上做列表最忌讳的就是拿原生 RN 的性能思维硬套。原生 RN 的问题大多是 JS 侧逻辑写重了而 RNOH 的问题更多出在桥接层的信息缺失、图片解码和渲染窗口配置上。先把环境版本确认好再按固定高度信息、压缩图片、控制预渲染数量、保持引用稳定这个顺序过一遍大部分卡顿问题都能解决。List 组件是 rn_for_openharmony 开发的起点也是试金石——这一关通了后面的页面开发会顺很多。
返回列表