1. 前言
本文主要对 Bitmap 的加载和 Cache 进行总结。
2. 正文
2.1 Bitmap 的高效加载
2.1.1 说一下对于Android 中的 Bitmap 的理解
正如 String
(字符串)是一个文本文件在内存中的表达形式一样,Bitmap
(位图)本质上是一张图片的内容在内存中的表达形式。
Bitmap
将图片内容表示为有限但足够多的像素的集合,也就是说,Bitmap
是由一个个像素点组成的,但是像素点的个数是并不是无限多的,而是有限的。
Bimtap
是如何存储每个像素点呢?
我们知道,一张位图所占用的内存 = 位图长度(px)x 位图宽度(px)x 一个像素点占用的字节数,其中位图长度和位图宽度就代表了长度方向上和宽度方向上的像素点个数。需要特别说明的是,Bitmap
的位图长度和位图宽度未必等于图片的长度和宽度,这是因为为了适配不同的屏幕分辨率,可能存在图片的缩放。
在 Android 中,存储一个像素点所占用的字节数是用枚举类型 Bitmap.Config
中的各个参数来表示的。
枚举类 | 枚举值 | 说明 | 每个像素点占用的空间 | 比较分析 |
---|---|---|---|---|
Bimtap.Config | ALPHA_8(1) | 表示 8 位 Alpha 位图,即 A = 8,表示只存储 Alpha 位,不存储颜色值。它没有颜色,只有透明度。 | 一个像素点占 8 位 = 1字节 | 一般不适用,使用场景特殊,比如设置遮盖效果等。 |
Bimtap.Config | RGB_565(3) | 只存储 RGB 通道:红色占 5 位(有 32 种取值),绿色占 6 位(有 64 种取值),蓝色占 5 位(有 32 种取值)。它只有颜色,没有透明度。 | 一个像素点占 5 + 6 + 5 = 16 位 = 2 字节 | 不需要设置透明度时,比如拍摄的照片,同时对节省空间有要求,对图片质量没有太高的要求,推荐使用 RGB_565 。 |
Bimtap.Config | ARGB_4444(4) | 三个 RGB 颜色通道和 Alpha 透明度通道都占 4 位(有 16 种取值) 注意:从 KITKAT(19) 开始,任何使用这种配置来创建的位图都会使用 ARGB_8888 配置来替代。被标记位 @Deprecated ,建议使用 ARGB_8888 代替。 | 一个像素点占 4 + 4 + 4 + 4 = 16 位 = 2 字节 | 图片的失真严重,不要用这种配置。 |
Bimtap.Config | ARGB_8888(5) | 三个 RGB 颜色通道和 Alpha 透明度通道都占 8位(有 256 种取值)。 | 一个像素点占 8 + 8 + 8 + 8 = 32 位 = 4 字节 | 当需要透明度,对图片质量要求比较高,对空间占用没有限制时,就用 ARGB_8888 。 |
2.1.2 内存中存储的 Bitmap 对象和本地图片有什么区别?
内存中存储的 Bitmap
对象是本地图片在内存中的表达形式,要将 Bitmap
对象持久化存储为一张本地图片,需要对 Bitmap
对象表示的内容进行压缩存储,使用 Bitmap
对象的 compress
方法。压缩存储根据不同的压缩算法可以得到不同的图片压缩格式,如 JPEG,PNG,WEBP等。这里经过了一个压缩的过程,目的是为了节省本地磁盘空间。
这里对一张放在 drawable-nodpi 文件下的 bridge.jpg 对应的 Bitmap
对象采用 PNG,JPEG,WEBP 三种格式来压缩,查看对应的压缩代码:
final Bitmap bridgeBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.bridge);
new Thread(new Runnable() {
@Override
public void run() {
FileOutputStream fos1 = null;
FileOutputStream fos2 = null;
FileOutputStream fos3 = null;
try {
File dir = getExternalCacheDir();
fos1 = new FileOutputStream(new File(dir, "bridge.png"));
// 压缩为 PNG 格式
boolean result1 = bridgeBitmap.compress(Bitmap.CompressFormat.PNG, 100, fos1);
Log.d(TAG, "run: result1=" + result1);
fos2 = new FileOutputStream(new File(dir, "bridge.jpg"));
// 压缩为 JPEG 格式
boolean result2 = bridgeBitmap.compress(Bitmap.CompressFormat.JPEG, 100, fos2);
Log.d(TAG, "run: result2=" + result2);
fos3 = new FileOutputStream(new File(dir, "bridge.webp"));
// 压缩为 WEBP 格式
boolean result3 = bridgeBitmap.compress(Bitmap.CompressFormat.WEBP, 100, fos3);
Log.d(TAG, "run: result3=" + result3);
} catch (FileNotFoundException e) {
e.printStackTrace();
} finally {
if (fos1 != null) {
try { fos1.close(); } catch (IOException ignored) {}
}
if (fos2 != null) {
try { fos2.close(); } catch (IOException ignored) {}
}
if (fos3 != null) {
try { fos3.close(); } catch (IOException ignored) {}
}
}
}
}).start();
原始的 bridge.jpg 信息如下:
查看压缩后的文件如下:
可以看到,采用不同的压缩算法,获得的图片文件大小是不一样的,但是它们的分辨率都是一样的。
本地图片到内存中的 Bitmap
对象要使用 BitmapFactory
的 decodeFile
方法,这里经过了一个解压缩的过程,目的是在内存中展示图片的完整内容。
这里加载手机上的一张 bridge.jpg。
Bitmap fileBitmap = BitmapFactory.decodeFile(new File(dir, "bridge.jpg").getAbsolutePath());
Log.d(TAG, "onCreate: fileBitmap width=" + fileBitmap.getWidth() +
",height=" + fileBitmap.getHeight() +
",byteCount=" + (fileBitmap.getByteCount() / 1024) + "kb" +
", calculateSize = " + (4 * fileBitmap.getWidth() * fileBitmap.getHeight() / 1024) +"kb");
打印日志:
D/MainActivity: onCreate: fileBitmap width=270,height=360,byteCount=379kb, calculateSize = 379kb
可以看到,采用 getByteCount()
方法获取的内存大小和计算得到的值是一样的,这说明默认采用的像素点存储格式是 ARGB_8888
。
2.1.3 一张图片加载到内存中究竟需要占多少空间?
首先列出常用分辨率、屏幕密度对应关系表
屏幕像素密度等级 | ldpi | mdpi | hdpi | xhdpi | xxhdip | xxxhdpi |
---|---|---|---|---|---|---|
屏幕像素密度范围 | 0dpi-120dpi | 120dpi-160dpi | 160dpi-240dpi | 240dpi-320dpi | 320dpi-480dpi | 480dpi-640dpi |
常见分辨率 | 320*240 | 480*320 | 800*480 | 1280*720 | 1920*1080 | 3840*2160 |
dp与px转化关系(1dp = (dpi / 160)px)这里的dpi取屏幕像素密度范围的上限 | 1dp=0.75px | 1dp=1px | 1dp=1.5px | 1dp=2px | 1dp=3px | 1dp=4px |
然后获取 Redmi Note 9 Pro设备的 dpi 值:
DisplayMetrics displayMetrics = getResources().getDisplayMetrics();
float density = displayMetrics.density;
int densityDpi = displayMetrics.densityDpi;
Log.d(TAG, "onCreate: density="+density + ",densityDpi="+ densityDpi);
打印如下:
density=2.75,densityDpi=440
查看常用分辨率、屏幕像素密度对应关系表,可以知道 440 在 320dpi-480dpi 范围内,所以该设备的屏幕像素密度等级是 xxhdpi 的。这个小米手机并没有采用 120、160、240、320、480、640 中的值作为屏幕像素密度,而是选择实际的 dpi 作为屏幕像素密度。那么,density=2.75 是什么含义呢?density 表示当前设备的 densityDpi 与 160 的比值,这里 densityDpi=440,所以 440/160=2.75。
然后将一张 water.jpg 图片
依次放入 drawable、drawable-nodpi、drawable-ldpi、drawable-mdpi、drawable-hdpi、drawable-xhdpi、drawable-xxhdpi、drawable-xxxdpi 文件夹中,对应的 xml 文件如下:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/image"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/water" />
</LinearLayout>
在页面中打印信息:
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.drawable_test_activity);
ImageView iv = (ImageView) findViewById(R.id.image);
Drawable drawable = iv.getDrawable();
if (drawable instanceof BitmapDrawable) {
BitmapDrawable bitmapDrawable = (BitmapDrawable) drawable;
Bitmap bitmap = bitmapDrawable.getBitmap();
Log.d(TAG, "onCreate: bitmap width=" + bitmap.getWidth() +
", height=" + bitmap.getHeight() +
", byteCount=" + bitmap.getByteCount());
}
printDrawableFolderDensity();
}
private void printDrawableFolderDensity(){
TypedValue typedValue = new TypedValue();
Resources resources= getResources();
resources.openRawResource(R.drawable.water, typedValue);
int density=typedValue.density;
Log.d(TAG, "printDrawableFolderDensity: density=" + density);
}
比如把 water.jpg 放在 drawable-nodpi 文件夹中,效果如下:

打印日志如下:
D/DrawableTestActivity: onCreate: bitmap width=270, height=360, byteCount=388800
D/DrawableTestActivity: printDrawableFolderDensity: density=65535
可以看到,在 drawable-nodpi 中加载的图片是不会被缩放的。
把 water.jpg 放在 drawable-xxhdpi 文件夹中,效果如下:

打印日志如下:
D/DrawableTestActivity: onCreate: bitmap width=248, height=330, byteCount=327360
D/DrawableTestActivity: printDrawableFolderDensity: density=480
可以看到,虽然手机的屏幕像素密度是 440dpi,是在320dpi-480dpi之间,但是图片还是存在一定的缩小。这是为什么呢?因为这个小米手机并没有采用 120、160、240、320、480、640 中的值作为屏幕像素密度,而是选择实际的 dpi 作为屏幕像素密度。所以会进行一个比例计算,实际的图片宽度=原始的图片宽度x(屏幕像素密度/文件夹代表的屏幕像素密度)=270x(440/480)=247.5≈248。
把 water.jpg 放在 drawable-mdpi 文件夹中,效果如下:

打印日志如下:
D/DrawableTestActivity: onCreate: bitmap width=743, height=990, byteCount=2942280
D/DrawableTestActivity: printDrawableFolderDensity: density=160
可以看到,图片变大了好多啊。再使用上面的公式:实际的图片宽度=原始的图片宽度x(屏幕像素密度/文件夹代表的屏幕像素密度)=270x(440/160)=742.5≈743。
把所有的操作数据放到如下的表格里面:
各种drawable文件夹 | drawable | drawable-nodpi | drawable-ldpi | drawable-mdpi | drawable-hdpi | drawable-xhdpi | drawable-xxhdpi | drawable-xxxhdpi |
---|---|---|---|---|---|---|---|---|
文件夹所代表的的屏幕像素密度 | 0 | 65535 | 120 | 160 | 240 | 320 | 480 | 640 |
宽 | 743 | 270 | 990 | 743 | 495 | 371 | 248 | 186 |
高 | 990 | 360 | 1320 | 990 | 660 | 495 | 330 | 248 |
大小 | 2942280 | 388800 | 5227200 | 2942280 | 1306800 | 734580 | 327360 | 184512 |
从这张数据表,可以得出:
- 使用 drawable-nodpi 文件夹下的资源时,不会对图片进行缩放;
- 使用 drawable 文件夹下的资源时,和使用 drawable-mdpi 文件夹下的资源效果等效;
- 当实际的屏幕像素密度与图片所在文件夹代表的屏幕像素密度不同时,会进行缩放,缩放比例是:屏幕像素密度/文件夹代表的屏幕像素密度。
另外,在 2.1.2 的最后,从手机的 sd 卡上加载了一张图片,它的宽度和高度也是没有变化的。这是因为,当从本地磁盘上加载图片时,不会对图片进行缩放。
2.1.4 BitmapFactory 类提供了哪些加载 Bitmap 的方法?
BitmapFactory
类是一个工具类,提供大量以 decode 开头的函数,用于从各种资源、文件、数据流和字节数组中创建 Bitmap
对象。
方法 | 描述 |
---|---|
public static Bitmap decodeResource(Resources res, int id), public static Bitmap decodeResource(Resources res, int id, Options opts) | 从资源中加载位图,主要以 R.drawable.xxx 的形式从本地资源中加载。 |
public static Bitmap decodeFile(String pathName), public static Bitmap decodeFile(String pathName, Options opts) | 通过文件路径来加载图片,如从相册中加载位图。 |
public static Bitmap decodeByteArray(byte[] data, int offset, int length), public static Bitmap decodeByteArray(byte[] data, int offset, int length, Options opts) | 根据 Byte 数组来解析出位图。 |
public static Bitmap decodeFileDescriptor(FileDescriptor fd), public static Bitmap decodeFileDescriptor(FileDescriptor fd, Rect outPadding, Options opts) | 通过文件描述符来加载位图。 |
public static Bitmap decodeStream(InputStream is), public static Bitmap decodeStream(InputStream is, Rect outPadding, Options opts) | 通过 InputStream 加载位图。 |
public static Bitmap decodeResourceStream(Resources res, TypedValue value, InputStream is, Rect pad, Options opts) | 通过 从 resources 获取的 InputStream 加载位图。 |
它们之间的调用关系图如下:
2.1.5 为什么要高效地加载 Bitmap?
这里使用一个例子来说明,使用笔者自己的Redmi Note 9 Pro设备加载一张在 drawable-xxhdpi 下的高清图片,图片信息如下:
布局如下:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp"
android:orientation="vertical"
tools:context=".MainActivity">
<ImageView
android:id="@+id/iv"
android:background="#4400ff00"
android:layout_width="200dp"
android:layout_height="200dp"/>
</LinearLayout>
可以看到只需要在 200dp x 200dp (也就是 550px x 550px)的空间上显示这张 4000 x 3000 的高清图片。
代码如下:
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.building);
Log.d(TAG, "onClick: bitmap width="+ bitmap.getWidth()+",height="+ bitmap.getHeight()
+",byteCount=" + (bitmap.getByteCount() / 1024 / 1024) + "MB");
iv.setImageBitmap(bitmap);
打印日志如下:
D/MainActivity: onClick: bitmap width=3667,height=2750,byteCount=38MB
可以看到,实际加载到的是 3667x2750的分辨率,占用的内存空间达到了38M。这是一种浪费,毕竟我们只需要最多 550px x 550px就够了。那么,怎么办呢?通过设置合理的采样率可以加载到缩小后的图片,这样既不会影响用户视觉体验,又在一定程度上降低了内存占用,提高了加载 Bitmap 的性能。
可能有同学会说,现在的手机内存空间都很大啊,没有必要在乎这一点内存吧?
Android 手机的内存空间虽然很大,但是 Android 对单个应用所使用的内存大小是有限制的,比如256MB。这时如果我们加载的 Bitmap 太大,就会导致系统抛出异常。
我们来演示这种情形:把 building.jpg 放在 drawable-mdpi 文件夹下,根据 2.1.3 中的公式计算它占用的内存,缩放比例=440/160=2.75,位图宽度=4000x2.75=11000,位图高度=3000x2.75=8250,占用内存=4x11000x8250=363000000B=346MB。好了,运行程序,查看日志:
D/MainActivity: onClick: bitmap width=11000,height=8250,byteCount=346MB
E/AndroidRuntime: FATAL EXCEPTION: main
Process: com.wzc.chapter_12, PID: 26232
java.lang.RuntimeException: Canvas: trying to draw too large(363000000bytes) bitmap.
at android.graphics.RecordingCanvas.throwIfCannotDraw(RecordingCanvas.java:280)
at android.graphics.BaseRecordingCanvas.drawBitmap(BaseRecordingCanvas.java:88)
at android.graphics.drawable.BitmapDrawable.draw(BitmapDrawable.java:548)
at android.widget.ImageView.onDraw(ImageView.java:1436)
总结一下:为什么要高效加载 Bitmap?
- 为了降低内存占用,从而一定程度上避免程序超出内存占用限制导致崩溃;
- 为了提高加载图片的性能。
2.1.6 如何高效地加载 Bitmap?
采样率的理解
采用 BitmapFactory.Options
来加载所需尺寸的图片,主要是用到它的 inSampleSize
参数,即采样率。
采样率的全称是采样频率,是指每隔多少个样本采样一次作为结果。比如,将这个字段设置为 1,意思就是从原本图片的 1 个像素中取一个像素作为结果返回,这样采样后的图片大小为图片的原始大小;将这个字段设置为 2,意思是从原本图片的 2 个像素中取一个像素作为结果返回,其余的都被丢弃,这样采样后的图片其宽和高都为原来的 1/2,而像素数为原图的 1/4,其占用的内存大小也为原图的 1/4;将这个字段设置为 4,意思是从原本图片的 4 个像素中取一个像素作为结果返回,其余的都被丢弃,这样采样后的图片其宽和高都为原来的 1/4,而像素数为原图的 1/16,其内存占用大小也为原图的 1/16。
对于采样率,官方建议取 2 的指数,比如1、2、4、8、16 等,否则会被系统向下取整找到一个最接近的值;不能取小于 1 的值,否则系统将一直使用 1 来作为采样率。
在设置采样率时应该注意使得缩放后的图片尺寸尽量大于等于相应的 View
需要的大小,这样尽管会多占用一些内存,但不会造成图片质量的下降。
采样率的确定
-
获取图片的原始宽/高:将
BitmapFactory.Options
的inJustDecodeBounds
参数设为true
并加载图片,这样只会解析图片的原始宽/高,并不会真正地加载图片;// 1, 设置 inJustDecodeBounds = true, 进行 decode 来获取尺寸 final BitmapFactory.Options options = new BitmapFactory.Options(); options.inJustDecodeBounds = true; BitmapFactory.decodeResource(res, resId, options);
-
从
BitmapFactory.Options
中取出图片的原始宽高信息,他们对应于outWidth
和outHeight
参数;// 图片的原始宽和高 final int height = options.outHeight; final int width = options.outWidth;
-
根据采样率的规则并结合目标
View
的所需大小计算出采样率inSampleSize
;public static int calculateInSampleSize( BitmapFactory.Options options, int reqWidth, int reqHeight) { // 图片的原始宽和高 final int height = options.outHeight; final int width = options.outWidth; int inSampleSize = 1; if (height > reqHeight || width > reqWidth) { final int halfHeight = height / 2; final int halfWidth = width / 2; while ((halfHeight / inSampleSize >= reqHeight) && (halfWidth / inSampleSize >= reqWidth)) { inSampleSize *= 2; } } return inSampleSize; }
-
将
BitmapFactory.Options
的inJustDecodeBounds
参数设为false
,然后重新加载图片。// 3, 使用 inSampleSize 来解码 bitmap options.inJustDecodeBounds = false; return BitmapFactory.decodeResource(res, resId, options);
完整的代码如下:
public class ImageUtils {
/**
* 从资源文件中解析采用图像
* @param res Resources 对象
* @param resId 图片资源 id
* @param reqWidth 期望的宽度
* @param reqHeight 期望的高度
* @return
*/
public static Bitmap decodeSampleBitmapFromResource(
Resources res, int resId, int reqWidth, int reqHeight) {
// 1, 设置 inJustDecodeBounds = true, 进行 decode 来获取尺寸
final BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(res, resId, options);
// 2, 计算 inSampleSize
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);
// 3, 使用 inSampleSize 来解码 bitmap
options.inJustDecodeBounds = false;
return BitmapFactory.decodeResource(res, resId, options);
}
public static int calculateInSampleSize(
BitmapFactory.Options options, int reqWidth, int reqHeight) {
// 图片的原始宽和高
final int height = options.outHeight;
final int width = options.outWidth;
int inSampleSize = 1;
if (height > reqHeight || width > reqWidth) {
final int halfHeight = height / 2;
final int halfWidth = width / 2;
while ((halfHeight / inSampleSize >= reqHeight)
&& (halfWidth / inSampleSize >= reqWidth)) {
inSampleSize *= 2;
}
}
return inSampleSize;
}
}
2.2 Android 中的缓存策略
2.2.1 缓存策略的作用是什么?
可以为用户节省流量:当程序第一次从网络加载图片后,将其缓存到存储设备上,这样下次使用同样的图片时就不用再从网络上获取;
可以提高加载效率,提高应用的用户体验:当程序第一次从从网络加载图片后,不仅缓存到存储设备上,而且在内存中也缓存一份,这样当下次使用同样的图片时就首先从内存中获取,这样效率更高;如果内存中的缓存不存在,就会从存储设备上获取,从存储设备上获取到之后,就更新一下内存缓存。
2.2.2 缓存算法的作用是什么?
不管是内存缓存还是存储设备缓存,它们的缓存大小都是有限制的,因为分配给应用的内存是有限制的,诸如SD卡之类的存储设备是有容量限制的。
正因为有限制,所以在使用内存缓存和存储设备缓存时都需要分别指定一个最大的容量。当缓存容量满了,但是程序还需要向其添加缓存对象,这时候就需要删除一些旧的缓存并添加新的缓存。
如何定义缓存的新旧就是缓存算法考虑的问题,比如先进先出算法(FIFO),即如果一个数据最先进入缓存中,则应该最早淘汰掉;使用频率最低算法(LFU),即淘汰掉最近一段时间内访问次数最少的缓存对象;LRU(Least Recently Used),是近期最少使用算法,它的核心思想是当缓存满时,会优先淘汰那些近期最少使用的缓存对象。
2.2.3 采用 LRU 算法的缓存有哪两种?
有 LruCache 和 DiskLruCache。其中,LruCache 用于实现内存缓存,而 DiskLruCache 用于实现存储设备缓存。
2.2.4 LruCache 的工作原理是什么?
初始 LinkedHashMap
LruCache
内部采用一个 LinkedHashMap
以强引用的方式存储外界的缓存对象,甚至可以说,LruCache
的核心工作原理就是 LinkedHashMap
,具体来说,是 LinkedHashMap
的基于访问顺序的数据排序。
下面通过代码演示 LinkedHashMap
的基于访问顺序的数据排序:
LinkedHashMap<String, Integer> linkedHashMap = new LinkedHashMap<>(
0, 0.75f, true);
linkedHashMap.put("one", 1);
linkedHashMap.put("two", 2);
linkedHashMap.put("three", 3);
linkedHashMap.put("four", 4);
Log.d(TAG, "onCreate: linkedHashMap=" + linkedHashMap);
Log.d(TAG, "onCreate: linkedHashMap.get(\"one\")");
linkedHashMap.get("one");
Log.d(TAG, "onCreate: linkedHashMap=" + linkedHashMap);
Log.d(TAG, "onCreate: linkedHashMap.get(\"two\")");
linkedHashMap.get("two");
Log.d(TAG, "onCreate: linkedHashMap=" + linkedHashMap);
需要特别说明的是,这里使用的 LinkedHashMap
的构造方法是:
/**
* 第一个参数:初始化容量
* 第二个参数:加载因子
* 第三个参数:排序模式 - 对于访问顺序,为 true;对于插入顺序,则为 false
*/
public LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder)
在文档中,对这个方法的作用有一段说明:
创建链接哈希映射,该哈希映射的迭代顺序就是最后访问其条目的顺序,从近期访问最少到近期访问最多的顺序(访问顺序)。这种映射很适合构建 LRU 缓存。
运行代码,打印如下:
D/MainActivity: onCreate: linkedHashMap={one=1, two=2, three=3, four=4}
D/MainActivity: onCreate: linkedHashMap.get("one")
D/MainActivity: onCreate: linkedHashMap={two=2, three=3, four=4, one=1}
D/MainActivity: onCreate: linkedHashMap.get("two")
D/MainActivity: onCreate: linkedHashMap={three=3, four=4, one=1, two=2}
可以看到,第一次打印的内容是1,2,3,4,这和插入顺序是一致的;当调用了linkedHashMap.get("one");
获取 1 之后,再次输出内容是2,3,4,1;当调用了linkedHashMap.get("two");
获取 2 之后,输出内容是3,4,1,2。总结一下,就是每次 get
方法调用的那个键值对,就会被放到 LinkedHashMap
的尾部了,这一点正是由构造方法里的第三个参数 boolean accessOrder
设置为 true
来保证的。
仅仅把 accessOrder
设置为 true
,就可以实现基于访问顺序的排序,真是方便啊。我们看看源码里面是如何使用这个值来实现基于访问顺序排序的:
@Override public V get(Object key) {
// 省略掉了 key 为 null 的情形,我们这里只讨论一般情形。
int hash = Collections.secondaryHash(key);
HashMapEntry<K, V>[] tab = table;
// 遍历对应 key 的桶上的链表
for (HashMapEntry<K, V> e = tab[hash & (tab.length - 1)];
e != null; e = e.next) {
K eKey = e.key;
// 进入这个 if,那么就在链表上找到了相同的 key
if (eKey == key || (e.hash == hash && key.equals(eKey))) {
// 当 accessOrder 为 true 时,在返回 value 之前就会调用 makeTail 方法
if (accessOrder)
// 把指定的键值对重新链接到链表的尾部
makeTail((LinkedEntry<K, V>) e);
return e.value;
}
}
return null;
}
可以当 accessOrder
为 true
时,会调用 makeTail
方法把指定的键值对重新链接到链表的尾部,这个方法内部利用 LinkedHashMap
是一个双向循环列表(从双向链表中的任意一个结点开始,都可以很方便地访问它的前驱结点和后继结点,这就构成了双向循环列表)这一特点。
构造 LruCache 对象
int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024); // kb
int cacheSize = maxMemory / 8;
Log.d(TAG, "MemoryCache: cacheSize=" + cacheSize + "kb"); // 32768kb=32M
cache = new LruCache<String, Bitmap>(cacheSize) {
@Override
protected int sizeOf(String key, Bitmap bitmap) {
return bitmap.getRowBytes() * bitmap.getHeight() / 1024; // kb
}
@Override
protected void entryRemoved(boolean evicted, String key, Bitmap oldValue, Bitmap newValue) {
Log.d(TAG, "entryRemoved: evicted=" + evicted + ",key=" + key +
",oldValue=" + oldValue + ",newValue=" + newValue);
}
};
可以看到,LruCache
是一个有多个泛型参数的泛型类,这里我们传入的泛型实参是 String
和 Bitmap
,String
表示缓存对象的标识,比如一张图片的 url,或者一张图片的名字,Bitmap
表示缓存对象。
通过 LruCache
的构造方法传入了 cacheSize
作为内存缓存的最大容量大小。
private final LinkedHashMap<K, V> map;
/** Size of this cache in units. Not necessarily the number of elements. */
private int size;
private int maxSize;
public LruCache(int maxSize) {
if (maxSize <= 0) {
throw new IllegalArgumentException("maxSize <= 0");
}
this.maxSize = maxSize;
this.map = new LinkedHashMap<K, V>(0, 0.75f, true);
}
在构造方法内部:
- 会使用
maxSize
成员变量持有最大容量大小; - 会初始化一个
LinkedHashMap
对象,并赋值给成员变量map
。这里也使用了LinkedHashMap
的三参构造方法,并且第三个参数的accessOrder
设置为true
。
我们重写了 LruCache
的 sizeOf
方法用来获取每一个缓存对象的大小,这里我们返回的就是 Bitmap
的大小了。需要注意的一点是,返回值必须大于 0,并且返回值的单位要与 maxSize
的单位一致,这里采用的都是 kb。
我们还重写了 LruCache
的 entryRemoved
方法,用于打印旧缓存被移除;也可以在这个方法里面完成一些资源回收工作,比如回收 Bitmap
对象等。这里说一下方法参数的含义:
/**
* 参数一:evicted,如果为了腾出空间而移除条目,则为 true;
* 如果因调用 put 方法或者 remove 方法而移除条目,则为 false;
* 参数二:key,缓存对象的标识
* 参数三:oldValue,被移除的缓存对象
* 参数四:newValue,对应 key 的新缓存对象,如果因腾出空间或者调用 remove 方法,则为 null;
* 如果因调用 put 方法,则不为 null。
*/
protected void entryRemoved(boolean evicted, K key, V oldValue, V newValue)
另外,说明一下 LruCache
的 size
和 maxSize
成员变量的含义:当没有重写 sizeOf
方法时,sizeOf
方法默认返回值是 1,那么 size
就代表了当前所有缓存对象的个数,maxSize
就表示缓存对象的最大个数容量;当重写了 sizeOf
方法后,比如这里返回的是缓存对象 Bitmap
的大小,那么 size
就代表了当前所有缓存对象的总大小,maxSize
表示内存缓存的最大容量大小。
获取缓存
调用 LruCache
的 get
方法:cache.get(url);
获取一个缓存对象。
public final V get(K key) {
// 不支持 key 为 null 的获取
if (key == null) {
throw new NullPointerException("key == null");
}
V mapValue;
synchronized (this) {
// 这里是核心代码。
mapValue = map.get(key);
if (mapValue != null) {
hitCount++;
return mapValue;
}
missCount++;
}
V createdValue = create(key); // create 方法默认返回 null
if (createdValue == null) {
return null;
}
// 省略后面的代码,因为本次分析没有重写 create 方法,所以不会走后面的代码了。
}
可以看到,get
方法内部最重要的就是调用了 LinkedHashMap
对象的 get
方法了。而在初识 LinkedHashMap
部分我们知道,每次 get
方法调用的那个键值对,就会被放到 LinkedHashMap
的尾部了。
添加缓存
调用 LruCache
的 put
方法:cache.put(url, bitmap);
添加一个缓存对象。
public final V put(K key, V value) {
// 不支持 key 为 null 或者 value 为 null 的存储
if (key == null || value == null) {
throw new NullPointerException("key == null || value == null");
}
V previous;
synchronized (this) {
putCount++;
size += safeSizeOf(key, value);
previous = map.put(key, value);
if (previous != null) {
size -= safeSizeOf(key, previous);
}
}
if (previous != null) {
entryRemoved(false, key, previous, value);
}
trimToSize(maxSize);
return previous;
}
这个方法的工作有:
- 进入同步代码块中;
size += safeSizeOf(key, value);
,把新的缓存对象大小统计到当前的总缓存大小中;previous = map.put(key, value);
,把新的缓存对象及其标识添加到LinkedHashMap
对象中,返回值值即同 key 的缓存对象赋值给previous
变量,新添加的键值对会放在链表的尾部;- 如果
previous
不为null
,则说明在内存缓存中已经存在同 key 的缓存对象,这时就从size
中要减去previous
这个缓存对象的大小,即调用size -= safeSizeOf(key, previous);
; - 结束同步代码块;
- 如果
previous
不为null
,则调用entryRemoved(false, key, previous, value);
; - 调用
trimToSize(maxSize);
,这个方法的作用是当当前缓存对象的总大小超过maxSize
时,移除掉最旧的元素(即链表头部的元素)直到剩余缓存对象的总大小不大于maxSize
。
修剪当前总大小
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!");
}
// 当前总大小没有超过最大容量 或者 集合为空,就跳出 while 循环。
if (size <= maxSize || map.isEmpty()) {
break;
}
// 获取链表头部的条目,这就是要移除的缓存了。
Map.Entry<K, V> toEvict = map.entrySet().iterator().next();
key = toEvict.getKey();
value = toEvict.getValue();
map.remove(key); // 从集合中移除条目
size -= safeSizeOf(key, value); // 从当前总大小减去移除的缓存大小。
evictionCount++;
}
entryRemoved(true, key, value, null);
}
}
这个方法的作用:
- 首先会进入一个
while(true)
的无限循环中,这表明里面的循环体的代码可以执行多次。 - 无限循环的跳出条件是:
size <= maxSize || map.isEmpty()
,即当前总大小没有超过最大容量 或者 集合为空,就跳出 while 循环;换句话说,如果当前总缓存大小大于最大缓存容量并且集合不为空,那么循环就会一直执行下去了,这就说明缓存溢出了,需要进行旧元素的移除操作了。 - 获取链条头部的条目
Map.Entry<K, V> toEvict = map.entrySet().iterator().next();
,并调用集合的remove
方法移除掉,从size
中减去移除的缓存大小。 - 调用
entryRemoved
方法,回调出移除缓存的信息。
在文章尾部的代码链接里,包括有对 LruCache
进行添加缓存,获取缓存,展示缓存的例子,通过例子的形式,可以更直观地理解 LruCache
的工作原理。这里由于篇幅限制,不展示例子的代码,只展示进行操作的日志打印:
D/MemoryCache: MemoryCache: cacheSize=32768kb
// 依次添加 4 个缓存对象:image1,image2,image3,image4
D/MainActivity: putMemoryCache: key=image1, bitmap=android.graphics.Bitmap@9c327c8, size=1574kb
D/MainActivity: putMemoryCache: key=image2, bitmap=android.graphics.Bitmap@765b786, size=1574kb
D/MainActivity: putMemoryCache: key=image3, bitmap=android.graphics.Bitmap@bc6f874, size=5445kb
D/MainActivity: putMemoryCache: key=image4, bitmap=android.graphics.Bitmap@b55c612, size=5445kb
// 展示添加的缓存对象:image1,image2,image3,image4,和添加顺序是一样的。
D/MainActivity: showMemoryCache: key=image1, bitmap=android.graphics.Bitmap@9c327c8, size=1574kb
D/MainActivity: showMemoryCache: key=image2, bitmap=android.graphics.Bitmap@765b786, size=1574kb
D/MainActivity: showMemoryCache: key=image3, bitmap=android.graphics.Bitmap@bc6f874, size=5445kb
D/MainActivity: showMemoryCache: key=image4, bitmap=android.graphics.Bitmap@b55c612, size=5445kb
// 获取缓存 image3
D/MainActivity: getMemoryCache: key=image3, bitmap=android.graphics.Bitmap@bc6f874, size=5445kb
// 再次展示添加的缓存对象:image1,image2,image4,image3,可以看到 image3 移动到了链表的尾部
D/MainActivity: showMemoryCache: key=image1, bitmap=android.graphics.Bitmap@9c327c8, size=1574kb
D/MainActivity: showMemoryCache: key=image2, bitmap=android.graphics.Bitmap@765b786, size=1574kb
D/MainActivity: showMemoryCache: key=image4, bitmap=android.graphics.Bitmap@b55c612, size=5445kb
D/MainActivity: showMemoryCache: key=image3, bitmap=android.graphics.Bitmap@bc6f874, size=5445kb
// 再添加缓存对象,image5,image6,image7,当添加 image7 时,当前总缓存为 39559,
// 超过了缓存最大容量 32678,触发了移除旧缓存的操作。
D/MainActivity: putMemoryCache: key=image5, bitmap=android.graphics.Bitmap@e9527e0, size=8507kb
D/MainActivity: putMemoryCache: key=image6, bitmap=android.graphics.Bitmap@4c6315e, size=8507kb
D/MainActivity: putMemoryCache: key=image7, bitmap=android.graphics.Bitmap@c54e20c, size=8507kb
// 先移除了链表头的 image1,当前总缓存为 37985,还是超过最大容量 32678,
// 这时链表中有:image2,image4,image3,image5,image6,image7
D/MemoryCache: entryRemoved: evicted=true,key=image1,oldValue=android.graphics.Bitmap@9c327c8,newValue=null
// 接着移除了链表头的 image2,当前总缓存为 36411,还是超过容量限制 32678,
// 这时链表中有:image4,image3,image5,image6,image7
D/MemoryCache: entryRemoved: evicted=true,key=image2,oldValue=android.graphics.Bitmap@765b786,newValue=null
// 最后移除了链表头的 image4,当前总缓存为 30966,满足了容量限制,
// 这时链表中有:image3,image5,image6,image7
D/MemoryCache: entryRemoved: evicted=true,key=image4,oldValue=android.graphics.Bitmap@b55c612,newValue=null
// 展示缓存对象:image3,image5,image6,image7
D/MainActivity: showMemoryCache: key=image3, bitmap=android.graphics.Bitmap@bc6f874, size=5445kb
D/MainActivity: showMemoryCache: key=image5, bitmap=android.graphics.Bitmap@e9527e0, size=8507kb
D/MainActivity: showMemoryCache: key=image6, bitmap=android.graphics.Bitmap@4c6315e, size=8507kb
D/MainActivity: showMemoryCache: key=image7, bitmap=android.graphics.Bitmap@c54e20c, size=8507kb
2.2.5 DiskLruCache 的工作流程是什么?
DiskLruCache 库的下载地址是:https://github.com/JakeWharton/DiskLruCache。
创建 DiskLruCache
对象
DiskLruCache
不能通过构造方法来创建,只能通过静态的 open
方法来创建自己,如下所示:
public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize)
-
参数一
File directory
:硬盘缓存在文件系统中的存储路径。 -
参数二
int appVersion
:应用的版本号,当版本号发生改变时 DiskLruCache 会清除之前所有的缓存文件。如果希望应用版本号发生变化后保留缓存文件,传入一个固定的值即可。 -
参数三
int valueCount
:单个节点所对应的数据的个数,这个值一般设置为1,表示一个节点只对应一个缓存数据,如果设置为 2,就表示一个节点对应两个缓存数据,笔者在查看钉钉应用的缓存文件时发现钉钉是设置为 2 的。这个值的设置会影响
Editor
的newOutputStream(int index)
方法和Snapshot
的getInputStream(int index)
方法传入的index
值:如果valueCount
设置为 1,那么index
需要传入 0;如果valueCount
设置为 2,那么index
可以传入 0 和 1。 -
参数四
long maxSize
:最大缓存容量,单位是字节。
open
方法的执行流程图如下:

