ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

React Native鸿蒙双向滚动表格实现:从原理到实战优化

React Native鸿蒙双向滚动表格实现:从原理到实战优化 做移动端项目的人早晚都会碰上这个需求屏幕就那么宽数据列却有十几个如果只是简单把表格塞进界面后面的列直接被截掉用户想看都看不到。我的做法是在React Native开发里引入双向滚动表格——横着能滑、竖着也能滑同时把表头和关键列固定住。这篇文章是“零基础入门React Native鸿蒙跨平台开发”系列的第7篇我会用一套能直接跑通的方案把双向滚动表格从原理到代码再到鸿蒙真机上的坑都过一遍适合刚搭好RN鸿蒙环境、准备写业务表格页面的同学参考。1. 双向滚动表格的需求拆解与方案取舍先别急着写代码。表格这种UI组件需求永远比看上去复杂三层。你以为要的是“能滚动”实际上业务方要的是“能看清、能对齐、能快速定位”。1.1 一个数据表格里其实藏了四个区域一个完整的数据表格页面横向和纵向两个方向滚动时涉及四个独立区域区域位置滚动行为左上角第一行第一列始终固定表头区第一行数据列上方横向跟随数据滚动纵向始终固定锁定列区左侧若干列纵向跟随数据滚动横向始终固定数据区其余行列横向、纵向都可滚动把区域拆清楚之后实现方向就明确了一半。如果只做最简单的“上下左右都能滑”那太容易了难点在于表头要跟着横向走但不跟着纵向走锁定列要跟着纵向走但不跟着横向走两个方向的偏移必须始终保持一致。1.2 三种常见实现方案的对比我在真正动手之前把社区里常见做法都过了一遍各有各的坑方案一外层ScrollView垂直每个行再套一个Horizontal ScrollView这是很多新手第一反应会写出来的方案每行数据用一个横向ScrollView横向滑动。看起来“每行都能横滑”但致命问题在于每一行的横向ScrollView是独立的你滑第一行时第二行不会跟着动表头也不会跟着动。要做到全部行同步就得在每次滚动时给所有行统一发scrollTo指令行数一多别说性能光是状态同步的代码就能把你绕晕。这个方案直接否决。方案二嵌套两个ScrollView外层横向、内层纵向外层是一个横向ScrollView内层放一个纵向ScrollView或FlatList。因为两个滚动方向互相垂直手势不会打架横向滚动天然作用于整张表各行的横向位置自然保持一致。这个方案结构简单、容易理解适合零基础起步。方案三引入社区表格库社区里有react-native-data-table之类封装但大多停留在“能用”阶段对鸿蒙的支持参差不齐而且一旦需要锁定列、自定义单元格、合并单元格这些库反而成了绊脚石。自己写核心结构并不难可控性又高我更推荐自己实现。1.3 本方案为什么选择“横向ScrollView加纵向FlatList”最终我选的是方案二的变体外层横向ScrollView包一个FlatList用FlatList来做纵向滚动。这样有几个明确的好处横向方向由一根ScrollView统一控制各列横向位置天然同步省去大量同步代码。纵向方向交给FlatList自带行虚拟化几十行几百行数据都不会一次性渲染全部单元格。表头和锁定列作为独立区域挂在同一个滚动事件源上做同步逻辑清晰。这个组合是性能和代码复杂度的平衡点。如果你的表格列数不多二三十列以内这套方案跑得很顺后面我会给出针对大列数的优化方向。2. 最小可用原型外层横向滚动容器加内层FlatList先把不带锁定列、不带冻结表头的最简版本写出来目标是让表格先能横竖双向滚起来。这一步走通后面改动都是在它基础上加区域。2.1 一个能跑起来的双滚动表格骨架假设你现在有一份商品列表字段包括ID、产品名称、价格、库存、销量、分类一共六列。列宽合计可能超过手机屏幕宽度所以必须横向滚动。import React, { useCallback } from react; import { View, Text, FlatList, ScrollView, StyleSheet, Dimensions, } from react-native; const SCREEN_WIDTH Dimensions.get(window).width; // 固定列宽 const COL_WIDTHS { id: 70, product: 100, price: 90, stock: 80, sales: 80, category: 90, }; const ROW_HEIGHT 48; // 数据总宽度 所有列宽相加 const TOTAL_WIDTH Object.values(COL_WIDTHS).reduce((sum, w) sum w, 0); const columns [ { key: id, label: ID }, { key: product, label: 产品名 }, { key: price, label: 价格 }, { key: stock, label: 库存 }, { key: sales, label: 销量 }, { key: category, label: 分类 }, ]; const rows Array.from({ length: 300 }, (_, i) ({ id: String(i 1), product: 商品-${i 1}, price: (i * 0.5 10).toFixed(2), stock: Math.floor((i * 7) % 1000), sales: Math.floor((i * 13) % 800), category: i % 4 0 ? 食品 : i % 3 0 ? 数码 : 日用, })); const DataRow React.memo(({ item }: { item: any }) { return ( View style{{ flexDirection: row, width: TOTAL_WIDTH }} {columns.map((col) ( View key{col.key} style{[styles.cell, { width: COL_WIDTHS[col.key], height: ROW_HEIGHT }]} Text style{styles.cellText} numberOfLines{1} {item[col.key]} /Text /View ))} /View ); }); function SimpleTwoWayTable() { const renderItem useCallback(({ item }: { item: any }) { return DataRow item{item} /; }, []); return ( ScrollView horizontal style{{ flex: 1 }} FlatList data{rows} renderItem{renderItem} keyExtractor{(item) item.id} style{{ width: TOTAL_WIDTH }} / /ScrollView ); } const styles StyleSheet.create({ cell: { borderRightWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: #e0e0e0, justifyContent: center, paddingHorizontal: 8, backgroundColor: #ffffff, }, cellText: { fontSize: 13, color: #333333, }, }); export default SimpleTwoWayTable;2.2 三个必须注意的关键点这段代码看起来简单有三个细节非常容易踩坑第一FlatList必须显式设置宽度为数据总宽度。很多人问为什么外层ScrollView滑不动原因就是内层FlatList宽度默认是父容器宽度屏幕宽度内容列虽然画出来了但并没有把FlatList本身的宽度撑大外层横向ScrollView自然没有可滚动的内容。把style{{ width: TOTAL_WIDTH }}加上滚动就通了。第二行高和列宽尽量用固定常量。双向滚动表格里行与行、表头与数据之间的对齐依赖确定的行高和列宽。固定值还能让我们后面用getItemLayout跳过动态测量性能收益很大。如果你确实需要动态行高那表格复杂度会成倍上升零基础阶段不建议碰。第三单元格组件务必用React.memo包裹。用FlatList时每次滚动画布内的行会被反复创建和回收如果不做缓存每个单元格都要重渲染三百行二十列这种规模直接卡到没法用。把DataRow用memo包起来之后数据没变的行不会重复render这是后面性能优化能成立的前提。这段代码放到模拟器或真机上已经是一个真正能双向滚动的表格了。不过业务上肯定不会满足于此表头跟着横向走了但纵向也滚走了左侧的ID列横向也被挤出去了。下一节解决这两个问题。3. 冻结表头与锁定列四区域布局加双轴同步用户对表格的预期通常有三条往下翻数据时表头还在、往右滑数据时ID列还在、左上角那个角落永远在那里。这就要回到第1节里说的四区域布局每个区域各司其职再通过滚动事件把偏移量同步起来。3.1 四区域的布局拆解整个页面我按“两行两列”来组织左上角一个普通View宽等于锁定列宽度高等于表头高度。右上表头一个横向ScrollView里面放一行表头单元格宽度是数据总宽度。左下锁列一个FlatList或者 ListView宽度等于锁定列宽度只渲染锁定列的数据。右下数据区一个横向ScrollView包一个FlatList这是我们第2节写的核心结构。布局上我推荐用View的flexDirection row和flex布局组合千万不要用绝对定位去硬摆不同屏幕尺寸下会错位。3.2 横向同步表头跟手的关键代码表头自己不参与用户手势它接收数据区横向ScrollView的滚动事件然后调用自己的scrollTo方法跟进。这里有几个细节表头的ScrollView设置scrollEnabled{false}不允许用户直接滑表头。数据区的横向ScrollView在onScroll里把contentOffset.x同步给表头。两处都要设置scrollEventThrottle{16}保证事件按帧粒度回调才不会滑起来一顿一顿。const onDataHScroll useCallback((e: any) { const x e.nativeEvent.contentOffset.x; if (Math.abs(x - offsetX.current) 0.5) return; // 防循环 offsetX.current x; headerHScrollRef.current?.scrollTo({ x, animated: false }); }, []);3.3 纵向同步锁定列和数据区互相同步纵向同步比横向多一个方向用户可能在锁定列上往下滑也可能在数据区往下滑两条链路都要通。数据区FlatList的onScroll里把contentOffset.y同步给锁定列FlatList的scrollToOffset。锁定列FlatList的onScroll里把contentOffset.y同步给数据区FlatList的scrollToOffset。一个完整的带冻结表头和锁定列的表格组件代码大致如下import React, { useCallback, useRef } from react; import { View, Text, FlatList, ScrollView, StyleSheet, Dimensions, } from react-native; const SCREEN_WIDTH Dimensions.get(window).width; const HEADER_HEIGHT 44; const ROW_HEIGHT 48; const LOCK_WIDTH 70; const dataColumns [ { key: product, label: 产品名, width: 100 }, { key: price, label: 价格, width: 90 }, { key: stock, label: 库存, width: 80 }, { key: sales, label: 销量, width: 80 }, { key: category, label: 分类, width: 90 }, ]; const DATA_WIDTH dataColumns.reduce((sum, col) sum col.width, 0); const rows Array.from({ length: 300 }, (_, i) ({ id: String(i 1), product: 商品-${i 1}, price: (i * 0.5 10).toFixed(2), stock: Math.floor((i * 7) % 1000), sales: Math.floor((i * 13) % 800), category: i % 4 0 ? 食品 : i % 3 0 ? 数码 : 日用, })); const DataRow React.memo(({ item }: { item: any }) ( View style{{ flexDirection: row, width: DATA_WIDTH }} {dataColumns.map((col) ( View key{col.key} style{[styles.cell, { width: col.width, height: ROW_HEIGHT }]} Text style{styles.cellText} numberOfLines{1}{item[col.key]}/Text /View ))} /View )); const LockRow React.memo(({ item }: { item: any }) ( View style{[styles.lockCell, { width: LOCK_WIDTH, height: ROW_HEIGHT }]} Text style{styles.cellText} numberOfLines{1}{item.id}/Text /View )); const HeaderRow React.memo(() ( View style{{ flexDirection: row, width: DATA_WIDTH }} {dataColumns.map((col) ( View key{col.key} style{[styles.cell, styles.headerCell, { width: col.width, height: HEADER_HEIGHT }]} Text style{styles.headerText}{col.label}/Text /View ))} /View )); function DataTable() { const headerHScrollRef useRefScrollView(null); const lockVListRef useRefFlatList(null); const dataVListRef useRefFlatList(null); const offsetX useRef(0); const lockOffsetY useRef(0); const dataOffsetY useRef(0); const onDataHScroll useCallback((e: any) { const x e.nativeEvent.contentOffset.x; if (Math.abs(x - offsetX.current) 0.5) return; offsetX.current x; headerHScrollRef.current?.scrollTo({ x, animated: false }); }, []); const onDataVScroll useCallback((e: any) { const y e.nativeEvent.contentOffset.y; if (Math.abs(y - dataOffsetY.current) 0.5) return; dataOffsetY.current y; lockVListRef.current?.scrollToOffset({ offset: y, animated: false }); }, []); const onLockVScroll useCallback((e: any) { const y e.nativeEvent.contentOffset.y; if (Math.abs(y - lockOffsetY.current) 0.5) return; lockOffsetY.current y; dataVListRef.current?.scrollToOffset({ offset: y, animated: false }); }, []); const getItemLayout useCallback( (_: any, index: number) ({ length: ROW_HEIGHT, offset: ROW_HEIGHT * index, index, }), [] ); const renderDataRow useCallback(({ item }: { item: any }) { return DataRow item{item} /; }, []); const renderLockRow useCallback(({ item }: { item: any }) { return LockRow item{item} /; }, []); return ( View style{styles.container} {/* 第一行左上角 冻结表头 */} View style{{ flexDirection: row }} View style{[ styles.cell, styles.cornerCell, { width: LOCK_WIDTH, height: HEADER_HEIGHT }, ]} Text style{styles.headerText}序号/Text /View ScrollView ref{headerHScrollRef} horizontal scrollEnabled{false} showsHorizontalScrollIndicator{false} style{{ width: SCREEN_WIDTH - LOCK_WIDTH, height: HEADER_HEIGHT }} HeaderRow / /ScrollView /View {/* 第二行锁定列 数据区 */} View style{{ flex: 1, flexDirection: row }} FlatList ref{lockVListRef} data{rows} renderItem{renderLockRow} keyExtractor{(item) item.id} getItemLayout{getItemLayout} onScroll{onLockVScroll} scrollEventThrottle{16} showsVerticalScrollIndicator{false} bounces{false} overScrollModenever style{{ width: LOCK_WIDTH }} / ScrollView horizontal onScroll{onDataHScroll} scrollEventThrottle{16} showsHorizontalScrollIndicator{false} style{{ width: SCREEN_WIDTH - LOCK_WIDTH }} FlatList ref{dataVListRef} data{rows} renderItem{renderDataRow} keyExtractor{(item) item.id} getItemLayout{getItemLayout} onScroll{onDataVScroll} scrollEventThrottle{16} showsVerticalScrollIndicator{false} bounces{false} overScrollModenever style{{ width: DATA_WIDTH }} / /ScrollView /View /View ); } const styles StyleSheet.create({ container: { flex: 1, backgroundColor: #f5f6fa, }, cell: { borderRightWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: #e0e0e0, justifyContent: center, paddingHorizontal: 8, backgroundColor: #ffffff, }, cellText: { fontSize: 13, color: #333333, }, lockCell: { borderRightWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: #e0e0e0, justifyContent: center, paddingHorizontal: 8, backgroundColor: #fafafa, }, headerCell: { backgroundColor: #f0f2f5, }, headerText: { fontSize: 13, fontWeight: 600, color: #222222, }, cornerCell: { backgroundColor: #e6e9ef, }, }); export default DataTable;3.4 同步循环与回弹的防御两个列表互相调scrollToOffset最怕出现“同步风暴”——A滚动触发B的scrollToB的scrollTo又触发A的onScroll形成循环抖动。实测中React Native的编程式滚动不一定会触发onScroll回调但鸿蒙平台上这个行为并非绝对可靠所以我在每个回调里加了偏移量差值判断如果新的偏移量和上次记录的偏移量差小于0.5直接返回把循环掐断。另外两个列表都要设置bounces{false}和overScrollModenever。在iOS上bounce回弹会让表格顶部出现空洞左右回弹也会破坏表头同步鸿蒙上虽然默认没有iOS那种橡皮筋效果但为了多平台一致最好显式关掉。还有一点锁定列的宽度不要超过屏幕的三分之一否则留给数据区的空间太窄横滑体验很差。锁定列数量能少就少通常只锁ID、序号或者操作按钮列就足够了。4. 把滚动性能从卡顿拉到跟手FlatList调优清单表格组件写完能跑和“跑得丝滑”中间隔着一条性能鸿沟。我第一版在三百行二十列的数据上明显掉帧滚动时能感觉到列表“一格一格”往前跳后来逐步优化才算稳住。4.1 卡顿的根源在哪里双向滚动表格卡顿通常是这几个原因叠加单元格组件没有缓存滚动时整个可视区域的行全部重新render一个单元格就是一个View加Text一屏几十个单元格都是重复劳动。每帧滚动事件里都触发React setState更新状态导致整个表格组件重新渲染。列宽、行高、样式对象在renderItem里临时创建每次渲染都生成新的对象引用memo直接就失效了。FlatList没有提供getItemLayout需要动态测量行高滚动定位本身就慢。滚动时最伤性能的是第一点和第二点第三点是让memo失效的隐藏元凶。4.2 五个立竿见影的优化项第一所有单元格组件用memo包裹renderItem也要稳定引用。我们在第2节的代码里已经做了。注意renderItem本身也要用useCallback包裹否则外层组件每次渲染都会生成新的renderItem函数导致FlatList认为渲染函数变了各行全部重渲染。第二固定行高一定要配getItemLayout。代码里已经写了。getItemLayout能让FlatList在不知道每行具体尺寸的情况下直接通过index * ROW_HEIGHT算出滚动偏移量同步滚动、跳转指定行都毫秒级完成。第三不要在onScroll里调用setState。我同步两个列表偏移用的是ref加scrollTo整个过程中不涉及React状态更新。如果表格上方要显示“当前在第几行”之类的实时信息建议加一个300毫秒的节流或者用Animated.event配合useNativeDriver来做否则滚动流畅度会立刻被打回原形。第四调整FlatList的渲染批次参数。这几个参数按数据量调节适合大多数表格场景参数建议值作用initialNumToRender10首屏渲染行数太小会白屏闪一下maxToRenderPerBatch12每次滚动时最多新渲染的行数太大首屏卡updateCellsBatchingPeriod50批渲染的间隔毫秒数windowSize5可视区域上下各预渲染的行数窗口removeClippedSubviewsAndroid/HarmonyOS可尝试true裁剪屏幕外视图但鸿蒙上要实测其中removeClippedSubviews在鸿蒙上表现不稳定我遇到过开启后偶发“滚动到空白行”的问题。如果出现优先把它关掉换取稳定性。第五把列配置和样式对象提到组件外部。列宽表、行高、边框颜色这些常量定义在组件函数外部作为模块常量不要每次render重新生成。否则传入单元格的props每次都变memo完全失效。4.3 实测数据参考我在一套500行乘20列的数据上做过对比测试设备是某款鸿蒙开发板带触摸屏。未做任何优化时白屏加载大约需要700毫秒滚动时明显掉帧帧率估算掉到20帧左右做了memo、getItemLayout和参数调优之后首屏渲染压缩到300毫秒以内正常滚动基本保持在55到60帧只有快速甩动极限场景下会掉到40帧以下。这个成绩对业务表格来说已经足够用了。如果你的数据量更大比如上万行几十列那单靠这套方案还是有瓶颈。到时候需要做横向的列虚拟化也就是让数据列的渲染范围也跟随横向滚动偏移量动态计算那属于另一个量级的复杂度零基础阶段先把这套跑顺是更务实的选择。5. 鸿蒙真机上的白屏、手势与滚动事件排查React Native开发鸿蒙应用和Android、iOS最大的区别在于底层桥接是额外适配出来的。很多在Android上好好跑的代码鸿蒙真机上就是表现不一样。这一节把我在双向滚动表格开发过程中真实遇到的几个问题列出来按排查思路讲遇到同样情况时可以照方抓药。5.1 启动白屏先分清楚是加载问题还是渲染问题开发阶段启动RN鸿蒙应用白屏是社区里最常见的问题之一。排查思路要分两步走。第一步看console或DevEco Studio的日志输出。如果日志里有类似“Unable to load script”或“bundle download failed”的信息说明是JS Bundle没有加载成功。常见原因是调试模式下的Metro服务地址配置不对或者真机没有访问到开发机的Metro端口。你需要在项目配置里确认Metro的host地址是否指向开发机局域网IP而不是默认的localhost——真机上的localhost指向的是手机自己。第二步如果日志没有明显错误但界面依然白屏启动之后过一个屏直接黑了那多半是原生侧初始化没完成。检查RN鸿蒙的so库、oh-package依赖版本是否和RN核心版本匹配。我在项目里遇到过0.72.5的RN配错鸿蒙适配包版本直接导致启动崩溃。降级到对应的适配版本后白屏就消失了。5.2 滚动事件不触发先检查scrollEventThrottle双滚动表格的一切同步逻辑都依赖onScroll回调。如果你发现锁定列不同步或者表头不跟手先在两个ScrollView和两个FlatList上确认都设置了scrollEventThrottle{16}。RN文档里scrollEventThrottle表示两次onScroll事件之间的最大时间间隔毫秒数单位是毫秒。不设置时平白无故少事件同步就会滞后甚至丢失。这是零基础最容易忽略的一个属性我在鸿蒙上实测过不设的话横向滚动时表头同步至少丢掉三分之一的事件表现就是表头慢半拍、最后还追不齐。5.3 手势被系统抢走横向滑动不跟手鸿蒙系统对返回手势和横滑比较敏感。如果你的表格横滑时明明触摸了但ScrollView就是不响应先考虑是否被页面的侧滑返回手势拦截了。比较快速的验证方法在ScrollView上临时设置scrollEnabled{true}确认不是代码写错之后再检查页面路由容器是否支持配置边缘返回手势区域。鸿蒙的路由页面可以在配置文件或原生代码里调整边缘滑动手势的响应范围把左侧边缘区域让出来数据区就能正常横滑了。另外RN鸿蒙上一些Android属性不保证有效比如directionalLockEnabled这类在iOS文档里写着但Android实现就不一致的属性鸿蒙上更不要依赖。我的建议是能用基本ScrollView属性解决的不要用平台专属属性遇到手势冲突优先从页面级路由配置下手而不是继续往ScrollView上加属性。5.4 Release包白屏bundle资源路径问题调试模式白屏解决了打Release包之后又白屏一般是bundle文件没有正确放进鸿蒙工程的rawfile目录。RN鸿蒙应用在Release模式下会读取打包进原生应用的JS Bundle文件路径和文件名必须和原生侧的配置完全一致。我遇到过文件名对不上导致的加载失败核心就一句话Debug模式走MetroRelease模式走rawfile两种模式的路径校验逻辑不同排查时分别确认。鸿蒙真机上调试还有一个比Android方便的点DevEco Studio里可以直接查看hilog流RN层的console.log在Dev模式下能在Metro终端看到但原生侧的崩溃日志就只能看hilog。遇到白屏建议两边日志一起看哪边先报错就从哪边查起比盲猜快得多。这套双向滚动表格方案从需求拆解到最后落地前前后后调了两周。如果你从一开始就把四区域结构、同步偏移量差值判断、固定行高配getItemLayout这三件事做对后面能省下大量的坑。最后再分享一个我的小习惯任何涉及双列表同步的页面我都会用一个统一的偏移量管理器来管数值而不是在回调里散落一堆ref读写。数据流清晰了后续加列宽拖拽、加固定操作列、做横向虚拟化都会轻松很多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进