为了账号安全,请及时绑定邮箱和手机立即绑定

Android 内存缓存框架 LruCache 的源码分析

标签:
Android

webp

LruCache 是 Android 提供的一种基于内存的缓存框架。LRU 是 Least Recently Used的缩写,即最近最少使用。当一块内存最近很少使用的时候就会被从缓存中移除。在这篇文章中,我们会先简单介绍 LruCache 的使用,然后我们会对它的源码进行分析。

1、基本的使用示例

首先,让我们来简单介绍一下如何使用 LruCache 实现内存缓存。下面是 LruCache 的一个使用示例。

这里我们实现的是对 RecyclerView 的列表的截图的功能。因为我们需要将列表的每个项的 Bitmap 存储下来,然后当所有的列表项的 Bitmap 都拿到的时候,将其按照顺序和位置绘制到一个完整的 Bitmap 上面。如果我们不使用 LruCache 的话,当然也能够是实现这个功能——将所有的列表项的 Bitmap 放置到一个 List 中即可。但是那种方式存在缺点:因为是强引用类型,所以当内存不足的时候会导致 OOM。

在下面的方法中,我们先获取了内存的大小的 8 分之一作为缓存空间的大小,用来初始化 LruCache 对象,然后从 RecyclerView 的适配器中取出所有的 ViewHolder 并获取其对应的 Bitmap,然后按照键值对的方式将其放置到 LruCache 中。当所有的列表项的 Bitmap 都拿到之后,我们再创建最终的 Bitmap 并将之前的 Bitmap 依次绘制到最终的 Bitmap 上面:

public static Bitmap shotRecyclerView(RecyclerView view) {
 RecyclerView.Adapter adapter = view.getAdapter();
 Bitmap bigBitmap = null; if (adapter != null) { int size = adapter.getItemCount(); int height = 0;
 Paint paint = new Paint(); int iHeight = 0; final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024); // 使用内存的 8 分之一作为该缓存框架的缓存空间
 final int cacheSize = maxMemory / 8;
 LruCache<String, Bitmap> bitmaCache = new LruCache<>(cacheSize); for (int i = 0; i < size; i++) {
 RecyclerView.ViewHolder holder = adapter.createViewHolder(view, adapter.getItemViewType(i));
 adapter.onBindViewHolder(holder, i);
 holder.itemView.measure(
 View.MeasureSpec.makeMeasureSpec(view.getWidth(), View.MeasureSpec.EXACTLY),
 View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED));
 holder.itemView.layout(0, 0, holder.itemView.getMeasuredWidth(),
 holder.itemView.getMeasuredHeight());
 holder.itemView.setDrawingCacheEnabled(true);
 holder.itemView.buildDrawingCache();
 Bitmap drawingCache = holder.itemView.getDrawingCache(); if (drawingCache != null) {
 bitmaCache.put(String.valueOf(i), drawingCache);
 }
 height += holder.itemView.getMeasuredHeight();
 }
 bigBitmap = Bitmap.createBitmap(view.getMeasuredWidth(), height, Bitmap.Config.ARGB_8888);
 Canvas bigCanvas = new Canvas(bigBitmap);
 Drawable lBackground = view.getBackground(); if (lBackground instanceof ColorDrawable) {
 ColorDrawable lColorDrawable = (ColorDrawable) lBackground; int lColor = lColorDrawable.getColor();
 bigCanvas.drawColor(lColor);
 } for (int i = 0; i < size; i++) {
 Bitmap bitmap = bitmaCache.get(String.valueOf(i));
 bigCanvas.drawBitmap(bitmap, 0f, iHeight, paint);
 iHeight += bitmap.getHeight();
 bitmap.recycle();
 }
 } return bigBitmap;
}

因此,我们可以总结出 LruCahce 的基本用法如下:

首先,你要声明一个缓存空间的大小,在这里我们用了运行时内存的 8 分之 1 作为缓存空间的大小

LruCache<String, Bitmap> bitmaCache = new LruCache<>(cacheSize);

但是应该注意的一个问题是缓存空间的单位的问题。因为 LruCache 的键值对的值可能是任何类型的,所以你传入的类型的大小如何统计需要自己去指定。后面我们在分析它的源码的时候会指出它的单位的问题。LruCahce 的 API 中也已经提供了计算传入的值的大小的方法。我们只需要在实例化一个 LruCache 的时候覆写该方法即可。而这里我们认为一个 Bitmap 对象所占用的内存的大小不超过 1KB.

然后,我们可以像普通的 Map 一样调用它的 put() 和 get() 方法向缓存中插入和从缓存中取出数据:

bitmaCache.put(String.valueOf(i), drawingCache);
Bitmap bitmap = bitmaCache.get(String.valueOf(i));

2、LruCahce 源码分析

2.1 分析之前:当我们自己实现一个 LruCache 的时候,我们需要考虑什么

在我们对 LruCache 的源码进行分析之前,我们现来考虑一下当我们自己去实现一个 LruCache 的时候需要考虑哪些东西,以此来带着问题阅读源码。

因为我们需要对数据进行存储,并且又能够根据指定的 id 将数据从缓存中取出,所以我们需要使用哈希表表结构。或者使用两个数组,一个作为键一个作为值,然后使用它们的索引来实现映射也行。但是,后者的效率不如前者高。

此外,我们还要对插入的元素进行排序,因为我们需要移除那些使用频率最小的元素。我们可以使用链表来达到这个目的,每当一个数据被用到的时候,我们可以将其移向链表的头节点。这样当要插入的元素大于缓存的最大空间的时候,我们就将链表末位的元素移除,以在缓存中腾出空间。

综合这两点,我们需要一个既有哈希表功能,又有队列功能的数据结构。在 Java 的集合中,已经为我们提供了 LinkedHashMap 用来实现这个功能。

实际上在 Android 中的 LruCache 也正是使用 LinkedHashMap 来实现的。LinkedHashMap 拓展自HashMap。如果理解 HashMap 的话,它的源码就不难阅读。LinkedHashMap 仅在 HashMap 的基础之上,又将各个节点放进了一个双向链表中。每次增加和删除一个元素的时候,被操作的元素会被移到到链表的末尾。Android 中的 LruCahce 就是在 LinkedHashMap 基础之上进行了一层拓展,不过 Android 中的 LruCache 的实现具有一些很巧妙的地方值得我们学习。

2.2 LruCache 源代码分析

从上面的分析中我们知道了选择 LinkedHashMap 作为底层数据结构的原因。下面我们分析其中的一些方法。这个类的实现还有许多的细节考虑得非常周到,非常值得我们借鉴和学习。

2.2.1 缓存的最大可用空间

在 LruCache 中有两个字段 size 和 maxSize. maxSize 会在 LruCache 的构造方法中被赋值,用来表示该缓存的最大可用的空间:

int cacheSize = 4 * 1024 * 1024; // 4MiB,cacheSize 的单位是 KBLruCache<String, Bitmap> bitmapCache = new LruCache<String, Bitmap>(cacheSize) { protected int sizeOf(String key, Bitmap value) { return value.getByteCount();
 }
}};

这里我们使用 4MB 来设置缓存空间的大小。我们知道 LruCache 的原理是指定了空间的大小之后,如果继续插入元素时,空间超出了指定的大小就会将那些“可以被移除”的元素移除掉,以此来为新的元素腾出空间。那么,因为插入的类型时不确定的,所以具体被插入的对象如何计算大小就应该交给用户来实现。

在上面的代码中,我们直接使用了 Bitmap 的 getByteCount() 方法来获取 Bitmap 的大小。同时,我们也注意到在最初的例子中,我们并没有这样去操作。那样的话一个 Bitmap 将会被当作 1KB 来计算。

这里的 sizeOf() 是一个受保护的方法,显然是希望用户自己去实现计算的逻辑。它的默认值是 1,单位和设置缓存大小指定的 maxSize 的单位相同:

protected int sizeOf(K key, V value) { return 1;
}

这里我们还需要提及一下:虽然这个方法交给用户来实现,但是在 LruCache 的源码中,不会直接调用这个方法,而是

private int safeSizeOf(K key, V value) { int result = sizeOf(key, value); if (result < 0) { throw new IllegalStateException("Negative size: " + key + "=" + value);
 } return result;
}

所以,这里又增加了一个检查,防止参数错误。其实,这个考虑是非常周到的,试想如果传入了一个非法的参数,导致了意外的错误,那么错误的地方就很难跟踪了。如果我们自己想设计 API 给别人用并且提供给他们自己可以覆写的方法的时候,不妨借鉴一下这个设计。

2.2.2 LruCache 的 get() 方法

下面我们分析它的 get() 方法。它用来从 LruCahce 中根据指定的键来获取对应的值:

/**
 * 1). 获取指定 key 对应的元素,如果不存在的话就用 craete() 方法创建一个。
 * 2). 当返回一个元素的时候,该元素将被移动到队列的首位;
 * 3). 如果在缓存中不存在又不能创建,就返回n ull
 */public final V get(K key) { if (key == null) { throw new NullPointerException("key == null");
 }
 V mapValue; synchronized (this) { // 在这里如果返回不为空的话就会将返回的元素移动到队列头部,这是在 LinkedHashMap 中实现的
 mapValue = map.get(key); if (mapValue != null) { // 缓存命中
 hitCount++; return mapValue;
 } // 缓存没有命中,可能是因为这个键值对被移除了
 missCount++;
 } // 这里的创建是单线程的,在创建的时候指定的 key 可能已经被其他的键值对占用
 V createdValue = create(key); if (createdValue == null) { return null;
 } // 这里设计的目的是防止创建的时候,指定的 key 已经被其他的 value 占用,如果冲突就撤销插入
 synchronized (this) {
 createCount++; // 向表中插入一个新的数据的时候会返回该 key 之前对应的值,如果没有的话就返回 null
 mapValue = map.put(key, createdValue); if (mapValue != null) { // 冲突了,还要撤销之前的插入操作
 map.put(key, mapValue);
 } else {
 size += safeSizeOf(key, createdValue);
 }
 } if (mapValue != null) {
 entryRemoved(false, key, createdValue, mapValue); return mapValue;
 } else {
 trimToSize(maxSize); return createdValue;
 }
}

这里获取值的时候对当前的实例进行了加锁以保证线程安全。当用 map 的 get() 方法获取不到数据的时候用了 create() 方法。因为当指定的键值对找不到的时候,可能它本来就不存在,可能是因为缓存不足被移除了,所以,我们需要提供这个方法让用户来处理这种情况,该方法默认返回 null. 如果用户覆写了 create() 方法,并且返回的值不为 null,那么我们需要将该值插入到哈希表中。

插入的逻辑也在同步代码块中进行。这是因为,创建的操作可能过长而且是非同步的。当我们再次向指定的 key 插入值的时候,它可能已经存在值了。所以当调用 map 的 put() 的时候如果返回不为 null,就表明对应的 key 已经有对应的值了,就需要撤销插入操作。最后,当 mapValue 非 null,还要调用 entryRemoved() 方法。每当一个键值对从哈希表中被移除的时候,这个方法将会被回调一次。

最后调用了 trimToSize() 方法,用来保证新的值被插入之后缓存的空间大小不会超过我们指定的值。当发现已经使用的缓存超出最大的缓存大小的时候,“最近最少使用” 的项目将会被从哈希表中移除。

那么如何来判断哪个是 “最近最少使用” 的项目呢?我们先来看下 trimToSize() 的方法定义:

public void trimToSize(int maxSize) { while (true) {
 K key;
 V value;
 synchronized (this) { if (size < 0 || (map.isEmpty() && size != 0)) { throw new IllegalStateException(getClass().getName()
 + ".sizeOf() is reporting inconsistent results!");
 } if (size <= maxSize) { break;
 } // 获取用来移除的 “最近最少使用” 的项目
 Map.Entry<K, V> toEvict = map.eldest(); if (toEvict == null) { break;
 }
 key = toEvict.getKey();
 value = toEvict.getValue(); map.remove(key);
 size -= safeSizeOf(key, value);
 evictionCount++;
 }
 entryRemoved(true, key, value, null);
 }
}

显然,这里是使用了 LinkedHashMap 的 eldest() 方法,这个方法的返回值是:

public Map.Entry<K, V> eldest() { return head;
}

也就是 LinkedHashMap 的头结点。那么为什么要移除头结点呢?这不符合 LRU 的原则啊,这里分明是直接移除了头结点。实际上不是这样,魔力发生在 get() 方法中。在 LruCache 的 get() 方法中,我们调用了 LinkedHashMap 的 get() 方法,这个方法中又会在拿到值的时候调用下面的方法:

void afterNodeAccess(Node<K,V> e) { // move node to last
 LinkedHashMapEntry<K,V> last; if (accessOrder && (last = tail) != e) {
 LinkedHashMapEntry<K,V> p =
 (LinkedHashMapEntry<K,V>)e, b = p.before, a = p.after;
 p.after = null; if (b == null)
 head = a; else
 b.after = a; if (a != null)
 a.before = b; else
 last = b; if (last == null)
 head = p; else {
 p.before = last;
 last.after = p;
 }
 tail = p;
 ++modCount;
 }
}

这里的逻辑是把 get() 方法中返回的结点移动到双向链表的末尾。所以,最近最少使用的结点必然就是头结点了。

3、总结

以上是我们对 LruCache 的是使用和源码的总结,这里我们实际上只分析了 get() 的过程。因为这个方法才是 LruCache 的核心,它包含了插入值和移动最近使用的项目的过程。至于 put() 和 remove() 两种方法,它们内部实际上直接调用了 LinkedHashMap 的方法。这里我们不再对它们进行分析。不过当使用这个框架的时候,还是要提醒下注意缓存的对象的单位问题!



作者:Android高级架构
链接:https://www.jianshu.com/p/97637e5e30ee


点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消