news 2026/9/9 1:41:38

React Native on OpenHarmony:自定义useList实现列表加载与竞态处理

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
React Native on OpenHarmony:自定义useList实现列表加载与竞态处理

React Native跑在OpenHarmony上,放在两年前还是不太敢想的事,现在却已经成了很多团队的现实选项。公司现有的RN代码库想快速覆盖鸿蒙生态,又不想再养一支ArkTS原生团队,这类诉求在我接触的项目里越来越常见。列表页又是移动应用里最基础也最高频的交互形态,所以我干脆把列表的加载、刷新、分页、竞态处理全部收口到一个自定义useList里,在这套RN on OpenHarmony的开发模式下直接复用。这篇文章就把这个Hook从设计思路到完整实现,再到大屏设备联调阶段遇到的坑,一次性讲透。适合正在用React Native开发OpenHarmony应用、或者正准备把老RN工程往鸿蒙方向迁移的团队参考。

1. 为什么在OpenHarmony上做应用,还要用React Native?

1.1 这套组合解决了什么问题

很多团队面对OpenHarmony的第一反应,是“要不要全部用ArkTS重写”。但现实情况往往是:业务压力不允许,人力也不允许。尤其是那些已经沉淀了好几年的RN代码库,里面不只是页面,还有一套完整的业务逻辑、网络层封装、埋点体系和组件库,全部推倒重来,周期按季度算都不够。

React Native on OpenHarmony的价值在于,它让前端团队已经积累的跨端能力直接延伸到了鸿蒙生态里。也就是说,同一套JS业务代码,Android、iOS、OpenHarmony三端共用,只有真正涉及系统能力的部分才需要通过原生模块去适配。对我个人来说,这套方案最大的优势不是“性能极致”,而是“业务覆盖率提升得极快”。你能在很短的时间内让一个功能稳定的版本跑在新的系统上,先验证市场反馈,再决定哪些页面值得花力气做原生优化。

你也不用担心被某个特定框架绑死。从架构上看,RN在OpenHarmony上的运行方式和其他平台没有本质区别:JS引擎负责执行业务逻辑,原生侧负责渲染和系统能力调用,中间通过一套桥接协议通信。业务代码里使用的组件库、状态管理方案、网络库,只要不涉及平台强相关能力,基本都能平滑迁移。这也是我敢在公司主推这套方案的原因——它不是一个临时的hack,而是一条可长期演进的技术路线。

1.2 FlatList够用,但成规模的列表页远远不够

有人可能会问,FlatList自带下拉刷新和上拉加载,为什么还要去封装一个自定义useList?这个问题问到点子上了。FlatList本身确实提供了onRefresh和onEndReached,如果只是做一个简单的、请求一次数据就完事的列表,它完全够用。

但稍微复杂一点的场景就暴露问题了。比如一个列表需要支持首次加载、下拉刷新、上拉分页、加载失败重试,还要处理“刷新过程中用户又触发了上拉”、“快速下拉多次导致重复请求”、“接口返回值顺序错乱导致旧数据覆盖新数据”等等竞态问题。这些逻辑如果全部写在页面组件里,每个页面都要重复实现一遍,而且很容易因为细节处理不到位出bug。

更重要的是,在OpenHarmony这种新的运行环境上,调试工具链还不像Android和iOS那么成熟,如果每个页面的列表逻辑都各自为政,出了问题排查难度会成倍增加。把列表相关的状态和行为统一收口到一个useList里,本质上是在做一个可复用的“分页请求状态机”,让页面组件只关心渲染,不需要关心数据是怎么来的、什么时候该加载下一页。这也是我推荐大家花时间做封装的核心原因:它不是炫技,而是为了在真实项目中降低维护成本。

2. 自定义useList的核心设计与状态模型

2.1 先想清楚:这个Hook对外暴露什么

设计任何Hook的第一步,不是写代码,而是定义好边界。useList的输入是一个获取数据的异步函数,输出是一份列表数据和处理动作。我把它设计成下面这样的接口形态:

