主要介绍内容:
如何有效地加载一个 Bitmap,这是一个很有意义的话题,由于 Bitmap 的特殊性以及 Android 对单个应用所施加的内存限制,比如 16MB,这导致加载 Bitmap 的时候很容易出现内存溢出。下面这个异常信息在开发中应该时常遇到:
java.lang.OutofMemoryError: bitmap size exceeds VM budget
因此如何高效地加载 Bitmap 是一个很重要也很容易被开发者忽视的问题。
在介绍 Bitmap 的高效加载之前,先说一下如何加载一个 Bitmap,Bitmap 在 Android 中指的是一张图片,可以是 png 格式也可以是 jpg 等其他常见的图片格式。那么如何加载一个图片呢? BitmapFactory 类提供了四类方法:decodeFile、decodeResource、decodeStream 和 decodeByteArray,分别用于支持从文件系统、资源、输入流以及字节数组中加载出一个 Bitmap 对象,其中 decodeFile 和 decodeResource 又间接调用了 decodeStream 方法,这四类方法最终是在 Android 的底层实现的,对应着 BitmapFactory 类的几个 native 方法。
如何高效地加载 Bitmap 呢?其实核心思想也很简单,那就是采用 BitmapFactory.Options 来加载所需尺寸的图片。这里假设通过 ImageView 来显示图片,很多时候 ImageView 并没有图片的原始尺寸那么大,这个时候把整个图片加载进来后在设给 ImageView,这显然是没有必要的,因为 ImageView 并没与办法显示原始的图片。通过 BitmapFactory.Options 就可以按一定的采样率来加载缩小后的图片,将缩小后的图片在 ImageView 中显示,这样就会降低内存占用从而在一定程度上避免 OOM,提高了 Bitmap 加载时的性能。BitmapFactory 提供的加载图片的四类方法都支持 BitmapFactory.Options 参数,通过它们就可以很方便地对一个图片进行采样缩放。
通过 BitmapFactory.Options 来缩放图片,主要是用到了它的 inSampleSize 参数,即采样率。当 inSampleSize 为 1 时,采样后的图片大小为图片的原始大小;当 inSampleSize 大于 1 时,比如为 2,那么采样后的图片其宽/高均为原图大小的 1/2,而像素数为原图的 1/4,其占有的内存大小因为原图的 1/4。那一张 1024 x 1024 像素的图片来说,嘉定采用 ARGB8888 格式存储,那么它占有的内存为 1024 x 1024 x 4,即 4MB,如果 inSampleSize 为 2,那么采样后的图片其内存占用只有 512 x 512 x 4,即 1MB。可以发现采用率 inSampleSize 必须是大于 1 的整数图片才会有缩小的效果,并且采样率同时作用于宽/高,这将导致缩放后的图片大小以采样率的 2次方 形式递减,即缩放比例为 1/(inSampleSize 的 2次方),比如 inSampleSize 为 4,那么缩放比例就是 1/16.有一种特殊情况,那就是当 inSampleSize 小于 1 时,其作用相当于 1,即无缩放效果。另外最新的官方文档中指出,inSampleSize 的取值应该总是 2 的指数,比如 1、2、4、8、16 等等。如果外界传递给系统的 inSampleSize 不为 2 的指数,那么系统会向下取整并选择一个最接近 2 的指数来代替,比如 3,系统会选择 2 来代替,但是经过验证发现这个结论并非在所有的 Android 版本上都成立,因此把它当中一个开发建议即可。
考虑如下实际情况,比如 ImageView 的大小是 100 x 100 像素,而图片的原始大小为 200 x 200,那么只需要将采样率 inSampleSize 设为 2 即可。但是如果图片大小为 200 x 300 呢? 这个时候采样率还应该选择 2,这样缩放后的图片的大小为 100 x 150 像素,仍然是适合 ImageView 的,如果 采样率为 3,那么缩放后的图片大小就会小于 ImageView 所期望的大小,这样图片就会被拉伸从而导致模糊(ImageView 的 scaleType 属性默认为 fitCenter 当原图大小小于 ImageView 会拉伸)。
通过采样率即可有效地加载图片,那么到底如何获取采样率呢?获取采用率也很简单,遵循如下流程:
经过上面 4 个步骤,加载出的图片就是最终缩放后的图片,当然也有可能不需要缩放。这里说明一下 inJustDecodeBounds 参数,当此参数设为 true 时,BitmapFactory 只会解析图片的原始宽/高信息,并不会去真正地加载图片,所以这个操作是轻量级的。另外需要注意的是,这个时候 BitmapFactory 获取的图片宽/高信息和图片的位置以及程序运行的设备有关,比如同一张图片放在不同的 drawable 目录下或者程序运行在不同屏幕密度的设备上,这都可能导致 BitmapFactory 获取到不同的结果,之所以会出现这个现象,这和 Android 的资源加载机制有关,相信大家在平日里也都有所体会,这里就不在详细说明了。
将上面的 4 个流程用程序来实现,就产生了下面的代码:
private Bitmap decodeSampledBitmapFromResource(Resources res, int resId, int reqWidth, int reqHeight) {
final BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(res, resId, options);
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);
options.inJustDecodeBounds = false;
return BitmapFactory.decodeResource(res, resId, options);
}
private 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) {
while (height / inSampleSize >= reqHeight
&& width / inSampleSize >= reqWidth) {
inSampleSize *= 2;
}
}
return inSampleSize;
}
有了上面的两个方法,实际使用的时候就很简单了,比如 ImageView 所期望的图片的大小为 100 x 100 像素,这个时候就可以通过如下方式高效地加载并显示图片:
mImageView.setImageBitmap(decodeSampledBitmapFromResource(getResources(), R.id.myimage,100,100));
除了 BitmapFactory 的 decodeResource 方法,其他的三个 decode 系列的方法也是支持采样加载的,并且处理方式也是类似的,但是 decodeStream 方法稍微有点特殊,这个会在后续内容中介绍。
下面先简单介绍一下缓存策略,已经了解的可以直接略过看后面的内容(LruCache 和 DiskLruCache)。
缓存策略在 Android 中有着广泛的使用场景,尤其在图片加载这个场景下,缓存策略就变得更为重要。考虑一种场景:有一批网络图片,需要下载后在用户界面上进行展示,这个场景在 PC 环境下是很简单的,直接把所有的图片下载到本地在显示即可,但是放到移动设备上就不一样了。不管是 Android 还是 IOS 设备,流量对于用户来说都是一种宝贵的资源,由于流量是收费的,所以在应用开发中并不能过多地消耗用户的流量,否则这个应用可能不能被用户所接受。在加上目前国内公共场所的 WiFi 普及率并不算太高,因此用户在很多情况下手机上都是用的移动网络而非 WiFi,因此必须提供一种解决方案来解决流量的消耗问题。
如何避免过多的流量消耗呢?那就是我们今天要说的内容:缓存。当程序第一次从网络加载图片后,就将其缓存到存储设备上,这样下次使用这张图片就不用再从网络上获取了,这样就为用户节省了流量。很多时候为了提高应用的用户体验,往往还会把图片在内存中在存储一份,这样当应用打算从网络上请求一张图片时,程序会首先从内存中去获取,如何内存中没有那就从存储设备中获取,如果存储设备中也没有,那就从网络上下载这张图片。因为从内存中加载图片比从存储设备中加载图片要快,所以这样既提高了程序的效率又为用户节约了不必要的流量开销。上述的缓存策略不仅仅适用于图片,也适用于其他文件类型。
说到缓存策略,其实并没有统一的标准。一般来说,缓存策略主要包含缓存的添加、获取和删除这三类操作。如何添加和获取缓存这个比较好理解,那么为什么还要删除缓存呢?这是因为不管是内存缓存还是存储设备缓存,它们的缓存大小都是有限制的,因为内存和诸如 SD 卡之类的存储设备都是有容量限制的,因此在使用缓存时总是要为缓存指定一个最大的容量。如果当缓存容量满了,但是程序还需要向其添加缓存,这个时候该怎么办呢?这就需要删除一些旧的缓存并添加新的缓存,如何定义缓存的新旧这就是一种策略,不同的策略就对应不同的缓存算法,比如可以简单地根据文件的最后修改时间来定义缓存的新旧,当缓存满时就将最后修改时间较早的缓存移除,这就是一种缓存算法,但是这种算法并不算很完美。
目前常用的一种缓存算法是 LRU(Least Recently Used),LRU 是近期最少使用算法,它的核心思想是当缓存满时,会优先淘汰那些近期最少使用的缓存对象。采用 LRU 算法的缓存有两种:LruCache 和 DiskLruCache,LruCache 用于实现内存缓存,而 DiskLruCache 则充当了存储设备缓存,通过这两者的完美结合,就可以很方便地实现一个具有很高实用价值的 ImageLoader。
LruCache 是 Android 3.1 所提供的一个缓存类,通过 support-v4 兼容包可以兼容到早期的 Android 版本,目前 Android 2.2 一下的用户量已经很少了,因此我们开发的应用兼容到 Android 2.2 就已经足够了。为了能够兼容 Android 2.2 版本,在使用 LruCache 时建议采用 support-v4 兼容包中提供的 LruCache,而不要直接使用 Android 3.1 提供的 LruCache。
LruCache 是一个泛型类,它内部采用一个 LinkedHashMap 以强引用的方式存储外界的缓存对象,其提供了 get 和 put 方法来完成缓存的获取和添加操作,当缓存满时,LruCache 会移除较早使用的缓存对象。这里大家要明白强引用、软引用和弱引用的区别:
LruCache 的定义如下:
public class LruCache {
private final LinkedHashMap map;
...
}
LruCache 的实现比较简单,这里就不在重复的造轮子了,稍后会给出几篇介绍的比较好的博客地址。下面给出 LruCache 典型的初始化过程:
int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
int cacheSize = maxMemory / 8;
mMemoryCache = new LruCache(cacheSize) {
@Override
protected int sizeOf(String key, Bitmap bitmap) {
return bitmap.getRowBytes() * bitmap.getHeight() / 1024;
}
};
在上面的代码中,只需要提供缓存的总容量大小并重写 sizeOf 方法即可。sizeOf 方法的作用是计算缓存对象的大小,这里大小的单位需要和总容量的单位一致。对于上面额示例代码来说,总容量的大小为当前进程的可用内存的 1/6,单位为 KB,而 sizeOf 方法则完成了 Bitmap 对象的大小计算。很明显,之所以除以 1024 也是为了将其单位转换为 KB,一些特殊情况下,还需要重写 LruCache 的 entryRemoved 方法,LruCache 移除旧缓存时会调用 entryRemoved 方法,因此可以在 entryRemoved 中完成一些资源回收工作(如果需要的话)。
更多关于 LruCache 和 LinkedHashMap 的文章请点击下方链接:
DiskLruCache 用于实现存储设备缓存,即磁盘缓存,它通过将缓存对象写入文件系统从而实现缓存的效果。DiskLruCache 得到了 Android 官方文档的推荐,但它不属于 Android SDK 的一部分,它的源码可以从如下网址得到:
官方地址:
https://android.googlesource.com.platform/libcore/+/android-4.1.1_rl/luni/src/main/java/libcore/io/DiskLruCache.java
gitHub地址:
https://github.com/JakeWharton/DiskLruCache
需要注意的是,从上述网址获取的 DiskLruCache 的源码并不能直接在 Android 中使用,需要稍微修改编译错误。下面分别从 DiskLruCache 的创建、缓存查找和缓存添加这三个方面来介绍 DiskLruCache 的使用方式。
DiskLruCache 并不能通过构造方法来创建,它提供了 open 方法用于创建自身,如下所示:
public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize)
open 方法有四个参数,其中第一个参数表示磁盘缓存在文件中的存储路径。缓存路径可以选择 SD 卡上的缓存目录,具体是指 /sdcard/Android/data/package_name/cache 目录,其中 package_name 表示当前应用的包名,当应用被卸载后,此目录会一并被删除。当然也可以选择 SD 卡上的其他指定目录,还可以选择 data 下的当前应用的目录,具体可根据需要灵活设定。这里给出一个建议:如果应用卸载后就希望删除缓存文件,那么就选择 SD 卡上的缓存目录,如果希望保留缓存数据那就应该选择 SD 卡上的其他特定目录。
第二个参数表示应用的版本号,一般设为 1 即可。当版本号发生改变时 DiskLruCache 会清空之前所有的缓存文件,而这个特性在实际开发中作用并不大,很多情况下即使应用的版本号发生了改变缓存文件却仍然是有效的,因此这个参数设为 1 比较好。
第三个参数表示单个节点所对应的数据的个数,一般设为 1 即可。
第四个参数表示缓存的总大小,比如 50MB,当缓存大小超出这个设定值后,DiskLruCache 会清除一些缓存从而保证总大小不大于这个设定值。
下面是一个典型的 DiskLruCache 的创建过程:
DISK_CACHE_SIZE = 1024 * 1024 * 50;
File diskCacheDir = getDiskCacheDir(this, "bitmap");
if (!diskCacheDir.exists()) {
diskCacheDir.mkdirs();
}
mDiskLruCache = DiskLruCache.open(diskCacheDir, 1, 1, DISK_CACHE_SIZE);
private File getDiskCacheDir(Context context, String dir) {
File cacheDir = context.getExternalCacheDir();
return new File(cacheDir, dir);
}
DiskLruCache 的缓存添加的操作是通过 Editor 完成的,Editor 表示一个缓存对象的编辑对象。这里仍然以图片缓存举例,首先需要获取图片 url 所对应的 key,然后根据 key 就可以通过 edit() 来获取 Editor 对象,如果这个缓存正在被编辑,那么 edit() 会返回 null,即 DiskLruCache 不允许同时编辑一个缓存对象。之所以要把 url 转换成 key,是因为图片的 url 中很可能有特殊字符,这将影响 url 在 Android 中直接使用,一般采用 url 的 MD5 值作为 key,如下所示:
private String hashKeyFormUrl(String url) {
String cacheKey = url;
try {
final MessageDigest mDigest = MessageDigest.getInstance("MD5");
mDigest.update(url.getBytes());
cacheKey = bytesToHexString(mDigest.digest());
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
}
return cacheKey;
}
private String bytesToHexString(byte[] bytes) {
StringBuilder sb = new StringBuilder();
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();
}
将图片的 url 转成 key 以后,就可以获取 Editor 对象了。对于这个 key 来说,如果当前不存在其他 Editor 对象,那么 edit() 就会返回一个新的 Editor 对象,通过它就可以得到一个文件输出流。需要注意的是,由于前面在 DiskLruCache 的 open 方法中设置了一个节点只能有一个数据,因此下面的 DISK_CACHE_INDEX 常量直接设为 0 即可,如下所示:
String key = hashKeyFormUrl(url);
DiskLruCache.Editor editor = mDiskLruCache.edit(key);
if (editor != null) {
OutputStream outputStream = editor.newOutputStream(DISK_CACHE_INDEX);
}
有了文件输出流,接下来要怎么做呢?其实是这样的,当从网络下载图片时,图片就可以通过这个文件输出流写入到文件系统上,这个时候的实现如下所示:
public 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();
in = new BufferedInputStream(urlConnection.getInputStream(), IO_BUFFER_SIZE);
out = new BufferedOutputStream(urlConnection.getOutputStream(), IO_BUFFER_SIZE);
int b = 0;
while ((b = in.read()) != -1) {
out.write(b);
}
return true;
} catch (IOException e) {
e.printStackTrace();
} finally {
if (urlConnection != null) {
urlConnection.disconnect();
}
if (in != null) {
try {
in.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (out != null) {
try {
out.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return false;
}
经过上面的步骤,其实并没有真正地将图片写入文件系统,还必须通过 Editor 的 commit 来提交写入操作,如果图片下载过程发生了异常,那么还可以通过 Editor 的 abort() 来回退整个操作,这个过程如下所示:
if (editor != null) {
OutputStream outputStream = editor.newOutputStream(DISK_CACHE_INDEX);
if (downloadUrlToStream(url, outputStream)) {
editor.commit();
} else {
editor.abort();
}
}
mDiskLruCache.flush();
经过上面的几个步骤,图片已经被正确地写入到文件系统了,接下来图片获取的操作就不需要请求网络了。
和缓存的添加过程类似,缓存查找过程也需要将 url 转换为 key,然后通过 DiskLruCache 的 get 方法得到一个 Snapshot 对象,接着再通过 Snapshot 对象即可得到缓存的文件输入流,有了文件输入流,自然就可以得到 Bitmap 对象了。为了避免加载图片过程中导致的 OOM 问题,一般不建议直接加载原始图片。在前面我们已经介绍了通过 BitmapFactory.Options 对象来加载一张缩放后的图片,但是那种方法对 FileInputStream 的缩放存在问题,原因是 FileInputStream 是一种有序的文件流,而两次 decodeStream 调用影响了文件流的位置属性,导致了第二次 decodeStream 时得到的是 null。为了解决这个问题。可以通过文件流来得到它所对应的文件描述符,然后在通过 BitmapFactory.decodeFileDescriptor 方法来加载一张缩放后的图片,这个过程的实现如下所:
Bitmap bitmap = null;
String key = hashKeyFormUrl(url);
DiskLruCache.Snapshot snapShot = null;
try {
snapShot = mDiskLruCache.get(key);
if (snapShot != null) {
FileInputStream fileInputStream = (FileInputStream) snapShot.getInputStream(DISK_CACHE_INDEX);
FileDescriptor fileDescriptor = fileInputStream.getFD();
bitmap = decodeSampledBitmapFromFileDescriptor(fileDescriptor, reqWidth, reqHieght);
return bitmap;
}
} catch (IOException e) {
e.printStackTrace();
}
return null;
private Bitmap decodeSampledBitmapFromFileDescriptor(FileDescriptor fileDescriptor, int reqWidth, int reqHieght) {
final BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeFileDescriptor(fileDescriptor, null, options);
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHieght);
options.inJustDecodeBounds = false;
return BitmapFactory.decodeFileDescriptor(fileDescriptor, null, options);
}
由于篇幅的问题,本篇暂时就介绍到这里,下篇我们将真正的去运用 LruCache 和 DiskLruCache 去实现一个简化版的 ImageLoader