Android加载图片你必须知道的技巧

学习如何处理和加载Bitmap,显示在UI上非常的重要。如果你不重视这块,Bitmap讲很快耗尽你的内存资源,最终导致oom内存溢出。

  • 移动设备的内存资源很稀缺,很多时候每个应用只能分配到16MB的内存空间。部分机型可能分配的会更多,但是我们必须保证不超过最大内存的限制。
  • Bitmaps本身就非常占用资源。比如一个Galaxy Nexus拍一张照片2592x1936分辨率。如果使用ARGB_8888(2.3版本以后默认值)加载bitmap的话,加载这张图将耗费将近19MB(2592*1936*4 bytes)的内存,直接就超过了很多机器的最大内存。
  • 有时候针对ListView, GridView 和 ViewPager 这种控件,我们会有显示很多图片的需求,也要为即将可能显示在屏幕上的图片做处理,让图片为显示做好准备。

加载大图片

图片什么大小和形状都有。经常图片比你的UI控件大得多。例如摄像头拍出来的照片比你手机屏幕的分辨率高得多。

获取Bitmap大小和类型

BitmapFactory提供了很多通过各种各样渠道解码的方法(decodeByteArray(), decodeFile(), decodeResource(), 等等.) 。这些方法都很容易引起oom内存溢出。每个方法都可以通过BitmapFactory.Options这个类去指定解码选项。把inJustDecodeBounds设定成true,然后进行解码,这个时候就不会真的去分配内存,而是返回空的bitmap同时也会返回outWidth, outHeight 和 outMimeType.有了这三个值,我们就可以按照需要对图片进行压缩。

BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(getResources(), R.id.myimage, options);
int imageHeight = options.outHeight;
int imageWidth = options.outWidth;
String imageType = options.outMimeType;

除非你对图片的来源有绝对的信心,不然建议每次解码都需要检查图片的大小和类型。

按比例压缩后加载

你需要注意这些问题
- 预估加载整个图片需要多少内存
- 愿意从整个应用中,分配多少内存给这张图
- 目标UI控件比如ImageView 的大小
- 当前设备的屏幕分辨率和尺寸

例如把一个1024x768的图片全部加载显示在一个128x96像素的ImageView.上是没有价值的。
下面是根据目标控件传入的宽和高,计算出合适的inSampleSize值的方法。

public static int calculateInSampleSize(
            BitmapFactory.Options options, int reqWidth, int reqHeight) {
    // Raw height and width of image
    final int height = options.outHeight;
    final int width = options.outWidth;
    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;
        }
    }

    return inSampleSize;
}

使用这个方法,第一次解码设置inJustDecodeBounds为true,得到合适的inSampleSize后,设置inJustDecodeBounds为false,然后第二次解码。

public static 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);
}

这个方法可以把很大的图,很方便的展示在很小的ImageView上。

mImageView.setImageBitmap(
    decodeSampledBitmapFromResource(getResources(), R.id.myimage, 100, 100));

避免在UI线程上处理图片

如果图片来源在磁盘或者网络上(或者其他内存之外的媒介),上面讨论的BitmapFactory.decode*方法不应该在主UI线程上执行。加载图片需要的时间是不可预知的,依赖于很多因素(磁盘读取速度,网速,图片大小,CPU速度等)。任何一个工作都可以阻塞UI线程,造成无响应。下面说说用子线程加载bitmaps的问题。

AsyncTask

AsyncTask应该是大家都很熟悉的子线程通知UI线程修改的方法。

class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> {
    private final WeakReference<ImageView> imageViewReference;
    private int data = 0;

    public BitmapWorkerTask(ImageView imageView) {
        // Use a WeakReference to ensure the ImageView can be garbage collected
        imageViewReference = new WeakReference<ImageView>(imageView);
    }

    // Decode image in background.
    @Override
    protected Bitmap doInBackground(Integer... params) {
        data = params[0];
        return decodeSampledBitmapFromResource(getResources(), data, 100, 100));
    }

    // Once complete, see if ImageView is still around and set bitmap.
    @Override
    protected void onPostExecute(Bitmap bitmap) {
        if (imageViewReference != null && bitmap != null) {
            final ImageView imageView = imageViewReference.get();
            if (imageView != null) {
                imageView.setImageBitmap(bitmap);
            }
        }
    }
}

