Android中的三级缓存解析与实战

凡永恒伟大的爱,都要绝望一次,消失一次,一度死,才会重获爱,重新知道生命的价值。——《文学回忆录》

1、概述

由于Bitmap的特殊性以及Android对单个进程应用只分配16M的内存,这导致加载Bitmap的时候很容易出现内存泄漏。为了解决这个问题,引入了缓存策略。

缓存策略是一个通用的思想,可以用在很多场景中,在实际开发中,经常用Bitmap做缓存。通过缓存策略,我们不需要每次从网络上请求图片或者从存储设备中加载图片,这样就极大的提高了图片的加载效率以及产品的用户体验。

目前常用的缓存策略,LruCache常被用作内存缓存,DiskLruCache常被用作存储缓存。Lru(Least Recently Used),即最近最少使用算法。核心思想:当缓存快满时,会淘汰近期最少使用的缓存目标。

2、三级缓存

三级缓存策略,通过网络、本地、内存三级缓存图片,来减少不必要的网络交互,避免浪费流量。

  • 网络加载,不优先加载,速度慢,浪费流量
  • 本地缓存,次优先加载,速度快
  • 内存缓存,优先加载,速度最快

三级缓存原理

  • 首次加载 Android App 时,肯定要通过网络交互来获取图片,之后我们可以将图片保存至本地SD卡和内存中
  • 之后运行 App 时,优先访问内存中的图片缓存,若内存中没有,则加载本地SD卡中的图片
  • 总之,只在初次访问新内容时,才通过网络获取图片资源

3、Bitmap的高效加载

缓存实现最终目的是为了图片的高效显示,所以先介绍Bitmap的高效加载。

3.1、Bitmap加载

BitmapFactory类提供了四类方法:decodeFile、decodeResource、decodeStream和decodeByteArray,分别用于从文件系统、资源、输入流以及字节数组中加载一个Bitmap对象,其中decodeFile和decodeResource间接调用了decodeStream方法。

3.2、高效加载Bitmap

采用BitmapFactory.Options来加载所需尺寸的图片。这里假设通过ImageView来显示图片,很多时候,ImageView的尺寸并没有图片的原始尺寸那么大,这个时候把整个图片加载进来再设给ImageView,这显然是没有必要的,因为ImageView并没有办法显示原始图片。

通过BitmapFactory.Options就可以按一定的采样率来加载缩小后的图片,将缩小后的图片在ImageView中显示,就可以降低内存的使用,在一定程度上避免OOM,提高Bitmap加载时的性能。

BitmapFactory.Options中的inJustDecodeBounds参数:
当此参数为true时,BitmapFactory只会解析图片的原始宽/高信息,并不会真正加载图片,这个操作是轻量级的。这里需要注意的是,Bitmap获取的图片宽/高信息和图片的位置以及程序运行的设备有关,比如同一张图片放在不同的drawable目录下或运行在不同屏幕密度的设备上,这都可能导致BitmapFactory获取不同的结果。

BitmapFactory.Options中的inSampleSize参数:
通过BitmapFactory.Option来缩放图片,主要用到它的inSampleSize参数即采样率。当inSampleSize为1时,采样后的图片大小为图片的原始大小;当inSampleSize为2时,采样后的图片大小宽/长为图片的原始大小的1/2,而像素数为原来的1/4,内存也为原来的1/4。

获取采样率流程:
(1)将BitmapFactory.Options的inJustDecodeBounds参数设为true并加载图片;
(2)从BitmapFactory.Options中取出图片的原始宽高信息,它们对应于outWidth和outHeight参数;
(3)根据采样率的规则并结合目标View的所需大小计算出采样率inSampleSize;
(4)将BitmapFactory.Options的inJustDecodeBounds参数设置为false,然后重新加载图片。

代码实现:

package com.chunsoft.bitmapcache;

import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.util.Log;

import java.io.FileDescriptor;

/**
 * Developer:chunsoft on 2017/3/27 15:22
 * Email:chun_soft@qq.com
 * Content:图片压缩,降低OOM概率
 */

public class ImageResizer {
    private static final String TAG = "ImageResizer";

    public ImageResizer() {

    }
    public Bitmap decodeSampledBitmapFromResource(Resources res,
                                                  int resId, int reqWidth, int reqheight) {
        // 1.将BitmapFactory.Options的inJustDecodeBounds参数设为true并加载图片
        BitmapFactory.Options options = new BitmapFactory.Options();
        options.inJustDecodeBounds = true;
        BitmapFactory.decodeResource(res, resId,options);
        // 2.从BitmapFactory.Options中取出图片的原始宽高信息,它们对应于outWidth和outHeight参数

        // 3.根据采样率的规则并结合目标View的所需大小计算出采样率inSampleSize
        options.inSampleSize = calculateInSampleSize(options, reqWidth, reqheight);
        // 4.将BitmapFactory.Options的inJustDecodeBounds参数设置为false,然后重新加载图片
        options.inJustDecodeBounds = false;
        return BitmapFactory.decodeResource(res,resId, options);
    }

