Android Bitmap 的高效加载和三级缓存读书笔记

本文主要内容出自《Android 开发艺术探索》,作为记录的同时加入个人的理解和思考,同时搜索其它资料和自己动手翻源码来理解实现原理。

问题:

  1. 如何高效加载 Bitmap?
    解决这个问题的核心思路是根据图片需要显示的大小来缩放图片进行显示,缩放的方式为设置采样率。
  2. LruCache 的原理?
    下文有个人总结的答案,可以参考,建议大家自己总结答案。
  3. Android 三级缓存?
    内存缓存、本地缓存(磁盘缓存)、网络缓存。
  4. 本地缓存原理?
    与内存缓存原理类似。

三级缓存的原理就是当 App 需要引用缓存时,首先到内存缓存中读取,读取不到再到本地缓存中读取,还获取不到就到网络异步读取,读取成功之后再保存到内存和本地缓存中。

这里提一句,LruCache 只是内存缓存的一种比较优秀且常用的缓存算法,并不是说内存缓存就一定指的是 LruCache。很多优秀的图片加载库比如 Glide 有自己独特的内存、磁盘、网络缓存技术。

  • 内存缓存:优先加载,读取速度最快,本文只记录 LruCache 的使用。
    关于内存缓存还有一种方式是使用软引用于 LruCache 的强引用相结合,也就是手动维护一个 SoftReference 集合,当 LruCache 保存的缓存被销毁后存储到 SoftReference 中,使用时先到软引用集合获取。由于 SoftReference 会随时被销毁所以不会对内存造成太大影响;
  • 本地缓存:次优先加载,速度一般。可以使用 DiskLruCache 但不限于;
  • 网络缓存:加载优先级最低,消耗流量。使用时注意异步加载。

一、Bitmap 加载

加载 Bitmap 的方法:
  • decodeFile:从文件系统加载 Bitmap 对象
  • decodeResource:从资源文件中加载 Bitmap
  • decodeStream:从输入流加载 Bitmap
  • decodeByteArray:从字节数组中加载 Bitmap

decodeFile 和 decodeResource 间接调用了 decodeStream 方法。

Bitmap 高效加载思路:

采用 BitmapFactory.Options 按一定的采样率来加载所需尺寸的图片。

主要使用 BitmapFactory.OptionsinSampleSize 来设置采样率。

  • inSampleSize 为1,原始图片
  • inSampleSize 为2,宽高均为原来的 1/2,像素为原来的 1/4
  • inSampleSize 为4,宽高均为原来的 1/4,像素为原来的 1/16

Tips:采样率一般为 2 的指数,如 1、2、4、8、16 等等,如果不为 2 的指数会向下取整。

获取采样率的方法和步骤:
  1. 将 BitmapFactory.Options 的 inJustDecodeBounds 参数设置为 true;
  2. 从 BitmapFactory.Options 中取出图片的原始宽高信息,也就是 outWidth 和 outHeight 参数;
  3. 结合目标 View 所需大小来计算所需采样率 inSampleSize;
  4. 将 BitmapFactory.Options 的 inJustDecodeBounds 设置为 false,重新加载图片。

设置 inJustDecodeBounds 参数为 true 时只会解析图片的宽/高信息,并不会去加载图片,所以该操作是轻量级的。

实例代码:
  1. 设置 ImageView
ImageView iv_decode = (ImageView)findViewById(R.id.iv_decode);
iv_decode.setImageBitmap(BitmapUtils.decodeSampledBitmapFromResoruce(getResources(),R.drawable.mz,160,200));
  1. 采样率的计算和图片读取
public static Bitmap decodeSampledBitmapFromResoruce(Resources res,int resId,
                                                     int reqWidth,int reqHeight){
    // 获取 BitmapFactory.Options,这里面保存了很多有关 Bitmap 的设置
    final BitmapFactory.Options options = new BitmapFactory.Options();
    // 设置 true 轻量加载图片信息
    options.inJustDecodeBounds = true;
    // 由于上方设置false,这里轻量加载图片
    BitmapFactory.decodeResource(res,resId,options);
    // 计算采样率
    options.inSampleSize = calculateInSampleSize(options,reqWidth,reqHeight);
    // 设置 false 正常加载图片
    options.inJustDecodeBounds = false;
    return BitmapFactory.decodeResource(res,resId,options);
}

