HarmonyOS5 一顿饭时间 —— LRU、磁盘缓存与内存优化的结合

一、前言

HarmonyOS 的 Image 组件,相信大家平时用得还是挺开心的:一个 url 往里一塞,咔咔就能显示,啥也不用管,直接起飞。

但是,用着用着你可能会发现一些"奇妙体验"------

  • 图片只加载了一半,像是网断了。
  • 图片加载失败了,但是给你一个毫无用处的错误码和错误信息。
  • 最难搞的是,缓存你根本没法控制,它就那么一直卡着,死活不重新拉。

这就很尴尬了,本来以为是"开箱即用",结果掉坑里了。

老样子

如果您有任何疑问、对文章写的不满意、发现错误或者有更好的方法,如果你想支持下一期请务必点赞 ~,欢迎在评论私信邮件中提出,这真的对我很重要!非常感谢您的支持。🙏

二、关于Image缓存

Image的缓存策略

Image模块提供了三级Cache机制,解码后内存图片缓存、解码前数据缓存、物理磁盘缓存。在加载图片时会逐级查找,如果在Cache中找到之前加载过的图片则提前返回对应的结果。

Image组件如何配置打开和关闭缓存

  • 内存图片缓存:通过setImageCacheCount接口打开缓存,如果希望每次联网都获取最新资源,可以不设置或设置为0不缓存。
  • 磁盘缓存:磁盘缓存是默认开启的,默认值为100M,可以将setImageFileCacheSize的值设置为0关闭磁盘缓存。
  • 解码前数据缓存:通过setImageRawDataCacheSize设置内存中缓存解码前图片数据的大小上限,单位为字节,提升再次加载同源图片的加载速度。如果不设置则默认为0,不进行缓存。

setImageCacheCount、setImageRawDataCacheSize、和setImageFileCacheSize这这三个图片缓存接口灵活性不足,后续不再演进,对于复杂情况,建议使用ImageKnife。(以上全是官方说的)

所以ImageKnife是如何将LRU算法、磁盘缓存和内存优化融为一体,以及为什么官方说复杂情况,建议使用ImageKnife呢。看官往下看。

三、缓存策略基础概念

ImageKnife采用了双层缓存架构,就像我们生活中的"短期记忆"和"长期存储"一样:

  • 内存缓存(Memory Cache):相当于短期记忆,访问速度极快,但容量有限

  • 磁盘缓存(File Cache):相当于长期存储,容量大但访问速度相对较慢

这种设计遵循了计算机科学中的缓存层次结构原理,通过不同层级的缓存来平衡访问速度和存储容量。

缓存策略枚举

ImageKnife提供了三种缓存策略,让开发者可以根据需求灵活选择:

ts 复制代码
export enum CacheStrategy {
  // 默认-写入/读取内存和文件缓存
  Default = 0,
  // 只写入/读取内存缓存
  Memory = 1,
  // 只写入/读取文件缓存
  File = 2
}

可能有人会说,为什么需要提供多种缓存策略,可实际上对于任何事物,都会存在频繁,和不频繁的,那么频繁访问的图片适合放在内存中,不常用图片更适合放在磁盘中。以及遇到了大图片那么也可能不适合常驻在内存,你也不想的吧?

四、LRU内存缓存机制

LRU,相信每个开发都非常的了解了。Least Recently Used算法就像图书馆的借阅系统:最近被借阅的书籍放在最显眼的位置,长期无人问津的书籍会被移到角落,甚至下架。这样可以确保最常用的资源始终在快速访问范围内。

在ImageKnife中,我们可以找到显眼的

ts 复制代码
private memoryCache: IMemoryCache = new MemoryLruCache(256, 128 * 1024 * 1024);

让我们看看MemoryLruCache的核心实现:

ts 复制代码
export class MemoryLruCache implements IMemoryCache {
  maxMemory: number = 0          // 最大内存限制
  currentMemory: number = 0      // 当前已使用内存
  maxSize: number = 0            // 最大缓存条目数
  private lruCache: util.LRUCache<string, ImageKnifeData>  // 鸿蒙系统LRU缓存
  
