作者:一只修仙的猿
最近回归android业务开发,开发了如下图的视频剪辑时间轴(图源:剪映):
对于时间轴上的缩略图,需要去解码器加载获取。若每次都去解码器获取,会导致缩略图加载卡顿,无法满足性能需求,因此这里需要对缩略图进行缓存来提高加载效率。那么其中的缓存策略,就是一个值得我们思考的关键点。
这篇文章来介绍这个缩略图缓存的思考与设计的过程,希望能够对你有所帮助。
背景
视频时间轴,我使用的是RecyclerView来实现,其中,每个Item为一个ImageView,即一个缩略图。如下图所示:
缩略图使用时间戳在解码器中进行定位获取。例如一个10s的视频,需要显示10个缩略图,则每个缩略图对应的视频时间位置是0s、1s、2s…9s。
缩略图加载的时机是:
- 当我们滑动时间轴时,一个item从不可见到可见,该item的
onBindViewHolder()
方法会被调用,则对应时间的缩略图会被加载一次。 - 当我们调用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;
- 我这里创建了ThumbnailKey类表示一个缩略图:视频+时间戳。由于我们需要使用其作为HashMap的Key,所以要重写
hashcode()
和equals()
方法。 - 创建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,到这里我们回顾一下我们整体的缓存策略:
- 当发起缩略图请求时,优先判断是否有内存缓存。
- 没有内存缓存,则判断是否有磁盘缓存。若存在磁盘缓存则通过IO去加载缩略图,并将结果缓存到内存中。
- 没有磁盘缓存,则需要通过解码器去获取缩略图,并将结果缓存到内存和磁盘中。
需要注意的是,每次完成编辑后,需要同步删除所有的磁盘缓存。否则随着时间的推移,我们的app磁盘内存占用,也要向小而美app靠近了。
我们理论分析到这,那么这个缓存策略不就ok了吗?如果仅仅是局限于分析,这个大框架策略,确实没毛病。但在真正落实到实现时,会发现有一些优化的点还需要我们关注,而且会很大程度上,影响我们的性能表现。
结合具体场景优化
1. 异步请求任务去重
从我们上面整体缓存策略来说,每一张缩略图只需要通过解码器加载一次,然后存储在磁盘与内存中即可,也就是理论上,只有首次加载是非常耗时的。
但在实际开发中,RecyclerView的notifyDataChanged()
很有可能频繁触发,导致解码器获取缩略图的时候,累积了很多相同的请求。
比如当前正在显示的缩略图如下:
当我们正在瞬间刷新三次的时候,就会累积12个缩略图请求,其中6个是重复的。此时解码器就会把性能,浪费在一些无意义的缩略图请求上。因此我们需要对这6个重复的缩略图进行去重,让同一个缩略图,仅会通过解码器加载一次。
而磁盘加载也是同理,毕竟也是一个耗时的异步操作,也是需要进行缩略图请求任务去重处理。
上面两种异步请求后的缩略图数据,放到内存缓存中就无需要进行去重处理了。
去重的方式很多,例如使用HashSet等。
2. 优先加载当前正在显示的缩略图、预加载缩略图
在体验上,我们还可以做一些优化。
例如解码器请求队列改为请求栈,优先处理当前屏幕上显示的缩略图请求,这样就可以更快看到缩略图显示。
例如可以将所有缩略图在进入剪辑页面的时候全部放入请求栈中,提前预加载缩略图。滑动时,再将新的缩略图请求从栈中提升到栈顶,优先加载当前显示的缩略图。
这些优化策略还有很多细节可以说,这里就不详细展开了。
最后
最后我们再来回顾一下整体的缓存策略,如下图:
相比上个流程图,增加了任务去重以及请求栈结构。
整个优化策略看下来有没有一丢丢眼熟,是不是操作系统的多级缓存很像?我们学习的一些基础知识、思想、策略等,有时候看着很高大上,但在实际应用中,还是有很大的帮助。
其次更重要的一点是,对于策略的分析到方案的落地,中间还存在很多的问题。方案思考分析仅仅只是整体的框架,将这套框架应用到具体的项目中,要完善很多的细节。