美文网首页
Android缓存实践2019-11-27

Android缓存实践2019-11-27

作者: 勇往直前888 | 来源:发表于2019-12-03 17:42 被阅读0次

LRU缓存原理

LRU(Least Recently Used),近期最少使用的算法,它的核心思想是当缓存满时,会优先淘汰那些近期最少使用的缓存对象。采用LRU算法的缓存有两种:LruCache和DiskLruCache,分别用于实现内存缓存和硬盘缓存。其中内存缓存可以直接使用,磁盘缓存要使用第三方库。

争议点: 一般认为,最近使用的元素会移到队尾,删除的是头部元素。这样理解也没什么大问题。不过,根据LinkedHashMap的习惯,应该是插入的时候是加在队头,删除的是队尾元素,而遍历是从队尾向队头顺序进行。

  • 下面这张图,将原理说得很清楚:从队头插入,从队尾删除。遍历的话,从队尾向队头遍历。
image.png
  • trimToSize的作用是超过容量的时候做删除动作。从下面这张图可以明显的看到,遍历和删除的都是队尾的元素。
image.png
  • LinkedHashMap的get()方法会将元素移到队头,这是通过recordAccess()方法实现的:先删除此元素,然后将此元素加到队头。
image.png

简要步骤

  • 创建一个空白工程,就是那个Hello World

  • 加载DiskLruCache的第三方库,一般用GitHub上的,在gradle文件中加下面这句,然后Sync一下:

    // https://github.com/JakeWharton/DiskLruCache
    compile 'com.jakewharton:disklrucache:2.0.2'
  • 切换到Project标签视图,可以看到第三方的DiskLruCache已经下载了。
image.png

简单封装

一般这种考虑容量的缓存,在图片上用得比较多,因此封装成一个BitmapCache,包含内存缓存和磁盘缓存两部分。

内存缓存初始化

  • LruCache是泛型,确定键值对的类型为<String, Bitmap>之后,需要重写sizeOf函数,需要给出Bitmap的大小。随着版本发展,Bitmap大小有不同的表示方法,所以做成一个静态方法,方便调用。
    /**
     * 得到bitmap的大小
     */
    public static int getBitmapSize(Bitmap bitmap) {
        // API 19
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
            return bitmap.getAllocationByteCount();
        }
        // API 12
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB_MR1) {
            return bitmap.getByteCount();
        }
        // 在低版本中用一行的字节x高度
        return bitmap.getRowBytes() * bitmap.getHeight();
    }

Android 计算Bitmap大小 getRowBytes和getByteCount()

  • 初始化内存缓存:
  1. 设置LruCache缓存的大小,一般为当前进程可用容量的1/8。
  2. 重写sizeOf方法,计算出要缓存的每张图片的大小。
   /**
     * 初始化内存缓存
     */
    private void initMemoryCache() {
        // 线程总内存,以KB为单位。
        final int maxMemory = (int)Runtime.getRuntime().maxMemory() / 1024;
        // 缓存大小为总内存的1/8,以KB为单位。
        final int cacheSize = maxMemory / 8;
        memoryCache = new LruCache<String, Bitmap>(cacheSize) {
            @Override
            protected int sizeOf(String key, Bitmap value) {
                // Bitmap, 以KB为单位。
                return BitmapUtil.getBitmapSize(value) / 1024;
            }
        };
    }

磁盘缓存初始化

  • DiskLruCache的open函数需要File类型的directory参数,所以,先写一个工具方法,获取cache路径
    /**
     *
     * @param context     上下文
     * @param uniqueName  名字
     * @return
     */
    public static File getCacheDirectory(Context context, String uniqueName) {
        // 获取cache目录,优先使用SD卡
        String cachePath;
        boolean isExternal = (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())
                || !Environment.isExternalStorageRemovable());
        if (isExternal) {
            // SD卡存在或者不可被移除时,使用外部存储
            // cachePath = /sdcard/Android/data/<application package>/cache
            cachePath = context.getExternalCacheDir().getPath();
        } else {
            // SD卡不可用,使用内部存储
            // cachePath = /data/data/<application package>/cache
            cachePath = context.getCacheDir().getPath();
        }
        
        // 创建目录
        File directory = new File(cachePath + File.separator + uniqueName);
        
        //如果不存在,创建目录
        if (!directory.exists()) {
            directory.mkdir();
        }
        
        return  directory;
    }
  • 每当版本号改变,缓存路径下存储的所有数据都会被清除掉,因为DiskLruCache认为当应用程序有版本更新的时候,所有的数据都应该从网上重新获取。所以DiskLruCache的open方法需要一个appVersion的参数。所以写一个获取版本号的工具方法。
    /**
     *
     * @param context 上下文
     * @return 版本号version Code
     */
    public static int getAppVersion(Context context) {
        // 默认填1
        int appVersion = 1;
        try {
            PackageInfo packageInfo = context.getPackageManager().getPackageInfo(context.getPackageName(),0);
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
                appVersion = (int)packageInfo.getLongVersionCode();
            } else {
                appVersion = packageInfo.versionCode;
            }
            Log.d(TAG, "appVersionCode:" + appVersion);
        } catch (PackageManager.NameNotFoundException e){
            e.printStackTrace();
        }

        return appVersion;
    }
  • DiskLruCache的初始化并不是构造函数,而是open方法。
    // 用类名做本类的TAG
    private static String TAG = BitmapCache.class.getSimpleName();

    // 硬盘缓存目录名字
    private static final String DISK_CACHE_NAME = "DiskBitmapCache";

    // 硬盘缓存大小,10M
    private static final long DISK_CACHE_SIZE = 10 * 1024 * 1024;

    /**
     *
     * @param context 上下文
     */
    private void initDiskCache(Context context) {
        File directory = FileUtil.getCacheDirectory(context, DISK_CACHE_NAME);
        int appVersion = AppUtil.getAppVersion(context);
        try {
            diskCache = DiskLruCache.open(directory, appVersion, 1, DISK_CACHE_SIZE);
            Log.d(TAG, "initDiskCache finished");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

Key 的选择

硬盘缓存和磁盘缓存都需要String类型的Key。如果是本地资源图片,没有必要用到缓存。所以一般是加载网络图片,才要用到缓存。直观理解,就会想到用图片的url字符串来代表不同的图片。由于url可能含有特殊字符,作为Key不合适,所以做一下转换,将url字符串md5一下,然后作为key。

    /**
     * 将url字符串转换为MD5字符串
     * @param urlString
     * @return
     */
    public static String urlStringToMd5String(String urlString) {
        String md5String;
        try {
            final MessageDigest digest = MessageDigest.getInstance("MD5");
            digest.update(urlString.getBytes());
            md5String = EncodeUtil.bytesToHexString(digest.digest());
        } catch (NoSuchAlgorithmException e){
            md5String = String.valueOf(urlString.hashCode());
        }
        return md5String;
    }

    /**
     * byte转16进制字符串
     * @param bytes 字节数组
     * @return  16进制字符串
     */
    public static String bytesToHexString(byte[] bytes) {
        StringBuffer sb = new StringBuffer();
        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();
    }

下载图片

由于硬盘缓存DiskLruCache是通过本地输出流OutputStream的方式写入的,所以需要有一个方法,将网络图片下载到本地的输出流进行对接。

    /**
     * 将图片下载到本地输出流
     * @param urlString      图片url
     * @param outputStream   本地输出流
     * @return               下载是否成功
     */
    public static 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();
            // Buffer大小使用默认的8K,8 * 1024 = 8192字节
            out = new BufferedOutputStream(outputStream);
            in = new BufferedInputStream(urlConnection.getInputStream());
            int b;
            while ((b = in.read()) != -1) {
                out.write(b);
            }
            return true;
        } catch (final IOException e){
            e.printStackTrace();;
        } finally {
            try {
                if (urlConnection != null) {
                    urlConnection.disconnect();
                }
                if (out != null) {
                    out.close();
                }
                if (in != null) {
                    in.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

        // 默认不成功
        return false;
    }

下载并写入磁盘缓存

以流的方式从url下载图片,然后写入本地缓存。这个过程不需要转化为Bitmap,直接操作流。这是DiskLruCache写入的方式,以流的方式进行对接,确实有点别扭。这个过程由于涉及到网络下载,所以耗时较长。

    /**
     * 下载图片并写入硬盘缓存;这个过程耗时,放入子线程执行
     * @param imageUrl   图片url
     * @return           执行结果
     */
    public boolean downloadUrlToDiskCache(String imageUrl) {
        try {
            String key = EncodeUtil.urlStringToMd5String(imageUrl);
            DiskLruCache.Editor editor = diskCache.edit(key);
            if (editor != null) {
                OutputStream outputStream = editor.newOutputStream(DISK_CACHE_INDEX);
                if (NetworkUtil.downloadUrlToStream(imageUrl, outputStream)) {
                    editor.commit();
                    Log.v(TAG, "下载" + imageUrl + "到磁盘缓存");
                    return true;
                } else {
                    editor.abort();
                    return false;
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }

        // 默认不成功
        return false;
    }

从磁盘缓存读取Bitmap

如果图片已经下载过一次,那么就存在于磁盘缓存中,那么就可以从磁盘缓存中读出来。这个时候是InputStream,可以转化为Bitmap格式,同时存一份到内存缓存中,下次可以直接从内存缓存中读。DiskLruCache读是通过snapshot的方式完成的,也很别扭。

    /**
     * 从磁盘缓存读入图片,并转存到内存缓存中
     * @param imageUrl 图片url
     * @return Bitmap
     */
    private Bitmap getBitmapFromDiskCache(String imageUrl) {
        Bitmap bitmap = null;
        try {
            String key = EncodeUtil.urlStringToMd5String(imageUrl);
            DiskLruCache.Snapshot snapshot = diskCache.get(key);
            if (snapshot != null) {
                InputStream inputStream = snapshot.getInputStream(DISK_CACHE_INDEX);
                bitmap = BitmapFactory.decodeStream(inputStream);
                // 如果成功读取了,存入内存缓存中
                if (bitmap != null) {
                    memoryCache.put(key, bitmap);
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }

        return bitmap;
    }

读取Bitmap

对于使用者来说,只是想从缓存读取Bitmap,并不关心来源。所以可以再包一层。如果曾经读取过,那么就来自内存缓存,速度是最快的。如果曾经下载过,但是还没读取过,那么就需要从磁盘缓存中读取,速度稍微慢一点,不过也是比较快的,(读文件的速度)。如果硬盘缓存中也没有,j就返回null。

     /**
     * 从缓存中读取Bitmap
     * @param imageUrl  图片url
     * @return          Bitmap;如果内存中没有,返回null
     */
    public Bitmap getBitmap(String imageUrl) {
        Bitmap bitmap = null;
        try {
            String key = EncodeUtil.urlStringToMd5String(imageUrl);
            // 先从内存缓存读取; 如果有,直接返回
            bitmap = memoryCache.get(key);
            if (bitmap != null) {
                Log.v(TAG, "来自内存缓存");
                return bitmap;
            }
            // 内存缓存中没有,从磁盘缓存读取
            bitmap = getBitmapFromDiskCache(imageUrl);
            if (bitmap != null) {
                Log.v(TAG, "来自磁盘缓存");
                return bitmap;
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        // 内存缓存和磁盘缓存中都没有
        return null;
    }

加载图片

由于从网络下载图片比较耗时,所以要引入多线程。由于是异步的,所以得到的Bitmap需要通过回调函数的方式回传给调用者。另外,由于Bitmap的使用一般是UI进程,所以回调函数的执行在主线程中。

    // 主线程Handler
    private static Handler mainHandler = new Handler(Looper.getMainLooper());

    // 回调函数
    public interface BitmapLoaderCallback {
        void success(Bitmap bitmap);
        void failure(String message);
    }

    // 加载
    public void loadBitmap(final String imageUrl, final BitmapLoaderCallback callback) {
        // 如果内存中有,从内存中取
        final Bitmap bitmap = cache.getBitmap(imageUrl);
        if (bitmap != null) {
            mainHandler.post(new Runnable() {
                @Override
                public void run() {
                    if (callback != null) {
                        callback.success(bitmap);
                    }
                }
            });
            return;
        }
        // 内存中没有,需要下载,这个过程耗时比较长,放子线程中执行
        // 图片经常用,所以这里用线程池管理一下比较好
        new Thread(new Runnable() {
            @Override
            public void run() {
                // 下载并写入磁盘缓存
                boolean isOk = cache.downloadUrlToDiskCache(imageUrl);
                if (!isOk) {
                    mainHandler.post(new Runnable() {
                        @Override
                        public void run() {
                            if (callback != null) {
                                callback.failure("下载并缓存图片失败,url:" + imageUrl);
                            }
                        }
                    });
                    return;
                }
                // 下载完成后,再从缓存中读一次,从磁盘缓存读入到内存缓存
                final Bitmap bitmapFromNetwork = cache.getBitmap(imageUrl);
                mainHandler.post(new Runnable() {
                    @Override
                    public void run() {
                        if (callback != null) {
                            if (bitmapFromNetwork != null) {
                                callback.success(bitmapFromNetwork);
                            } else {
                                callback.failure("获取图片失败,url:" + imageUrl);
                            }
                        }
                    }
                });
            }
        }).start();
    }

小结:通过以上封装,只要调用loadBitmap这一个方法就可以了。

其他接口封装

    /**
     * 清空缓存
     */
    public void clear() {
        // 磁盘缓存
        memoryCache.evictAll();

        // 硬盘缓存
        try {
            diskCache.delete();
            // 清除之后会自动关闭,要重新打开
            initDiskCache(context);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    /**
     * 缓存大小信息
     * @return  信息字符串,Kb
     */
    public String size() {
        int memorySize = memoryCache.size();
        long diskSize = diskCache.size();
        return ("内存缓存:" + memorySize/1024 + "Kb;" + "磁盘缓存:" + diskSize/1024 + "Kb。");
    }

    /**
     * 删除指定图片
     * @param imageUrl  图片的url
     */
    public void removeImage(String imageUrl) {
        String key = EncodeUtil.urlStringToMd5String(imageUrl);
        if (key == null) {
            return;
        }
        try {
            memoryCache.remove(key);
            diskCache.remove(key);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    /**
     * 仅磁盘缓存在某些情况下需要,比如进入后台
     */
    public void flush() {
        try {
            diskCache.flush();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

测试Demo

  • 测试demo可以简洁一点,比如这样
image.png
  • 因为涉及到网络,所以要用到权限
    <!-- 允许程序打开网络套接字 -->
    <uses-permission android:name="android.permission.INTERNET" />
  • 另外图片的url要求是https的,不然网络连不上

  • MainActivity的代码类似这样的:

public class MainActivity extends AppCompatActivity {
    // 图片缓存
    private BitmapLoader bitmapLoader;
    // 测试url,网上随便选的
    final String imageUrl = "https://img.ithome.com/newsuploadfiles/2014/12/20141223_115629_592.jpg";

    private Context context;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        context = getApplicationContext();
        bitmapLoader = new BitmapLoader(context);
    }

    @Override
    protected void onPause() {
        super.onPause();
        bitmapLoader.flush();
    }

    // 获取测试图片
    public void getImageButtonClicked(View view) {
        bitmapLoader.loadBitmap(imageUrl, new BitmapLoader.BitmapLoaderCallback() {
            @Override
            public void success(Bitmap bitmap) {
                ImageView getButtonImageView = (ImageView)findViewById(R.id.img_test);
                getButtonImageView.setImageBitmap(bitmap);
            }

            @Override
            public void failure(String message) {
                Toast.makeText(context, message, LENGTH_LONG).show();
            }
        });
    }

    // 获取缓存大小信息
    public void getSizeButtonClicked(View view) {
        String sizeInfo = bitmapLoader.size();
        if (sizeInfo != null) {
            TextView sizeTextView = (TextView)findViewById(R.id.text_size);
            sizeTextView.setText(sizeInfo);
        }
    }

    // 移除测试图片
    public void removeTestImageButtonClicked(View view) {
        bitmapLoader.removeImage(imageUrl);
    }
}

相关文章

网友评论

      本文标题:Android缓存实践2019-11-27

      本文链接:https://www.haomeiwen.com/subject/hizuwctx.html