一起写一个Android图片加载框架

本文会从工作原理到具体实现来详细介绍如何开发一个简洁而实用的Android图片加载框架,并从内存占用与加载图片所需时间这两个方面量化它的性能。通过开发这个框架,我们可以进一步深入了解Android中的Bitmap操作、LruCache、LruDiskCache,让我们以后与Bitmap打交道能够更加得心应手。若对Bitmap的大小计算及inSampleSize计算还不太熟悉,请参考这里:Android开发之高效加载Bitmap。由于个人水平有限,叙述中难免存在不准确或是不清晰的地方,希望大家能够指出,谢谢大家:)

需求描述

在着手进行实际开发工作之前,我们先来明确以下我们的需求。通常来说,一个实用的图片加载框架应该具备以下2个功能:

  • 图片的加载:包括从不同来源(网络、文件系统、内存等),支持同步及异步方式,支持对图片的压缩等等;
  • 图片的缓存:包括内存缓存和磁盘缓存。

下面我们来具体描述下这些需求。

图片的加载

同步加载与异步加载

我们先来简单的复习下同步与异步的概念:

  • 同步:发出了一个“调用”后,需要等到该调用返回才能继续执行;
  • 异步:发出了一个“调用”后,无需等待该调用返回就能继续执行。

同步加载就是我们发出加载图片这个调用后,直到完成加载我们才继续干别的活,否则就一直等着;异步加载也就是发出加载图片这个调用后我们可以直接去干别的活。

从不同的来源加载

我们的应用有时候需要从网络上加载图片,有时候需要从磁盘加载,有时候又希望从内存中直接获取。因此一个合格的图片加载框架应该支持从不同的来源来加载一个图片。对于网络上的图片,我们可以使用HttpURLConnection来下载并解析;对于磁盘中的图片,我们可以使用BitmapFactory的decodeFile方法;对于内存中的图片,则直接使用即可。

图片的压缩

关于对图片的压缩,主要的工作是计算出inSampleSize,剩下的细节在下面实现部分我们会介绍。

图片的缓存

缓存功能对于一个图片加载框架来说是十分必要的,因为从网络上加载图片既耗时耗电又费流量。通常我们希望把已经加载过的图片缓存在内存或磁盘中,这样当我们再次需要加载相同的图片时可以直接从内存缓存或磁盘缓存中获取。

内存缓存

访问内存的速度要比访问磁盘快得多,因此我们倾向于把更加常用的图片直接缓存在内存中,这样加载速度更快,但是内存对于移动设备来说是稀缺资源,因此能够缓存的图片比较少。我们可以选择使用SDK提供的LruCache类来实现内存缓存,这个类使用了LRU算法来管理缓存对象,LRU算法即Least Recently Used(最近最少使用),它的主要思想是当缓存空间已满时,移除最近最少使用(上一次访问时间距现在最久远)的缓存对象。关于LruCache类的具体使用我们下面会进行详细介绍。

磁盘缓存

磁盘缓存的优势在于能够缓存的图片数量比较多,不足就是磁盘IO的速度比较慢。磁盘缓存我们可以用DiskLruCache来实现,这个类不包含在Android SDK中,它的源码可以从这里获取:
http://developer.android.com/intl/zh-cn/samples/DisplayingBitmaps/src/com.example.android.displayingbitmaps/util/DiskLruCache.html
无法访问的同学请戳文末给出的示例代码的,其中包含了DiskLruCache。

DisLruCache同样使用了LRU算法来管理缓存,关于它的具体使用我们会在后文进行介绍。

缓存类使用介绍

LruCache的使用

首先我们来看一下LruCache类的定义:

public class LruCache<K, V> {
    private final LinkedHashMap<K, V> map;

    ...
   
    public LruCache(int maxSize) {
        if (maxSize <= 0) {
            throw new IllegalArgumentException("maxSize <= 0");
        }
        this.maxSize = maxSize;
        this.map = new LinkedHashMap<K, V>(0, 0.75f, true);
    }
    ...
}

由以上代码我们可以知道,LruCache是个泛型类,它的内部使用一个LinkedHashMap来管理缓存对象。

