聊一聊关于视频缩略图缓存策略

596 阅读9分钟

前言

很高兴见到你!

最近回归android业务开发,开发了如下图的视频剪辑时间轴(图源:剪映):

剪映

对于时间轴上的缩略图,需要去解码器加载获取。若每次都去解码器获取,会导致缩略图加载卡顿,无法满足性能需求,因此这里需要对缩略图进行缓存来提高加载效率。那么其中的缓存策略,就是一个值得我们思考的关键点。

这篇文章来介绍这个缩略图缓存的思考与设计的过程,希望能够对你有所帮助。

背景

视频时间轴,我使用的是RecyclerView来实现,其中,每个Item为一个ImageView,即一个缩略图。如下图所示:

image.png

缩略图使用时间戳在解码器中进行定位获取。例如一个10s的视频,需要显示10个缩略图,则每个缩略图对应的视频时间位置是0s、1s、2s...9s。

缩略图加载的时机是:

  1. 当我们滑动时间轴时,一个item从不可见到可见,该item的onBindViewHolder()方法会被调用,则对应时间的缩略图会被加载一次。
  2. 当我们调用RecyclerView的notifyDataChanged()时,屏幕上显示的所有缩略图,都会被重新加载一次。

在我们的项目中,缩略图解码器的性能比较差。举个例子,一个缩略图的加载耗时,可能是1s。假如我们不使用缓存,那么我们每次滑动到一个新的位置,都需要等待好几秒,缩略图才能完全显示完毕,这个体验是非常差的。

优化的方法,其中最直接的,是降低获取缩略图接口的耗时,例如从1s降低到0.1s,但这个优化对于负责这个模块的同事来说是一个巨大的挑战。且假如缩短到0.1s,其耗时依旧是不可接受的。

第二个优化方法,就是我们自己想办法,也就是今天我要聊的:增加缩略图缓存。增加了缓存之后,首次加载使用多媒体接口,依旧很慢。但是加载一次之后,响应延迟就可以达到无感了。

那么,这个缓存策略我们该如何设计?

内存缓存

首先最直接的方式,是增加内存缓存,说人话就是,使用一个HashMap,来缓存从解码器中获取的缩略图Bitmap。如下:

public class ThumbnailKey {
    int position = 0;
    String videoPath = "";

    @Override
    public boolean equals(Object obj) {...}

    @Override
    public int hashCode() {...}
}

HashMap<ThumbnailKey,Bitmap> mThumbnailMap;
  1. 我这里创建了ThumbnailKey类表示一个缩略图:视频+时间戳。由于我们需要使用其作为HashMap的Key,所以要重写hashcode()equals()方法。
  2. 创建HashMap对象来存储缩略图

这样,每次加载缩略图的时候,把结果存储在HashMap中,下次请求就直接从Map中去获取即可。Map中没有,再去解码器中加载。

但这里我们很容易发现一个问题:内存暴涨

如果缓存没有上限且视频比较长,那么缓存的bitmap内存占用会非常巨大。最终导致软件性能降低、甚至可能OOM。因此我们不能无限制地缓存缩略图,必须设置一个上限。这里我们结合LRU淘汰规则,使用LRUCache来代替HashMap,就可以很好地解决这个问题。LRUCache是android提供的一个官方库,内部使用的是LinkedHashMap,我们直接使用即可。

如果仅使用内存缓存,在长距离滑动超出内存缓存的范围时,仍然需要从解码器中重新加载。且由于内存的珍贵,上限无法设置地太大。

因此,这里我们需要引入另一个速度稍慢,但是量管够的缓存磁盘缓存

磁盘缓存

磁盘缓存的速度虽然比内存缓存慢许多,但对于解码器的解码速度也是降维打击了。磁盘缓存在读取一张缩略图的耗时是毫米级别的,平均耗时3ms。加载完成一屏幕的缩略图,假设6张,只需要18ms,这个耗时是完全满足需求的。

磁盘相比内存还有另一个好处,就是量大管饱。一张缩略图对应的bitmap大小大概是150k。一秒钟一个缩略图,一个10分钟的视频,所有缩略图的大小大概是88Mb。这个内存占用相对于磁盘来说都是小ks。作为对比,小而美的国民APP微信,磁盘占用都是以G为单位。

因此,我们完全可以将所有缩略图缓存在磁盘中,实现整个视频不管如何滑动,都能实现无感零延迟加载缩略图,且对应的内存开销,是可接受的。

