最近在做 React Native 应用往鸿蒙系统迁移时,碰到一个特别有代表性的需求:主 Feed 列表,第一屏加载 20 条,滚动到底部自动拉取下一页,同时支持下拉刷新。这套逻辑在 iOS 和 Android 的 React Native 上已经是成熟方案,但迁移到鸿蒙版 React Native 后,列表组件底层桥接方式不一样了,数据请求的时序也变得更敏感,曾经“能用就行”的分页写法直接暴露出一堆问题。这篇文章把我实际踩坑和最终落地的方案完整记录下来,核心就是用 React Query 的 useInfiniteQuery 来管理无限滚动的数据流,配合 FlatList 完成 UI 层联动。
文章会覆盖几个部分:为什么鸿蒙上的无限滚动不能简单照搬老代码、工程落地前必须搞清楚的环境问题、useInfiniteQuery 的数据层设计思路、一组可以直接拿走的完整实现代码,以及我在鸿蒙真机和模拟器上实测遇到的几个坑。适合正在做 RN 鸿蒙适配、或者在调研鸿蒙开发方案的团队参考。
1. 为什么说鸿蒙上的无限滚动不是简单搬一个 FlatList
很多同学第一反应是:无限滚动不就是 FlatList 加 onEndReached 再加一个页码 state 吗?这套东西在 Android 上跑了两年都没事,换个平台怎么就不行了?实际上无限滚动的完整链路远不止 UI 层的“滚动到底部触发一次回调”,它是一条从数据获取、状态管理到 UI 渲染的闭环。
1.1 无限滚动的本质是一条数据链路
拆开来看,一次完整的无限滚动交互至少包含四个环节:
- 数据获取:根据当前页码或游标向服务端请求一页数据,同时还要知道还有没有下一页。
- 状态管理:维护加载中、加载成功、加载失败、刷新中、加载更多中、没有更多数据、空数据等一组状态。
- UI 触发:列表滚动接近底部时触发加载,下拉时触发刷新,加载更多时在底部展示 loading。
- 渲染层:把内存中累积的所有页数据扁平化后交给列表渲染,同时控制虚拟化参数避免长列表卡顿。
这四个环节在 iOS 和 Android 的 React Native 上可以靠成熟的社区方案盖住,但鸿蒙版 RN 的问题在于:FlatList 不再走原来那套 VirtualizedList 的完整实现,而是桥接到鸿蒙原生的列表容器上,网络请求语义、事件回调时机、JS 与原生侧通信的效率全都和原来不一样。任何一个环节出问题,都会表现为“滚动不到底、重复请求、数据错乱、白屏”。
1.2 鸿蒙 RN 版的问题出在“链路分层”
我在排查过程中一个很深的体会是:鸿蒙版 React Native 不是什么全新的东西,它是把 RN 的 JS 层保留,把原来的 Android/iOS 原生实现替换成鸿蒙的 ArkUI 组件和系统能力。这带来一个结果——JS 层的 API 看着和原版一样,但组件行为细节有大量差异。
举一个最典型的例子:FlatList 的 onEndReached 在 Android 上非常灵敏,滚动条只要进入阈值范围立刻触发;但在鸿蒙的某些版本上,这个回调的触发存在明显的“滞后”,如果你用手头的页码 state 去控制是否发起请求,很容易出现快速滑动时只触发一次、停下来又突然连发两次的情况。这个我在第 5 章会细讲。
还有一点,鸿蒙 RN 的网络层默认走的是鸿蒙系统的网络能力,虽然暴露出来的还是 fetch 接口,但在 TLS 握手、DNS 解析、并发连接数上的行为和标准 Android 不完全一致。如果列表分页接口本身的响应时间不稳定,前端又在每次滚动到底部时机械地加一页,最终表现就是列表停在某个位置一直转圈。
1.3 为什么少有人聊这块
说实话,鸿蒙版 RN 的中文资料目前还处在一个很尴尬的阶段:官方文档能保证你把 Hello World 跑起来,但社区里深入讲“某个业务组件在鸿蒙上的适配差异”的内容很少。原因也简单,真正把大型 RN 应用完整迁移到鸿蒙的团队本来就少,迁移完又愿意把过程中的坑写出来的更少。
所以我才决定把这次实践的完整链路写出来。项目最终其实不是“把老代码搬过来”,而是借这个机会把数据层重构了一遍,用 React Query 统一管理分页、缓存、刷新和加载更多逻辑。这样即使鸿蒙端列表组件某个版本行为有变化,需要动的也只是 UI 层的一小段代码,数据层是稳定的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 鸿蒙 RN 工程落地前,先把这三个前置门槛踩平
如果你是从零开始搭鸿蒙 RN 工程,建议先别急着写无限滚动,把下面三件事确认好。这里每一件我都实际卡过,顺序也按照“从依赖到运行时再到打包产物”来排。
2.1 react-native-harmony 依赖安装与版本锁定
鸿蒙版 React Native 不是用 npm 上那个官方 react-native 包直接跑起来的,而是需要引入 OpenHarmony SIG 组维护的 react-native-harmony 仓库。这个仓库会提供一套鸿蒙原生工程模板,以及对应的 JS 侧补丁包。
比较稳妥的做法是:
- 先确定鸿蒙 SDK 版本(API 10 还是 API 12),再选择对应版本的 react-native-harmony 分支。
- 在工程根目录通过
ohpm安装鸿蒙原生侧依赖,而不是npm install。 - JS 侧依然通过 npm 安装 @react-native-community/cli 等常规依赖,但需要按照官方模板里的版本约束锁定 react-native 版本,不要轻易升级 minor 版本。
这里最重要的一件事是锁版本。我见过一个项目因为 react-native 从 0.72 升到 0.73,鸿蒙原生侧的包没有同步升级,结果所有组件都渲染不出来,也没有明显报错,最后排查了一整天才发现是版本不匹配。
2.2 初始化白屏:最容易误判成代码问题的第一道坎
热搜词里“react native 启动白屏”几乎成了每个人入坑鸿蒙 RN 的第一课。这个白屏和 Android 上常见的白屏原因类似,但在鸿蒙上有个额外因素:鸿蒙原生容器启动、JS 引擎初始化和远端 bundle 加载是串行完成的,任何一个环节慢,白屏时间都会非常明显。
我在做 Feed 列表时就遇到过一次:首屏进入后白屏 3 到 4 秒,然后列表一次性出现。一开始以为是分页接口慢,后来发现是因为开发模式下连的是 Metro 的 bundle 地址,鸿蒙原生侧每次启动都会等待 bundle 拉取完成,期间没有给用户任何反馈。
排查方法其实很简单,分三步:
- 先在真机上用 release 包测试,排除 Metro 热更新链路的影响。
- 在 JS 入口最顶部加一条日志,确认 JS 引擎何时开始执行。
- 在鸿蒙侧查看启动日志里 bundle 加载完成的时间点,定位耗时集中在原生容器还是网络拉取。
解决白屏的通用做法是在原生侧先放一张启动图,或者用 ArkUI 的 Stack 布局叠一个加载占位,等 JS 侧首帧渲染完成后再隐藏。这个和无限滚动的关系在于:如果白屏期没有占位,用户会以为页面死了,而实际上列表正在默默加载第一页数据,等数据回来后又因为 onContentSizeChange 触发的问题导致首屏只有半屏内容。
2.3 打包产物与动态能力:hap/hsp/har 对落地的影响
另一个绕不开的概念是鸿蒙的三种包格式:hap 是可安装的应用包,hsp 是动态共享包,har 是静态共享包。做 RN 鸿蒙应用时,JS bundle 最终会被打包进 hap 的 assets 目录,但如果你有多个业务模块,可以拆成多个 hsp 动态加载,har 则类似本地库。
对无限滚动这个功能来说,打包格式本身不直接影响实现,但会影响启动和首屏性能:如果所有 JS 代码都打到一个 hap 里,bundle 体积可能膨胀到好几 MB,首屏解析执行时间明显变长,白屏期随之拉长。我的建议是至少把基础库(react-native、react、@tanstack/react-query)和业务页面拆成两个 hsp 或 har 包,让首屏只加载必要代码。
3. 数据层重构:用 useInfiniteQuery 收起所有分页状态
在开始写 UI 之前,先聊聊为什么最终选择 useInfiniteQuery 而不是自己维护一堆 state。这不是赶时髦,而是因为手写分页状态在鸿蒙端更容易被触发问题的组合搞崩。
3.1 手写分页为什么在鸿蒙上更容易崩
一开始我沿用老项目的写法:page、refreshing、loadingMore、hasMore、error、list 六个 useState 或者 useReducer 管理。在 Android 上跑着没问题,但鸿蒙端 onEndReached 触发时序不稳,导致 loadingMore 和 refreshing 同时为 true、或者 refetch 跟 loadMore 并发修改同一个 list 的情况开始出现。
举一个实际的竞态例子:用户下拉刷新触发 refreshing = true,同时列表滚动到底部又触发了 loadingMore = true,两个异步请求都发出去了。Android 下由于回调顺序稳定,最终 list 会被覆盖成最后一次返回的数据;鸿蒙下两个回调的完成顺序不确定,就可能出现刷新后的新数据被更早发出的分页请求结果覆盖,列表内容倒退到几秒前。
这种问题用手写状态去解决当然可以,但需要引入请求序号、AbortController、状态锁等一系列机制,代码复杂度会成倍上升。而 useInfiniteQuery 本身就把这类竞态问题处理掉了:它内部会跟踪 queryKey、请求状态、取消语义,UI 层拿到的永远是最新的数据快照,不需要你手动加锁。
3.2 useInfiniteQuery 的参数模型
useInfiniteQuery 的核心模型可以理解为:一个函数根据上一次请求结果决定下一次请求的参数,然后把所有页的结果累积在同一个数据结构里。
我用一个典型的分页接口来拆解参数:
typescript复制const feedQuery = useInfiniteQuery({
queryKey: ['feed', 'home'],
queryFn: async ({ pageParam }) => {
const res = await request<FeedPage>({
url: '/api/feed',
params: {
page: pageParam,
pageSize: 20,
},
});
return res;
},
initialPageParam: 1,
getNextPageParam: (lastPage) => {
if (lastPage.page >= lastPage.totalPage) {
return undefined;
}
return lastPage.page + 1;
},
});
这里几个参数逐个说:
- queryKey 是这组查询的唯一标识。当 queryKey 变化时,整个查询会被重置,这在下拉切换 Tab、登录状态切换时特别有用。
- queryFn 接收一个参数对象,其中 pageParam 就是当前请求要用的页码或游标。第一页的参数由 initialPageParam 提供,后续页参数由 getNextPageParam 决定。
- getNextPageParam 接收上一次请求返回的 lastPage,返回下一次要用的参数;如果返回 undefined,React Query 就认为没有更多数据了。
这个模型把“页码+ 有没有下一页”这两个最容易写乱的逻辑,封装成了两个纯函数,非常容易测试。
3.3 返回值与状态管理:把“结果”交给 UI
useInfiniteQuery 的返回值里,最常用的几个是:
- data.pages:一个数组,每一页对应一次请求的完整返回值。
- data.pageParams:每页实际使用的参数,调试时很有用。
- fetchNextPage:手动触发下一页请求。
- hasNextPage:getNextPageParam 是否返回了 undefined,对应“是否还有更多”。
- isFetchingNextPage:下一页请求是否在途,用来控制底部 loading。
- isPending:第一次数据还没回来。
- isError:请求出错。
- refetch:手动重新加载全部数据。
用这个模型之后,UI 层彻底不用关心“现在在第几页”“上一页的数据还在不在”,它只需要知道:给我所有页的数据、告诉我还有没有下一页、告诉我当前是个什么状态。这个分层思想在鸿蒙上特别受用,因为列表组件的行为差异只集中在 UI 层,数据层是跨平台一致的。
4. FlatList 与查询状态联动:可直接抄的完整实现
下面给出一段完整的核心代码,涵盖数据扁平化、FlatList 绑定、下拉刷新、底部加载、空态和错误态。这段代码已经在鸿蒙真机上验证过,你替换成自己的接口和数据模型后可以直接跑。
4.1 页面数据结构与数据扁平化
接口返回的数据结构建议统一成这样:
typescript复制interface FeedPage {
page: number;
totalPage: number;
list: FeedItem[];
}
interface FeedItem {
id: string;
title: string;
cover: string;
description: string;
}
FlatList 的 data 属性需要一个一维数组,所以要把 data.pages 里的所有 list 拼起来。注意不要在 renderItem 里做扁平化,否则每次渲染都会重新计算:
typescript复制const allItems = useMemo(() => {
return feedQuery.data?.pages.flatMap((page) => page.list) ?? [];
}, [feedQuery.data]);
keyExtractor 一定要给稳定且唯一的 key,否则列表更新时会频繁重渲染。如果数据里的 id 不够唯一,可以组合生成:
typescript复制const keyExtractor = (item: FeedItem) => `${item.id}`;
4.2 核心组件绑定
把 useInfiniteQuery 和 FlatList 绑定起来,代码很直观:
tsx复制import React from 'react';
import { FlatList, ActivityIndicator, Text, View, Pressable } from 'react-native';
import { useInfiniteQuery } from '@tanstack/react-query';
export function FeedList() {
const feedQuery = useInfiniteQuery({
queryKey: ['feed', 'home'],
queryFn: async ({ pageParam }) => {
const res = await request<FeedPage>({
url: '/api/feed',
params: { page: pageParam, pageSize: 20 },
});
return res;
},
initialPageParam: 1,
getNextPageParam: (lastPage) => {
return lastPage.page >= lastPage.totalPage ? undefined : lastPage.page + 1;
},
});
const allItems = React.useMemo(
() => feedQuery.data?.pages.flatMap((page) => page.list) ?? [],
[feedQuery.data]
);
const loadMore = () => {
if (feedQuery.hasNextPage && !feedQuery.isFetchingNextPage) {
feedQuery.fetchNextPage();
}
};
if (feedQuery.isPending) {
return <FeedSkeleton />;
}
if (feedQuery.isError) {
return (
<ErrorView
onRetry={() => feedQuery.refetch()}
/>
);
}
return (
<FlatList
data={allItems}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <FeedCard data={item} />}
onEndReached={loadMore}
onEndReachedThreshold={0.3}
onRefresh={() => feedQuery.refetch()}
refreshing={feedQuery.isRefetching}
ListEmptyComponent={<EmptyView />}
ListFooterComponent={
feedQuery.isFetchingNextPage ? <FooterLoading /> : null
}
/>
);
}
这里的 onEndReachedThreshold 我用了 0.3,表示滚动位置距底部还剩列表可视区域 30% 高度时触发。具体值可以根据页面实际情况调整,鸿蒙上建议不要小于 0.2,太小的阈值会导致快速滚动时回调还没来得及触发就被用户滑过去了。
4.3 首屏不足一屏的场景兜底
有一个场景容易漏掉:第一页数据返回后,如果不足一屏(比如只有 3 条数据),FlatList 的 onEndReached 可能根本不触发,用户看到列表只有 3 条,以为加载完了或者接口有问题,但其实后面还有数据。
解决办法是通过 onContentSizeChange 判断内容高度是否小于容器高度,如果不足就自动触发加载更多:
tsx复制const onContentSizeChange = () => {
if (
feedQuery.hasNextPage &&
!feedQuery.isFetchingNextPage &&
!feedQuery.isRefetching
) {
feedQuery.fetchNextPage();
}
};
这段代码加进去后,第一次加载如果不满一屏会自动连续拉取,直到填满屏幕或者没有更多数据,体验会好很多。
4.4 四态 UI:加载中、错误、空、加载更多
列表页的状态可以分成四类,UI 上要分别处理:
| 状态 | 判断条件 | UI 表现 |
|---|---|---|
| 首次加载中 | isPending 为 true | 骨架屏或居中 ActivityIndicator |
| 请求失败 | isError 为 true | 错误提示 + 重试按钮 |
| 空数据 | allItems.length 为 0 | 空态占位图 + 提示文案 |
| 加载更多中 | isFetchingNextPage 为 true | 底部 8 到 12 像素高的 loading 条 |
很多人把空态和错误态合并处理,这是不对的。空态是一种正常业务状态,错误态是异常状态,用户看到空态去操作不会触发重试,看到错误态则需要一个明确的“点击重试”入口。
5. 鸿蒙实测中的性能与兼容坑
代码层面跑通之后,真正的挑战才开始。鸿蒙的模拟器和真机上,我把这版无限滚动做了多轮验证,下面这几个坑最值得记录。
5.1 onEndReached 触发时机的偏差
鸿蒙版本上,onEndReached 有两个比较明显的行为差异:
一是触发偏晚。在快速滚动时,手势已经滑到底部,但 onEndReached 要过几百毫秒才触发,用户会看到列表停在底部但内容没有续上,产生“卡住”的错觉。解决方法是适当调大 onEndReachedThreshold,我最终调到 0.5 才在真机上获得接近 Android 的体验。
二是偶发连触。停在底部不动时,onEndReached 在某些场景会连续触发两次。写法上如果不加保护,就会发出两个相同的分页请求。React Query 的 fetchNextPage 本身会做一定去重,但 UI 层的状态判断仍然要保留:
tsx复制const loadMore = () => {
if (feedQuery.hasNextPage && !feedQuery.isFetchingNextPage) {
feedQuery.fetchNextPage();
}
};
这个双重保护不可省掉,尤其是接口响应较慢时,防重复请求的第一道防线就是这行简单的布尔判断。
5.2 快速滚动与重复请求防护
还有一类重复请求来自“滚动太快,onEndReached 多次触发”和“下拉刷新与加载更多并发”。前面提到 useInfiniteQuery 能在数据层挡住一部分,但网络层仍然需要服务端配合做幂等,因为同一个 pageParam 的请求可能真的已经发出去了。
客户端还能做的一件事是设置较长的 staleTime,让 React Query 在短时间内对相同参数的请求直接返回缓存数据:
typescript复制const feedQuery = useInfiniteQuery({
// ...其他参数
staleTime: 30 * 1000,
});
上面这个 30 秒的 staleTime 意味着:30 秒内重复请求同一页数据时,React Query 会直接返回缓存,不会真的打到服务端。这个配置要结合业务场景调,频控类需求可以压到 5 秒,纯 Feed 流建议 30 秒到 1 分钟。
5.3 长列表内存与虚拟化参数
无限滚动的数据会不断累积,如果不做控制,内存占用会线性增长。FlatList 本身自带虚拟化,默认 windowSize 是 21,也就是会渲染当前可视区域前后共 21 屏的内容。
鸿蒙上我实测发现,windowSize 保持默认值比较稳,手动调小到 5 以下虽然能降低内存,但快速滚动时会出现白屏闪烁。更值得做的是用 maxPages 参数限制 React Query 在内存里保留的页数:
typescript复制const feedQuery = useInfiniteQuery({
// ...其他参数
maxPages: 10,
});
maxPages 设为 10 意味着只保留最近 10 页的 data,更早的数据会被丢弃。注意这和 UI 上的列表累积是两回事:data 里没有的数据,FlatList 无法展示。所以 maxPages 只适合“不需要回翻到很久之前”的 Feed 流场景,如果你的页面支持跳转到第一页后逐屏回看,就不要设置这个参数。
另外 removeClippedSubviews 在鸿蒙上建议谨慎开启,部分场景下会导致快速滑动时出现空白项。真机上实测关闭该属性反而更平滑,原因可能是鸿蒙原生容器的回收机制和 Android 不完全一样。
5.4 缓存持久化配置建议
最后提一下 React Query 的缓存持久化。无限滚动已经把数据放在了内存里,如果配合 persistQueryClient 持久化到本地存储,用户下次打开 App 时可以立即看到上一次加载过的列表,不用等网络请求回来,首屏体验会有非常明显的提升。
不过持久化在鸿蒙上要注意一点:本地存储的 IO 速度和 Android 有差异,数据量大的时候同步读盘会阻塞 UI。建议用异步持久化方案,并且只缓存数据快照,不要缓存庞大的图片 base64 或者视频相关数据。
typescript复制import { persistQueryClient } from '@tanstack/react-query-persist-client';
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
import AsyncStorage from '@react-native-async-storage/async-storage';
const asyncStoragePersister = createAsyncStoragePersister({
storage: AsyncStorage,
key: 'rn-query-cache',
throttleTime: 3000,
});
await persistQueryClient({
queryClient,
persister: asyncStoragePersister,
maxAge: 24 * 60 * 60 * 1000,
});
这里 throttleTime 设为 3000 毫秒,意思是 3 秒内多次缓存写入会合并成一次,降低鸿蒙端 LocalStorage 的写入压力。
在实际调试这段功能的过程中,我最大的感触是:鸿蒙版 React Native 的应用场景已经越来越清晰,它并不是要把 iOS 或 Android 的代码原封不动搬过去,而是需要针对鸿蒙的组件行为和运行时特性做一次分层重构。数据层用 React Query 这类跨平台状态方案,UI 层再针对鸿蒙做适配,这样后续鸿蒙系统升级、RN 适配层更新时,业务代码受影响的范围可以控制到最小。
最后再分享一个小技巧:把 useInfiniteQuery 的配置做一层封装,统一处理分页参数解析、错误码转换、pageParam 拼接,业务页面只需要传一个 queryKey 和请求函数。这样团队里每个人写的列表页都是同一套逻辑,排查问题只看一个文件就够了。这个思路在你接手的项目需要迁移多个列表页时,能省下大把时间。