public static int calculateInSampleSize(BitmapFactory.Options options,
                                        int reqWidth,int reqHeight){
    final int width = options.outWidth;
    final int height = options.outHeight;
    int inSampleSize = 1;
    // 宽或高大于预期就将采样率 *=2 进行缩放
    if(width > reqWidth || height > reqHeight){
        final int halfHeight = height/2;
        final int halfWidth = width/2;
        while((halfHeight / inSampleSize) >= reqHeight &&
                (halfWidth / inSampleSize) >= reqWidth){
            inSampleSize *= 2;
        }
    }
    return inSampleSize;
}

二、Android 缓存策略

目前最常用的一种缓存算法是 LRU(Least Recently Userd),是近期最少使用算法,当缓存满时,优先淘汰近期最少使用的缓存对象。

  • LruCache:内存缓存
  • DiskLruCache:磁盘缓存
LruCache

为了兼容更多版本,建议采用 support-v4 包中的 LruCache。

LruCache 是一个泛型类,内部采用 LinkedHashMap 以强引用的方式储存缓存对象,并提供 get/put 来完成获取和添加操作。当缓存满时,会移除较早使用的缓存对象,然后添加新的缓存对象。

  • 强引用:直接的对象引用;
  • 软引用:当一个对象只有软引用时,系统内存不足时此对象会被 gc 回收;
  • 弱引用:当一个对象只有弱引用存在时,此对象随时会被 gc 回收。

下面是 LruCache 的定义:

public class LruCache<K, V> {
    private final LinkedHashMap<K, V> map;
}

LruCache 使用 LinkedHashMap 来维护数据,LruCache 是线程安全的,因为使用了 synchronized 关键字。

*HashMap 和 LinkedHashMap 相关:

java为数据结构中的映射定义了一个接口java.util.Map;
它有四个实现类,分别是HashMap Hashtable LinkedHashMap 和TreeMap.

  • Map:主要用于存储键值对,允许值重复,但不允许键重复(重复就覆盖了);
  • HashMap:HashMap 是根据键的 HashCode 值储存数据,根据键可以直接获取它的值,所以具有很快的访问速度。但是遍历时所获取的键值是随机的。
    HashMap 键不可重复但值可以重复,只允许一个键为 null,同时允许多个值为 null。
    HashMap 不是线程同步的,如果需要同步可以使用 Collections 的 synchronizedMap 方法或使用 ConcurrentHashMap。
  • LinkedHashMap:是 HashMap 的子类,拥有 HashMap 的大部分特性,不同的是它保存了记录插入顺序。在使用 Iterator 遍历时得到的数据是按顺序获取的。
  • Hashtable:与 HashMap 类似,主要特点是不允许键或值为 null,同时支持线程同步(主要 public 方法和有些代码块使用 synchronized 关键字修饰)。所以在写入时会比较慢。
  • TreeMap:实现 SortMap 接口,能够把它保存的记录根据键排序,默认是按键值的升序排序。

总结:

HashMap 和 Hashtable :
  1. HashMap 不允许键重复但允许值重复,所以可以存在键或值为 null 的情况。但是 Hashtable 不允许键或值为 null。
  2. HashMap 不支持同步,Hashtable 线程安全。
  3. HashMap 读取速度较快,Hashtable 由于需要同步所以较慢。
HashMap 和 LinkedHashMap :

主要区别是 LinkedHashMap 是有序的,HashMap 遍历时无序。

LruCache 的使用

LruCache 通常被用来缓存图片,但是也可以缓存其它内容到内存中。市面上主流的第三方图片加载库都有相应的缓存策略,但是 LruCache 作为一种广泛使用的缓存算法,研究学习它有着重要意义。
下面是一个使用 LruCache 缓存 Bitmap 的例子:

public class BitmapLruCache extends LruCache<String,Bitmap> {

    /**
     * @param maxSize for caches that do not override {@link #sizeOf}, this is
     *                the maximum number of entries in the cache. For all other caches,
     *                this is the maximum sum of the sizes of the entries in this cache.
     */
    // maxSize 一般为可用最大内存的 1/8.
    // int maxSize = (int) (Runtime.getRuntime().totalMemory()/1024/8);
    public BitmapLruCache(int maxSize) {
        super(maxSize);
    }

