ARTICLE DETAIL

资讯详情

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

React Native 鸿蒙开发实战:无限滚动列表从选型到调优

React Native 鸿蒙开发实战:无限滚动列表从选型到调优 把 React Native 搬到鸿蒙上跑已经从“能不能跑”升级成了“跑得顺不顺”。我最近在做一款资讯类 App 的首页信息流核心交互就是无限滚动列表正好把 React Native 鸿蒙跨平台开发这条链路完整走了一遍。从环境搭建、模拟器调试到 FlatList 的 onEndReached 陷阱、ScrollView 手写方案再到 NAPI 原生模块对接整个过程踩了不少坑也沉淀了一套可以直接抄作业的代码指南。这篇文章不打算讲大道理就按我实际动手的顺序来先聊清楚当前 React Native 上鸿蒙的技术路线再把无限滚动效果从选型、实现到调优逐层拆开。无论你只是想快速实现一个“滑到底加载更多”的列表还是打算把已有 RN 工程迁移到鸿蒙设备上这篇都能给你一个可落地的参考。1. 先摸清现状React Native 鸿蒙生态到底走到哪一步了1.1 从“能不能跑”到“跑得顺不顺”很多同学对这个组合的第一反应是React Native 不是安卓/iOS 的跨端方案吗跟鸿蒙有什么关系其实鸿蒙生态早就把 React Native 纳入了一等公民的适配范围。OpenHarmony 社区维护着 react-native-harmony 分支核心思路是把 RN 的 JS 侧逻辑跑在鸿蒙的 JSVM 上UI 层则通过鸿蒙的 ArkUI 原生组件来承载。换句话说你写的 React 组件逻辑、状态管理、业务代码几乎不用动真正要适配的是底层渲染和原生模块调用。我这次项目的真实体验是社区版 RN 在鸿蒙上已经能跑通大多数业务场景信息流、弹窗、表单、网络请求都没问题。但和安卓/iOS 相比有几个地方务必提前知道。第一是第三方原生库没法直接复用凡是依赖 react-native link 或者原生 SDK 的库都要找鸿蒙替代品或者自己封装 NAPI。第二是模拟器只支持 arm64 平台x86 的 Windows 电脑跑模拟器基本无解最稳的方式是真机调试。第三是性能调优要更主动RN 在鸿蒙上的 JS 与原生通信路径跟安卓不完全一致列表类的场景必须显式做渲染优化。这些不是劝退而是让你心里有数。掌握了 RN 在鸿蒙上的边界再实现无限滚动这种高频交互就能少走弯路。1.2 现在做鸿蒙 RN 开发主流是哪几条路线目前实际可用的路线基本有三条。第一条是直接用社区维护的 React Native 鸿蒙分支基于 OpenHarmony 的脚手架自行搭建工程。优点是可控性最强能和官方 RN 主版本保持同步缺点是你需要手动处理鸿蒙工程和 RN 工程的构建关系入门成本略高。第二条是用 Taro 这类跨端框架它们已经支持将产物输出到鸿蒙。如果你原本就是 Taro 的重度用户这条路线最顺。但要注意Taro 跑鸿蒙本质上也是把 React/Vue 语法映射到鸿蒙原生组件和直接写 RN 的运行时还是有差异部分 React Native 专属 API 会受限。第三条是主工程继续用原生 ArkTS某个复杂页面或模块内嵌 React Native 容器。这种混合形态在存量鸿蒙 App 里最常见适合新功能试水。我这次的项目就是这种形态原生壳子负责导航和基础能力信息流页面整体由 RN 承载。选路线时建议先看团队底子。如果你团队本来就是 RN 技术栈第一条最合理能最大化复用现有代码如果是从零开始且未来主要面向鸿蒙可以考虑混合形态。无限滚动这个功能在三条路线上实现原理一致但适配细节会略有差异后面我会重点讲通用方案再补充鸿蒙特有的坑。2. 工程与环境准备模拟器、真机、Metro 一条线跑通2.1 脚手架搭建与目录结构要点我基于 react-native-harmony 的模板工程来初始化。创建完 RN 工程后鸿蒙侧会生成一个独立的 HarmonyOS 工程目录二者通过构建脚本关联。这里最关键的一点RN 侧的 node_modules、Metro 配置、bundle 产物的路径必须在鸿蒙工程里配置正确否则运行时直接报找不到 bundle。初始化完成后先装 pod 依赖然后启动 Metro最后在鸿蒙工程里配置 bundle 入口。如果你是首次玩鸿蒙建议先跑通模板自带的示例页面确认能出 UI 再动业务代码。否则一旦混入自定义原生模块排查问题的维度会指数级增加。我见过不少同事一上来就改原生代码结果连模板页都白屏最后查半天发现是 Metro 端口没开。工程目录建议这样组织js/ 放 RN 业务代码harmony/ 放鸿蒙壳子工程native-modules/ 放自定义 NAPI 模块源码。JS 和原生代码严格分离后面做无限滚动列表时涉及原生能力扩展比如数据库缓存就能立刻定位到文件。2.2 模拟器只能跑 arm64真机调试才是关键热词里反复出现“鸿蒙模拟器目前只能在 arm64 平台运行”这不是空穴来风。受限于 JSVM 的原生库编译架构鸿蒙模拟器目前只支持 arm64 指令集。你的 Windows x86 电脑上跑模拟器大概率会看到“运行设备不兼容”之类的提示。就算用 mac 的 M 系列芯片跑通了模拟器也没有真机上那些传感器、深度相机、多窗口交互列表性能数据参考意义有限。所以我的建议很直接鸿蒙 RN 开发直接上真机。用 DevEco Studio 连接设备后通过 hdc 命令管理应用安装和日志。hdc 的用法和安卓的 adb 很像常用的就是 hdc list targets、hdc file send、hdc shell hilog。调试无限滚动时我习惯用 hdc shell hilog 实时过滤 JS 侧的 console 日志再结合 Metro 的终端输出定位问题。2.3 Metro 与热更新链路调试技巧RN 开发离不开 Metro鸿蒙侧同样支持从 Metro 加载 bundle这样你改 JS 代码后能秒级看到效果。启动 Metro 后在鸿蒙工程里把 bundle 加载模式切换成开发模式App 启动时会主动从本地端口拉取 JS bundle。这里有个非常容易踩的坑鸿蒙 App 默认有网络权限限制如果设备没授权访问开发机端口Metro 连接会一直失败表现就是启动白屏加一行“Unable to load script”。排查思路是先用 hdc 确认 App 进程的网络权限再确认设备能 ping 通开发机 IP。我在真机上遇到过 WiFi 隔了 VLAN 导致端口不通的情况最后通过 hdc 端口转发解决。这个如果不提前处理后续所有功能都没法开发。3. 无限滚动主方案用 FlatList 实现“滑到底自动加载”3.1 为什么首选 FlatList 而不是 ScrollView实现无限滚动第一反应可能是用一个 ScrollView 包住所有数据然后监听滚动位置判断是否到底。数据量小比如几十条这样够用但一旦数据量上千ScrollView 会把所有子组件一次性渲染出来内存和渲染压力直接拉满鸿蒙设备上会明显掉帧。FlatList 的核心特性是“虚拟列表”它只渲染当前窗口附近的数据项滑出一定距离的项目会被回收。无限滚动本身就是为了让用户持续浏览大量数据所以主方案优先选 FlatList。而且 FlatList 自带 onEndReached 回调做分页加载几乎是开箱即用。在鸿蒙的 RN 适配层中FlatList 对应的底层实现会映射到 ArkUI 的滚动容器组件。这个映射有一个需要注意的点由于底层是原生滚动容器存在 JS 侧事件回传的一定延迟。所以在 onEndReached 里不能做太重的同步计算否则会出现“滚动到列表底部后卡了半秒才加载新数据”的体感问题。3.2 分页状态机page、loading、hasMore 缺一不可无限滚动最核心的不是 UI 层而是状态管理。我把它拆成三个状态当前页码 page、加载状态 loading、是否还有更多数据 hasMore。这三个状态必须配合严密否则会出现重复请求或者“永远加载不完”的死循环。第一次进入页面时page 初始化为 1loading 为 falsehasMore 为 true。FlatList 首次渲染后若数据不满一屏onEndReached 会立即触发此时正常加载第二页。如果第一页返回的数据已经不足一页可以把 hasMore 置为 false避免无意义的请求。加载更多时先判断 loading 和 hasMore若正在加载或没有更多数据则直接 return。请求成功后把新数据追加到列表尾部page 自增hasMore 根据本次返回条数更新。这个状态机我贴一段可以直接用的代码const [list, setList] useStateItemType[]([]); const [page, setPage] useState(1); const [loading, setLoading] useState(false); const [hasMore, setHasMore] useState(true); const loadMore useCallback(async () { if (loading || !hasMore) return; setLoading(true); try { const res await fetchList({ page: page 1, pageSize: 20 }); setList(prev [...prev, ...res.list]); setHasMore(res.list.length 0); setPage(prev prev 1); } catch (e) { // 错误统一交给错误提示这里建议保留 hasMore 不变让用户可重试 } finally { setLoading(false); } }, [page, loading, hasMore]); FlatList data{list} renderItem{renderItem} keyExtractor{item String(item.id)} onEndReached{loadMore} onEndReachedThreshold{0.3} ListFooterComponent{() loading ? LoadingIndicator / : hasMore ? null : NoMoreFooter / } /注意 onEndReachedThreshold 的取值。它表示距离底部多远时触发回调0.3 代表列表总长度的 30%。这个值不能太小否则在快速滑动时可能来不及触发也不能太大否则用户还没到底就开始加载。我实测在鸿蒙设备上取 0.3 到 0.5 比较舒服网络慢的场景建议改成 0.5给加载预留时间。3.3 鸿蒙上 onEndReached 的奇怪触发时机安卓和 iOS 上 onEndReached 的触发时机相对稳定但鸿蒙上我发现一个现象列表初次渲染时如果页面布局尚未完全稳定onEndReached 可能会被提前触发一次直接拉取了第二页。这在不经意间会导致首屏请求了两页数据请求量翻倍。排查办法是在 loadMore 里加一个可重入保护用 ref 记录首次渲染完成标志。首次渲染完成且页面没有发生滚动时不触发加载。另一种做法是延迟初始化 FlatList 的 data等首屏布局完成后再从空数组切换为第一页数据。我比较推荐前者代码侵入更小。另外如果 ListFooterComponent 返回了空视图也可能影响 onEndReached 的计算。建议在 footer 中给一个明确高度或者干脆用 null 控制占位不要让 footer 的渲染影响滚动容器的 contentSize 计算。这个坑在鸿蒙的 ArkUI 容器上更容易出现因为 footer 的高度计算链路和 RN 主分支略有差异。4. 如果 FlatList 不满足需求ScrollView 手写无限滚动4.1 onScroll 阈值判断与节流有些场景不能直接用 FlatList比如需要自由排列的宫格瀑布流或者列表项高度差异极大且需要动态测量的场景。这时候手写 ScrollView 监听方案反而是更稳的选择。核心思路是监听 onScroll 事件取出 contentOffset.y、layoutMeasurement.height、contentSize.height 三个值。当 contentOffset.y 加上可视高度已经接近内容总高度时说明用户滑到底部触发加载更多。我写了一个通用判断const handleScroll ({ nativeEvent }: NativeSyntheticEventNativeScrollEvent) { const { layoutMeasurement, contentOffset, contentSize } nativeEvent; const distanceFromBottom contentSize.height - (contentOffset.y layoutMeasurement.height); if (distanceFromBottom 200) { loadMore(); } }; ScrollView onScroll{handleScroll} scrollEventThrottle{16} {children} /ScrollViewscrollEventThrottle 很关键它控制滚动事件的触发频率。设为 16 表示大约每 16 毫秒触发一次接近 60FPS。如果不设默认可能会以更高频率触发导致 JS 侧频繁计算滚动卡顿设得太低比如 100又会导致滚动快时漏判。鸿蒙的 ArkUI 滚动事件回传链路比安卓稍长我建议保持 16。4.2 用 requestAnimationFrame 做防抖避免重复触发onScroll 是高频事件用户在底部区域来回滑动时handleScroll 可能被连续触发十几次。如果每一次都去请求接口就会产生大量重复请求。最简单的方式是加一个时间窗口或者用一个 ref 记录“是否已经在加载中”跟 FlatList 方案的 loading 判断同理。我习惯再叠加一层 requestAnimationFrame 节流。具体做法是在 handleScroll 里用 rAF 标记一个待执行任务如果上一次 rAF 还没有执行就不重复安排。这样即使 onScroll 触发频率再高实际业务判断也只会每帧执行一次既可靠又省电。代码如下const rafRef useRefnumber | null(null); const handleScroll (e) { if (rafRef.current ! null) return; rafRef.current requestAnimationFrame(() { rafRef.current null; const { layoutMeasurement, contentOffset, contentSize } e.nativeEvent; if (contentSize.height - (contentOffset.y layoutMeasurement.height) 200) { loadMore(); } }); };这里要注意rAF 的回调里拿到的 nativeEvent 是 e 上的引用不要直接在外部重新取事件对象否则某些 RN 版本会报“Attempted to access nativeEvent after it was released”之类的错误。4.3 滚动加载的 UI 反馈与占位管理手动管理 ScrollView 时所有反馈元素都得自己拼。我的做法是在 children 尾部追加一个加载状态组件loading 时显示转圈没有更多数据时显示“已经到底了”。这个组件不要用绝对定位直接放在内容流后面否则会影响 contentSize 的判断。很多新手会犯一个错误在加载更多时把 loading 状态塞进现有列表的某个位置导致内容跳动。正确做法是让 loading 组件固定占位高度例如 60 像素左右。加载完成后数据追加到尾部loading 组件消失页面滚动位置不会发生明显的视觉跳变。这个细节非常影响体验尤其是用户正好停在底部时如果页面突然被压缩视觉跳动会非常明显。5. 鸿蒙原生模块边界从 JS 到 NAPI你迟早会碰到5.1 纯 JS 实现无限滚动够不够用大部分信息流场景纯 JS 的 FlatList 方案已经够用。但无限滚动做到后面一定会遇到性能和数据持久化问题。比如用户飞速滑动时从网络拉取新数据的速度跟不上列表出现短暂的空白区或者 App 被杀后用户希望恢复上次浏览的位置和数据。这些问题光靠 JS 层很难优雅解决。网络请求可以在 JS 层加缓存但磁盘缓存、数据库存储、甚至本地文件缓存这些能力都需要通过鸿蒙的原生接口来实现。RN 在鸿蒙上要调用原生能力一般通过 NAPI 完成。NAPINative API是鸿蒙提供的 C/C 接口层你可以用它在 JS 和原生代码之间搭一座桥。5.2 har 封装 so 库一次封装处处调用在鸿蒙工程里如果你需要把一段 C/C 代码暴露给 RN 的 JS 侧调用通常流程是用 DevEco Studio 创建 har 模块内部封装 NAPI 接口然后编译产物是 .so 文件。RN 侧只需要在工程配置里声明这个 har 依赖就能通过一个全局对象调用原生方法。我做无限滚动时用原生模块做了一个轻量级的本地分页缓存把已加载的页面数据按页序列化后写入本地文件下次启动时先读缓存再请求网络。这个缓存逻辑如果用 JS 层实现读写文件会受限于 Node.js 核心模块的裁剪很多模块在 RN 鸿蒙环境下并不可用。通过 NAPI 暴露一个简单的 readCache(page) 和 writeCache(page, data) 接口JS 侧调用干净利落。5.3 NAPI 调用时的数据序列化与线程安全NAPI 调用不是免费的每次跨语言调用都有序列化开销。无限滚动场景里如果每条数据都单独调一次原生方法性能会非常难看。我的做法是批量传递把一页的数据组装成一个大数组一次传给原生侧原生侧统一写入。反过来读取缓存时也是一次性返回整页数据。另外要注意线程问题。NAPI 接口默认运行在调用线程但如果你的原生实现里启动了子线程做 IO必须在子线程里通过 napi_create_async_work 把结果回传到 JS 线程。这个坑我踩过表现为偶尔数据不刷新用 hilog 看日志才发现原生侧报线程相关错误。解决办法是给 NAPI 模块加一个 async work 的模板所有耗时操作都走异步队列确保 JS 侧拿到结果时一定在主线程。6. 常见问题与排查实录从白屏到卡顿逐个击破6.1 React Native 启动白屏问题热词里“react native 启动白屏”出现频率很高确实也是鸿蒙 RN 开发遇到最多的现象。白屏的本质是 JS bundle 没有成功加载。排查路径一般分三层第一层确认 Metro 是否开着第二层确认鸿蒙工程能否访问 Metro 端口第三层确认 bundle 加载入口配置是否正确。如果你用的是真机建议先用 hdc 看看设备的 IP 是否能 ping 通开发机再用 hdc 转发端口绕过网络限制。白屏时 Metro 终端通常会有日志输出看到 “Running application xxx” 就说明连接已经建立。如果连 Metro 的日志都没有问题大概率在网络层而不是 RN 本身。6.2 列表滚动卡顿与掉帧无限滚动列表最容易暴露性能问题。卡顿的常见原因有三个列表项组件太复杂、每次渲染的数据量太大、影子树更新过于频繁。列表项组件太复杂时需要把图片懒加载、圆角裁剪、阴影效果能省则省。鸿蒙的 ArkUI 渲染阴影的代价比安卓高能截图替代就不要用动态阴影。数据量方面检查 FlatList 的 initialNumToRender 和 maxToRenderPerBatch 参数首屏渲染项数建议控制在 10 到 15 个后续批量渲染控制在 10 个以内避免一次性渲染过多导致掉帧。6.3 重复请求与 loading 闪烁重复请求的根源是 loadMore 没有做好防重入。除了用 loading 状态判断还建议用 ref 记录“是否正在请求”避免状态更新异步导致的老值判断。loading 闪烁通常是因为加载完成太快footer 的 loading 组件一闪而过。体验优化方案是在 onEndReached 触发后强制让 loading 组件至少显示 300 毫秒避免视觉闪烁也降低用户感知焦虑。6.4 内存持续上涨无限滚动最怕内存爆炸。FlatList 虽然虚拟化后只渲染可见项但如果你在 renderItem 里创建了过多的闭包、图片没走缓存、或者列表项 key 不稳定都会导致内存只增不减。建议用 keyExtractor 返回 id 字符串图片组件显式使用 resizeMethod 和 cache 属性。在鸿蒙原生侧注意图片解码的内存占用大图要先压缩再展示。另外一个容易忽略的点是onEndReached 触发的接口返回数据里不要把所有字段都塞进 state。只保留渲染需要的字段比如 id、title、cover、summary其他元数据直接丢弃。这样能显著减少 JS 对象占用的堆内存以及原生侧序列化的开销。6.5 快速问题排查速查表现象可能原因排查与解法启动白屏Metro 未启动或端口不通启动 Metro检查 hdc 端口转发查看 Metro 日志确认连接首屏立即加载多页onEndReached 初始误触发在 onEndReached 里增加首次渲染完成标记滑到底不加载onEndReachedThreshold 太小调大阈值到 0.3~0.5确认 bottom padding 是否存在列表卡顿列表项渲染过重、批量渲染过多减少 initialNumToRender优化 renderItem 复杂度重复请求loading 状态判断过期用 ref 记录请求状态叠加 rAF 节流内存持续上涨图片缓存缺失、数据字段冗余压缩图片只保留必要字段使用 getItemLayout原生模块调用无响应NAPI 线程未回主线程用 async work 封装耗时操作确保回调在主线程在无限滚动这个功能上React Native 结合鸿蒙开发已经不是一个玩具级的组合而是可以认真落地的方案。只要把环境链路、虚拟列表、状态机、原生模块边界这几个环节吃透你完全可以在鸿蒙设备上做出一款信息流体验顺滑的跨平台应用。最后再分享一个小技巧调试无限滚动时强烈建议在开发模式下打开鸿蒙自带的性能分析工具实时查看滚动帧率和内存曲线。很多卡顿问题不是在代码 review 时发现的而是在性能录制过程中一眼看出来的。
返回列表