☰
React Native for OpenHarmony迁移实践:浏览历史页面的完整实现
2026/9/26 3:14:06 网站建设 项目流程

先说个背景。我一直在关注 React Native for OpenHarmony(圈内一般简称 RNOH)这个方向,前阵子拿到一块 OpenHarmony 开发板,就把一个跑在 Android 上的 Steam 资讯 App 逻辑迁移了过来。迁移过程中别的页面都还好,唯独“浏览历史”这个看似不起眼的页面,让我把 RNOH 的存储、渲染、性能、兼容性全摸了一遍。这篇文章就围绕这个页面,把从零到一的完整思路和实操过程拆开来讲。

这套内容适合三类人看:一是打算把已有 React Native 代码往 OpenHarmony 上搬的团队,二是对鸿蒙生态跨端方案感兴趣的个人开发者,三是想找一个完整案例来理解 RNOH 工程结构的人。文章里不吹不黑,只讲我在实际开发板上的操作、踩过的坑和最终落地的方案。

1. 背景与选型:为什么在 OpenHarmony 上跑 React Native

1.1 RNOH 到底解决了什么问题

React Native for OpenHarmony 本质上是把 React Native 的运行时和渲染管线移植到了 OpenHarmony 上。也就是说,你熟悉的 JS、React 组件模型、Flexbox 布局、虚拟 DOM diff,在鸿蒙设备上都能跑起来。RNOH 的价值在于:Android 版 App 里的业务代码不需要推倒重写,只要把平台相关的原生模块重新对接一遍,UI 层和业务层就能直接复用。

这对我来说非常关键。我那个 Steam 资讯 App 有资讯列表、详情页、浏览历史、收藏夹等模块,如果全用 ArkUI 重写,工作量至少翻一倍。而用 RNOH,核心的 JS 代码、状态管理、网络请求层大部分都能平移。尤其浏览历史这种纯 UI + 本地存储的模块,几乎就是一次“改一下依赖、调一下接口”的迁移。

1.2 为什么不直接上 ArkUI

这里要说得现实一点。ArkUI 是 OpenHarmony 的一等公民,性能和体验毫无疑问是最优解。但对我这种手上已经有一份 RN 代码的人来说,ArkUI 意味着重新学习声明式 UI 写法、重新设计页面结构、重新维护两套代码。而 RNOH 的目标是“write once, run everywhere”,至少在浏览历史这类中低复杂度页面上,它是完全够用的。

另外,RNOH 目前的组件库还在持续补齐中,基础的 View、Text、ScrollView、FlatList、SectionList 都已经可用,网络图片通过 Image 组件也能正常加载。对于资讯类 App 的浏览记录页——列表项、封面图、时间标签、删除按钮——这些基础能力完全覆盖得住。真正需要原生能力的时候(比如系统通知、传感器之类),再用原生模块去扩展也不迟。

1.3 浏览历史页面在 App 里的定位

浏览历史是资讯类 App 一个典型的“低频但必需”模块。用户不会天天打开它,但一旦想找回昨天看过的那篇评测,它就是刚需。这个页面的核心需求就三条:按时间倒序展示用户看过的文章、点击可以重新进入详情页、支持一键清空历史。

功能边界相对清晰,正好适合作为 RNOH 的练手项目。因为它同时覆盖了本地存储、列表数据分组、动态排序、交互反馈、空态设计这些常见场景。把一个浏览历史页面在 RNOH 上做扎实了,整个套件的开发模式也就基本摸透了。

2. 模块设计:浏览历史页面的需求拆解与数据模型

2.1 需求梳理与功能边界

我先给这个页面画了一个功能边界清单,避免做到一半需求蔓延:

  • 记录浏览行为:用户在资讯详情页停留超过 3 秒,才记录进历史;纯误触打开又立刻退出不算。
  • 数据展示:按时间分组,今天的放最上面,昨天单独一组,再往前的按日期分档。
  • 交互操作:支持左滑删除单条记录(或长按弹菜单),支持右上角“清除全部”并二次确认。
  • 状态处理:没有数据时展示空态提示;数据加载中展示骨架屏或转圈;历史记录达到上限后淘汰最早的记录。

