目录

  • 1. 前言
  • 2. 正文
    • 2.1 Bitmap 的高效加载
      • 2.1.1 说一下对于Android 中的 Bitmap 的理解
      • 2.1.2 内存中存储的 Bitmap 对象和本地图片有什么区别?
      • 2.1.3 一张图片加载到内存中究竟需要占多少空间?
      • 2.1.4 BitmapFactory 类提供了哪些加载 Bitmap 的方法?
      • 2.1.5 为什么要高效地加载 Bitmap?
      • 2.1.6 如何高效地加载 Bitmap?
    • 2.2 Android 中的缓存策略
      • 2.2.1 缓存策略的作用是什么?
      • 2.2.2 缓存算法的作用是什么?
      • 2.2.3 采用 LRU 算法的缓存有哪两种?
      • 2.2.4 LruCache 的工作原理是什么?
      • 2.2.5 DiskLruCache 的工作流程是什么?
      • 2.2.6 如何实现一个 ImageLoader?
  • 3. 最后
  • 参考

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() {@Overridepublic 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 对象要使用 BitmapFactorydecodeFile 方法,这里经过了一个解压缩的过程,目的是在内存中展示图片的完整内容。

这里加载手机上的一张 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"><ImageViewandroid: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"?>
<LinearLayoutxmlns: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"><ImageViewandroid: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: mainProcess: com.wzc.chapter_12, PID: 26232java.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 需要的大小,这样尽管会多占用一些内存,但不会造成图片质量的下降。

采样率的确定

  1. 获取图片的原始宽/高:将 BitmapFactory.OptionsinJustDecodeBounds 参数设为 true 并加载图片,这样只会解析图片的原始宽/高,并不会真正地加载图片;

    // 1, 设置 inJustDecodeBounds = true, 进行 decode 来获取尺寸
    final BitmapFactory.Options options = new BitmapFactory.Options();
    options.inJustDecodeBounds = true;
    BitmapFactory.decodeResource(res, resId, options);
    
  2. BitmapFactory.Options 中取出图片的原始宽高信息,他们对应于 outWidthoutHeight 参数;

    // 图片的原始宽和高
    final int height = options.outHeight;
    final int width = options.outWidth;
    
  3. 根据采样率的规则并结合目标 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;
    }
    
  4. BitmapFactory.OptionsinJustDecodeBounds 参数设为 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, 计算 inSampleSizeoptions.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);// 3, 使用 inSampleSize 来解码 bitmapoptions.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,那么就在链表上找到了相同的 keyif (eKey == key || (e.hash == hash && key.equals(eKey))) {// 当 accessOrder 为 true 时,在返回 value 之前就会调用 makeTail 方法if (accessOrder)// 把指定的键值对重新链接到链表的尾部makeTail((LinkedEntry<K, V>) e);return e.value;}}return null;
}

可以当 accessOrdertrue 时,会调用 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) {@Overrideprotected int sizeOf(String key, Bitmap bitmap) {return bitmap.getRowBytes() * bitmap.getHeight() / 1024; // kb}@Overrideprotected void entryRemoved(boolean evicted, String key, Bitmap oldValue, Bitmap newValue) {Log.d(TAG, "entryRemoved: evicted=" + evicted + ",key=" + key +",oldValue=" + oldValue + ",newValue=" + newValue);}
};

可以看到,LruCache 是一个有多个泛型参数的泛型类,这里我们传入的泛型实参是 StringBitmapString 表示缓存对象的标识,比如一张图片的 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

我们重写了 LruCachesizeOf 方法用来获取每一个缓存对象的大小,这里我们返回的就是 Bitmap 的大小了。需要注意的一点是,返回值必须大于 0,并且返回值的单位要与 maxSize 的单位一致,这里采用的都是 kb。

我们还重写了 LruCacheentryRemoved 方法,用于打印旧缓存被移除;也可以在这个方法里面完成一些资源回收工作,比如回收 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)

另外,说明一下 LruCachesizemaxSize成员变量的含义:当没有重写 sizeOf 方法时,sizeOf 方法默认返回值是 1,那么 size 就代表了当前所有缓存对象的个数,maxSize 就表示缓存对象的最大个数容量;当重写了 sizeOf 方法后,比如这里返回的是缓存对象 Bitmap 的大小,那么 size 就代表了当前所有缓存对象的总大小,maxSize 表示内存缓存的最大容量大小。

获取缓存

调用 LruCacheget 方法: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 方法默认返回 nullif (createdValue == null) {return null;}// 省略后面的代码,因为本次分析没有重写 create 方法,所以不会走后面的代码了。
}