这么看来,我们几乎只需要使用磁盘缓存就满足需求了,那岂不是可以直接取消内存缓存,还能减少内存占用?

这个逻辑没有问题,但是我们忽略了RecyclerView的一个刷新特性。当我们调用notifyDataChanged()的时候,会刷新当前显示的所有缩略图。那么在一些需要频繁调用notifyDataChanged()的场景,例如拖动剪辑视频的时候,会不断地去刷新缩略图,频率甚至可能是毫秒级的。磁盘缓存虽然很快,但是在他依旧是一个耗时操作,和内存缓存相比,依旧很慢。其次,当我们高频地左右滑动时间轴,那么显示之外的的缩略图也会被频繁加载。

磁盘读取文件本身是一个耗时的IO操作,高频地进行IO操作,也会降低我们程序的性能。因此这里我们需要结合内存缓存一起来使用。

我们内存缓存,主要解决的场景,是时间轴频繁刷新、以及左右快速来回拖动导致的缩略图频繁请求。那么我们确定内存缓存的上限,为一个屏幕上能显示的缩略图数量的三倍即可。用较少的内存占用,实现较好的表现效果。

ok,到这里我们回顾一下我们整体的缓存策略:

image.png

  1. 当发起缩略图请求时,优先判断是否有内存缓存。
  2. 没有内存缓存,则判断是否有磁盘缓存。若存在磁盘缓存则通过IO去加载缩略图,并将结果缓存到内存中。
  3. 没有磁盘缓存,则需要通过解码器去获取缩略图,并将结果缓存到内存和磁盘中。

需要注意的是,每次完成编辑后,需要同步删除所有的磁盘缓存。否则随着时间的推移,我们的app磁盘内存占用,也要向小而美app靠近了。

我们理论分析到这,那么这个缓存策略不就ok了吗?如果仅仅是局限于分析,这个大框架策略,确实没毛病。但在真正落实到实现时,会发现有一些优化的点还需要我们关注,而且会很大程度上,影响我们的性能表现。

结合具体场景优化

1. 异步请求任务去重

从我们上面整体缓存策略来说,每一张缩略图只需要通过解码器加载一次,然后存储在磁盘与内存中即可,也就是理论上,只有首次加载是非常耗时的。

但在实际开发中,RecyclerView的notifyDataChanged()很有可能频繁触发,导致解码器获取缩略图的时候,累积了很多相同的请求。

比如当前正在显示的缩略图如下:

image.png

当我们正在瞬间刷新三次的时候,就会累积12个缩略图请求,其中6个是重复的。此时解码器就会把性能,浪费在一些无意义的缩略图请求上。因此我们需要对这6个重复的缩略图进行去重,让同一个缩略图,仅会通过解码器加载一次。

而磁盘加载也是同理,毕竟也是一个耗时的异步操作,也是需要进行缩略图请求任务去重处理。

上面两种异步请求后的缩略图数据,放到内存缓存中就无需要进行去重处理了。

去重的方式很多,例如使用HashSet等。

2. 优先加载当前正在显示的缩略图、预加载缩略图

在体验上,我们还可以做一些优化。

例如解码器请求队列改为请求栈,优先处理当前屏幕上显示的缩略图请求,这样就可以更快看到缩略图显示。

例如可以将所有缩略图在进入剪辑页面的时候全部放入请求栈中,提前预加载缩略图。滑动时,再将新的缩略图请求从栈中提升到栈顶,优先加载当前显示的缩略图。

这些优化策略还有很多细节可以说,这里就不详细展开了。

最后

最后我们再来回顾一下整体的缓存策略,如下图:

image.png

相比上个流程图,增加了任务去重以及请求栈结构。

整个优化策略看下来有没有一丢丢眼熟,是不是操作系统的多级缓存很像?我们学习的一些基础知识、思想、策略等,有时候看着很高大上,但在实际应用中,还是有很大的帮助。

其次更重要的一点是,对于策略的分析到方案的落地,中间还存在很多的问题。方案思考分析仅仅只是整体的框架,将这套框架应用到具体的项目中,要完善很多的细节。

全文到此,原创不易,觉得有帮助可以点赞收藏评论转发。 有任何想法欢迎评论区交流指正。 如需转载请评论区或私信沟通。 另外欢迎光临笔者的个人博客:传送门