前段时间在OpenHarmony设备上调试一个React Native应用,正好碰上了FlatList多选功能的开发任务。一开始觉得这功能很常规,无非是记录选中项、渲染选中态、再处理一下全选和反选。真动手才发现,坑比想象中多得多——RN的适配层行为、FlatList的虚拟列表机制、OpenHarmony上的触摸事件响应,任何一环没对上,多选就会从“正常功能”变成“玄学现场”。
这篇文章把整个功能从设计到落地的过程完整拆一遍,包括状态模型怎么选、为什么不能用item内部state、extraData到底解决什么问题、OpenHarmony上滚动性能怎么调。适合正在做RN跨端开发、尤其是准备把RN应用适配到OpenHarmony的团队参考。如果你只是想在普通RN平台上实现FlatList多选,大部分内容同样适用,底层的性能优化思路是通用的。
1. 为什么要在OpenHarmony里做RN的FlatList多选
1.1 背景:OpenHarmony下的RN开发现状
React Native官方支持的是Android和iOS,要在OpenHarmony上跑RN,核心依赖一套适配层。这个适配层做的事情,可以理解成把RN的JS引擎、渲染指令、UI组件、触摸事件,全部翻译成OpenHarmony原生框架能理解的形式。没有这个翻译层,RN的代码在OpenHarmony设备上就是一堆无法运行的JS脚本。
团队当时选RN的原因很实际:跨端代码复用率最高,团队里RN的经验积累最厚,第三方生态里关于RN的组件、报错处理、性能优化方案也最全。虽然OpenHarmony有自己的声明式UI开发模式,但让整个团队在短期之内切换过去,学习成本和业务迁移成本都不小。RN通过适配层直接跑在OpenHarmony上,业务代码基本不用动,这对存量项目来说是最平滑的过渡路径。
不过代价也很明显:适配层不是万能的,它会把一部分RN底层行为“翻译”成OpenHarmony风格,导致某些API的表现和标准RN平台不一样。比如触摸事件的响应延迟、列表组件的渲染窗口大小、图片组件的解码方式,这些差异平时不碰还好,一旦做起FlatList多选这种重度交互功能,就会集中暴露出来。这次的开发经历,本质上就是一次“RN能力在OpenHarmony适配层上的边界探索”。
1.2 需求场景:从单选到多选的进化
这次做的功能是一个资料管理App里的附件列表,支持长按进入多选模式,然后批量下载、批量删除。业务场景很典型:附件列表动辄几百条数据,每条包含缩略图、文件名、大小、修改时间,用户需要勾选多条后统一操作。最开始只做了单选预览,长按之后打开详情。产品后来要求改成多选,把列表组件单独拎出来迭代了一轮。
多选和单选看起来只是“状态从单值变成集合”,实际复杂度完全不同。单选只需要一个变量记录当前选中的id,多选则要维护一个集合,还要处理全选、反选、清除、批量操作按钮的状态联动,还要应对滑动时item被回收复用导致的选中态丢失。如果没写过列表多选的人来做,最容易踩的坑就是直接把选中状态存在每个item自己的state里,滚动几屏后状态全乱,重现都难。
另外,多选模式和数据源之间是强耦合的:批量删除要操作真实数据源,全选要遍历数据源生成选中集合,反选要基于数据源做差集。这意味着多选功能的实现好坏,很大程度上取决于数据模型设计是否清晰。这也是我坚持先把“选中状态模型”设计完整再动手写UI的原因——UI可以后调,数据结构错了,返工成本极高。
2. 环境搭建与工程结构
2.1 基础环境准备
在OpenHarmony设备上跑RN项目,基础流程分四步:
- 准备好Node.js环境,版本建议不低于14,新版RN已经逐步抛弃对旧版Node的支持。
- 用RN官方脚手架创建基础工程,执行
npx react-native init 某项目名。 - 接入OpenHarmony适配层。这一步通常需要手动安装适配包,并把适配包的原生工程目录合并进项目的native目录,然后配置好打包脚本。
- 构建产物并运行。RN的JS bundle需要打包成OpenHarmony应用能加载的资源,原生侧需要编译出对应的动态库,最后通过IDE工具一键运行到模拟器或真机。
这里不贴具体命令,因为适配层的接入方式随版本迭代变化很快。最稳妥的做法是先确认适配层当前支持哪个RN大版本,再决定RN工程用什么版本。不要直接上最新版RN,跨端适配往往滞后于RN官方版本更新,选一个适配层明确支持的版本能省掉大量无意义的排错时间。
另外有一点值得提醒:OpenHarmony适配层对原生构建环境的要求和标准RN不同,比如原生编译工具链版本、CMake版本、NDK版本,都会影响最终能否跑通。环境问题通常占了前期排错的一半时间,建议团队里专门有人先把适配层的官方示例工程原样跑通一遍,确认整条链路没问题后,再开始写业务代码。
2.2 FlatList在RN中的工作方式
聊多选之前,先得把FlatList的渲染机制说清楚。FlatList本质是一个虚拟列表,它不会一次性创建所有item,而是只渲染视口附近的一小部分。滚动时,远离视口的item会被卸载,接近视口的item会被创建,被卸载的item所在的位置会被新内容复用。这个机制保证了长列表在滑动时的性能稳定,但也给状态管理带来了一个核心约束:item是会“消失”和“复活”的。
如果在item内部维护自己的选中状态,那当它滚出视口被卸载时,这个状态就丢失了;滚回来时会发现,item重新创建后选中状态不在了。并且,由于FlatList的复用机制,同一个视图实例可能先展示了第5条数据,再展示了第20条数据,如果第5条和第20条的选中状态不一样,而组件只“记住”了上一个状态,就会出现勾选框错乱的情况。
所以,FlatList多选的第一定律是:选中状态必须提升到列表组件外层,绝对不能放在item内部。所有渲染依赖的外部状态,要通过data或extraData传给FlatList。只有理解了这一点,后面所有代码设计才有立足之地。
3. 多选功能核心设计
3.1 状态模型的选型
先定义数据结构。item本身是数据源中的一个对象,至少包含id、名称、大小、时间等展示字段。多选状态我建议用一个Set来存储选中的id,而不是用数组。理由有三点:
- 判断是否选中:Set的has操作是O(1),数组的includes是O(n)。几百条数据看不出差距,但几千条数据且频繁渲染时,每次include都是全量遍历,损耗非常明显。
- 切换选中项:Set的add/delete天然去重,不需要先indexOf再截断拼接。
- 触发更新:每次生成新Set引用清晰,React的浅比较能稳定感知变化,不容易出现改了状态但UI不刷新的问题。
选中集合的切换逻辑可以这样写:
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const toggleSelect = useCallback((id: string) => { setSelectedIds(prev => { const next = new Set(prev); if (next.has(id)) { next.delete(id); } else { next.add(id); } return next; }); }, []);这里有个关键点:不能直接修改原Set。new Set(prev)相当于把原集合拷贝一份再修改,保证每次操作返回的都是一个新引用。React依赖这个新引用来触发重新渲染。如果直接prev.add(id),引用没有变化,组件不会重绘,UI和状态就对不上了。
可能有同学问,为什么不直接把选中字段写进item对象,比如item.selected: true。听起来更直观,但副作用很大:数据源被污染,后面做批量删除、数据刷新都要额外处理;而且修改item后如果data引用没变化,FlatList同样不会刷新,还得额外做一层不可变更新。与其让数据和UI状态纠缠在一起,不如彻底分离——数据源只负责展示内容,选中状态独立维护,各管各的。
3.2 交互逻辑的实现
这次的多选交互有两种模式:普通预览模式和多选编辑模式。普通模式下点击item直接预览附件;长按某个item进入多选模式,同时默认选中当前长按的item。进入多选后,item左侧出现复选框,点击item切换选中状态,顶部操作栏显示当前选中数量和批量操作按钮。
我用useState维护当前模式,取值范围是normal和selecting。renderItem里根据模式决定是否渲染复选框,同时根据selectedIds.has(item.id)决定item是否处于选中样式。
const renderItem = useCallback(({ item }: ListRenderItemInfo<ItemData>) => { const isSelected = selectedIds.has(item.id); return ( <TouchableOpacity activeOpacity={0.7} style={[styles.itemContainer, isSelected && styles.itemSelected]} onPress={() => handlePress(item.id)} onLongPress={() => handleLongPress(item.id)} > {mode !== 'normal' && ( <View style={styles.checkboxWrapper}> <Icon name={isSelected ? 'checked' : 'unchecked'} size={20} /> </View> )} <View style={styles.itemBody}> <Text style={styles.itemName}>{item.name}</Text> <Text style={styles.itemMeta}>{item.size} · {item.modifiedTime}</Text> </View> </TouchableOpacity> ); }, [selectedIds, mode, handlePress, handleLongPress]);有两个细节值得单独说。
第一个是依赖数组。selectedIds、mode、handlePress、handleLongPress必须出现在useCallback的依赖里。如果漏掉,renderItem会一直持有第一次渲染时的旧状态,点击后UI不更新,控制台却打印出新状态——这个bug非常隐蔽,不看依赖数组根本发现不了。
第二个是长按事件的触发阈值。TouchableOpacity的长按默认触发时间是500ms,这个值对普通用户来说刚好。如果产品有更精细的要求,可以通过delayLongPress调整。还有一点:如果item内部还有其他触摸组件(比如收藏按钮、下载按钮),要注意嵌套冲突。长按被内层组件拦截的话,外层onLongPress永远触发不了,排查时可以先删掉内层组件试一下。
3.3 全选、反选与批量操作
全选逻辑基于当前选中数量和总数量的关系判断。如果已经全选,再点全选就应该清空;否则全选。
const handleSelectAll = useCallback(() => { setSelectedIds(prev => { if (prev.size === data.length) { return new Set(); } return new Set(data.map(item => item.id)); }); }, [data]); const handleClear = useCallback(() => { setSelectedIds(new Set()); }, []);反选同样是遍历data,把原Set里没有的id加进来,已经有的删掉:
const handleInvert = useCallback(() => { setSelectedIds(prev => { const next = new Set<number>(); data.forEach(item => { if (!prev.has(item.id)) { next.add(item.id); } }); return next; }); }, [data]);批量操作按钮放在顶部操作栏,文案随着选中数量变化,比如“删除(3)”。选中数量不要单独维护一个state,直接在渲染时取selectedIds.size,必要时用useMemo包一层。这样数量永远和selectedIds保持一致,不会出现“操作完数量还没刷新”的时序问题。
批量删除执行时,要从真实数据源里过滤掉选中的id,而不是操作页面上缓存的data副本。过滤完生成新数据源,同时把selectedIds清空。这里有个经验:先把selectedIds清空,再更新data。顺序反了的话,可能会触发一次“选中的空数据”渲染,界面闪现空白。
4. 性能优化与踩坑实录
4.1 extraData说三遍都不嫌多
FlatList的官方文档里反复强调:如果列表依赖外部状态,必须通过extraData传进去,否则组件不会重新渲染。原理是FlatList继承自PureComponent,对props和state做的是浅比较。如果data数组引用没变,只是外部状态变了,FlatList就认为不需要更新,renderItem不会重新执行。
这个机制直接导致了经典bug:点击item,控制台打印selectedIds已经更新,但UI完全没反应。我第一次遇到的时候查了很久,以为是适配层渲染问题,最后发现就是忘了传extraData。FlatList这个组件在RN任何平台上都一样,OpenHarmony适配层不会帮你自动修复这个行为。
正确写法:
<FlatList data={data} extraData={{ selectedIds, mode }} renderItem={renderItem} keyExtractor={keyExtractor} getItemLayout={getItemLayout} />把selectedIds和mode合并成一个对象传进去,比分开传两个字段的扩展性更好。后面如果还有新的状态参与渲染,比如某个过滤开关,直接合并进这个对象,不用改FlatList的props结构。
4.2 keyExtractor和getItemLayout的作用
keyExtractor不写,RN会警告并默认用item索引做key。索引做key的隐患在于:当列表头部数据发生变化时(比如删了一条),索引整体前移,原本绑定到第5个item的视图会被第4个item复用,选中状态就会“串”过去。这就是为什么滚动之后勾选状态错乱的常见根因。多选列表必须用id做key:
const keyExtractor = useCallback((item: ItemData) => item.id, []);如果列表的item高度固定,强烈建议加getItemLayout。它的作用是告诉FlatList每个item的位置坐标,跳过动态测量环节直接按坐标渲染。对几百条长度的列表提升不明显,但上千条数据的场景,性能差异肉眼可见。OpenHarmony适配层上这个优化尤其重要,因为测量环节的JS和原生通信成本比标准RN平台更高。
const ITEM_HEIGHT = 72; const getItemLayout = useCallback( (_: ArrayLike<ItemData> | null, index: number) => ({ length: ITEM_HEIGHT, offset: ITEM_HEIGHT * index, index, }), [] );注意ITEM_HEIGHT必须和实际渲染的item高度完全一致,包括边框、间距、内边距。不对齐的话,滚动时会看到item跳动或者点击错位,排查起来很头疼。
4.3 实际遇到的三个性能坑
第一个坑:renderItem内部做了重活。最开始我在item渲染的render函数里做时间戳格式化、拼接缩略图路径,心想这些操作没多少开销。但在OpenHarmony适配层上,渲染路径中任何JS计算都会被放大,滚动时大量item同时格式化时间戳,卡顿感非常明显。后来把格式化全部提前到数据处理阶段,data里的item在进入列表前就已经是可直接渲染的字段。
第二个坑:选中状态用plain object存储。{id: true}这种结构写起来方便,但全选操作要遍历所有key生成新对象,代码可读性差,而且Object的key顺序在某些场景下会带来意外。换成Set之后,逻辑清爽很多,计算复杂度也更好控制。
第三个坑:OpenHarmony上FlatList的windowSize和initialNumToRender需要单独调。默认值在标准RN平台上表现正常,但OpenHarmony适配层上渲染窗口偏小,快速滑动时会出现大面积空白。我把windowSize从默认的21调整到30左右,initialNumToRender从10调整到15,实测白屏和卡顿都明显减少。这类参数没有统一标准,建议在目标设备上做压力测试,把列表拉到最大数据量再快速滑动看表现。
5. 常见问题与排查技巧
5.1 问题速查表
这次开发过程中碰到的高频问题,整理成一张速查表,方便遇到类似情况时快速定位方向。
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 点击item无选中反馈 | 忘了传extraData | 把selectedIds和mode合并传进extraData |
| 滚动后选中状态错乱 | keyExtractor未指定或用索引 | 改为用item.id做key |
| 长按无法进入多选 | 内层触摸组件拦截事件 | 检查item内部是否有其他Touchable组件,调整结构 |
| 全选后列表卡顿 | 每次渲染重建大量对象 | 用Set存id,减少不可变更新的拷贝成本 |
| 快速滑动出现空白 | windowSize或initialNumToRender偏小 | 适当调大,真机压测验证 |
| 删除后勾选项错位 | item被复用但状态匹配不当 | 严格保证keyExtractor返回值稳定 |
| OpenHarmony上点击偶发不响应 | 触摸事件被原生侧拦截 | 检查适配层触摸配置,必要时升级适配包版本 |
这个表格里的问题,前四个在纯RN环境也会遇到,后三个在OpenHarmony上更容易暴露。排查时建议遵循一个原则:先确认JS逻辑没问题,再看是不是渲染机制导致的,最后再怀疑适配层。不要一上来就甩锅给适配层,很多bug其实就是keyExtractor写错了。
5.2 两个容易被忽视的细节
第一个细节:不要用Math.random()或时间戳做key。这个问题在开发联调时不太容易被发现,因为列表短,随机key导致每次渲染全部重建item,视觉上只是感觉滚动有点肉。一旦列表拉到几百条、加上缩略图加载,随机key会引发全部item频繁卸载重建,性能断崖式下跌。key生成必须稳定且可预测,id就是最简单的答案。
第二个细节:多选模式下,顶部操作栏的选中数量不要单独用useState维护。我第一次就犯了这个错——点击选中一个item后,又setCount一次,结果两个状态不同步。后来发现直接渲染时取出selectedIds.size就够了,配合useMemo在数据量大的时候做缓存,代码简洁且永远一致。
5.3 一次典型的排查现场
这里记录一个印象深刻的bug排查过程。当时在OpenHarmony真机上,长按item进入多选模式后,前几个item点击选中正常,滑到后面点击就不响应了。第一反应是FlatList滚出了可视区,item被回收导致事件绑定丢失。检查之后发现,问题出在checkbox组件的触摸事件和TouchableOpacity的onPress发生了竞争。内层的checkbox组件在OpenHarmony适配层上消耗掉了触摸事件,外层的onPress就永远收不到。
解决方式是去掉内层单独的可点击组件,把选中状态全部通过样式和图标表达,点击整个item去切换选中。改完之后,长列表任意位置的点击都正常了。这件事给我的教训是:在OpenHarmony上做触摸交互,嵌套可点击组件要格外谨慎。适配层的触摸事件分发机制和标准RN平台存在细微差异,能少一层就少一层。
6. 写在最后的一些经验
这次把FlatList多选功能做完并稳定运行在OpenHarmony设备上之后,最大的体会是:跨端开发的坑,大多数不是JS逻辑层的坑,而是底层渲染和适配层行为差异的坑。多选之所以踩了这么多雷,本质上是因为FlatList把状态、渲染、复用三者耦合在一起,任何一环没匹配对,都会出现难以定位的诡异现象。
如果让我再重做一次,我会在第一天就把状态模型锁定为Set,把所有渲染依赖的外部状态统一合并进extraData,用item.id作为唯一key,并提前在真机上压测滚动性能。这些基本功看起来不起眼,但都做到位之后,多选功能就只剩业务层面的细节打磨了。
最后再分享一个调试技巧:在OpenHarmony上调试RN列表问题,强烈建议开启RN的远程调试日志,把renderItem的执行次数打出来。正常滚动时,renderItem执行次数应该接近可视区item数量加上少量预渲染。如果发现滚动过程中renderItem频繁执行几百次,基本可以断定是keyExtractor或extraData配置出了问题。这个指标是判断列表健康度的最直观信号,比肉眼盯着界面猜要高效得多。