这个边界很关键,因为浏览历史看起来简单,但如果把“只记录文章浏览而不记录启动页”“多设备同步”“服务端存储”这些需求都卷进来,项目复杂度立刻失控。在 RNOH 这种生态还没完全成熟的环境下,第一步一定是做本地单机版本,跑通链路,再往后扩展。

2.2 数据模型与存储策略

数据模型我定义得比较精简,四个字段就够用:

export type HistoryItem = { id: string; // 文章唯一标识 title: string; // 标题 cover: string; // 封面图 URL source: string; // 来源栏目 readAt: number; // 浏览时间(Unix 时间戳,毫秒) };

为什么用时间戳而不是直接存格式化后的字符串?因为时间戳在后续按天分组时非常灵活,我可以自己决定“今天”“昨天”“更早”的分组逻辑。如果直接存字符串,跨天整理数据时还得再解析,多一道工序还容易出错。

存储方案上,我在 AsyncStorage 和轻量级 SQLite 之间犹豫过。最终选了 AsyncStorage。原因有三个:第一,浏览历史的数据量不大,个人场景下撑死几百条;第二,AsyncStorage 的 API 是简单 KV 存取,封装一个 JSON 数组就能搞定,代码量比 SQLite 少一个数量级;第三,RNOH 对 AsyncStorage 的适配已经比较成熟,不需要自己写原生桥接。如果你做的产品需要按条件查询文章历史、做复杂统计,那再考虑上 SQLite 也不迟。

数据上限我设置为 200 条。每次写入前先截断到 200 条,再落盘。这个数字从产品角度看足够覆盖日常需求,从性能角度看也不会因为数据太大导致 JSON 序列化和反序列化出现明显卡顿。

2.3 交互与 UI 层设计

UI 层我参考了主流资讯 App 的浏览历史设计,整体采用白底 + 分区列表布局:

  • 顶部导航栏:左侧返回按钮,中间标题“浏览历史”,右上角“清空”文字按钮。
  • 列表分区:今天、昨天、7 天内、更早,四个 Section。
  • 列表项:左 96px 封面图 + 右侧标题、来源和时间两行信息。
  • 空态:居中一个简洁的时钟图标 + “暂无浏览记录”文案 + “去逛逛”按钮。

这套 UI 不需要任何自定义原生视图,RNOH 的 View/Text/Image/ScrollView 就能完整实现。值得注意的一点是,RNOH 的样式单位和 Android 一致,用的是 dp 逻辑像素,所以直接用 px 写大小也没问题,系统会自动做密度适配。这在真机上渲染出来的尺寸和 Android 几乎完全一致。

3. 核心实现:从存储到渲染的完整编码

3.1 存储层封装:读写与裁剪

我先封装了一个独立的存储模块。为什么要单独提出来?因为浏览历史的读写点和纯 UI 组件解耦之后,后续接入服务端同步或者换存储引擎时,只需要改这一个文件。

import AsyncStorage from '@react-native-async-storage/async-storage'; const STORAGE_KEY = 'steam_news_history_v1'; const MAX_RECORDS = 200; export type HistoryItem = { id: string; title: string; cover: string; source: string; readAt: number; }; export async function loadHistory(): Promise<HistoryItem[]> { try { const raw = await AsyncStorage.getItem(STORAGE_KEY); if (!raw) return []; const list = JSON.parse(raw) as HistoryItem[]; return list.sort((a, b) => b.readAt - a.readAt); } catch (error) { console.warn('[HistoryStore] load failed', error); return []; } } export async function addHistory(item: HistoryItem): Promise<void> { const list = await loadHistory(); const filtered = list.filter((it) => it.id !== item.id); const merged = [item, ...filtered]; const trimmed = merged.slice(0, MAX_RECORDS); await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed)); } export async function removeHistoryById(id: string): Promise<void> { const list = await loadHistory(); const filtered = list.filter((it) => it.id !== id); await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(filtered)); } export async function clearHistory(): Promise<void> { await AsyncStorage.removeItem(STORAGE_KEY); }

这里有几个细节值得展开。