    public Bitmap decodeSampledBitmapFromFileDescriptor(FileDescriptor fd,
                                                        int reqWidth,int reqHeight) {
        // 1.将BitmapFactory.Options的inJustDecodeBounds参数设为true并加载图片
        BitmapFactory.Options options = new BitmapFactory.Options();
        options.inJustDecodeBounds = true;
        BitmapFactory.decodeFileDescriptor(fd, null, options);
        // 2.从BitmapFactory.Options中取出图片的原始宽高信息,它们对应于outWidth和outHeight参数

        // 3.根据采样率的规则并结合目标View的所需大小计算出采样率inSampleSize
        options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);
        // 4.将BitmapFactory.Options的inJustDecodeBounds参数设置为false,然后重新加载图片
        options.inJustDecodeBounds = false;
        return BitmapFactory.decodeFileDescriptor(fd, null, options);

    }

    private int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqheight) {
        if (reqheight == 0 || reqWidth == 0) {
            return 1;
        }
        // 从BitmapFactory.Options中取出图片的原始宽高信息,它们对应于outWidth和outHeight参数
        final int height = options.outHeight;
        final int width = options.outWidth;

        Log.e(TAG,"origin, w=" + width +" h=" + height);

        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;
            }
        }
        Log.e(TAG, "sampleSize:" + inSampleSize);
        return inSampleSize;
    }
}

4、LruCache

LruCache是一个泛型类,它内部采用了LinkedHashMap以强引用的方式存储外界的缓存对象,其提供了get和put方法来完成缓存的获取和添加操作,当缓存满时,LruCache会移除较早使用的缓存对象,然后再添加新的缓存对象。LruCache是线程安全的。

代码实现:

//求最大内存大小,换成KB单位
int maxMemory = (int)(Runtime.getRuntime().maxMemory() / 1024);
//定义缓存为内存的1/8
int cacheSize = maxMemory / 8;
//内存缓存
mMemoryCache = new LruCache<String, Bitmap>(cacheSize){
    //Bitmap大小的计算
    @Override
    protected int sizeOf(String key, Bitmap bitmap) {
                return bitmap.getRowBytes()*bitmap.getHeight() / 1024;
            }
        };

     //添加图片到内存缓存
     private void addBitmapToMemoryCache(String key,Bitmap bitmap) {
        if (getBitmapFromMemCache(key) == null) {
            mMemoryCache.put(key, bitmap);
        }
    }

    //从内存缓存中获取图片
    private Bitmap getBitmapFromMemCache(String key) {
        return mMemoryCache.get(key);
    }

5、DiskLruCache

DiskLruCache用于实现存储设备缓存,即磁盘缓存,它通过将缓存对象写入文件系统,从而实现缓存的效果。它不属于Android SDK的一部分,源码链接

5.1、DiskLruCache的创建

DiskLruCache不能通过构造方法来创建,它提供了open方法创建自身

/*** 参数1:磁盘缓存文件在文件系统中的存储路径
   * 参数2:应用版本号,一般设置为1
   * 参数3:单个节点对应的数据个数,一般设置为1
   * 参数4:缓存的总大小
   */
public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize)

5.2、DiskLruCache的缓存添加

DiskLruCache的缓存添加的操作是通过Editor完成的,Editor表示一个缓存对象的编辑对象。首先需要获取图片url所对应的key,然后根据ke y就可以通过edit()来获取Editor对象,如果这个缓存正在被编辑,edit()会返回null,即DiskLruCache不允许同时编辑一个缓存对象,之所以要把url转换成key,是因为图片中的url中可能存在特殊字符,这将影响url在Android中直接使用,一般采用url的md5值作为key。

//将URL转化为md5值作为key
    //图片中的url很有可能有特殊字符,这将影响url在Android直接使用,一般采用url的md5值作为key
    private String hashKeyFromUrl(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;
    }


    //转化为16进制
    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();
    }

将图片的url转换成key之后,就可以获取Editor对象了。对于这个key来说,如果当前不存在其他Editor对象,那么edit()就会返回一个新的Editor对象,通过它就可以得到一个文件输出流。

String key = hashKeyFromUrl(url);

        DiskLruCache.Editor editor = mDiskLruCache.edit(key);
        if (editor != null) {
            OutputStream outputStream = editor.newOutputStream(DISK_CACHE_INDEX);
            if (downloadUrlToStream(url, outputStream)) {
                editor.commit();
            } else {
                editor.abort();
            }
            mDiskLruCache.flush();
        }

有了文件输出流,当从网络下载图片时,图片就可以通过这个文件输出流写入到文件系统上。

