본문 바로가기
안드로이드/Android

[Android] Memory Cache와 Disk Cache

by jinwo_o 2024. 11. 8.

Memory vs Disk

  • Memory 는 컴퓨터 내부에서 현재 CPU 가 처리하고 있는 내용을 저장하고 있는 휘발성 장치로, 처리 속도가 빠르다.
  • Disk 는 Memory 보다는 느리지만, 많은 양의 데이터를 전원이 꺼져도 사라지지 않고 영구적으로 보관할 수 있는 장치이다.
 

[CS] CPU, 주기억장치(ROM, RAM(SRAM, DRAM), 보조기억장치(HDD, SDD)

중앙 처리 장치 (Central Processing Unit, CPU)컴퓨터의 중앙에서 모든 데이터의 처리를 담당하는 장치, 컴퓨터의 두뇌컴퓨터의 속도는 CPU 의 성능이 가장 큰 영향을 미친다. 컴퓨터는 사용자의 명령을

dev-baik.tistory.com


Memory Cache

  • 애플리케이션 내에 존재하는 메모리에 비트맵을 캐싱하고, 필요할 때 빠르게 접근가능하다.
  • 하지만 Memory Cache 도 곧 애플리케이션 용량을 키우는 주범이 될 수 있기 때문에 많은 캐싱을 요구하는 비트맵의 경우에는 Disk Cache 에 넣는 것이 더 좋을 수 있다.

 

1. BitmapPool

  • 사용하지 않는 Bitmap 을 리스트에 넣어 놓고, 추후에 동일한 이미지를 로드할 때 다시 메모리에 적재하지 않고 pool 에 잇는 이미지를 가져와 재사용하는 것이다.
  • 보통 BitmapPool 을 이용해 재사용 Pool 을 만들게 될 때, LRU 캐싱 알고리즘으로 구현된 LinkedList(lruBitmapList)와 Byte Size 순으로 정렬된 LinkedList(bitmapList)를 사용하여 구현하게 된다.
    • 이 둘은 들어있는 비트맵의 순서만 다를 뿐, 같은 비트맵이 담기게 된다.
private val lruBitmapList = LinkedList<Bitmap>()
private val bitmapList = LinkedList<Bitmap>()
  • LRU 알고리즘을 이용해 오랫동안 참조되지 않은 비트맵 객체는 맨 뒤로 밀리게 되고, 맨 뒤에 있는 객체를 회수하면서 BitmapPool 을 유지시키는 것이다.
    • LRU 알고리즘을 이용하지 않는다면 처음 BitmapPool 이 가득 찰 때까지는 문제없이 동작하지만, 비트맵을 재사용하는 시점부터는 특정 비트맵만 재사용될 수 있으며, 앱이 끝날 때까지 메모리가 줄어들지 않게 된다.
  • Glide 에서 구현한 LruBitmapPool Class 내부
public class LruBitmapPool implements BitmapPool {
    private static final String TAG = "LruBitmapPool";
    private static final Bitmap.Config DEFAULT_CONFIG = Bitmap.Config.ARGB_8888;

    private final LruPoolStrategy strategy;
    private final Set<Bitmap.Config> allowedConfigs;
    private final long initialMaxSize;
    private final BitmapTracker tracker;

    // Pool에 들어올수 있는 최대 크기
    private long maxSize;
    // 현재 pool에 들어간 bitmap byte 크기
    private long currentSize;
  • Glide 는 디코딩 단계에서 *RGB_565를 사용하여 메모리 효율성을 높이고, LruBitmapPool 에서는 *ARGB_8888을 기본값으로 사용해 재사용성과 호환성을 보장한다.
    • RGB 565 : 2 byte(16 bit)로 1 px 를 표현하는 방식
      • 16 bit 를 R(5 bit) G(6 BIt) B (5 bit)로 표현하게 된다. R 과 B 의 경우 2^5인 32가지의 색상을 나타낼 수 있어 색상 표현 수준은 낮으나, 그만큼 이미지의 용량을 아낄 수 있다.
    • ARGB 8888 : 4 btye(32 bit)로 1 px 를 표현하는 방식
      • 32 bit 를 각 8 bit 로 동등하게 표현이 가능하며, 투명도를 나타내는 알파 값이 추가되어 보다 다채로운 색상의 표현이 가능하다.
  • Glide 내 LruBitmapPool Class 에서는 strategy(LruPoolStrategy)가 곧 LRU 기반으로 구현된 리스트이며, tracker(BitmapTracker)가 Bitmap size 순으로 정렬된 리스트이다.
@Override
public synchronized void put(Bitmap bitmap) {
    if (bitmap == null) {
        throw new NullPointerException("Bitmap must not be null");
    }
    
    // isRecycled : *재활용된 bitmap 인지 여부
    // 재활용 : 해당 비트맵이 더 이상 필요하지 않거나 사용되지 않도록 메모리에서 해제되었다는 것
    if (bitmap.isRecycled()) {
        throw new IllegalStateException("Cannot pool recycled bitmap");
    }
    
    // isMutable : canvas를 얻을 수 있는 bitmap 인지 여부
    if (!bitmap.isMutable()
        || strategy.getSize(bitmap) > maxSize
        || !allowedConfigs.contains(bitmap.getConfig())) {
        if (Log.isLoggable(TAG, Log.VERBOSE)) {
            Log.v(
                TAG,
                "Reject bitmap from pool"
                        + ", bitmap: "
                        + strategy.logBitmap(bitmap)
                        + ", is mutable: "
                        + bitmap.isMutable()
                        + ", is allowed config: "
                        + allowedConfigs.contains(bitmap.getConfig()));
        }
        bitmap.recycle();
        return;
    }

    // pool에 넣으려는 bitmap의 사이즈를 얻어온다.
    final int size = strategy.getSize(bitmap);
    strategy.put(bitmap);
    tracker.add(bitmap);

    puts++;
    currentSize += size;

    if (Log.isLoggable(TAG, Log.VERBOSE)) {
        Log.v(TAG, "Put bitmap in pool=" + strategy.logBitmap(bitmap));
    }
    dump(); // 현재 비트맵 풀의 상태를 출력하는 메서드

    evict(); 
}

private void evict() {
    trimToSize(maxSize);
}
  • Bitmap 을 Pool 에 넣을 수 있는 조건을 충족시킨다면, strategy & tracker 에 bitmap 이 들어가게 된다.
  • Lru 기반인 strategy 는 최근에 들어온 bitmap 일수록 리스트의 맨 앞으로 배치시켜야 하는데, 그 로직이 LruPoolStrategy 구현체 내부에 존재하게 된다.
private synchronized void trimToSize(long size) {
    while (currentSize > size) {
    	// LRU List에서 삭제한다.
        final Bitmap removed = strategy.removeLast();
        if (removed == null) {
            if (Log.isLoggable(TAG, Log.WARN)) {
                Log.w(TAG, "Size mismatch, resetting");
                dumpUnchecked();
            }
            currentSize = 0;
            return;
        }
        // BitmapList에서 삭제한다. 
        tracker.remove(removed);
        currentSize -= strategy.getSize(removed);
        evictions++;
        if (Log.isLoggable(TAG, Log.DEBUG)) {
            Log.d(TAG, "Evicting bitmap=" + strategy.logBitmap(removed));
        }
        dump();
        removed.recycle();
    }
}
  • trimToSize()를 이용하면 pool 이 최대 사용량을 넘어선 경우, 참조를 가장 적게하고 있는 lastIndex 부터 객체를 지워가며 크기를 줄여줄 수 있다.
@Override
@Nullable
public Bitmap get(int width, int height, Bitmap.Config config) {
    int size = Util.getBitmapByteSize(width, height, config);
    Key bestKey = findBestKey(size, config);

    // 비트맵을 찾은 경우, pool에서 제거한다.
    Bitmap result = groupedMap.get(bestKey);

    if (result != null) {
        decrementBitmapOfSize(bestKey.size, result);
        result.reconfigure(width, height, config);
    }
    return result;
}
  • 이미지를 로드하기 위해 pool 에서 주어진 크기와 설정에 맞는 비트맵을 가져온다.

 

  • 비트맵 재사용 BitmapPool 만들기
import android.graphics.Bitmap
import android.os.Build
import java.util.LinkedList

// maxByteSize : 캐시 풀에 저장할 수 있는 최대 바이트 크기
class LruBitmapPool(private val maxByteSize: Int) {

    private val TAG = "LruBitmapPool"

    // 현재 Pool 내에 있는 Bitmap들의 총 메모리 사용량
    private var useByteSize = 0
    
    // 가장 최근에 사용된 비트맵을 맨 앞에 두고, 오래된 비트맵을 맨 뒤에 두는 LRU 리스트
    private val lruList = LinkedList<Bitmap>()
    // 비트맵의 크기별로 정렬된 리스트 (오름차순)
    private val sizeList = LinkedList<Bitmap>()

    init {
        require(maxByteSize > 0) { "Invalid maxByteSize" }
    }

    // Bitmap을 Pool에 추가하는 메서드
    @Synchronized
    fun add(bmp: Bitmap) {
    	// 비트맵이 null인지, 이미 해제된(recycled) 상태인지, 또는 변경 불가능한 상태인지 확인
        requireNotNull(bmp) { "Bitmap is null, null can't be added to pool" }
        require(!bmp.isRecycled) { "Bitmap is already recycled" }
        require(bmp.isMutable) { "Bitmap is not mutable" }

        // sizeList에 Bitmap을 크기별로 적절한 위치에 삽입
        var idx = 0
        for (b in sizeList) {
            if (getByteSize(b) >= getByteSize(bmp)) {
                break
            }
            idx++
        }

        sizeList.add(idx, bmp)

        // lruList에는 최근 Bitmap을 맨 앞에 추가
        lruList.addFirst(bmp)
        useByteSize += getByteSize(bmp)

        // 메모리 사용량이 최대 용량(maxByteSize)을 초과할 경우 
        // trimToSize 메서드를 호출하여 오래된 비트맵을 제거
        if (useByteSize > maxByteSize) {
            trimToSize(maxByteSize)
        }
    }

    // 지정된 크기(w, h)와 구성(config)의 비트맵을 반환하는 메서드
    @Synchronized
    fun getBitmap(w: Int, h: Int, config: Bitmap.Config): Bitmap {
        var bmp: Bitmap? = null

        val it = sizeList.iterator()
        while (it.hasNext()) {
            val b = it.next()

            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
            	// allocationByteCount를 이용해 메모리 할당 크기를 확인
                if (b.allocationByteCount >= w * h * getBytePerPixel(config)) {
                    b.reconfigure(w, h, config)
                    bmp = b
                    break
                }
            } else {
                if (getByteSize(b) > w * h * getBytePerPixel(config)) {
                    break
                }

                // KitKat 미만에서는 폭, 높이, 픽셀 구성이 일치하는 경우에만 재사용이 가능
                if (b.width == w && b.height == h && b.config == config) {
                    bmp = b
                    break
                }
            }
        }

        if (bmp != null) {
            // 적절한 Bitmap을 찾은 경우, Pool에서 제거하고 사용량을 업데이트
            sizeList.remove(bmp)
            lruList.remove(bmp)
            useByteSize -= getByteSize(bmp)
        } else {
            // 찾지 못한 경우 새 Bitmap을 생성해 반환
            bmp = Bitmap.createBitmap(w, h, config)
        }

        return bmp
    }

    // 현재 Pool의 크기가 지정된 크기를 초과할 경우 오래된 비트맵을 제거하는 메서드
    @Synchronized
    fun trimToSize(trimToSize: Int) {
        while (lruList.isNotEmpty() && useByteSize > trimToSize) {
            val bmp = lruList.removeLast()
            sizeList.remove(bmp)
            useByteSize -= getByteSize(bmp)
            // 제거된 Bitmap은 recycle을 호출해 리소스를 해제
            bmp.recycle()
        }
    }

    // 모든 비트맵을 Pool에서 제거하는 메서드
    fun evictAll() {
        trimToSize(-1)
    }

    companion object {
    	// 비트맵의 구성에 따라 픽셀당 바이트 수를 반환하는 정적 메서드
        fun getBytePerPixel(config: Bitmap.Config): Int {
            return when (config) {
                Bitmap.Config.RGBA_F16 -> 8
                Bitmap.Config.ARGB_8888 -> 4
                Bitmap.Config.RGB_565, Bitmap.Config.ARGB_4444 -> 2
                Bitmap.Config.ALPHA_8 -> 1
                else -> -1
            }
        }

        // 주어진 비트맵의 메모리 크기를 반환하는 정적 메서드
        fun getByteSize(bmp: Bitmap): Int {
            return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
                bmp.allocationByteCount
            } else {
                bmp.byteCount
            }
        }
    }
}
  • 사용 예제
