1. 项目背景与技术选型思考1.1 为什么在OpenHarmony上选择React Native这段时间一直在折腾OpenHarmony应用开发手里正好有个叫AnimeHub的追番类App项目首屏、详情页都跑通了轮到历史记录页面的时候团队里有了分歧有人主张直接用ArkTS重写有人坚持继续用React Native做跨端复用。最后我们还是选了RN不是因为偷懒而是算过一笔账。OpenHarmony的生态和Android、iOS比还有差距但RN for OpenHarmony的适配进度比想象中快。历史记录页面这个模块信息密度不高、交互不算复杂核心就是列表展示加状态管理恰好是RN最擅长的场景。用RN还有一个好处AnimeHub本身有Android版本历史记录的数据结构、页面逻辑都是现成的直接复用JS层代码只针对OpenHarmony的特性做适配开发成本比纯ArkTS重写低不少。不过要泼一盆冷水目前RN在OpenHarmony上跑Fabric架构的组件覆盖率还没有完全对齐Android/iOS偶尔会遇到某个组件表现不一致的情况。所以选型的时候我的建议是“核心页面用RN系统能力用原生模块补齐”不要指望一套代码三端通吃先把历史记录这种业务型页面跑通再逐步扩大RN的覆盖面。1.2 AnimeHub历史记录页面的核心需求拆解历史记录页面听上去简单做起来有不少细节。我先把它拆成了四个核心需求模块数据展示按时间倒序展示用户看过的番剧条目每条记录包含封面图、剧名、看到第几集、观看进度百分比、最后更新时间。续播入口点击某条记录直接跳转到对应的播放页并从上次进度继续播放。批量管理支持单条删除和清空全部记录操作前需要二次确认防止误删。空状态处理没有历史记录的时候展示引导性空页面引导用户去番剧库逛逛。这些需求看起来不复杂但真正动手的时候会发现数据格式设计、时间格式化、进度条计算、列表性能优化每一块都有值得琢磨的地方。下面我会把整个开发过程拆开讲包括数据层怎么设计、UI怎么实现、交互怎么调优以及那些我踩过的坑。2. 数据存储方案从键值对到轻量级数据库2.1 存储选型的对比分析历史记录的本质是“用户的观看行为数据”它有两个特点写入频率高每看一集都要更新、读取频率也高每次打开页面都要拉取。存储方案选不好后面会很痛苦。OpenHarmony生态里本地持久化一般有这么几种选择方案适用场景优点缺点Preferences轻量级键值对少量配置项、用户设置接口简单、同步读取不适合存大量结构化数据RelationalStore关系型数据库结构化数据、复杂查询SQL语法、支持索引、事务需要建表、写SQL、管理连接文件存储JSON序列化数据量中等、整存整取实现简单、跨平台通用写入是全量覆盖、数据量大时有性能问题我们最终选了RelationalStore原因很直接历史记录表需要按时间排序、需要按剧集ID查重、需要支持批量删除这些都是关系型数据库擅长的操作。Preferences存不了复杂查询文件存储虽然能用但每次写入都是全量序列化数据量上来后会有肉眼可见的卡顿。2.2 建表语句与数据模型设计我建的表结构是这样的CREATE TABLE IF NOT EXISTS watch_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, anime_id TEXT NOT NULL, anime_title TEXT NOT NULL, cover_url TEXT, episode_id TEXT NOT NULL, episode_title TEXT, progress REAL DEFAULT 0, duration REAL DEFAULT 0, updated_at INTEGER NOT NULL )几个字段的设计思路说一下anime_id和episode_id分开存因为一条番剧有多集续播时要精确定位到“第几集第几秒”而不是只定位到“哪部番”。progress和duration存的是秒数不是百分比。百分比是UI层计算出来的纯展示用存储层用秒数方便做进度条拖动和精度计算。updated_at存的是Unix时间戳毫秒级不是字符串。排序直接用ORDER BY updated_at DESC效率高也不会有字符串格式化的麻烦。2.3 插入或更新策略UPSERT的两种实现方式历史记录最典型的场景是用户看到第3集第10分钟退出后又在第3集第15分钟重新进入这时候不应该新增一条记录而应该更新旧记录。这就是经典的“存在则更新、不存在则插入”逻辑也就是UPSERT。我试过两种实现方式方式一先查后插const result await historyStore.queryByAnimeAndEpisode(animeId, episodeId); if (result.rowCount 0) { await historyStore.update({ ... }); } else { await historyStore.insert({ ... }); }这种方式的优点是逻辑直观缺点是两次数据库操作有间隔极端并发下可能插入两条重复记录。方式二使用INSERT OR REPLACEawait historyStore.execute( INSERT OR REPLACE INTO watch_history (id, anime_id, episode_id, progress, duration, updated_at) VALUES (?, ?, ?, ?, ?, ?) );这种方式要求表上有唯一索引把anime_id episode_id作为唯一键CREATE UNIQUE INDEX idx_anime_episode ON watch_history(anime_id, episode_id);我最终用的是方式二配合事务批量处理实测下来性能和正确性都更稳定。这里有一个值得注意的坑INSERT OR REPLACE在SQLite里本质是“删除旧行插入新行”如果表里有外键关联ON DELETE CASCADE会把关联数据也删掉。我们历史记录表没有外键所以没问题但如果你有要谨慎使用。3. 核心页面架构与状态管理设计3.1 页面层级与组件拆分历史记录页面我采用了“父组件控制数据流子组件专注渲染”的架构。整体分为三层页面容器层HistoryScreen负责数据加载、状态管理、事件分发。列表渲染层HistoryList负责FlatList的渲染配置处理滚动事件。卡片条目层HistoryCard负责单条记录的UI展示包括封面、标题、进度条、删除按钮。这样拆分的好处是各层职责清晰后续如果要加“编辑模式”或者“搜索功能”不需要动整个页面结构。3.2 状态管理优先使用useReducer而不是useState历史记录页面的状态其实不算复杂但有几个状态是相互关联的加载中、加载成功、加载失败、空数据、下拉刷新中、批量操作中。如果用多个useState管理很容易出现状态不同步的问题比如数据还没加载完就把空状态渲染出来了。我建议用useReducer把状态收敛起来大致是这样的const [state, dispatch] useReducer(reducer, initialState); function reducer(state, action) { switch (action.type) { case LOAD_START: return { ...state, loading: true }; case LOAD_SUCCESS: return { ...state, loading: false, list: action.payload.list, hasMore: action.payload.hasMore }; case LOAD_FAILURE: return { ...state, loading: false, error: action.payload }; case DELETE_ITEM: return { ...state, list: state.list.filter(item item.id ! action.payload) }; case CLEAR_ALL: return { ...state, list: [] }; default: return state; } }useReducer的好处是状态转移是纯函数可预测、好调试。特别是历史记录这种频繁增删的页面状态变更逻辑集中在一起比散落在各个事件回调里强太多了。3.3 分页加载与FlatList性能优化历史记录可能会累积到几百条甚至上千条。一次性全量加载会把数据库查慢、渲染卡顿所以必须做分页。我在数据层做了LIMIT和OFFSET查询const pageSize 20; const offset page * pageSize; SELECT * FROM watch_history ORDER BY updated_at DESC LIMIT ? OFFSET ?UI层配合FlatList的onEndReached回调滚动到底部自动加载下一页FlatList data{state.list} keyExtractor{(item) item.id.toString()} renderItem{renderItem} onEndReached{loadMore} onEndReachedThreshold{0.3} initialNumToRender{10} maxToRenderPerBatch{10} windowSize{7} /性能参数这块我调了几个值实际感受是initialNumToRender设成10比较合理首屏渲染不会卡windowSize默认21太大了调到7能明显减少内存占用maxToRenderPerBatch保持10就行一次渲染太多依然会掉帧。踩坑提示OpenHarmony的RN适配层对FlatList的onEndReached触发时机和Android略有差异实测在OpenHarmony上onEndReachedThreshold设0.3时触发偏早列表还有不少空白区域就开始加载了。这不算Bug但明显体感不一致。我最后的处理是把阈值降到0.1同时加了一个loading状态锁防止连续触发多次分页请求。4. UI实现细节与交互优化4.1 历史记录卡片的布局方案每条历史记录的卡片我采用的是“左图右文”的布局。左侧封面图固定宽度120像素、高宽比16:9右侧从上到下依次是番剧标题单行省略、集数信息单行、进度条、更新时间。底部放一个“继续观看”按钮和一个“删除”图标按钮两者水平排列。这里有两个视觉细节值得说封面图加载RN默认的Image组件在Android上加载网络图偶尔会闪白屏在OpenHarmony上这个问题也存在。我统一在图片加载前先设置一个浅灰色的占位背景色然后用onLoadStart/onLoadEnd控制透明度渐变视觉上会舒服很多。进度条不需要引入第三方进度条库用一个View加百分比宽度就能实现。外层是圆角灰色背景内层是主题色填充高度设为4像素简洁干净。4.2 时间格式化今天、昨天、更早历史记录列表里时间的展示方式通常不是“2025年1月15日 14:32:11”这种完整格式而是带相对语义的今天、昨天、N天前。实现思路是拿updated_at和当前时间戳比较落在同一天显示“今天 HH:mm”落在前一天显示“昨天 HH:mm”更早的显示“MM月DD日”。这里有一个跨端一致性的陷阱判断“今天”不能简单地用“24小时前”去减因为昨晚23点和今天凌晨1点虽然间隔只有2小时但已经跨了天。我封装了一个工具函数先通过时区偏移把时间戳转成对应时区的“年月日”再比较日期是否相等而不是比较时间差值。OpenHarmony的JavaScript运行环境是ArkTS引擎。坦白说在日期处理上ArkTS引擎对Intl.DateTimeFormat的支持不完整个别API行为跟V8不一致。所以在OpenHarmony上跑RN时间格式化建议不要依赖toLocaleString()自己拼字符串最稳妥。4.3 空状态与加载状态的体验设计历史记录页面最容易出现的两种情况一是刚安装App还没有任何记录二是网络慢导致封面加载不出来。这两种情况的UI我都会专门设计。空状态不是简单显示一行“暂无记录”而是给用户一个“出口”放一个动画插画、一段引导文案“你看过的番剧会出现在这里”再放一个“去逛逛番剧库”按钮。这样空状态不再是死胡同而是流量引导的入口。加载状态我用的是占位骨架屏方案不是转菊花。骨架屏的优点是视觉上“内容即将出现”用户等待的焦虑感更低。RN实现骨架屏很简单就是一个灰色背景的View加上透明度循环动画不需要第三方库。Android上骨架屏的闪烁动画用Animated.loop就行OpenHarmony上实测也兼容。4.4 封面图加载失败的处理历史记录的封面图是从网络拉取的。如果封面图加载失败404、断网、CDN过期图片会显示一个裂开的图标非常难看。我的处理是写了一个SmartImage组件加载成功显示网络图。加载失败先尝试从本地缓存读取读不到就显示内置的默认封面图。加载超时超过15秒没响应直接按失败处理。组件封装好之后历史页面、番剧详情页、搜索页都能复用一套逻辑解决三处封面图问题。5. 关键交互续播跳转与删除逻辑5.1 续播功能的实现从列表到播放器的数据传递续播是整个历史记录页面最重要的交互用户点卡片就是希望“接着上次的地方继续看”。这块的数据传递方案我踩过坑也总结出了稳定可靠的方案。错误示范把进度百分比的秒数放在AsyncStorage里播放器页面启动时再去读。这会有两个问题一是异步读取有延迟播放器可能已经初始化了才拿到进度数据二是如果用户清理了缓存进度数据就丢了。正确方案点击卡片时通过路由参数把数据一次性传给播放页navigation.navigate(Player, { animeId: item.animeId, animeTitle: item.animeTitle, episodeId: item.episodeId, episodeTitle: item.episodeTitle, progress: item.progress, duration: item.duration, coverUrl: item.coverUrl });播放器页面拿到progress参数后在初始化播放器时设置初始播放位置并在onEnded或onUnload时回写最新进度。注意回写进度要防抖比如每3秒才写一次数据库否则播放过程中数据库会被高频写入OpenHarmony的数据库写入是有锁的频繁写会影响UI流畅度。5.2 单条删除的交互细节删除操作我做了两步交互第一步点击卡片上的删除按钮弹出一个ActionSheet提示“删除这条观看记录”按钮是“取消”和“删除”。第二步点击确认后先做UI乐观更新列表里直接移除这一条再请求数据库删除。如果数据库删除失败回滚UI并弹Toast提示。乐观更新的好处是体感快用户不用等待数据库操作完成。但有一个要注意的点如果同一时间删了两条而第二条的数据库回调先返回回滚逻辑可能会把已经删除的第一条错误地恢复回去。解决方法是给每次删除操作生成一个递增的操作ID回滚时只回滚对应ID的操作。5.3 清空全部记录的防误触设计“清空全部”按钮我放在了页面的右上角图标是一个垃圾桶。点击后弹一个Modal文案是“确定清空全部观看记录吗此操作不可恢复”确认按钮默认是灰色需要用户再点一次才会变成红色确认态。两次确认的设计不是多余的。做过数据管理的人都懂一次性清空的数据无法找回这造成的用户损失是灾难性的。加上二次确认误触的概率可以降到几乎为零。5.4 数据一致性问题播放进度回写与列表刷新还有一个容易忽略的场景用户从历史记录页点击续播看完一集后返回历史记录页页面上那条记录的进度、时间如果还是旧的观感很差。处理方案是在页面获得焦点时刷新数据useFocusEffect( useCallback(() { loadHistoryList(); }, []) );注意这里不能用useEffect因为useEffect只在页面挂载时触发从播放器返回时页面并没有重新挂载只有useFocusEffect能感知到“页面重新获得焦点”这个生命周期。OpenHarmony的RN适配层对useFocusEffect的支持是好的实测可用。刷新的时候不要整页刷新造成列表闪烁而是对比新旧数据只更新变化的条目。我的做法是刷新后拿到新列表按id做Map映射找出进度值或时间戳不同的条目更新到列表状态里。6. 新老架构适配OpenHarmony下的RN特性兼容问题6.1 新架构下的TurboModule与老架构的差异现在RN的版本迭代很快新架构Fabric TurboModule和老架构Paper的差异是一个绕不开的话题。在OpenHarmony上开发RN对这个差异的感知比在Android上更明显。老架构下RN和原生层通过Bridge通信JS调用异步方法需要序列化、跨桥传输、反序列化性能有损耗。新架构下TurboModule让JS可以直接拿到原生模块的引用调用是同步的、类型安全的性能好很多。但OpenHarmony的RN适配目前Fabric渲染器的组件覆盖面还在持续完善中。我用的RN版本在OpenHarmony上可以启用新架构但个别第三方库还没有完成新架构适配比如我之前用的一个图片缓存库在新架构下会报JSI错误。遇到这种情况要么等库作者更新要么换一个兼容新架构的替代库没有第三条路。6.2 调用系统能力电话功能与文件上传的实现思路项目里有两个非列表页的功能也跟历史记录页有关联顺带说一下参考思路。一个是“联系客服”入口用户点了之后要调用系统电话功能拨号。在RN for OpenHarmony中不能直接用Linking.openURL(tel:xxx)OpenHarmony的URL Scheme支持和Android不完全一致。正确做法是写一个原生模块通过ArkTS调用ohos.telephony.call接口来完成拨号然后在JS层暴露一个callPhone(phoneNumber)的方法。这块需要同时会写RN原生模块和ArkTS是跨端开发绕不开的能力。另一个是“用户反馈可以上传截图”涉及到图片裁剪和选择。图片裁剪在RN生态里有成熟的库比如react-native-image-crop-picker但OpenHarmony上要确认这个库是否有对应的适配版本。如果没有可以用系统PickPhoto接口选图再用Canvas做等比裁剪需要自己造轮子。6.3 网络数据缓存与离线查看AnimeHub支持离线查看历史记录。网络请求策略上我做了逐级降级的方案先查内存缓存再查数据库缓存最后发起网络请求。这样即使离线历史记录列表依然能展示封面图会自动降级成占位图并标注“离线缓存”。数据库缓存的表结构和历史记录表是独立的两张表历史记录表存观看行为缓存表存番剧元数据。两者通过anime_id关联页面查询时做一次JOIN。这个方案的好处是历史记录页启动时不需要等待网络返回就能立刻渲染出数据体验是“秒开”的。7. 常见问题排查与避坑指南7.1 问题一列表滚动卡顿现象历史记录超过200条后滚动时出现掉帧快速滑动时白屏闪烁。排查过程先用Log打印了每个卡片的渲染耗时发现图片加载占了大部分时间。检查FlatList配置发现maxToRenderPerBatch没有设置默认值在数据量大时会导致单次渲染过多。进一步定位发现卡片里用了shadow属性OpenHarmony的RN适配层对阴影的绘制性能开销很大。解决方案给FlatList设置maxToRenderPerBatch{10}和windowSize{7}。阴影效果改用图片素材替代或者干脆去掉阴影用1像素的边框线区分卡片。图片统一走缓存层减少重复加载。7.2 问题二时间显示错误现象有时显示“昨天”但实际是前天有时显示“今天”但实际是昨天。排查过程排查发现是时区处理的问题。OpenHarmony的RN适配层里new Date(timestamp)如果timestamp是字符串类型部分场景下解析行为不一致。有的设备上字符串时间戳会被当成UTC时间有的会被当成东八区时间导致日期计算偏移。解决方案时间戳统一用Number类型传递不要用字符串。封装formatTime工具函数所有时间计算基于数字时间戳完成不依赖Date构造函数解析字符串。7.3 问题三数据库写入失败现象高频写入进度时偶发“database is locked”错误。排查过程数据库锁是SQLite在多线程同时写时常见的并发问题。RN的JS线程和原生模块线程可能同时操作数据库而RelationalStore默认的锁机制是文件级别的单文件并发写会冲突。解决方案写入操作统一走一个串行队列不要并行调用数据库写方法。写入频率做节流播放进度每3秒写一次。使用事务批量写入多个更新合并成一次数据库操作。7.4 问题四屏幕适配异常现象部分OpenHarmony设备上卡片宽度超出屏幕出现横向滚动。排查过程原因是部分设备的分辨率比例特殊Dimensions.get(window)在某些折叠屏上取值不准导致计算出的卡片宽度不等于屏幕宽度。解决方案不使用固定宽度改用百分比宽度或者使用flex: 1让卡片自适应。列表的contentContainerStyle加上paddingHorizontal避免卡片顶到屏幕边缘。在真机上测试多种分辨率不要只在模拟器上调试。7.5 常见问题速查表问题可能原因解决方案封面加载失败显示裂图CDN地址失效 / 无网络封装SmartImage组件失败降级为默认图列表滚动白屏闪FlatList渲染批次过大调低maxToRenderPerBatch和windowSize删除后记录又回来数据库删除失败或乐观更新回滚逻辑有误加操作ID只回滚对应操作返回页面数据不刷新用了useEffect而不是useFocusEffect换成useFocusEffect监听页面焦点时间显示偏差一天字符串时间戳被解析为UTC统一用数字时间戳播放进度未保存页面卸载前没有回写在useEffect cleanUp中回写进度数据库locked错误并发写库串行队列 节流 事务新架构下组件报错库未适配Fabric换兼容库或等库更新8. 日志分析与数据追踪实践8.1 埋点方案设计历史记录页的埋点我分成了四类曝光事件页面打开时记录一次包含来源首页、个人中心、深链调起。点击事件记录用户点击了哪条记录、点击位置卡片主体、删除按钮、清空按钮。操作事件记录删除成功、清空成功、续播跳转等操作结果。性能事件记录页面首帧渲染时间、数据加载耗时、图片加载失败率。埋点数据统一上报到自建的数据服务格式是JSON包含事件名、时间戳、设备ID、页面路径、扩展参数。8.2 性能数据采集与优化验证我在页面的性能采集上用了两个指标接口耗时和渲染耗时。接口耗时在数据层打点从发起查询到拿到结果记录耗时分布。渲染耗时在UI层打点用requestAnimationFrame技术统计页面从开始渲染到首帧完成的时间。数据跑了一周结论是历史记录页的P95渲染耗时一度是900ms优化后降到420ms主要是图片缓存和分页渲染的功劳。这些数据可以作为后续优化方向的依据界面好不好看是一方面数据上的提升才是实打实的。8.3 日志查看工具OpenHarmony开发调试时我用的日志工具是hilog。RN层的console.log在OpenHarmony上会映射到底层的hilog输出用DevEco Studio的Log窗口可以看到。要注意的是RN的生产包默认会剪掉console输出所以发布前记得检查是否保留了线上日志通道方便远程排查问题。9. 性能优化策略与实战心得9.1 图片加载优化的三个层次历史记录页有大量封面图需要加载这块优化空间最大。我做了三个层次的优化第一层内存缓存。用LRU策略缓存最近访问的图片命中内存缓存的不走网络。RN生态的图片缓存库在OpenHarmony上适配不一我最后直接用原生模块实现了一套基础的内存缓存访问速度提升明显。第二层磁盘缓存。图片加载成功之后写入本地磁盘下次直接从磁盘读不需要等网络。磁盘缓存的淘汰策略是按LRU 容量上限双重控制。第三层CDN图片瘦身。请求图片的时候在URL后面拼接裁剪参数按列表的展示尺寸拉取不拉原图。原始封面图可能有几MB列表里展示只需要几百KB传输差别很大。9.2 数据库查询优化的细节分页查询如果只是LIMIT 20 OFFSET 40数据量过万后OFFSET越大越慢。我调整了查询策略不用OFFSET翻页改为用上一页最后一条记录的updated_at作为游标SELECT * FROM watch_history WHERE updated_at ? ORDER BY updated_at DESC LIMIT 20这个方案的好处是不管翻到多深的页数据量都稳定不会因为OFFSET太大而变慢。注意这里有一个边界问题如果两条记录的updated_at完全相同游标查询可能会漏数据。解决办法是在排序上增加次级排序字段比如id游标传递(updated_at, id)组合条件。9.3 JS层逻辑优化的核心原则RN在OpenHarmony上的性能瓶颈往往不在渲染层而在JS逻辑层。因为JavaScript执行是不可能完全无开销的大量不必要的数据遍历、对象拷贝都会造成卡顿。几个核心原则避免渲染列表时做复杂计算。每一行的UI数据在数据层提前格式化好不要在渲染函数里做字符串拼接和日期转换。使用不可变数据更新状态。更新列表项时不要直接在原对象上修改属性而是创建新对象。React的浅比较能更高效地发现状态变化。减少setState次数。一条删除操作不要先删list再删total再删hasMore分三次setState合并成一次。10. 项目扩展方向与后续规划AnimeHub的历史记录页面当前已经稳定跑起来了但这只一个开始。后续还有几个方向值得继续深入。方向一多端数据同步。当前历史记录只存在本地设备如果用户更换设备记录就没了。可以增加账号体系和云端同步能力把历史记录数据结构化后同步到服务端多端共享。方向二智能推荐联动。历史记录的数据可以用来做“猜你喜欢”推荐的输入特征。用户在哪些番剧上停留时间长、哪些看了一半弃番这些信息都是有价值的推荐信号。方向三小组件落地。OpenHarmony支持桌面小组件。把“继续观看”做成桌面卡片用户不用打开App就能一键续播产品体验上是一个很有吸引力的场景。方向四OpenHarmony专项适配。目前RN在OpenHarmony上的生态还处于快速发展阶段组件兼容性、性能表现、原生模块覆盖都在持续演进。后续要密切关注RN新架构在OpenHarmony上的落地进度及时跟进适配。写在最后做AnimeHub的历史记录页面最大的体会是跨端开发听起来很美好实际落地需要面对大量细碎的平台差异和兼容性问题。选择RN for OpenHarmony不是因为它完美而是因为它在“复用代码”和“平台能力”之间找到了一个平衡点。你要是正准备在OpenHarmony上开发RN应用我的建议是不要一上来就追求所有功能都跨端先把业务逻辑层用RN写好把系统能力通过原生模块逐个补齐遇到一个平台差异就记录一个慢慢会形成一套属于你自己的适配经验库。技术选型没有放之四海而皆准的标准答案只有适合当前项目现状的可行解。