interface UseListOptions<T> { fetchData: (page: number, pageSize: number) => Promise<FetchResult<T>>; pageSize?: number; initialPage?: number; // 数据清洗,比如把接口返回的嵌套字段拍平 transformer?: (raw: any) => T; // 是否需要自动去重,默认按 id 去重 autoMergeById?: boolean; } interface UseListResult<T> { list: T[]; loading: boolean; // 首次加载或刷新中 refreshing: boolean; // 下拉刷新中 loadingMore: boolean; // 加载更多中 hasMore: boolean; // 是否还有更多数据 refresh: () => Promise<void>; loadMore: () => Promise<void>; setList: React.Dispatch<React.SetStateAction<T[]>>; }

这里的核心思路是:调用方只关注两件事——什么时候刷新,什么时候加载更多;至于翻到第几页、当前请求是否进行中、返回的数据该拼接到列表尾部还是替换整个列表,全部由useList内部管理。page、hasMore这些状态对外只读,不提供setter,避免外部组件在不知情的情况下破坏内部状态的一致性。

为什么把loading拆成三个布尔值而不是用一个?因为三种状态的UI表现完全不同:首次loading通常是页面中间的转圈,refreshing是下拉刷新动画,loadingMore是列表底部的Loading条。从一开始就分开,后面接入UI会非常省事,也方便做单元测试。

2.2 分页协议:避免被后端接口绑定

分页接口的设计千差万别,常见的有三种类型。第一种是最传统的page+pageSize参数,后端返回total或hasMore;第二种是cursor游标模式,现在很多新接口在用,返回nextCursor,用游标查询下一页;第三种是offset偏移量模式,不管数据怎么变,按固定偏移取数据。

useList内部不做任何分页协议的假设,它只关心“当前是第几页”。至于这个page怎么转换成后端需要的参数,由fetchData函数自己处理。比如后端用的是游标模式,你就在外层用一个变量存游标,page每次加1时顺便更新游标即可。这样设计的好处是,Hook的复用在任何分页协议下都成立,不会因为后端接口改版导致Hook也需要改动。

从实际项目来看,我建议fetchData的返回值尽量统一成{list, hasMore}这个结构。即使后端没有返回hasMore,你也可以在外面算好:如果本次返回的数据条数小于pageSize,说明没有更多了。这个小细节能让Hook的判断逻辑保持简洁,不用去猜接口到底返回了什么字段。

2.3 竞态处理:让并发请求不互相打架

列表操作里最容易踩的坑,就是竞态问题。典型场景是这样的:用户进入页面,首次请求还没返回,马上又下拉刷新了一下;或者刷新过程中列表滚动到底部,触发了onEndReached的加载更多。如果不对这些操作做拦截,就会出现多个请求同时进行,返回顺序错乱后页面数据就乱了。

useList内部用ref来维护“是否正在请求中”的状态锁。注意这里不能用useState,因为state更新是异步的,在两个操作间隔极短的情况下,你读到的最新值可能还是旧的。ref的读写是同步的,用在这里就是为了一锤定音:锁住了就是锁住了,不可能出现并发穿透。

具体到逻辑上,refresh和loadMore是互斥的。refresh进行中时,loadMore直接返回;loadMore进行中时,refresh也直接返回。refresh自身有个保护:如果上一次刷新还没有结束,第二次refresh直接被忽略。对于组件已经卸载的场景,我用一个mountedRef来标记,异步请求回来后如果组件已经卸载,就不再setState,避免React的警告和潜在的内存泄漏。

3. 实操:从零实现一个可以用的useList

3.1 核心实现,直接抄作业

下面是我在项目里实际使用的一个精简版本。删掉了缓存和埋点逻辑,保留了最核心的分页、刷新、防重入这些能力。TypeScript写的,如果你用的是纯JS,把类型去掉即可。

import { useCallback, useRef, useState } from 'react'; export function useList<T>({ fetchData, pageSize = 10, initialPage = 1, transformer, autoMergeById = true, }: UseListOptions<T>): UseListResult<T> { const [list, setList] = useState<T[]>([]); const [loading, setLoading] = useState(false); const [refreshing, setRefreshing] = useState(false); const [loadingMore, setLoadingMore] = useState(false); const [hasMore, setHasMore] = useState(true); const pageRef = useRef(initialPage); const loadingRef = useRef(false); const refreshingRef = useRef(false); const loadingMoreRef = useRef(false); const mountedRef = useRef(true); const mergeById = useCallback((oldList: T[], newItems: T[]) => { if (!autoMergeById) return [...oldList, ...newItems]; const map = new Map<string, T>(); oldList.forEach((item: any) => map.set(item.id, item)); newItems.forEach((item: any) => map.set(item.id, item)); return Array.from(map.values()); }, [autoMergeById]); const runLoad = useCallback(async (page: number, mode: 'refresh' | 'loadMore') => { if (mode === 'refresh') { if (refreshingRef.current) return; refreshingRef.current = true; setRefreshing(true); setLoading(true); } else { if (loadingMoreRef.current || !hasMore) return; loadingMoreRef.current = true; setLoadingMore(true); } try { const res = await fetchData(page, pageSize); if (!mountedRef.current) return; const items = res.list.map((item: any) => transformer ? transformer(item) : item); if (mode === 'refresh') { pageRef.current = initialPage; setList(items); } else { pageRef.current = pageRef.current + 1; setList(prevList => mergeById(prevList, items)); } setHasMore(res.hasMore ?? items.length >= pageSize); } catch (e) { // 错误处理交给上层,这里不对错误做UI层面的假设 console.error('[useList] fetch error:', e); } finally { if (mountedRef.current) { setLoading(false); setRefreshing(false); setLoadingMore(false); } refreshingRef.current = false; loadingMoreRef.current = false; loadingRef.current = false; } }, [fetchData, pageSize, initialPage, transformer, mergeById, hasMore]); const refresh = useCallback(() => { loadingRef.current = true; return runLoad(initialPage, 'refresh').finally(() => { loadingRef.current = false; }); }, [runLoad, initialPage]); const loadMore = useCallback(() => { return runLoad(pageRef.current + 1, 'loadMore'); }, [runLoad]); return { list, loading, refreshing, loadingMore, hasMore, refresh, loadMore, setList, }; }

几个实现细节值得说一下。pageRef是我用来记录当前页码的,手动维护而不是依赖setState,原因是加载更多时页码更新和数据追加这两个操作要保证原子性,页面刷新时页码重置也要和数据替换保持一致。loadingRef虽然没有在runLoad里直接用到,但它承担了refresh防重入的职责:连续多次点击下拉刷新,前面的请求还没结束,后面的refresh直接忽略。

还有一个容易被忽略的点:requestId竞态。上面的代码通过ref锁解决了重复请求的问题,但还有一个场景——刷新进行中时用户切走了页面再切回来,触发了一个新的刷新,此时上一次刷新才返回。由于ref锁的存在,后一次刷新会直接被吞掉,这其实也是一种保护。如果还需要处理更极端的“返回顺序错乱”场景,可以加一个递增的requestId,每次发起请求时自增,返回时只认最新的requestId,旧请求的结果直接丢弃。这个我在下面的扩展部分会提到。

3.2 在页面里接入FlatList

Hook本身再完美,也要和UI组件配合才能发挥作用。接入FlatList的代码其实很直接:

const { list, loading, refreshing, loadingMore, hasMore, refresh, loadMore, } = useList<UserItem>({ fetchData: fetchUserList, // (page, pageSize) => Promise<{list, hasMore}> }); // 渲染部分 <FlatList data={list} keyExtractor={(item: UserItem) => item.id} renderItem={renderUserItem} onRefresh={refresh} refreshing={refreshing} onEndReached={loadMore} onEndReachedThreshold={0.3} ListFooterComponent={ loadingMore ? <ActivityIndicator /> : !hasMore && list.length > 0 ? <FooterEndText /> : null } />

FlatList的这几个props不需要多解释。真正要提醒的是onEndReachedThreshold这个参数,它表示距离底部还有多少比例时触发onEndReached。设得太小,用户滑到底部了还没触发加载,会有白屏等待的危机感;设得太大,内容只有一屏时可能还没看到就自动加载了下一页。实测下来0.3是一个比较稳的默认值,既不会让用户等太久,也不会频繁加载。

3.3 扩展:加了缓存和竞态保护之后

基础版本在大多数场景下已经完全够用,但到了生产环境,你还会碰到一些更细碎的问题。第一个是数据缓存。OpenHarmony的开发板网络环境往往不太稳定,弱网情况下列表页反复刷新体验很差。我后来在useList里加了一层内存缓存,key由fetchData的入参决定,刷新时如果有缓存先展示缓存数据,再在后台请求新数据。这个逻辑结合了“展示优先”和“数据更新”,用户感知会好很多。

第二个是过期请求丢弃。这个其实就是前面提到的requestId方案。每次refresh或loadMore都生成一个新的请求ID,请求返回时判断这个ID是否仍然是当前最新的ID,不是的话直接丢弃,不再走setState。实际场景里,用户快速下拉刷新两三次,只有最后一次的结果会被应用,前面的数据哪怕先返回也不会影响UI。

第三个是空态和错误态的统一处理。业务开发中经常会遇到“列表是空数组,但页面显示空白”的情况,因为空态组件没写、加载失败也没有提示。我在useList里增加了error状态,并把“list.length === 0且非loading且非error”这个组合作为空态标准,页面可以直接用它来控制是否渲染空态占位组件。

4. 在OpenHarmony设备上联调时踩过的坑

4.1 启动白屏第一坑:bundle加载才是元凶

如果之前只在Android和iOS上调试过RN,第一次在OpenHarmony设备上跑应用,大概率会被启动白屏卡个半天。我在RK3568的开发板上调试时,第一次launch应用,屏幕上什么都渲染不出来,log里也没有明显的JS报错,整整一个下午都处于“它怎么还不出来”的状态。

后来冷静下来梳理,白屏问题其实可以拆成几个链路去看。第一步,确认Metro服务有没有正常响应bundle请求。在开发阶段,RN应用默认是从Metro服务器拉取JS bundle的,如果设备连不上电脑的Metro服务,业务代码根本不会执行,页面自然什么都没有。第二步,检查原生容器有没有初始化完成。OpenHarmony的RN容器和Android上略有不同,它在首次初始化时需要加载运行时、注册原生模块,这些耗时少则几百毫秒,多则数秒。第三步才是业务代码本身的问题。

针对这个坑,我的处理方式是这样的。开发阶段,确保开发板和电脑在同一个局域网,Metro的端口可以通,然后先在log里确认“Running application”这条日志出现,说明bundle已经加载成功。生产环境下,建议把JS bundle打包到应用本地,不依赖网络拉取,同时做一个首屏占位页面,在RN容器还没渲染出来之前展示原生页面,这样用户不会有“应用崩溃了”的错觉。

4.2 低内存设备上的长列表性能优化

OpenHarmony的开发板(比如RK3568)内存普遍比较紧张,和手机上动辄8G、12G没法比。在低内存设备上跑长列表,最直观的感受就是滑动卡顿,尤其是列表项多了以后,每帧都要创建和销毁大量原生视图,CPU和内存都会吃紧。

FlatList本身已经做了窗口化渲染,但默认配置在低端设备上还不够激进。我实测下来,几个参数的调整影响很大。第一个是windowSize,默认值是21,表示当前视口上下各渲染10个屏幕宽度的内容,在开发板上可以适当调小。第二个是removeClippedSubviews,这个属性在Android上默认开启,但在OpenHarmony的RN适配层上不一定默认生效,建议手动置为true,把超出可视区域的视图直接移除。第三个是getItemLayout,如果你能确定每个列表项的高度是固定的,一定把这个属性写上,它能让FlatList跳过动态测量,滚动位置计算会快不少。

还有一个很容易忽略的点:列表项的组件层级越深,渲染开销越大。在低端设备上,我会刻意减少列表项里的嵌套视图数量,能用View就不要套三层,能用border就不用阴影,一个列表页的滑动流畅度往往就是这样一点一点抠出来的。

4.3 设备树与开发环境:从RK3568到模拟器的差异

有些第一次接触OpenHarmony的同事会跑来问我,网上那么多RK3568的设备树到底选哪个。这个问题如果你只是做应用层开发,其实不应该成为你的主要矛盾。RK3568上有不同内存颗粒、不同屏幕面板、不同板级外设的开发板,设备树选择确实会影响内核能否正确驱动硬件,但这和RN应用开发基本是隔离的。我更推荐直接使用官方发布的、针对你手头开发板的预编译版本,而不是自己去编译内核。自己编内核这个操作留给做BSP的同事去折腾就好。

模拟器的情况又不一样。“电脑版x86 OpenHarmony”这种模拟器环境在应用开发阶段其实更好用,因为它资源配置灵活、启动速度快,适合快速验证业务逻辑。但要注意模拟器和真机的差异:模拟器往往有独立于真实硬件的字体、分辨率、内存参数,列表项在模拟器上显示正常,不代表真机上不会出现留白或者高度计算错误。我的实践是,开发阶段用模拟器调业务逻辑,每出一个版本都要在真机上过一遍列表滑动、下拉刷新、Infinity加载这些关键路径。两边的表现都确认了,再往下走。

5. 常见问题与排查技巧速查表

列表页在OpenHarmony上的联调,踩过的坑比较集中。我把实际项目里高频出现的问题整理成一个表,方便你排查的时候直接对照。

现象可能原因排查思路解决方式
应用启动白屏bundle未加载、RN容器初始化慢、原生页面未做占位看log里是否有“Running application”,确认Metro连接生产环境本地化bundle;原生层增加启动占位页
下拉刷新不触发手势和系统手势冲突,或refreshing参数被外部错误控制检查FlatList的refreshing当前值,确认onRefresh被调用确认refreshing只由useList管理,不要外部强行覆盖
上拉加载不触发onEndReachedThreshold太小,或hasMore被提前置为false在loadMore里打印日志确认是否被调用调整threshold为0.3,确认hasMore计算逻辑正确
加载时页面闪烁多个loading状态混用,首次加载和刷新用了同一个loading检查loading/refreshing/loadingMore是否各自独立按UI形态分离状态,避免一次请求同时置多个loading为true
列表重复数据分页接口在添加或删除元素后页码错乱看接口返回的list中id是否有重复开启autoMergeById按id去重;或改用游标分页
内存持续上涨长列表没有窗口化,或列表项重视图过多使用Profile观察渲染层节点数和内存快照开启removeClippedSubviews,设置getItemLayout,精简列表项结构
网络错误无提示错误被捕获后没有透出到UI在catch里临时加console.error确认是否进入useList增加error状态,由页面统一渲染错误占位
页面退出后请求回调报错组件卸载后异步setState检查mountedRef是否在卸载时被置为false在useEffect的cleanup中设置mountedRef.current=false

这些不是刚遇到的“新鲜问题”,很多在Android和iOS上也会遇到,但在OpenHarmony上排查链路更长、工具不完全,处理起来更耗时间。把这些问题沉淀成速查表,团队里任何一个人遇到同类问题时都能快速定位,这才是最有价值的产出。

最后再讲一个我在实战里比较深的体会。做跨端框架适配的时候,最容易陷入的误区是一有问题就往框架层想,总觉得是React Native在OpenHarmony上适配得不好。但大部分时候,问题出在自己的代码对运行环境的假设上。比如RN在Android里默认能用的能力,在OpenHarmony的适配层上不一定默认开启;再比如分页接口在开发环境和设备上返回的数据结构可能有细微差异。先把业务代码的边界条件都理清楚,再去看框架层,排查效率会高很多。useList这个Hook看起来简单,但它在真实项目里帮我拦下了太多类似的边缘case,这也是我愿意把它完整分享出来的原因。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 1:41:35

Auto-Rig Pro 3.40z角色自动绑定插件详解:安装、流程与避坑

简介&#xff1a;面向Blender三维艺术家与动画师的自动绑定工具Auto_Rig_Pro 3.40z资源包&#xff0c;专门解决角色骨骼生成、蒙皮权重分配及IK/FK切换等绑定效率难题。压缩包共9个文件&#xff0c;包含blend工程文件、Python脚本、bmap映射预设及zip扩展模块&#xff0c;bmap预…

作者头像 李华
网站建设 2026/9/9 1:41:21

DeepSeek Harness深度实测:安装、插件与源码解析的大模型工作台全指南

前两天我还在自己博客里写了篇吐槽&#xff0c;说DeepSeek Harness大概率就是官方套壳&#xff0c;把命令行包一层UI就拿出来糊弄人。结果连夜实测了一整个晚上&#xff0c;第二天我就把文章删了&#xff0c;并且回去给梁神道了个歉——这东西的厚度&#xff0c;比我印象里那些…

作者头像 李华
网站建设 2026/9/9 1:39:53

深入理解magnitude:从向量模长到地震震级的核心概念与实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/9 1:39:16

树莓派Pico RP2040 DMA实战:MicroPython下UART收发不卡顿

做嵌入式的小伙伴应该都有过这种经历&#xff1a;跑着一套看似简单的MicroPython脚本&#xff0c;结果串口一打开、数据一多&#xff0c;主循环就开始卡顿。轮询收发会一直占着CPU&#xff0c;中断处理又把正常的执行流打得七零八落。如果你手里正好有一块树莓派Pico&#xff0…

作者头像 李华
网站建设 2026/9/9 1:39:10

多轮对话加密漏洞与RAG/Agent分层防御实战

最近这段时间&#xff0c;“思维链被爆破”这个话题在 AI 开发者圈子里讨论得不少。尤其是 Claude 这种以推理能力见长的模型&#xff0c;很多人都想知道&#xff1a;系统提示词到底能不能被套出来&#xff1f;多轮对话里的内部指令是不是藏得住&#xff1f;RAG 知识库和 Agent…

作者头像 李华
网站建设 2026/9/9 1:38:20

从零用 TypeScript 实现最小通用智能体:100 行核心循环

我见过不少人第一次接触通用智能体的时候&#xff0c;第一反应是去打开一个成熟的 Agent 框架&#xff1a;安装依赖、配置模型、注册工具、读文档&#xff0c;然后在“这个东西到底怎么搭”里消耗掉一整个下午。后来我在一个周末做了一次减法&#xff1a;不引框架&#xff0c;不…

作者头像 李华