Android 高效安全載入圖片

誰是喬喬發表於2019-02-22

本人只是 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. 總結

本文主要針對不同的圖片載入場景提出了不同的載入策略,以保證在載入和顯示過程中既然能滿足基本的顯示需求,又不會導致記憶體溢位,具體包括針對單個圖片的壓縮顯示,區域性顯示和針對多圖的記憶體快取技術,如若有表述不清甚至錯誤的地方,請及時提出,大家一起學習。

相關文章