  // 添加缓存的核心逻辑
  put(key: string, value: ImageKnifeData): void {
    let size = this.getImageKnifeDataSize(value)
    
    // 如果缓存已满,按LRU方式删除最旧的条目
    if (this.lruCache.length == this.maxSize && !this.lruCache.contains(key)) {
      this.remove(this.lruCache.keys()[0])  // 删除第一个(最旧的)
    } else if (this.lruCache.contains(key)) {
      this.remove(key)  // 如果key已存在,先删除旧的
    }
    
    this.lruCache.put(key, value)
    this.currentMemory += size
    this.trimToSize()  // 确保内存不超限
  }
}

可以看到核心代码也非常简单。

  1. 容量检查:this.lruCache.length == this.maxSize 检查缓存条目数是否达到上限
  2. LRU删除:this.lruCache.keys()[0] 获取最旧的缓存条目
  3. 内存管理:trimToSize() 确保内存使用量不超过设定阈值

为了处理"更新现有缓存"的场景。如果key已存在,我们不需要删除其他条目,只需要替换现有值即可,所以在删除旧缓存时要先检查!this.lruCache.contains(key)

LRUCache

Harmony很贴心的给我们提供一个LRU的工具

LRUCache通过LinkedHashMap来实现LRU。LinkedHashMap继承于HashMap,HashMap用于快速查找数据,LinkedHashMap双向链表用于记录数据的顺序关系。因此,对于get()、put()、remove()等操作,LinkedHashMap除了包含HashMap的功能,还需要实现调整Entry顺序链表的工作。其数据结构如下图所示:

图1 LRUCache的LinkedHashMap数据结构图

LruCache中将LinkedHashMap的顺序设置为LRU顺序,链表头部的对象为近期最少用到的对象。常用的方法及其说明如下所示:

  • 调用get()方法:根据key查询对应,如果没有查到则返回null。查询到对应对象后,将该对象移到链表的尾端,并返回查询的对象。
  • 调用put()方法:将key-value对添加到缓存中,同时将新对象存储在链表尾端。当内存缓存达到最大值时,移除链表头部的对象。如果key已存在,则更新其对应的value。
  • 调用remove()方法:删除key对应的缓存value,如果key对应的value不在,则返回为null,否则,返回已删除的key-value键值对。
  • 调用updateCapacity()方法,设置缓存存储容量。如果新容量小于原容量,仅保留新容量大小的数据。

(以上官方原话)

可扩展性

在MemoryLruCache,我们可以看到实现了IMemoryCache,而且ImageKnife中也是

ts 复制代码
private memoryCache: IMemoryCache = new MemoryLruCache(256, 128 * 1024 * 1024);

所以是支持我们自定义扩展的,只需要我们实现IMemoryCache~

ts 复制代码
  /**
   * 设置自定义的内存缓存
   * @param newMemoryCache 自定义内存缓存
   */
  initMemoryCache(newMemoryCache: IMemoryCache): void {
    this.memoryCache = newMemoryCache
  }

五、磁盘缓存与文件管理

磁盘缓存就像是一个智能的文件柜系统:不仅要知道文件在哪里,还要知道哪些文件最常用,哪些可以清理。

很容易的,我们能观察到FileCache这个文件:

让我们看看FileCache的实现:

ts 复制代码
export class FileCache {
  private lruCache: util.LRUCache<string, number>
  // 初始化缓存目录,扫描现有文件
  public async initFileCache(path: string = FileCache.CACHE_FOLDER) {
    // 遍历缓存目录下的文件,按照时间顺序加入缓存
    let filenames: string[] = await FileUtils.getInstance().ListFile(this.path)
    
    // 按照文件创建时间排序
    let cachefiles: CacheFileInfo[] = []
    for (let i = 0; i < filenames.length; i++) {
      let stat: fs.Stat | undefined = await FileUtils.getInstance().Stat(this.path + filenames[i])
      cachefiles.push({
        file: filenames[i],
        ctime: stat === undefined ? 0 : stat.ctime,  // 文件创建时间
        size: stat?.size ?? 0
      })
    }
    
    // 按时间排序,确保LRU顺序
    let sortedCachefiles: CacheFileInfo[] = cachefiles.sort((a, b) => a.ctime - b.ctime)
    
    // 将文件信息加入LRU缓存
    for (let i = 0; i < sortedCachefiles.length; i++) {
      this.lruCache.put(sortedCachefiles[i].file, fileSize)
      this.addMemorySize(fileSize)
    }
  }
}
  1. 启动时扫描:应用启动时扫描缓存目录,重建LRU缓存状态
  2. 时间排序:按文件创建时间排序,确保LRU顺序正确
  3. 内存同步:LRU缓存记录文件大小,用于内存使用量统计

