Android 高效安全加载图片

1. 概述

Android 应用程序的设计中,几乎不可避免地都需要加载和显示图片,由于不同的图片在大小上千差万别,有些图片可能只需要几十KB的内存空间,有些图片却需要占用几十MB的内存空间;或者一张图片不需要占用太多的内存,但是需要同时加载和显示多张图片。

在这些情况下,加载图片都需要占用大量的内存,而 Android系统分配给每个进程的内存空间是有限的,如果加载的图片所需要的内存超过了限制,进程就会出现 OOM,即内存溢出。

本文针对加载大图片或者一次加载多张图片等两种不同的场景,采用不同的加载方式,以尽量避免可能导致的内存溢出问题。

2. 加载大图片

有时一张图片的加载和显示就需要占用大量的内存,例如图片的大小是 2592x1936 ,同时采用的位图配置是 ARGB_8888 ,其在内存中需要的大小是 2592x1936x4字节,大概是 19MB。仅仅加载这样一张图片就可能会超过进程的内存限制,进而导致内存溢出,所以在实际使用时肯定无法直接加载到内存中。

为了避免内存溢出,根据不同的显示需求,采取不同的加载方式:

  • 显示一张图片的全部内容:对原图片进行 压缩显示
  • 显示一张图片的部分内容:对原图片进行 局部显示

2.1 图片压缩显示

图片的压缩显示指的是对原图片进行长宽的压缩,以减少图片的内存占用,使其能够在应用上正常显示,同时保证在加载和显示过程中不会出现内存溢出的情况。 BitmapFactory 是一个创建Bitmap 对象的工具类,使用它可以利用不同来源的数据生成Bitamp对象,在创建过的过程中还可以对需要生成的对象进行不同的配置和控制,BitmapFactory的类声明如下:

Creates Bitmap objects from various sources, including files, streams,and byte-arrays.
复制代码

由于在加载图片前,是无法提前预知图片大小的,所以在实际加载前必须根据图片的大小和当前进程的内存情况来决定是否需要对图片进行压缩,如果加载原图片所需的内存空间已经超过了进程打算提供或可以提供的内存大小,就必须考虑压缩图片。

2.1.1 确定原图片长宽