public boolean downloadUrlToStream(String urlString,
                                       OutputStream outputStream) {
        HttpURLConnection urlConnection = null;
        BufferedOutputStream out = null;
        BufferedInputStream in = null;

        try {
            final URL url = new URL(urlString);
            urlConnection = (HttpURLConnection) url.openConnection();
            in = new BufferedInputStream(urlConnection.getInputStream(),
                    IO_BUFFER_SIZE);
            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;
    }

经过上面的步骤,其实并没有真正地将图片写入文件系统,还必须通过Editor()的commit()来提交写入操作。如果下载过程发生异常,那么可以通过Editor的abort()来回退下载操作。

 OutputStream outputStream = editor.newOutputStream(DISK_CACHE_INDEX);
            if (downloadUrlToStream(url, outputStream)) {
                editor.commit();
            } else {
                editor.abort();
            }
            mDiskLruCache.flush();

5.3、DiskLruCache的缓存查找

和缓存的添加过程类似,缓存查找过程也需要将url转化为key,然后通过DiskLruCache的get方法得到一个Snapshot对象,接着再通过Snapshot对象即可获得缓存的文件输入流,有了文件输入流就可以得到Bitmap对象。通过文件流来得到文件描述符,再通过BitmapFactory.decodeFileDescriptor方法来加载一张缩放后的图片:

 Bitmap bitmap = null;
        String key = hashKeyFromUrl(url);
        //磁盘缓存的读取需要通过Snapshot来完成
        DiskLruCache.Snapshot snapShot = mDiskLruCache.get(key);
        if (snapShot != null) {
            //通过Snapshot可以得到磁盘缓存对象的FileInputStream
            FileInputStream fileInputStream = (FileInputStream) snapShot.getInputStream(DISK_CACHE_INDEX);
            //FileInputStream无法便捷地进行压缩,所以通过FileDescriptor来加载压缩后的图片,
            //最后将加载后的Bitmap添加到内存缓存中
            FileDescriptor fileDescriptor = fileInputStream.getFD();
            bitmap = mImageResizer.decodeSampledBitmapFromFileDescriptor(fileDescriptor,
                    reqWidth, reqHeight);
            if (bitmap != null) {
                addBitmapToMemoryCache(key, bitmap);
            }

那么这样就实现了Android的三级缓存,具体实现代码在文末会给出。

6、图片三级缓存加载实现

(1)图片的同步加载
图片的同步加载是指能够以同步的方式向调用者提供加载的图片,这个图片可能是从缓存中读取的,也可能是从磁盘缓存中读取的,还可能是从网络拉取的。

(2)图片的异步加载
很多时候调用者不想在单独的线程中以同步的方式来获取图片,这时候需要在线程中加载图片,并将图片设置给需要的ImageView。

(3)图片压缩
降低OOM概率的有效手段

(4)内存缓存

(5)磁盘缓存

(6)网络拉取

具体代码在文末。

7、优化列表的卡顿现象

主要解决方案就一条,不要在主线程中做太耗时的操作。主要三个方面。

(1)不要在getView中执行耗时操作。如果直接在getView中加载图片,肯定会导致卡顿,因为加载图片是一个耗时操作,因此采用异步操作。

(2)控制异步任务的执行频率。以照片墙来说,在getView方法中会通过异步方法来异步加载图片,但是如果用户刻意地频繁上下滑动,这在一瞬间产生上百个异步任务,这些异步任务会造成线程池的拥堵并随即带来大量的UI更新操作,这是没有意义的。

可以考虑在列表滑动的时候停止加载图片,尽管这个过程是异步的,等列表停下来以后再加载图片仍然可以获得良好的用户体验。具体实现时,可以考虑给ListView或GridView设置setOnScrollListener,并在OnScrollListener的onScrollStatechanged方法中判断列表是否处于滑动状态,如果是的话就停止加载图片:

if (scrollState == AbsListView.OnScrollListener.SCROLL_STATE_IDLE) {
            mIsGridViewIdle = true;
            mImageAdapter.notifyDataSetChanged();
        } else {
            mIsGridViewIdle = false;
        }

然后在getView方法中,仅当列表停止时才能加载图片。

if (mIsGridViewIdle && mCanGetBitmapFromNetWork) {
                imageView.setTag(uri);
                mImageLoader.bindBitmap(uri, imageView, mImageWidth, mImageWidth);
            }

(3)硬件加速。一般来说,经过上面两个步骤,列表都不会有卡顿现象,在绝大数情况下,硬件加速可以解决莫名的卡顿现象,还可以设置android:hardwareAcclerated=”true”即可为Activity开启硬件加速。

8、总结

本文介绍了图片加载、三级缓存策略、列表的滑动流畅性,通过文末的demo,可以更好的理解本文内容。

=====================================================

源码链接。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值