缓存写入策略

ts 复制代码
// 添加缓存键值对,同时写文件
put(key: string, value: ArrayBuffer): void {
  // LRU容量管理
  if (this.lruCache.length == this.maxSize && !this.lruCache.contains(key)) {
    this.remove(this.lruCache.keys()[0])  // 删除最旧的文件
  }
  
  let pre = this.lruCache.put(key, value.byteLength)
  FileUtils.getInstance().writeDataSync(this.path + key, value)  // 同步写入文件
  
  if (pre !== undefined) {
    this.addMemorySize(value)  // 更新内存统计
  }
  this.trimToSize()  // 确保不超限
}

有两个小细节:LRU缓存在主线程管理,避免并发冲突,文件读写在子线程进行,不阻塞UI。

作为一个程序员,我们必须有拿来主义的精神,实际上不仅仅是图片,大家可以看到两个地方都有lruCache,而ImageKnfile给我们提供了内存和文件的Lru实现。我们完全可以用到我们的下载等框架里面使用~

不同缓存策略

ts 复制代码
// 从内存或文件缓存中获取图片数据
getCacheImage(loadSrc: string, cacheType: CacheStrategy = CacheStrategy.Default, signature?: string): Promise<ImageKnifeData | undefined> {
  return new Promise((resolve, reject) => {
    if (cacheType == CacheStrategy.Memory) {
      // 只从内存缓存获取
      resolve(this.readMemoryCache(loadSrc, option, engineKeyImpl))
    } else if (cacheType == CacheStrategy.File) {
      // 只从文件缓存获取
      this.readFileCache(loadSrc, engineKeyImpl, resolve)
    } else {
      // 默认策略:先查内存,再查磁盘
      let data = this.readMemoryCache(loadSrc, option, engineKeyImpl)
      data == undefined ? this.readFileCache(loadSrc, engineKeyImpl, resolve) : resolve(data)
    }
  })
}

// 预加载缓存,支持不同策略
putCacheImage(url: string, pixelMap: PixelMap, cacheType: CacheStrategy = CacheStrategy.Default, signature?: string) {
  let memoryKey = this.getEngineKeyImpl().generateMemoryKey(url, ImageKnifeRequestSource.SRC, { loadSrc: url, signature: signature });
  let fileKey = this.getEngineKeyImpl().generateFileKey(url, signature);
  
  switch (cacheType) {
    case CacheStrategy.Default:
      // 同时存入内存和磁盘
      this.saveMemoryCache(memoryKey, imageKnifeData);
      this.saveFileCache(fileKey, this.pixelMapToArrayBuffer(pixelMap));
      break;
    case CacheStrategy.File:
      // 只存入磁盘
      this.saveFileCache(fileKey, this.pixelMapToArrayBuffer(pixelMap));
      break;
    case CacheStrategy.Memory:
      // 只存入内存
      this.saveMemoryCache(memoryKey, imageKnifeData);
      break;
  }
}

可以看到ImageKnfile,支持三种不同的缓存策略,满足不同需求,可以在运行时动态选择缓存策略,无需重新初始化。

那在实际应用中,什么时候应该使用CacheStrategy.Memory,什么时候使用CacheStrategy.File?

  • Memory策略:适用于频繁访问的小图片,如用户头像、图标等

  • File策略:适用于大图片或不常用图片,如背景图、详情图等

  • Default策略:适用于大多数场景,在性能和容量之间取得平衡

六、缓存架构的整体设计

让我们综合看看ImageKnife类是如何协调各个缓存组件的:

分层架构的层次关系

ImageKnife采用了经典的三层架构设计,每一层都有明确的职责边界:

  • 应用层:负责用户交互和配置管理

  • 策略层:负责缓存策略决策和任务分发

  • 缓存层:负责数据存储和检索

  • 加载层:负责从不同数据源获取图片

  • 存储层:提供物理存储支持

这种分层设计让系统具有了高内聚、低耦合的特性。每一层都可以独立演进,不会因为其他层的变化而受到影响。