这段代码很简单,但是可以看到好的代码习惯,比如使用软引用避免AsyncTask的引用导致内存泄漏。判断是否为空,避免空指针异常。使用代码如下:

public void loadBitmap(int resId, ImageView imageView) {
    BitmapWorkerTask task = new BitmapWorkerTask(imageView);
    task.execute(resId);
}

并发的使用

如果我们使用ListView 和 GridView这种重复利用子控件的UI控件时,如果我们使用上面的AsyncTask,当任务完成的时候 无法保证该子控件是否已经被重用了。此外,任务开始的顺序和完成的顺序也无法保证。
下面是解决方案,创建一个专门的 Drawable 子类来储存载入图片的任务引用。这样使用BitmapDrawable,当任务完成的时候placeholder 中的图片就能在ImageView显示了。

static class AsyncDrawable extends BitmapDrawable {
    private final WeakReference<BitmapWorkerTask> bitmapWorkerTaskReference;

    public AsyncDrawable(Resources res, Bitmap bitmap,
            BitmapWorkerTask bitmapWorkerTask) {
        super(res, bitmap);
        bitmapWorkerTaskReference =
            new WeakReference<BitmapWorkerTask>(bitmapWorkerTask);
    }

    public BitmapWorkerTask getBitmapWorkerTask() {
        return bitmapWorkerTaskReference.get();
    }
}

在执行 BitmapWorkerTask前,创建一个AsyncDrawable 并且绑定到目标 ImageView中:

public void loadBitmap(int resId, ImageView imageView) {
    if (cancelPotentialWork(resId, imageView)) {
        final BitmapWorkerTask task = new BitmapWorkerTask(imageView);
        final AsyncDrawable asyncDrawable =
                new AsyncDrawable(getResources(), mPlaceHolderBitmap, task);
        imageView.setImageDrawable(asyncDrawable);
        task.execute(resId);
    }
}

cancelPotentialWork方法用来检查是否已经有任务绑定到,如果已经有一个任务了就尝试取消这个任务(调用 cancel()方法)。
在少数情况下,如果新的任务和已经存在任务的数据一样,则不需要特殊额外的处理。下面是 cancelPotentialWork 方法的一种实现:

public static boolean cancelPotentialWork(int data, ImageView imageView) {
    final BitmapWorkerTask bitmapWorkerTask = getBitmapWorkerTask(imageView);

    if (bitmapWorkerTask != null) {
        final int bitmapData = bitmapWorkerTask.data;
        // If bitmapData is not yet set or it differs from the new data
        if (bitmapData == 0 || bitmapData != data) {
            // Cancel previous task
            bitmapWorkerTask.cancel(true);
        } else {
            // The same work is already in progress
            return false;
        }
    }
    // No task associated with the ImageView, or an existing task was cancelled
    return true;
}

方法 getBitmapWorkerTask(),获取和 ImageView关联的任务:

private static BitmapWorkerTask getBitmapWorkerTask(ImageView imageView) {
   if (imageView != null) {
       final Drawable drawable = imageView.getDrawable();
       if (drawable instanceof AsyncDrawable) {
           final AsyncDrawable asyncDrawable = (AsyncDrawable) drawable;
           return asyncDrawable.getBitmapWorkerTask();
       }
    }
    return null;
}

最后一步是在BitmapWorkerTask执行更新 onPostExecute() 。
检查任务是否取消了,和当前的任务和 ImageView引用的任务是否为同一个任务。

class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> {
    ...

    @Override
    protected void onPostExecute(Bitmap bitmap) {
        if (isCancelled()) {
            bitmap = null;
        }

        if (imageViewReference != null && bitmap != null) {
            final ImageView imageView = imageViewReference.get();
            final BitmapWorkerTask bitmapWorkerTask =
                    getBitmapWorkerTask(imageView);
            if (this == bitmapWorkerTask && imageView != null) {
                imageView.setImageBitmap(bitmap);
            }
        }
    }
}

这套实现适用于ListView 和 GridView ,也适用于其他有子控件回收机制的控件。简单的在设置ImageView图片的地方调用loadBitmap 方法就可以。比如在GridView的getView方法中调用loadBitmap。

缓存Bitmaps

