您的位置:首页 > 移动开发 > Android开发

Android Bitmap大量使用不产生OOM之使用缓存机制

2015-08-10 10:27 621 查看
转载请注明 /article/1462864.html

加载一张图到你的界面,很轻松。但是,如果是在一瞬间要加载很多图的情况呢?比方说像listview、gridview、或者viewpager。或许你已经想到了,是的,用我上篇讲到Android Bitmap大量使用不产生OOM之多线程并发加载Bitmap的处理方式。虽然上篇能解决一瞬间加载很多图片,但是,这样一来不是每次都在请求数据吗?当我把一部分items滑出了界面,这部分加载的bitmap就没用了,然后会被垃圾回收器给回收了。等下次滑进来的时候,又要重新去加载一次。这样虽然内存使用上是没问题的,但是在图片显示性能上显然不好。为了保证一个流畅又美好的界面,你必须得做点什么来保护那些可能下次还会被使用的bitmap数据。

这一篇会介绍如何使用内存和本地缓存bitmap机制,在需要不断加载大量图片数据的情况下,来提高响应速度和ui的流畅。

使用内存缓存

内存缓存机制可以让你快速的得到bitmaps,但是相应的,需要消耗一定的应用内存。我们不需要自己去实现内存缓存,在support-v4里已经有现成的了。那就是LruCache,这个类很适合用来缓存bitmaps。它会把最近经常使用的objects保存到一个强大的数据结构LinkedHashMap,然后当缓存区满的时候,把最近最少使用的objects丢掉。

题外话:以前,一个很有名的内存缓存是用SoftReference(软引用)和WeakReference(弱引用)来管理bitmap的。但是这种方式已经不被推荐了。因为从Android2.3(API Level 9)开始垃圾回收器回收软引用和弱引用的积极性有了很大的提升,这样一来,先前的方案就显得没什么意义了。还有就是,在Android3.0(API Level 11)之前,bitmap的数据是存储在native内存区(这一块区域不受垃圾回收器限制,是底层c和c++管辖的区域),然后它的释放时不可预见的,潜在的会导致很容易超出一个应用的内存限制而崩溃。

要选择一个合适的LruCache大小,有一些因素需要被考虑,比方说:

1.你给其余的activity或者应用剩多少内存。

2.一次要加载多少图片?有多少需要马上显示到界面?

3.当前屏幕的尺寸和屏幕像素密度是多少?一个很高屏幕像素密度的手机像Galaxy Nexus(xhdpi)在缓存相同数量图片时,所需的缓存大小肯定比像素密度稍微低点的像Nexus S(hdpi)来的大。

4.要知道bitmaps的尺寸和结构类型,从而确定每个要消耗多少。

5.这些图片使用频繁吗?有没有一些图片比其他图片使用的更频繁?如果是,你可能需要让它们一直保存在缓存,或者在多建些LruCache,用来分批保存这些图片。

6.你能在质量和数量之间找到一个平衡点吗?有时候,保存大量低质量的图片会更加有效,潜在的在后台加载高质量版本的图片。

对于所有应用来说,指定LruCache的大小是没有规定的,也无法给个明确值,这取决与你对你内存的使用情况分析。如果缓存太小,没太大意义,还要浪费缓存的维护开销;如果缓存太大,那么留给你应用的其他内存就少,也会更容易发生OOM。

这里举个例子,看看怎么使用LruCache:

private LruCache<String, Bitmap> mMemoryCache;

@Override
protected void onCreate(Bundle savedInstanceState) {
    ...
    // 获取该应用虚拟机允许的最大内存,超过该内存量,会OOM。用kb单位保存。
    final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);

    // 获取最大内存的1/8.
    final int cacheSize = maxMemory / 8;

    mMemoryCache = new LruCache<String, Bitmap>(cacheSize) {
        @Override
        protected int sizeOf(String key, Bitmap bitmap) {
            // 缓存的单位大小用kb表示。
            return bitmap.getByteCount() / 1024;
        }
    };
    ...
}

public void addBitmapToMemoryCache(String key, Bitmap bitmap) {
    if (getBitmapFromMemCache(key) == null) {
        mMemoryCache.put(key, bitmap);
    }
}

public Bitmap getBitmapFromMemCache(String key) {
    return mMemoryCache.get(key);
}


在这个例子里,我们给LruCache分配了1/8的允许最大内存。在一般配置或者hdpi的手机,这个最小大概在4M(32/4)。一个满屏的GridView,然后在800x480的分辩率下,大概要消耗1.5M(800*480*4),这样一来,LruCache能缓存大概2.5页的数据。

