从我开始做鸿蒙版的React Native适配起,图片缓存就是绕不开的一关。业务跑通不难,真正咬人的是体验:首页信息流一屏几十张封面图,用户来回滑动时如果不做缓存,图片反复下载,流量扛不住,滑动也肉眼可见地卡。iOS和Android的React Native项目里,Image缓存多少还有系统级或成熟三方库托底,但在鸿蒙版RN里,我发现那套经验不能直接平搬,Image图片缓存策略必须针对鸿蒙的底层设计重新做。这篇文章会把我实际落地React Native鸿蒙版Image图片缓存策略的过程、选型理由、代码骨架和踩坑记录都整理出来,给正在做鸿蒙版RN适配或者打算做的开发者一个可复现的参考。
1. 为什么鸿蒙版RN的Image缓存必须重新设计
1.1 三大平台的缓存实现差异
在iOS上,React Native的Image最终走的是原生网络栈,系统URL加载框架本身就带一级缓存,图片加载第二次进内存基本是瞬时命中,开发者不太需要操心细节。在Android上,React Native新架构已经默认把Fresco作为图片加载后端,Fresco自带三级缓存:内存、磁盘、网络,磁盘还有独立的缓存目录和淘汰机制。可以说在iOS和Android里,“缓存”这件事有很多现成的轮子可以蹭。
到了鸿蒙版这边,情况就变了。React Native鸿蒙版是通过原生模块把JS侧的Image请求桥接到鸿蒙原生能力上,但鸿蒙生态里没有一个像Fresco那样长期沉淀、专为RN接入而生的图片加载库。Image组件虽然能加载网络图,但缓存行为取决于你接的原生图片加载方案,甚至可能根本没做缓存。也就是说,你在iOS/Android上习惯的那些默认行为,在鸿蒙版RN里都得自己明确实现。
另一个差异是底层文件系统。iOS的缓存目录有系统自动清理机制,Android有明确的缓存目录语义,而鸿蒙版的沙箱目录体系下,缓存目录该选哪里、怎么申请、被系统清理后怎么重建,这些细节都影响缓存策略的边界设计。
1.2 ArkTS与JS桥接带来的约束
鸿蒙版RN里,JS侧与原生侧的通信成本比通常想象的要高。每次从TS侧调用原生方法,都要经过桥接层的序列化和异步分发。如果缓存命中判断做成“先问原生拿一下缓存状态,再决定是否发网络请求”,图片一多,桥接请求会排长队,卡顿反而更严重。
这就逼着你把缓存策略的决策尽量前移到JS侧,让JS侧自己维护索引(比如哪些URL已经缓存了、缓存在哪个文件),原生侧只负责真正读文件、写文件和返回二进制数据。我的做法是:JS持有内存索引和磁盘索引,网络请求只发生在JS判定缓存未命中之后。这个设计后续所有并发控制和预加载逻辑都建立在这之上,是整个缓存策略的地基。
1.3 缓存这件事在鸿蒙版RN里到底卡在哪
总结下来有三点:
- 默认无缓存或缓存行为不统一,不同图片格式、不同加载路径结果不一致
- 桥接调用成本高,不能高频同步问原生“有没有缓存”
- 原生侧没有统一的缓存淘汰组件,磁盘空间膨胀需要自己控制
明确了这三件事,整个缓存策略的方向就清楚了:JS侧做索引与决策,原生侧做文件读写,内存和磁盘分层,淘汰策略自己写。
2. 我的缓存架构:内存LRU + 磁盘分级的双层方案
2.1 内存缓存层的LRU实现
内存缓存我选了LRU(Least Recently Used)策略。原因很直接:图片列表有很强的局部性,你刚滑过的图片很可能马上滑回去,最近使用的图片应该最快被命中。LFU这类基于频率的策略在信息流场景反而容易缓存住一批很久不用的“高频老图”,不如LRU贴合滑动浏览的习惯。
LRU在JS侧用双向链表加哈希表实现,标准做法,不复杂。需要注意的坑是,React Native的JS引擎里,大对象大量创建销毁会有GC压力,所以缓存条目本身要控制重量,不要缓存原始图像数据的大Buffer,而是缓存简单的元信息和一个可复用的对象引用。我缓存的是Image的source对象、宽高以及原生侧的句柄标识,而不是图片字节。
// 示意:LRU缓存核心结构 export class LRUImageCache { private map = new Map<string, CacheEntry>(); private capacity: number; get(url: string): CacheEntry | undefined { const entry = this.map.get(url); if (entry) { this.map.delete(url); this.map.set(url, entry); // 移动到最新位置 return entry; } return undefined; } put(url: string, entry: CacheEntry) { if (this.map.has(url)) { this.map.delete(url); } this.map.set(url, entry); if (this.map.size > this.capacity) { const oldestKey = this.map.keys().next().value; this.map.delete(oldestKey); } } }内存容量怎么定?我的经验是不要拍脑袋。React Native应用本身JS引擎就有内存占用,图片缓存再吃太多,低端机会先崩。我按可用内存的比例估算,同时设了硬下限和硬上限:最低不小于50MB,最高不超过设备内存的八分之一左右。运行时可以通过鸿蒙提供的系统内存信息接口拿个大概值,然后动态调整。实测下来,这个量级对信息流场景足够。
2.2 磁盘缓存层的目录结构与文件命名
磁盘缓存的核心问题有两个:存哪里,怎么命名。
存哪里,要选系统认可的缓存语义目录,而不是应用私有文档目录。缓存目录的最大特点是“丢了不可惜”,系统在存储紧张时可能清理它。我实际用的路径是<应用沙箱>/cache/rn_image_cache/,并在应用启动时确认目录存在,不存在就创建。不要放在files目录下,否则应用卸载重装后残留文件会越积越多。
文件命名用URL的哈希值,而不是URL原文。因为URL可能有各种特殊字符,直接拼文件名既丑又不安全,还可能触发文件系统对长度、非法字符的限制。我用的哈希算法是SHA-256,转十六进制后截取前32位作为文件名主体。之所以不直接用完整64位,是为了文件名短一点,读取时省一点开销。哈希碰撞的概率在图片规模下可以忽略。
磁盘缓存还要额外存一个配套的索引文件,记录每个URL对应的文件名、大小、最后访问时间。索引文件每次写入可能频繁,我会做节流合并,比如每次更新先写内存,30秒或50次变更后再统一落盘。否则图片一多,索引文件本身就成了性能瓶颈。
2.3 缓存读写路径与回调时序
我把一次图片加载拆成四步:内存命中、磁盘命中、网络请求、兜底失败。时序上,内存命中是同步返回,JS侧直接给Image组件设置source,首帧立刻渲染。磁盘命中是异步返回,先给占位图,磁盘读出来后替换。
磁盘读取走原生侧时,最大的坑是回调时序。JS原生模块的回调如果直接抛到网络回调线程,UI线程渲染是拿不到的。所以我在原生侧统一把缓存读取结果post回JS线程。这里建议所有缓存读写相关的原生方法都做线程约束,否则你会发现一个诡异现象:缓存明明有,图片却偶尔显示不出来,过几秒又突然出现。
写缓存放在网络请求成功之后。图片二进制完整拿到,先写临时文件,写完再原子性改名到正式文件名。这样避免中途下载失败或者进程被杀,留在缓存目录里一堆半截文件,每次启动还要清理,很麻烦。
// 示意:加载流程的决策逻辑 async function resolveImage(url: string) { const mem = memoryCache.get(url); if (mem) { return { source: mem.source, from: 'memory' }; } const diskKey = hashUrl(url); const diskFile = await diskCache.get(diskKey); if (diskFile) { promoteToMemory(url, diskFile); return { source: diskFile.source, from: 'disk' }; } const netSource = await fetchRemote(url); const saved = await diskCache.put(diskKey, netSource); promoteToMemory(url, saved); return { source: saved.source, from: 'network' }; }3. 缓存键、并发去重与淘汰机制的工程细节
3.1 缓存键设计:URL规范化还是保留query
缓存键不能直接用URL字符串了事。我在实际项目里遇到过同一张图三种URL的情况:带签名参数、不带签名参数、带CDN缩放参数。如果全部当成不同键,缓存会被大量重复内容塞满,命中率很难看。
但也不能无脑去掉全部query参数。有些CDN的图片格式转换和裁剪就是用参数控制的,同一路径不同参数出来的是真正不同的图,去掉参数会发生张冠李戴。
我的方案是规范化规则可配置。默认做法是:
- 只保留协议、域名、端口、路径,去掉query中用于统计和签名的参数
- 保留影响图片内容的参数,比如宽高、质量、裁剪参数
- 提供一个配置函数,让业务方自己决定哪些query保留哪些去除
这样不同业务可以按自己的URL规则定制缓存键,不会出现信息流图和商品图互相污染。
3.2 并发重复请求合并
图片列表首次加载时,同一个URL可能被多个列表Item同时引用。如果每个Item都各自去请求网络、各自写缓存,缓存策略做得再好也是白搭,流量和磁盘写入都翻倍。
这个问题用“进行中请求表”解决。TS侧维护一个Map<缓存键, Promise>,同一个键的图片已经在请求中,新来的请求直接返回同一个Promise。请求完成后,统一把结果写入缓存,Promised的所有调用方都能拿到同一份结果。
这个方案要注意的是Promise失败时的处理。如果网络请求失败,必须把这个键从进行中表中删掉,否则后续所有请求都会拿到同一个reject的Promise,整批图片永久失败。我踩过这个坑,症状就是偶发几张图一直加载不出来,杀进程重启就好,就是因为失败后没清理request map。
// 示意:并发请求合并 const inflight = new Map<string, Promise<ResolvedImage>>(); function requestImage(key: string): Promise<ResolvedImage> { const pending = inflight.get(key); if (pending) return pending; const promise = doNetworkRequest(key).then((img) => { inflight.delete(key); return img; }).catch((err) => { inflight.delete(key); throw err; }); inflight.set(key, promise); return promise; }并发请求量本身也要限流。我不做无脑全量并发,默认同时最多发起4个网络图片请求。这个数字来自实测:4个并发的下载速度和带宽利用已经足够,超过之后卡顿感知明显上升。网络较好的Wi-Fi下可以调高到6,蜂窝网络下建议压到3。
3.3 淘汰与懒删除机制
磁盘缓存不能只进不出。我给每个缓存文件都记录了最后访问时间,淘汰策略是:优先删最后访问时间最老的,直到目录总体积降到上限的80%。这个“先涨到100%,再清回80%”的设计,是为了避免频繁触发淘汰,每次清理都留一点缓冲。
但实际的清理触发方式很关键。我没有在每次写缓存时都去检查磁盘体积,那样会拖慢写入路径。做法是:
- 写缓存时只判断是否超过体积上限的软阈值
- 超过阈值后,标记一个“需要清理”的脏标志,并不立刻阻塞
- 在应用进入后台或请求空闲时统一执行清理
这个懒删除机制,保证了缓存写入的平稳性。否则图片列表快速滑动时,每个新图下载完都要触发一次磁盘扫描,那感觉就像高速路上每过一个收费站都要停下来查一遍车。
4. 列表页批量加载下的缓存优化联动
4.1 预加载策略与调度并发数
信息流场景更常用的优化是预加载。用户还没滑到下一屏,我已经把下一屏的图片URL提前请求一遍。配合缓存策略,预加载的结果会放进磁盘缓存,用户真正滑到时直接走缓存,体验是“秒开”。
预加载的调度需要单独做,不能和正常加载共用一条并发通道,否则用户快速滑动时预加载会抢占正常图片的带宽。我当时给预加载单独设了一个最大并发数2,并且只在列表空闲时启动。判断空闲的方式很简单:列表停止滚动超过150ms,才开始预加载当前可见区域之后N条图片。
预加载的单位也有讲究。不要只预加载下一屏的图片,那用户滑两级之后又要等。我的做法是预加载到可见位置往后3屏的图片。更多也没必要,预加载过深意味着可能加载大量用户根本不会看到的图,浪费流量,尤其在蜂窝网络下体验很糟糕。
4.2 命中展示效果的优化
缓存命中不代表UI一定顺滑。内存命中是同步的,直接换source,无感知。磁盘命中是异步的,从读到显示有几十毫秒的间隙,如果直接替换Image的source,会遇到闪一下白底或占位图的现象。
这里的优化是为磁盘命中的图准备一个极轻量的内存占位,而不是给一张重图。具体做法是:磁盘缓存读取时,先返回一个很小的模糊缩略图或纯色背景,真实图读完后替换,视觉上是一个从模糊到清晰的过程,用户不会觉得“闪”。
如果业务对首图要求极高,可以在内存缓存命中后,直接用原生侧持有一个预解码的图片句柄,这样连首帧解码时间都省了。但这是有代价的,图片句柄本身占内存,我一般在核心大图上才开这个能力。
4.3 图片质量分级与缓存分级
信息流里不同位置对图片质量要求完全不同。列表小卡片的图可能是320宽就够,详情页的大图需要1080宽。同一个URL,业务上可以用不同参数请求不同尺寸,也可以请求同一张原图后本地缩放。前者缓存键天然区分,后者则需要缓存策略支持“同一张图的不同使用级别”。
我的实测建议是:能用URL参数区分就尽量用URL参数区分,让CDN帮你做缩放,缓存层只负责按不同键缓存不同结果。本地缩放看似省了一次下载,但会引入解码大图和缩放两段CPU开销,在低端机上图片多了内存抖动非常明显。
如果确实需要本地缩放,缓存里要额外记录图片的原始尺寸,按需生成缩放版本,并且每个缩放版本单独管理生命周期。这里水比较深,我的态度是能躲就躲。
5. 磁盘空间治理:清理、损坏恢复与监控打点
5.1 容量上限与定时清理
磁盘缓存必须设体积上限。我根据应用类型给的默认值是100MB,如果是图片重度应用,可调到200MB,但超过之后收益会递减。因为信息流图片老化很快,用户昨天看的商品图今天再回看的概率很低,你存再多也多是“死缓存”。
清理任务的触发策略,除了前面说的懒删除,还有一个兜底:应用冷启动后延迟90秒做一个全量巡检。为什么延迟90秒?因为启动初期是图片请求高峰,如果一启动就立刻清理磁盘,会和首屏渲染抢IO资源,反而让用户觉得启动变卡了。90秒后首屏基本安静,再清理才安全。
5.2 缓存损坏识别与自愈
缓存文件在系统存储压力大或者进程被杀时,可能写入不完整。我遇到过好多次:磁盘缓存文件名在,索引也指向它,但文件长度只有几KB,图片解码直接失败。
这类损坏缓存一定要在读取阶段就识别并删除,否则它会反复坏、反复报错。读取时我会校验文件的头部特征和最小长度。以JPEG为例,检查文件头是不是FF D8开头,WebP检查RIFF头,PNG检查PNG签名。不符合就直接删文件,同时删索引,并把这个缓存键从索引表移除,下次请求重新走网络。
这个自愈机制很重要。没有它,你会在线上收到一堆“图片加载失败”的反馈,但本地复现时因为网络正常,根本看不出问题。
5.3 监控数据与命中率分析
没有监控的缓存策略都是盲调。我给缓存模块加了一套轻量打点,数据上报内容包括:内存命中次数、磁盘命中次数、网络请求次数、磁盘缓存总大小、缓存文件数、清理时释放的空间。
通过这些数据可以算出内存命中率和磁盘命中率。我现在的项目里,内存命中率稳定在40%左右,磁盘命中率在85%左右。如果你发现磁盘命中率长期低于50%,就要回头检查两个方向:一是缓存键是否规范化过度,导致键冲突;二是预加载深度是否太浅,用户看到的图根本没机会提前缓存。
打点本身要轻,不要每张图都同步上报。我在缓存统计对象里累加计数,每30秒批量上报一次,避免了打点自身消耗大幅带宽。
6. 实测踩坑记录与最终调优配置
6.1 ArkTS原生侧的线程阻塞坑
最诡异的问题出现在磁盘缓存读取频繁时,JS线程出现周期性卡顿。排查到最后发现是原生侧读文件时,用了阻塞式IO跑在了RN的JS线程绑定的原生线程上。图片大一点,读取几十毫秒,对JS线程来说就是一次明显卡顿。
解法是原生侧所有缓存文件读取必须放到独立IO线程,读完后通过任务分发回到JS线程。这个坑在Android上一般不容易踩,因为Fresco把IO线程管理包得严严实实,但自己实现时没人帮你兜底,必须自己加线程约束。
6.2 图片格式兼容问题
鸿蒙版的图片解码对不同格式的兼容性比想象中更严格。我在测试时发现WebP格式在部分版本上解码正常,但在另一些版本上返回失败。后来统一加了一道格式探测逻辑,发现不支持的格式就走请求重发策略:把带格式转换参数的URL去掉格式参数,请求一份PNG或JPEG版本。这个兜底逻辑放在缓存读失败的路径里,不算复杂,但能有效避免白屏反馈。
6.3 磁盘不足与写入失败
缓存写入时,磁盘可能随时满了。写入文件时如果没有catch异常,会导致整个图片加载链路崩溃。我的处理是:所有磁盘写入都包一层try-catch,写失败就视为缓存未命中,图片该显示还是正常显示,只是不缓存。
还有一次遇到系统存储权限异常,缓存目录申请失败。这里也做了容错:目录创建失败就禁用磁盘缓存,只保留内存缓存。保证缓存策略的失败不影响业务功能,这是一条铁律。
6.4 我最终使用的参数组合
这是我跑了一段时间后稳定的配置,供参考:
- 内存缓存上限:设备可用内存的八分之一,下限50MB,上限200MB
- 磁盘缓存上限:100MB,清理到80MB后停止
- 缓存键规范化:保留协议、域名、端口、路径,保留图片尺寸参数,去掉统计和签名参数
- 哈希:SHA-256,取前32位
- 网络并发:4,预加载并发:2
- 预加载深度:当前可见位置往后3屏
- 索引落盘:30秒一次或50次变更
- 启动清理:冷启动后90秒执行
这套配置在信息流业务上表现稳定,磁盘命中率没有低于过80%。如果你的业务图片更重,磁盘上限可以放宽到200MB,但命中率可能只涨几个点,存储压力却翻倍,性价比不高。
最后分享一个小技巧:调试缓存策略时,不要只看日志,直接在开发者菜单里加一个“清空图片缓存”的入口,保留在灰度包中,方便测试回归。我在项目里就因为这个入口,节省了无数次手动删数据的过程。