💎一站式轻松地调用各大LLM模型接口,支持GPT4、智谱、星火、月之暗面及文生图 广告
12.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刚刚下载好的图片,这个时候就会出现item B中显示了item A的图片,这就是常见的列表的错位问题,ImageLoader需要正确地处理这些特殊情况。 上面对ImageLoader的功能做了一个全面的分析,下面就可以一步步实现一个ImageLoader了,这里主要分为如下几步。 1.图片压缩功能的实现 图片压缩在第12.1节中已经做了介绍,这里就不再多说了,为了有良好的设计风格,这里单独抽象了一个类用于完成图片的压缩功能,这个类叫ImageResizer,它的实现如下所示。 public class ImageResizer { private static final String TAG = "ImageResizer"; public ImageResizer() { } public Bitmap decodeSampledBitmapFromResource(Resources res, int resId, int reqWidth, int reqHeight) { // First decode with inJustDecodeBounds=true to check dimensions final BitmapFactory.Options options = new BitmapFactory.Options(); options.inJustDecodeBounds = true; BitmapFactory.decodeResource(res, resId, options); // Calculate inSampleSize options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight); // Decode bitmap with inSampleSize set options.inJustDecodeBounds = false; return BitmapFactory.decodeResource(res, resId, options); } public Bitmap decodeSampledBitmapFromFileDescriptor(FileDescriptor fd, int reqWidth, int reqHeight) { // First decode with inJustDecodeBounds=true to check dimensions final BitmapFactory.Options options = new BitmapFactory.Options(); options.inJustDecodeBounds = true; BitmapFactory.decodeFileDescriptor(fd, null, options); // Calculate inSampleSize options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight); // Decode bitmap with inSampleSize set options.inJustDecodeBounds = false; return BitmapFactory.decodeFileDescriptor(fd, null, options); } public int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) { if (reqWidth == 0 || reqHeight == 0) { return 1; } // Raw height and width of image final int height = options.outHeight; final int width = options.outWidth; Log.d(TAG, "origin, w=" + width + " h=" + height); int inSampleSize = 1; if (height > reqHeight || width > reqWidth) { final int halfHeight = height / 2; final int halfWidth = width / 2; // Calculate the largest inSampleSize value that is a power of 2 and // keeps both // height and width larger than the requested height and width. while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) { inSampleSize *= 2; } } Log.d(TAG, "sampleSize:" + inSampleSize); return inSampleSize; } } 2.内存缓存和磁盘缓存的实现 这里选择LruCache和DiskLruCache来分别完成内存缓存和磁盘缓存的工作。在ImageLoader初始化时,会创建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; mMemoryCache = new LruCache<String, Bitmap>(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 void addBitmapToMemoryCache(String key, Bitmap bitmap) { if (getBitmapFromMemCache(key) == null) { mMemoryCache.put(key, bitmap); } } private Bitmap getBitmapFromMemCache(String key) { return mMemoryCache.get(key); } 而磁盘缓存的添加和读取功能稍微复杂一些,具体内容已经在12.2.2节中进行了详细的介绍,这里再简单说明一下。磁盘缓存的添加需要通过Editor来完成,Editor提供了commit和abort方法来提交和撤销对文件系统的写操作,具体实现请参看下面的loadBitmap-FromHttp方法。磁盘缓存的读取需要通过Snapshot来完成,通过Snapshot可以得到磁盘缓存对象对应的FileInputStream,但是FileInputStream无法便捷地进行压缩,所以通过FileDescriptor来加载压缩后的图片,最后将加载后的Bitmap添加到内存缓存中,具体实现请参看下面的loadBitmapFromDiskCache方法。 private Bitmap loadBitmapFromHttp(String url, int reqWidth, int reqHeight) throws IOException { if (Looper.myLooper() == Looper.getMainLooper()) { throw new RuntimeException("can not visit network from UI Thread."); } if (mDiskLruCache == null) { return null; } String key = hashKeyFormUrl(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, reqHeight); } private Bitmap loadBitmapFromDiskCache(String url, int reqWidth, int reqHeight) throws IOException { if (Looper.myLooper() == Looper.getMainLooper()) { Log.w(TAG, "load bitmap from UI Thread, it's not recommended! "); } if (mDiskLruCache == null) { return null; } Bitmap bitmap = null; String key = hashKeyFormUrl(url); DiskLruCache.Snapshot snapShot = mDiskLruCache.get(key); if (snapShot ! = null) { FileInputStream fileInputStream = (FileInputStream)snapShot. getInputStream(DISK_CACHE_INDEX); FileDescriptor fileDescriptor = fileInputStream.getFD(); bitmap = mImageResizer.decodeSampledBitmapFromFileDescriptor (fileDescriptor, reqWidth, reqHeight); if (bitmap ! = null) { addBitmapToMemoryCache(key, bitmap); } } return bitmap; } 3.同步加载和异步加载接口的设计 首先看同步加载,同步加载接口需要外部在线程中调用,这是因为同步加载很可能比较耗时,它的实现如下所示。 /** * load bitmap from memory cache or disk cache or network. * @param uri http url * @param reqWidth the width ImageView desired * @param reqHeight the height ImageView desired * @return bitmap, maybe null. */ public Bitmap loadBitmap(String uri, int reqWidth, int reqHeight) { Bitmap bitmap = loadBitmapFromMemCache(uri); if (bitmap ! = null) { Log.d(TAG, "loadBitmapFromMemCache, url:" + uri); return bitmap; } try { bitmap = loadBitmapFromDiskCache(uri, reqWidth, reqHeight); if (bitmap ! = null) { Log.d(TAG, "loadBitmapFromDisk, url:" + uri); return bitmap; } bitmap = loadBitmapFromHttp(uri, reqWidth, reqHeight); Log.d(TAG, "loadBitmapFromHttp, url:" + uri); } catch (IOException e) { e.printStackTrace(); } if (bitmap == null && ! mIsDiskLruCacheCreated) { Log.w(TAG, "encounter error, DiskLruCache is not created."); bitmap = downloadBitmapFromUrl(uri); } 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); Bitmap bitmap = loadBitmapFromMemCache(uri); if (bitmap ! = null) { imageView.setImageBitmap(bitmap); return; } Runnable loadBitmapTask = new Runnable() { @Override public void run() { Bitmap bitmap = loadBitmap(uri, reqWidth, reqHeight); if (bitmap ! = null) { LoaderResult result = new LoaderResult(imageView, uri, bitmap); mMainHandler.obtainMessage(MESSAGE_POST_RESULT, result). sendToTarget(); } } }; THREAD_POOL_EXECUTOR.execute(loadBitmapTask); } 从bindBitmap的实现来看,bindBitmap方法会尝试从内存缓存中读取图片,如果读取成功就直接返回结果,否则会在线程池中去调用loadBitmap方法,当图片加载成功后再将图片、图片的地址以及需要绑定的imageView封装成一个LoaderResult对象,然后再通过mMainHandler向主线程发送一个消息,这样就可以在主线程中给imageView设置图片了,之所以通过Handler来中转是因为子线程无法访问UI。 bindBitmap中用到了线程池和Handler,这里看一下它们的实现,首先看线程池THREAD_POOL_EXECUTOR的实现,如下所示。可以看出它的核心线程数为当前设备的CPU核心数加1,最大容量为CPU核心数的2倍加1,线程闲置超时时长为10秒,关于线程池的详细介绍可以参看第11章的有关内容。 private static final int CORE_POOL_SIZE = CPU_COUNT + 1; private static final int MAXIMUM_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); public Thread newThread(Runnable r) { return new Thread(r, "ImageLoader#" + mCount.getAndIncrement()); } }; public static final Executor THREAD_POOL_EXECUTOR = new ThreadPoolExecutor( CORE_POOL_SIZE, MAXIMUM_POOL_SIZE, KEEP_ALIVE, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>(), sThreadFactory); 之所以采用线程池是有原因的,首先肯定不能采用普通的线程去做这个事,线程池的好处在第11章已经做了详细的说明。如果直接采用普通的线程去加载图片,随着列表的滑动这可能会产生大量的线程,这样并不利于整体效率的提升。另外一点,这里也没有选择采用AsyncTask, AsyncTask封装了线程池和Handler,按道理它应该适合ImageLoader的场景。从第11章中对AsyncTask的分析可以知道,AsyncTask在3.0的低版本和高版本上具有不同的表现,在3.0以上的版本AsyncTask无法实现并发的效果,这显然是不能接受的,因为ImageLoader就是需要并发特性,虽然可以通过改造AsyncTask或者使用AsyncTask的executeOnExecutor方法的形式来执行异步任务,但是这终归是不太自然的实现方式。鉴于以上两点原因,这里选择线程池和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; imageView.setImageBitmap(result.bitmap); String uri = (String) imageView.getTag(TAG_KEY_URI); if (uri.equals(result.uri)) { imageView.setImageBitmap(result.bitmap); } else { Log.w(TAG, "set image bitmap, but url has changed, ignored! "); } }; }; 到此为止,ImageLoader的细节都已经做了全面的分析,下面是ImageLoader的完整的代码。 public class ImageLoader { private static final String TAG = "ImageLoader"; public static final int MESSAGE_POST_RESULT = 1; private static final int CPU_COUNT = Runtime.getRuntime().available- Processors(); private static final int CORE_POOL_SIZE = CPU_COUNT + 1; private static final int MAXIMUM_POOL_SIZE = CPU_COUNT * 2 + 1; private static final long KEEP_ALIVE = 10L; private static final int TAG_KEY_URI = R.id.imageloader_uri; private static final long DISK_CACHE_SIZE = 1024 * 1024 * 50; private static final int IO_BUFFER_SIZE = 8 * 1024; 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); public Thread newThread(Runnable r) { return new Thread(r, "ImageLoader#" + mCount.getAndIncrement()); } }; public static final Executor THREAD_POOL_EXECUTOR = new ThreadPool- Executor( CORE_POOL_SIZE, MAXIMUM_POOL_SIZE, KEEP_ALIVE, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>(), sThreadFactory); private Handler mMainHandler = new Handler(Looper.getMainLooper()) { @Override public void handleMessage(Message msg) { LoaderResult result = (LoaderResult) msg.obj; ImageView imageView = result.imageView; imageView.setImageBitmap(result.bitmap); String uri = (String) imageView.getTag(TAG_KEY_URI); if (uri.equals(result.uri)) { imageView.setImageBitmap(result.bitmap); } else { Log.w(TAG, "set image bitmap, but url has changed, ignored! "); } }; }; private Context mContext; private ImageResizer mImageResizer = new ImageResizer(); 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; mMemoryCache = new LruCache<String, Bitmap>(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(); } } } /** * build a new instance of ImageLoader * @param context * @return a new instance of ImageLoader */ public static ImageLoader build(Context context) { return new ImageLoader(context); } private void addBitmapToMemoryCache(String key, Bitmap bitmap) { if (getBitmapFromMemCache(key) == null) { mMemoryCache.put(key, bitmap); } } private Bitmap getBitmapFromMemCache(String key) { return mMemoryCache.get(key); } /** * load bitmap from memory cache or disk cache or network async, then bind imageView and bitmap. * NOTE THAT: should run in UI Thread * @param uri http url * @param imageView bitmap's bind object */ 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); Bitmap bitmap = loadBitmapFromMemCache(uri); if (bitmap ! = null) { imageView.setImageBitmap(bitmap); return; } Runnable loadBitmapTask = new Runnable() { @Override public void run() { Bitmap bitmap = loadBitmap(uri, reqWidth, reqHeight); if (bitmap ! = null) { LoaderResult result = new LoaderResult(imageView, uri, bitmap); mMainHandler.obtainMessage(MESSAGE_POST_RESULT, result). sendToTarget(); } } }; THREAD_POOL_EXECUTOR.execute(loadBitmapTask); } /** * load bitmap from memory cache or disk cache or network. * @param uri http url * @param reqWidth the width ImageView desired * @param reqHeight the height ImageView desired * @return bitmap, maybe null. */ public Bitmap loadBitmap(String uri, int reqWidth, int reqHeight) { Bitmap bitmap = loadBitmapFromMemCache(uri); if (bitmap ! = null) { Log.d(TAG, "loadBitmapFromMemCache, url:" + uri); return bitmap; } try { bitmap = loadBitmapFromDiskCache(uri, reqWidth, reqHeight); if (bitmap ! = null) { Log.d(TAG, "loadBitmapFromDisk, url:" + uri); return bitmap; } bitmap = loadBitmapFromHttp(uri, reqWidth, reqHeight); Log.d(TAG, "loadBitmapFromHttp, url:" + uri); } catch (IOException e) { e.printStackTrace(); } if (bitmap == null && ! mIsDiskLruCacheCreated) { Log.w(TAG, "encounter error, DiskLruCache is not created."); bitmap = downloadBitmapFromUrl(uri); } return bitmap; } private Bitmap loadBitmapFromMemCache(String url) { final String key = hashKeyFormUrl(url); Bitmap bitmap = getBitmapFromMemCache(key); return bitmap; } private Bitmap loadBitmapFromHttp(String url, int reqWidth, int reqHeight) throws IOException { if (Looper.myLooper() == Looper.getMainLooper()) { throw new RuntimeException("can not visit network from UI Thread."); } if (mDiskLruCache == null) { return null; } String key = hashKeyFormUrl(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, reqHeight); } private Bitmap loadBitmapFromDiskCache(String url, int reqWidth, int reqHeight) throws IOException { if (Looper.myLooper() == Looper.getMainLooper()) { Log.w(TAG, "load bitmap from UI Thread, it's not recommended! "); } if (mDiskLruCache == null) { return null; } Bitmap bitmap = null; String key = hashKeyFormUrl(url); DiskLruCache.Snapshot snapShot = mDiskLruCache.get(key); if (snapShot ! = null) { FileInputStream fileInputStream = (FileInputStream)snapShot. getInputStream(DISK_CACHE_INDEX); FileDescriptor fileDescriptor = fileInputStream.getFD(); bitmap = mImageResizer.decodeSampledBitmapFromFileDescriptor (fileDescriptor, reqWidth, reqHeight); if (bitmap ! = null) { addBitmapToMemoryCache(key, bitmap); } } return bitmap; } 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(outputStream, IO_BUFFER_SIZE); int b; while ((b = in.read()) ! = -1) { out.write(b); } return true; } catch (IOException e) { Log.e(TAG, "downloadBitmap failed." + e); } finally { if (urlConnection ! = null) { urlConnection.disconnect(); } MyUtils.close(out); MyUtils.close(in); } return false; } private Bitmap downloadBitmapFromUrl(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(), IO_BUFFER_SIZE); bitmap = BitmapFactory.decodeStream(in); } catch (final IOException e) { Log.e(TAG, "Error in downloadBitmap: " + e); } finally { if (urlConnection ! = null) { urlConnection.disconnect(); } MyUtils.close(in); } return bitmap; } private String hashKeyFormUrl(String url) { String cacheKey; try { final MessageDigest mDigest = MessageDigest.getInstance("MD5"); mDigest.update(url.getBytes()); cacheKey = bytesToHexString(mDigest.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(); } public File getDiskCacheDir(Context context, String uniqueName) { 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 + uniqueName); } @TargetApi(VERSION_CODES.GINGERBREAD) private long getUsableSpace(File path) { if (Build.VERSION.SDK_INT >= 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; } } }