做跨端开发的朋友可能都有这种感觉:业务逻辑再复杂,咬咬牙也能写完,真正耗时间的反而是那些看起来不起眼的交互细节。这个月我一直在OpenHarmony设备上调试React Native应用,卡得最久的就是一个“列表多选”的需求。第一版上线后,测试反馈了几个问题:列表卡顿、长按偶尔会误触、批量删除以后界面状态没复位。于是有了这一轮的FlatList多选重构。
需求本身不复杂,就是内容管理列表里,长按一条数据进入多选模式,可以勾选多条,然后批量删除或批量移动。做过Android原生或者Flutter的人会觉得这算什么,但放在OpenHarmony + React Native这套组合里,情况就不一样了。RN在鸿蒙上的运行时和社区版本还在快速迭代,很多在Android/iOS上被默认处理好的细节,在这里都会暴露出来。这篇记录把完整的方案设计、代码实现、性能优化和踩坑过程都写清楚,给准备在OpenHarmony上做RN列表类需求的人一个参考。
1. 项目背景与需求拆解
1.1 为什么是OpenHarmony + React Native这套组合
先说场景。我们手上是一个内容管理类应用,面向OpenHarmony平板和一体机,核心界面是各种列表:文档列表、媒体列表、任务列表。团队情况比较特殊,业务代码一直是React Native写的,Android和iOS共用同一套JS层代码,如果OpenHarmony版本重新用ArkTS写一套,人力成本和时间成本都翻倍。所以选了社区维护的React Native鸿蒙适配版本,让同一套业务代码尽量在OpenHarmony上也能跑起来。
这套组合能不能用来做列表业务?我的结论是可以,但你必须接受一个现实:RN在鸿蒙上的成熟度不如Android/iOS,尤其是列表性能、弹窗层级、动画兼容这些偏底层的部分,经常需要额外处理。比如同一个FlatList,在Android上400条数据滑得飞起,在鸿蒙平板上就可能出现肉眼可见的掉帧。这不代表方案不行,只是说你要留出适配和优化的时间。
还有一个容易被低估的点:OpenHarmony设备形态差异很大,从几英寸的竖屏设备到十几英寸的横屏平板都有,列表多选的布局和操作栏交互都要跟着调。我们在设计初期定了一个原则:所有交互状态必须集中在页面容器层,不要在列表行组件内部散落,这样后续适配不同屏幕时才不会改得焦头烂额。
1.2 “列表多选”真正要解决的五个子问题
把“列表多选”拆开看,它不是一个“往每行加个勾选框”的功能,而是一整套状态交互设计。我第一版就是太天真,直接在renderItem里塞了一个Switch组件,数据源里加了一个selected字段,结果上线就被打回。
拆解下来,核心问题有这么几个:
第一,选中状态的归属。选中项放在列表数据里,还是单独维护?如果直接改item对象,那么任何一次列表刷新、下拉刷新、搜索过滤都会把选中状态冲掉,甚至出现“这条明明没勾选,却显示勾选”的脏数据。
第二,多选模式和普通模式的切换。长按进入多选后,点击一条应该变成选中/取消选中,而不是跳详情;退出多选后,点击行为要恢复。这个模式切换如果只用一两行if判断,后面一定会漏。
第三,行组件的渲染性能。FlatList虽然是虚拟列表,但多选过程中每条数据的选中状态都在变,如果写得不讲究,一次勾选就会触发整个列表重渲染,在OpenHarmony上卡顿非常明显。
第四,批量操作的联动。全选、取消全选、半选状态、删除后的状态清理、底部操作栏的计数,这些是一套完整的状态机,不是单独一个checkbox能搞定的。
第五,交互细节。长按和点击的冲突、触摸区域大小、选中行的背景色和勾选图标反馈,这些在真机上都会被放大,特别是在触控屏和遥控器操作场景下。
我后面所有的设计和代码,都是围绕这五个问题展开的。
2. 方案选型与状态设计
2.1 交互模型:显式勾选还是长按进入多选
多先列表的交互模型大致有两类,需要根据业务场景取舍。
第一类是“常驻复选框”,每行右侧固定放一个checkbox,进入页面就能勾选。这种更适合批量操作频率极高的场景,比如文件管理器、邮件列表,用户可能一口气整理几十条数据,视觉上愿意接受每行都带一个框的噪声。缺点也明显:普通浏览时界面太吵,而且每一行都要渲染一个额外组件,列表性能压力更大。
第二类是“长按进入多选模式”,进入模式之前界面干净清爽,长按任意一条后,顶部弹出操作栏,行的点击行为切换为勾选,点击“完成”或关闭操作栏退出。这种更适合“浏览为主、批量操作为辅”的应用,比如内容管理、相册整理。
我最终选了第二种,因为我们的业务主线是查看和编辑单条内容,批量删除是偶尔才用的操作。把批量操作藏在一个明确的操作入口后面,既保证了主路径的清爽,也不会让用户误触。
需要提醒的是,纯长按进入多选对用户并不友好。很多用户根本不知道长按有这功能,尤其是一体机和触屏设备上,长按时间如果设置不对,会频繁误触。我在页面的右上角加了一个“多选”按钮作为兜底入口,效果立竿见影。测试反馈里,10个人至少有6个是用按钮进入多选的。所以如果你是做类似需求,我建议“长按 + 按钮”两个入口都留,别只做一个。
2.2 选中状态到底应该放在哪
这是整个多选功能最核心的设计决策。我第一版把selected字段直接写进列表项的item对象里,每次勾选都去改数据源,然后setData触发刷新。前20条数据一切正常,数据一多问题就全出来了。
首先是数据污染。列表数据是接口返回的,如果勾选状态写进去,再请求新数据时旧页面的选中状态会和新数据混在一起,不好区分。其次是筛选冲突。列表有搜索过滤功能,用户勾了几条后去搜索,结果列表被过滤成另一批数据,原先的选中状态要么看不见了,要么被错误地带到新结果上。第三是性能浪费。改item对象后,整个data数组引用变了,FlatList认为所有数据都变了,所有行全部重渲染。
正确做法是:选中项用Set<string>单独维护,放在列表页容器组件里,跟data平级。
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [editMode, setEditMode] = useState(false);用Set而不是数组的原因很简单:判断一个id是否被选中,Set用has是常数时间;数组用includes是线性时间,几千条数据时每次点击都要遍历一次,累计起来就是卡顿。另外Set天然去重,同一个id不会出现两条。
还要注意一个细节:React的状态更新必须返回新引用,所以不能用next.add(id)后直接返回同一个Set,要new Set(prev)先拷贝再增删。很多人在这里翻车,因为Set内容变了但引用没变,React对比不到变化,界面不会刷新。
2.3 组件分层与回调缓存
状态设计清楚后,组件结构就顺理成章了。我按“容器层 + 列表层 + 行组件层”三层来拆:
- 页面容器组件:持有data、selectedIds、editMode,负责操作栏渲染和批量删除逻辑。
- FlatList层:担任纯渲染职责,只接收
data、renderItem、extraData和getItemLayout。 - Row行组件:用
React.memo包裹,只接收单条item数据和几个稳定回调。
为什么要单独抽Row组件?因为FlatList的renderItem每次父组件重新渲染时都会重新执行,如果里面直接写复杂的行UI,那么父组件每次setState,所有可见行都会跟着重建。把行UI抽到Row里,再配合React.memo,可以让FlatList在重渲染时自动跳过那些props没有变化的行。
回调的稳定引用也特别重要。有人觉得useCallback只是性能微优化,其实在FlatList场景里是必备项。如果renderItem里每次写onPress={() => handlePress(item.id)},每个行拿到的onPress都是新函数引用,React.memo的浅比较就会失效,所有行还是全部重渲染。
我最终的做法是:容器层定义稳定回调,Row内部再自己处理点击事件参数。这样传下去的props里,函数引用始终不变,只有selected布尔值和editMode模式切换时才会触发对应行更新。
3. FlatList多选核心代码实现
3.1 模式切换:长按和按钮两个入口
多选模式的管理是整个功能的地基。长按一条进入多选时,通常要同时选中被长按的那条,这是交互设计里的细节;通过页面右上角按钮进入时,则不需要默认选中任何条目。
const enterEditModeFromPress = useCallback((id: string) => { setEditMode(true); setSelectedIds(new Set([id])); }, []); const enterEditModeFromButton = useCallback(() => { setEditMode(true); setSelectedIds(new Set()); }, []);这里不要做多余的事,比如进入多选后再判断之前是否是编辑模式。两个入口的逻辑虽然相似,但初始选中行为不同,分开写更清晰。
退出多选的时机也值得想清楚。删除操作成功后、点击“完成”后、退出页面时,都要显式把这些状态复位。我一开始只在删除后复位,但漏了“完成”按钮,导致用户在操作栏点完成,列表还停留在可勾选的视觉状态,很尴尬。所以最终的退出逻辑全部收敛到一个exitEditMode函数里:
const exitEditMode = useCallback(() => { setEditMode(false); setSelectedIds(new Set()); }, []);3.2 行组件的渲染与选中态控制
行组件是多选功能的核心,也是性能差异最大的地方。先看Row的完整实现:
interface RowProps { item: ContentItem; selected: boolean; editMode: boolean; onPressItem: (id: string) => void; onLongPressItem: (id: string) => void; } const Row = memo(function Row({ item, selected, editMode, onPressItem, onLongPressItem, }: RowProps) { return ( <Pressable onPress={() => onPressItem(item.id)} onLongPress={() => onLongPressItem(item.id)} style={[styles.row, selected && styles.rowSelected]} > <View style={styles.rowContent}> <Text style={styles.title} numberOfLines={1}>{item.title}</Text> <Text style={styles.subtitle} numberOfLines={1}>{item.subtitle}</Text> </View> {editMode && ( <View style={[styles.checkBox, selected && styles.checkBoxActive]}> {selected && <Text style={styles.checkMark}>✓</Text>} </View> )} </Pressable> ); });关键点在容器层的renderItem:
const renderItem = useCallback(({ item }: ListRenderItemInfo<ContentItem>) => { const selected = selectedIds.has(item.id); return ( <Row item={item} selected={selected} editMode={editMode} onPressItem={handlePressItem} onLongPressItem={handleLongPressItem} /> ); }, [selectedIds, editMode, handlePressItem, handleLongPressItem]);selected是一个布尔值,不是整个selectedIds集合。这样当勾选状态变化时,只有那条被勾选/取消勾选的行会收到selected变化,其他行的props完全相等,React.memo直接跳过渲染。这是整个列表性能优化里最关键的一条。
FlatList配置也很重要:
<FlatList data={data} renderItem={renderItem} keyExtractor={item => item.id} extraData={selectedIds} getItemLayout={getItemLayout} initialNumToRender={20} maxToRenderPerBatch={20} windowSize={11} />extraData必须传,因为FlatList在内部对比是否需要刷新时,不仅仅看data和renderItem,还需要一个额外的数据源。selectedIds变化时,它才能感知到列表需要重新渲染。
3.3 全选、反选、半选与批量删除
全选和半选状态是另一个容易出错的点。如果列表有分页或筛选条件,全选的范围需要提前定义清楚:是选当前页所有数据,还是选所有数据(包括未加载的)?我们的业务是单次拉取全部,所以全选范围为data数组里所有id。
const visibleIds = useMemo(() => data.map(item => item.id), [data]); const selectionStatus = useMemo(() => { if (selectedIds.size === 0) return 'none'; if (selectedIds.size === visibleIds.length) return 'all'; return 'partial'; }, [selectedIds, visibleIds]); const toggleSelectAll = useCallback(() => { setSelectedIds(prev => { if (prev.size === visibleIds.length) { return new Set(); } return new Set(visibleIds); }); }, [visibleIds]);这里有个容易被忽略的场景:搜索过滤后,selectedIds里可能还残留着不可见条目的id。如果直接用selectedIds.size === visibleIds.length判断全选,就会计算出错误结果。我加了一步兜底:在列表数据或筛选条件变化时,剔除selectedIds中那些在visibleIds里不存在的id。
批量删除则要处理三个事情:调用接口、更新数据源、复位选中状态。更新数据源时,用filter过滤掉已删除的id,不要再发一次全量请求;复位状态时,一定要连editMode一起清掉,否则删除完成后操作栏还在。
const handleBatchDelete = useCallback(async () => { const deleteIds = [...selectedIds]; if (deleteIds.length === 0) return; setDeleting(true); try { await requestBatchDelete(deleteIds); setData(prev => prev.filter(item => !deleteIds.includes(item.id))); setSelectedIds(new Set()); setEditMode(false); } catch (err) { // 这里做统一错误提示 } finally { setDeleting(false); } }, [selectedIds]);删除过程中的防重复点击也要注意,deleting状态记得在删除按钮上做loading和disable。
4. 性能优化与OpenHarmony适配实录
4.1 四百条数据滑动掉帧的排查经历
第一版上线后,测试反馈“列表滑动掉帧,特别是进入多选模式以后更明显”。我先在代码里找问题,发现renderItem里每次渲染都做了几件蠢事:一个是拼接富文本,另一个是创建内联style对象,还有一个是图组件每次都会new一个对象。这三项在Android上可能只有轻微影响,在OpenHarmony上直接被放大成肉眼可见的卡顿。
然后做了四件事,按效果排序:
第一,固定行高并配置getItemLayout。列表行高如果固定,就不需要动态测量每一行的布局,FlatList可以直接算出某个索引对应的滚动偏移量。这个优化对长列表效果非常显著。
const ROW_HEIGHT = 72; const getItemLayout = useCallback((_data, index) => ({ length: ROW_HEIGHT, offset: ROW_HEIGHT * index, index, }), []);第二,行内不要做任何复杂计算。样式全部写在StyleSheet.create里,不要在render过程创建对象;图片组件提前初始化好,不要在renderItem里new;文本长度用numberOfLines={1}限制,不要动态测量高度。
第三,保证React.memo真正生效。这里学的教训是:如果renderItem内部使用箭头函数给Row传onPress,React.memo就会失效。因为每次重新渲染时,新的onPress函数引用会打破浅比较。我们的方案是像前面代码那样,传稳定回调给Row,Row内部再用箭头函数包一层。
第四,控制renderItem的依赖。我把renderItem用useCallback包起来,依赖是selectedIds和editMode,这样大多数情况下renderItem引用是稳定的,FlatList不会因为父组件其他无关状态变化而重渲染。
优化完以后,在OpenHarmony平板上从掉帧明显改善到基本流畅。注意,不要指望和原生列表一样丝滑,RN跨端方案天然有JS和原生之间的通信开销,目标应该是“无感知卡顿”,而不是“帧率拉满”。
4.2 启动白屏问题实录
这个月还踩了一个开发阶段最常见的问题:React Native应用在OpenHarmony上冷启动白屏,持续时间从1秒到5秒不等,非常吓人。这个问题让我联想起网上很多人提到的新手期白屏,还是要从几个方向排查。
第一步看bundle加载。release包如果本地bundle路径配置不对,应用会一直等报错的加载结果。检查metro.config.js和原生加载路径,确认bundle被正确打进包并解压到指定目录。
第二步确认入口注册。AppRegistry.registerComponent所用的appName,必须和原生端设置的moduleName完全一致。OpenHarmony适配层对大小写敏感,Android上能容忍的差异在这里可能直接白屏。
第三步尝试切换JS引擎。RN鸿蒙适配版本对Hermes的支持经历了一个过程,如果你的某些依赖或动画库跟Hermes有兼容问题,冷启动阶段会无限卡死。我们最后从Hermes切回JSC,白屏时间明显缩短。这一点不同版本表现不同,建议做A/B对比,不要默认新版一定更好。
第四步是减轻白屏的视觉影响。原生启动页设置和业务背景色接近的颜色,RootView也设背景色,这样即便JS还没就绪,用户看到的也不是刺眼的白屏。还有人在Splash页面里放一个静态Logo,体验会好很多。
如果你一打开应用就白屏且日志里没有任何业务代码输出,优先怀疑bundle路径和入口注册,不用先去排查业务组件。
4.3 OpenHarmony下的几个“小脾气”
在OpenHarmony上调RN,有几个点跟Android差异很大,提前知道能省很多时间。
弹窗层级问题。RN的Modal在OpenHarmony上的层级行为不总是和Android一致,有时遮罩盖不住顶部的状态栏区域,甚至被系统的系统栏盖住。如果多选删除操作要用Modal做二次确认,建议在真机上反复验证一下遮罩层级。
事件时序问题。Pressable的onLongPress在部分OpenHarmony设备上触发偏慢,而且可能被系统的触摸判定打断。如果用户觉得长按进入多选不灵敏,可以直接设置delayLongPress的值为250-350ms,太短容易误触,太长又觉得不跟手。
文件与权限问题。列表条目如果涉及本地文件预览、下载,OpenHarmony的权限模型需要动态申请,不能像旧版Android那样在Manifest声明就完事。文件URI的解析也有差异,在Android上能直接用的路径,到这里要重新做映射。我们有一个FTP下载功能,最初在Android上测试正常,到OpenHarmony上反复踩了文件路径和权限的坑,最后统一下沉到一个原生工具方法里处理。
触控面积问题。平板上一体机上用户的点击精度不如手机触屏,行高如果低于48dp很容易误触。多选模式下勾选的目标区域如果太小,也会被用户吐槽。我最终把整个行作为点击区域,而不是只点checkbox,误触率降了很多。
5. 常见问题与排查技巧
5.1 问题速查表
把这一轮开发的典型问题整理成一个表,可以直接对照排查:
| 现象 | 根因 | 解决方案 |
|---|---|---|
| 勾选一条后列表整体闪烁、卡顿 | renderItem内创建新函数/新对象,React.memo失效 | 抽取Row组件,传稳定回调,selected传布尔值 |
| 删除后操作栏还在,状态没复位 | 只清理selectedIds,没清理editMode | 用一个退出函数统一清理所有状态 |
| 全选按钮判断错误 | 筛选或分页后selectedIds包含不可见id | 每次可见数据变化时剔除不可见id |
| 长按进入多选后仍触发跳转详情 | onLongPress和onPress都执行了 | 在长按回调里标记,onPress里判断后return |
| 冷启动白屏数秒 | bundle路径不对或引擎不兼容 | 检查路径和AppRegistry注册,测试不同JS引擎 |
| keyExtractor告警且勾选错乱 | id存在重复或类型不为string | 保证key唯一且为string,必要时造复合key |
| Modal遮罩盖不住状态栏 | OpenHarmony弹窗层级差异 | 调整自定义statusBarTranslucent,或改用原生弹窗 |
| 列表滚动越来越卡 | 行内图片、富文本对象没有复用 | 在行外统一生成组件,禁止renderItem里new对象 |
5.2 实战中踩过的坑
第一个坑是数据id重复。项目接口里有个字段叫id,看起来唯一,实际有几条数据是重复的,Android上列表居然没有报错,到OpenHarmony真机上出现了勾选一条、两条同时变选中。排查时用keyExtractor的重复告警定位,这才发现是数据问题。后来加了数据校验,并且keyExtractor改用item.id + '_' + item.channel,保底独一无二。
第二个坑是搜索筛选后的选中残留。用户在多选模式下搜索关键词,列表变成筛选结果,但selectedIds里还保留着原列表的选中项。最明显的异常是:操作栏显示“已选3项”,而当前可见列表里只有1条被勾选。这里的关键是不要在筛选时才清理,而是在所有可能改变列表数据的路径上统一处理,比如数据加载完成时做一次setSelectedIds(prev => new Set([...prev].filter(id => newDataIds.has(id))))。
第三个坑是Pressable长按后还会触发onPress。在部分鸿蒙设备上尤其明显。我用的处理方式比较简单:用一个didLongPressRef标记,onPress回调里先检查标记,如果为true就return并重置标记。
const didLongPressRef = useRef(false); const handlePressItem = useCallback((id: string) => { if (didLongPressRef.current) { didLongPressRef.current = false; return; } if (editMode) { toggleSelect(id); } else { openDetail(id); } }, [editMode, toggleSelect, openDetail]); const handleLongPressItem = useCallback((id: string) => { didLongPressRef.current = true; enterEditModeFromPress(id); }, [enterEditModeFromPress]);第四个坑是半选状态的处理。全选框只有“全选”和“取消全选”两个状态是不够的,用户勾了一条后,全选框应该显示为半选,再次点击应该变成全选。所以要把selectionStatus做成'none' | 'partial' | 'all'三态,操作栏里对应切换图标和文案。
第五个坑是操作栏性能。底部操作栏的“已选X项”如果每次selectedIds变化都重新渲染整个列表所在页面,也会拖累性能。操作栏本身是一个独立的组件,只接收count和几个回调,用memo包住,这样计数变化只会更新操作栏,不影响FlatList。
最后再分享一个个人体会。做了这么多版本的多选功能,最值钱的不是某个具体API的用法,而是把“多选”当成一个小状态机来设计:普通模式、多选模式、全选态、半选态、删除后复位,每个状态之间的跳转条件都提前列清楚。后面不管是加侧滑删除、加跨页选择,还是换到其他平台,这套状态逻辑都可以直接复用。在OpenHarmony上踩过的这些坑,说到底也是用一套更清晰的状态设计去对冲平台的不确定性。