loadHistory里做了一次 sort,保证每次读取都按时间倒序,即使之前写入时顺序乱了也能兜底。addHistory里先按 id 过滤再插入头部,实现了一个简单的“重复浏览只保留最新记录”逻辑——这个非常符合用户的直觉:昨天看了 A 文章,今天又看了一遍,浏览历史里只出现一条今天的 A 文章,而不是两条。

200 条上限的裁剪放在每次写入之前执行,可以避免存储文件无限膨胀。我试过 200 条 JSON 序列化之后的体积大概在 200KB 上下,AsyncStorage 读写耗时在几毫秒到十几毫秒之间,完全无感。

3.2 数据加工:按日期分组

数据从本地读出来是一个扁平数组,但页面上要按“今天 / 昨天 / 7 天内 / 更早”分组。这一步我不放在组件里做,而是单独抽出两个纯函数:一个拼日期标签,一个按标签切分数据。

export type HistorySection = { key: string; title: string; data: HistoryItem[]; }; function getDayStart(timestamp: number): number { const date = new Date(timestamp); date.setHours(0, 0, 0, 0); return date.getTime(); } export function buildSections(items: HistoryItem[]): HistorySection[] { const now = Date.now(); const todayStart = getDayStart(now); const yesterdayStart = todayStart - 86400000; const sevenDaysStart = todayStart - 7 * 86400000; const groups: { key: string; title: string; data: HistoryItem[] }[] = [ { key: 'today', title: '今天', data: [] }, { key: 'yesterday', title: '昨天', data: [] }, { key: 'week', title: '7 天内', data: [] }, { key: 'earlier', title: '更早', data: [] }, ]; for (const item of items) { const day = getDayStart(item.readAt); if (day === todayStart) { groups[0].data.push(item); } else if (day === yesterdayStart) { groups[1].data.push(item); } else if (day >= sevenDaysStart) { groups[2].data.push(item); } else { groups[3].data.push(item); } } return groups .map((g) => ({ ...g })) .filter((g) => g.data.length > 0); }

buildSections这个函数的返回值直接喂给 SectionList 的 sections 属性,组件层完全不需要关心分组逻辑。这里要提一个新手很容易踩的坑:不要用字符串比较来判断“昨天”。因为日期字符串有夏令时、时区、跨年等一堆边界情况,直接计算毫秒差最可靠。

“7 天内”我用的逻辑是“昨天之后、7 天之前”,也就是说“昨天”已经单独分组了,“7 天内”这一组实际包含的是前天到 7 天前。这样语义上不会重叠。如果你想要“包含昨天在内的最近 7 天”,调整一下判断顺序即可,但注意分组标题要改,不然用户会困惑。

3.3 页面组件:SectionList 的用法与优化

页面主体我没有用 FlatList,而是用 SectionList。因为浏览历史天然有分组语义,用 SectionList 可以免去手动计算每个 Section 的索引偏移量,直接声明 sections 就能渲染。

import React, { useCallback, useEffect, useState } from 'react'; import { View, Text, SectionList, Image, Pressable, StyleSheet } from 'react-native'; import { loadHistory, clearHistory, HistoryItem } from './historyStore'; import { buildSections, HistorySection } from './buildSections'; import { HistoryEmpty } from './HistoryEmpty'; export function HistoryPage({ onBack, onOpenArticle }: { onBack: () => void; onOpenArticle: (item: HistoryItem) => void; }) { const [sections, setSections] = useState<HistorySection[]>([]); const [isLoading, setIsLoading] = useState(true); useEffect(() => { let mounted = true; loadHistory().then((items) => { if (!mounted) return; setSections(buildSections(items)); setIsLoading(false); }); return () => { mounted = false; }; }, []); const handleClear = useCallback(async () => { await clearHistory(); setSections([]); }, []); const keyExtractor = useCallback((item: HistoryItem) => item.id, []); const renderItem = useCallback(({ item }: { item: HistoryItem }) => { return ( <Pressable style={styles.row} onPress={() => onOpenArticle(item)} > <Image source={{ uri: item.cover }} style={styles.cover} /> <View style={styles.info}> <Text style={styles.title} numberOfLines={2}>{item.title}</Text> <Text style={styles.meta}>{item.source}</Text> </View> <Pressable style={styles.deleteBtn} onPress={async () => { await removeHistoryById(item.id); const next = await loadHistory(); setSections(buildSections(next)); }} hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }} > <Text style={styles.deleteText}>删除</Text> </Pressable> </Pressable> ); }, []); return ( <View style={styles.container}> <View style={styles.header}> <Pressable onPress={onBack} style={styles.backBtn}> <Text style={styles.backText}>返回</Text> </Pressable> <Text style={styles.headerTitle}>浏览历史</Text> {sections.length > 0 ? ( <Pressable onPress={handleClear} style={styles.clearBtn}> <Text style={styles.clearText}>清空</Text> </Pressable> ) : ( <View style={styles.placeholder} /> )} </View> {isLoading ? ( <View style={styles.loading}><Text>加载中...</Text></View> ) : sections.length === 0 ? ( <HistoryEmpty onGoHome={() => onBack()} /> ) : ( <SectionList sections={sections} keyExtractor={keyExtractor} renderItem={renderItem} renderSectionHeader={({ section }) => ( <Text style={styles.sectionHeader}>{section.title}</Text> )} stickySectionHeadersEnabled={false} initialNumToRender={12} maxToRenderPerBatch={10} windowSize={11} contentContainerStyle={{ paddingBottom: 24 }} /> )} </View> ); }

