最近在折腾 React Native 在 OpenHarmony 上运行这件事,项目是一个 Steam 资讯类 App,这次把“我的收藏”模块完整做了一遍。先说结论:RN 在 OpenHarmony 上已经不是“能不能跑”的问题,而是“怎么跑顺”的问题,尤其是收藏这种既要本地存储、又要跨页面同步、还要处理列表交互的功能,坑比想象中多,但方案本身并不复杂。如果你正好在做 react-native-harmony 的 App,或者只是想把一个已有的 RN 项目移植到 OpenHarmony 设备上,这篇应该对你有用。
收藏功能乍一看很简单:一个心形图标,点击之后存数据,收藏列表展示。真做起来,你会发现它牵扯到状态管理、异步存储、跨页面联动、空状态、点击事件穿透、列表渲染性能,任何一个环节偷懒都会在后面还债。这篇我按实际开发顺序走一遍,把数据模型怎么设计、存储层怎么封、收藏按钮怎么防抖、点击空白处怎么处理、常见问题怎么排查都写清楚。
1. 项目背景与整体思路
1.1 为什么这个模块值得单独写
Steam 资讯类 App 的典型使用场景是:用户刷到某条游戏新闻,比如《黑神话》的新公告、Dota2 的版本更新日志,或者某个游戏的促销信息,想收藏起来稍后细看。这时候“收藏”就不只是 UI 上的一个心形图标,它实际上承担了用户的数据持久化诉求,用户会默认下次打开 App 时收藏还在,会默认收藏列表能离线打开,甚至会默认在别的页面收藏后,Tab 上的角标立刻变化。
所以收藏模块虽然小,却是整个 App 里最能体现“一致性和稳定性”的地方。第一次开发时我天真地以为只需要一个数组加一个按钮,结果实际拆下来发现至少有四块内容要同时处理好:
- 数据模型:收藏列表存什么字段,怎么兼容老版本数据。
- 状态管理:用什么方式让多个页面共享收藏状态,并且不引发不必要的重渲染。
- 本地存储:OpenHarmony 上的 AsyncStorage 怎么接,启动时怎么恢复,写入时机怎么控制。
- 交互细节:心形按钮的点击反馈、防重复点击、空状态展示、点击空白处关闭面板、列表滚动流畅度。
任何一个环节不做,收藏功能都能用,但就是“体验不够好”。这篇文章就按这四条线来展开,后面每一步会给到可以直接抄的代码和思路。
1.2 技术选型:为什么用 RN 而不是原生 ArkUI
OpenHarmony 原生推荐 UI 是 ArkUI/ArkTS,如果是从零开发一个只跑在 OpenHarmony 上的 App,直接用 ArkUI 反而更省事。但这个项目不是从零开始的,我们原本就有一套 React Native 的 Steam 资讯 App,iOS 和 Android 都已经在维护,新的 OpenHarmony 版本只是多一个目标平台,所以核心诉求是“尽可能复用现有 RN 代码”,而不是再维护一套 ArkUI 版本。
react-native-harmony 这个社区适配层解决的就是这个问题,它把 RN 的 JS 组件映射到 OpenHarmony 的 ArkUI 组件上,让 JS 代码跑在 OpenHarmony 设备里。好处很明显:列表页、详情页、网络层、路由这些已经写好的逻辑基本不用动,只需要针对 OpenHarmony 做少量适配。代价也很现实:RN 生态里的原生模块不能默认可用,尤其是涉及本地存储、蓝牙、电话等原生能力的库,都需要找适配包或者自己写原生桥接。
收藏模块正好是“RN 跨端能力”的一个典型样本:它需要本地存储,但没有到必须自己写原生模块的程度;它需要全局状态共享,这部分纯 JS 就能解决;它需要流畅的列表渲染,这就要靠 FlatList 优化。换句话说,收藏模块是验证 RN 在 OpenHarmony 上是否可用的一个很好基准,跑通它,你这个项目的 RN 技术路线基本就有底了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与设备排查
2.1 OpenHarmony 设备版本与调试环境确认
开始写代码之前,先确认设备环境。我手上是一块 rk3568 的开发板,OpenHarmony 版本比较老,一开始没留意 API 版本,结果后面 AsyncStorage 适配包加载失败,排查了半天发现是系统版本不在支持范围内。所以建议所有人在第一步就用 hdc 把设备信息查清楚。
看设备有没有连上,先执行:
bash复制hdc list targets
如果能输出设备序列号,说明 hdc 通道正常。接着查看系统版本和产品名:
bash复制hdc shell param get const.ohos.version
hdc shell param get const.ohos.apiversion
hdc shell param get const.product.name
const.ohos.version 返回的是系统版本号,比如 4.0 或者 5.0;const.ohos.apiversion 是 API 等级,RN 适配层对这个特别敏感;const.product.name 会告诉你当前是 rk3568、rk3588 还是别的设备。这三个信息在提交问题、选插件版本、判断是不是设备差异的时候都很有用。
这里有一个实际经验:hdc 连不上时不要先怀疑板子,先看电脑上的 USB 驱动和 hdc 服务状态。开发板上电之后如果只有电源灯亮,USB 口没识别到设备,多半是驱动或线的问题,换一根数据线有时候就好了。如果 hdc list targets 偶尔能偶尔不能,执行一下 hdc kill 再重新连,比反复插拔稳定。
2.2 RN 工程挂到 OpenHarmony 上的基本流程
把 RN 工程挂到 OpenHarmony,常规流程是先用 RN 官方脚手架初始化工程,再加入 react-native-harmony 适配层。大致命令如下:
bash复制npx react-native init SteamNews
cd SteamNews
npm install react-native-harmony
装完之后,工程里会多出一个 harmony 目录,这是给 DevEco Studio 用的 OpenHarmony 工程入口。用 DevEco Studio 打开这个 harmony 目录,构建出 HAP 包,再用 hdc 安装到设备上,就能在 OpenHarmony 里跑 RN 页面了。
这里要特别提醒:从 RN 跑到 OpenHarmony 真机,并不是改完 JS 直接刷新就行。如果你安装了带原生代码的 npm 包,比如 AsyncStorage 的 OpenHarmony 适配包,就必须回到原生工程重新编译一次 HAP,再重新安装。很多收藏模块的“ NativeModule is null ”报错,都是因为改了 npm 包后没有重新构建原生工程导致的。我们的操作习惯是:每次改了 package.json 或安装了新的原生模块,先跑一遍完整构建,再做业务逻辑开发,避免把环境问题和代码问题混在一起。
另外,开发阶段建议用 release 包做功能性验证。OpenHarmony 开发板普遍性能一般,开 debug 模式加载 JS bundle 慢,而且有些原生模块在 debug 下行为不一致,收藏列表这种涉及异步存储的功能,用 release 包验证更接近真实体验。
3. 收藏模块的数据设计与本地存储
3.1 收藏数据结构:不能只存一个 id
很多人在设计收藏表时喜欢精简,只存资讯的 id,比如“news_12345”,然后在收藏列表页根据 id 去远程拉接口、重新组装数据。这个方案在 iOS/Android 上都会踩坑,到了 OpenHarmony 上问题更明显:设备离线时收藏页什么都没有,接口慢时收藏页一直转圈,资讯源下架后收藏直接变成死数据。
我最后采用的是一份冗余快照模型:
ts复制type FavoriteItem = {
id: string;
title: string;
summary: string;
coverUrl: string;
source?: string;
addedAt: number;
read: boolean;
};
在用户点击收藏的瞬间,把当时这条资讯的标题、摘要、封面图一起存进去。收藏列表页只从本地这份数据渲染,不依赖网络请求。这样做的核心原因是:收藏列表本质上是“用户主动保存的快照”,不是“服务端返回的实时列表”。你收藏的是那一刻对你有用的信息,哪怕后来文章被删了,用户依然可以在收藏里看到原文摘要,这是合理的用户体验。
addedAt 字段很多人会忽略。没有它,你就只能用数组的 push 顺序来排序,一旦要做“最近收藏优先”或者“按时间升序”,就得重构数据。read 字段是用来标记“是否已读”的,这个字段虽然不是必须的,但资讯类 App 的收藏列表里通常要区分已读和未读,提前加上能省以后一次数据迁移。
3.2 AsyncStorage 封装与启动时恢复
在 OpenHarmony 上,RN 的 AsyncStorage 不能直接用社区版本,要用适配包。我安装的是 @react-native-oh-tpl/async-storage,安装后导入方式和原来基本一致。
bash复制npm install @react-native-oh-tpl/async-storage
存储层我单独抽了一个文件,没有在业务组件里直接调用 AsyncStorage.setItem,因为收藏的读写逻辑后续大概率会加字段、做版本迁移,集中封装会好维护很多。
ts复制import AsyncStorage from '@react-native-oh-tpl/async-storage';
const STORAGE_KEY = 'favorites:v1';
export async function loadFavorites(): Promise<FavoriteItem[]> {
try {
const raw = await AsyncStorage.getItem(STORAGE_KEY);
if (!raw) {
return [];
}
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
return [];
}
}
export async function saveFavorites(items: FavoriteItem[]) {
try {
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(items));
} catch (e) {
// 这里我把日志打了出来,方便排查写入失败
console.warn('save favorites failed', e);
}
}
loadFavorites 里做 Array.isArray 判断很重要,因为本地数据一旦被破坏,JSON.parse 可能返回的是对象甚至 null,直接当数组用会在 .map 上报错。
接下来是启动时恢复。我在最外层包了一个 FavoritesProvider,进入 App 时异步读取本地存储,然后放入 React Context。这样不管是资讯列表页、详情页还是收藏页,都能拿到同一份收藏状态。
tsx复制type Action =
| { type: 'HYDRATE'; payload: FavoriteItem[] }
| { type: 'TOGGLE'; payload: FavoriteItem }
| { type: 'REMOVE'; payload: string }
| { type: 'MARK_READ'; payload: string };
初始化时不要默认 favorites 是空数组就直接渲染页面,否则会出现“进入 App 后前几秒收藏按钮全部是空心,突然变成实心”的现象。正确做法是给一个 hydrated 标志位,在本地数据没加载完之前,可以显示 loading 占位,或者干脆先不渲染收藏相关控件。我是在 Context 里加了 hydrated 字段,等 loadFavorites() 执行完再刷新。
写入时机也值得说。我是通过 useEffect 监听 favorites 变化,然后做 300ms 防抖后调用 saveFavorites。如果每次 toggle 都立刻写盘,第一是频繁 IO 在低端设备上会有肉眼可见的卡顿,第二是快速连续点击收藏/取消收藏时,会产生竞态问题,后写的覆盖先写的,导致数据不一致。防抖之后,用户快速点击只会触发一次最终写入,稳定很多。
4. 收藏交互与列表联动实现
4.1 心形按钮与防重复点击
收藏按钮的 UI 倒是没什么好说的,一个心形图标加一个背景反馈。我用了 Pressable 而不是 TouchableOpacity,因为 Pressable 对按压状态的控制更细,可以分别处理 style 里的 pressed 状态。
tsx复制<Pressable
hitSlop={8}
onPress={() => onToggle(item)}
style={({ pressed }) => [
styles.favBtn,
pressed && styles.favBtnPressed,
]}
>
<Icon name={isFavorite ? 'heart' : 'heart-outline'} size={20} color={isFavorite ? '#f5222d' : '#999'} />
</Pressable>
这里最容易出问题的是“防重复点击”。RN 的 onPress 本身并没有内置防抖,如果用户在 300ms 内连续点两下,dispatch 会被执行两次。第一次 dispatch 后,state 更新是异步的,第二次 dispatch 拿到的还是旧的 isFavorite 状态,结果就是“点一下收藏,再点一下还是收藏”,没有取消成功。
我在 reducer 里做了基于当前 state 的判断,不依赖外部传入的 isFavorite:
tsx复制case 'TOGGLE': {
const exists = state.items.some((item) => item.id === action.payload.id);
if (exists) {
return {
...state,
items: state.items.filter((item) => item.id !== action.payload.id),
};
}
return {
...state,
items: [{ ...action.payload, addedAt: Date.now() }, ...state.items],
};
}
这样每次 dispatch 都是基于最新的 state.items 来判断,不会出现连续点击状态错乱的问题。另外,如果组件外部因为用了 useCallback 闭包导致 isFavorite 值过期,建议让按钮组件内部自己根据 favorites.some(...) 实时计算,而不是从 props 传一个可能是旧的布尔值。
4.2 空状态、角标与跨页面同步
收藏列表页我用 FlatList 渲染,ListEmptyComponent 不能只放一句“暂无收藏”。我加了一个“去逛逛”按钮,点击后跳回资讯列表页。对一个内容型 App 来说,空状态是最好的引导入口,别浪费它。
tsx复制<FlatList
data={favorites}
keyExtractor={(item) => item.id}
renderItem={renderItem}
ListEmptyComponent={
<View style={styles.empty}>
<Text style={styles.emptyText}>还没有收藏任何资讯</Text>
<Button title="去逛逛" onPress={() => navigation.navigate('Home')} />
</View>
}
/>
跨页面同步是收藏模块最容易被忽略的部分。如果不做全局状态管理,详情页收藏了,返回列表页之后列表页不知道收藏状态变了,心形图标就不会更新。我一开始图省事,在详情页和列表页各自维护一份收藏状态,结果经常出现“详情页显示已收藏,列表页还是未收藏”的尴尬情况。
解决方案很简单:把 FavoritesProvider 放在 NavigationContainer 外面,让所有页面共享同一份 Context。这样详情页点击收藏后,列表页的 favorites 天然更新,Tab 角标也会更新,不需要手动发事件通知。
Tab 角标的实现更简单,直接在 Tab 组件里读 favorites.length,不需要额外的统计逻辑:
tsx复制const { favorites } = useFavorites();
const unreadCount = favorites.filter((item) => !item.read).length;
这里有一个性能优化点:不要直接在列表页的每条 item 里都调用 useFavorites(),因为 Context 更新会让所有消费它的组件重渲染。更好的做法是主列表组件用 useFavorites(),然后把 isFavorite 和 onToggle 作为 props 传给子组件,子组件用 React.memo 包一层,避免收藏状态变化时整列表全部刷一遍。
4.3 点击页面其他区域执行某个函数的实现思路
这个需求在收藏模块里很常见:比如收藏列表支持“长按某个收藏项进入多选管理”,然后你希望点击其他区域退出多选;或者点击收藏面板以外的区域自动关掉面板。网上搜“rn 如何实现点击页面其他区域执行某个函数”,搜出来的方案大多是用 TouchableWithoutFeedback 包一层大 View。但这在收藏列表里会踩一个很大的坑:如果你用 TouchableWithoutFeedback 直接包住整个 FlatList,那么点击列表里的任何一个 item 都会先触发外层 onPress,导致你刚点开一个收藏项,面板就关了或者多选退出了。
我的做法是把“背景遮罩”和“内容面板”分开成两个绝对定位层次,背景层处理点击空白关闭,内容面板盖在上面,这样点击内容区域时事件不会传给背景层。
tsx复制<View style={styles.root}>
{/* 主体内容,比如收藏列表 */}
<FlatList ... />
{/* 遮罩层,点击关闭面板 */}
{isPanelVisible && (
<TouchableWithoutFeedback onPress={closePanel}>
<View style={StyleSheet.absoluteFill} />
</TouchableWithoutFeedback>
)}
{/* 浮层面板,盖在遮罩上面 */}
{isPanelVisible && (
<View style={styles.panel}>
...
</View>
)}
</View>
StyleSheet.absoluteFill 让遮罩铺满整个页面,面板组件在遮罩之后渲染,自然盖在上面。点击面板内部不会触发遮罩的 onPress,点击面板以外的任何区域,都会触发遮罩的 closePanel。这个方案的关键是层级顺序:遮罩先、面板后。
如果需求是“点击列表页空白处收起键盘”,那就不要用这个方案了,因为整个列表都是可点击区域,很容易误触。更合理的做法是在 ScrollView 或 FlatList 的 onScroll 事件里执行 Keyboard.dismiss(),这样用户滚动列表时键盘自动收起,既符合移动端习惯,又不会跟列表项的点击事件打架。
5. 常见问题与排查实录
5.1 AsyncStorage 原生模块找不到
这是 OpenHarmony 上跑收藏功能最常遇到的报错,控制台会出现类似 NativeModule: AsyncStorage is null 的信息,页面运行到读收藏数据时直接崩溃。
我排查时的第一反应是去看 package.json,确认装的是不是适配包。如果装的是 @react-native-async-storage/async-storage,在 react-native-harmony 环境里通常不能用,需要换成 @react-native-oh-tpl/async-storage。改完包名之后,删掉原生构建缓存,重新执行完整构建,不能只 reload JS。
bash复制# 在 harmony 工程目录重新构建
hdc uninstall com.example.steamnews
hdc install entry-default-signed.hap
如果确认包装对了、也构建了,还是报错,就检查 hdc 连接设备是否正常,以及设备的 API 版本是否在适配包支持范围内。rk3568 这类开发板如果系统版本太老,新版本适配包的原生 so 库可能无法加载,这时候要么升级系统镜像,要么降低适配包版本。
5.2 低配设备上收藏列表滑动卡顿
用 rk3568 实测,收藏列表在首次进入时会出现明显掉帧,滑动起来也一卡一卡的。第一反应以为是设备性能问题,但排查之后发现大多是我们自己的代码写得不够精细。
第一个原因是收藏列表的 item 没有固定高度。FlatList 如果不给 getItemLayout,需要动态测量每个 item 的高度,列表项多了以后测量成本很高。收藏卡片这种结构固定、内容长度基本可控的场景,完全可以在数据层就统一截断摘要字数,固定卡片高度,然后设置:
tsx复制getItemLayout={(data, index) => ({
length: CARD_HEIGHT,
offset: CARD_HEIGHT * index,
index,
})}
第二个原因是收藏状态变化导致列表整体重绘。我最早是把 useFavorites() 放在每个收藏卡片组件里调,结果任意一条收藏状态变化,所有卡片都重新 render。优化方式是让列表页统一管理 Context,卡片组件改成纯展示组件,用 React.memo 包裹:
tsx复制const FavoriteCard = memo(({ item, onToggle }) => {
return (
<View style={styles.card}>
<Text numberOfLines={2}>{item.title}</Text>
<Pressable onPress={() => onToggle(item)}>...</Pressable>
</View>
);
});
另外,收藏卡片的封面图在低端设备上特别容易造成卡顿。我最后给图片组件加了缩略图参数和质量参数,封面图控制在 256 宽度以内,resizeMethod="resize",实测滑动流畅度提升很明显。开发阶段如果用 debug 模式测试,卡顿会更严重,建议直接用 release 包验证性能。
5.3 收藏状态错乱与“点击没反应”
另一个常见现象是:在详情页收藏成功,返回列表页后心形图标没变;或者收藏列表里点“取消收藏”,状态变了一下,切走再切回来,又恢复了原样。这类问题大多是状态同步和异步写入时序导致的。
先看第一个场景。详情页和列表页如果各自用了不同的收藏状态源,就会出现不同步。我的排查方法是直接看两个页面是不是都被同一个 FavoritesProvider 包裹,如果没有,把 Provider 提升到导航容器外层即可。这和 4.2 节说的是同一个问题,跨页面状态必须走同一个 Context 源。
第二个场景更隐蔽。取消收藏后,UI 变了,但数据恢复原样,一般是写入时序问题。比如组件卸载时触发了保存,保存的是旧数据;或者防抖保存的定时器还没执行完,App 就被切到后台或退出,导致这次写入没有成功。我的经验是:
- 在
AppState从 active 切到 background 时,主动 flush 一次防抖队列,强制执行保存。 - 在应用退出前,不要依赖异步保存,尽量在每次收藏状态变更后,及时写入,防抖时间不要设太长。
- 如果收藏数据对一致性要求很高,可以改成“变更即写”,性能损耗通常可以接受,尤其是收藏这种低频操作。
我自己的排查顺序是:先确认收藏写库成功,再确认 UI 状态是否刷新,最后才怀疑存储插件本身的问题。按这个顺序,大多数“收藏了没反应”都能在五分钟内定位到具体环节。从实际操作来看,RN 在 OpenHarmony 上做收藏这种模块,真正的难点从来不是“能不能写”,而是“怎么写得稳、写得顺”。