在ImageView加载Bitmap之前,我们应该先尝试从LruCache获取试试。如果找到了,那么就可以直接被使用了。如果找不到,那就新建个task重新下载。

public void loadBitmap(int resId, ImageView imageView) {
    final String imageKey = String.valueOf(resId);

    final Bitmap bitmap = getBitmapFromMemCache(imageKey);
    if (bitmap != null) {
        mImageView.setImageBitmap(bitmap);
    } else {
        mImageView.setImageResource(R.drawable.image_placeholder);
        BitmapWorkerTask task = new BitmapWorkerTask(mImageView);
        task.execute(resId);
    }
}


我们也要重新修改下BitmapWorkerTask部分代码,把LruCache给加进去:

class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> {
    ...
    // Decode image in background.
    @Override
    protected Bitmap doInBackground(Integer... params) {
        final Bitmap bitmap = decodeSampledBitmapFromResource(
                getResources(), params[0], 100, 100));
        addBitmapToMemoryCache(String.valueOf(params[0]), bitmap);
        return bitmap;
    }
    ...
}


使用DiskCache

内存缓存适合解决最近常常浏览图片的快速获取,但是我想说,朋友,你不能只依靠这个。像GridView,它需要大量数据,于是,很快就会充满整个LruCache。此时如果你的应用被其他应用给中断了呢?比方说,突然进来一个电话。最坏的情况,你的应用被系统回收释放了,此时,保存在缓存的数据也就全没了。然后,你苦逼的回到原先的应用,发现这些图片必须重新加载了。

DiskLruCache就很适合处理刚才描述的情况,它会储存这些图片到本地,然后我们下次就不用去网上下载,可以直接从本地获取到。

关于这部分内容,Android DOC里讲述的内容不全面,所以,我会自己总结一下。

我们先来看看官方给的一个使用DiskLruCache的例子:

private DiskLruCache mDiskLruCache;
private final Object mDiskCacheLock = new Object();
private boolean mDiskCacheStarting = true;
private static final int DISK_CACHE_SIZE = 1024 * 1024 * 10; // 10MB
private static final String DISK_CACHE_SUBDIR = "thumbnails";

@Override
protected void onCreate(Bundle savedInstanceState) {
    ...
    // Initialize memory cache
    ...
    // Initialize disk cache on background thread
    File cacheDir = getDiskCacheDir(this, DISK_CACHE_SUBDIR);
    new InitDiskCacheTask().execute(cacheDir);
    ...
}

class InitDiskCacheTask extends AsyncTask<File, Void, Void> {
    @Override
    protected Void doInBackground(File... params) {
        synchronized (mDiskCacheLock) {
            File cacheDir = params[0];
            mDiskLruCache = DiskLruCache.open(cacheDir, DISK_CACHE_SIZE);
            mDiskCacheStarting = false; // Finished initialization
            mDiskCacheLock.notifyAll(); // Wake any waiting threads
        }
        return null;
    }
}

class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> {
    ...
    // Decode image in background.
    @Override
    protected Bitmap doInBackground(Integer... params) {
        final String imageKey = String.valueOf(params[0]);

        // Check disk cache in background thread
        Bitmap bitmap = getBitmapFromDiskCache(imageKey);

        if (bitmap == null) { // Not found in disk cache
            // Process as normal
            final Bitmap bitmap = decodeSampledBitmapFromResource(
                    getResources(), params[0], 100, 100));
        }

        // Add final bitmap to caches
        addBitmapToCache(imageKey, bitmap);

        return bitmap;
    }
    ...
}

public void addBitmapToCache(String key, Bitmap bitmap) {
    // Add to memory cache as before
    if (getBitmapFromMemCache(key) == null) {
        mMemoryCache.put(key, bitmap);
    }

    // Also add to disk cache
    synchronized (mDiskCacheLock) {
        if (mDiskLruCache != null && mDiskLruCache.get(key) == null) {
            mDiskLruCache.put(key, bitmap);
        }
    }
}

public Bitmap getBitmapFromDiskCache(String key) {
    synchronized (mDiskCacheLock) {
        // Wait while disk cache is started from background thread
        while (mDiskCacheStarting) {
            try {
                mDiskCacheLock.wait();
            } catch (InterruptedException e) {}
        }
        if (mDiskLruCache != null) {
            return mDiskLruCache.get(key);
        }
    }
    return null;
}

// Creates a unique subdirectory of the designated app cache directory. Tries to use external
// but if not mounted, falls back on internal storage.
public static File getDiskCacheDir(Context context, String uniqueName) {
    // Check if media is mounted or storage is built-in, if so, try and use external cache dir
    // otherwise use internal cache dir
    final String cachePath =
            Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState()) ||
                    !isExternalStorageRemovable() ? getExternalCacheDir(context).getPath() :
                            context.getCacheDir().getPath();

    return new File(cachePath + File.separator + uniqueName);
}


