ビットマップ メモリの管理

注: ほとんどのケースでは、Glide ライブラリを使用してアプリのビットマップを取得、デコード、表示することをおすすめします。Glide を使用すると、Android におけるビットマップなどの画像の操作に関連するこうした複雑なタスクの処理を簡素化できます。 Glide の使用方法とダウンロード方法については、GitHub の Glide リポジトリをご覧ください。

ビットマップのキャッシュ保存で説明した手順に加え、ガベージ コレクションやビットマップの再利用を促進するために実行できる特定の操作もあります。おすすめの戦略は、対象とする Android のバージョンによって異なります。このクラスに付属の BitmapFun サンプルアプリをご覧いただくことで、Android のさまざまなバージョンで効率的に動作するアプリを設計する方法を理解できます。

このレッスンの本題に入る前に、Android によるビットマップ メモリの管理がどのように進化してきたかを以下に示します。

  • Android 2.2(API レベル 8)以前では、ガベージ コレクションが実行されるとアプリのスレッドが停止していました。これにより遅延が発生し、パフォーマンスが低下する可能性があります。 Android 2.3 では同時実行ガベージ コレクションが追加されました。これは、ビットマップが参照されなくなった直後にメモリが再利用されることを意味します。
  • Android 2.3.3(API レベル 10)以前では、ビットマップのバッキング ピクセルデータがネイティブ メモリに格納されます。このデータはビットマップ自体とは分離されており、ビットマップは Dalvik ヒープに格納されます。ネイティブ メモリ内のピクセルデータは予測可能な方法では解放されないため、アプリのメモリの上限をすぐに超えてクラッシュする可能性があります。Android 3.0(API レベル 11)から Android 7.1(API レベル 25)では、ピクセルデータが関連するビットマップとともに Dalvik ヒープに格納されます。Android 8.0(API レベル 26)以降では、ビットマップ ピクセルデータがネイティブ ヒープに格納されます。

以下のセクションでは、各 Android バージョンでのビットマップ メモリの管理を最適化する方法について説明します。

Android 2.3.3 以前でのメモリの管理

Android 2.3.3(API レベル 10)以前では recycle() を使用することをおすすめします。アプリで大量のビットマップ データを表示すると、OutOfMemoryError エラーが発生する可能性が高くなります。recycle() メソッドを使用すると、アプリでのメモリの再利用をできる限り早く行えるようになります。

注意: recycle() は、ビットマップが使用されなくなったことが確認できた場合にのみ使用してください。recycle() を呼び出して、その後でビットマップを描画しようとすると、エラー "Canvas: trying to use a recycled bitmap" が表示されます。

次のコード スニペットは、recycle() の呼び出しの例を示しています。参照カウント(変数 mDisplayRefCount および mCacheRefCount)を使用して、ビットマップが現在表示されているか、キャッシュに保存されているかを追跡しています。以下の条件が満たされると、コードはビットマップを再利用します。

  • mDisplayRefCountmCacheRefCount の両方の参照カウントが 0。
  • ビットマップが null ではなく、まだ再利用されていない。

Kotlin

private var cacheRefCount: Int = 0
private var displayRefCount: Int = 0
...
// Notify the drawable that the displayed state has changed.
// Keep a count to determine when the drawable is no longer displayed.
fun setIsDisplayed(isDisplayed: Boolean) {
    synchronized(this) {
        if (isDisplayed) {
            displayRefCount++
            hasBeenDisplayed = true
        } else {
            displayRefCount--
        }
    }
    // Check to see if recycle() can be called.
    checkState()
}

// Notify the drawable that the cache state has changed.
// Keep a count to determine when the drawable is no longer being cached.
fun setIsCached(isCached: Boolean) {
    synchronized(this) {
        if (isCached) {
            cacheRefCount++
        } else {
            cacheRefCount--
        }
    }
    // Check to see if recycle() can be called.
    checkState()
}

@Synchronized
private fun checkState() {
    // If the drawable cache and display ref counts = 0, and this drawable
    // has been displayed, then recycle.
    if (cacheRefCount <= 0
            && displayRefCount <= 0
            && hasBeenDisplayed
            && hasValidBitmap()
    ) {
        getBitmap()?.recycle()
    }
}

