最近在把 AnimeHub 的 React Native 代码往 OpenHarmony 上搬,历史记录页面是我们第一批迁移的试点页面之一。这个页面看起来就是个平平无奇的列表页,但真在 RN for OpenHarmony(社区叫 RNOH 那套方案)上跑起来之后,涉及的数据持久化、原生模块调用、新老架构适配问题一个都没少踩。这篇文章就是一次完整复盘,把我在 AnimeHub 历史记录页面开发过程中踩过的坑、验证过的方案、最后沉淀下来的代码组织方式都写出来,给正准备带 RN 项目上 OpenHarmony 的朋友做参考。不管你是已经有 RN App 想覆盖鸿蒙生态,还是刚接触 RNOH 想做技术评估,这篇文章都应该能帮上忙。
1. 为什么是 RNOH:AnimeHub 的历史记录页面凭什么做“第一个吃螃蟹的人”
1.1 AnimeHub 的现状与痛点
AnimeHub 是一个追番应用,核心场景是首页推荐、详情页、播放页和历史记录。在安卓和 iOS 端,我们一直是用 React Native 开发的,播放器使用原生组件,其余业务页面全是 RN。这样一套技术选型,到了要覆盖 OpenHarmony 设备的时候,第一个摆在面前的问题就是:RN 官方并不直接支持 OpenHarmony。
如果走原生重写,用 ArkTS 把整个 App 重新写一遍,业务工作量是巨大的,而且后续迭代要同时维护两套代码。如果直接无视 OpenHarmony 渠道,又等于放弃了整块增量市场。所以 RNOH 的出现是这套迁移方案里最关键的一张牌。
RNOH 是 OpenHarmony SIG 社区维护的 React Native 适配层,它做的事情可以粗暴理解为:把 RN 的 C++ 核心、原生模块、渲染树统统换了一套 OpenHarmony 的落地实现,但 JS 层的 React 组件和业务代码几乎不用改。对我们团队来说,这意味着 AnimeHub 现成的页面可以直接复用,历史记录页面就是一个绝佳的验证对象。
1.2 为什么选历史记录页面做迁移试点
历史记录页面虽然 UI 简单,但它五脏俱全:要读本地存储、要有列表渲染、要处理点击跳转、要做空状态、要支持删除操作,甚至还要对接播放器的进度回调。一个页面跑通了,基本就把 RNOH 上最常用的一套链路摸熟了。
而且历史记录页面不涉及支付、登录这类对稳定性要求极高的业务,就算出了 bug,用户最多是找不到上次看到哪一集,损失可控。作为团队里第一个迁移到 RNOH 的页面,这个风险承受度刚刚好。
还有一层考虑是性能验证。历史记录页的数据量一般不会太大,但也不算小——重度用户看个几十上百条记录很常见。如果 FlatList 在这类中等规模列表上表现正常,后续再做首页信息流和搜索页就有底了。如果 FlatList 在 OpenHarmony 上渲染有问题,我们也可以在投入更大页面之前及时止损。
1.3 环境准备上容易被忽略的版本对齐问题
RNOH 不是官方 RN,版本对应关系必须看清楚。我们实际使用的组合是:
| 组件 | 版本 / 配置 |
|---|---|
| DevEco Studio | 5.x,配套 OpenHarmony SDK API 12 或更高 |
| Node.js | 18 以上,Metro 打包需要 |
| react-native | 0.72 或 0.73 系列,RNOH 分支跟随此版本 |
| @react-native-openharmony/* | 对应 RN 版本拉取的配套包 |
| ArkTS 编译开关 | useNormalizedOHMName 关闭,防止模块名被改写 |
这里有个特别容易踩的坑:DevEco Studio 新版本的 ArkTS 编译器默认会做模块名归一化,把一些符号命名改掉,导致 JS 侧通过 TurboModule 反射拿不到原生模块。这个开关是必关的,不然就会遇到莫名其妙的“找不到模块”报错。我们团队第一个 RNOH Demo 跑起来的时候,就卡在这个问题上整整半天。具体表现是:页面能编译、能打包,但 JS 一调用原生能力就报 undefined。所以如果你也用 DevEco 开发 RNOH,先检查这个开关。
2. 历史记录的数据链路:从播放器进度到页面列表的持久化设计
2.1 一条历史记录到底该存哪些字段
历史记录页面在 AnimeHub 里的定位是“追番续播入口”,所以它要展示的不是播放历史明细,而是“我上次看到哪”的关键信息。我们在设计数据模型的时候,经过几轮取舍,最终定型为这样:
| 字段 | 类型 | 说明 |
|---|---|---|
| animeId | string | 番剧 ID,点击跳转时使用 |
| episodeId | string | 剧集 ID,续播时定位精确到集 |
| seasonId | string | 季 ID,防止不同季的内容互相覆盖 |
| title | string | 展示用,通常是“第 X 集 标题” |
| coverUrl | string | 封面图 URL |
| progress | number | 上次播放到的秒数 |
| duration | number | 这一集总时长 |
| updatedAt | number | 更新时间戳,列表按这个字段倒序 |
| source | string | 可选,播放源标识 |
最核心的取舍是:不要把 progress 存成百分比,要存秒数。因为视频源的总时长可能因为清晰度切换、片源更新而发生变化,如果存百分比,一旦总时长变了,续播定位就全乱了。存秒数和总时长两个绝对量,前端展示进度条的时候自己算百分比,这个设计在后续播放器对接中帮我们省了不少麻烦。
2.2 存储选型:先用 AsyncStorage 顶住,RDB 等数据量大了再说
OpenHarmony 原生侧能用的存储方案不少:用户首选项 Preferences、关系型数据库 RDB、分布式数据服务。但问题在于,RNOH 的 JS 层并不能直接调用这些 API,需要经过原生模块封装。在 RN 生态里,历史记录这类结构化但量级不大的数据,社区最常用的方案就是 AsyncStorage。
RNOH 的兼容模块列表里,AsyncStorage 是有对应的 OpenHarmony 实现的。它的底层在 OpenHarmony 上大概率映射到了某种本地持久化能力,但 JS 侧的 API 和官方保持一致。对我们来说,这意味着在业务代码里不需要写任何平台判断,直接import AsyncStorage from '@react-native-async-storage/async-storage'就能用。
几百条历史记录,每次读取一次 JSON 序列化解出来,在 OpenHarmony 上的耗时基本在可忽略范围。所以我们的第一阶段实现直接用 AsyncStorage 顶住。什么时候才需要换 RDB?我的判断标准很简单:单用户历史记录超过几千条,或者你需要做复杂的条件查询(比如按分类筛选历史)。到那个量级,再自己封装一个 RDB 原生模块也不迟。
2.3 存储封装:把所有读写逻辑收敛到一个模块
我强烈建议不要在每个页面里直接散落 AsyncStorage 调用,而是做一个统一的历史记录仓储模块。AnimeHub 里的实现大概是这样的:
// historyStorage.ts import AsyncStorage from '@react-native-async-storage/async-storage'; const STORAGE_KEY = 'animehub:history:list'; export interface HistoryItem { animeId: string; episodeId: string; seasonId: string; title: string; coverUrl: string; progress: number; duration: number; updatedAt: number; source?: string; } export async function loadHistory(): Promise<HistoryItem[]> { const raw = await AsyncStorage.getItem(STORAGE_KEY); if (!raw) { return []; } try { const list = JSON.parse(raw) as HistoryItem[]; return list.sort((a, b) => b.updatedAt - a.updatedAt); } catch (e) { console.warn('历史记录解析失败,已重置', e); await AsyncStorage.removeItem(STORAGE_KEY); return []; } } export async function saveHistory(item: HistoryItem): Promise<void> { const list = await loadHistory(); const index = list.findIndex( (it) => it.animeId === item.animeId && it.seasonId === item.seasonId ); if (index >= 0) { list[index] = item; } else { list.unshift(item); } const trimmed = list.slice(0, 200); // 最多保留 200 条 await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed)); } export async function removeHistory(animeId: string, seasonId: string): Promise<void> { const list = await loadHistory(); const next = list.filter( (it) => !(it.animeId === animeId && it.seasonId === seasonId) ); await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } export async function clearHistory(): Promise<void> { await AsyncStorage.removeItem(STORAGE_KEY); }这个封装有几个细节值得说说。第一,saveHistory里不是简单 append,而是按animeId + seasonId去重,同一个番剧只保留最新一条记录。用户追番的场景里,历史记录页本质上是个“最近在追”列表,不需要展示“我今天看了第一集、第二集”这种明细。第二,列表控制在 200 条以内,避免 AsyncStorage 的 key-value 结构长时间写入大体积数据。第三,解析失败时直接清掉重来,宁可让用户丢记录也不能让页面崩溃。
2.4 播放器进度上报的三个时机与兜底策略
历史记录的数据源头在播放器。RN 层在 AnimeHub 播放页会监听播放器的原生事件,上报进度我们只做了三个时机:
- 播放器暂停事件
- 用户退出播放页时主动上报
- 播放结束自动上报
中间过程不要一直上报,每分钟一次都嫌多。播放器进度回调是高频事件,如果每次都触发 AsyncStorage 写入,整个页面都会卡顿。我们的做法是在 JS 侧维护一个“最后上报时间戳”,如果当前时间距离上次上报小于 10 秒,就把数据保存在内存变量里,等到了 10 秒间隔或者页面卸载时再强制写入。
这里有一个很容易被忽略的坑:如果播放器是原生组件,进度事件通过 TurboModule 或事件总线转到 JS 侧时,可能会出现“页面已经卸载但事件还在派发”的情况。用户都退出播放页了,这部分进度就丢了。我们的兜底策略是:播放器的原生侧组件在 onUnload 时会把最后进度缓存下来,等下一次用户进入页面时,JS 主动去原生侧拉取一次“未上报的进度”,再决定是否补写历史记录。这个策略保证了 AnimeHub 里最核心的“续播”功能在任何异常退出情况下都不会失效。
3. 列表核心 UI 与交互:续播入口、滑动操作与空状态
3.1 卡片布局的取舍:信息要醒目,操作要收敛
历史记录页的交互核心是“用户点一下就回到上次看的位置”,所以 UI 结构必须围绕续播设计。最终我们定的布局是:左侧封面图固定 96×128,右侧上方是标题一行省略,下方是“第 X 集”和观看时间,再下方是一条 3 像素高的进度条。整个卡片区域点击即触发续播跳转,右上角一个小的删除按钮用于移除单条记录。
为什么不让整个卡片同时承担“进详情页”和“继续播放”两个操作?实测下来,用户很容易误触,尤其是夜间躺着追番的时候。既然历史记录页的定位就是续播入口,那核心操作只有一个:点卡片直接打开播放页并定位到上次进度。想看剧集列表的用户自然会去追番详情页,不需要在这个页面再加一层入口。
进度条的实现其实很土但很稳:外层一个 3px 高的 View 铺满宽度,背景用主题色的 20% 透明度,内层一个 View 宽度设为progress / duration * 100%,背景用主题色。RN 里没有原生进度条需要,这种两层 View 的方案在 RNOH 上渲染毫无压力。
3.2 FlatList 的渲染设置:在 RNOH 上保证滚动不掉帧
RNOH 的渲染端不在 RN 官方的 ViewGroup 体系里,而是映射到 ArkUI 的节点上。列表这种场景,FlatList 的配置对体验影响非常大。我们项目里最终固定下来的参数是:
<FlatList data={historyList} keyExtractor={(item) => `${item.animeId}_${item.seasonId}_${item.episodeId}`} renderItem={renderHistoryItem} initialNumToRender={10} windowSize={5} maxToRenderPerBatch={5} updateCellsBatchingPeriod={50} removeClippedSubviews={true} getItemLayout={(data, index) => ({ length: layout.cardHeight, offset: layout.cardHeight * index, index, })} />removeClippedSubviews在官方 RN 上通常建议谨慎开启,因为有时候会导致白屏闪烁。但 RNOH 的 ArkUI 节点回收机制不同,实测开启后滚动稳定了很多。getItemLayout一定要给,否则 FlatList 需要动态测量每一项高度,滚动定位会明显抖动。另外,renderItem 里的每一项我们都会用React.memo包一层,配合稳定 key,避免某一条记录刷新时整列重渲染。
3.3 空状态:别让用户进去面对一片白
历史记录为空的情况非常常见,新用户第一次进来就是这个场景。AnimeHub 的空状态做的是:一个插画图标 + “还没有观看记录” + 一个“去首页看看”按钮。整个空状态组件放在 FlatList 的ListEmptyComponent里,这样数据加载完成后如果列表为空,直接渲染空状态。
这里要说一个加载时序的问题。入口进入历史记录页时,数据是异步从 AsyncStorage 读的。如果页面直接先渲染一个空 FlatList,等数据回来再渲染列表,用户会看到“空白 → 内容出现”的闪烁,观感很差。我们的做法是把页面状态分成三态:加载中显示骨架屏、加载完成有数据显示列表、加载完成无数据显示空状态。骨架屏就用几个灰色的卡片占位,模拟真实卡片的形状,这样用户感知到的是一次流畅的渲染,而不是跳变。
3.4 删除交互:三个方案里选了个最保守的
删除操作我们内部讨论过三种方案:左滑删除、编辑模式多选删除、卡片右上角直接删除。最后是三种都做了,但入口逻辑做了收敛。单条删除用右上角的小删除按钮,点击后弹 Alert 二次确认;批量删除通过右上角的“管理”按钮进入编辑模式,编辑模式下卡片左上角出现勾选框,底部有“全部选择”和“删除选中”操作。
这里最核心的原则是:删除操作必须二次确认,并且要等到用户明确点击确认后才执行。AnimeHub 的历史记录对用户来说是有情感价值的,误删一条比没有这条更让人恼火。RNOH 的 Alert 组件在 OpenHarmony 上表现正常,可以直接复用业务代码,不需要另外适配。
4. 原生能力调用实测:电话能力、文件导出与系统分享的封装
4.1 拨打电话:Linking 在 RNOH 上不一定能用 tel scheme
历史记录页面里有一个用户反馈入口,产品要求用户点击“联系客服”时能直接唤起拨号盘。在官方 RN 里,一行Linking.openURL('tel:10086')就解决了。但在 RNOH 上,Linking 这个原生模块虽然基础能力可用,但telscheme 的跳转并不保证被 OpenHarmony 系统解析——实际测试发现,它会直接报“无法打开链接”。
我们的解决办法是自己封装了一个原生的 PhoneCall 模块。大致步骤是这样:
- 在 ArkTS 侧定义
PhoneCallModule,方法名命名为dial(phone: string): void。 - 使用 OpenHarmony 的
callAbility或abilityManager拉起拨号能力,注意需要申请ohos.permission.PLACE_CALL权限。 - 在 RNOH 的 TurboModule 注册表里把模块挂上去,JS 侧通过
TurboModuleRegistry.get('PhoneCallModule')获取。 - JS 里封装一个工具函数,先用
TurboModuleRegistry判断模块是否存在,存在就走原生调用,不存在就降级到打开一个客服页面,保证功能可用。
// phone.ts import { TurboModuleRegistry } from 'react-native'; interface PhoneCallModule { dial(phone: string): void; } export function callPhone(phone: string): boolean { const module = TurboModuleRegistry.get<PhoneCallModule>('PhoneCallModule'); if (module) { module.dial(phone); return true; } return false; }这个封装的核心思想是“优雅降级”。RNOH 生态还在早期,某些 NativeModule 在一台设备上能用、在另一台设备上可能因为权限或系统版本问题不可用,所以任何原生能力调用都要有 fallback。客服入口的 fallback 就是打开 WebView 页面,虽然体验差一点,但不会让用户卡在“点了没反应”的状态。
4.2 文件导出与分享:历史记录备份功能背后的原生模块
AnimeHub 还有一个被用户提过多次的小需求:导出我的追番历史。用户想把自己的追番列表导出成 JSON 文件发给朋友,或者备份到网盘。这个功能在 RNOH 上动用了两套原生能力:文件系统写入和系统分享。
react-native-fs这类库大概率没有 RNOH 的适配版本,不要浪费时间尝试。我们直接写了一个HistoryExportModule,ArkTS 侧做的事情是:接收 JS 传入的 JSON 字符串,用 OpenHarmony 的文件管理 API 把内容写入应用沙盒,生成一个animehub_history_20250301.json文件,然后通过系统分享面板把文件分享出去。
// 伪代码展示模块契约 interface HistoryExportModule { exportAndShare(jsonText: string, fileName: string): Promise<boolean>; }JS 侧调用时,先把内存里的历史记录 JSON 序列化,然后调用原生模块导出:
import { TurboModuleRegistry } from 'react-native'; const module = TurboModuleRegistry.get<HistoryExportModule>('HistoryExportModule'); async function exportHistory() { const items = await loadHistory(); const json = JSON.stringify(items, null, 2); const fileName = `animehub_history_${Date.now()}.json`; const ok = await module?.exportAndShare(json, fileName); if (!ok) { Alert.alert('导出失败', '请稍后再试'); } }这里要提醒一句:不要试图把导出逻辑放在 JS 层用 fetch 上传到某个服务器,除非产品明确需要云同步。文件导出和分享这种能力,原生模块是绕不过去的。RN 层只负责组织和展示数据,真正和系统打交道的事交给 ArkTS 去做,这是 RNOH 开发里最务实的分工。
4.3 FTP 场景:如果真要对接,别把协议栈写进 JS
相关热词里还有 OpenHarmony FTP 这个方向,我顺便提一下。如果你们的历史记录功能后续要做“局域网导出到 NAS”之类的需求,需要对接 FTP 服务器,核心注意点是:不要尝试在 RN 的 JS 层写 FTP 客户端逻辑。JS 层处理二进制流和 socket 的效率远不如原生侧。
正确的姿态是:原生模块暴露给 JS 的接口只有两三个——connect(host, port, username, password)、uploadFile(localPath, remotePath)、disconnect(),JS 侧只管组织好文件名和路径,网络传输的压力全部交给 OpenHarmony 的原生能力。我们的经验是,原生侧用 ArkTS 写一个轻量 FTP 上传封装并不复杂,但把它放到业务代码里做,就会变成谁都不敢碰的黑洞。
4.4 原生模块注册的固定流程:每加一个模块都要走一遍
不管上面哪个原生模块,注册流程是固定的,RNOH 项目里加模块的步骤分为三步:
- 在 ArkTS 侧实现模块类,继承
TurboModule,声明的方法名要和 JS 侧完全一致,大小写都算。 - 在入口文件的模块注册表里把实例挂载到对应的名称上。
- JS 侧用
TurboModuleRegistry.get获取时,传入的名称必须和注册名一致。
我见过太多人在这步踩坑。ArkTS 编译器在 DevEco 开启useNormalizedOHMName时会把方法名改写,导致 JS 侧调用module.dial()时找不到方法。所以每次新加原生模块,查报错的时候先确认这个开关,再确认方法名一致。
5. 新老架构切换下,RNOH 适配的差异与踩坑记录
5.1 新架构到底改了啥:Bridge 到 JSI 的本质变化
React Native 的新老架构之争,在 RNOH 项目里同样绕不开。老架构的核心是 Bridge,JS 和 Native 之间的每次通信都要经过 JSON 序列化和异步消息队列,高频率的 UI 状态同步很容易变成瓶颈。新架构的核心变化有两点:一是 JSI,JS 可以直接持有 C++ 对象的引用,不再需要 JSON 序列化;二是 Fabric,Shadow Tree 的布局和渲染可以直接在 C++ 层同步处理,UI 更新路径变短了。
TurboModule 是另一个大变化。老架构下,所有原生模块在启动时就全部注册,不管你用不用。新架构下,TurboModule 按需加载,JS 第一次访问某个模块时才真正初始化。对 RNOH 项目来说,这个差异直接决定了第三方库的兼容性——很多老库的实现在新架构下拿不到对应的原生引用,就会崩。
5.2 我们遇到的两个典型兼容性问题
第一个问题是 Modal 组件。AnimeHub 的历史记录页有一个“分享进度”的弹窗,在 RNOH 的新架构下,Modal 偶尔会出现“显示不出来,但背景变暗”的状态。排查下来发现是 Modal 内部实现依赖了老架构的findNodeHandle,而新架构的 Fabric 渲染端里这个 API 的行为已经变了。最后我们绕开了 RN 的 Modal,用自定义遮罩层 + 绝对定位实现同等效果,反而更可控。
第二个问题是图片加载。历史记录的封面图是远程 URL,我们用Image组件加载。在新架构下,Image的本地缓存行为跟老架构不一样,快速滑动列表时会出现图片先空白后闪烁的情况。我们的解决方案是给封面图加一层内存缓存预处理,列表加载完成后先对可见区域的图片 URL 做预加载,减少滚动时的网络请求压力。
5.3 Hermes 与 JSC 的选择:别急着上 Hermes
RNOH 在不同阶段对 JS 引擎的支持并不完全一致。OpenHarmony 上一个可选的引擎是 Hermes,它在启动时间和二进制体积上有明显优势,但我们实测发现,Hermes 的 OpenHarmony 版本构建需要手动处理不少交叉编译问题,如果团队的构建能力不强,很容易在打包阶段卡住。
稳妥的做法是:先默认使用 JSC(JavaScriptCore)跑通所有业务,等页面稳定后再尝试切 Hermes,并且预留一个切换开关。RNOH 社区对 Hermes 的支持一直在推进,但“能用”和“生产环境稳定”之间还有一段路。我们的正式版第一版没有强行上 Hermes,历史记录页面用 JSC 的表现完全满足预期。
5.4 切换新架构的操作路径
如果你用的是 RNOH 的 0.72 以上版本,新架构默认可能是开启的。切换路径主要是通过编译配置控制:关闭新架构时用newArchEnabled=false,开启时设为true。这里有一个强制建议:迁移第一个页面时,先用老架构跑通,再切新架构。否则混合了业务适配问题和架构适配问题,排查链路会变得特别长。我们当时是历史记录页面在老架构下功能全部正常后,再整体切到新架构回归一遍,把 Modal 和图片缓存两个问题单独列入新架构专项修复计划,这样问题边界就清楚了。
6. 一次白屏事故的完整排查链路:HistoryModule 为什么在 Native 侧找不到
6.1 事故现场
事情发生在历史记录页面联调第二天。首页正常,点击“历史记录”进入新页面,页面一片空白,DevMenu 里能看到一条报错:TypeError: Cannot read property 'loadHistory' of null。这条报错的意思很明确:JS 侧拿到了一个null的模块引用,然后尝试调用它的loadHistory方法。
6.2 排查链路第一步:先确认是业务问题还是模块问题
看到报错,我的第一反应是查代码里调用模块的地方。我们 JS 侧的封装是这样的:
const module = TurboModuleRegistry.get('HistoryModule');如果HistoryModule在原生侧没有注册,get返回的就是null。所以第一件事就是确认原生侧到底注没注册。
打开 DevEco 工程,检查入口模块的TurboModuleProvider列表,结果发现HistoryModule根本没有被写入注册表。这一步让我很意外,因为上一轮联调时这个模块明明还在用。
6.3 排查链路第二步:为什么上轮能用这轮就丢了
翻 git diff,发现罪魁祸首是一个合并操作。我在优化历史记录存储逻辑时,顺手把HistoryModule的注册代码放到了一个条件编译块里:
// 伪代码,示意问题所在 if (USE_HISTORY_V2) { turboModuleProvider.register('HistoryModule', () => new HistoryModule()); }而USE_HISTORY_V2这个变量在联调分支上被定义成了false,编译时这段代码直接被优化掉,模块自然就没了。这是一种很蠢但很常见的错误——为了做功能开关加了条件编译,结果某个配置项在特定分支下导致模块缺失。
6.4 排查链路第三步:修复+防御
修复很简单,把注册代码移出条件编译,作为无条件注册。但为了防止以后再出现这种问题,我们做了两个防御措施。
第一,JS 侧工具函数统一增加空值判断。之前的代码是module.loadHistory(),如果 module 为 null 就直接抛 TypeError。改成:
const module = TurboModuleRegistry.get('HistoryModule'); if (!module) { // 降级为直接读 AsyncStorage 老数据 return loadHistoryFromLegacyStorage(); } return module.loadHistory();这个降级路径很重要。RNOH 项目里,原生模块是否可用取决于编译配置、系统版本、权限等多个因素,JS 侧必须把“模块不可用”当成正常运行状态来处理,而不是直接崩溃。
第二,写了一份原生模块注册自查清单,每新增或修改一个模块都逐项核对:
| 检查项 | 说明 |
|---|---|
| 模块类名是否与 JS 侧 get 名称一致 | 大小写一致,驼峰一致 |
| 方法名是否与 JS 调用名一致 | ArkTS 编译器可能改写,需确认开关 |
| 是否被条件编译排除 | 搜索所有if/USE_*包裹的注册块 |
| 模块注册方式是否同步 | RNOH 不同版本对 Register 的调用方式有差异 |
| 是否清理过 build 缓存 | 偶发“改了代码但效果没变”,清理 DerivedData 后解决 |
这个排查链路本身不算复杂,但它提醒了我一件事:RNOH 比官方 RN 更脆弱,因为多了一层“在非官方平台上模拟官方行为”的适配层。任何一步配置错了,都不是报一个优雅的错,而是给 JS 侧返回一个 null 让你猜。所以项目里面一定要有这种降级机制和自查清单。
7. 发布前必须打磨的细节:进度条精度、暗色模式与误触删除
7.1 进度条“看起来正确”比“计算正确”更重要
历史记录页的进度条,如果用户上次看到 59 分 40 秒,总时长 60 分钟,那进度条应该是 99%。但有些视频源的 duration 不是秒数,而是 0,或者非常接近 0 的错误值。推送这种数据到历史记录页,前端一算就是Infinity或NaN,进度条直接异常。
我们的兜底逻辑是:duration <= 0 || progress <= 0时,不渲染进度条,只显示“正片开始”。同时,如果progress > duration,就按 100% 显示。这些都是老生常谈的边界问题,但 RNOH 项目里播放器返回的数据格式五花八门,不做这层保护,很容易上线第一天就收到用户截图反馈。
7.2 暗色模式:追番 App 的夜间体验是底线
AnimeHub 的主力使用场景是夜间,历史记录页面如果硬编码一个白色背景,用户一打开就会被亮瞎。我们全页面所有颜色都走主题变量,通过useColorScheme()判断系统深浅色模式。RNOH 上useColorScheme的返回值一般能正确跟随系统设置,但为了保险,我们在 App 设置页里加了一个“跟随系统 / 强制深色 / 强制浅色”的开关,默认跟随系统。
主题变量不只是背景色和文字色,还包括封面图占位色、进度条颜色、删除按钮的反馈色等等。把颜色统一抽到一个theme.ts文件里,比在组件里散落#fff和#000要可维护得多。RNOH 上你不可能指望第三方 UI 库帮你适配暗色,RN 基础组件也不一定支持DynamicColorIOS这类 iOS 专属 API,所以靠自定义主题体系是最稳的。
7.3 删除误触的三重防护
历史记录页的删除操作,我最终采用了三重防护:删除按钮的目标点击区域不小于 44×44 像素,避免太小导致误触;点击后弹 Alert 二次确认;删除操作执行后提供 5 秒的“撤销”Toast。
“撤销”这个功能是后期加的,就是因为用户反馈“我本来只想清掉一条,结果把整个追番记录删了”。Toast 撤销的实现不难:删除时不直接调removeHistory,而是先把要删除的数据放进内存里的“待撤销栈”,显示 Toast,5 秒后如果用户没点撤销,再真正写入存储。RNOH 的 ToastAndroid 在 OpenHarmony 上不一定可用,我们直接用的自定义 Toast 组件,这也印证了之前在 Modal 上的经验——自定义实现比依赖系统组件更可控。
7.4 列表性能的最后一层保障:别让图片请求拖垮滚动
历史记录页的封面图来自远程服务器,如果没有做缓存,每次进入页面都会重新下载。RN 的 Image 组件本身有内存缓存,但 RNOH 的缓存行为可能跟官方实现有差异。我们的做法是:进入页面时先读一次历史记录列表,取前 10 条封面 URL 做预加载;滚动过程中,对即将进入可视区域的条目用Image.prefetch()提前拉取。
Image.prefetch这个 API 在 RNOH 上实测是可用的,但它返回一个 Promise,要注意 catch 掉可能的网络错误,否则会在没网的情况下抛 unhandled promise rejection。这一点在官方 RN 上很少有人注意,因为官方 RN 的prefetch失败只是静默,但 RNOH 某些版本会把失败抛出来。我们的封装里统一做了错误吞掉处理。
最后再分享一点实际体会
历史记录页面现在已经在 RNOH 上稳定跑了一段时间。坦率地说,三天开发时间里,纯业务代码只占一天半,剩下的时间全花在原生模块注册、架构切换回归和图片缓存这类问题上。RNOH 确实还处于早期生态,写 RN 业务代码不难,难的是一旦碰到 NativeModule 断裂或 ArkUI 渲染端的特殊行为,社区里很难找到现成答案。
我的建议很明确:如果你的团队已经在维护 RN 应用,而且想覆盖 OpenHarmony 渠道,历史记录这种“中等复杂度”页面是最合适的试水对象。但在动手之前,先把你准备用的原生模块列个清单,逐项确认它们在 RNOH 里有没有适配版本,没有就提前规划原生封装的人力。历史记录页面只是一个开始,AnimeHub 后续的视频播放页、弹幕交互页会暴露更多深层问题,但只要数据链路、原生模块调用这套基础设施打牢了,后面无非是往同样的管道里塞更多业务而已。