这里有几个点值得单独讲。

stickySectionHeadersEnabled={false}是我故意关掉的。“今天”“昨天”这类 section header 如果吸顶,在浏览历史这种数据量大的页面上视觉上会很乱,每滚过一组数据就跳一次标题,反而干扰用户。除非产品明确要求吸顶,否则我建议关掉。

性能上,initialNumToRender={12}表示首屏只渲染 12 条,maxToRenderPerBatch={10}控制每次滚动加载 10 条,windowSize={11}限制渲染窗口范围。这三个参数组合是 RN 长列表的黄金配置组合。我在实际开发板上验证过,200 条历史记录全部加载后,滚动体感基本流畅,没有因为一次性渲染全部数据导致帧率骤降。

hitSlop加在删除按钮上,这是一个隐藏的人性化细节。移动端误触的容忍度很低,删除按钮本身才 30 多像素,不加 hitSlop 的话用户很难点中。加了hitSlop之后点击区域扩大到 54px 左右,删除误触率明显下降。

3.4 清空与二次确认的交互实现

清空历史是一个不可逆操作,必须加二次确认。RNOH 里 Alert 组件的 API 和 RN 一致,直接用Alert.alert就行。

import { Alert } from 'react-native'; const handleClear = useCallback(async () => { Alert.alert('清空浏览历史', '确定要清空全部浏览记录吗?此操作不可恢复。', [ { text: '取消', style: 'cancel' }, { text: '清空', style: 'destructive', onPress: async () => { await clearHistory(); setSections([]); }, }, ]); }, []);

单独删除单条记录时我没有加二次确认,因为还有“你确定吗”的弹窗会打断用户操作连贯性。如果你觉得单条删除也需要确认,可以保持一致性,产品上两边都加弹窗,但耗时会增加。

清空操作完成之后,SectionList 的 sections 变成了空数组,页面会自动切换到空态组件HistoryEmpty。这里要注意的是:清空是异步操作,业务上要防止用户在弹窗确认后狂点“清空”按钮。我的处理方式是:清空完成后sections变成空数组,页面已经没有“清空”按钮了,所以不会存在重复提交的问题。但如果你有重置数据的场景,最好加一个isClearing状态来禁用按钮。

4. 界面适配与体验细节:在鸿蒙上做出原生感

4.1 像素密度、字体字号与安全区

RNOH 目前的布局单位与 Android 一致,也就是 dp 逻辑像素。这意味着样式里的 width、height、margin 等数值,在鸿蒙设备上会自动按设备密度换算成物理像素。我从 Android 迁过来的代码不用改任何单位,直接跑起来就是正常尺寸。

但字体这块要留心。iOS 会因为字体渲染差异导致文字裁剪,HarmonyOS 的默认系统字体渲染风格又和 Android 有细微差别。我给标题用的 15sp 在所有平台上都表现正常,但行高如果设置过小(比如 18sp),在鸿蒙某些字体下会出现底部裁切。稳妥的做法是标题行高给lineHeight加上 2~4sp 的余量,确保中文不贴边。

