一、 Bitmap 的高效加载
Bitmap 在 Android 中指的是一张图片,可以是 png 格式也可以是 jpg 格式的图片,在Android 中,BitmapFactory 类提供了四类方法:
- decodeFile
- decodeResource
- decodeStream
- decodeByteArray
来用于从文件系统、资源、输入流以及字节数组中加载出一个 Bitmap 对象,其中 decodeFile 和 decodeResource 又间接调用了 decodeStream 方法,这四类方法最终是在 Android 底层实现的,对应着 BitmapFractory 类的几个 native 方法。
高效的加载 Bitmap 核心思想就是采用 BitmapFactory.Options 来加载所需要的尺寸的图片。通过 BitmapFactory.Options 来缩放图片,主要用到了它的 inSampleSize 参数,即采样率。当 inSampleSize 为 1 时,采样后的图片大小为图片的原始大小,当 inSampleSize 大于 1 时,比如2,那么采样后的图片其宽和高均为原始大小的 1/2,即像素数为原来的 1/4 。依次类推。
通过采样率来加载图片,遵循如下步骤:
- 将 BitmapFactory.Options 的 inJustDecodeBounds 参数设置为 true 并加载图片。
- 从 BitmapFactory.Options 中取出图片的原始宽高,它们分别对应 outWidth 和 outHeight 参数。
- 根据采样率的规则并结合目标 View 所需大学计算采样率 inSampleSize。
- 将 BitmapFacoty.Options 的 inJustDecodeBounds 参数设置为 false,然后重新加载图片。
将上面的流程用代码表示,如下:
public static Bitmap decodeSampledBitmapFromResource(Resources res, int resId, int reqWidth, int rewHeight){
final BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(res,resId,options);
options.inSampleSize = calculateInSampleSize(options, reqWidth, rewHeight);
options.inJustDecodeBounds = false;
return BitmapFactory.decodeResource(res,resId,options);
}
public static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int rewHeight){
final int height = options.outHeight;
final int width = options.outWidth;
int inSampleSize = 1;
if (height > rewHeight || width > reqWidth) {
final int halfHeight = height/2;
final int haleWidth = width/2;
while ((halfHeight / inSampleSize) >= rewHeight
&& (haleWidth/inSampleSize) >= reqWidth){
inSampleSize *= 2;
}
}
return inSampleSize;
}
有了上面两个方法,实际使用的时候就非常简单了,比如 ImageView 所期望的图片大小为 100 * 100,可以通过下面的代码来加载:
mImageView.setImageBitmap(
decodeSampledBitmapFromResource(
getResources(),R.mipmap.ic_launcher,100,100));
二、Android 中的缓存策略
缓存策略在Android中有着广泛的应用场景,尤其在图片加载这个场景下,缓存策略就变得更为重要。考虑一种场景:有一批网络图片,需要下载后再用户界面上予以显示,这个场景再PC环境下是很简单的,直接把所有的图片下载到本地再显示即可,但是放到移动设备上就不一样了。不管是Android还是IOS设备,流量对于客户来说都是一种宝贵的资源,由于流量是收费的,所以在应用开发中并不能过多地消耗用户的流量,否则这个应用肯定不能被用户所接受。再加上目前国内公共场所的wifi的普及率并不算高,因此用户在很多情况下手机上都是用的移动网络而非wifi,因此必须提供一种解决方案来解决流量的消耗问题。
如何避免多多的流量消耗呢?那就是本节所要讨论的主题:缓存。当程序第一次网络加载图片后,就将其缓存到存储设备上,这样下次使用这张图片就不用再从网络上获取了,这样就为用户节省了流量。很多时候为了提高应用的用户体验,往往还会把图片放在内存中再缓存一份,这样当应用打算从网络上请求一张图片时,程序会首先从内存中去获取,如果内存中没有那就从存储设备中去获取。如果存储设备中也没有,那就从网络上下载这张图片。因为从内存中加载图片比从存储设备中加载图片要快,所以这样及提高了程序的效率又为用户节约了不必要的流量开销。上述的缓存策略不仅仅适用于图片,也适用于其他文件类型。
说到缓存策略,其实并没有统一的标准。一般来说,缓存策略主要包含缓存的添加、获取和删除这三类操作。如何添加和获取这个比较好理解,那为什么还要删除缓存呢?这是因为不管时内存缓存还是存储设备缓存,它们的缓存大小都是有限制的,因为内存和诸如SD卡之类的存储设备都是有容量限制的,因此再使用缓存时总是要为缓存指定一个最大的容量。如果当缓存容量满了,但是程序还需要向其添加缓存,这个时候该怎么办?这就需要删除一些旧的缓存并添加新的缓存,如何定义缓存的新旧这就是一种策略,不同的策略就对应着不同的缓存算法,比如可以简单地根据文件的最后修改时间来定义缓存的新旧,当缓存满时就将最后修改时间较早的缓存移除,这就是一种缓存算法,但是这种算法并不算很完美。
目前最常用的一种缓存算法是LRU,LRU是近期最少使用算法,它的核心思想是当缓存满时,会优先淘汰哪些近期最少使用的缓存对象。采样LRU算法的缓存有两种:LruCache和DiskLruCache,LruCache用于实现内存缓存,而DiskLruCache则充当了存储设备缓存,通过这二者的完美结合,就可以很方便地实现一个具有很高使用价值地ImageLoader。本节首先会介绍LruCache和DiskLruCache,然后利用LruCache和DiskLruCache来实现一个优秀地ImageLoader,并且提供一个使用ImageLoader来从网络下载并展示图片的例子,在这个例子种体现了ImageLoader以及大批量网络图片加载所设计的大量技术点。
2.1 LruCache
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会移除较早使用的缓存对象,然后再添加新的缓存对象。这里要明白强引用、软引用和弱引用的区别,如下所示。
- 强引用:直接的对象引用。
- 软引用:当一个对象只有软引用存在时,系统内存不足时此对象会被gc回收。
- 弱引用:当一个对象只有软引用存在时,此对象会随时被gc回收。
另外LruCache是线程安全的。LruCache的实现比较简单,读者可以参考它的源码,这里仅介绍如何使用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/8,单位为KB,而sizeOf方法则完成了Bitmap对象的大小计算。很明显,之所以除以1024也是为了将其单位转换为KB。一些特殊情况下,还需要重写LruCache的entryRemoved方法,LruCache移除就缓存时会调用entryRemoved方法,因此可以在entryRemoved种完成一些资源回收工作。
除了LruCache的创建外,还有缓存的获取和添加,这也很简单,从LruCache获取一个缓存对象,如下所示。
mMemoryCache.get(key);
向LruCache中添加一个缓存对象,如下所示:
mMemoryCache.put(key,bitmap);
LruCache还支持删除操作,通过remove方法即可删除一个指定的缓存对象。可以看到LruCache的实现以及使用都非常简单,虽然简单,但是仍不影响它具有强大的功能。
2.2 DiskLruCache
uCache还支持删除操作,通过remove方法即可删除一个指定的缓存对象。可以看到LruCache的实现以及使用都非常简单,虽然简单,但是仍不影响它具有强大的功能。
DiskLruCache 的创建
DiskLruCache并不能通过构造方法来创建,它提供了open方法用于创建自身,如下所示:
public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize)
throws IOException
open方法有4个参数,其中第一个参数表示磁盘缓存在文件系统中的存储路径。缓存路径可以选择SD卡上的缓存目录,具体是指/sdcard/Android/data/package_name/cache目录,其中package_name表示当前应用的包名,当应用被卸载后,此目录一并被删除。当然也可以选择SD卡上的其他指定目录,还可以选择data下的当前应用的目录,具体可以根据需要灵活设定。这里给出一个建议:如果应该卸载后就希望删除缓存文件,那么就选择SD卡上的缓存目录,如果希望保留缓存数据,那就应该选择SD卡上的其他特定目录。
第二个参数表示应用的版本号,一般设为1即可。当版本号发送改变时DiskLruCache会情况之前所有的缓存文件,而这个特性在实际开发中作用不大,很多情况下即使应用的版本号发送了改变缓存文件却仍然是有效的,因此这个参数设为1比较好。
第三个参数表示单个节点所对应的数据的个数,一般设为1即可。第4个参数表示缓存的总大小,比如50MB,当缓存大小超出这个设定值后,DiskLruCache会清楚一些缓存从而保证大小不大于这个设定值。下面是一个典型的DiskLruCache的创建过程:
File diskCacheDir = getDiskCacheDir(mContext, "bitmap");
if (!diskCacheDir.exists()){
diskCacheDir.mkdirs();
}
try {
mDiskLruCache = DiskLruCache.open(diskCacheDir, 1, 1, DISK_CACHE_SIZE);
} catch (IOException e) {
e.printStackTrace();
}
DiskLruCache 的 缓存添加
DiskLruCache的缓存添加的操作是通过Editor完成的,Editor表示一个缓存对象的编辑对象。这里仍然以图片缓存举例,首先火药获取url所对应的key,然后更具key就可以通过edit()来获取Editor对象,如果这个缓存正在被编辑,那么edit()会返回null,即DiskLruCache不允许同时编辑一个缓存对象。之所以要把url转换成key,是因为图片url中很有可能存在特殊字符,这将影响url在Android中直接使用,一般采用url的md5作为key。如下所示:
private String hashKeyFromUrl(String url){
String cacheKey;
try {
final MessageDigest digest = MessageDigest.getInstance("MD5");
digest.update(url.getBytes());
cacheKey = bytesToHexString(digest.digest());
} catch (NoSuchAlgorithmException e) {
cacheKey = String.valueOf(url.hashCode());
}
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 = hashKeyFromUrl(url);
try {
DiskLruCache.Editor editor = mDiskLruCache.edit(key);
if (editor != null) {
OutputStream outputStream = editor.newOutputStream(DISK_CACHE_INDEX);
}
} catch (IOException e) {
e.printStackTrace();
}
有了文件输出流,接下来要怎么做呢?其实是这也的,当从网络下载图片时,图片即可以通过这个文件输出流写入到文件系统上,这个过程的实现如下所示:
private boolean downloadUrlToStream(String urlString, OutputStream outputStream){
HttpURLConnection urlConnection = null;
BufferedInputStream in = null;
BufferedOutputStream out = null;
try {
final URL url = new URL(urlString);
urlConnection = (HttpURLConnection)url.openConnection();
in = new BufferedInputStream(urlConnection.getInputStream());
out = new BufferedOutputStream(outputStream, IO_BUFFER_SIZE);
int b;
while ((b = in.read())!=-1){
out.write(b);
}
return true;
} catch (MalformedURLException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} finally {
if (urlConnection != null){
urlConnection.disconnect();
}
MyUtils.close(out);
MyUtils.close(in);
}
return false;
}
经过上面的步骤,其实并没有真正的将图片写入文件系统,还必须通过Editor的commit()来提交写入操作,如果图片下载过程发送了异常,那么还可以通过Editor的abort()来回退整个操作,这个过程如下所示:
OutputStream outputStream = editor.newOutputStream(DISK_CACHE_INDEX);
if (downloadUrlToStream(url, outputStream)){
editor.commit();
} else {
editor.abort();
}
mDiskLruCache.flush();
经过上面的几个步骤,图片以及被正确地写入到文件系统了,接下来图片获得的操作就需要请求网络了。
DiskLruCache 的 缓存查找
和缓存添加过程类似,缓存查找也需要将url转换为key,然后通过DiskLruCache的get方法得到一个Snapshot对象,接着再通过Snapshot对象即可得到缓存文件的输出流,有了文件输出流,自然就可以得到Bitmap对象了。为了避免加载过程中导致的OOM问题,一般不建议直接加载原始图片。再1节中已经介绍了通过BitmapFactory.Options对象来加载一张缩放后的图片,但是那种方法对FileInputStream的缩放存在问题,原因是FileInputStream是一种有序的文件流,而两次decodeStream调用影响了文件流的位置属性,导致了第二次decodeStream时得到的是null。为了解决这个问题,可以通过文件流来得到它所对应的文件描述符,然后再通过BitmapFactory.decodeFileDescriptor方法来加载一张缩放后的图片,这个过程实现如下所示:
Bitmap bitmap = null;
String key = hashKeyFromUrl(url);
try {
DiskLruCache.Snapshot snapshot = mDiskLruCache.get(key);
if (snapshot != null) {
FileInputStream fileInputStream = (FileInputStream)snapshot.getInputStream(DISK_CACHE_INDEX);
FileDescriptor descriptor = fileInputStream.getFD();
bitmap = BitmapUtil.decodeSampledBitmapFromFileDescriptor(descriptor,reqWidth,rewHeight);
if (bitmap != null){
addBitmapToMemoryCache(key,bitmap);
}
}
} catch (IOException e) {
e.printStackTrace();
}
return bitmap;
上面介绍了DiskLruCache的创建、缓存的添加过和查找过程,读者应该对DiskLruCache的使用方式有了一个大致的了解,除此之外,DiskLruCache还提供了remove、delete等方法用于磁盘缓存的删除操作。
2.3 ImageLoader 的实现
的前面先后介绍了Bitmap的高效加载方式、LruCache以及DiskLruCache,现在我们来着手实现一个优秀的ImageLoader。
一般来说,一个优秀的ImageLoader应该具备如下功能:
- 图片的同步加载
- 图片的异步加载
- 图片压缩
- 内存缓存
- 磁盘缓存
- 网络拉取。
图片的同步加载是指能够以同步的方式向调用者提供锁甲在的图片,这个图片可能是从内存中读取的,也可能是从磁盘缓存中读取的,还可能使从网络拉取的。图片的异步加载时一个很有用的功能,很多时候调用者不想再单独的线程中已同步的方式来获取图片,这个时候ImageLoader内部需要自己在线程中加载图片并将图片设置给所需的ImageView。图片压缩的作用更毋庸置疑了,这是降低OOM概率的有效手段,ImageLoader必须合适地处理图片的压缩问题。
内存缓存和磁盘缓存时ImageLoader的核心,也是ImageLoader的意义所在,通过这两集缓存极大地提高了程序的效率并且有效地降低了对用户造成地流量消耗,只有当这两级缓存都不可以时才需要从网络中拉取图片。
除此之外,ImageLoader还需要处理一些特殊情况,比如在ListView或者GridView中,View复用既是它们的有点也是它们的缺点,有点想必应该都清楚了,那缺点可能还不太清楚。考虑一种情况,在ListView或者GridView中,假设一个item A 正在从网络加载图片,它对应的ImageView为A,这个时候用户快速地向下滑动列表,很可能item B复用了ImageView A,然后等了一会之前的图片下载完毕了。 如果直接给ImageView A设置图片,由于这个时候ImageView A被item B所复用,但是item B显然不是item A刚刚下载好的图片,这个时候会出现B中显示了A的图片,这就是常见的列表的错位问题,ImageLoader需要正确地处理这些特殊情况。
上面对ImageLoader的功能做了一个全面的分析,下面就可以一步步实现ImageLoader了,这里主要分为如下几步。
图片压缩功能的实现
图片压缩在前面已经做了介绍,这里就不再多说了,为了有良好的设计风格,这里单独抽象了一个类用于完成图片的压缩功功能,这个类叫ImageResizer,它的实现如下所示:
public class ImageResizer {
public ImageResizer() {
}
public Bitmap decodeSampledBitmapFromFileDescriptor(FileDescriptor descriptor, int reqWidth, int rewHeight ){
final BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeFileDescriptor(descriptor, null,options);
options.inSampleSize = calculateInSampleSize(options, reqWidth, rewHeight);
options.inJustDecodeBounds = false;
return BitmapFactory.decodeFileDescriptor(descriptor, null,options);
}
public Bitmap decodeSampledBitmapFromResource(Resources res, int resId, int reqWidth, int rewHeight){
final BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(res,resId,options);
options.inSampleSize = calculateInSampleSize(options, reqWidth, rewHeight);
options.inJustDecodeBounds = false;
return BitmapFactory.decodeResource(res,resId,options);
}
public int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int rewHeight){
if (reqWidth == 0 || rewHeight == 0) {
return 1;
}
final int height = options.outHeight;
final int width = options.outWidth;
int inSampleSize = 1;
if (height > rewHeight || width > reqWidth) {
final int halfHeight = height/2;
final int haleWidth = width/2;
while ((halfHeight / inSampleSize) >= rewHeight
&& (haleWidth/inSampleSize) >= reqWidth){
inSampleSize *= 2;
}
}
return inSampleSize;
}
}
内存缓存和磁盘缓存的实现
这里选择LruCache和DiskLruCache来分贝完成内存缓存和磁盘缓存的工作。在ImageLoader初始化时,会创建LruCache和DiskLruCache,如下所示:
private LruCache mMemoryCache;
private DiskLruCache mDiskLruCache;
public ImageLoader(Context context) {
mContext = context.getApplicationContext();
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;
}
};
File diskCacheDir = getDiskCacheDir(mContext, "bitmap");
if (!diskCacheDir.exists()){
diskCacheDir.mkdirs();
}
if (getUsableSpace(diskCacheDir) > DISK_CACHE_SIZE) {
try {
mDiskLruCache = DiskLruCache.open(diskCacheDir, 1, 1, DISK_CACHE_SIZE);
mIsDiskLruCacheCreated = true;
} catch (IOException e) {
e.printStackTrace();
}
}
}
在创建磁盘缓存时,这里做了判断,即有可能磁盘剩余空间小于磁盘缓存所需的大小,一般是指用户的手机空间已经不足了,因此没有办法创建磁盘缓存,这个时候磁盘缓存就会失效。在上面的海马实现中,ImageLoader的内存缓存容量为当前进程可用内存的1/8,磁盘缓存的容量是50MB。
内存缓存和磁盘缓存创建完毕后,还需要提高方法来完成缓存的添加和获取功能。首先看内存缓存,它的添加和读取过程比较简单,如下所示:
private Bitmap getBitmapFromMemoryCache(String key){
return mMemoryCache.get(key);
}
private void addBitmapToMemoryCache(String key, Bitmap bitmap){
if (getBitmapFromMemoryCache(key) == null) {
mMemoryCache.put(key, bitmap);
}
}
而磁盘缓存和读取功能稍微复杂一些,具体内容已经在2.2节中进行了详细的介绍,这里再简单说明一下。磁盘缓存的添加需要通过Editor来完成,Editor提高了commit和abort方法来提交和撤销对文件系统的写操作,具体实现请参看下面的loadBitmapFromHttp方法。磁盘缓存的读取需要通过Snapshot来完成,通过Snapshot可以得到磁盘缓存对象对应的FileInputStream,但是FileInputStream无法边界的进行压缩,所以通过FileDescriptor来加载压缩后的图片,最后将加载后的Bitmap添加到内存中,具体实现请参考下面的loadBitmapFromDiskCache方法:
private Bitmap loadBitmapFromHttp(String url,int reqWidth, int rewHeight)throws IOException{
if (Looper.myLooper() == Looper.getMainLooper()){
throw new RuntimeException("can not visit network from UI thread.");
}
if (mDiskLruCache == null){
return null;
}
String key = hashKeyFromUrl(url);
DiskLruCache.Editor editor = mDiskLruCache.edit(key);
if (editor != null) {
OutputStream outputStream = editor.newOutputStream(DISK_CACHE_INDEX);
if (downloadUrlToStream(url, outputStream)){
editor.commit();
} else {
editor.abort();
}
mDiskLruCache.flush();
}
return loadBitmapFromDiskCache(url, reqWidth, rewHeight);
}
private Bitmap loadBitmapFromDiskCache(String url,int reqWidth, int rewHeight) throws IOException{
if (Looper.myLooper() == Looper.getMainLooper()){
throw new RuntimeException("can not visit network from UI thread.");
}
if (mDiskLruCache == null){
return null;
}
Bitmap bitmap = null;
String key = hashKeyFromUrl(url);
DiskLruCache.Snapshot snapshot = mDiskLruCache.get(key);
if (snapshot != null) {
FileInputStream fileInputStream = (FileInputStream)snapshot.getInputStream(DISK_CACHE_INDEX);
FileDescriptor descriptor = fileInputStream.getFD();
bitmap = mImageResizer.decodeSampledBitmapFromFileDescriptor(descriptor,reqWidth,rewHeight);
if (bitmap != null){
addBitmapToMemoryCache(key,bitmap);
}
}
return bitmap;
}
同步和异步加载接口的设计
首先看同步加载,同步加载接口需要外部在线程中调用,这是因为同步很可能比较耗时,它的实现如下所示:
public Bitmap loadBitmap(String url,int reqWidth, int rewHeight){
Bitmap bitmap = loadBitmapFromMemoryCache(url);
if (bitmap != null) {
return bitmap;
}
try {
bitmap = loadBitmapFromDiskCache(url,reqWidth,rewHeight);
if (bitmap != null) {
return bitmap;
}
bitmap = loadBitmapFromHttp(url,reqWidth,rewHeight);
} catch (IOException e) {
e.printStackTrace();
}
if (bitmap == null && !mIsDiskLruCacheCreated) {
bitmap = downloadFromUrl(url);
}
return bitmap;
}
从loadBitmap的实现可以看出,其工作过程遵循如下几步:首先尝试从内存中读取图片,接着尝试从磁盘缓存中读取图片,最后才从网络中拉取图片。另外,这个方法不能在主线程中调用,否则就会抛出异常。这个执行换下的检查时在loadBitmapFromHttp中实现的,通过检测当前线程的Looper是否为主线的Looper来判断当前线程是否是主线程,如果不是主线程就直接抛出异常终止程序,如下所示:
if (Looper.myLooper() == Looper.getMainLooper()){
throw new RuntimeException("can not visit network from UI thread.");
}
接着看异步加载接口的设计,如下所示:
public void bindBitmap(final String uri, final ImageView imageView,
final int reqWidth, final int reqHeight){
imageView.setTag(TAG_KEY_URI, uri);
final Bitmap bitmap = loadBitmapFromMemoryCache(uri);
if (bitmap != null) {
imageView.setImageBitmap(bitmap);
return;
}
final Runnable loadBitmapTask = new Runnable() {
@Override
public void run() {
Bitmap bitmap1 = loadBitmap(uri,reqWidth,reqHeight);
if (bitmap1 != null) {
LoaderResult result = new LoaderResult(imageView, uri, bitmap);
mMainHandler.obtainMessage(MESSAGE_POST_RESULT, result).sendToTarget();
}
}
};
THREAD_POOL_EXECUTOR.execute(loadBitmapTask);
}
loadBitmap方法,当图片加载成功后再将图片、图片地址已经需要绑定的ImageView封装成一个LoadResult对象,然后再通过mMainHandler向主线程发送一个消息,这也就可以在主线程中给ImageView设置图片了,之所以通过Handler来中专是因为子线程无法访问UI。
bindBitmap中用到了线程池和Handler,这里看一下它们的实现,首先看线程池THREAD_POOL_ECECUTOR的实现,如下所示。可以看出它的核心线程数为当前设备的CPU核心数+1,最大容量为CPU核心数的2倍加1,线程闲置超时时长为10秒。
private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors();
private static final int CORE_POOL_SIZE = CPU_COUNT + 1;
private static final int MAX_POOL_SIZE = CPU_COUNT*2 + 1;
private static final long KEEP_ALIVE = 10L;
private static final ThreadFactory sThreadFactory = new ThreadFactory() {
private final AtomicInteger mCount = new AtomicInteger(1);
@Override
public Thread newThread(@NonNull Runnable r) {
return new Thread(r, "ImageLoader#" + mCount.getAndIncrement());
}
};
public static final Executor THREAD_POOL_EXECUTOR =
new ThreadPoolExecutor(CORE_POOL_SIZE,MAX_POOL_SIZE,KEEP_ALIVE, TimeUnit.SECONDS,
new LinkedBlockingDeque(), sThreadFactory);
由于 AsyncTask 在3.0的低版本和高版本上具有不同的表现,在3.0以上的版本 AsyncTask 无法实现并发效果,这显然是不能接受的,因为ImageLoader需要并发性,虽然可以通过改造 AsyncTask 或者使用AsyncTask 的 executeExecutor 方式的形式来执行异步任务,但是这最终不是太自然的实现方式。鉴于以上两点原因,这里选择线程池和 Handler 来提高 ImageLoader 的并发能力和访问 UI 的能力。
分析完线程的选择,下面看一下 Handler 的实现,如下所示。ImageLoader 直接采用主线程的 Looper 来构造Handler对象,这就使得ImageLoader 可以在非主线程中构造了。另外为了解决由于 View 复用所导致的列表错位的这一问题,在给 ImageView 设置图片之前都会检查它的 url 有没有发生改变,如果发送改变就不再给他设置图片,这样就解决了列表的错位问题。
private Handler mMainHandler = new Handler(Looper.getMainLooper()){
@Override
public void handleMessage(Message msg) {
LoaderResult result = (LoaderResult) msg.obj;
ImageView imageView = result.imageView;
String uri = (String) imageView.getTag();
if (uri.equals(result.uri)){
imageView.setImageBitmap(result.bitmap);
} else {
Log.d(TAG, "set image bitmap, but uri has changed,ignored!");
}
}
};
ImageLoader 完整代码:
public class ImageLoader {
private static final String TAG= "ImageLoader";
private static final int MESSAGE_POST_RESULT = 1;
private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors();
private static final int CORE_POOL_SIZE = CPU_COUNT + 1;
private static final int MAX_POOL_SIZE = CPU_COUNT*2 + 1;
private static final long KEEP_ALIVE = 10L;
private static final int IO_BUFFER_SIZE = 8 * 1024;
private static final int TAG_KEY_URI = R.id.imageloader_url;
private static final long DISK_CACHE_SIZE = 1024 * 1024 *50;
private static final int DISK_CACHE_INDEX = 0;
private boolean mIsDiskLruCacheCreated = false;
private static final ThreadFactory sThreadFactory = new ThreadFactory() {
private final AtomicInteger mCount = new AtomicInteger(1);
@Override
public Thread newThread(@NonNull Runnable r) {
return new Thread(r, "ImageLoader#" + mCount.getAndIncrement());
}
};
public static final Executor THREAD_POOL_EXECUTOR =
new ThreadPoolExecutor(CORE_POOL_SIZE,MAX_POOL_SIZE,KEEP_ALIVE, TimeUnit.SECONDS,
new LinkedBlockingDeque(), sThreadFactory);
private Handler mMainHandler = new Handler(Looper.getMainLooper()){
@Override
public void handleMessage(Message msg) {
LoaderResult result = (LoaderResult) msg.obj;
ImageView imageView = result.imageView;
String uri = (String) imageView.getTag();
if (uri.equals(result.uri)){
imageView.setImageBitmap(result.bitmap);
} else {
Log.d(TAG, "set image bitmap, but uri has changed,ignored!");
}
}
};
private Context mContext;
private LruCache mMemoryCache;
private DiskLruCache mDiskLruCache;
private ImageResizer mImageResizer;
private ImageLoader(Context context) {
mImageResizer = new ImageResizer();
mContext = context.getApplicationContext();
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;
}
};
File diskCacheDir = getDiskCacheDir(mContext, "bitmap");
if (!diskCacheDir.exists()){
diskCacheDir.mkdirs();
}
if (getUsableSpace(diskCacheDir) > DISK_CACHE_SIZE) {
try {
mDiskLruCache = DiskLruCache.open(diskCacheDir, 1, 1, DISK_CACHE_SIZE);
mIsDiskLruCacheCreated = true;
} catch (IOException e) {
e.printStackTrace();
}
}
}
public static ImageLoader build(Context context){
return new ImageLoader(context);
}
private Bitmap getBitmapFromMemoryCache(String key){
return mMemoryCache.get(key);
}
private void addBitmapToMemoryCache(String key, Bitmap bitmap){
if (getBitmapFromMemoryCache(key) == null) {
mMemoryCache.put(key, bitmap);
}
}
public void bindBitmap(final String uri, final ImageView imageView){
bindBitmap(uri,imageView,0,0);
}
public void bindBitmap(final String uri, final ImageView imageView,
final int reqWidth, final int reqHeight){
imageView.setTag(TAG_KEY_URI, uri);
final Bitmap bitmap = loadBitmapFromMemoryCache(uri);
if (bitmap != null) {
imageView.setImageBitmap(bitmap);
return;
}
final Runnable loadBitmapTask = new Runnable() {
@Override
public void run() {
Bitmap bitmap1 = loadBitmap(uri,reqWidth,reqHeight);
if (bitmap1 != null) {
LoaderResult result = new LoaderResult(imageView, uri, bitmap);
mMainHandler.obtainMessage(MESSAGE_POST_RESULT, result).sendToTarget();
}
}
};
THREAD_POOL_EXECUTOR.execute(loadBitmapTask);
}
public Bitmap loadBitmap(String url,int reqWidth, int rewHeight){
Bitmap bitmap = loadBitmapFromMemoryCache(url);
if (bitmap != null) {
return bitmap;
}
try {
bitmap = loadBitmapFromDiskCache(url,reqWidth,rewHeight);
if (bitmap != null) {
return bitmap;
}
bitmap = loadBitmapFromHttp(url,reqWidth,rewHeight);
} catch (IOException e) {
e.printStackTrace();
}
if (bitmap == null && !mIsDiskLruCacheCreated) {
bitmap = downloadFromUrl(url);
}
return bitmap;
}
private Bitmap loadBitmapFromMemoryCache(String url){
final String key = hashKeyFromUrl(url);
return getBitmapFromMemoryCache(key);
}
private Bitmap loadBitmapFromHttp(String url,int reqWidth, int rewHeight)throws IOException{
if (Looper.myLooper() == Looper.getMainLooper()){
throw new RuntimeException("can not visit network from UI thread.");
}
if (mDiskLruCache == null){
return null;
}
String key = hashKeyFromUrl(url);
DiskLruCache.Editor editor = mDiskLruCache.edit(key);
if (editor != null) {
OutputStream outputStream = editor.newOutputStream(DISK_CACHE_INDEX);
if (downloadUrlToStream(url, outputStream)){
editor.commit();
} else {
editor.abort();
}
mDiskLruCache.flush();
}
return loadBitmapFromDiskCache(url, reqWidth, rewHeight);
}
private Bitmap loadBitmapFromDiskCache(String url,int reqWidth, int rewHeight) throws IOException{
if (Looper.myLooper() == Looper.getMainLooper()){
throw new RuntimeException("can not visit network from UI thread.");
}
if (mDiskLruCache == null){
return null;
}
Bitmap bitmap = null;
String key = hashKeyFromUrl(url);
DiskLruCache.Snapshot snapshot = mDiskLruCache.get(key);
if (snapshot != null) {
FileInputStream fileInputStream = (FileInputStream)snapshot.getInputStream(DISK_CACHE_INDEX);
FileDescriptor descriptor = fileInputStream.getFD();
bitmap = mImageResizer.decodeSampledBitmapFromFileDescriptor(descriptor,reqWidth,rewHeight);
if (bitmap != null){
addBitmapToMemoryCache(key,bitmap);
}
}
return bitmap;
}
private boolean downloadUrlToStream(String urlString, OutputStream outputStream){
HttpURLConnection urlConnection = null;
BufferedInputStream in = null;
BufferedOutputStream out = null;
try {
final URL url = new URL(urlString);
urlConnection = (HttpURLConnection)url.openConnection();
in = new BufferedInputStream(urlConnection.getInputStream());
out = new BufferedOutputStream(outputStream, IO_BUFFER_SIZE);
int b;
while ((b = in.read())!=-1){
out.write(b);
}
return true;
} catch (MalformedURLException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} finally {
if (urlConnection != null){
urlConnection.disconnect();
}
MyUtils.close(out);
MyUtils.close(in);
}
return false;
}
private Bitmap downloadFromUrl(String urlString){
Bitmap bitmap = null;
HttpURLConnection urlConnection = null;
BufferedInputStream in = null;
try {
final URL url = new URL(urlString);
urlConnection = (HttpURLConnection)url.openConnection();
in = new BufferedInputStream(urlConnection.getInputStream());
bitmap = BitmapFactory.decodeStream(in);
} catch (MalformedURLException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} finally {
if (urlConnection != null) {
urlConnection.disconnect();
}
MyUtils.close(in);
}
return bitmap;
}
private String hashKeyFromUrl(String url){
String cacheKey;
try {
final MessageDigest digest = MessageDigest.getInstance("MD5");
digest.update(url.getBytes());
cacheKey = bytesToHexString(digest.digest());
} catch (NoSuchAlgorithmException e) {
cacheKey = String.valueOf(url.hashCode());
}
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();
}
private File getDiskCacheDir(Context context, String name) {
boolean externalStorageAvailable = Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED);
final String cachePath;
if (externalStorageAvailable){
cachePath = context.getExternalCacheDir().getPath();
} else {
cachePath = context.getCacheDir().getPath();
}
return new File(cachePath + File.separator + name);
}
@TargetApi(Build.VERSION_CODES.GINGERBREAD)
private long getUsableSpace(File path){
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.GINGERBREAD){
return path.getUsableSpace();
}
final StatFs stats = new StatFs(path.getPath());
return (long)stats.getBlockSize() * (long)stats.getAvailableBlocks();
}
private static class LoaderResult {
public ImageView imageView;
public String uri;
public Bitmap bitmap;
public LoaderResult(ImageView imageView, String uri, Bitmap bitmap) {
this.imageView = imageView;
this.uri = uri;
this.bitmap = bitmap;
}
}
}
三、 优化列表的卡顿现象
优化卡顿,主要就是不要在主线程中做太多的耗时的操作即可提高滑动的流畅度,可以从三个方面来说。
首先,不要在 getView 中执行耗时操作。对于上面的例子来说,如果直接在 getView中 加载图片,肯定会导致卡顿,加载图片是一个耗时的操作,这张操作必须通过异步的方式来处理,就像 ImageLoader 实现的那样。
其次,控制异步的任务执行频率。这一点也很重要,对于列表来说,仅仅再 getView 采用异步是不够的。考虑一种情况,以照片墙来说,在getView 方法中会通过 ImageLoader 的 bindBitmap 方法来异步加载图片,但是如果用户可以频繁上下滑动,这就会在一瞬间产生上百个异步任务,这些异步任务会造成线程池的拥堵并随即带来大量的UI更新操作,这是没有意义的。由于一瞬间存在大量的UI操作,这些UI操作是运行在主线程的,这就会造成一定的卡顿现象。如何解决这个问题呢?可以考虑在滑动的时候停止加载图片,尽管这个过程是异步的,等列表停下来以后再加载图片仍然可以获得良好的用户体验。具体实现时,可以给 ListView 或者 GridView 设置 setOnScrollListener,并在 setOnScrollListener 的 onScrollStateChanged 方法中判断列表是否处于滑动状态,如果是的话,就停止加载图片,如下所示:
public void onScrollStateChanged(AbsListView view, int scrollState) {
if (scrollState == AbsListView.OnScrollListener.SCROLL_STATE_IDLE){
mIsGridViewIdle = true;
ImageAdapter.this.notifyDataSetChanged();
} else {
mIsGridViewIdle = false;
}
}
然后在getView方法中,仅当列表静止时才能加载图片,如下所示:
if (mIsGridViewIdle){
imageView.setTag(url);
mImageLoader.bindBitmap(url,imageView,mImageWidth, mImageWidth);
}
一般来说经过上面两个步骤,列表都不会有卡顿现象,但是在某些特殊情况下,列表还是会有偶尔卡顿线程,这个时候还可以开启硬件加速。绝大多说情况下,硬件加速可以解决莫名的卡顿问题,通过设置android:hardwareAccelerated="true"即可为Activity开启硬件加速。
《Android 开发艺术探索》 Bitmap 的加载和 Cache