class MyActivity : AppCompatActivity() {

    private lateinit var bitmapPool: LruBitmapPool
    private lateinit var imageView: ImageView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_my)

        bitmapPool = LruBitmapPool(10 * 1024 * 1024)
        imageView = findViewById(R.id.myImageView)

        loadBitmap()
    }

    private fun loadBitmap() {
        val width = 100
        val height = 100
        val config = Bitmap.Config.ARGB_8888

        val opts = BitmapFactory.Options().apply {
            inMutable = true // 비트맵을 변경 가능하도록 설정
            inBitmap = bitmapPool.getBitmap(width, height, config) // 비트맵 풀에서 재사용할 비트맵 할당
        }

        val bitmap = BitmapFactory.decodeResource(resources, R.drawable.example_image, opts)
        val oldBitmap: Bitmap? = imageView.getTag(R.string.bitmap_tag_key) as? Bitmap

        imageView.setImageBitmap(bitmap)
        imageView.setTag(key, bitmap)

        if (oldBitmap != null) {
            bitmapPool.add(oldBitmap)
        }
    }

    private fun releaseBitmap() {
        val drawable = imageView.drawable as? BitmapDrawable
        val bitmap: Bitmap? = drawable?.bitmap

        if (bitmap != null) {
            val taggedBitmap: Bitmap? = imageView.getTag(R.string.bitmap_tag_key) as? Bitmap

            if (bitmap === taggedBitmap) {
                imageView.setImageBitmap(null)
                bitmapPool.add(bitmap)
            }
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        bitmapPool.evictAll()
    }
}

 

