
最近在把 AnimeHub 的 React Native 代码往 OpenHarmony 上搬历史记录页面是我们第一批迁移的试点页面之一。这个页面看起来就是个平平无奇的列表页但真在 RN for OpenHarmony社区叫 RNOH 那套方案上跑起来之后涉及的数据持久化、原生模块调用、新老架构适配问题一个都没少踩。这篇文章就是一次完整复盘把我在 AnimeHub 历史记录页面开发过程中踩过的坑、验证过的方案、最后沉淀下来的代码组织方式都写出来给正准备带 RN 项目上 OpenHarmony 的朋友做参考。不管你是已经有 RN App 想覆盖鸿蒙生态还是刚接触 RNOH 想做技术评估这篇文章都应该能帮上忙。1. 为什么是 RNOHAnimeHub 的历史记录页面凭什么做“第一个吃螃蟹的人”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 Studio5.x配套 OpenHarmony SDK API 12 或更高Node.js18 以上Metro 打包需要react-native0.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 里的定位是“追番续播入口”所以它要展示的不是播放历史明细而是“我上次看到哪”的关键信息。我们在设计数据模型的时候经过几轮取舍最终定型为这样字段类型说明animeIdstring番剧 ID点击跳转时使用episodeIdstring剧集 ID续播时定位精确到集seasonIdstring季 ID防止不同季的内容互相覆盖titlestring展示用通常是“第 X 集 标题”coverUrlstring封面图 URLprogressnumber上次播放到的秒数durationnumber这一集总时长updatedAtnumber更新时间戳列表按这个字段倒序sourcestring可选播放源标识最核心的取舍是不要把 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(): PromiseHistoryItem[] { 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): Promisevoid { 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): Promisevoid { 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(): Promisevoid { 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.getPhoneCallModule(PhoneCallModule); if (module) { module.dial(phone); return true; } return false; }这个封装的核心思想是“优雅降级”。RNOH 生态还在早期某些 NativeModule 在一台设备上能用、在另一台设备上可能因为权限或系统版本问题不可用所以任何原生能力调用都要有 fallback。客服入口的 fallback 就是打开 WebView 页面虽然体验差一点但不会让用户卡在“点了没反应”的状态。4.2 文件导出与分享历史记录备份功能背后的原生模块AnimeHub 还有一个被用户提过多次的小需求导出我的追番历史。用户想把自己的追番列表导出成 JSON 文件发给朋友或者备份到网盘。这个功能在 RNOH 上动用了两套原生能力文件系统写入和系统分享。react-native-fs这类库大概率没有 RNOH 的适配版本不要浪费时间尝试。我们直接写了一个HistoryExportModuleArkTS 侧做的事情是接收 JS 传入的 JSON 字符串用 OpenHarmony 的文件管理 API 把内容写入应用沙盒生成一个animehub_history_20250301.json文件然后通过系统分享面板把文件分享出去。// 伪代码展示模块契约 interface HistoryExportModule { exportAndShare(jsonText: string, fileName: string): Promiseboolean; }JS 侧调用时先把内存里的历史记录 JSON 序列化然后调用原生模块导出import { TurboModuleRegistry } from react-native; const module TurboModuleRegistry.getHistoryExportModule(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 项目里同样绕不开。老架构的核心是 BridgeJS 和 Native 之间的每次通信都要经过 JSON 序列化和异步消息队列高频率的 UI 状态同步很容易变成瓶颈。新架构的核心变化有两点一是 JSIJS 可以直接持有 C 对象的引用不再需要 JSON 序列化二是 FabricShadow 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 的选择别急着上 HermesRNOH 在不同阶段对 JS 引擎的支持并不完全一致。OpenHarmony 上一个可选的引擎是 Hermes它在启动时间和二进制体积上有明显优势但我们实测发现Hermes 的 OpenHarmony 版本构建需要手动处理不少交叉编译问题如果团队的构建能力不强很容易在打包阶段卡住。稳妥的做法是先默认使用 JSCJavaScriptCore跑通所有业务等页面稳定后再尝试切 Hermes并且预留一个切换开关。RNOH 社区对 Hermes 的支持一直在推进但“能用”和“生产环境稳定”之间还有一段路。我们的正式版第一版没有强行上 Hermes历史记录页面用 JSC 的表现完全满足预期。5.4 切换新架构的操作路径如果你用的是 RNOH 的 0.72 以上版本新架构默认可能是开启的。切换路径主要是通过编译配置控制关闭新架构时用newArchEnabledfalse开启时设为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而是先把要删除的数据放进内存里的“待撤销栈”显示 Toast5 秒后如果用户没点撤销再真正写入存储。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 后续的视频播放页、弹幕交互页会暴露更多深层问题但只要数据链路、原生模块调用这套基础设施打牢了后面无非是往同样的管道里塞更多业务而已。