图片加载框架ImageLoader实现原理

图片加载框架ImageLoader实现原理

声明:本篇文章已授权微信公众号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<String, Bitmap>(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<Runnable>(),
        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,还在完善中,欢迎指点意见~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章