HarmonyOS7 缓存不是越多越好:图片、数据、视图多层缓存策略这样定
文章目录
- 前言
- 缓存分层设计思路
- L1 内存缓存:LRUCache 封装
- 图片缓存组件
- 数据缓存:接口响应 + 过期策略
- CacheManager:统一入口
- 踩坑提醒
- 小结
前言
做过列表页的同学都有体会:用户上下滑动的时候,图片反复加载、接口反复请求,列表卡顿,流量还哗哗地跑。体验差到想摔手机。
这些问题的根源就一个——没有缓存。或者更准确地说,没有一套分层合理的缓存策略。今天来聊聊怎么在组件层面搭建内存→磁盘→网络的多层缓存体系。
缓存分层设计思路
经典的三层缓存模型:
- 内存缓存(L1):速度最快,容量有限,App 退出就没了
- 磁盘缓存(L2):速度中等,容量大,持久化存储
- 网络请求(L3):速度最慢,数据最新鲜
每次取数据,按 L1 → L2 → L3 的顺序找,哪层命中就返回,同时回填上层缓存。写入时反向,先写 L3(如果是接口),再同步到 L2 和 L1。
L1 内存缓存:LRUCache 封装
内存缓存的核心是 LRU(Least Recently Used)策略,容量满了就淘汰最久没用的。ArkTS 里Map本身就是按插入顺序迭代的,可以利用这个特性实现 LRU:
// cache/LRUCache.etsexportclassLRUCache<K,V>{privatecache:Map<K,V>=newMap()privatemaxSize:numberconstructor(maxSize:number){this.maxSize=maxSize}get(key:K):V|undefined{constvalue=this.cache.get(key)if(value!==undefined){// 移到末尾(最近使用)this.cache.delete(key)this.cache.set(key,value)}returnvalue}put(key:K,value:V):void{if(this.cache.has(key)){this.cache.delete(key)}elseif(this.cache.size>=this.maxSize){// 淘汰最久没用的(Map 第一个元素)constoldestKey=this.cache.keys().next().valueif(oldestKey!==undefined){this.cache.delete(oldestKey)}}this.cache.set(key,value)}has(key:K):boolean{returnthis.cache.has(key)}remove(key:K):void{this.cache.delete(key)}clear():void{this.cache.clear()}getsize():number{returnthis.cache.size}}这个实现利用了Map的有序性,delete再set就能把元素移到末尾,keys().next().value拿到的就是最久没访问的元素。
图片缓存组件
图片是缓存需求最强烈的场景。基于 LRUCache 封装一个图片缓存:
// cache/ImageCache.etsimport{image}from'@kit.ImageKit'interfaceCachedImage{pixelMap:image.PixelMap width:numberheight:numbersizeBytes:number}exportclassImageCache{privatestaticinstance:ImageCache// 内存缓存:最多缓存 50 张图privatememoryCache:LRUCache<string,CachedImage>=newLRUCache(50)// 磁盘缓存目录privatediskCacheDir:string=''staticgetInstance():ImageCache{if(!ImageCache.instance){ImageCache.instance=newImageCache()}returnImageCache.instance}asyncinitDiskCache(context:Context):Promise<void>{this.diskCacheDir=context.cacheDir+'/images'// 确保目录存在constfs=awaitimport('@kit.CoreFileKit')if(!fs.fs.accessSync(this.diskCacheDir)){fs.fs.mkdirSync(this.diskCacheDir,true)}}asyncgetImage(url:string):Promise<image.PixelMap|null>{// L1: 内存缓存constmemCached=this.memoryCache.get(url)if(memCached){console.info(`[ImageCache] L1 命中:${url}`)returnmemCached.pixelMap}// L2: 磁盘缓存constdiskResult=awaitthis.loadFromDisk(url)if(diskResult){console.info(`[ImageCache] L2 命中:${url}`)this.memoryCache.put(url,diskResult)returndiskResult.pixelMap}// L3: 网络下载console.info(`[ImageCache] L3 网络请求:${url}`)constdownloaded=awaitthis.downloadAndCache(url)returndownloaded}privateasyncloadFromDisk(url:string):Promise<CachedImage|null>{try{constfileName=this.urlToFileName(url)constfilePath=`${this.diskCacheDir}/${fileName}`constfs=awaitimport('@kit.CoreFileKit')if(fs.fs.accessSync(filePath)){constbuffer=fs.fs.readFileSync(filePath)constsource:image.ImageSource=image.createImageSource(buffer.buffer)constpixelMap=awaitsource.createPixelMap()return{pixelMap,width:0,height:0,sizeBytes:buffer.byteLength}}}catch(e){// 磁盘缓存未命中,正常流程}returnnull}privateasyncdownloadAndCache(url:string):Promise<image.PixelMap|null>{try{consthttp=awaitimport('@kit.NetworkKit')constresponse=awaithttp.http.createHttp().request(url)constdata=response.resultasArrayBuffer// 写入磁盘缓存constfileName=this.urlToFileName(url)constfilePath=`${this.diskCacheDir}/${fileName}`constfs=awaitimport('@kit.CoreFileKit')fs.fs.writeFileSync(filePath,data)// 写入内存缓存constsource=image.createImageSource(data)constpixelMap=awaitsource.createPixelMap()constcached:CachedImage={pixelMap,width:0,height:0,sizeBytes:data.byteLength}this.memoryCache.put(url,cached)returnpixelMap}catch(e){console.error(`[ImageCache] 下载失败:${url},${e}`)returnnull}}privateurlToFileName(url:string):string{// 简单哈希,避免文件名冲突和过长lethash=0for(leti=0;i<url.length;i++){hash=((hash<<5)-hash)+url.charCodeAt(i)hash|=0}return`${Math.abs(hash).toString(16)}.cache`}}配合 UI 组件使用:
// components/CachedImage.ets@Componentexportstruct CachedImage{@Propurl:string=''@PropplaceholderColor:string='#E0E0E0'@StatepixelMap:image.PixelMap|null=null@StateisLoading:boolean=trueaboutToAppear():void{this.loadImage()}asyncloadImage():Promise<void>{this.isLoading=truethis.pixelMap=awaitImageCache.getInstance().getImage(this.url)this.isLoading=false}build(){Stack(){if(this.isLoading){// 占位图Column().width('100%').height('100%').backgroundColor(this.placeholderColor).borderRadius(8)}elseif(this.pixelMap){Image(this.pixelMap).width('100%').height('100%').objectFit(ImageFit.Cover).borderRadius(8)}}}}数据缓存:接口响应 + 过期策略
接口数据不能无限期缓存,需要加过期时间。封装一个带 TTL 的数据缓存:
// cache/DataCache.etsinterfaceCacheEntry<T>{data:Ttimestamp:numberttl:number// 毫秒}exportclassDataCache{privatememoryStore:Map<string,CacheEntry<any>>=newMap()privatediskPath:string=''asyncinit(context:Context):Promise<void>{this.diskPath=context.cacheDir+'/data_cache.json'awaitthis.loadFromDisk()}// 写入缓存,ttl 默认 5 分钟set<T>(key:string,data:T,ttl:number=5*60*1000):void{constentry:CacheEntry<T>={data,timestamp:Date.now(),ttl}this.memoryStore.set(key,entry)this.persistToDisk()}// 读取缓存,自动检查过期get<T>(key:string):T|null{constentry=this.memoryStore.get(key)if(!entry)returnnullconstisExpired=Date.now()-entry.timestamp>entry.ttlif(isExpired){this.memoryStore.delete(key)console.info(`[DataCache] 缓存过期,已清除:${key}`)returnnull}returnentry.dataasT}// 获取缓存,如果过期就执行 refresh 函数获取新数据asyncgetOrRefresh<T>(key:string,refresh:()=>Promise<T>,ttl:number=5*60*1000):Promise<T>{constcached=this.get<T>(key)if(cached!==null)returncachedconstfreshData=awaitrefresh()this.set(key,freshData,ttl)returnfreshData}// 清理所有过期缓存purgeExpired():number{letcount=0constnow=Date.now()this.memoryStore.forEach((entry,key)=>{if(now-entry.timestamp>entry.ttl){this.memoryStore.delete(key)count++}})returncount}clear():void{this.memoryStore.clear()this.persistToDisk()}privateasyncloadFromDisk():Promise<void>{try{constfs=awaitimport('@kit.CoreFileKit')if(fs.fs.accessSync(this.diskPath)){constcontent=fs.fs.readTextFileSync(this.diskPath)constdata=JSON.parse(content)asRecord<string,CacheEntry<any>>// 恢复时过滤掉已过期的constnow=Date.now()for(constkeyofObject.keys(data)){if(now-data[key].timestamp<=data[key].ttl){this.memoryStore.set(key,data[key])}}}}catch(e){console.warn('[DataCache] 磁盘缓存加载失败,使用空缓存')}}privateasyncpersistToDisk():Promise<void>{try{constfs=awaitimport('@kit.CoreFileKit')constdata:Record<string,CacheEntry<any>>={}this.memoryStore.forEach((entry,key)=>{data[key]=entry})fs.fs.writeFileSync(this.diskPath,JSON.stringify(data))}catch(e){console.warn('[DataCache] 磁盘缓存持久化失败')}}}getOrRefresh是最实用的方法——有缓存用缓存,没缓存就自动拉取并缓存,业务代码用起来特别省心:
constproducts=awaitdataCache.getOrRefresh('product_list',()=>api.fetchProducts(),10*60*1000// 10 分钟过期)CacheManager:统一入口
把图片缓存和数据缓存统一管理:
// cache/CacheManager.etsexportclassCacheManager{privatestaticinstance:CacheManagerprivateimageCache:ImageCache=ImageCache.getInstance()privatedataCache:DataCache=newDataCache()staticgetInstance():CacheManager{if(!CacheManager.instance){CacheManager.instance=newCacheManager()}returnCacheManager.instance}asyncinit(context:Context):Promise<void>{awaitthis.imageCache.initDiskCache(context)awaitthis.dataCache.init(context)// 启动时清理过期缓存this.dataCache.purgeExpired()}getImage(url:string):Promise<image.PixelMap|null>{returnthis.imageCache.getImage(url)}getData<T>(key:string):T|null{returnthis.dataCache.get<T>(key)}setData<T>(key:string,data:T,ttl?:number):void{this.dataCache.set(key,data,ttl)}getOrRefresh<T>(key:string,refresh:()=>Promise<T>,ttl?:number):Promise<T>{returnthis.dataCache.getOrRefresh(key,refresh,ttl)}// 清理全部缓存clearAll():void{this.imageCache.clear()this.dataCache.clear()}}踩坑提醒
内存缓存别贪大。图片缓存 50 张看着不多,如果是高清大图,内存占用轻松上几百 MB。建议根据图片平均大小动态调整上限,或者限制总字节数而不是条目数。
磁盘缓存要定期清理。用户如果一直不清缓存,磁盘占用会持续增长。可以在 App 启动时检查磁盘缓存大小,超过阈值就清理最老的一半。
注意线程安全。ArkTS 单线程模型下问题不大,但如果用了 TaskPool,多个线程同时读写磁盘缓存文件就可能出问题。加个简单的锁或者队列就行。
小结
多层缓存不是越多越好,关键是根据场景选择合适的缓存策略。图片适合 LRU 内存缓存 + 磁盘持久化,接口数据适合 TTL 过期策略,频繁创建的组件适合复用池。
我自己的习惯是在项目初期就把CacheManager搭好,所有网络请求都走缓存层。前期多花半天时间,后期能省掉大量性能优化的工作。用户体验也明显更好——页面秒开,滑动流畅,流量还省了一大半。