@Synchronized
private fun hasValidBitmap(): Boolean =
        getBitmap()?.run {
            !isRecycled
        } ?: false

Java

private int cacheRefCount = 0;
private int displayRefCount = 0;
...
// Notify the drawable that the displayed state has changed.
// Keep a count to determine when the drawable is no longer displayed.
public void setIsDisplayed(boolean isDisplayed) {
    synchronized (this) {
        if (isDisplayed) {
            displayRefCount++;
            hasBeenDisplayed = true;
        } else {
            displayRefCount--;
        }
    }
    // Check to see if recycle() can be called.
    checkState();
}

// Notify the drawable that the cache state has changed.
// Keep a count to determine when the drawable is no longer being cached.
public void setIsCached(boolean isCached) {
    synchronized (this) {
        if (isCached) {
            cacheRefCount++;
        } else {
            cacheRefCount--;
        }
    }
    // Check to see if recycle() can be called.
    checkState();
}

private synchronized void checkState() {
    // If the drawable cache and display ref counts = 0, and this drawable
    // has been displayed, then recycle.
    if (cacheRefCount <= 0 && displayRefCount <= 0 && hasBeenDisplayed
            && hasValidBitmap()) {
        getBitmap().recycle();
    }
}

private synchronized boolean hasValidBitmap() {
    Bitmap bitmap = getBitmap();
    return bitmap != null && !bitmap.isRecycled();
}

Android 3.0 以降でのメモリの管理

Android 3.0(API レベル 11)では、BitmapFactory.Options.inBitmap フィールドが導入されています。このオプションが設定されている場合、Options オブジェクトを受け取るデコード メソッドが、コンテンツの読み込み時に既存のビットマップを再利用しようとします。つまり、ビットマップのメモリが再利用され、結果としてパフォーマンスが改善し、メモリの割り当てと割り当て解除が行われなくなります。ただし、inBitmap の使用方法には一定の制限があります。特に、Android 4.4(API レベル 19)以前では、同じサイズのビットマップのみがサポートされています。詳しくは、inBitmap のドキュメントをご覧ください。

ビットマップを保存して後で使用する

次のスニペットは、既存のビットマップを保存して、後でサンプルアプリで使用できるようにする方法を示しています。アプリが Android 3.0 以降を搭載している場合、ビットマップが LruCache から削除されても、HashSet でビットマップへのソフト参照が行われるため、後で inBitmap を使用して再利用できます。

Kotlin

var reusableBitmaps: MutableSet<SoftReference<Bitmap>>? = null
private lateinit var memoryCache: LruCache<String, BitmapDrawable>
// If you're running on Honeycomb or newer, create a
// synchronized HashSet of references to reusable bitmaps.
if (Utils.hasHoneycomb()) {
    reusableBitmaps = Collections.synchronizedSet(HashSet<SoftReference<Bitmap>>())
}

memoryCache = object : LruCache<String, BitmapDrawable>(cacheParams.memCacheSize) {

    // Notify the removed entry that is no longer being cached.
    override fun entryRemoved(
            evicted: Boolean,
            key: String,
            oldValue: BitmapDrawable,
            newValue: BitmapDrawable
    ) {
        if (oldValue is RecyclingBitmapDrawable) {
            // The removed entry is a recycling drawable, so notify it
            // that it has been removed from the memory cache.
            oldValue.setIsCached(false)
        } else {
            // The removed entry is a standard BitmapDrawable.
            if (Utils.hasHoneycomb()) {
                // We're running on Honeycomb or later, so add the bitmap
                // to a SoftReference set for possible use with inBitmap later.
                reusableBitmaps?.add(SoftReference(oldValue.bitmap))
            }
        }
    }
}

Java

Set<SoftReference<Bitmap>> reusableBitmaps;
private LruCache<String, BitmapDrawable> memoryCache;