初始化LruCache

初始化LruCache的惯用代码如下所示:

1 //获取当前进程的可用内存(单位KB)
2 int maxMemory = (int) (Runtime.getRuntime().maxMemory() /1024);
3 int memoryCacheSize = maxMemory / 8;
4 mMemoryCache = new LruCache<String, Bitmap>(memoryCacheSize) {
5     @Override
6     protected int sizeOf(String key, Bitmap bitmap) {
7         return bitmap.getByteCount() / 1024;
8     }
9 }; 

在以上代码中,我们创建了一个LruCache实例,并指定它的maxSize为当前进程可用内存的1/8。我们使用String作为key,value自然是Bitmap。第6行到第8行我们重写了sizeOf方法,这个方法被LruCache用来计算一个缓存对象的大小。我们使用了getByteCount方法返回Bitmap对象以字节为单位的大小,又除以了1024,转换为KB为单位的大小,以达到与cacheSize的单位统一。

获取缓存对象

LruCache类通过get方法来获取缓存对象,get方法的源码如下:

 1 public final V get(K key) {
 2         if (key == null) {
 3             throw new NullPointerException("key == null");
 4         }
 5 
 6         V mapValue;
 7         synchronized (this) {
 8             mapValue = map.get(key);
 9             if (mapValue != null) {
10                 hitCount++;
11                 return mapValue;
12             }
13             missCount++;
14         }
15 
16         /*
17          * Attempt to create a value. This may take a long time, and the map
18          * may be different when create() returns. If a conflicting value was
19          * added to the map while create() was working, we leave that value in
20          * the map and release the created value.
21          */
22 
23         V createdValue = create(key);
24         if (createdValue == null) {
25             return null;
26         }
27 
28         synchronized (this) {
29             createCount++;
30             mapValue = map.put(key, createdValue);
31 
32             if (mapValue != null) {
33                 // There was a conflict so undo that last put
34                 map.put(key, mapValue);
35             } else {
36                 size += safeSizeOf(key, createdValue);
37             }
38         }
39 
40         if (mapValue != null) {
41             entryRemoved(false, key, createdValue, mapValue);
42             return mapValue;
43         } else {
44             trimToSize(maxSize);
45             return createdValue;
46         }
47     }

通过以上代码我们了解到,首先会尝试根据key获取相应value(第8行),若不存在则会调用create方法尝试新建一个value,并将key-value pair放入到LinkedHashMap中。create方法的默认实现会直接返回null,我们可以重写这个方法,这样当key还不存在时,我们可以按照自己的需求根据给定key创建一个value并返回。从get方法的实现我们可以看到,它用synchronized关键字作了同步,因此这个方法是线程安全的。实际上,LruCache类对所有可能涉及并发数据访问的方法都作了同步。

添加缓存对象

在添加缓存对象之前,我们先得确定用什么作为被缓存的Bitmap对象的key,一种很直接的做法便是使用Bitmap的URL作为key,然而由于URL中存在一些特殊字符,所以可能会产生一些问题。基于以上原因,我们可以考虑使用URL的md5值作为key,这能够很好的保证不同的URL具有不同的key,而且相同的URL具有相同的key。我们自定义一个getKeyFromUrl方法来通过URL获取key,该方法的代码如下:

    private String getKeyFromUrl(String url) {
        String key;
        try {
            MessageDigest messageDigest = MessageDigest.getInstance("MD5");
            messageDigest.update(url.getBytes());
            byte[] m = messageDigest.digest();
            return getString(m);
        } catch (NoSuchAlgorithmException e) {
            key = String.valueOf(url.hashCode());
        }
        return key;
    }
    private static String getString(byte[] b){
        StringBuffer sb = new StringBuffer();
        for(int i = 0; i < b.length; i ++){
            sb.append(b[i]);
        }
        return sb.toString();
    }

得到了key后,我们可以使用put方法向LruCache内部的LinkedHashMap中添加缓存对象,这个方法的源码如下:

 1 public final V put(K key, V value) {
 2         if (key == null || value == null) {
 3             throw new NullPointerException("key == null || value == null");
 4         }
 5 
 6         V previous;
 7         synchronized (this) {
 8             putCount++;
 9             size += safeSizeOf(key, value);
10             previous = map.put(key, value);
11             if (previous != null) {
12                 size -= safeSizeOf(key, previous);
13             }
14         }
15 
16         if (previous != null) {
17             entryRemoved(false, key, previous, value);
18         }
19 
20         trimToSize(maxSize);
21         return previous;
22 }

从以上代码我们可以看到这个方法确实也作了同步,它将新的key-value对放入LinkedHashMap后会返回相应key原来对应的value。

删除缓存对象

我们可以通过remove方法来删除缓存对象,这个方法的源码如下:

public final V remove(K key) {
        if (key == null) {
            throw new NullPointerException("key == null");
        }

        V previous;
        synchronized (this) {
            previous = map.remove(key);
            if (previous != null) {
                size -= safeSizeOf(key, previous);
            }
        }

        if (previous != null) {
            entryRemoved(false, key, previous, null);
        }

        return previous;
}

这个方法会从LinkedHashMap中移除指定key对应的value并返回这个value,我们可以看到它的内部还调用了entryRemoved方法,如果有需要的话,我们可以重写entryRemoved方法来做一些资源回收的工作。

DiskLruCache的使用

初始化DiskLruCache

通过查看DiskLruCache的源码我们可以发现,DiskLruCache就存在如下一个私有构造方法:

private DiskLruCache(File directory, int appVersion, int valueCount, long maxSize) {
        this.directory = directory;
        this.appVersion = appVersion;
        this.journalFile = new File(directory, JOURNAL_FILE);
        this.journalFileTmp = new File(directory, JOURNAL_FILE_TMP);
        this.valueCount = valueCount;
        this.maxSize = maxSize;
}

因此我们不能直接调用构造方法来创建DiskLruCache的实例。实际上DiskLruCache为我们提供了open静态方法来创建一个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;
}

从以上代码中我们可以看到,open方法内部调用了DiskLruCache的构造方法,并传入了我们传入open方法的4个参数,这4个参数的含义分别如下:

  • directory:代表缓存文件在文件系统的存储路径;
  • appVersion:代表应用版本号,通常设为1即可。需要注意的是,当版本号改变时,该应用的磁盘缓存会被请空。
  • valueCount:代表LinkedHashMap中每个节点上的缓存对象数目,通常设为1即可;
  • maxSize:代表了缓存的总大小,若缓存对象的总大小超过了maxSize,DiskLruCache会自动删去最近最少使用的一些缓存对象。

以下代码展示了初始化DiskLruCache的惯用代码:

File diskCacheDir= getAppCacheDir(mContext, "images");
if (!diskCacheDir.exists()) {
    diskCacheDir.mkdirs();
}
mDiskLruCache = DiskLruCache.open(diskCacheDir, 1, 1, DISK_CACHE_SIZE); 

以上代码中的getAppCacheDir是我们自定义的用来获取磁盘缓存目录的方法,它的定义如下:

public static File getAppCacheDir(Context context, String dirName) {
    String cacheDirString;
    if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) {
        cacheDirString = context.getExternalCacheDir().getPath();
    } else {
        cacheDirString = context.getCacheDir().getPath();
    }
    return new File(cacheDirString + File.separator + dirName);
}

接下来我们介绍如何添加、获取和删除缓存对象。

添加缓存对象

先通过以上介绍的getKeyFromUrl获取Bitmap对象对应的key,接下来我们就可以把这个Bitmap存入磁盘缓存中了。我们通过Editor来向DiskLruCache添加缓存对象。首先我们要通过edit方法获取一个Editor对象:

String key = getKeyFromUrl(url);
DiskLruCache.Editor editor = mDiskLruCache.edit(key);

获取到Editor对象后,通过调用Editor对象的newOutputStream我们就可以获取key对应的Bitmap的输出流,需要注意的是,若我们想通过edit方法获取的那个缓存对象正在被“编辑”,那么edit方法会返回null。相关的代码如下:

if (editor != null) {
    OutputStream outputStream = editor.newOutputStream(0); //参数为索引,由于我们创建时指定一个节点只有一个缓存对象,所以传入0即可
}