简单来说,压缩图片就是对原图的长宽按照一定的比例进行缩小,所以首先要确定原图的长宽信息。为了获得图片的长宽信息,利用 BitmapFactory.decodeResource(Resources res, int id, Options opts) 接口,其声明如下:

    /**
     * Synonym for opening the given resource and calling
     * {@link #decodeResourceStream}.
     *
     * @param res   The resources object containing the image data
     * @param id The resource id of the image data
     * @param opts null-ok; Options that control downsampling and whether the
     *             image should be completely decoded, or just is size returned.
     * @return The decoded bitmap, or null if the image data could not be
     *         decoded, or, if opts is non-null, if opts requested only the
     *         size be returned (in opts.outWidth and opts.outHeight)
     * @throws IllegalArgumentException if {@link BitmapFactory.Options#inPreferredConfig}
     *         is {@link android.graphics.Bitmap.Config#HARDWARE}
     *         and {@link BitmapFactory.Options#inMutable} is set, if the specified color space
     *         is not {@link ColorSpace.Model#RGB RGB}, or if the specified color space's transfer
     *         function is not an {@link ColorSpace.Rgb.TransferParameters ICC parametric curve}
     */
    public static Bitmap decodeResource(Resources res, int id, Options opts) {
复制代码

通过这个函数声明,可以看到通过这个接口可以得到图片的长宽信息,同时由于返回 null并不申请内存空间,避免了不必要的内存申请。

为了得到图片的长宽信息,必须传递一个 Options 参数,其中的 inJustDecodeBounds 设置为 true,其声明如下:

   /**
     * If set to true, the decoder will return null (no bitmap), but
     * the <code>out...</code> fields will still be set, allowing the caller to
     * query the bitmap without having to allocate the memory for its pixels.
     */
    public boolean inJustDecodeBounds;
复制代码

下面给出得到图片长宽信息的示例代码:

    BitmapFactory.Options options = new BitmapFactory.Options();
    // 指定在解析图片文件时,仅仅解析边缘信息而不创建 bitmap 对象。
    options.inJustDecodeBounds = true;
    // R.drawable.test 是使用的 2560x1920 的测试图片资源文件。
    BitmapFactory.decodeResource(getResources(), R.drawable.test, options);
    int width = options.outWidth;
    int height = options.outHeight;
    Log.i(TAG, "width: " + width + ", height: " + height);
复制代码

在实际测试中,得到的长宽信息如下:

    01-05 04:06:23.022 29836 29836 I Android_Test: width: 2560, height: 1920
复制代码

2.1.2 确定目标压缩比例

得知原图片的长宽信息后,为了能够进行后续的压缩操作,必须要先确定目标压缩比例。所谓压缩比例就是指要对原始的长宽进行的裁剪比例,如果如果原图片是 2560x1920,采取的压缩比例是 4,进行压缩后的图片是 640x480,最终大小是原图片的1/16。 压缩比例在 BitmapFactory.Options中对应的属性是 inSampleSize,其声明如下:

    /**
     * If set to a value > 1, requests the decoder to subsample the original
     * image, returning a smaller image to save memory. The sample size is
     * the number of pixels in either dimension that correspond to a single
     * pixel in the decoded bitmap. For example, inSampleSize == 4 returns
     * an image that is 1/4 the width/height of the original, and 1/16 the
     * number of pixels. Any value <= 1 is treated the same as 1. Note: the
     * decoder uses a final value based on powers of 2, any other value will
     * be rounded down to the nearest power of 2.
     */
    public int inSampleSize;
复制代码

需要特别注意的是,inSampleSize 只能是 2的幂,如果传入的值不满足条件,解码器会选择一个和传入值最节俭的2的幂;如果传入的值小于 1,解码器会直接使用1

要确定最终的压缩比例,首先要确定目标大小,即压缩后的目标图片的长宽信息,根据原始长宽和目标长宽来选择一个最合适的压缩比例。下面给出示例代码:

    /**
     * @param originWidth the width of the origin bitmap
     * @param originHeight the height of the origin bitmap
     * @param desWidth the max width of the desired bitmap
     * @param desHeight the max height of the desired bitmap
     * @return the optimal sample size to make sure the size of bitmap is not more than the desired.
     */
    public static int calculateSampleSize(int originWidth, int originHeight, int desWidth, int desHeight) {
        int sampleSize = 1;
        int width = originWidth;
        int height = originHeight;
        while((width / sampleSize) > desWidth && (height / sampleSize) > desHeight) {
            sampleSize *= 2;
        }
        return sampleSize;
    }
复制代码

需要注意的是这里的desWidthdesHeight 是目标图片的最大长宽值,而不是最终的大小,因为通过这个方法确定的压缩比例会保证最终的图片长宽不大于目标值。 在实际测试中,把原图片大小设置为2560x1920,把目标图片大小设置为100x100:

    int sampleSize = BitmapCompressor.calculateSampleSize(2560, 1920, 100, 100);
    Log.i(TAG, "sampleSize: " + sampleSize);
复制代码

测试结果如下:

    01-05 04:42:07.752  8835  8835 I Android_Test: sampleSize: 32
复制代码

最终得到的压缩比例是32,如果使用这个比例去压缩2560x1920的图片,最终得到80x60的图片。

2.1.3 压缩图片

在前面两部分,分别确定了原图片的长宽信息和目标压缩比例,其实确定原图片的长宽也是为了得到压缩比例,既然已经得到的压缩比较,就可以进行实际的压缩操作了,只需要把得到的inSampleSize通过Options传递给BitmapFactory.decodeResource(Resources res, int id, Options opts)即可。 下面是示例代码:

    public static Bitmap compressBitmapResource(Resources res, int resId, int inSampleSize) {
        BitmapFactory.Options options = new BitmapFactory.Options();
        options.inJustDecodeBounds = false;
        options.inSampleSize = inSampleSize;
        return BitmapFactory.decodeResource(res, resId, options);
    }
复制代码

2.2 图片局部显示

图片压缩会在一定程度上影响图片质量和显示效果,在某些场景下并不可取,例如地图显示时要求必须是高质量图片,这时就不能进行压缩处理,在这种场景下其实并不要求要一次显示图片的所有部分,可以考虑一次只加载和显示图片的特定部分,即***局部显示***。

要实现局部显示的效果,可以使用BitmapRegionDecoder 来实现,它就是用来对图片的特定部分进行显示的,尤其是在原图片特别大而无法一次全部加载到内存的场景下,其声明如下:

    /**
     * BitmapRegionDecoder can be used to decode a rectangle region from an image.
     * BitmapRegionDecoder is particularly useful when an original image is large and
     * you only need parts of the image.
     *
     * <p>To create a BitmapRegionDecoder, call newInstance(...).
     * Given a BitmapRegionDecoder, users can call decodeRegion() repeatedly
     * to get a decoded Bitmap of the specified region.
     *
     */
    public final class BitmapRegionDecoder { ... }
复制代码

这里也说明了如果使用BitmapRegionDecoder进行局部显示:首先通过newInstance()创建实例,再利用decodeRegion()对指定区域的图片内存创建Bitmap对象,进而在显示控件中显示。

通过BitmapRegionDecoder.newInstance()创建解析器实例,其函数声明如下:

    /**
     * Create a BitmapRegionDecoder from an input stream.
     * The stream's position will be where ever it was after the encoded data
     * was read.
     * Currently only the JPEG and PNG formats are supported.
     *
     * @param is The input stream that holds the raw data to be decoded into a
     *           BitmapRegionDecoder.
     * @param isShareable If this is true, then the BitmapRegionDecoder may keep a
     *                    shallow reference to the input. If this is false,
     *                    then the BitmapRegionDecoder will explicitly make a copy of the
     *                    input data, and keep that. Even if sharing is allowed,
     *                    the implementation may still decide to make a deep
     *                    copy of the input data. If an image is progressively encoded,
     *                    allowing sharing may degrade the decoding speed.
     * @return BitmapRegionDecoder, or null if the image data could not be decoded.
     * @throws IOException if the image format is not supported or can not be decoded.
     *
     * <p class="note">Prior to {@link android.os.Build.VERSION_CODES#KITKAT},
     * if {@link InputStream#markSupported is.markSupported()} returns true,
     * <code>is.mark(1024)</code> would be called. As of
     * {@link android.os.Build.VERSION_CODES#KITKAT}, this is no longer the case.</p>
     */
    public static BitmapRegionDecoder newInstance(InputStream is,
            boolean isShareable) throws IOException { ... }
复制代码

需要注意的是,这只是BitmapRegionDecoder其中一个newInstance函数,除此之外还有其他的实现形式,读者有兴趣可以自己查阅。 在创建得到BitmapRegionDecoder实例后,可以调用decodeRegion方法来创建局部Bitmap对象,其函数声明如下:

    /**
     * Decodes a rectangle region in the image specified by rect.
     *
     * @param rect The rectangle that specified the region to be decode.
     * @param options null-ok; Options that control downsampling.
     *             inPurgeable is not supported.
     * @return The decoded bitmap, or null if the image data could not be
     *         decoded.
     * @throws IllegalArgumentException if {@link BitmapFactory.Options#inPreferredConfig}
     *         is {@link android.graphics.Bitmap.Config#HARDWARE}
     *         and {@link BitmapFactory.Options#inMutable} is set, if the specified color space
     *         is not {@link ColorSpace.Model#RGB RGB}, or if the specified color space's transfer
     *         function is not an {@link ColorSpace.Rgb.TransferParameters ICC parametric curve}
     */
    public Bitmap decodeRegion(Rect rect, BitmapFactory.Options options) { ... }
复制代码

由于这部分比较简单,下面直接给出相关示例代码:

    // 解析得到原图的长宽值,方便后面进行局部显示时指定需要显示的区域。
    BitmapFactory.Options options = new BitmapFactory.Options();
    options.inJustDecodeBounds = true;
    BitmapFactory.decodeResource(getResources(), R.drawable.test, options);
    int width = options.outWidth;
    int height = options.outHeight;

    try {
        // 创建局部解析器 
        InputStream inputStream = getResources().openRawResource(R.drawable.test);
        BitmapRegionDecoder decoder = BitmapRegionDecoder.newInstance(inputStream,false);
        
        // 指定需要显示的矩形区域,这里要显示的原图的左上 1/4 区域。
        Rect rect = new Rect(0, 0, width / 2, height / 2);

        // 创建位图配置,这里使用 RGB_565,每个像素占 2 字节。
        BitmapFactory.Options regionOptions = new BitmapFactory.Options();
        regionOptions.inPreferredConfig = Bitmap.Config.RGB_565;
        
        // 创建得到指定区域的 Bitmap 对象并进行显示。
        Bitmap regionBitmap = decoder.decodeRegion(rect,regionOptions);
        ImageView imageView = (ImageView) findViewById(R.id.main_image);
        imageView.setImageBitmap(regionBitmap);
    } catch (Exception e) {
        e.printStackTrace();
    }
复制代码

从测试结果看,确实只显示了原图的左上1/4区域的图片内容,这里不再贴出结果。

3. 加载多图片

有时需要在应用中同时显示多张图片,例如使用ListView,GridViewViewPager时,可能会需要在每一项都显示一个图片,这时情况就会变得复杂些,因为可以通过滑动改变控件的可见项,如果每增加一个可见项就加载一个图片,同时不可见项的图片继续在内存中,随着不断的增加,就会导致内存溢出。

为了避免这种情况的内存溢出问题,就需要对不可见项对应的图片资源进行回收,即当前项被滑出屏幕的显示区域时考虑回收相关的图片,这时回收策略对整个应用的性能有较大影响。

  • 立即回收:在当前项被滑出屏幕时立即回收图片资源,但如果被滑出的项很快又被滑入屏幕,就需要重新加载图片,这无疑会导致性能的下降。
  • 延迟回收:在当前项被滑出屏幕时不立即回收,而是根据一定的延迟策略进行回收,这时对延迟策略有较高要求,如果延迟时间太短就退回到立即回收状况,如果延迟时间较长就可能导致一段时间内,内存中存在大量的图片,进而引发内存溢出。 通过上面的分析,针对加载多图的情况,必须要采取延迟回收,而Android提供了一中基于LRU,即最近最少使用策略的内存缓存技术: LruCache, 其基本思想是,以强引用的方式保存外界对象,当缓存空间达到一定限制后,再把最近最少使用的对象释放回收,保证使用的缓存空间始终在一个合理范围内。

其声明如下:

/**
 * A cache that holds strong references to a limited number of values. Each time
 * a value is accessed, it is moved to the head of a queue. When a value is
 * added to a full cache, the value at the end of that queue is evicted and may
 * become eligible for garbage collection.
 */
public class LruCache<K, V> { ... }
复制代码

从声明中,可以了解到其实现LRU的方式:内部维护一个有序队列,每当其中的一个对象被访问就被移动到队首,这样就保证了队列中的对象是根据最近的使用时间从近到远排列的,即队首的对象是最近使用的,队尾的对象是最久之前使用的。正是基于这个规则,如果缓存达到限制后,直接把队尾对象释放即可。

在实际使用中,为了创建LruCache对象,首先要确定该缓存能够使用的内存大小,这是效率的决定性因素。如果缓存内存太小,无法真正发挥缓存的效果,仍然需要频繁的加载和回收资源;如果缓存内存太大,可能导致内存溢出的发生。在确定缓存大小的时候,要结合以下几个因素:

  • 进程可以使用的内存情况
  • 资源的大小和需要一次在界面上显示的资源数量
  • 资源的访问频率

下面给出一个简单的示例:

    // 获得进程可以使用的最大内存量
    int maxMemory = (int) Runtime.getRuntime().maxMemory();
    
    mCache = new LruCache<String, Bitmap>(maxMemory / 4) {
        @Override
        protected int sizeOf(String key, Bitmap value) {
            return value.getByteCount();
        }
    };
复制代码

在示例中简单地把缓存大小设定为进程可以使用的内存的 1/4,当然在实际项目中,要考虑的因素会更多。需要注意的是,在创建LruCache对象的时候需要重写sizeOf方法,它用来返回每个对象的大小,是用来决定当前缓存实际大小并判断是否达到了内存限制。

在创建了LruCache对象后,如果需要使用资源,首先到缓存中去取,如果成功取到就直接使用,否则加载资源并放入缓存中,以方便下次使用。为了加载资源的行为不会影响应用性能,需要在子线程中去进行,可以利用AsyncTask来实现。 下面是示例代码:

    public Bitmap get(String key) {
        Bitmap bitmap = mCache.get(key);
        if (bitmap != null) {
            return bitmap;
        } else {
            new BitmapAsyncTask().execute(key);
            return null;
        }
    }

    private class BitmapAsyncTask extends AsyncTask<String, Void, Bitmap> {
        @Override
        protected Bitmap doInBackground(String... url) {
            Bitmap  bitmap = getBitmapFromUrl(url[0]);
            if (bitmap != null) {
                mCache.put(url[0],bitmap);
            }
            return bitmap;
        }

        private Bitmap getBitmapFromUrl(String url) {
            Bitmap bitmap = null;
            // 在这里要利用给定的 url 信息从网络获取 bitmap 信息.
            return bitmap;
        }
    }
复制代码

示例中,在无法从缓存中获取资源的时候,会根据url信息加载网络资源,当前并没有给出完整的代码,有兴趣的同学可以自己去完善。

4. 总结

本文主要针对不同的图片加载场景提出了不同的加载策略,以保证在加载和显示过程中既然能满足基本的显示需求,又不会导致内存溢出,具体包括针对单个图片的压缩显示,局部显示和针对多图的内存缓存技术,如若有表述不清甚至错误的地方,请及时提出,大家一起学习。

猜你喜欢

转载自juejin.im/post/5c6f88f3f265da2d89632f29