我们可以看到,DiskLruCache的使用和LruCache几乎是差不多的。先是初始化,然后再可以写入和读出。

我通过官方给的链接,把DiskLruCache下载下来,发现代码和上面的例子很不一样了。所以,我们不能参考官方的doc去熟悉DiskCache,我们只能通过自己去探索并结合网上能找到的信息。

DiskLruCache的官网链接是:

https://android.googlesource.com/platform/libcore/+/jb-mr2-release/luni/src/main/java/libcore/io/DiskLruCache.java

使用DiskLruCache

我们可以看到,/src/main/java/libcore/io/DiskLruCache.java,也就是说DiskLruCache的package是
package libcore.io;


所以,我们使用它的时候,也要创一个这个package。



初始化DiskLruCache

我们先来看看怎么初始化DiskLruCache。

初始化调用的方法和官方给的是一样的,只是参数不一样了。

public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize)


我们可以看到,需要提供四个参数。这四个参数分别是:

1.File directory 缓存的文件位置

2.int appVersion 应用的版本

3.int valueCount 指定同一个key能对应的缓存文件的数量,这里一般设置1。

4.long maxSize 这个参数指定缓存大小。

缓存的文件位置,大部分开发者都会设定在sd卡。当然,我们可以选择sd卡的任意能访问到的位置,但是,一般对于这种本地缓存,有很好的地方可以放置,那就是/sdcard/Android/data/application package/cache。指定在这里,有一个好处,那就是当指定的应用被卸载后,在这里的文件也会被系统给清理掉。从用户的角度出发,这种方式显然是最好的。

官方给了获取该地址的方案了:

// Creates a unique subdirectory of the designated app cache directory. Tries to use external
// but if not mounted, falls back on internal storage.
public static File getDiskCacheDir(Context context, String uniqueName) {
    // Check if media is mounted or storage is built-in, if so, try and use external cache dir
    // otherwise use internal cache dir
    final String cachePath =
            Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState()) ||
                    !isExternalStorageRemovable() ? getExternalCacheDir(context).getPath() :
                            context.getCacheDir().getPath();

    return new File(cachePath + File.separator + uniqueName);
}


它写得很到位,先是判断sd卡能不能获取到,如果能,就获取/sdcard/Android/data/application package/cache的路径;如果不能,退而求其次,获取/data/data/application package/cache的路径,该位置也受系统的管制,当应用卸载了,数据也会清理掉,只是那里的使用空间有限。

得到了缓存位置,然后是获取应用版本。这里为啥要获取应用版本呢?是这样的,每次初始化的时候,它都会把当前获取到的应用版本和DiskLruCache已记录的应用版本进行比较,如果不一样,会把缓存数据清光。这样一来,你的应用升级,数据就会没了!当然如果你不喜欢这样,可以把它源码改动一下。

我们先贴个获取应用版本的代码:

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会因为版本不同而清空数据:

public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize)
            throws IOException {
        if (maxSize <= 0) {
            throw new IllegalArgumentException("maxSize <= 0");
        }
        if (valueCount <= 0) {
            throw new IllegalArgumentException("valueCount <= 0");
        }

        // prefer to pick up where we left off
        DiskLruCache cache = new DiskLruCache(directory, appVersion, valueCount, maxSize);
        if (cache.journalFile.exists()) {
            try {
                cache.readJournal();
                cache.processJournal();
                cache.journalWriter = new BufferedWriter(new FileWriter(cache.journalFile, true),
                        IO_BUFFER_SIZE);
                return cache;
            } catch (IOException journalIsCorrupt) {
//                System.logW("DiskLruCache " + directory + " is corrupt: "
//                        + journalIsCorrupt.getMessage() + ", removing");
                cache.delete();
            }
        }

        // create a new empty cache
        directory.mkdirs();
        cache = new DiskLruCache(directory, appVersion, valueCount, maxSize);
        cache.rebuildJournal();
        return cache;
    }


第11行是创建了cache,然后12行是判断是否已经有journalFile创建过。那什么是cache.journalFile呢?我直接贴关键的了:

static final String JOURNAL_FILE = "journal";

this.journalFile = new File(directory, JOURNAL_FILE);


这里,directory的位置就是先前定义的缓存位置。也就是说先读取/data/application package/cache/journal这个文件是否存在。

这个journal文件其实就是DiskLruCache的日志,记录了每一笔的操作情况,也记录了DiskLruCache的初始配置值。

我们再来看看第14行的
cache.readJournal();