缓存策略的选择不是硬编码的,而是通过策略模式实现的。开发者可以通过CacheStrategy枚举选择不同的缓存行为:这种设计让ImageKnife能够适应不同的使用场景。

责任链模式的负载均衡

ImageKnifeDispatcher作为系统的"交通指挥中心",采用了责任链模式来管理图片加载请求:

ts 复制代码
// 策略选择影响整个缓存流程
switch (cacheType) {
  case CacheStrategy.Default:   // 平衡策略
  case CacheStrategy.Memory:    // 速度优先
  case CacheStrategy.File:      // 容量优先
}

这种设计确保了系统在高并发情况下的稳定性,就像高速公路的匝道控制,避免交通拥堵。

架构的扩展性设计

除了当前的场景,肯定要考虑可扩展性。

插件化的加载策略

ImageKnife通过工厂模式实现了加载策略的插件化:

ts 复制代码
// 检查重复请求,避免重复下载
checkRepeatRequests(request: ImageKnifeRequest, imageSrc: string | ImageKnifeRequestSource): ImageKnifeCheckRequest | undefined {
  // 如果并发请求数达到上限,加入排队队列
  if (this.executingJobMap.length >= this.maxRequests && !this.executingJobMap.get(memoryKey)) {
    this.jobQueue.add(request)
    return
  }
}

这种设计让开发者可以轻松添加新的图片加载方式,比如从数据库加载、从加密存储加载等。

缓存接口的抽象化

通过IMemoryCache接口,ImageKnife为缓存实现提供了统一的抽象:

ts 复制代码
export class ImageLoaderFactory {
  static getLoaderStrategy(request: RequestJobRequest): IImageLoaderStrategy | null {
    if (request.customGetImage !== undefined) {
      return new CustomLoaderStrategy();        // 自定义加载
    }
    if (request.src.startsWith('http://')) {
      return new HttpLoaderStrategy();          // HTTP加载
    }
    if (request.src.startsWith('file://')) {
      return new FileSystemLoaderStrategy();    // 文件系统加载
    }
    // ... 其他策略
  }
}

这种抽象让系统可以轻松支持不同的缓存实现,比如Redis缓存、内存映射缓存等。

七、小结

通过深入分析ImageKnife的缓存策略源码,我们不仅看到了一个优秀的图片缓存系统,更看到了一个开源项目在可扩展性和设计理念上的卓越表现。

  • 自定义缓存策略:实现自己的内存管理算法

  • 替换核心组件:在不影响其他代码的情况下更换缓存实现

  • 扩展功能:添加新的缓存特性,如缓存统计、性能监控等

不仅仅是图片。其实这样的设计,我们完全可以当做轮子,用于我们自己项目的文件管理,缓存管理等等等~拿来主义!

八、总结

没了

看这 -------------------->[如果有想加入鸿蒙生态的大佬们,快来加入鸿蒙认证吧!初高级证书没获取的,点我!!!!!!!!,我真的很需要求求了!]<--------------------看这

相关推荐
小小小小小星1 小时前
鸿蒙开发性能优化实战指南:从工具到代码全解析
性能优化·harmonyos
奶糖不太甜1 小时前
鸿蒙元应用与服务卡片技术文档及案例
harmonyos
特立独行的猫a1 小时前
C/C++三方库移植到HarmonyOS平台详细教程
c语言·c++·harmonyos·napi·三方库·aki
鸿蒙小灰1 小时前
鸿蒙开发中CMake/Ninja编译问题与解决方案
harmonyos·cmake
架构师沉默1 小时前
Java 开发者别忽略 return!这 11 种写法你写对了吗?
java·后端·架构
缘澄1 小时前
ArkUI基础篇-组件事件
harmonyos·arkui
鸿蒙先行者1 小时前
HarmonyOS与OpenHarmony区别分析
harmonyos
li理2 小时前
鸿蒙NEXT渲染控制全面解析:从条件渲染到混合开发
harmonyos
Hello.Reader2 小时前
Kafka 在 6 大典型用例的落地实践架构、参数与避坑清单
数据库·架构·kafka
三无少女指南2 小时前
动态线程池核心解密:从 Nacos 到 Pub/Sub 架构的实现与对比
运维·架构