上周我接到一个需求:把团队一直在做的 Steam 资讯 App 迁移到 OpenHarmony 设备上,第一个要交付的功能模块是“浏览历史页面”。这个 App 的主端技术栈一直是 React Native,已经有 Android 和 iOS 版本,用户在这里看游戏资讯、跟踪促销活动和版本更新。现在要新增 OpenHarmony 支持,我得先证明 React Native for OpenHarmony(以下简称 RNOH)不是只存在于文档里,而是真能把页面跑起来。这篇文章从技术选型、数据设计、页面实现到踩坑排错,完整记录一次基于 React Native for OpenHarmony 的实战过程,希望能给同样在做 OpenHarmony 适配的移动端团队一些参考。
1. 为什么是 React Native:迁移 OpenHarmony 时我做的第一道选择题
1.1 团队约束比技术先进更重要
接到需求后,第一个问题不是“怎么写浏览历史页”,而是“用什么写”。当时团队里有三种声音:用 ArkUI(ArkTS)原生重写、用 Flutter 重写、用 React Native 做 OpenHarmony 适配。我最终选了第三种,原因很现实:团队里大部分人都在写 React 和 TypeScript,Android 和 iOS 端的历史页面已经用 React Native 实现了九成。如果换成 ArkUI 或 Flutter,意味着两套完全独立的代码,后续每个小改动都要双倍工量。
另外还有一个关键点:热更新。资讯类 App 的内容迭代非常快,今天加一个卡片字段,明天改一个推荐位逻辑,如果全部走原生发版周期,审核和灰度成本都太高。React Native 能把 JS 侧逻辑动态下发,OpenHarmony 环境也保留了这条路,这对运营驱动的产品价值很大。浏览历史模块又恰好涉及列表、图片、本地存储、路由跳转这些典型能力,拿它来验证 RNOH 的边界最合适不过。
1.2 RN for OpenHarmony 的当前边界
RNOH 的目标是让标准 React Native 应用直接运行在 OpenHarmony 设备上,相关 SIG 团队通过重新实现 React Native 的 C++ 渲染层,与 ArkUI 的渲染引擎对接,再把系统能力通过原生模块暴露给 JS。现在主流支持的是 React Native 0.72 左右的版本,核心组件和基础 API 已经能跑,但并不意味着每个 npm 包都能开箱即用。
我当时整理了这样一张兼容性心谱:
| 能力类型 | 代表库 | RNOH 上的状态 |
|---|---|---|
| 纯 JS 库 | dayjs、zustand、axios | 基本可用,无原生依赖 |
| 核心组件 | View、Text、FlatList | 原生支持 |
| 存储 | AsyncStorage | 需要安装 @react-native-oh-tpl/async-storage 适配包 |
| 图片缓存 | react-native-fast-image | 目前社区适配不完整,需要自己包一层 |
| 导航 | @react-navigation | 核心可用,但原生栈动画要真机测试 |
| 原生 SDK | 各种地图、推送、支付 | 基本都要等官方或社区适配 |
看到这张表,我反而不慌了。它说明 RNOH 已经从一个 Demo 框架走到了“大部分业务页面能跑”的阶段,只是第三方面要先查清楚再动手。
1.3 选型后定的技术验证清单
定了 React Native 方向后,我没有直接写页面,而是先列了一个技术验证清单,每项都有明确的通过标准。
- 环境搭建:DevEco Studio 能创建 OpenHarmony 工程,并正确引入 RNOH 的 SDK 依赖。
- Hello World:一个空 RN 页面能在真机上 5 秒内从启动到渲染完成。
- 导航链路:首页能 push 到浏览历史页面,返回不闪退。
- 本地存储:写入 200 条历史记录,重启 App 后能完整读出来。
- 列表压测:渲染 200 条混合长度的记录,快速滚动不掉帧。
- 构建产物:Release 包能离线加载 JS Bundle,不依赖开发机 Metro 服务。
这个清单听起来很基础,但真实帮我挡掉了后面至少一半的坑。特别是最后一条“离线加载 Bundle”,很多团队在开发模式下跑得欢,一发 Release 就白屏,原因就是 Bundle 没有正确打进 HAP 包。
2. 浏览历史页到底在存什么:数据模型、存储方案与清理策略
2.1 历史条目字段设计,别把页面写死
浏览历史页面看起来简单,但最容易犯的错是把数据结构设计成“够用就行”。我一开始的粗版本长这样:列表里只有标题、封面和时间。写完才发现,产品要的还不止这些:要区分资讯类型,要展示摘要,要统计阅读时长,还要能跳转回原文。如果数据结构不支持,后期就得做数据迁移,最痛苦。
最终我定的 TypeScript 接口是这样:
export type HistoryItemType = 'news' | 'promotion' | 'update' | 'column'; export interface HistoryItem { // 唯一 ID,用纳秒时间戳加随机数生成 id: string; // 内容类型:资讯、促销、版本更新、深度专栏 type: HistoryItemType; // 完整标题 title: string; // 列表页显示的摘要,限制在 80 字以内 summary: string; // 来源标识,比如 Steam News、社区商店页 source: string; // 封面图直链,可能是空 coverUrl?: string; // 跳转目标地址,用于打开原文页 targetUrl: string; // 访问时间,存的是毫秒时间戳 visitedAt: number; // 用户在原文页停留的时长,单位秒 readDuration?: number; }有人会问,visitedAt 为什么不用 ISO 字符串?因为排序、时间分组、计算“今天/昨天/更早”都需要时间戳,直接参与数值比较最方便。而且存储格式越小越好,异步存储的读写压力也能小一点。
2.2 本地存储选型:我在 RNOH 环境下的对比结果
确定字段之后,第二步是选存储方案。业界常见的有 AsyncStorage、MMKV、SQLite,以及 OpenHarmony 自己的 Preferences。我在 RNOH 环境里都做了快速验证,对比结果如下:
| 方案 | 优点 | 缺点 | RNOH 适配成本 |
|---|---|---|---|
| AsyncStorage | 键值存储简单,RN 社区最常用 | 大量条目时读写偏慢,不适合复杂查询 | 安装 @react-native-oh-tpl/async-storage 即可 |
| MMKV | 性能极高,支持增量写入 | 需要原生依赖,RNOH 上国内适配较少 | 高,要自己编译原生代码 |
| SQLite | 支持复杂查询和去重 | 需要 sqlite 原生模块,包体积变大 | 中,有社区方案但要踩坑 |
| Preferences | OpenHarmony 原生偏好存储 | JSON 序列化需自己做 | 低,但偏底层 |
对浏览历史这种场景,数据量通常只有几百条,单次读取也就十几 KB,根本到不了 SQLite 的性能优势区。很多团队一上来就上 SQLite,其实是过度设计。所以我选择 AsyncStorage,把历史记录作为一条 JSON 数组整体读写,简单直接,还少一次原生桥接的适配风险。
2.3 去重与过期策略的具体实现
浏览历史的去重规则必须想清楚,否则刷几次首页,历史列表就堆满了重复内容。我的规则是:同一个 targetUrl 视为同一条信息,每次访问只更新 visitedAt 和 readDuration,把这条记录挪到最前面,不新增。
过期策略方面,产品经理给的指标是“保留最近 90 天”,超过 90 天且用户从未手动收藏的内容直接清掉。我用一个常量控制:
const STORAGE_KEY = 'history_items_v1'; const MAX_HISTORY_COUNT = 300; const MAX_AGE = 90 * 24 * 60 * 60 * 1000; // 90 天毫秒数 export async function addHistory(item: HistoryItem): Promise<void> { const raw = await AsyncStorage.getItem(STORAGE_KEY); const list: HistoryItem[] = raw ? JSON.parse(raw) : []; // 先按 targetUrl 去重 const filtered = list.filter(i => i.targetUrl !== item.targetUrl); // 新条目插到最前面 const merged = [{ ...item, visitedAt: Date.now() }, ...filtered]; // 清理过期数据,并限制最大条数 const now = Date.now(); const pruned = merged .filter(i => now - i.visitedAt < MAX_AGE) .slice(0, MAX_HISTORY_COUNT); await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(pruned)); }注意 STORAGE_KEY 我带了_v1后缀。别小看这个细节,当后续数据结构升级,或者要迁移到远端同步时,版本号就是你做数据迁移的抓手。没有版本号的存储键,遇到老数据只能直接丢掉。
3. 页面实现:从本地数据到资讯卡片的完整链路
3.1 数据层封装:Repository + Hook,避免页面堆逻辑
我曾经见过一个页面,加载、去重、排序、解析全写在 useEffect 里,两千行代码的大组件。这次我特意把数据层单独抽出来,页面组件只负责渲染,不碰 AsyncStorage。
数据访问层叫 HistoryRepository:
export class HistoryRepository { static async getAll(): Promise<HistoryItem[]> { const raw = await AsyncStorage.getItem(STORAGE_KEY); if (!raw) return []; try { const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed : []; } catch { // 数据损坏时兜底,返回空列表但不抛异常 return []; } } static async remove(id: string): Promise<void> { const list = await this.getAll(); const next = list.filter(item => item.id !== id); await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } static async clearAll(): Promise<void> { await AsyncStorage.removeItem(STORAGE_KEY); } }然后封装成 Hook,让组件可以拿到加载状态和操作函数:
export function useHistory() { const [items, setItems] = useState<HistoryItem[] | null>(null); const refresh = useCallback(async () => { const data = await HistoryRepository.getAll(); setItems(data); }, []); useEffect(() => { refresh(); }, [refresh]); const removeItem = useCallback(async (id: string) => { await HistoryRepository.remove(id); setItems(prev => prev?.filter(item => item.id !== id) ?? null); }, []); const clearAll = useCallback(async () => { await HistoryRepository.clearAll(); setItems([]); }, []); return { items, refresh, removeItem, clearAll }; }注意我把 items 的初始状态设为 null,而不是空数组。这样页面能区分“正在加载”和“确实没有历史记录”两种情况。很多 App 的浏览历史页空态一闪而过,其实就是没有保留这个 null 状态。
3.2 按天分组的 SectionList 渲染与卡片设计
浏览历史页最舒服的阅读方式,是按时间分组。今天看的、昨天看的、7 天前的、更早的,每组一个小标题。我用 SectionList 而不是 FlatList 来做,轻松实现分组和吸顶小标题。
const sections = useMemo(() => { if (!items) return []; const groups = new Map<string, HistoryItem[]>(); for (const item of items) { const label = getTimeGroupLabel(item.visitedAt); if (!groups.has(label)) groups.set(label, []); groups.get(label)!.push(item); } return Array.from(groups.entries()).map(([title, data]) => ({ title, data })); }, [items]); <SectionList sections={sections} keyExtractor={item => item.id} renderItem={({ item }) => <HistoryCard item={item} />} renderSectionHeader={({ section }) => ( <Text style={styles.sectionHeader}>{section.title}</Text> )} stickySectionHeadersEnabled={true} onRefresh={refresh} refreshing={false} ListEmptyComponent={<EmptyState />} />分组逻辑很简单:今天、昨天、一周内、更早。新浪微博和很多新闻类 App 都是这么分的,用户不用看具体时间也能定位。
卡片本身的设计也藏着性能细节。封面图我固定用 120x120 的圆角缩略图,而不是直接把原图塞进去。资讯类 App 的封面原图动辄 1080p,直接加载不仅慢,还会把内存吃满。
3.3 加载、空态、异常三态处理
这一节我吃了亏才想起来做。第一版页面只处理了有数据和没数据两种情况,结果用户清空历史之后,页面直接变白屏。因为 items 被置成了空数组,列表渲染没问题,但没有给用户任何视觉反馈。
后来我把三态拆开:
- 加载态:items 为 null 时显示一个轻量 loading,用 ActivityIndicator 加“正在加载浏览历史”文案。
- 空态:items 为空数组时显示居中图标和引导文案“你还没有浏览记录,去首页看看热门资讯吧”。
- 异常态:JSON 解析失败、存储读取报错时,显示“历史数据加载失败”和重试按钮。
异常态特别容易被忽略。实际上 AsyncStorage 也可能抛异常,比如系统存储空间不足,或者旧版本的数据格式变了。我在 Repository 层做了 try/catch,页面层也做了一层兜底,保证任何情况下都不会出现白屏。
3.4 图片缓存与缩略图加载的取舍
RNOH 的 Image 组件能加载网络图片,但默认没有完善的缓存策略。每次页面进入都要重新下载封面图,不仅慢,还会浪费用户流量。我在热词里看到有人提到“启动白屏”和“列表卡顿”,其中一部分原因就是图片请求阻塞了列表渲染。
我的处理方式分两层:
第一层,服务端在返回资讯列表时,直接给你一张处理好的小尺寸缩略图 URL,避免客户端下载原图后再压缩。
第二层,客户端实现一个极简的内存缓存 Map,key 是图片 URL,value 是 ImageSource。在同一个页面生命周期内,相同 URL 的图片直接复用。
const imageCache = new Map<string, ImageSource>(); export function getCachedCover(url?: string): ImageSource | undefined { if (!url) return undefined; if (imageCache.has(url)) return imageCache.get(url); const source = { uri: url, width: 120, height: 120 }; imageCache.set(url, source); return source; }如果后续历史页图片越来越多,才需要考虑磁盘缓存或接入第三方图片加载库。现阶段这样已经能让滚动体验明显变好。
4. 真机踩坑实录:白屏、包兼容、抓包失败与列表卡顿
4.1 启动白屏的完整排查链路
RNOH 真机调试中最让人崩溃的问题就是白屏。开发模式下页面有时能转出来,有时直接卡在启动页之后一片空白。我这次遇到的白屏,跟前段时间热词里反复出现的“react native 启动白屏”完全是一类问题,不过根因有好几个,我按从外到内排了一遍。
第一步,确认 Metro 服务是否真的被设备访问到了。OpenHarmony 设备通过局域网 IP 访问开发机的 Metro,很多人下意识写 localhost,这在真机上绝对跑不通。我在 Metro 启动日志里看到设备 IP 有连入,说明网络路径没问题。
第二步,看 DevEco Studio 的日志。如果 JS Bundle 加载失败,日志里会有类似LoadScriptException或者bundle not found的关键字。我这次遇到的就是 Release 包忘记把 Bundle 打进 HAP 资源目录,导致设备离线启动时找不到 JS 脚本。
第三步,区分 Debug 和 Release。Debug 模式走 Metro 热更新,Release 模式必须打包进 HAP。很多团队只测了 Debug,上线前才发现 Release 起不来,这是最常见的坑。我在验证清单里专门加了“不依赖 Metro 的离线启动”这一项,就是为这个问题准备的。
第四步,如果前面都没问题,考虑是不是根视图挂载时机太早。RNOH 需要在 ArkUI 的 onWindowStageCreate 之后再挂载 RN 根视图,如果启动初始化顺序不对,也会白屏。
4.2 第三方 RN 组件在 OpenHarmony 上的兼容性测试
我在历史页面里原本想偷懒直接引 react-native-fast-image,结果发现 RNOH 社区还没有稳定适配版本,强行装上去后编译都过不了。后来换成了原生 Image 加内存缓存,反而更可控。
再比如导航库。@react-navigation 的核心部分在 RNOH 上能用,但原生栈的那层动画有时候在 OpenHarmony 设备上会表现异常,比如转场卡顿、返回时闪一下。我的建议是:页面跳转尽量用纯 JS 的 navigator 逻辑,不要去依赖原生平台View 的动画效果。
这里给后来者一个实操原则:能选纯 JS 库就选纯 JS 库;凡是要安装带@react-native-oh-tpl/前缀的包,先查它的 README 支持版本再动工。没有现成适配包的原生功能,宁可自己用 ArkTS 写一个极简模块桥接,也不要花一两天去折腾一个有隐患的三方包。
4.3 网络层:抓包失败与上游数据异常的处理
开发过程中有个小插曲:我想抓一下资讯接口的请求和响应,看看本地缓存有没有生效,结果真机抓包一直失败。查了半天发现是 HTTPS 证书问题。OpenHarmony 设备上调试时,如果没有把代理证书装进系统信任证书库,Charles 或者 DevEco 自带的网络分析都看不到解密后的流量。
解决办法分两种:如果只是本地调试,可以在代码里临时忽略证书校验;如果要长期用,建议把调试代理的 CA 证书安装到设备系统证书目录。注意临时忽略证书校验的代码一定别带到 Release 包,这在生产环境是严重安全漏洞。
另外要说一下上游数据。我们资讯服务端会去拉 Steam 商店和社区的数据源,偶尔会遇到上游连接不稳定,返回类似“server failed to connected to steam”的异常。这个问题的处理不在客户端,而在服务端要加缓存降级。我的做法是:历史页本身不直接依赖最新资讯接口,打开页面时先把本地历史记录渲染出来,再静默请求一次当前用户最新浏览过的内容元数据。如果请求失败,本地数据照常显示,只是封面和标题可能不是最新,不让网络异常影响主流程。
4.4 浏览历史列表性能优化的实测调整
历史页条目多了以后,第二个常见问题就是滚动卡顿。我在真机上实测,150 条记录的时候滚动还流畅,到了 300 条就明显能感到掉帧。这个量级还不至于上分页加载,但需要把列表渲染参数好好调一遍。
最终我把 FlatList/SectionList 的关键参数做了如下调整:
| 参数 | 默认值 | 调整值 | 调整原因 |
|---|---|---|---|
| initialNumToRender | 10 | 20 | 首屏加载更多卡片,减少白屏跳动 |
| maxToRenderPerBatch | 10 | 20 | 单批渲染更多,加快滚动出屏速度 |
| windowSize | 21 | 15 | 缩小预渲染窗口,降低内存占用 |
| removeClippedSubviews | false | true | 裁剪屏幕外视图,减少绘制开销 |
打开 removeClippedSubviews 之后,真机滚动的流畅度提升非常明显。但这一步在 RNOH 上有个注意点:如果卡片里有绝对定位的弹层或 Tooltip,裁剪可能会让它们提前消失。所以只对纯列表项的页面开启,不要全局开。
还有一个容易被忽略的性能点:不要在 renderItem 里创建匿名函数。每次渲染都创建新的闭包,会破坏 React 的 memo 优化。我把 onPress 事件提前绑定到 item 上的 handler,用 useCallback 包一层,滚动时明显更跟手。
5. 复盘:如果重新做一遍,我会调整哪些决策
5.1 值得保留的决策
先说哪些决策我是满意的。用 React Native 而不是 ArkUI 重写,这个方向我觉得非常正确,团队代码复用率接近 90%,后续维护只需要改一套逻辑。
本地存储选 AsyncStorage 而不是 SQLite,也算明智。浏览历史这个场景数据量不大,AsyncStorage 加 JSON 数组的方案足够用,接入成本只有一个适配包。数据模型加版本号、Repository 做隔离、Hook 暴露状态,这套分层以后迁移到账号同步时也不用推倒重来。
三态处理也值得留作团队规范。加载中、空数据、加载异常这三件事,每个列表页都该有标准 UI,浏览历史页只是第一个吃螃蟹的。
5.2 我会改掉的地方
第一,一开始就应该把 CI 配好。现在我们都是本地构建 Release 包,很容易出现“我本地能跑,你本地崩了”的尴尬。如果每个 PR 都自动构建一次 OpenHarmony 产物,白屏和依赖问题能提前半天发现。
第二,历史数据应该尽早设计远端同步的抽象层。这次只做了本地存储,但产品肯定希望用户换个设备也能看到自己的浏览记录。如果我在 Repository 层一开始就定义好接口,而不是直接操作 AsyncStorage,后面加云端同步会轻松很多。
第三,图片链路不要直接依赖上游原图。上游一个高清封面图可能几百 KB,历史页一次渲染几十张,对 OpenHarmony 设备的内存不太友好。早点把所有资讯图走一遍 CDN 压缩,体验会好很多。
5.3 给后来者的最后建议
最后说点实在的。如果你和我一样,要在 React Native for OpenHarmony 上做一个资讯类模块,我给你三条建议。
第一条,先做最小闭环再铺量。不要一上来就想着把首页、详情、历史、个人中心全部适配,先跑通一个能写入、能读取、能渲染的页面,把工具链摸熟。
第二条,把“启动白屏”当成必考题。搭建完环境的第一件事,就是验证 Debug 和 Release 两种模式下都能离线启动。这个问题不过关,后面所有页面都建立在流沙上。
第三条,多花时间看 RNOH 官方仓库的 issues。很多新踩到的坑,可能已经在 issue 里有了结论。我当时排一个导航动画的兼容问题,就是在 issue 评论区找到的临时方案,比自己瞎猜快得多。
浏览历史页面只是整个 Steam 资讯 App 迁移 OpenHarmony 的第一步,但它把数据、UI、性能、兼容性这些关键点都过了一遍。这个页面跑顺了,后面首页和详情页的适配就有了可复用的方法论和工具链。希望这次的实践记录,能帮你少走几步弯路。