加载一张图片很简单,加载一大堆图片就麻烦了,比如 ListView, GridView 或者 ViewPager。LruCache 是官方推荐的缓存图片的类,低版本可以使用v4支持包。

  • 你的设备可以为每个应用程序分配多大的内存?
  • 设备屏幕上一次最多能显示多少张图片?有多少图片需要进行预加载,因为有可能很快也会显示在屏幕上?
  • 你的设备的屏幕大小和分辨率分别是多少?一个超高分辨率的设备(例如 Galaxy Nexus) 比起一个较低分辨率的设备(例如 Nexus S),在持有相同数量图片的时候,需要更大的缓存空间。
  • 图片的尺寸和大小,还有每张图片会占据多少内存空间。
  • 图片被访问的频率有多高?会不会有一些图片的访问频率比其它图片要高?如果有的话,你也许应该让一些图片常驻在内存当中,或者使用多个LruCache 对象来区分不同组的图片。
  • 你能维持好数量和质量之间的平衡吗?有些时候,存储多个低像素的图片,而在后台去开线程加载高像素的图片会更加的有效。

下面是一个例子:

private LruCache<String, Bitmap> mMemoryCache;

@Override
protected void onCreate(Bundle savedInstanceState) {
    ...
    // Get max available VM memory, exceeding this amount will throw an
    // OutOfMemory exception. Stored in kilobytes as LruCache takes an
    // int in its constructor.
    final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);

    // Use 1/8th of the available memory for this memory cache. 使用最大可以内存的八分之一作为LruCache的缓存大小
    final int cacheSize = maxMemory / 8;

    mMemoryCache = new LruCache<String, Bitmap>(cacheSize) {
        @Override
        protected int sizeOf(String key, Bitmap bitmap) {
            // The cache size will be measured in kilobytes rather than 这个是必须实现的,返回的是每个bitmap的大小,每次添加bitmap时都会调用
            // number of items.
            return bitmap.getByteCount() / 1024;
        }
    };
    ...
}

public void addBitmapToMemoryCache(String key, Bitmap bitmap) {
    if (getBitmapFromMemCache(key) == null) {
        mMemoryCache.put(key, bitmap);
    }
}

public Bitmap getBitmapFromMemCache(String key) {
    return mMemoryCache.get(key);
}

当加载bitmap到ImageView时候,LruCache先检查如果找到了键,就直接更新ImageView,否则在开启线程处理图片。

public void loadBitmap(int resId, ImageView imageView) {
    final String imageKey = String.valueOf(resId);

    final Bitmap bitmap = getBitmapFromMemCache(imageKey);
    if (bitmap != null) {
        mImageView.setImageBitmap(bitmap);
    } else {
        mImageView.setImageResource(R.drawable.image_placeholder);
        BitmapWorkerTask task = new BitmapWorkerTask(mImageView);
        task.execute(resId);
    }
}

BitmapWorkerTask 也需要把新加载的图片的键值对放到缓存中。

class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> {
    ...
    // Decode image in background.
    @Override
    protected Bitmap doInBackground(Integer... params) {
        final Bitmap bitmap = decodeSampledBitmapFromResource(
                getResources(), params[0], 100, 100));
        addBitmapToMemoryCache(String.valueOf(params[0]), bitmap);
        return bitmap;
    }
    ...
}

使用磁盘缓存

如果使用GridView LruCache很容易超出内存限制, DiskLruCache是官方推荐做持久化的缓存类,把图片缓存到磁盘内避免反复网络下载图片也能提高加载速度。

private DiskLruCache mDiskLruCache;
private final Object mDiskCacheLock = new Object();
private boolean mDiskCacheStarting = true;
private static final int DISK_CACHE_SIZE = 1024 * 1024 * 10; // 10MB
private static final String DISK_CACHE_SUBDIR = "thumbnails";

@Override
protected void onCreate(Bundle savedInstanceState) {
    ...
    // Initialize memory cache
    ...
    // Initialize disk cache on background thread
    File cacheDir = getDiskCacheDir(this, DISK_CACHE_SUBDIR);
    new InitDiskCacheTask().execute(cacheDir);
    ...
}

class InitDiskCacheTask extends AsyncTask<File, Void, Void> {
    @Override
    protected Void doInBackground(File... params) {
        synchronized (mDiskCacheLock) {
            File cacheDir = params[0];
            mDiskLruCache = DiskLruCache.open(cacheDir, DISK_CACHE_SIZE);
            mDiskCacheStarting = false; // Finished initialization
            mDiskCacheLock.notifyAll(); // Wake any waiting threads
        }
        return null;
    }
}

class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> {
    ...
    // Decode image in background.
    @Override
    protected Bitmap doInBackground(Integer... params) {
        final String imageKey = String.valueOf(params[0]);

        // Check disk cache in background thread
        Bitmap bitmap = getBitmapFromDiskCache(imageKey);

        if (bitmap == null) { // Not found in disk cache
            // Process as normal
            final Bitmap bitmap = decodeSampledBitmapFromResource(
                    getResources(), params[0], 100, 100));
        }

        // Add final bitmap to caches
        addBitmapToCache(imageKey, bitmap);

        return bitmap;
    }
    ...
}

public void addBitmapToCache(String key, Bitmap bitmap) {
    // Add to memory cache as before
    if (getBitmapFromMemCache(key) == null) {
        mMemoryCache.put(key, bitmap);
    }

    // Also add to disk cache
    synchronized (mDiskCacheLock) {
        if (mDiskLruCache != null && mDiskLruCache.get(key) == null) {
            mDiskLruCache.put(key, bitmap);
        }
    }
}

public Bitmap getBitmapFromDiskCache(String key) {
    synchronized (mDiskCacheLock) {
        // Wait while disk cache is started from background thread
        while (mDiskCacheStarting) {
            try {
                mDiskCacheLock.wait();
            } catch (InterruptedException e) {}
        }
        if (mDiskLruCache != null) {
            return mDiskLruCache.get(key);
        }
    }
    return null;
}

// Creates a unique subdirectory of the designated app cache directory. Tries to use external
// but if not mounted, falls back on internal storage.
public static File getDiskCacheDir(Context context, String uniqueName) {
    // Check if media is mounted or storage is built-in, if so, try and use external cache dir
    // otherwise use internal cache dir
    final String cachePath =
            Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState()) ||
                    !isExternalStorageRemovable() ? getExternalCacheDir(context).getPath() :
                            context.getCacheDir().getPath();

    return new File(cachePath + File.separator + uniqueName);
}

当图片加载完毕,图片应该同时缓存到memory和磁盘中,以便以后加载。

处理配置变化

当配置发生变化,例如横竖屏切换的时候,安卓会销毁使用新的配置重建activity。为了避免图片重新处理,我们需要对代码做一些修改。

这是一个Fragment中保留LruCache不因为配置变化而重新加载的例子。

private LruCache<String, Bitmap> mMemoryCache;

@Override
protected void onCreate(Bundle savedInstanceState) {
    ...
    RetainFragment retainFragment =
            RetainFragment.findOrCreateRetainFragment(getFragmentManager());
    mMemoryCache = retainFragment.mRetainedCache;
    if (mMemoryCache == null) {
        mMemoryCache = new LruCache<String, Bitmap>(cacheSize) {
            ... // Initialize cache here as usual
        }
        retainFragment.mRetainedCache = mMemoryCache;
    }
    ...
}

class RetainFragment extends Fragment {
    private static final String TAG = "RetainFragment";
    public LruCache<String, Bitmap> mRetainedCache;

    public RetainFragment() {}

    public static RetainFragment findOrCreateRetainFragment(FragmentManager fm) {
        RetainFragment fragment = (RetainFragment) fm.findFragmentByTag(TAG);
        if (fragment == null) {
            fragment = new RetainFragment();
            fm.beginTransaction().add(fragment, TAG).commit();
        }
        return fragment;
    }

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setRetainInstance(true);
    }
}

使用第三方图片加载库

处理图片是每个开发者都无法避免的大问题,幸运的是已经有很多非常成熟的第三方图片加载库。笔者用过市面上主流的四种加载库。ImageLoader,Picasso,Glide,Fresco,甚至Volley的图片加载也尝试过。每个加载库各有千秋,可以根据需求做选择,大大提升开发速度,减少oom异常的概率。笔者更推荐来自facebook的Fresco,在使用过程中,渐进式显示,三级内存在低端机上体验更好。送上github上的链接: https://github.com/facebook/fresco 。中文文档非常详尽,地址: http://fresco-cn.org/ 。开发者可以根据自己的需求,选择适合自己项目的库。

你可能感兴趣的:(android)