美文网首页
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