private void readJournal() throws IOException {
        InputStream in = new BufferedInputStream(new FileInputStream(journalFile), IO_BUFFER_SIZE);
        try {
            String magic = readAsciiLine(in);
            String version = readAsciiLine(in);
            String appVersionString = readAsciiLine(in);
            String valueCountString = readAsciiLine(in);
            String blank = readAsciiLine(in);
            if (!MAGIC.equals(magic)
                    || !VERSION_1.equals(version)
                    || !Integer.toString(appVersion).equals(appVersionString)
                    || !Integer.toString(valueCount).equals(valueCountString)
                    || !"".equals(blank)) {
                throw new IOException("unexpected journal header: ["
                        + magic + ", " + version + ", " + valueCountString + ", " + blank + "]");
            }

            while (true) {
                try {
                    readJournalLine(readAsciiLine(in));
                } catch (EOFException endOfJournal) {
                    break;
                }
            }
        } finally {
            closeQuietly(in);
        }
    }


可以看到,先是获取记录的各种配置信息,包括了应用版本,然后和当前传进来的进行对比,如果不一致,就直接抛异常了。

再回到DiskLruCache的open,它捕获异常之后,就直接
cache.delete();


再说DiskLruCache的open的第三个参数int valueCount,这个参数的含义是,我们传入数据的时候,会用key去对应这个数据,然后它指定了key能对应的数据的个数。

一般来说,我们是希望一一对应,所以,我们就传个1。

第四个参数我就不说了。

官方给的初始化是在子线程进行的,这样是最好的,不过,你要控制一下流程,别还没初始化完就去读取或者写入。当然写在主线程一般也没啥太大问题,除非遇到特殊情况了(sd卡好好的,初始化的时候突然松了$#&@)。

缓存写入

怎么写入缓存呢?有点类似于SharedPreferece,先获取DiskLruCache.Editor,然后再获取OutputStream,通过Stream得到数据,再执行commit()。

DiskLruCache.Editor editor = mDiskLruCache.edit(key);  
            if (editor != null) {  
                OutputStream outputStream = editor.newOutputStream(0);  
                if (dataToStream(key, outputStream)) {  
                    editor.commit();  
                } else {  
                    editor.abort();  
                }  
            }


这里,如果你觉得这种方式不爽,你也可以改源码。

key不用说了,通过key获取editor,然后通过editor获取stream。这里
editor.newOutputStream(0);
的0是指key对应的第一个数据,我们先前定义key对应一个数据,所以这里只要获取第一个数据就行。

写入成功后,就会在对应的地方产生一条数据:




我创建的缓存位置在sd卡,所以会到Android/data下,缓存文件名是bitmap,这里的key,我做了处理。其实只要保证唯一性就行了。

缓存读取

缓存读取是通过DiskLruCache的get方法:

public synchronized Snapshot get(String key) throws IOException


我这里直接贴示例代码:

DiskLruCache.Snapshot snapshot;
        try {
            snapshot = diskLruCache.get(key);
            if (snapshot != null) {
                fileInputStream = (FileInputStream) snapshot.getInputStream(0);
                fileDescriptor = fileInputStream.getFD();
            }


可以看到,读取时,先要获得DiskLruCache.Snapshot对象,然后要先判空,再从里面获取Inputstream,就可以读入了。当然,如果你觉得这种方式不适合你,你可以改代码。

缓存删除

缓存移除通过调用remove方法:

public synchronized boolean remove(String key) throws IOException


一句话调用:

diskLruCache.remove(key);


很简单吧,但是我们一般用不到,因为本身它有管理容量,超过容量,它会自动选择最近最少使用的把它删除,所以不用我们去多此一举。

其他操作

还有一些其他操作,这里稍微讲下。

flush()

看到这个名称,大部分就知道这个干嘛用的了。没错,它就是用来把文件操作的缓存内容写到journal,这个是针对日志的写入的。一般我们只要在activity的onPause()调用下就行。

close()

这个是关闭DiskLruCache缓存。

delete()

这个是删除所有缓存文件,包含了close():

public void delete() throws IOException {
        close();
        deleteContents(directory);
    }


public static void deleteContents(File dir) throws IOException {
        File[] files = dir.listFiles();
        if (files == null) {
            throw new IllegalArgumentException("not a directory: " + dir);
        }
        for (File file : files) {
            if (file.isDirectory()) {
                deleteContents(file);
            }
            if (!file.delete()) {
                throw new IOException("failed to delete file: " + file);
            }
        }
    }


size()

这个是当前缓存的数据大小。

好了,关于bitmap的缓存部分讲完了。如果你对DiskLruCache有兴趣,可以自己查看它的代码,就一个文件,大部分是文件操作,不难。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: