设计一个简易图片缓存组件

在UI界面加载一张图片时很简单,然而如果需要加载多张较大的图像,事情就会变得更加复杂。在许多情况下(如ListView、RecyclerView或ViewPager等的组件),屏幕上的图片的总数伴随屏幕的滚动会大大增加,且基本上是无限的。为了使内存使用保持在稳定范围内,防止出现OOM,这些组件会在子 View划出屏幕后,对其进行资源回收,并重新显示新出现的图片,垃圾回收机制会释放掉不再显示的图片的内存空间。但是这样频繁地处理图片的加载和回收不利于操作的流畅性,而内存或者磁盘的Cache就会帮助解决这个问题,实现快速加载已加载的图片。在缓存上,主要有两种级别的Cache:LruCache和DiskLruCache,即内存缓存与磁盘缓存。我们就来借助内存缓存和磁盘缓存来实现一个简易的图片缓存框架。

概要设计

代码实现

BitMapLruCache.java:

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Matrix;
import android.util.Log;
import android.util.LruCache;
import android.widget.ImageView;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class BitMapLruCache {
    private static MemoryCacheUtils memoryCacheUtils;
    private static ExecutorService executor;
    
    private static final String TAG = "BitMapLruCache";

    private BitMapLruCache(){}

    /**
     * 外部私有缓存文件夹
     */
    private static File cacheDir;

    /**
     * 初始化BitMapLruCache,必须在App启动完成后调用!
     * @param context context
     */
    public static void init(Context context){
        cacheDir = context.getExternalCacheDir();
        memoryCacheUtils = new MemoryCacheUtils();
        executor = new ThreadPoolExecutor(
                4, 8,
                10, TimeUnit.SECONDS, new LinkedBlockingDeque<>(128));
    }

    /**
     * 根据URL获取一张图片
     * @param url URL
     * @param imageView {@link ImageView}
     * @param listener {@link LoadBitMapListener} 加载BitMap的监听器
     */
    public static void getBitMap(String url, ImageView imageView, LoadBitMapListener listener) {
        // 先从内存找
        Bitmap fromMemory = memoryCacheUtils.getBitmapFromMemory(url);
        if(fromMemory != null){
            listener.success(fromMemory, imageView);
        }else {
            // 找不到就从磁盘找
            Bitmap bitmap = getBitMapFromDisk(url);
            if(bitmap != null){
                listener.success(bitmap, imageView);
                // 找到就保存到内存
                memoryCacheUtils.setBitmapToMemory(url, bitmap);
            }else{
                // 找不到从网络加载 & 保存到内存
                GetFromNetWorkTask task = new GetFromNetWorkTask(url, imageView, listener);
                executor.submit(task);
            }
        }
    }

    /**
     * 根据URL从磁盘加载BitMap
     * @param url 图片URL
     * @return bitmap {@link Bitmap}
     */
    private static Bitmap getBitMapFromDisk(String url) {
        File file = new File(cacheDir, calcMD5(url));
        if(file.exists()){
            try {
                return BitmapFactory.decodeStream(new FileInputStream(file));
            } catch (IOException e) {
                Log.e(TAG, "getBitMapFromDisk: ", e);
            }
        }
        return null;
    }

    /**
     * 保存BitMap到磁盘
     * @param url 图片URL
     * @param bitmap {@link Bitmap}
     */
    private static void saveBitMapToDisk(String url, Bitmap bitmap) {
        //url convert
        try {
            File file = new File(cacheDir, calcMD5(url));
            Log.i(TAG, "saveBitMapToDisk: file' path = " + file.getAbsolutePath());
            FileOutputStream out = new FileOutputStream(file);
            bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out);
        } catch (IOException e) {
            Log.e(TAG, "saveBitMapToDisk: ", e);
        }
    }

    /**
     * 加载BitMap过程中的监听器
     */
    public interface LoadBitMapListener{
        void success(Bitmap bitmap, ImageView imageView);
        void failed();
    }

    /**
     * 从网络加载的Task
     */
    static private class GetFromNetWorkTask implements Runnable {
        String urlStr;
        ImageView imageView;
        LoadBitMapListener listener;

        public GetFromNetWorkTask(String urlStr, ImageView imageView, LoadBitMapListener listener) {
            this.urlStr = urlStr;
            this.imageView = imageView;
            this.listener = listener;
        }

        @Override
        public void run() {
            try {
                URL url = new URL(urlStr);
                HttpURLConnection conn = (HttpURLConnection) url.openConnection();
                conn.setRequestMethod("GET");
                InputStream connInputStream = conn.getInputStream();
                Bitmap baseBitmap = BitmapFactory.decodeStream(connInputStream);
                // 比例压缩
                Bitmap bitmap = imageCompressL(baseBitmap);
                listener.success(bitmap, imageView);
                memoryCacheUtils.setBitmapToMemory(urlStr, bitmap);
                saveBitMapToDisk(urlStr, bitmap);
            } catch (IOException e) {
                listener.failed();
            }
        }
    }

    /**
     * 将URL转换为MD5
     * @param str URL
     * @return MD5
     */
    private static String calcMD5(String str) {
        if (str == null || str.length() == 0) {
            throw new IllegalArgumentException("String cannot be null or zero length");
        }
        StringBuilder hexString = new StringBuilder();
        try {
            MessageDigest md = MessageDigest.getInstance("MD5");
            md.update(str.getBytes());
            byte[] hash = md.digest();
            for (byte b : hash) {
                if ((0xff & b) < 0x10) {
                    hexString.append("0").append(Integer.toHexString((0xFF & b)));
                } else {
                    hexString.append(Integer.toHexString(0xFF & b));
                }
            }
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
        }
        return hexString.toString();
    }


    /**
     * 计算Bitmap大小,如果超过64kb,则进行压缩
     * @param bitmap 源BitMap {@link Bitmap}
     * @return 压缩后的Bitmap {@link Bitmap}
     */
    private static Bitmap imageCompressL(Bitmap bitmap) {
        double targetWidth = Math.sqrt(64.00 * 1000);
        if (bitmap.getWidth() > targetWidth || bitmap.getHeight() > targetWidth) {
            // 创建操作图片用的matrix对象
            Matrix matrix = new Matrix();
            // 计算宽高缩放率
            float x = (float) Math.max(targetWidth / bitmap.getWidth(), targetWidth / bitmap.getHeight());
            // 缩放图片动作
            matrix.postScale(x, x);
            bitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(),
                    bitmap.getHeight(), matrix, true);
        }
        return bitmap;
    }

    /**
     * LRUCache操作工具类
     */
    private static class MemoryCacheUtils {
        private static final String TAG = "MemoryCacheUtils";
        private final LruCache<String, Bitmap> mMemoryCache;

        public MemoryCacheUtils(){
            // 得到手机最大允许内存的1/8,即超过指定内存,则开始回收
            long maxMemory = Runtime.getRuntime().maxMemory()/8;
            Log.i(TAG, "MemoryCacheUtils: maxMemory = " + maxMemory);
            // 需要传入允许的内存最大值,虚拟机默认内存24M
            mMemoryCache = new LruCache<String,Bitmap>((int) maxMemory){
                @Override
                protected int sizeOf(String key, Bitmap value) {
                    return value.getByteCount();
                }
            };
        }

        /**
         * 从内存中读图片
         * @param url 图片URL
         * @return {@link Bitmap}
         */
        public Bitmap getBitmapFromMemory(String url) {
            return mMemoryCache.get(url);
        }

        /**
         * 往内存中写图片
         * @param url 图片URL
         * @param bitmap BitMap
         */
        public void setBitmapToMemory(String url, Bitmap bitmap) {
            mMemoryCache.put(url,bitmap);
        }
    }
}

LRUCache本身就是基于LinkHashMap实现的,关于更多LRUCache的内容可以看我的另一篇博客: 《LinkHashMap与LRU》

需要注意的是使用此组件需要外部存储的权限与网络访问权限(如果是Android6.0之后的设备还需要动态权限申请):

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

关于图片压缩

我们在在展示高分辨率图片的时候,最好先将图片进行压缩。压缩后的图片大小应该和用来展示它的控件大小相近,因为在一个很小的ImageView上显示一张超大的图片不会带来任何视觉上的好处,但却会占用我们相当多宝贵的内存,而且在性能上还可能会带来负面影响。下面我们就来看一看,如何对一张大图片进行适当的压缩,让它能够以最佳大小显示的同时,还能防止OOM的出现。

W/System.err: java.lang.RuntimeException: Canvas: trying to draw too large(552960000bytes) bitmap.

BitmapFactory这个类提供了多个解析方法(decodeByteArray, decodeFile, decodeResource等)用于创建Bitmap对象,我们应该根据图片的来源选择合适的方法。比如SD卡中的图片可以使用decodeFile方法,网络上的图片可以使用decodeStream方法,资源文件中的图片可以使用decodeResource方法。这些方法会尝试为已经构建的bitmap分配内存,这时就会很容易导致OOM出现。为此每一种解析方法都提供了一个可选的BitmapFactory.Options参数,将这个参数的inJustDecodeBounds属性设置为true就可以让解析方法禁止为bitmap分配内存,返回值也不再是一个Bitmap对象,而是null。虽然Bitmap是null了,但是BitmapFactory.Options的outWidth、outHeight和outMimeType属性都会被赋值。这个技巧让我们可以在加载图片之前就获取到图片的长宽值和MIME类型,从而根据情况对图片进行压缩。如下代码所示:

BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(getResources(), R.id.myimage, options);
int imageHeight = options.outHeight;
int imageWidth = options.outWidth;
String imageType = options.outMimeType;

为了避免OOM异常,最好在解析每张图片的时候都先检查一下图片的大小,除非你非常信任图片的来源,保证这些图片都不会超出你程序的可用内存。

现在图片的大小已经知道了,我们就可以决定是把整张图片加载到内存中还是加载一个压缩版的图片到内存中。以下几个因素是我们需要考虑的:

1、预估一下加载整张图片所需占用的内存 2、为了加载这一张图片你所愿意提供多少内存 3、用于展示这张图片的控件的实际大小 4、当前设备的屏幕尺寸和分辨率