获取了输出流后,我们就可以向这个输出流中写入图片数据,成功写入后调用commit方法即可,若写入失败则调用abort方法进行回退。相关的代码如下:

//getStreamFromUrl为我们自定义的方法,它通过URL获取输入流并写入outputStream,具体实现后文会给出
if (getStreamFromUrl(url, outputStream)) {
    editor.commit();
} else {
    //返回false表示写入outputStream未成功,因此调用abort方法回退整个操作
    editor.abort();
}
mDiskLruCache.flush(); //将内存中的操作记录同步到日志文件中

下面我们来看一下getStreamFromUrl方法的实现,这个方法的逻辑很直接,就是创建一个HttpURLConnection,然后获取InputStream再写入outputStream,为了提高效率,使用了包装流。该方法的代码如下:

public boolean getStreamFromUrl(String urlString, OutputStream outputStream) {
    HttpURLConnection urlCOnnection = null;
    BufferedInputStream bis = null;
    BufferedOutputStream bos = null;
    
    try {
        final URL url = new URL(urlString);
        urlConnection = (HttpURLConnection) url.openConnection();
        bis = new BufferedInputStream(urlConnection.getInputStream(), BUF_SIZE); //BUF_SIZE为使用的缓冲区大小
        
        int byteRead;
        while ((byteRead = bis.read()) != -1) {
            bos.write(byteRead);
        }
        return true;
    }catch (IOException e) {
        e.printStackTrace();
    } finally {
        if (urlConnection != null) {
            urlConnection.disconnect();
        }
        //HttpUtils为一个自定义工具类
        HttpUtils.close(bis);
        HttpUtils.close(bos);
    }
    return false;
}

经过以上的步骤,我们已经成功地将图片写入了文件系统。

获取缓存对象

我们使用DiskLruCache的get方法从中获取缓存对象,这个方法的大致源码如下:

 1 public synchronized Snapshot get(String key) throws IOException {
 2         checkNotClosed();
 3         validateKey(key);
 4         Entry entry = lruEntries.get(key);
 5         if (entry == null) {
 6             return null;
 7         }
 8  
 9         if (!entry.readable) {
10             return null;
11         }
12  
13         /*
14          * Open all streams eagerly to guarantee that we see a single published
15          * snapshot. If we opened streams lazily then the streams could come
16          * from different edits.
17          */
18         InputStream[] ins = new InputStream[valueCount];19         ...  
20         return new Snapshot(key, entry.sequenceNumber, ins);
21  }

我们可以看到,这个方法最终返回了一个Snapshot对象,并以我们要获取的缓存对象的key作为构造参数之一。Snapshot是DiskLruCache的内部类,它包含一个getInputStream方法,通过这个方法可以获取相应缓存对象的输入流,得到了这个输入流,我们就可以进一步获取到Bitmap对象了。在获取缓存的Bitmap时,我们通常都要对它进行一些预处理,主要就是通过设置inSampleSize来适当的缩放图片,以防止出现OOM。我们之前已经介绍过如何高效加载Bitmap,在那篇文章里我们的图片来源于Resources。尽管现在我们的图片来源是流对象,但是计算inSampleSize的方法是一样的,只不过我们不再使用decodeResource方法而是使用decodeFileDescriptor方法。

相关的代码如下:

 1 Bitmap bitmap = null;
 2 String key = getKeyFromUrl(url);
 3 DiskLruCache.Snapshot snapShot = mDiskLruCache.get(key);
 4 if (snapShot != null) {
 5     FileInputStream fileInputStream = (FileInputStream) snapShot.getInputStream(0); //参数表示索引,同之前的newOutputStream一样
 6     FileDescriptor fileDescriptor = fileInputStream.getFD();
 7     bitmap = decodeSampledBitmapFromFD(fileDescriptor, dstWidth, dstHeight);
 8     if (bitmap != null) {
 9         addBitmapToMemoryCache(key, bitmap);
10     }
11 }

第7行我们调用了decodeSampledBitmapFromFD来从fileInputStream的文件描述符中解析出Bitmap,decodeSampledBitmapFromFD方法的定义如下:

public Bitmap decodeSampledBitmapFromFD(FileDescriptor fd, int dstWidth, int dstHeight) {
    final BitmapFactory.Options options = new BitmapFactory.Options();
    options.inJustDecodeBounds = true;
    BitmapFactory.decodeFileDescriptor(fd, null, options);
    //calInSampleSize方法的实现请见“Android开发之高效加载Bitmap”这篇博文
    options.inSampleSize = calInSampleSize(options, dstWidth, dstHeight);
    options.inJustDecodeBounds = false;
    return BitmapFactory.decodeFileDescriptor(fd, null, options);
}

第9行我们调用了addBitmapToMemoryCache方法把获取到的Bitmap加入到内存缓存中,关于这一方法的具体实现下文会进行介绍。

图片加载框架的具体实现

图片的加载

同步加载

同步加载的相关代码需要在工作者线程中执行,因为其中涉及到对网络的访问,并且可能是耗时操作。同步加载的大致步骤如下:首先尝试从内存缓存中加载Bitmap,若不存在再从磁盘缓存中加载,若还不存在则从网络中获取并添加到磁盘缓存中。同步加载的代码如下:

public Bitmap loadBitmap(String url, int dstWidth, int dstHeight) {
    Bitmap bitmap = loadFromMemory(url);
    if (bitmap != null) {
        return bitmap;
    }
    //内存缓存中不存在相应图片
    try {
        bitmap = loadFromDisk(url, dstWidth, dstHeight);
        if (bitmap != null) {
            return bitmap;
        }
        //磁盘缓存中也不存在相应图片
        bitmap = loadFromNet(url, dstWidth, dstHeight);
    } catch (IOException e) {
        e.printStackTrace();
    }

    return bitmap;
}

loadBitmapFromNet方法的功能是从网络上获取指定url的图片,并根据给定的dstWidth和dstHeight对它进行缩放,返回缩放后的图片。loadBitmapFromDisk方法则是从磁盘缓存中获取并缩放,而后返回缩放后的图片。关于这两个方法的实现在下面“图片的缓存”部分我们会具体介绍。下面我们先来看看异步加载图片的实现。

异步加载

异步加载图片在实际开发中更经常被使用,通常我们希望图片加载框架帮我们去加载图片,我们接着干别的活,等到图片加载好了,图片加载框架会负责将它显示在我们给定的ImageView中。我们可以使用线程池去执行异步加载任务,加载好后通过Handler来更新UI(将图片显示在ImageView中)。相关代码如下所示:

 1 public void displayImage(String url, ImageView imageView, int dstWidth, int widthHeight) {
 2     imageView.setTag(IMG_URL, url);
 3     Bitmap bitmap = loadFromMemory(url);
 4     if (bitmap != null) {
 5         imageView.setImageBitmap(bitmap);
 6         return;
 7     }
 8     
 9     Runnable loadBitmapTask = new Runnable() {
10         @Override
11         public void run() {
12             Bitmap bitmap = loadBitmap(url, dstWidth, dstHeight);
13             if (bitmap != null) {
14                 //Result是我们自定义的类,封装了返回的Bitmap、Bitmap的URL和作为容器的ImageView
15                 Result result = new Result(bitmap, url, imageView);
16                 //mMainHandler为主线程中创建的Handler
17                 Message msg = mMainHandler.obtainMessage(MESSAGE_SEND_RESULT, result);
18                 msg.sendToTarget();
19              }
20         }
21     };
22     threadPoolExecutor.execute(loadBitmapTask);
23 }

从以上代码我们可以看到,异步加载与同步加载之间的区别在于,异步加载把耗时任务放入了线程池中执行。同步加载需要我们创建一个线程并在新线程中执行loadBitmap方法,使用异步加载我们只需传入url、imageView等参数,图片加载框架负责使用线程池在后台执行图片加载任务,加载成功后会通过发送消息给主线程来实现把Bitmap显示在ImageView中。我们来简单的解释下obtainMessage这个方法,我们传入了两个参数,第一个参数代表消息的what属性,这是个int值,相当于我们给消息指定的一个标识,来区分不同的消息;第二个参数代表消息的obj属性,表示我们附带的一个数据对象,就好比我们发email时带的附件。obtainMessage用于从内部的消息池中获取一个消息,就像线程池对线程的复用一样,通过这个方法获取消息更加高效。获取了消息并设置好它的what、obj后,我们在第18行调用sendToTarget方法来发送消息。