2. LruCache 클래스

  • LruCache 는 안드로이드에서 캐시를 관리하기 위해 사용하는 메모리 캐시 객체이다.
  • LruCache 는 LRU(Least Recent Used) 알고리즘을 사용하는데 간단히 말해서 최근에 조회된 것을 캐시에서 삭제하는 것을 늦추기 위한 객체이다.
    • 즉, 오랫동안 접근되지 않은 메모리가 우선적으로 삭제된다.
  • LruCache 는 제네릭으로 선언되어 있는데 K 가 캐시에 접근하기 위한 키값이고 V 는 캐시에서 가져올 객체의 타입이다. 또한 LruCache 객체는 생성자의 인자로 maxSize 를 int 값으로 받는다.
public class LruCache<K, V> {
    public LruCache(int maxSize) {
        ...
    }
    ...
}
  • 사용 예제
class MainActivity : AppCompatActivity() {

    private lateinit var memoryCache: LruCache<String, Bitmap>

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()
        val cacheSize = maxMemory / 8

        memoryCache = object : LruCache<String, Bitmap>(cacheSize) {
            override fun sizeOf(key: String, bitmap: Bitmap): Int {
                return bitmap.byteCount / 1024
            }
        }

        val imageView = findViewById<ImageView>(R.id.imageView)
        loadBitmap(R.drawable.example_image, imageView)
    }

    private fun loadBitmap(resId: Int, imageView: ImageView) {
        val imageKey: String = resId.toString()

        val bitmap: Bitmap? = memoryCache.get(imageKey)?.also {
            imageView.setImageBitmap(it)
        } ?: run {
            imageView.setImageResource(R.drawable.image_placeholder)
            val task = BitmapWorkerTask(imageView)
            task.execute(resId)
            null
        }
    }

    // TODO AsyncTask(Deprecated) -> Coroutine 
    private inner class BitmapWorkerTask(private val imageView: ImageView) : AsyncTask<Int, Unit, Bitmap>() {
        override fun doInBackground(vararg params: Int?): Bitmap? {
            if (params.isNotEmpty() && params[0] != null) {
                val imageId = params[0]!!
                return decodeSampledBitmapFromResource(resources, imageId, 100, 100)?.also { bitmap ->
                    memoryCache.put(imageId.toString(), bitmap)
                }
            }
            return null
        }

        override fun onPostExecute(result: Bitmap?) {
            if (result != null) {
                imageView.setImageBitmap(result)
            }
        }
    }

    private fun decodeSampledBitmapFromResource(res: Resources, resId: Int, reqWidth: Int, reqHeight: Int): Bitmap? {
        val options = BitmapFactory.Options().apply {
            inJustDecodeBounds = true
            BitmapFactory.decodeResource(res, resId, this)
            inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight)
            inJustDecodeBounds = false
        }
        return BitmapFactory.decodeResource(res, resId, options)
    }

    private fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
        val height = options.outHeight
        val width = options.outWidth
        var inSampleSize = 1

        if (height > reqHeight || width > reqWidth) {
            val halfHeight = height / 2
            val halfWidth = width / 2

            while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
                inSampleSize *= 2
            }
        }
        return inSampleSize
    }
}

 