可以看到,get 方法内部最重要的就是调用了 LinkedHashMap 对象的 get 方法了。而在初识 LinkedHashMap 部分我们知道,每次 get 方法调用的那个键值对,就会被放到 LinkedHashMap 的尾部了。

添加缓存

调用 LruCacheput 方法: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;
}

这个方法的工作有:

  1. 进入同步代码块中;
  2. size += safeSizeOf(key, value); ,把新的缓存对象大小统计到当前的总缓存大小中;
  3. previous = map.put(key, value);,把新的缓存对象及其标识添加到 LinkedHashMap 对象中,返回值值即同 key 的缓存对象赋值给 previous 变量,新添加的键值对会放在链表的尾部;
  4. 如果 previous 不为 null,则说明在内存缓存中已经存在同 key 的缓存对象,这时就从 size 中要减去 previous 这个缓存对象的大小,即调用 size -= safeSizeOf(key, previous);
  5. 结束同步代码块;
  6. 如果 previous 不为 null,则调用 entryRemoved(false, key, previous, value);
  7. 调用 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);}
}

这个方法的作用:

  1. 首先会进入一个 while(true) 的无限循环中,这表明里面的循环体的代码可以执行多次。
  2. 无限循环的跳出条件是:size <= maxSize || map.isEmpty(),即当前总大小没有超过最大容量 或者 集合为空,就跳出 while 循环;换句话说,如果当前总缓存大小大于最大缓存容量并且集合不为空,那么循环就会一直执行下去了,这就说明缓存溢出了,需要进行旧元素的移除操作了。
  3. 获取链条头部的条目 Map.Entry<K, V> toEvict = map.entrySet().iterator().next();,并调用集合的 remove 方法移除掉,从 size 中减去移除的缓存大小。
  4. 调用 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 的。

    这个值的设置会影响 EditornewOutputStream(int index) 方法和 SnapshotgetInputStream(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 对象创建一个输出流// 一个节点对应一个数据,所以这里传入索引为 0outputStream = 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 类图如下:

功能如下:

  1. 抽取出了图片缓存的抽象接口为 ImageCaceh 接口,它的实现类有 MemoryCache 内存缓存类、DiskCache 磁盘缓存类、DoubleCache 双缓存类(即内存缓存+磁盘缓存)、NoCache 无缓存类;
  2. 抽取出了网络下载图片获取流资源的接口为 DownloadCallback,默认实现是 HttpURLConnectionDownloadCallbackImpl
  3. 提供了 setImageCache(ImageCache imageCache) 允许外部设置自定义缓存,提供了 setDownloadCallback(DownloadCallback downloadCallback) 允许外部设置获取图片网络资源的方式,如使用 okhttp 来实现;
  4. MemoryCache 是内存缓存实现类,使用了 LruCacheDiskCache 是磁盘缓存实现类,使用了 DiskLruCache
  5. 从网络获取图片流资源和对磁盘缓存的存取是耗时操作,使用线程池来处理;
  6. 对网络获取到的图片流资源进行采样处理后,再存入缓存,实现 Bitmap 高效加载;
  7. 使用 Handler 将子线程获取到的 Bitmap 数据切换到主线程,供 UI 展示使用。

代码见文末链接地址。

3. 最后

本文相关的代码已经上传 github,代码地址在这里。

参考

  1. Android Bitmap优化: 关于 Bitmap 你要知道的一切;

  2. Android Bitmap(位图)详解;

    对理解 Android 中的 Bitmap 写得非常全面。

  3. Android drawable微技巧,你所不知道的drawable的那些细节;

    郭神通过直观的小例子说明了 drawable 缩放。

  4. Android多分辨率适配框架(1)— 核心基础;

    从 cpp 层源码上说明了 drawable 缩放的问题。

  5. 常用分辨率、屏幕密度对应关系;

  6. 《Android自定义控件开发入门与实战》第10章 10.2 Bitmap 小节;

  7. 面试官:如何实现一个LruCache,原理是什么?;

  8. Android DiskLruCache完全解析,硬盘缓存的最佳方案-郭霖;

    这篇文章偏向于 DiskLruCache 的使用。

  9. Android DiskLruCache 源码解析 硬盘缓存的绝佳方案-鸿洋。

    这篇文章着重于源码的分析。

《Android开发艺术探索》第12章- Bitmap 的加载和 Cache 读书笔记相关推荐

  1. Android开发艺术探索——第七章:Android动画深入分析

    Android开发艺术探索--第七章:Android动画深入分析 Android的动画可以分成三种,view动画,帧动画,还有属性动画,其实帧动画也是属于view动画的一种,,只不过他和传统的平移之类 ...

  2. Android开发艺术探索 - 第9章 四大组件的工作过程

    1.Activity启动过程 ref 从Activity的startActivity方法开始.startActivity的多个重载方法,最终都会调用startActivityForResult方法.m ...

  3. 《Android开发艺术探索》笔记目录

    该笔记以<Android开发艺术探索>为基础,结合Android 9.0代码和官方文档,修正了原书中表述不明确和过时的部分,同时加入了大量的个人理解. 13章,14章,15章是总结性的章节 ...

  4. 《android开发艺术探索》笔记之Bitmap的加载和Cache

    <Android开发艺术探索>笔记之Bitmap的加载和Cache<一> 我放暑假前,就在图书馆借了一本<Android开发艺术探索>,这也是我看到很多人推荐的.之 ...

  5. Android开发艺术探索——新的征程,程序人生路漫漫!

    Android开发艺术探索--新的征程,程序人生路漫漫! 偶尔写点东西分享,但是我还是比较喜欢写笔记,看书,群英传看完了,是学到了点东西,开始看这本更加深入Android的书籍了,不知道适不适合自己, ...

  6. Android开发艺术探索 读书笔记

    啥也不说了,@主席的<Android开发艺术探索>真是业界良心之作,不得不看!感谢主席,膜拜主席!主席主席,我要跟你生猴子!(>^ω^<) 读书笔记中若有任何问题请留言告知,谢 ...

  7. Android开发艺术探索读书笔记(一)

    首先向各位严重推荐主席这本书<Android开发艺术探索>. 再感谢主席邀请写这篇读书笔记 + 书评.书已经完整的翻完一遍了,但是还没有细致的品读并run代码,最近有时间正好系统的把整本书 ...

  8. Android 开发艺术探索 - 读书笔记目录

    仅作为读书笔记使用,建议阅读原书. 书中代码部分已和现版本不符,建议对比最新版本学习. 读了这本书,越发认识到和大佬们的差距.嗯,加油吧. 过去の自分が今仆の土台となる 第 1 章 - Activit ...

  9. Android开发艺术探索完结篇——天道酬勤

    这片文章发布,代表着我已经把本书和看完并且笔记也发布完成了,回忆了一下我看Android群英传,只用了两个月,但是看本书却花了2016年05月04日 - 2018年07月16日,整整两年多,真是惭愧 ...

最新文章

  1. CA证书服务器(4) 证书、CA、PKI
  2. 包含多个段的程序01 - 零基础入门学习汇编语言29
  3. 史上最强!机器学习领域最全综述列表来啦!
  4. SAP几则常用的BASIS技巧整理
  5. VTK:合并选择用法实战
  6. leetcode 219. 存在重复元素 II(规定步长)
  7. 计算机的网络与结构,计算机结构与组成29-网络.ppt
  8. freemarker 分页逻辑
  9. filegee为啥没变动也更新_小米miui12中谷歌商店无法更新油管和下载Twitter的问题...
  10. mysql 数据库dbhelp_策略模式实现支持多种类数据库的DBHelp
  11. bilibili怎么用用户名登录_b站(bilibili)账号只记得用户名忘了密码怎么办?实名认证能找回吗...
  12. @primary注解_springboot整合redis分别实现手动缓存和注解缓存
  13. pyplot gtk2 conflicts with gtk3
  14. android 学习之SurfaceView
  15. git add 所有修改文件_工作中Git的使用实践
  16. 【C语言】ASCII码表
  17. python read_csv函数_Python pandas.read_csv()函数
  18. 无法打开键,请验证您对该键拥有足够的访问权限
  19. 傻子,疯子,一根筋的人才能创业成功!
  20. NFC:跟现金和信用卡说不

热门文章

  1. 紫书搜索 例题7-5 UVA - 129 Krypton Factor
  2. Rol租车网项目总结
  3. 国产良心极简版地图软件,地图下载很丝滑,界面简洁无广告
  4. stm32基于TouchGFX的GUI开发(九):Touchgfx图片资源存储在外部Flash教程(SPI和QSPI方法一)
  5. 手机和Linux蓝牙通信,linux 用蓝牙和手机通信
  6. 离子交换树脂的使用方法及其原理
  7. 各种下拉菜单 ,多级下拉菜单,向上展开菜单 左侧展开菜单
  8. KMPlayer播放m2ts 格式文件
  9. 央视315曝光名单2014版看点解读
  10. Java基础 - 坦克大战(第四章,线程基础)