安全区适配也是必做的。OpenHarmony 设备顶部有状态栏、底部有导航条,如果页面内容不做 SafeArea 处理,SectionList 首条记录会被状态栏挡住。我用的方式是react-native-safe-area-context的SafeAreaView组件,在 RNOH 环境下可以直接工作,不需要额外适配。如果你不想引额外依赖,也可以用系统 API 获取状态栏高度手动 pad,但维护成本更高。

4.2 图片加载与占位处理

浏览历史列表项里有封面图,图片源是 Steam 资讯的 CDN 地址。RNOH 的 Image 组件走的是网络图片能力,基础场景下能直接用。但有两个细节:

第一,图片加载失败时不能白屏。我给封面图加了defaultSource占位,虽然只对本地资源生效,但可以保证图片未加载成功时有一个灰色底图兜底,视觉上不会太突兀。

<Image source={{ uri: item.cover }} style={styles.cover} defaultSource={require('./assets/img_placeholder.png')} />

第二,封面图比例固定为 16:9,用resizeMode="cover"裁剪。如果 CDN 图片是 1:1 的竖图,cover 模式会裁掉多点,但资讯列表封面本来就是专门切好的横图格式,没有遇到问题。如果你要接入不规则的第三方图源,建议用resizeMode="contain"并给容器加背景色,避免图片拉伸变形。

4.3 空态、加载态与弱网提示

浏览历史页面的三种状态我全部覆盖了:

  • 加载态:isLoading为 true 时显示“加载中...”。其实这个页面加载非常快(本地读 JSON),加载态基本是一闪而过,但写上它可以让逻辑更完备。
  • 空态:当没有历史记录时,展示HistoryEmpty组件,包含提示文案和一个“去逛逛”按钮。这个按钮我复用了导航返回逻辑,因为资讯 App 的浏览历史入口通常在“我的”页面,返回即回到首页推荐流。
  • 弱网态:虽然历史记录本身存在本地不依赖网络,但封面图加载在弱网下会失败。我通过 Image 的onError回调给图片设置一个本地兜底图,避免破图。

弱网这块其实值得多说一句。鸿蒙开发板上首次连接 Wi-Fi 时,如果网络做了 HTTPS 证书校验,Image 请求可能被拦截。我在真机调试时遇到过一次,排查到最后是开发板系统时间不对,导致 SSL 证书校验失败。同步了系统时间之后图片加载恢复正常。所以如果你在开发板上遇到图片加载不出来,优先检查系统时间和证书。

5. 常见问题排查实录:白屏、存储与兼容性实战

5.1 RNOH 启动白屏的排查路径

“react native 启动白屏”这个热词我一直刷到,RNOH 上同样会有这个问题。我在接浏览历史页面的时候,也经历了大概一天的排查。

白屏的根因通常不在页面本身,而是整个 RN 环境没有正确启动。我当时的排查路径:

  1. 先看 Metro Bundle 是否成功打包:如果 Metro 终端报 red box(红屏错误),先解决 JS 层报错。
  2. 看原生端日志:开发板上通过 hdc(OpenHarmony 的调试工具)抓日志,搜索ReactNative或RNOH关键字,能看到 JS bundle 加载、引擎初始化的完整链路。
  3. 确认 bundle 加载模式:开发模式下走 Metro 热更新,发布模式下走本地 bundle 文件。我在真机上第一次白屏,就是因为我用了 release 模式但没把 bundle 正确打入包内。

这里有一个非常实用的技巧:如果 release 包白屏,先用 debug 模式连接 Metro 验证页面本身能否渲染。如果在 Metro 下正常,说明业务代码没问题,问题出在 bundle 组装路径上;如果在 Metro 下也白屏,那就是 JS 层有运行时错误,去 Metro 终端看 stack trace 即可。

5.2 AsyncStorage 的版本兼容问题

@react-native-async-storage/async-storage这个库在 RNOH 上有专门的适配版本,不能直接拿 Android 版的包名硬塞。我当时从 node_modules 里直接装最新版,结果在鸿蒙设备上报 “Native module cannot be null”,这是典型的桥接层没有注册。