Disk Cache

  • Memory Cache 에 넣기엔 많은 캐시를 요구하는 경우, 혹은 앱이 백그라운드로 전환되어도 적재한 캐시가 삭제되지 않기를 바란다면 Disk Cache 를 이용하는 것이 좋다. 
  • 하지만 Disk 로부터 캐싱된 비트맵을 가져올 때는 Memory 에서 로드하는 것보다 오랜 시간이 걸린다.

 

DiskLruCache

 

비트맵 캐싱  |  App quality  |  Android Developers

단일 비트맵을 사용자 인터페이스(UI)에 로드하는 것은 간단하지만 한 번에 더 큰 이미지의 집합을 로드해야 하면 더 복잡해집니다. 많은 경우(ListView, GridView 또는 LruCache 클래스와 같은 구성요소

developer.android.com


 

[Android] Hello👋, Out Of Memory

많은 이미지를 사용하거나 고해상도 이미지를 이미지뷰에 로드해야하는 경우 발생하게 되는데, 이는 안드로이드 앱에서 사용할 수 있는 힙 메모리는 정해져있는데 반해 그 크기를 넘겨버렸기

velog.io

 

안드로이드 비트맵 재사용 BitmapPool만들기

이전편에서 언급한 Bitmap를 재사용하려면 사용하지 않는 비트맵을 담아 둘 Pool이 필요하게 된다. 이번편에서는 이 풀(pool)을 만들어 보기로 하겠다. 원리는 간단하다. 사용하지 않는 Bitmap을 리스

jamssoft.tistory.com

 

비트맵 캐싱  |  App quality  |  Android Developers

단일 비트맵을 사용자 인터페이스(UI)에 로드하는 것은 간단하지만 한 번에 더 큰 이미지의 집합을 로드해야 하면 더 복잡해집니다. 많은 경우(ListView, GridView 또는 LruCache 클래스와 같은 구성요소

developer.android.com

 

[Android] LruCache 사용해 이미지 캐싱하기(Bitmap Cache)

LruCache 란? LruCache객체는 안드로이드에서 캐시를 관리하기 위해 사용하는 메모리 캐시 객체이다. LruCache 객체는 LRU(Least Recent Used) 알고리즘을 사용하는데 간단히 말해서 최근에 조회된 것을 캐시

kotlinworld.com