Android Bitmap的加载和Cache

作者: 一个有故事的程序员 | 来源:发表于2019-04-23 14:51 被阅读18次

    导语

    主要介绍如何高效地加载一个Bitmap,Android中常用的缓存策略,如何优化列表的卡顿。

    主要内容

    • Bitmap的高效加载
    • Android中的缓存策略
    • ImageLoader的使用

    具体内容

    Bitmap的高效加载

    先来简单介绍一下如何加载一个Bitmap, Bitmap在android中指的是一张图片, 可以是png格式也可以是jpg等其他常见的图片格式.

    那么如何加载一个图片?首先BitmapFactory类提供了四种方法: decodeFile(), decodeResource(), decodeStream(), decodeByteArray(). 分别用于从文件系统, 资源文件, 输入流以及字节数组加载出一个Bitmap对象. 其中decodeFile和decodeResource又间接调用了decodeStream()方法, 这四类方法最终是在Android的底层实现的, 对应着BitmapFactory类的几个native方法.

    高效加载的Bitmap的核心思想:采用BitmapFactory.Options来加载所需尺寸的图片. 比如说一个ImageView控件的大小为300300. 而图片的大小为800800. 这个时候如果直接加载那么就比较浪费资源, 需要更多的内存空间来加载图片, 这不是很必要的. 这里我们就可以先把图片按一定的采样率来缩小图片在进行加载. 不仅降低了内存占用,还在一定程度上避免了OOM异常. 也提高了加载bitmap时的性能.

    而通过Options参数来缩放图片: 主要是用到了inSampleSize参数, 即采样率。

    • 如果是inSampleSize=1那么和原图大小一样,
    • 如果是inSampleSize=2那么宽高都为原图1/2, 而像素为原图的1/4, 占用的内存大小也为原图的1/4
    • 如果是inSampleSize=3那么宽高都为原图1/3, 而像素为原图的1/9, 占用的内存大小也为原图的1/9
    • 以此类推…..

    要知道Android中加载图片具体在内存中的占有的大小是根据图片的像素决定的, 而与图片的实际占用空间大小没有关系.而且如果要加载mipmap下的图片, 还会根据不同的分辨率下的文件夹进行不同的放大缩小.

    列举现在有一张图片像素为:10241024, 如果采用ARGB8888(四个颜色通道每个占有一个字节,相当于1点像素占用4个字节的空间)的格式来存储.(这里不考虑不同的资源文件下情况分析) 那么图片的占有大小就是102410244那现在这张图片在内存中占用4MB.
    如果针对刚才的图片进行inSampleSize=2, 那么最后占用内存大小为512512*4, 也就是1MB

    采样率的数值必须是大于1的整数是才会有缩放效果, 并且采样率同时作用于宽/高, 这将导致缩放后的图片以这个采样率的2次方递减, 即内存占用缩放大小为1/(inSampleSize的二次方). 如果小于1那么相当于=1的时候. 在官方文档中指出, inSampleSize的取值应该总是为2的指数, 比如1,2,4,8,16,32…如果外界传递inSampleSize不为2的指数, 那么系统会向下取整并选择一个最接近的2的指数来代替. 比如如果inSampleSize=3,那么系统会选择2来代替. 但是这条规则并不作用于所有的android版本, 所以可以当成一个开发建议

    整理一下开发中代码流程:

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

    inJustDecodeBounds这个参数的作用就是在加载图片的时候是否只是加载图片宽高信息而不把图片全部加载到内存. 所以这个操作是个轻量级的.

    通过这些步骤就可以整理出以下的工具加载图片类调用decodeFixedSizeForResource()即可.

    public class MyBitmapLoadUtil {
        /**
         * 对一个Resources的资源文件进行指定长宽来加载进内存, 并把这个bitmap对象返回
         *
         * @param res   资源文件对象
         * @param resId 要操作的图片id
         * @param reqWidth 最终想要得到bitmap的宽度
         * @param reqHeight 最终想要得到bitmap的高度
         * @return 返回采样之后的bitmap对象
         */
        public static Bitmap decodeFixedSizeForResource(Resources res, int resId, int reqWidth, int reqHeight){
            // 首先先指定加载的模式 为只是获取资源文件的大小
            BitmapFactory.Options options = new BitmapFactory.Options();
            options.inJustDecodeBounds = true;
            BitmapFactory.decodeResource(res, resId, options);
            //Calculate Size  计算要设置的采样率 并把值设置到option上
            options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);
            // 关闭只加载属性模式, 并重新加载的时候传入自定义的options对象
            options.inJustDecodeBounds = false;
            return BitmapFactory.decodeResource(res, resId, options);
        }
        /**
         *  一个计算工具类的方法, 传入图片的属性对象和 想要实现的目标大小. 通过计算得到采样值
         */
        private static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) {
            //Raw height and width of image
            //原始图片的宽高属性
            final int height = options.outHeight;
            final int width = options.outWidth;
            int inSampleSize = 1;
            // 如果想要实现的宽高比原始图片的宽高小那么就可以计算出采样率, 否则不需要改变采样率
            if (reqWidth < height || reqHeight < width){
                int halfWidth = width/2;
                int halfHeight = height/2;
                // 判断原始长宽的一半是否比目标大小小, 如果小那么增大采样率2倍, 直到出现修改后原始值会比目标值大的时候
                while((halfHeight/inSampleSize) >= reqHeight && (halfWidth/inSampleSize) >= reqWidth){
                    inSampleSize *= 2;
                }
            }
            return inSampleSize;
        }
    }
    

    Android中的缓存策略

    当程序第一次从网络上加载图片后,将其缓存在存储设备中,下次使用这张图片的时候就不用再从网络从获取了。很多时候为了提高应用的用户体验,往往还会把图片在内存中再缓存一份,因为从内存中加载图片比存储设备中快。一般情况会把图片存一份到内存中,一份到存储设备中,如果内存中没找到就去存储设备中找,还没有找到就从网络上下载。

    缓存策略包含缓存的添加、获取和删除操作。不管是内存还是存储设备,缓存大小都是有限制的。如何删除旧的缓存并添加新的缓存,就对应缓存算法。

    目前常用的一种缓存算法是LRU(Least Recently Used), 最近最少使用算法. 核心思想: 当缓存存满时, 会优先淘汰那些近期最少使用的缓存对象. 采用LRU算法的缓存有两种: LruCache和DiskLruCache,LruCahe用于实现内存缓存, DiskLruCache则充当了存储设备缓存, 当组合使用后就可以实现一个类似ImageLoader这样的类库.

    LruCache

    LruCache是Android 3.1所提供的一个缓存类, 通过support-v4兼容包可以兼容到早期的Android版本

    LruCache是一个泛型类, 它内部采用了一个LinkedHashMap以强引用的方式存储外界的缓存对象, 其提供了get和put方法来完成缓存的获取和添加的操作. 当缓存满了时, LruCache会移除较早使用的缓存对象, 然后在添加新的缓存对象. 普及一下各种引用的区别:

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

    LruCache是线程安全的。
    LruCache 典型初始化过程:

        int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
        int cacheSize = maxMemory / 8;
        mMemoryCache = new LruCache<String, Bitmap>(cacheSize) {
            @Override
            protected int sizeOf(String key, Bitmap value) {
                return value.getRowBytes() * value.getHeight() / 1024;
            }
        };
    

    这里只需要提供缓存的总容量大小(一般为进程可用内存的1/8)并重写 sizeOf 方法即可.sizeOf方法作用是计算缓存对象的大小。这里大小的单位需要和总容量的单位(这里是kb)一致,因此除以1024。一些特殊情况下,需要重写LruCache的entryRemoved方法,LruCache移除旧缓存时会调用entryRemoved方法,因此可以在entryRemoved中完成一些资源回收工作(如果需要的话)。

    还有获取和添加方法,都比较简单:

    mMemoryCache.get(key)
    mMemoryCache.put(key,bitmap)
    

    通过remove方法可以删除一个指定的对象。

    从Android 3.1开始,LruCache称为Android源码的一部分。

    DiskLruCache

    DiskLruCache用于实现磁盘缓存,DiskLruCache得到了Android官方文档推荐,但它不属于Android SDK的一部分,源码在这里

    DiskLruCache的创建:
    DiskLruCache并不能通过构造方法来创建, 他提供了open()方法用于创建自身, 如下所示

    public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize)
    
    • File directory: 表示磁盘缓存在文件系统中的存储路径. 可以选择SD卡上的缓存目录, 具体是指/sdcard/Andriod/data/package_name/cache目录, package_name表示当前应用的包名, 当应用被卸载后, 此目录会一并删除掉. 也可以选择data目录下. 或者其他地方. 这里给出的建议:如果应用卸载后就希望删除缓存文件的话 , 那么就选择SD卡上的缓存目录, 如果希望保留缓存数据那就应该选择SD卡上的其他目录.
    • int appVersion: 表示应用的版本号, 一般设为1即可. 当版本号发生改变的时候DiskLruCache会清空之前所有的缓存文件, 在实际开发中这个实用性不大.
    • int valueCount: 表示单个节点所对应的数据的个数, 一般设为1.
    • long maxSize: 表示缓存的总大小, 比如50MB, 当缓存大小超出这个设定值后, DiskLruCache会清除一些缓存而保证总大小不大于这个设定值.
        //初始化DiskLruCache,包括一些参数的设置
        public void initDiskLruCache
        {
            //配置固定参数
            // 缓存空间大小
            private static final long DISK_CACHE_SIZE = 1024 * 1024 * 50;
            //下载图片时的缓存大小
            private static final long IO_BUFFER_SIZE = 1024 * 8;
            // 缓存空间索引,用于Editor和Snapshot,设置成0表示Entry下面的第一个文件
            private static final int DISK_CACHE_INDEX = 0;
     
            //设置缓存目录
            File diskLruCache = getDiskCacheDir(mContext, "bitmap");
            if(!diskLruCache.exists())
                diskLruCache.mkdirs();
            //创建DiskLruCache对象,当然是在空间足够的情况下
            if(getUsableSpace(diskLruCache) > DISK_CACHE_SIZE)
            {
                try
                {
                    mDiskLruCache = DiskLruCache.open(diskLruCache, 
                            getAppVersion(mContext), 1, DISK_CACHE_SIZE);
                    mIsDiskLruCache = true;
                }catch(IOException e)
                {
                    e.printStackTrace();
                }
            }
        }
     
        //上面的初始化过程总共用了3个方法
        //设置缓存目录
        public File getDiskCacheDir(Context context, String uniqueName) {
            String cachePath;
            if (Environment.MEDIA_MOUNTED.equals(Environment
                    .getExternalStorageState())
                    || !Environment.isExternalStorageRemovable()) {
                cachePath = context.getExternalCacheDir().getPath();
            } else {
                cachePath = context.getCacheDir().getPath();
            }
            return new File(cachePath + File.separator + uniqueName);
        }
     
        // 获取可用的存储大小
        @TargetApi(VERSION_CODES.GINGERBREAD)
        private long getUsableSpace(File path) {
            if (Build.VERSION.SDK_INT >= VERSION_CODES.GINGERBREAD)
                return path.getUsableSpace();
            final StatFs stats = new StatFs(path.getPath());
            return (long) stats.getBlockSize() * (long) stats.getAvailableBlocks();
        }
        //获取应用版本号,注意不同的版本号会清空缓存
        public int getAppVersion(Context context) {
            try {
                PackageInfo info = context.getPackageManager().getPackageInfo(
                        context.getPackageName(), 0);
                return info.versionCode;
            } catch (NameNotFoundException e) {
                e.printStackTrace();
            }
            return 1;
        }
    

    DiskLruCache的缓存添加:
    DiskLruCache的缓存添加的操作是通过Editor完成的, Editor表示一个缓存对象的编辑对象.

    如果还是缓存图片为例子, 每一张图片都通过图片的url为key, 这里由于url可能会有特殊字符所以采用url的md5值作为key. 根据这个key就可以通过edit()来获取Editor对象, 如果这个缓存对象正在被编辑, 那么edit()就会返回null. 即DiskLruCache不允许同时编辑一个缓存对象.

    当用.edit(key)获得了Editor对象之后. 通过editor.newOutputStream(0)就可以得到一个文件输出流. 由于之前open()方法设置了一个节点只能有一个数据. 所以在获得输出流的时候传入常量0即可.

    有了文件输出流, 可以当网络下载图片时, 图片就可以通过这个文件输出流写入到文件系统上.最后,要通过Editor中commit()来提交写操作, 如果下载中发生异常, 那么使用Editor中abort()来回退整个操作.

    DiskLruCache的缓存查找:
    和缓存的添加过程类似, 缓存查找过程也需要将url转换成key, 然后通过DiskLruCache#get()方法可以得到一个Snapshot对象, 接着在通过Snapshot对象即可得到缓存的文件输入流, 有了文件输入流, 自然就可以得到Bitmap对象. 为了避免加载图片出现OOM所以采用压缩的方式. 在前面对BitmapFactory.Options的使用说明了. 但是这中方法对FileInputStream的缩放存在问题. 原因是FileInputStream是一种有序的文件流, 而两次decodeStream调用会影响文件的位置属性, 这样在第二次decodeStream的时候得到的会是null. 针对这一个问题, 可以通过文件流来得到它所对应的文件描述符, 然后通过BitmapFactory.decodeFileDescription()来加载一张缩放后的图片.

    /**
         * 磁盘缓存的读取
         * @param url
         * @param reqWidth
         * @param reqHeight
         * @return
     */
    private Bitmap loadBitmapFromDiskCache(String url, int reqWidth, int reqHeight) throws IOException
    {
        if(Looper.myLooper() == Looper.getMainLooper())
            Log.w(TAG, "it's not recommented load bitmap from UI Thread");
        if(mDiskLruCache == null)
            return null;
     
        Bitmap bitmap = null;
        String key = hashKeyForDisk(url);
        Snapshot snapshot = mDiskLruCache.get(key);
        if(snapshot != null)
        {
            FileInputStream fileInputStream = (FileInputStream) snapshot.getInputStream(DISK_CACHE_INDEX);
            FileDescriptor fd = fileInputStream.getFD();
            bitmap = mImageResizer.decodeSampleBitmapFromFileDescriptor(fd, reqWidth, reqHeight);
     
            if(bitmap != null)
                addBitmapToMemoryCache(key, bitmap);
     
        }
        return bitmap;      
    }
    
    ImageLoader的实现

    一个好的ImageLoader应该具备以下几点:

    • 图片的压缩
    • 网络拉取
    • 内存缓存
    • 磁盘缓存
    • 图片的同步加载
    • 图片的异步加载

    图片压缩功能
    ImageResizer
    内存缓存和磁盘缓存
    ImageLoader
    同步加载和异步加载的接口设计
    ImageLoader 173行

    异步加载过程:

    1. bindBitmap先尝试从内存缓存读取图片,如果没有会在线程池中调用loadBitmap方法。获取成功将图片封装为LoadResult对象通过mMainHandler向UI线程发送消息。选择线程池和Handler来提供并发能力和异步能力。
    2. 为了解决View复用导致的列表错位问题,在给ImageView设置图片之前都会检查它的url有没有发生改变,如果改变就不再给它设置图片。(76行)

    ImageLoader的使用

    照片墙效果

    实现照片墙效果,如果图片都需要是正方形;这样做很快,自定义一个ImageView,重写onMeasure方法。

    @Override
    protected void onMeasure(int widthMeasureSpec,int heightMeasureSpec){
        super.onMeasure(widthMeasureSpec,widthMeasureSpec);//将原来的参数heightMeasureSpec换成widthMeasureSpec
    }
    
    优化列表的卡顿现象
    1. 不要在getView中执行耗时操作,不要在getView中直接加载图片。
    2. 控制异步任务的执行频率:如果用户刻意频繁上下滑动,getView方法会不停调用,从而产生大量的异步任务。可以考虑在列表滑动停止加载图片;给ListView或者GridView设置 setOnScrollListener 并在 OnScrollListener 的 onScrollStateChanged 方法中判断列表是否处于滑动状态,如果是的话就停止加载图片。
    3. 大部分情况下,可以使用硬件加速解决莫名卡顿问题,通过设置 android:hardwareAccelerated=”true” 即可为Activity开启硬件加速。

    本章内容更多参考

    更多内容戳这里(整理好的各种文集)

    相关文章

      网友评论

        本文标题:Android Bitmap的加载和Cache

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