// If you're running on Honeycomb or newer, create a
// synchronized HashSet of references to reusable bitmaps.
if (Utils.hasHoneycomb()) {
    reusableBitmaps =
            Collections.synchronizedSet(new HashSet<SoftReference<Bitmap>>());
}

memoryCache = new LruCache<String, BitmapDrawable>(cacheParams.memCacheSize) {

    // Notify the removed entry that is no longer being cached.
    @Override
    protected void entryRemoved(boolean evicted, String key,
            BitmapDrawable oldValue, BitmapDrawable newValue) {
        if (RecyclingBitmapDrawable.class.isInstance(oldValue)) {
            // The removed entry is a recycling drawable, so notify it
            // that it has been removed from the memory cache.
            ((RecyclingBitmapDrawable) oldValue).setIsCached(false);
        } else {
            // The removed entry is a standard BitmapDrawable.
            if (Utils.hasHoneycomb()) {
                // We're running on Honeycomb or later, so add the bitmap
                // to a SoftReference set for possible use with inBitmap later.
                reusableBitmaps.add
                        (new SoftReference<Bitmap>(oldValue.getBitmap()));
            }
        }
    }
....
}

既存のビットマップを使用する

実行中のアプリでは、使用可能な既存のビットマップがあるかどうかをデコーダ メソッドで確認します。次に例を示します。

Kotlin

fun decodeSampledBitmapFromFile(
        filename: String,
        reqWidth: Int,
        reqHeight: Int,
        cache: ImageCache
): Bitmap {

    val options: BitmapFactory.Options = BitmapFactory.Options()
    ...
    BitmapFactory.decodeFile(filename, options)
    ...

    // If we're running on Honeycomb or newer, try to use inBitmap.
    if (Utils.hasHoneycomb()) {
        addInBitmapOptions(options, cache)
    }
    ...
    return BitmapFactory.decodeFile(filename, options)
}

Java

public static Bitmap decodeSampledBitmapFromFile(String filename,
        int reqWidth, int reqHeight, ImageCache cache) {

    final BitmapFactory.Options options = new BitmapFactory.Options();
    ...
    BitmapFactory.decodeFile(filename, options);
    ...

    // If we're running on Honeycomb or newer, try to use inBitmap.
    if (Utils.hasHoneycomb()) {
        addInBitmapOptions(options, cache);
    }
    ...
    return BitmapFactory.decodeFile(filename, options);
}

次のスニペットは、上記のスニペットで呼び出されている addInBitmapOptions() メソッドです。既存のビットマップを探して、inBitmap の値として設定しています。このメソッドは、適切な一致が見つかった場合にのみ inBitmap の値を設定します(コードでは、適切な一致が見つかることを前提としないでください)。

Kotlin

private fun addInBitmapOptions(options: BitmapFactory.Options, cache: ImageCache?) {
    // inBitmap only works with mutable bitmaps, so force the decoder to
    // return mutable bitmaps.
    options.inMutable = true

    // Try to find a bitmap to use for inBitmap.
    cache?.getBitmapFromReusableSet(options)?.also { inBitmap ->
        // If a suitable bitmap has been found, set it as the value of
        // inBitmap.
        options.inBitmap = inBitmap
    }
}

// This method iterates through the reusable bitmaps, looking for one
// to use for inBitmap:
fun getBitmapFromReusableSet(options: BitmapFactory.Options): Bitmap? {
    mReusableBitmaps?.takeIf { it.isNotEmpty() }?.let { reusableBitmaps ->
        synchronized(reusableBitmaps) {
            val iterator: MutableIterator<SoftReference<Bitmap>> = reusableBitmaps.iterator()
            while (iterator.hasNext()) {
                iterator.next().get()?.let { item ->
                    if (item.isMutable) {
                        // Check to see it the item can be used for inBitmap.
                        if (canUseForInBitmap(item, options)) {
                            // Remove from reusable set so it can't be used again.
                            iterator.remove()
                            return item
                        }
                    } else {
                        // Remove from the set if the reference has been cleared.
                        iterator.remove()
                    }
                }
            }
        }
    }
    return null
}

Java

private static void addInBitmapOptions(BitmapFactory.Options options,
        ImageCache cache) {
    // inBitmap only works with mutable bitmaps, so force the decoder to
    // return mutable bitmaps.
    options.inMutable = true;

    if (cache != null) {
        // Try to find a bitmap to use for inBitmap.
        Bitmap inBitmap = cache.getBitmapFromReusableSet(options);

        if (inBitmap != null) {
            // If a suitable bitmap has been found, set it as the value of
            // inBitmap.
            options.inBitmap = inBitmap;
        }
    }
}

// This method iterates through the reusable bitmaps, looking for one
// to use for inBitmap:
protected Bitmap getBitmapFromReusableSet(BitmapFactory.Options options) {
        Bitmap bitmap = null;

    if (reusableBitmaps != null && !reusableBitmaps.isEmpty()) {
        synchronized (reusableBitmaps) {
            final Iterator<SoftReference<Bitmap>> iterator
                    = reusableBitmaps.iterator();
            Bitmap item;

            while (iterator.hasNext()) {
                item = iterator.next().get();

                if (null != item && item.isMutable()) {
                    // Check to see it the item can be used for inBitmap.
                    if (canUseForInBitmap(item, options)) {
                        bitmap = item;

                        // Remove from reusable set so it can't be used again.
                        iterator.remove();
                        break;
                    }
                } else {
                    // Remove from the set if the reference has been cleared.
                    iterator.remove();
                }
            }
        }
    }
    return bitmap;
}

最後に、候補のビットマップが inBitmap で使用するためのサイズの条件を満たしているかどうかを次のメソッドで確認します。

Kotlin

private fun canUseForInBitmap(candidate: Bitmap, targetOptions: BitmapFactory.Options): Boolean {
    return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
        // From Android 4.4 (KitKat) onward we can re-use if the byte size of
        // the new bitmap is smaller than the reusable bitmap candidate
        // allocation byte count.
        val width: Int = targetOptions.outWidth / targetOptions.inSampleSize
        val height: Int = targetOptions.outHeight / targetOptions.inSampleSize
        val byteCount: Int = width * height * getBytesPerPixel(candidate.config)
        byteCount <= candidate.allocationByteCount
    } else {
        // On earlier versions, the dimensions must match exactly and the inSampleSize must be 1
        candidate.width == targetOptions.outWidth
                && candidate.height == targetOptions.outHeight
                && targetOptions.inSampleSize == 1
    }
}

/**
 * A helper function to return the byte usage per pixel of a bitmap based on its configuration.
 */
private fun getBytesPerPixel(config: Bitmap.Config): Int {
    return when (config) {
        Bitmap.Config.ARGB_8888 -> 4
        Bitmap.Config.RGB_565, Bitmap.Config.ARGB_4444 -> 2
        Bitmap.Config.ALPHA_8 -> 1
        else -> 1
    }
}

Java

static boolean canUseForInBitmap(
        Bitmap candidate, BitmapFactory.Options targetOptions) {

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
        // From Android 4.4 (KitKat) onward we can re-use if the byte size of
        // the new bitmap is smaller than the reusable bitmap candidate
        // allocation byte count.
        int width = targetOptions.outWidth / targetOptions.inSampleSize;
        int height = targetOptions.outHeight / targetOptions.inSampleSize;
        int byteCount = width * height * getBytesPerPixel(candidate.getConfig());
        return byteCount <= candidate.getAllocationByteCount();
    }

    // On earlier versions, the dimensions must match exactly and the inSampleSize must be 1
    return candidate.getWidth() == targetOptions.outWidth
            && candidate.getHeight() == targetOptions.outHeight
            && targetOptions.inSampleSize == 1;
}

/**
 * A helper function to return the byte usage per pixel of a bitmap based on its configuration.
 */
static int getBytesPerPixel(Config config) {
    if (config == Config.ARGB_8888) {
        return 4;
    } else if (config == Config.RGB_565) {
        return 2;
    } else if (config == Config.ARGB_4444) {
        return 2;
    } else if (config == Config.ALPHA_8) {
        return 1;
    }
    return 1;
}