比如,你的ImageView只有128×96像素的大小,只是为了显示一张缩略图,这时候把一张1024×768像素的图片完全加载到内存中显然是不值得的。通过设置BitmapFactory.Options中inSampleSize的值就可以实现。比如我们有一张2048×1536像素的图片,将inSampleSize的值设置为4,就可以把这张图片压缩成512×384像素。原本加载这张图片需要占用13M的内存,压缩后就只需要占用0.75M了(假设图片是ARGB_8888类型,即每个像素点占用4个字节)。下面的方法可以根据传入的宽和高,计算出合适的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 heightRatio = Math.round((float) height / (float) reqHeight);
		final int widthRatio = Math.round((float) width / (float) reqWidth);
		// 选择宽和高中最小的比率作为inSampleSize的值,这样可以保证最终图片的宽和高
		// 一定都会大于等于目标的宽和高。
		inSampleSize = heightRatio < widthRatio ? heightRatio : widthRatio;
	}
	return inSampleSize;
}

使用这个方法,首先你要将BitmapFactory.Options的inJustDecodeBounds属性设置为true,解析一次图片。然后将BitmapFactory.Options连同期望的宽度和高度一起传递到到calculateInSampleSize方法中,就可以得到合适的inSampleSize值了。之后再解析一次图片,使用新获取到的inSampleSize值,并把inJustDecodeBounds设置为false,就可以得到压缩后的图片了:

public static Bitmap decodeSampledBitmapFromResource(Resources res, int resId,
        int reqWidth, int reqHeight) {
  	// 第一次解析将inJustDecodeBounds设置为true,来获取图片大小
    final BitmapFactory.Options options = new BitmapFactory.Options();
    options.inJustDecodeBounds = true;
    BitmapFactory.decodeResource(res, resId, options);
    // 调用上面定义的方法计算inSampleSize值
    options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);
    // 使用获取到的inSampleSize值再次解析图片
    options.inJustDecodeBounds = false;
    return BitmapFactory.decodeResource(res, resId, options);
}

下面的代码非常简单地将任意一张图片压缩成100×100的缩略图,并在ImageView上展示:

mImageView.setImageBitmap(decodeSampledBitmapFromResource(getResources(), R.id.myimage, 100, 100));

解决RecyclerView加载图片错乱

因为有ViewHolder的重用机制,每一个Item在移出屏幕后都会被重新使用以节省资源,避免滑动卡顿。所以当使用异步任务加载图片的时候就可能会发生图片加载错乱的问题,既然弄清楚了原因,那么下面就给出解决方式:

1.先将图片预设为本地一个占位图片。(重要!很多错位情况在于复用了其他位置的图片缓存,而当前图片迟迟加载不出来,导致当前图片错位。所以解决之道是先用本地占位图片,快速刷新当前图片。等加载完毕之后,就可以替换掉占位图);

2.通过ImageView.setTag,把url记录在图片内部;

3.把url放进一个请求队列,(这是避免图片很大,请求耗时很长,重复发起url请求);

4.如果请求队列存在url,则将老的url对应图片控件,替换为本次请求图片控件,返回;

5.如果当前url和第一步ImageView.getTag一致,则用新的数据源更新图片,否则返回;

6.如果是重复使用的图片,最好就是访问之后,写入到本地存储上,避免耗时网络请求。

下面给出具体代码实现:(里面的BitMapLruCache正是用的上面的BitMapLruCache)

public class PictureAdapter extends RecyclerView.Adapter<PictureAdapter.MyViewHolder>{
    private Context context;
    private List<PictureItem> array;

    public PictureAdapter(Context context, List<PictureItem> array) {
        this.context = context;
        this.array = array;
    }

    @NonNull
    @Override
    public MyViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        View view = LayoutInflater.from(context).inflate(R.layout.picture_item, parent, false);
        return new MyViewHolder(view);
    }

    @Override
    public void onBindViewHolder(@NonNull MyViewHolder holder, int position) {
        PictureItem item = array.get(position);
        // 图片准备之前设置Tag
        holder.pictureImageView.setTag(item.getImgUrl());
        BitMapLruCache.getBitMap(item.getImgUrl(), holder.pictureImageView, new BitMapLruCache.LoadBitMapListener() {
            @Override
            public void success(Bitmap bitmap, ImageView imageView) {
                String tag = (String) imageView.getTag();
                // 加载图片的时候判断Tag
                if(item.getImgUrl().equals(tag)){
                    holder.pictureImageView.setImageBitmap(bitmap);
                }
            }

            @Override
            public void failed() {
                holder.pictureImageView.setImageResource(R.drawable.ic_launcher_background);
            }
        });
    }

    @Override
    public int getItemCount() {
        return array.size();
    }

    static class MyViewHolder extends RecyclerView.ViewHolder{
        ImageView pictureImageView;
        public MyViewHolder(@NonNull View itemView) {
            super(itemView);
            pictureImageView = itemView.findViewById(R.id.item_picture_iv);
            pictureImageView.setImageResource(R.drawable.ic_launcher_background);
        }
    }
}