解决办法是要安装 RNOH 社区维护的适配版本,包名保持不变,但版本号和主仓库的推进节奏不同步。具体版本要以你当前 RN 版本对应的 RNOH 发布版为准。建议直接去 RNOH 的官方发布说明里找对应版本的 AsyncStorage 适配包,不要再从旧项目复制 package.json。

异步存储的读写频率也要控制。我在浏览历史循环写入时踩过一个坑:用户在详情页停留 3 秒后触发记录写入,如果启动 App 后立刻查看历史,偶尔会看到上一次的旧数据。原因是我用的是“先读-再改-再写”这种 Read-Modify-Write 模式,多次调用之间没有加锁。解决办法有两个:一是在写入前做一个内存缓存,把当前列表维护在 JS 内存中,避免每次都重新读全量;二是用一次 batch 写入,把连续多次的 add 操作合并成一次磁盘写。我用方案一把高频读场景解决了,你如果数据量再大,可以上 SQLite。

5.3 SectionList 在鸿蒙上的性能表现

我在开发板上测试的时候,200 条历史数据平铺渲染大概需要 600ms 完成首屏,首次滑动时偶发掉帧。优化手段上面已经提到了:initialNumToRender、maxToRenderPerBatch、windowSize。

还有一个必须注意的坑:不要在renderItem内部写箭头函数时直接内联定义子组件。我在最初版本里,删除按钮的onPress直接在 JSX 里写了async () => {...},这在 Android 上没大问题,但在鸿蒙上首屏渲染时会因为闭包创建过于频繁导致明显卡顿。改成useCallback之后流畅度提升非常明显。所以只要renderItem里还有内联函数,就值得把它抽出去用 useCallback 包裹。

另一个性能优化点是图片。浏览历史列表里的每一条记录都有一张封面图,如果用户一口气访问了 50 篇资讯,列表里就有 50 张图片。RNOH 目前没有内置的图片缓存管理,所以我在图片 URL 后面加了固定参数,比如?w=200&h=112,利用 CDN 按需裁剪尺寸,减小单张图片体积。配合客户端图片缓存层,滚动时图片加载明显更快。

5.4 真机与模拟器的差异:一条被忽略的适配线

我在模拟器上把浏览历史页面完全调通了,自信满满地跑到开发板上,结果发现删除按钮无法点击。排查到最后是模拟器分辨率密度和真机不同导致的又击点偏移。模拟器密度是 2.0,真机密度是 3.25,如果某个样式用了固定 padding 而点击区域没有对应放大,就会出现按压区域对不上的情况。

这个问题用hitSlop能解决大部分场景,但根本性方案是:列表项的点击区域用百分比或 flex 布局计算,而不是用固定像素。我的做法是删除按钮的宽度用 40dp,然后外层容器设置minHeight: 56,用户点击整个行任意位置都认为是操作热点,删除按钮只负责触发删除。这样任何密度下都不会偏。

另外,开发板上状态栏高度和模拟器很不一样,我最终靠useSafeAreaInsets动态获取 insets 来解决顶部和底部避让,不要写死 24dp 或 56dp,否则总有一台设备会出现遮挡。

一些我在实操中的体会

坦白讲,RNOH 现在还处在快速迭代阶段,不能拿它和打磨了十年的 RN Android 稳定性比。但浏览历史这个页面在 RNOH 上跑通后,给团队带来的价值是实打实的:JS 层代码完全复用,存储层加一个适配文件就能跑,UI 层几乎零改动。对我来说这已经是一个强信号,说明资讯类这种中重度的页面,RNOH 已经可以扛住。

如果这个模块要继续扩展,我个人建议下一步做两件事:一是把浏览历史的数据层接上服务端同步,做到多设备漫游;二是给列表加上图片内存缓存,避免滚动时长时间空白。这两块在 RNOH 里都有可行方案,只是还需要再踩一轮坑。

最后分享一个小技巧:RNOH 开发时尽量保持 JS 代码“平台无关”的纯度。比如不要直接调用Platform.OS === 'harmony'做分支判断(至少在公开 API 稳定之前少用),优先用能力检测——typeof xxx !== 'undefined'来判断某个原生模块是否可用。这样你的代码在 Android、iOS、OpenHarmony 三端可以保持最小的差异面,迁移成本才会真正低下来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询