下面我们来看看mMainHandler和threadPoolExecutor的创建代码:

private static final int CORE_POOL_SIZE = CPU_COUNT + 1; //corePoolSize为CPU数加1
private static final int MAX_POOL_SIZE = 2 * CPU_COUNT + 1; //maxPoolSize为2倍的CPU数加1
private static final long KEEP_ALIVE = 5L; //存活时间为5s

public static final Executor threadPoolExecutor = new ThreadPoolExecutor(CORE_POOL_SIZE, MAX_POOL_SIZE, KEEP_ALIVE, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>());

private Handler mMainHandler = new Handler(Looper.getMainLooper()) {
    @Override
    public void handleMessage(Message msg) {
        Result result = (Result) msg.what;
        ImageView imageView = result.imageView;
        String url = (String) imageView.getTag(IMG_URL);
        if (url.equals(result.url)) {
            imageView.setImageBitmap(result.bitmap);
        } else {
            Log.w(TAG, "The url associated with imageView has changed");
        }
    };
};

从以上代码中我们可以看到创建mMainHandler时使用了主线程的Looper,因此构造mMainHandler的代码可以放在子线程中执行。另外,注意以上代码中我们在给imageView设置图片时首先判断了下它的url是否等于result中的url,若相等才显示。我们知道ListView会对其中Item的View进行复用,刚移出屏幕的Item的View会被即将显示的Item所复用。那么考虑这样一个场景:刚移出的Item的View中的图片还在未加载完成,而这个View被新显示的Item复用时图片加载好了,那么图片就会显示在新Item处,这显然不是我们想看到的。因此我们通过判断imageView的url是否与刚加载完的图片的url是否相等,并在只有两者相等时才显示,就可以避免以上提到的情况。

图片的缓存

缓存的创建

我们在图片加载框架类(FreeImageLoader)的构造方法中初始化LruCache和DiskLruCache,相关代码如下:

private LruCache<String, Bitmap> mMemoryCache;
private DiskLruCache mDiskLruCache;