    @Override
    protected int sizeOf(String key, Bitmap value) {
        // 重写获取某个节点的内存大小,不写默认返回1
        return value.getByteCount() / 1024;
    }

    // 某节点被移除后调用该函数
    @Override
    protected void entryRemoved(boolean evicted, String key, Bitmap oldValue, Bitmap newValue) {
        super.entryRemoved(evicted, key, oldValue, newValue);
    }
}

缓存中储存一张图片:

mLruCache.put(key, bitmap);

获取一张图片:

mLruCache.get(key);

删除一张图片:

mLruCache.remove(key);

有关 LruCache 的原理参考以下两篇文章:

Android LruCache源码分析
彻底解析Android缓存机制——LruCache

简单总结 LruCache 原理:
  1. 创建 LruCache 子类时设定储存对象的键和值类型(<K,V>)、最大可用内存,重写 sizeOf() 方法用来获取储存的对象大小。
    因为泛型为 <K,V>,源码无法得知储存的是什么类型所以就无法获取某个键储存的值的大小,重写后就可以正常使用了。
  2. 子类创建时调用 LruCache 的构造函数,这个函数里储存了最大内存值以及创建 LinkedHashMap 来储存缓存对象。
    LinkedHashMap 的特性是最近使用的键值对会放置到队尾,使用 iterator 遍历时首先获取的是队首也就是最早放置并没有使用的键值对。
  3. 放置(put)缓存对象时,调用到第一步重写的 sizeOf() 方法来计算当前储存的对象大小并记录。
    放置缓存成功之后死循环进行计算,如果当前储存的对象没有超过可用最大内存,跳出。反之则使用 map.entrySet().iterator().next() 来获取队首的键值对并移除,然后减去移除的对象大小并记录。
  4. 获取(get)缓存对象,成功后直接返回,找不到则进行储存并记录当前缓存对象的总大小。
  5. 无论是放置还是获取缓存对象都会调用内部维护的 LinkedHashMap 进行处理,所以使用过的对象会被移动到位,最后删除。
DiskLruCache

通过将缓存对象写入文件系统从而实现缓存效果。

  1. DiskLruCache 创建
public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize)

四个参数的含义:

  • directory:缓存在文件系统中的存储路径,可以放在 App 缓存中(App 删除时移除)。也可以自定义其它文件夹下。
  • appVersion:应用版本号,一般为 1,有时无作用意义不是太大。
  • valueCount:单个节点所对应的数据的格式,一般为 1.
  • maxSize:缓存的总大小,当超过时会清除一些缓存。
  1. DiskLruCache 缓存存储
    DiskLruCache 的缓存添加操作是通过 Editor 完成的。
    例如缓存图片,首先获取 url 所对应的 key(使用加密算法处理避免 url 字符违法),然后通过 edit() 来获取 Editor 再获取输出流将文件输出到磁盘上,最后一定记得 commit()

下方是《Android 开发艺术探索》中将 url 通过 MD5 算法转换成相应 key 的代码:

private String hashKeyFormUrl(String url) {
    String cacheKey;
    try {
        final MessageDigest mDigest = MessageDigest.getInstance("MD5");
        mDigest.update(url.getBytes());
        cacheKey = bytesToHexString(mDigest.digest());
    } catch (NoSuchAlgorithmException e) {
        cacheKey = String.valueOf(url.hashCode());
    }
    return cacheKey;
}

private String bytesToHexString(byte[] bytes) {
    StringBuilder sb = new StringBuilder();
    for (int i = 0; i < bytes.length; i++) {
        String hex = Integer.toHexString(0xFF & bytes[i]);
        if (hex.length() == 1) {
            sb.append('0');
        }
        sb.append(hex);
    }
    return sb.toString();
}

获取 key 之后就可以进行保存了,其实就是通过 key 获取 editor 对象再获取输出流:

String key = hashKeyFormUrl(url);
DiskLruCache.Editor editor = mDiskLruCache.edit(key);
if (editor != null) {
    OutputStream outputStream = editor.newOutputStream(DISK_CACHE_INDEX);
    // 有了输出流就可以写入文件到本地磁盘了
    if (downloadUrlToStream(url, outputStream)) {
        // 写入磁盘成功后 editor 提交
        editor.commit();
    } else {
        // 写入失败则取消操作
        editor.abort();
    }
    // mDiskLruCache 的检测流是否关闭和计算缓存是否超出的方法
    mDiskLruCache.flush();
}
public boolean downloadUrlToStream(String urlString,
        OutputStream outputStream) {
    HttpURLConnection urlConnection = null;
    BufferedOutputStream out = null;
    BufferedInputStream in = null;

    try {
        // 获取图片 url
        final URL url = new URL(urlString);
        // 创建 HttpURLConnection 建立网络连接
        urlConnection = (HttpURLConnection) url.openConnection();
        // 创建输入流,从网络获取图片文件流
        in = new BufferedInputStream(urlConnection.getInputStream(),
                IO_BUFFER_SIZE);
        // 创建输入流:DiskLruCache 的 editor 与 url 结合产生的输出流
        out = new BufferedOutputStream(outputStream, IO_BUFFER_SIZE);
        // 从网络读取流,写入到磁盘
        int b;
        while ((b = in.read()) != -1) {
            out.write(b);
        }
        return true;
    } catch (IOException e) {
        Log.e(TAG, "downloadBitmap failed." + e);
    } finally {
        // 关闭网络和流
        if (urlConnection != null) {
            urlConnection.disconnect();
        }
        MyUtils.close(out);
        MyUtils.close(in);
    }
    return false;
}
  1. DiskLruCache 缓存读取
    缓存读取是根据 key 获取 DiskLruCache.Snapshot 对象,然后根据该对象就可以获取输入流对象。有了流就可以获取文件比如 Bitmap,为了避免 OOM 不建议直接加载原图。所以要用之前的根据图片大小设置采样率的方式来设置图片的大小,根据文件读取设置采样率:
public Bitmap decodeSampledBitmapFromFileDescriptor(FileDescriptor fd, int reqWidth, int reqHeight) {
    // First decode with inJustDecodeBounds=true to check dimensions
    final BitmapFactory.Options options = new BitmapFactory.Options();
    options.inJustDecodeBounds = true;
    BitmapFactory.decodeFileDescriptor(fd, null, options);

    // Calculate inSampleSize
    options.inSampleSize = calculateInSampleSize(options, reqWidth,
            reqHeight);

    // Decode bitmap with inSampleSize set
    options.inJustDecodeBounds = false;
    return BitmapFactory.decodeFileDescriptor(fd, null, options);
}

计算采样率的方式与上文一样就不贴了,与上文通过资源 Resource 加载 Bitmap 不同的是文件流是用 BitmapFactory.decodeFileDescriptor 函数加载图片。

有关 DiskLruCache 的使用到此,更多关于 DiskLruCache 的原理参考下面文章:

Android DiskLruCache 源码解析 硬盘缓存的绝佳方案

三、总结和其他

  1. 【强制】加载大图片或者一次性加载多张图片,应该在异步线程中进行。图片的加载,涉及到 IO 操作,以及 CPU 密集操作,很可能引起卡顿。
    例:
class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> {
    ...
    // 在后台进行图片解码
    @Override
    protected Bitmap doInBackground(Integer... params) {
        final Bitmap bitmap = BitmapFactory.decodeFile("some path");
        return bitmap;
    }
    ...
}
  1. 【强制】在 ListView,ViewPager,RecyclerView,GirdView 等组件中使用图片时,应做好图片的缓存,避免始终持有图片导致内存泄露,也避免重复创建图片,引起性能问题。建议使用 Fresco ( https://github.com/facebook/fresco )、 Glide(https://github.com/bumptech/glide)等图片库。
  2. 【强制】png 图片使用 tinypng 或者类似工具压缩处理,减少包体积。
  3. 【推荐】应根据实际展示需要,压缩图片,而不是直接显示原图。手机屏幕比较小,直接显示原图,并不会增加视觉上的收益,但是却会耗费大量宝贵的内存。
  4. 【强制】使用完毕的图片,应该及时回收,释放宝贵的内存。
  5. 【推荐】针对不同的屏幕密度,提供对应的图片资源,使内存占用和显示效果达到合理的平衡。如果为了节省包体积,可以在不影响 UI 效果的前提下,省略低密度图片。

更多内容请阅读下方参考资料。

参考资料:
LinkedHashMap和HashMap的比较使用
《阿里巴巴Android开发手册》

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342

推荐阅读更多精彩内容