声明:本篇文章已授权微信公众号guolin_blog(郭霖)独家发布。
前序:在制作App的时候,会经常需要加载一些网络图片,在图片加载框架出来之前,我们都是通过 网络拉取 的方式去服务器端获取到图片的文件流后,再通过BitmapFactory.decodeStream(InputStream in)来加载图片,这种方式加载一两张图片倒不会出现问题,但是如果短时间内加载十几张或者几十张图片的时候,就很有可能会造成OOM(内存溢出),因为现在的图片资源大小都是非常大的,所以我们在加载图片之前还需要进行相应的 图片压缩 处理;但又有个问题来了,在蜂窝数据如此昂亏的情况下,如果用户每次进入App的时候都会去进行网络拉取图片,这样就会非常的浪费数据流量,这时我们又需要对图片资源进行一些相应的 内存缓存 以及 磁盘缓存 处理,这样不仅节省用户的数据流量,还能加快图片的加载速度;虽然利用缓存的方式可以加快图片的加载速度,但当我们需要加载很多张图片的时候(例如图片墙效果),就还需用到多线程来加载图片,使用多线程就会涉及到线程 同步加载 与 异步加载 问题;
总结:任何的图片加载框架都会涉及到这几个方面:内存缓存,磁盘缓存,网络拉取,图片压缩,同步加载,异步加载;
接下来我们一步一步来实现一个图片加载框架,仿写ImageLoader来实现一个图片加载框架PictureLoader:
如何加载一个图片呢?BitmapFactory类为我们提供了四类方法来加载Bitmap:decodeFile、decodeResource、decodeStream、decodeByteArray;
通常在加载图片之前,我们需要先对Bitmap进行压缩处理,那如何对Bitmap进行压缩处理?首先通常都是创建一个BitmapFactory.Options,然后将inJustDecodeBounds设置为true,并通过decodeResource加载图片,但这个时候并不是真正意义上加载图片,而是对图片的宽高进行获取,得到图片的宽高后,与我们的ImageView的宽高通过计算得出缩放比,因为我们的ImageView的宽高远远小于我们需要加载图片的宽高,所以这个时候我们需要对图片进行缩放,那如何计算这个缩放比呢?官方规定这个缩放比通常是2的倍数,但是该缩放比不是越大越好,而是需要恰到好处,如果缩放比过大,会导致图片压缩过多,这时候在ImageView展示的时候,该图片就会被拉伸,这样会非常严重的影响用户体验;计算出缩放比后,将该值赋予option的inSampleSize,然后再将inJustDecodeBounds设置为false,并通过decodeResource加载图片,这时候就会加载缩放后的图片了;
// resize the picture from resource;
public Bitmap resizePictureFromResource(Resources res, int viewId, int reqWidth, int reqHeight) {
final BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(res, viewId, options);
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);
options.inJustDecodeBounds = false;
return BitmapFactory.decodeResource(res, viewId, options);
}
// the algorithm for calculate the inSampleSize;
public int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) {
if (reqWidth == 0 || reqHeight == 0) return 1;
final int height = options.outHeight;
final int width = options.outWidth;
Log.e(TAG, "origin, width = " + width + " , height = " + height);
int inSampleSize = 1;
if (height > reqHeight || width > reqWidth) {
final int halfWidth = width / 2;
final int halfHeight = height / 2;
while ((halfHeight / inSampleSize) >= reqHeight
&& (halfWidth / inSampleSize) >= reqWidth) {
inSampleSize *= 2;
}
}
Log.e(TAG, "inSampleSize = " + inSampleSize);
return inSampleSize;
}
上面缩放方式可以缩放来自于Resource资源的图片,该方式适用于内存缓存(LruCache),但是如果我们用DiskLruCache作为磁盘缓存的话,从DiskLruCache获取缓存的时候,获取到的是一个SnapShot对象,接着我们可以通过SnapShot对象得到缓存的文件输入流,有了文件输入流,就可以得到Bitmap对象了,但是这个时候我们依然使用decodeResource方式来进行图片缩放的话,就会存在问题,因为FileInputStream是一种有序的文件流,而两次decodeStream调用会影响文件流的位置属性,就会导致第二次decodeStream时得到null,为了解决这个问题,我们可以通过文件流来得到所对应的文件描述符,然后再通过BitmapFactory.decodeFileDescriptor来得到一张缩放后的图片;
// resize the picture from file descriptor;
public Bitmap resizePictureFromFileDescriptor(FileDescriptor fd, int reqWidth, int reqHeight) {
final BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeFileDescriptor(fd, null, options);
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);
options.inJustDecodeBounds = false;
return BitmapFactory.decodeFileDescriptor(fd, null, options);
}
LruCache是Android提供的一个缓存类,通常运用于内存缓存,LruCache是一个泛型类,它的底层是用一个LinkedHashMap以强引用的方式存储外界的缓存对象来实现的,为什么使用LinkedHashMap来作为LruCache的存储,是因为LinkedHashMap有两种排序方式,一种是插入排序方式,一种是访问排序方式,默认情况下是以访问方式来存储缓存对象的;LruCache提供了get和put方法来完成缓存的获取和添加,当缓存满时,会将最近最少使用的对象移除掉,然后再添加新的缓存对象;在使用LruCache的时候,首先需要获取当前设备的内存容量,通常情况下会将总容量的八分之一作为LruCache的容量,然后重写LruCache的sizeof方法,sizeof方法用于计算缓存对象的大小,单位需要与分配的容量的单位一致;
创建LruCache:
// get system max memory;
int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
// set LruCache size;
int cacheSize = maxMemory / 8;
memoryCache = new LruCache(cacheSize) {
@Override
protected int sizeOf(String uri, Bitmap bitmap) {
return bitmap.getRowBytes() * bitmap.getHeight() / 1024;
}
};
添加操作:
memoryCache.put(key, bitmap);
获取操作:
memoryCache.get(key)
DiskLruCache适用于磁盘缓存,虽然其不是官方的API,但是官方还是推荐使用DiskLruCache作为磁盘缓存,在使用LruCache之前,我们需要给我们的项目添加其依赖:
implementation 'com.jakewharton:disklrucache:2.0.2'
在使用DiskLruCache之前,我们首先需要通过open方法来创建一个DiskLruCache,open方法有四个参数,第一个参数指的是磁盘缓存的路径,传参前需要确保该路径下的文件夹存在,没有就创建一个;第二个参数是版本号,通常设为1即可;第三个参数是指单个节点所对应的数据的个数,通常也设为1即可;第四个参数是指该DiskLruCache的容量大小:
File diskCacheDir = getDiskCahceDir(pContext, "bitmap");
if (!diskCacheDir.exists()) {
diskCacheDir.mkdirs();
}
if (getUsableSpace(diskCacheDir) > DISK_CACHE_SIZE) {
try {
diskLruCache = DiskLruCache.open(diskCacheDir, 1, 1, DISK_CACHE_SIZE);
isDiskLruCacheCreated = true;
} catch (IOException e) {
e.printStackTrace();
}
}
创建完DiskLruCache后,当我们使用DiskLruCache添加缓存操作的时候,是通过Editor来完成的,Editor表示一个缓存对象的编辑对象,在添加缓存的时候,我们的key通常是url,但是直接使用url作为key的话,可能会出现一些问题,如果当一个url存在特殊字符的时候,这将影响缓存查找操作,所以在添加之前需要对url进行相应的计算,一般采用url的md5值作为key,当然也可以使用其他的消息摘要算法:
// the algorithm for uri to key;
private String hashKeyFromUri(String uri) {
String cacheKey = null;
try {
final MessageDigest digest = MessageDigest.getInstance("MD5");
digest.update(uri.getBytes());
cacheKey = bytesToHexString(digest.digest());
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
}
return cacheKey;
}
// get hex string from bytes;
private String bytesToHexString(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
String hex = Integer.toHexString(0xFF & b);
sb.append(hex.length() == 1 ? '0' : hex);
}
return sb.toString();
}
当url转成key以后,就可以获取Editor对象,但是DiskLruCache不允许同时编辑同一个缓存对象,如果编辑一个正在编辑的缓存对,edit就会返回一个null,edit后就会得到一个Editor对象,通过Editor对象就可以得到一个文件输出流,当我们在网络上下载图片的时候,就可以将网络图片的文件流通过该文件输出流写入磁盘中,最后必须通过Editor的commit方法来提交写入操作才完成缓存添加操作,如果写入过程中发生异常,可通过abort方法来回退操作:
if (diskLruCache == null) {
return null;
}
String key = hashKeyFromUri(uri);
DiskLruCache.Editor editor = diskLruCache.edit(key);
if (editor != null) {
OutputStream outputStream = editor.newOutputStream(DISK_CACHE_INDEX);
if (downloadUriToStream(uri, outputStream)) {
editor.commit();
} else {
editor.abort();
}
diskLruCache.flush();
}
在DiskLruCache中查找我们缓存对象的时候,可以使用DiskLruCache的get方法通过相应的key来得到一个SnapShot对象,然后接着通过SnapShot对象就可以得到缓存对象的文件输入流,在之前的压缩图片操作提到过,该文件输入流不能直接用decodeStream来进行图片缩放,因为文件输入流是一个有序的文件流,两次decodeStream会使文件流的位置属性发生变化,从而会导致第二次decodeStream的时候得到的是null,为了解决这个问题,从磁盘缓存中得到缓存对象SnapShot后,将其得到的文件输入流得到相对应的文件描述符,然后通过BitmapFactory.decodeFileDescriptor方法来加载一张缩放图片,并将其添加到内存缓存中去:
Bitmap bitmap = null;
String key = hashKeyFromUri(uri);
DiskLruCache.Snapshot snapshot = diskLruCache.get(key);
if (snapshot != null) {
FileInputStream fileInputStream = (FileInputStream) snapshot.getInputStream(DISK_CACHE_INDEX);
FileDescriptor fileDescriptor = fileInputStream.getFD();
bitmap = pictureResizer.resizePictureFromFileDescriptor(fileDescriptor, reqWidth, reqHeight);
if (bitmap != null) {
// if bitmap is get, add the new bitmap to memory cache;
addBitmapToMemoryCache(key, bitmap);
}
}
一个App在加载图片的时候,可以先从内存缓存中获取,当内存缓存中不存在缓存对象时,就去磁盘缓存中尝试缓存,如果此时磁盘缓存中依然不存在时,就需要进行网络请求获取图片的文件流,在这我将采用最原生的网络请求方式HttpURLConnection方式进行图片获取(当然也可以使用流行的开源框架:Okhttp或者Retrofit等等):
Bitmap bitmap = null;
HttpURLConnection urlConnection = null;
BufferedInputStream in = null;
try {
final URL url = new URL(uri);
urlConnection = (HttpURLConnection) url.openConnection();
in = new BufferedInputStream(urlConnection.getInputStream(), IO_BUFFER_SIZE);
bitmap = BitmapFactory.decodeStream(in);
} catch (IOException e) {
e.printStackTrace();
} finally {
if (urlConnection != null) urlConnection.disconnect();
if (in != null) in.close();
}
如果我们只用单线程来加载大量图片的时候,虽然可以加载成功,但是会非常地耗时,所以这个时候同步加载图片的操作不能放在主线程中执行,需要外部在子线程中调用,写同步加载的时候,首先需要检测当前的线程的Looper是否为主线程的Looper,如果是则抛出异常:
private Bitmap loadBitmap(String uri, int reqWidth, int reqHeight) throws IOException {
Bitmap bitmap = loadBitmapFromMemoryCache(uri);
if (bitmap != null) {
return bitmap;
}
try {
bitmap = loadBitmapFromDiskCache(uri, reqWidth, reqHeight);
if (bitmap != null) {
return bitmap;
}
bitmap = loadBitmapFromHttp(uri, reqWidth, reqHeight);
} catch (IOException e) {
e.printStackTrace();
}
if (bitmap == null && !isDiskLruCacheCreated) {
bitmap = downloadBitmapFromUrl(uri);
}
return bitmap;
}
异步加载时使用多线程的方式来加载图片,在使用多线程的时候,我们通常都会使用线程池,因为大量线程的创建与销毁是非常消耗资源的,而线程池充分利用资源,复用线程,减少不必要的开销,当使用多线程方式加载图片的时候,为了保证线程安全,这里将给线程池使用LinkedBlockingQueue的方式来保证线程安全,当图片获取成功后将图片的url、图片以及ImageView封装成一个对象,将其通过Handler向主线程发送一个消息,这样就可以在主线程中更新UI,为ImageView设置图片了:
// get system's CPU count;
private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors();
// set ThreadPool's core thread count;
private static final int CORE_POOL_SIZE = CPU_COUNT + 1;
// set ThreadPool's max thread count;
private static final int MAX_POOL_SIZE = CORE_POOL_SIZE * 2 + 1;
// set every thread's time alive;
private static final long KEEP_ALIVE = 10L;
// create the ThreadPool;
private static final Executor loaderThreadPoolExecutor = new ThreadPoolExecutor(
CORE_POOL_SIZE,
MAX_POOL_SIZE,
KEEP_ALIVE,
TimeUnit.SECONDS,
new LinkedBlockingQueue(),
pThreadFactory
);
public void setBitmap(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;
}
Runnable loadBitmapTask = new Runnable() {
@Override
public void run() {
Bitmap bitmap1 = null;
try {
bitmap1 = loadBitmap(uri, reqWidth, reqHeight);
} catch (IOException e) {
e.printStackTrace();
}
if (bitmap1 != null) {
LoaderResult result = new LoaderResult(uri, imageView, bitmap1);
mainHandler.obtainMessage(MESSAGE_POST_RESULT, result).sendToTarget();
}
}
};
loaderThreadPoolExecutor.execute(loadBitmapTask);
}
以上是ImageLoader图片加载框架大体的实现,参考ImageLoader,小猿自己仿写了一个PictureLoader,在网络加载这块,采用了Okhttp来实现,并将其打包提交到了JitPack上,使用的时候可以在gradle中添加依赖即可:
allprojects {
repositories {
maven { url 'https://jitpack.io' }
}
}
implementation 'com.github.LanceShu:PictureLoader:1.0'
在Activity中使用的时候,可以这样使用:
PictureLoader.build(this).setBitmap(String url, ImageView iv, int reqWidth,int reqHeight);
build()方法中主要的操作是对内存缓存以及磁盘缓存的初始化操作,setBitmap()方法中有四个参数,必须要有的参数是url以及ImageView,可选参数是缩放图片的高度与宽度;
项目地址:https://github.com/LanceShu/PictureLoader
运行效果如图:
该框架还存在一些bug,还在完善中,欢迎指点意见~