添加缓存
// 1,将 url 转换为 key
String key = hashKeyFromUrl(url);
OutputStream outputStream = null;
try {
// 2,获取 Editor 对象
// 对于这个 key 来说,如果当前不存在其他 Editor 对象,就会返回一个新的 Editor 对象
DiskLruCache.Editor editor = diskLruCache.edit(key);
if (editor != null) {
// 3,使用 Editor 对象创建一个输出流
// 一个节点对应一个数据,所以这里传入索引为 0
outputStream = editor.newOutputStream(DISK_CACHE_INDEX);
// 4,将从网络获取的 Bitmap 对象存到文件输出流中
if (bitmap != null) {
bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream);
// 5,提交写入操作
editor.commit();
} else {
// 5,回退整个操作
editor.abort();
}
diskLruCache.flush();
}
} catch (IOException e) {
e.printStackTrace();
} finally {
Utils.close(outputStream);
}
获取缓存
// 1,将 url 转换为 key
String key = hashKeyFromUrl(url);
FileInputStream fileInputStream = null;
try {
// 2,通过 get 方法获取一个 Snapshot 对象
DiskLruCache.Snapshot snapshot = diskLruCache.get(key);
if (snapshot != null) {
// 3,通过 Snapshot 对象获取输入流对象
fileInputStream = (FileInputStream) snapshot.
getInputStream(DISK_CACHE_INDEX);
FileDescriptor fileDescriptor = fileInputStream.getFD();
return BitmapFactory.decodeFileDescriptor(fileDescriptor);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
Utils.close(fileInputStream);
}
2.2.6 如何实现一个 ImageLoader?
实现的 ImageLoader 的 UML 类图如下:
功能如下:
- 抽取出了图片缓存的抽象接口为
ImageCaceh
接口,它的实现类有MemoryCache
内存缓存类、DiskCache
磁盘缓存类、DoubleCache
双缓存类(即内存缓存+磁盘缓存)、NoCache
无缓存类; - 抽取出了网络下载图片获取流资源的接口为
DownloadCallback
,默认实现是HttpURLConnectionDownloadCallbackImpl
; - 提供了
setImageCache(ImageCache imageCache)
允许外部设置自定义缓存,提供了setDownloadCallback(DownloadCallback downloadCallback)
允许外部设置获取图片网络资源的方式,如使用 okhttp 来实现; MemoryCache
是内存缓存实现类,使用了LruCache
,DiskCache
是磁盘缓存实现类,使用了DiskLruCache
;- 从网络获取图片流资源和对磁盘缓存的存取是耗时操作,使用线程池来处理;
- 对网络获取到的图片流资源进行采样处理后,再存入缓存,实现
Bitmap
高效加载; - 使用
Handler
将子线程获取到的Bitmap
数据切换到主线程,供 UI 展示使用。
代码见文末链接地址。
3. 最后
本文相关的代码已经上传 github,代码地址在这里。
参考
-
对理解 Android 中的 Bitmap 写得非常全面。
-
Android drawable微技巧,你所不知道的drawable的那些细节;
郭神通过直观的小例子说明了 drawable 缩放。
-
从 cpp 层源码上说明了 drawable 缩放的问题。
-
《Android自定义控件开发入门与实战》第10章 10.2 Bitmap 小节;
-
Android DiskLruCache完全解析,硬盘缓存的最佳方案-郭霖;
这篇文章偏向于 DiskLruCache 的使用。
-
Android DiskLruCache 源码解析 硬盘缓存的绝佳方案-鸿洋。
这篇文章着重于源码的分析。