private ImageLoader(Context context) {
    mContext = context.getApplicationContext();
    int maxMemory = (int) (Runtime.getRuntime().maxMemory() /1024);
    int cacheSize = maxMemory / 8;
    mMemorySize = new LruCache<String, Bitmap>(cacheSize) {
        @Override
        protected int sizeof(String key, Bitmap bitmap) {
            return bitmap.getByteCount() / 1024;
        }
    };
    File diskCacheDir = getAppCacheDir(mContext, "images");
    if (!diskCacheDir.exists()) {
        diskCacheDir.mkdirs();
    }
    if (diskCacheDir.getUsableSpace() > DISK_CACHE_SIZE) { 
        //剩余空间大于我们指定的磁盘缓存大小
        try {
            mDiskLruCache = DiskLruCache.open(diskCacheDir, 1, 1, DISK_CACHE_SIZE);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
 
缓存的获取与添加

内存缓存的添加与获取我们已经介绍过,只需调用LruCache的put与get方法,示例代码如下:

private void addToMemoryCache(String key, Bitmap bitmap) {
    if (getFromMemoryCache(key) == null) {
        //不存在时才添加
        mMemoryCache.put(key, bitmap);
    }
}

private Bitmap getFromMemoryCache(String key) {
    return mMemoryCache.get(key);
}

接下来我们看一下如何从磁盘缓存中获取Bitmap:

private loadFromDiskCache(String url, int dstWidth, int dstHeight) throws IOException {
    if (Looper.myLooper() == Looper.getMainLooper()) {
        //当前运行在主线程,警告
        Log.w(TAG, "should not load Bitmap in main thread");
    }
    
    if (mDiskLruCache == null) {
        return null;
    }

    Bitmap bitmap = null;
    String key = getKeyFromUrl(url);
    DiskLruCache.Snapshot snapshot = mDiskLruCache.get(key);
    if (snapshot != null) {
        FileInputStream fileInputStream = (FileInputStream) snapshot.getInputStream(0);
        FileDescriptor fileDescriptor = fileInputStream.getFD();
        bitmap = decodeSampledBitmapFromFD(fileDescriptor, dstWidth, dstHeight);
        if (bitmap != null) {
            addToMemoryCache(key, bitmap);
        }
    }

    return bitmap;
}
    

把Bitmap添加到磁盘缓存中的工作在loadFromNet方法中完成,当从网络上成功获取图片后,会把它存入磁盘缓存中。相关代码如下:

private Bitmap loadFromNet(String url, int dstWidth, int dstHeight) throws IOException {
    if (Looper.myLooper() == Looper.getMainLooper()) {
        throw new RuntimeException("Do not load Bitmap in main thread.");
    }
    
    if (mDiskLruCache == null) {
        return null;
    }
    
    String key = getKeyFromUrl(url);
    DiskLruCache.Editor editor = mDiskLruCache.edit(key);
    if (editor != null) {
        OutputStream outputStream = editor.newOutputStream(0);
        if (getStreamFromUrl(url, outputStream)) {
            editor.commit();
        } else {
            editor.abort();
        }
        mDiskLruCache.flush();
    }
    return loadFromDiskCache(url, dstWidth, dstHeight);
}   

以上代码的大概逻辑是:当确认当前不在主线程并且mDiskLruCache不为空时,从网络上得到图片并保存到磁盘缓存,然后从磁盘缓存中得到图片并返回。

以上贴出的两段代码在最开头都判断了是否在主线程中,对于loadFromDiskCache方法来说,由于磁盘IO相对耗时,不应该在主线程中运行,所以只会在日志输出一个警告;而对于loadFromNet方法来说,由于在主线程中访问网络是不允许的,因此若发现在主线程,直接抛出一个异常,这样做可以避免做了一堆准备工作后才发现位于主线程中不能访问网络(即我们提早抛出了异常,防止做无用功)。

另外,我们在以上两段代码中都对mDiskLruCache是否为空进行了判断。这也是很必要的,设想我们做了一堆工作后发现磁盘缓存根本还没有初始化,岂不是白白浪费了时间。我们通过两个if判断可以尽量避免做无用功。

现在我们已经实现了一个简洁的图片加载框架,下面我们来看看它的实际使用性能如何。

简单的性能测试

这里我们主要从内存分配和图片的平均加载时间这两个方面来看一下我们的图片加载框架的大致性能。完整的demo请见这里:FreeImageLoader

内存分配情况

运行我们的demo,待图片加载完全,我们用adb看一下我们的应用的内存分配情况,我这里得到的情况如下图所示:


从上图我们可以看到,Dalvik Heap分配的内存为18003KB, Native Heap则分配了6212KB。下面我们来看一下FreeImageLoader平均每张图片的加载时间。

平均加载时间

这里我们获取平均加载时间的方法非常直接,基本思想是如以下所示:

//加载图片前的时间点
long beforeTime = System.currentTimeMillis();
//加载图片完成的时间点
long afterTime = System.currentTimeMillis();
//total为图片的总数,averTime为加载每张图片所需的平均时间
int averTime = (int) ((afterTime - beforeTime) / total)

然后我们维护一个计数值counts,每加载完一张就加1,当counts为total时我们便调用一个回调方法onAfterLoad,在这个方法中获取当前时间点并计算平均加载时间。具体的代码请看上面给出的demo地址。

我这里测试加载30张图片时,平均每张所需时间为1.265s。

参考资料

  1. Displaying Bitmap Efficiently

  2. 《Android开发艺术探索》


长按或扫描二维码关注我们,让您利用每天等地铁的时间就能学会怎样写出优质app。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,458评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,454评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,171评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,062评论 0 207
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,440评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,661评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,906评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,609评论 0 200
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,379评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,600评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,085评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,409评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,072评论 3 237
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,088评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,860评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,704评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,608评论 2 270

推荐阅读更多精彩内容