React Native适配OpenHarmony:网络请求与列表渲染实战指南 最近把 React Native 跑到了 OpenHarmony 上重点打通了网络请求和数据列表这两块基础能力。这篇学习笔记不是教程式的 PPT而是完整记录我的选型思路、实际工程搭建、请求层封装、列表渲染和真机调试过程遇到的白屏、failed to fetch 这些坑也都写在里面给准备在 OpenHarmony 上做 RN 开发的朋友一个可以直接照搬的参考。1. 项目背景与整体设计思路1.1 为什么选择 React Native for OpenHarmony先说结论我手上已经有现成的 React Native 业务原本跑在 Android 和 iOS 上现在要适配 OpenHarmony 设备。如果所有页面都用 ArkUI 重新写一遍工作量接近翻倍而且以后维护两条代码库会非常痛苦。这时候把 RN 这一层直接搬到 OpenHarmony 上业务代码能把大头留下来网络请求、列表渲染这种通用模块几乎不用改只有跟系统能力强相关的部分需要做桥接。OpenHarmony 的系统和工具链这两年发展很快但它不像安卓和 iOS 有庞大的第三方库生态。社区里已经有人做了 react-native-oh/react-native-harmony 这类桥接方案它不是用 WebView 套壳而是把 RN 的渲染引擎映射到 ArkUI 的原生节点上所以性能和交互体验比 H5 容器强很多。对我来说这就是一个现阶段最平滑的落地方案。当然这个方案也不是万能的。RN 生态里相当一部分原生模块是围绕 Android/iOS 写的OpenHarmony 上不能直接用需要等社区适配或者自己写桥接。所以在选型时我给自己定的原则是业务逻辑、网络层、数据列表这些跟系统能力边界清晰的模块放在 RN 层反过来如果要用到底层硬件能力或者系统定制 UI不要犹豫直接在 ArkTS 层开洞对外暴露。这样既享受跨端的开发效率又不至于被桥接层卡住。1.2 整体技术选型与工程结构整体工程结构我采用 RN TypeScript 业务层 ArkTS 壳工程的组合。业务代码放在src目录OpenHarmony 工程是一个独立的harmony子工程JS 层通过 NativeModule 和 ArkTS 交互。网络请求方面我选了 fetch 作为基础再手写一层封装。为什么不用 axios其实在 OpenHarmony 上 axios 也能跑但它的底层依然是 RN 网络栈遇到超时、请求中断这类问题封装的复杂性并不因为用了 axios 而减少。我最终选择轻量封装 fetch把超时、重试、错误码转换、token 注入这些都放在自己的 Request 工具类里可控性更强。列表渲染则直接用 FlatList它是 RN 内置的虚拟列表组件可视区外节点不会真实挂载这比 ScrollView 里 map 一堆节点靠谱得多。关于 fetch 和 axios 的取舍我列了个表格方便你参考。比较维度fetch 轻量封装axios依赖体积几乎为 0约几十 KB拦截器自己写内置超时控制需要 AbortController内置取消请求AbortControllerCancelToken / Abort错误格式统一自己处理内置社区插件无丰富两个方案真机表现差别不大。如果你之前的业务代码里已经全是 axios 的写法那就继续用 axios如果从零开始我建议轻量封装 fetch少一层依赖出问题也更好定位。学习阶段的目标是先把一条链路走通页面加载 - 发起网络请求 - 拿到数据 - 渲染列表 - 下拉刷新加载更多。这个链路打通之后后续加业务页面就是一个复制粘贴的活。2. 环境搭建与工程初始化2.1 开发环境准备OpenHarmony 上跑 RN首先要把工具链搞齐。我的环境是 Windows 11 DevEco Studio 5.0这里给你一个参考。需要装的东西包括Node.js 18主要用于跑 Metro 和编译 JS bundle。OpenHarmony SDK通过 DevEco Studio 内置的 SDK Manager 安装建议选 API 11 以上的版本。ohpm 包管理器和 hvigor 构建工具对应 OpenHarmony 开发中的 npm 和 gradle。装完之后先确认基础命令可用node -v、ohpm -v、hvigorw --version。DevEco Studio 首次创建工程时会自动配置 SDK 路径如果你是像我一样要接入现有 RN 仓库记得在local.properties或 hvigorConfig 里把 SDK 路径指对否则后面编译会报一堆 SDK not found 的错。这里有个容易忽略的点OpenHarmony 官方仓库在国内访问一般没问题但 npm 的默认源有时速度较慢建议把 npm 源和 ohpm 源都切换到国内镜像。这不是为了绕什么限制纯粹是下载依赖的时候能省很多时间。我一开始没换源装 react-native-oh/react-native-harmony 和 metro 相关依赖等了快二十分钟换了镜像源之后基本就是秒下。2.2 创建 RN 工程并接入 OpenHarmony接入方式有两种一种是从 RNOH 官方模板直接创建命令大概是npx react-native-oh/react-native-harmony init会同时生成 RN 工程和 harmony 壳工程另一种是现有 RN 工程里手动集成 OpenHarmony 支持。我第一次用的是手动集成步骤大概是这样先把 react-native-oh/react-native-harmony 依赖装进 package.json然后拷贝harmony目录到工程根目录再按文档配置 hvigor、module.json5 里的依赖和权限声明。这中间最容易翻车的点是 RN 版本和 OpenHarmony SDK 版本的配对关系官方文档每个 release 都会标注测试过的版本组合一定要严格对齐。版本不对最典型的症状就是编译能过但真机上启动直接崩或者 Metro 连不上。如果你是新建工程我更推荐用脚手架一步到位少踩配置的坑。创建好之后目录结构大概长这样src/放 RN 业务代码harmony/是 ArkTS 壳工程android/和ios/是原有的移动端壳。这样 Android/iOS 的构建链没有破坏OpenHarmony 是额外加的一条分支。我用了一上午把工程跑起来第一个页面只显示一个 Text但这第一步跑通后面学东西会顺很多。2.3 启动白屏问题的处理思路启动白屏这个问题基本每个从 React Native 转到 OpenHarmony 的开发者都会遇到热词也说明很多人中招。所谓白屏就是应用启动后屏幕上什么都没有过几秒甚至十几秒才看到 JS 渲染的内容。先说我的排查路径先看 Metro 是否正常启动如果页面是纯白的且 Metro 控制台没有任何 bundle 请求记录说明 RN 层根本没有进入加载流程如果 Metro 有请求但页面还是白说明 JS 在初始化阶段抛了异常打开 DevEco 的 Log 面板过滤ReactNativeJS能看到具体报错。还有一种常见情况是开了本地 bundle但 bundle 文件没生成或路径配错也会白屏。处理思路有三板斧。第一调试阶段用 Metro 开发服务器在真机上设置好 host 和端口保证手机和电脑在同一局域网第二发布阶段必须用本地 bundle执行react-native bundle命令把 JS 打包成 bundle 文件放在 assets 里不要再依赖 Metro第三为了优化首屏加载体验在 ArkTS 壳工程的 WindowStage 阶段做一张启动图让用户先看到 App 的品牌页而不是白屏等 JS 完成初始化后再切换到业务首页。我实际对比过本地 bundle 的加载速度比 Metro 快很多白屏时间能压到 1 秒以内而连 Metro 冷启动可能要等 3 到 5 秒。另外如果你用了自定义字体或者其他原生资源一定要确认这些资源在 OpenHarmony 下的文件名和路径资源加载失败也会导致首屏渲染不出来。3. 网络请求模块的实现与封装3.1 网络请求的权限与基础配置网络请求在 OpenHarmony 上是需要权限声明的这一点和 Android 类似。在 harmony 工程里找到entry/src/main/module.json5在requestPermissions字段里加上ohos.permission.INTERNET。不加这个权限的情况下请求不会直接抛错而是被系统静默拦截表现就是 fetch Promise 一直 pending超时后才报 failed to fetch这个错误非常误导人。我第一次真机请求接口就卡在这里还以为是后端跨域问题折腾了半小时才发现权限没加。另外如果你在开发环境要请求一个 http 明文接口OpenHarmony 默认对明文流量有限制需要给 entry 配置 networkSecurityConfig在里面允许特定域名的 http 请求。这个配置位置和 Android 类似但属性字段的写法不一样需要对照官方文档。请求地址的配置我也建议单独拆出来。不要把接口地址散落在各个页面里我习惯在src/config/api.ts里定义 baseURL根据__DEV__区分开发和生产环境。开发环境用局域网 IP注意真机和电脑连同一个 Wi-Fi、关闭电脑防火墙生产环境用正式域名。真机上不要用localhost它指向的是手机自身。3.2 封装统一的请求层先给一个最基础的 fetch 封装。我实现的核心逻辑包括请求超时中断、对非 2xx 状态码的统一错误转换、支持注入 token、提供日志开关。直接用 AbortController 控制超时写法如下// src/utils/request.ts export class RequestError extends Error { code: string; status?: number; constructor(message: string, code: string UNKNOWN_ERROR, status?: number) { super(message); this.code code; this.status status; } } interface RequestOptions extends OmitRequestInit, body { timeout?: number; params?: Recordstring, string | number; body?: any; } const BASE_URL __DEV__ ? http://192.168.1.100:8080 : https://api.example.com; export async function requestT(path: string, options: RequestOptions {}): PromiseT { const { timeout 10000, params, body, headers, ...rest } options; const url new URL(${BASE_URL}${path}); if (params) { Object.keys(params).forEach((key) url.searchParams.append(key, String(params[key]))); } const controller new AbortController(); const timer setTimeout(() controller.abort(), timeout); const finalHeaders: Recordstring, string { Content-Type: application/json, ...headers, }; const token globalThis.__AUTH_TOKEN__; if (token) { finalHeaders[Authorization] Bearer ${token}; } try { const response await fetch(url.toString(), { ...rest, headers: finalHeaders, body: body ? JSON.stringify(body) : undefined, signal: controller.signal, }); const text await response.text(); let data: any null; try { data JSON.parse(text); } catch { data text; } if (!response.ok) { throw new RequestError(data?.message || HTTP ${response.status}, HTTP_ERROR, response.status); } return data as T; } catch (err) { if (err instanceof RequestError) throw err; if (err instanceof Error err.name AbortError) { throw new RequestError(请求超时请稍后重试, TIMEOUT); } throw new RequestError(网络异常请检查网络设置, NETWORK_ERROR); } finally { clearTimeout(timer); } }这个封装有几个关键点。超时用的是setTimeout AbortController超过 10 秒自动 abortcatch 里识别 AbortError 并抛出一个语义明确的 TIMEOUT 错误所有非 2xx 的响应都统一转成 RequestError接口返回的 body 先做 JSON.parse 兜底防止后端返回纯文本时崩掉。token 我用了一个全局变量globalThis.__AUTH_TOKEN__在启动时注入生产环境可以接一个存储模块。你还可以根据业务需要加请求重试逻辑比如网络抖动时对 GET 请求重试一次但要小心 POST 请求重复提交所以重试一定要限定方法。提示这个封装只是基础版本。实际项目里我还会在 request 里打印请求日志和耗时把耗时超过 800ms 的接口单独标记方便在真机上定位慢接口。3.3 failed to fetch 等网络错误的定位与处理先说清楚failed to fetch 这个错误串在 React Native 的 fetch 实现里非常常见它本身并不能告诉你是哪一环出了问题。我在调试阶段总结了一个排查清单你按顺序过一遍基本都能解决。确认 module.json5 里有没有加 INTERNET 权限。确认请求地址是不是局域网 IP真机不能访问 localhost。确认项目和电脑在同一个网段且目标服务端口在防火墙里被放行。确认是不是 http 明文请求被拦截给应用配置 networkSecurityConfig 或者改用 https。打开 DevEco 的 Log 面板过滤 ArkTS 和 HTTP 相关 tag看看系统层有没有抛出更具体的错误。还要说一个很多人忽略的情况如果你电脑上开了抓包工具它会接管网络请求把真机的流量引导到抓包监听地址上。如果监听地址没配好业务请求全部失败报的就是 failed to fetch。所以排查到这一步时先把抓包工具关掉再验证别一边开抓包一边找 bug。还有一种很迷惑的情况服务端返回了 200 但 response body 不是合法 JSON。如果后端在网关层面做了字符集替换body 解析失败会抛 SyntaxError到业务层看起来和网络错误一模一样。遇到这类问题第一件事就是先打通一个最小可用的测试接口再接真实业务接口不要一上来就接复杂的接口。热搜里那条类似 message:error: 上传失败:网络请求错误 failed to fetch appid: ... 的报错我在别的项目里也见过。本质是前端在提交数据时把 appid 这类业务参数拼到了 URL 或 Header但由于 baseURL 或域名配置错误请求压根没发到目标服务所以返回的失败信息里只保留了最原始的 fetch 异常。处理方式也很朴实先把 appid 从报错串里拿出来去看它实际被拼接到了哪个域名上和预期接口地址做对比十有八九是环境变量没切过来。4. 数据列表的实现与性能优化4.1 用 FlatList 替代 ScrollView 渲染列表数据列表这块我直接用了 RN 的 FlatList。为什么不用 ScrollView 里嵌套 map因为当数据条数超过几百条ScrollView 会把全部节点一次性渲染出来内存和渲染开销直接起飞真机上表现为卡顿和掉帧。FlatList 是虚拟列表它会根据滚动位置动态回收可视区外的节点所以哪怕数据源有一千条当前屏幕上渲染的也就十几个 Item。一个最小可用的列表示例// src/screens/ListScreen.tsx import React, { useEffect, useState } from react; import { FlatList, Text, View, ActivityIndicator } from react-native; import { request } from ../utils/request; interface Article { id: string; title: string; summary: string; } export default function ListScreen() { const [list, setList] useStateArticle[]([]); const [loading, setLoading] useState(true); useEffect(() { request{ list: Article[] }(/articles, { params: { page: 1, size: 10 } }) .then((res) setList(res.list)) .catch((err) console.warn(load failed:, err)) .finally(() setLoading(false)); }, []); if (loading) { return ActivityIndicator style{{ marginTop: 100 }} /; } return ( FlatList data{list} keyExtractor{(item) item.id} renderItem{({ item }) ( View style{{ padding: 16, borderBottomWidth: 1, borderBottomColor: #eee }} Text style{{ fontSize: 16, fontWeight: 600 }}{item.title}/Text Text style{{ marginTop: 4, color: #666 }} numberOfLines{2}{item.summary}/Text /View )} ListEmptyComponent{Text style{{ textAlign: center, padding: 40 }}暂无数据/Text} / ); }这里要注意 keyExtractor 一定不能直接返回 index。用 index 当 key一旦数据顺序变化或者中间插了一条记录React 的 diff 机制会误判导致列表项内容错位、重复渲染。后端正常情况下会给每一条记录一个 id用 id 做 key 是最稳的。4.2 下拉刷新与分页加载完整实现列表不可能只加载第一页我把下拉刷新和上拉加载更多一起做了。下拉刷新用 FlatList 自带的RefreshControlonRefresh触发时把 page 重置为 1重新拉接口上拉加载更多用onEndReached监听滚动到底部把 page 加 1 继续拉。有几个细节容易出 bug。第一onEndReached在列表高度不足一屏的时候会直接触发而且可能触发多次所以必须加一个加载状态锁第二接口返回的总条数如果已经小于 pageSize要置一个 noMore 标识关闭加载更多第三刷新和加载更多的状态要分开不要混用同一个 loading不然刷新时会看到上一次加载更多的转圈。我把这套逻辑封装成了一个 hook叫usePagedList核心参数就是 requestFn、pageSize。hook 内部维护 list、page、loading、refreshing、noMore 这些状态暴露 reload、loadMore 两个方法// src/hooks/usePagedList.ts import { useCallback, useRef, useState } from react; export function usePagedListT( requestFn: (page: number, size: number) Promise{ list: T[]; total: number }, pageSize 10, ) { const [list, setList] useStateT[]([]); const [loading, setLoading] useState(false); const [refreshing, setRefreshing] useState(false); const [noMore, setNoMore] useState(false); const pageRef useRef(1); const lockRef useRef(false); const load useCallback(async (page: number, isRefresh: boolean) { if (lockRef.current || (page 1 noMore)) return; lockRef.current true; isRefresh ? setRefreshing(true) : setLoading(true); try { const res await requestFn(page, pageSize); setList((prev) (isRefresh ? res.list : [...prev, ...res.list])); setNoMore(res.list.length pageSize); pageRef.current page 1; } catch (e) { console.warn(e); } finally { lockRef.current false; isRefresh ? setRefreshing(false) : setLoading(false); } }, [requestFn, pageSize, noMore]); const reload useCallback(() load(1, true), [load]); const loadMore useCallback(() load(pageRef.current, false), [load]); return { list, loading, refreshing, noMore, reload, loadMore }; }这个 hook 的精髓在 lockRef。它保证同一时刻只有一个请求在跑避免用户快速下拉、上拉造成多个请求并发、列表数据顺序错乱。pageRef 存的是下一次要加载的页码refresh 时重置为 1loadMore 时取当前值。noMore 的作用是数据不够一页时直接提示没有更多不要再发请求。4.3 列表性能优化与常见体验问题列表数据量大了之后优化是必须做的。第一优先级是 keyExtractor前面说了用唯一 id第二是把 renderItem 拆成独立的 Item 组件并用React.memo包裹。为什么拆开因为 FlatList 在虚拟滚动时会频繁创建和回收节点如果不拆组件父组件每次 setState 都会导致所有 Item 重新 render用 memo 包裹后只要传入的 item 引用不变组件就跳过渲染。第三是避免在 renderItem 内部定义新的箭头函数或对象字面量比如style{{...}}每次渲染都会生成新引用memo 会失效。第四图片列表建议给 Image 设置固定宽高避免文字先渲染、图片后加载时造成布局抖动合理的默认图也能减少白屏观感。还有一个坑是 FlatList 里面嵌套了 ScrollView 或者同方向的滚动容器OpenHarmony 上尤其容易出现手势抢占表现为列表划不动或者划一下跳很远。我遇到后的处理办法是禁止内层滚动或者把内层列表放到ListHeaderComponent里而不是再套一个 ScrollView。记住一个原则同方向不要嵌套两个滚动容器这是移动端开发的铁律。性能优化的收益我很直观在测试机上优化前滚动到第 300 条记录帧率有明显下降内存一路上涨把 Item 用 memo 隔离、图片高度固定之后滚动全程基本保持在 60 帧内存也稳定在一个区间。这些零碎的小改动叠加起来效果非常明显。5. 真机调试、XTS认证与发布前准备5.1 真机调试与日志排查OpenHarmony 真机调试和安卓类似先用 USB 连接手机打开开发者模式然后在 DevEco Studio 里点击运行构建产物会直接推到设备上。除了 DevEco 的可视化调试我更习惯用命令行工具hdc它是 OpenHarmony 的调试桥。常用命令比如hdc list targets查看设备、hdc shell hilog抓系统日志。RN 应用的日志主要由两个部分组成一个是 JS 层的 console.log会输出到 hilog 且 tag 带ReactNativeJS另一个是 ArkTS 层和原生层的日志。排查白屏和网络问题的时候我用hilog | grep ReactNativeJS和hilog | grep ReactNative并行看一个管 JS 逻辑一个管桥接层很快能定位问题在哪个层面。网络请求的日志除了看 hilog我还会在 Request 封装里打印带时间戳的请求摘要包括方法、路径、消耗时长、状态码。真机上安装抓包工具不是所有调试工具都能用所以最依赖的还是自己埋点打的日志。这里顺便提一下 HDI 是怎么回事。HDI 是 OpenHarmony 的硬件设备接口一般来说普通应用开发者不会直接在 JS 层接触到它因为 HDI 是给系统服务或驱动实现方使用的它把 sensor、摄像头这些硬件能力抽象成标准接口。RN 应用要用到底层硬件正确姿势是在 ArkTS 层通过系统 API 调用相关能力再封装成 NativeModule 暴露给 JS而不是自己去碰 HDI。你在真机调试时看到 HDI更多是编译日志里某些驱动模块的初始化记录跟业务代码没有直接关系不用慌。5.2 XTS认证、签名与打包注意点如果你的应用要上 OpenHarmony 的应用市场或者在企业内部批量分发通常要过 XTS 认证。XTS 全称 OpenHarmony X Test Suite是一套北向应用兼容性测试工具用来验证应用在真实系统版本上的兼容性和稳定性。它分两个方向一是兼容性测试检查应用能不能在目标系统版本上正常运行二是安全与隐私测试检查权限申请是否合理、有没有在收集敏感信息前明确告知用户。RN 应用和原生应用在 XTS 这里并没有特殊豁免一样要过基础项检测比如安装启动、应用弹窗、后台任务、权限声明、网络行为这些。打包和签名这块我用的是 DevEco Studio 的自动签名登录后可以自动生成签名证书省去手动配置。但要注意签名证书和应用的 bundleName 必须一致否则安装到真机上会报签名冲突。发布包的时候记得把 JS bundle 打进 app 包里并关闭 Metro 依赖同时把__DEV__关掉。RN 在 OpenHarmony 上的包体本来就比原生大如果你还依赖远程 bundle用户第一次打开 App 看到的就是一个转圈或白屏体验很差。我自己的做法是发布前跑一次react-native bundle产物输出到 harmony 工程 assets 目录同时在 ArkTS 层做 bundle 加载的 fallback本地 bundle 加载失败时再尝试从 Metro 拉取方便测试阶段排查。5.3 FTP、HDI 等扩展能力的接入思路再聊一个很多朋友问到的点如果业务需要访问 FTP 服务器或者对接底层设备怎么办结论是不要在 JS 层直接整。React Native 官方没有封装 FTP 客户端OpenHarmony 上也没有现成的 RN 原生模块可以直接 import。你有两条路一条是自己写 ArkTS NativeModule封装 FTP 的上传下载能力通过桥接暴露给 JS另一条是如果业务量不大可以先把文件拉到临时目录再通过系统 API 分发。这个方案的坑主要在网络权限和文件路径映射上。真机上临时目录不能直接拿 JS 层字符串当本地路径用需要调用系统 API 或 NativeModule 转换成实际路径。HDI 这条线就更偏系统级了普通 App 开发基本碰不到。如果你是做系统裁剪或 ROM 适配HDI 的调试需要专门的工具链不在 React Native 的学习范畴里。RN 开发者记住一个边界就好业务层代码尽量待在 JS 里跨系统能力交给 ArkTS 原生模块不要试图用 JS 直接操作文件描述符或底层硬件接口。6. 个人实操体会与后续学习建议6.1 这次踩坑换来的三个判断标准这套流程跑完一遍我最大的感受是 React Native for OpenHarmony 能用但不要把它当成可以无脑迁移的银弹。跨端带来的开发效率提升是真的代价是你必须比在 Android/iOS 上更熟悉 ArkTS 壳工程的细节因为 OpenHarmony 的工具链和测试体系还在完善中很多问题没有现成的中文答案。我给自己立了三个判断标准。第一遇到组件库不支持先查社区有没有适配版本不要急着自己造轮子第二启动白屏这类问题先看是不是 bundle 加载链路断了再看业务代码顺序不能反第三涉及网络的问题第一反应不要是改代码先看权限、域名、端口这些基础配置。按这三个标准来大多数问题都能在半小时内定位。6.2 给后来者的学习顺序与实用技巧如果让我给一个学习顺序我会建议先花一天时间把环境搭起来跑通 Hello World再花两天做网络请求封装和列表渲染期间一定要在真机上跑不要在模拟器上自嗨。真机才会暴露权限、网络、白屏这些问题。另有一个小技巧分享给你在 OpenHarmony 上遇到任何诡异问题先抓 hilog 日志搜关键字ReactNativeJS和ReactNative这会告诉你是 JS 层还是桥接层的问题。定位是不是白屏就看有没有 bundle 请求定位是不是网络问题就看有没有 failed to fetch 或超时日志。这套排查思路比盲目改代码高效太多了。最后分享一个我在实际项目里养成的习惯把每次真机调试的命令行操作和常见报错整理成一份 checklist放到仓库的 docs 目录。React Native for OpenHarmony 的版本迭代很快过一段时间官方文档变了你还能有个自己的经验库。以上就是这次学习笔记的全部内容希望能帮你少走一点弯路。